feat: AI 自然语言选股(GLM)+ 全市场数据管道 + 远程 PostgreSQL

- 首页双入口(智能选股/策略回测):引入 vue-router,顶部导航
- 智能选股:自然语言 -> LLM 解析结构化条件(智谱 GLM,OpenAI 兼容,/v4 兼容)-> SQL 快照预筛 + pandas 指标过滤(复用 indicators 单一事实源)
- 条件模型:指标 vs 常数/指标(value_indicator,如 DIF>DEA、close<布林下轨)、lookback+match 表达连续N天/近N天任一天、市值/PE/PB/换手率快照条件、默认排除 ST/退市/北交所
- 全市场数据同步:按 trade_date 批量拉取未复权日线(与回测 candles qfq 隔离),交易日历/股票列表本地缓存,daily_basic 仅最新截面,Tushare 限频兜底(分钟级重试/小时级降级)
- 存储:DATABASE_URL 切远程 PostgreSQL(cirry.cn/stock),本地 SQLite 已移除
- .env 入库(私有仓库);smoke_test 扩展选股链路

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 14:49:53 +08:00
parent e0b5228008
commit 528357c3f5
29 changed files with 1765 additions and 12 deletions

View File

@@ -16,7 +16,8 @@
"pinia": "^2.3.0",
"primeicons": "^7.0.0",
"primevue": "^5.0.0",
"vue": "^3.5.0"
"vue": "^3.5.0",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/node": "^22.0.0",

View File

@@ -29,6 +29,9 @@ importers:
vue:
specifier: ^3.5.0
version: 3.5.41(typescript@5.6.2)
vue-router:
specifier: ^4.6.4
version: 4.6.4(vue@3.5.41(typescript@5.6.2))
devDependencies:
'@types/node':
specifier: ^22.0.0
@@ -457,6 +460,9 @@ packages:
'@vue/devtools-api@6.6.3':
resolution: {integrity: sha512-0MiMsFma/HqA6g3KLKn+AGpL1kgKhFWszC9U29NfpWK5LE7bjeXxySWJrOJ77hBz+TBrBQ7o4QJqbPbqbs8rJw==}
'@vue/devtools-api@6.6.4':
resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
'@vue/language-core@2.1.0':
resolution: {integrity: sha512-S7uUdQXn4aA7QloQeAIhKDkP243nCEktQu1Kxur1fJFfedf+izBTb9bAR2tHe0V7xhFYhIwkNCtQl2AlKFaW3w==}
peerDependencies:
@@ -662,6 +668,11 @@ packages:
'@vue/composition-api':
optional: true
vue-router@4.6.4:
resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
peerDependencies:
vue: ^3.5.0
vue-tsc@2.1.0:
resolution: {integrity: sha512-N6kelLgHPLiDzkeWfsfrfR79POitZ59M/2HaRWk4BW0ED40FlyQaVhGUvjg8VRPPJWic1M974cRTGIBR6zfgwQ==}
hasBin: true
@@ -967,6 +978,8 @@ snapshots:
'@vue/devtools-api@6.6.3': {}
'@vue/devtools-api@6.6.4': {}
'@vue/language-core@2.1.0(typescript@5.6.2)':
dependencies:
'@volar/language-core': 2.4.28
@@ -1182,6 +1195,11 @@ snapshots:
dependencies:
vue: 3.5.41(typescript@5.6.2)
vue-router@4.6.4(vue@3.5.41(typescript@5.6.2)):
dependencies:
'@vue/devtools-api': 6.6.4
vue: 3.5.41(typescript@5.6.2)
vue-tsc@2.1.0(typescript@5.6.2):
dependencies:
'@volar/typescript': 2.4.28

View File

@@ -1,13 +1,18 @@
<script setup lang="ts">
import BacktestView from '@/views/BacktestView.vue';
import { RouterLink, RouterView } from 'vue-router';
</script>
<template>
<div class="app-shell">
<header class="app-header">
<h1>股市回测平台</h1>
<span class="sub">历史回测 · 回放式模拟 · A股红涨绿跌</span>
<h1>股市量化平台</h1>
<span class="sub">智能选股 · 历史回测 · A股红涨绿跌</span>
<nav class="app-nav">
<RouterLink to="/" class="nav-link">首页</RouterLink>
<RouterLink to="/screener" class="nav-link">智能选股</RouterLink>
<RouterLink to="/backtest" class="nav-link">策略回测</RouterLink>
</nav>
</header>
<BacktestView />
<RouterView />
</div>
</template>

View File

@@ -1,4 +1,13 @@
import type { BacktestRequest, BacktestResponse, SyncRequest, SyncResponse } from './types';
import type {
BacktestRequest,
BacktestResponse,
ScreenerRunRequest,
ScreenerRunResponse,
ScreenerSyncRequest,
ScreenerSyncStatus,
SyncRequest,
SyncResponse,
} from './types';
// dev 用 Vite 代理(/api -> :8000生产构建设 VITE_API_BASE 指向后端地址。
const BASE = import.meta.env.VITE_API_BASE ?? '';
@@ -27,3 +36,47 @@ export async function syncData(req: SyncRequest): Promise<SyncResponse> {
return (await res.json()) as SyncResponse;
}
// ---------- 智能选股 ----------
export async function runScreener(req: ScreenerRunRequest): Promise<ScreenerRunResponse> {
const res = await fetch(`${BASE}/api/screener/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
if (!res.ok) {
// 后端 detail 字段携带可读中文原因503 未配 key / 409 未同步 / 502 LLM 错)
let detail = '';
try {
detail = (await res.json())?.detail ?? '';
} catch {
detail = await res.text();
}
throw new Error(detail || `选股失败 (HTTP ${res.status})`);
}
return (await res.json()) as ScreenerRunResponse;
}
export async function startScreenerSync(req: ScreenerSyncRequest = {}): Promise<ScreenerSyncStatus> {
const res = await fetch(`${BASE}/api/screener/sync`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
if (!res.ok) {
let detail = '';
try {
detail = (await res.json())?.detail ?? '';
} catch {
detail = await res.text();
}
throw new Error(detail || `启动同步失败 (HTTP ${res.status})`);
}
return (await res.json()) as ScreenerSyncStatus;
}
export async function getScreenerSyncStatus(): Promise<ScreenerSyncStatus> {
const res = await fetch(`${BASE}/api/screener/sync/status`);
if (!res.ok) throw new Error(`获取同步状态失败 (HTTP ${res.status})`);
return (await res.json()) as ScreenerSyncStatus;
}

View File

@@ -74,3 +74,76 @@ export interface SyncResponse {
bars: number;
source: string;
}
// ---------- 智能选股(镜像 app/schemas.py ----------
export type Op = 'gt' | 'ge' | 'lt' | 'le' | 'between';
export interface IndicatorCondition {
indicator: string;
params?: Record<string, number>;
op: Op;
value: number;
value2?: number | null;
value_indicator?: string | null;
value_params?: Record<string, number>;
lookback?: number;
match?: 'all' | 'any';
}
export interface SnapshotCondition {
field: string; // total_mv | circ_mv | pe_ttm | pb | turnover_rate | close
op: Op;
value: number;
value2?: number | null;
}
export interface ScreenConditions {
indicator: IndicatorCondition[];
snapshot: SnapshotCondition[];
exclude_st: boolean;
exclude_delisted: boolean;
exclude_bj: boolean;
}
export interface ScreenerRunRequest {
text: string;
conditions?: ScreenConditions | null; // 直传则跳过 LLM 解析
}
export interface ScreenerItemOut {
ts_code: string;
name: string;
close: number | null;
pct_chg: number | null;
total_mv: number | null; // 亿元
circ_mv: number | null;
pe_ttm: number | null;
pb: number | null;
turnover_rate: number | null;
indicators: Record<string, number | null>;
}
export interface ScreenerRunResponse {
conditions: ScreenConditions;
trade_date: string | null;
total: number;
items: ScreenerItemOut[];
indicator_labels: Record<string, string>;
}
export interface ScreenerSyncRequest {
days?: number;
force?: boolean;
}
export interface ScreenerSyncStatus {
running: boolean;
step?: string | null;
total_days: number;
done_days: number;
error?: string | null;
ready: boolean;
last_trade_date?: string | null;
last_synced_at?: string | null;
stats: { stocks: number; daily_rows: number; snapshot_rows: number; dates: number };
}

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import type { ScreenConditions } from '@/api/types';
defineProps<{ conditions: ScreenConditions }>();
const OP_TEXT: Record<string, string> = { gt: '>', ge: '≥', lt: '<', le: '≤', between: '区间' };
const FIELD_TEXT: Record<string, string> = {
total_mv: '总市值', circ_mv: '流通市值', pe_ttm: '市盈率TTM',
pb: '市净率', turnover_rate: '换手率', close: '最新价',
};
function paramsStr(p?: Record<string, number>) {
if (!p || Object.keys(p).length === 0) return '';
const vals = Object.values(p).map((v) => (Number.isInteger(v) ? String(v) : String(v)));
return `(${vals.join(',')})`;
}
function lookbackText(c: { lookback?: number; match?: string }) {
const n = c.lookback ?? 1;
if (n <= 1) return '今日';
return c.match === 'any' ? `${n}日任一天` : `${n}日连续`;
}
</script>
<template>
<div class="cond-chips">
<span class="clabel">解析条件</span>
<span v-for="(c, i) in conditions.indicator" :key="'i' + i" class="cond-chip ci">
<i class="pi pi-bolt" style="font-size: 10px;"></i>
{{ c.indicator }}{{ paramsStr(c.params) }}
<span class="arrow">{{ OP_TEXT[c.op] }}</span>
<template v-if="c.value_indicator">{{ c.value_indicator }}{{ paramsStr(c.value_params) }}</template>
<template v-else-if="c.op === 'between' && c.value2">{{ c.value }} ~ {{ c.value2 }}</template>
<template v-else>{{ c.value }}</template>
<span style="color: var(--ink-3);"> · {{ lookbackText(c) }}</span>
</span>
<span v-for="(c, i) in conditions.snapshot" :key="'s' + i" class="cond-chip cs">
<i class="pi pi-table" style="font-size: 10px;"></i>
{{ FIELD_TEXT[c.field] ?? c.field }}
<span class="arrow">{{ OP_TEXT[c.op] }}</span>
<template v-if="c.op === 'between' && c.value2">{{ c.value }} ~ {{ c.value2 }}</template>
<template v-else>{{ c.value }}</template>
</span>
<span class="cond-chip cx">
排除ST · 退市<span v-if="conditions.exclude_bj"> · 北交所</span>
</span>
</div>
</template>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import { ref } from 'vue';
import Button from 'primevue/button';
import Textarea from 'primevue/textarea';
defineProps<{ loading: boolean }>();
const emit = defineEmits<{ (e: 'run', text: string): void }>();
const text = ref('');
// 示例(点击填入文本框)
const examples = [
'帮我找出这两天 KDJ 中的 J 小于 10市值大于 100 亿,小于 200 亿的公司',
'RSI 低于 30市盈率 TTM 小于 20 的公司',
'近 5 天曾经 MACD 金叉DIF 上穿 DEA换手率大于 5%,流通市值小于 100 亿',
'股价在布林带下轨之下,流通市值小于 50 亿',
];
function run() {
if (text.value.trim()) emit('run', text.value.trim());
}
</script>
<template>
<div class="toolbar" style="flex-direction: column; align-items: stretch; gap: 10px;">
<div class="field" style="gap: 7px;">
<label>用一句话描述你的选股条件</label>
<Textarea
v-model="text"
class="screener-input"
:auto-resize="true"
rows="2"
size="small"
placeholder="例如:这两天 KDJ 的 J 小于 10市值 100~200 亿的公司"
@keyup.ctrl.enter="run"
/>
</div>
<div class="quick">
<span class="qlabel">示例</span>
<span v-for="(ex, i) in examples" :key="i" class="qchip" @click="text = ex">{{ ex }}</span>
</div>
<div class="hint">
支持 KDJ / RSI / MACD / 布林 / 均线指标条件市值 / 市盈率 / 换手率等快照条件以及连续 N N 天任一天等时间窗口
<code>Ctrl+Enter</code> 快速筛选
</div>
<div>
<Button label="开始筛选" icon="pi pi-search" size="small" :loading="loading" :disabled="!text.trim()" @click="run" />
</div>
</div>
</template>

View File

@@ -0,0 +1,75 @@
<script setup lang="ts">
import { computed } from 'vue';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import type { ScreenerItemOut, ScreenerRunResponse } from '@/api/types';
const props = defineProps<{ result: ScreenerRunResponse }>();
const items = computed(() => props.result.items);
// 动态指标列:[{ key: 'kdj_j', label: 'KDJ J(9,3,3)' }]
const indCols = computed(() =>
Object.entries(props.result.indicator_labels).map(([key, label]) => ({ key, label })),
);
function pctClass(v: number | null) {
if (v == null) return '';
return v > 0 ? 'pos' : v < 0 ? 'neg' : '';
}
function fmt(v: number | null, digits = 2) {
return v == null ? '—' : v.toFixed(digits);
}
</script>
<template>
<div class="panel">
<div class="panel-title">
命中 {{ result.total }} {{ result.total > items.length ? `(仅显示前 ${items.length}` : '' }}
<span v-if="result.trade_date" style="color: var(--ink-3); margin-left: 8px;">· 数据基准 {{ result.trade_date.slice(0, 10) }}</span>
</div>
<DataTable :value="items" size="small" striped-rows :scrollable="true" scroll-height="560px" sort-mode="single">
<Column field="ts_code" header="代码" sortable style="min-width: 92px;">
<template #body="{ data }">
<span style="font-variant-numeric: tabular-nums; color: var(--ink);">{{ data.ts_code }}</span>
</template>
</Column>
<Column field="name" header="名称" sortable style="min-width: 96px;" />
<Column field="close" header="最新价" sortable style="min-width: 84px;">
<template #body="{ data }">{{ fmt(data.close) }}</template>
</Column>
<Column field="pct_chg" header="涨跌幅%" sortable style="min-width: 88px;">
<template #body="{ data }">
<span :class="pctClass(data.pct_chg)">{{ data.pct_chg == null ? '—' : (data.pct_chg > 0 ? '+' : '') + data.pct_chg.toFixed(2) }}</span>
</template>
</Column>
<Column field="total_mv" header="总市值(亿)" sortable style="min-width: 100px;">
<template #body="{ data }">{{ fmt(data.total_mv) }}</template>
</Column>
<Column field="circ_mv" header="流通市值(亿)" sortable style="min-width: 106px;">
<template #body="{ data }">{{ fmt(data.circ_mv) }}</template>
</Column>
<Column field="pe_ttm" header="PE-TTM" sortable style="min-width: 84px;">
<template #body="{ data }">{{ fmt(data.pe_ttm) }}</template>
</Column>
<Column field="pb" header="PB" sortable style="min-width: 70px;">
<template #body="{ data }">{{ fmt(data.pb) }}</template>
</Column>
<Column field="turnover_rate" header="换手率%" sortable style="min-width: 88px;">
<template #body="{ data }">{{ fmt(data.turnover_rate) }}</template>
</Column>
<Column
v-for="col in indCols"
:key="col.key"
:field="`indicators.${col.key}`"
:header="col.label"
sortable
style="min-width: 110px;"
>
<template #body="{ data }">
<span style="font-variant-numeric: tabular-nums;">{{ data.indicators?.[col.key] == null ? '—' : data.indicators[col.key].toFixed(2) }}</span>
</template>
</Column>
</DataTable>
</div>
</template>

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
import { computed } from 'vue';
import Button from 'primevue/button';
import type { ScreenerSyncStatus } from '@/api/types';
const props = defineProps<{ status: ScreenerSyncStatus | null }>();
const emit = defineEmits<{ (e: 'sync'): void }>();
function fmtDate(s?: string | null) {
if (!s) return '—';
return s.slice(0, 10);
}
// 数据是否可筛(有日线)。快照缺失则纯指标仍可用,给黄色提示。
const freshness = computed(() => {
const s = props.status;
if (!s) return { tone: 'none', text: '正在检查数据状态…' };
if (!s.ready) return { tone: 'warn', text: '全市场数据尚未同步,请先点击右侧「同步数据」' };
const snapMissing = s.stats.snapshot_rows === 0;
const d = s.stats.dates;
const base = `数据截至 ${fmtDate(s.last_trade_date)} · 共 ${d} 个交易日 · ${s.stats.stocks} 只股票`;
if (snapMissing) return { tone: 'warn', text: `${base}(每日指标暂不可用:市值/市盈率条件不可用,纯指标条件不受影响)` };
return { tone: 'ok', text: base };
});
</script>
<template>
<div class="sync-bar">
<span :class="freshness.tone === 'ok' ? 'ok' : freshness.tone === 'warn' ? 'warn' : ''">
<i class="pi" :class="freshness.tone === 'ok' ? 'pi-check-circle' : 'pi-info-circle'" style="margin-right: 5px;"></i>
{{ freshness.text }}
</span>
<template v-if="status && status.running">
<span class="sync-progress">
<i class="pi pi-spin pi-spinner"></i>
<span class="txt">{{ status.step || '同步中…' }}{{ status.done_days }}/{{ status.total_days }}</span>
</span>
</template>
<template v-else>
<span class="spacer"></span>
<Button
label="同步市场数据"
icon="pi pi-refresh"
size="small"
severity="secondary"
:disabled="status?.running"
@click="emit('sync')"
/>
</template>
<div v-if="status && status.error" class="warn" style="flex-basis: 100%; margin-top: 4px;">
<i class="pi pi-exclamation-triangle" style="margin-right: 5px;"></i>{{ status.error }}
</div>
</div>
</template>

View File

@@ -7,9 +7,11 @@ import 'primeicons/primeicons.css';
import './style.css';
import App from './App.vue';
import router from './router';
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.use(PrimeVue, {
theme: {
preset: Aura,

14
frontend/src/router.ts Normal file
View File

@@ -0,0 +1,14 @@
import { createRouter, createWebHistory } from 'vue-router';
import HomeView from '@/views/HomeView.vue';
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', name: 'home', component: HomeView },
{ path: '/screener', name: 'screener', component: () => import('@/views/ScreenerView.vue') },
{ path: '/backtest', name: 'backtest', component: () => import('@/views/BacktestView.vue') },
{ path: '/:pathMatch(.*)*', redirect: '/' },
],
});
export default router;

View File

@@ -0,0 +1,70 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { getScreenerSyncStatus, runScreener, startScreenerSync } from '@/api/client';
import type { ScreenerRunResponse, ScreenerSyncStatus } from '@/api/types';
export const useScreenerStore = defineStore('screener', () => {
const loading = ref(false);
const stage = ref<'idle' | 'parsing' | 'screening'>('idle');
const note = ref<string | null>(null);
const error = ref<string | null>(null);
const result = ref<ScreenerRunResponse | null>(null);
const syncStatus = ref<ScreenerSyncStatus | null>(null);
let pollTimer: ReturnType<typeof setInterval> | null = null;
async function run(text: string) {
loading.value = true;
error.value = null;
result.value = null;
note.value = 'AI 解析条件中…';
stage.value = 'parsing';
try {
// 条件解析与全市场筛选在后端一气呵成;切到筛选阶段给个过渡提示
setTimeout(() => {
if (loading.value && stage.value === 'parsing') {
stage.value = 'screening';
note.value = '全市场筛选中…';
}
}, 1200);
result.value = await runScreener({ text });
} catch (e) {
error.value = e instanceof Error ? e.message : '选股失败';
} finally {
loading.value = false;
stage.value = 'idle';
note.value = null;
}
}
async function fetchStatus() {
try {
syncStatus.value = await getScreenerSyncStatus();
} catch {
/* 静默:状态拉取失败不阻塞页面 */
}
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
async function startSync(days = 90) {
error.value = null;
try {
await startScreenerSync({ days });
await fetchStatus();
stopPolling();
pollTimer = setInterval(async () => {
await fetchStatus();
if (syncStatus.value && !syncStatus.value.running) stopPolling();
}, 2000);
} catch (e) {
error.value = e instanceof Error ? e.message : '启动同步失败';
}
}
return { loading, stage, note, error, result, syncStatus, run, fetchStatus, startSync, stopPolling };
});

View File

@@ -121,3 +121,66 @@ body {
padding: 10px 14px; font-size: 13px; margin-top: 14px;
}
.placeholder { color: var(--ink-3); font-size: 13px; padding: 56px 0; text-align: center; }
/* ---------- 顶部导航 ---------- */
.app-nav { display: flex; gap: 4px; margin-left: 18px; }
.nav-link {
font-size: 13px; color: var(--ink-2); text-decoration: none;
padding: 5px 12px; border-radius: 8px; transition: 0.15s;
}
.nav-link:hover { color: var(--ink); background: var(--surface-2); }
.nav-link.router-link-active { color: #fff; background: var(--dif); }
/* ---------- 首页功能入口卡片 ---------- */
.home-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: 16px; margin-top: 22px;
}
.feature-card {
display: block; text-decoration: none; color: var(--ink);
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
padding: 26px 24px 20px; transition: 0.18s; position: relative;
}
.feature-card:hover { border-color: var(--border-2); transform: translateY(-2px); box-shadow: 0 14px 34px rgba(0,0,0,0.35); }
.fc-icon {
width: 44px; height: 44px; border-radius: 12px; display: flex; align-items: center; justify-content: center;
background: rgba(91, 143, 249, 0.12); color: var(--dif); font-size: 20px; margin-bottom: 14px;
}
.fc-title { font-size: 18px; font-weight: 600; margin-bottom: 8px; }
.fc-desc { font-size: 13px; color: var(--ink-2); line-height: 1.75; min-height: 66px; }
.fc-tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 14px 0 16px; }
.fc-tag {
font-size: 11.5px; color: var(--ink-3); background: var(--surface-2);
border: 1px solid var(--border); border-radius: 999px; padding: 2px 9px;
}
.fc-enter { font-size: 13px; color: var(--dif); display: flex; align-items: center; gap: 6px; }
/* ---------- 智能选股 ---------- */
.screener-input { width: 100%; }
.cond-chips { display: flex; flex-wrap: wrap; gap: 7px; align-items: center; }
.cond-chips .clabel { font-size: 12px; color: var(--ink-3); margin-right: 3px; }
.cond-chip {
display: inline-flex; align-items: center; gap: 6px;
font-size: 12.5px; color: var(--ink); background: var(--surface-2);
border: 1px solid var(--border-2); border-radius: 999px; padding: 4px 11px;
font-variant-numeric: tabular-nums;
}
.cond-chip.ci { border-color: rgba(91, 143, 249, 0.45); color: #cdddfb; }
.cond-chip.cs { border-color: rgba(246, 189, 22, 0.4); color: #f2e3b3; }
.cond-chip.cx { color: var(--ink-3); }
.cond-chip .arrow { color: var(--ink-3); }
/* 数据状态条 */
.sync-bar {
display: flex; flex-wrap: wrap; align-items: center; gap: 12px;
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
padding: 9px 14px; margin-top: 14px; font-size: 12.5px; color: var(--ink-2);
}
.sync-bar .ok { color: var(--down); }
.sync-bar .warn { color: var(--dea); }
.sync-bar .sync-progress { flex: 1 1 200px; min-width: 160px; display: flex; align-items: center; gap: 8px; }
.sync-bar .sync-progress .txt { white-space: nowrap; }
/* 结果表数字着色 */
.pos { color: var(--up); }
.neg { color: var(--down); }

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router';
// 首页:两大功能入口(智能选股 / 策略回测)
const features = [
{
to: '/screener',
icon: 'pi pi-sparkles',
title: '智能选股',
desc: '用一句自然语言描述条件(如「这两天 KDJ 的 J 小于 10市值 100~200 亿」AI 解析后全市场筛选。',
tags: ['KDJ / RSI / MACD / 布林', '市值 · 市盈率 · 换手率', 'AI 条件解析'],
},
{
to: '/backtest',
icon: 'pi pi-chart-line',
title: '策略回测',
desc: '选标的、调参数跑历史回测K 线 + 指标 + 买卖点 + 净值曲线与绩效指标A股真实成本建模。',
tags: ['MACD / 双均线 / 单均线', 'T+1 · 印花税 · 佣金', '净值与绩效'],
},
];
</script>
<template>
<div class="home-grid">
<RouterLink v-for="f in features" :key="f.to" :to="f.to" class="feature-card">
<div class="fc-icon"><i :class="f.icon"></i></div>
<div class="fc-title">{{ f.title }}</div>
<div class="fc-desc">{{ f.desc }}</div>
<div class="fc-tags">
<span v-for="t in f.tags" :key="t" class="fc-tag">{{ t }}</span>
</div>
<div class="fc-enter">进入 <i class="pi pi-arrow-right"></i></div>
</RouterLink>
</div>
</template>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted } from 'vue';
import { useScreenerStore } from '@/stores/screener';
import ScreenerForm from '@/components/ScreenerForm.vue';
import ConditionChips from '@/components/ConditionChips.vue';
import SyncStatusBar from '@/components/SyncStatusBar.vue';
import ScreenerTable from '@/components/ScreenerTable.vue';
const store = useScreenerStore();
onMounted(() => {
// 仅查状态;同步由用户点击「同步市场数据」显式触发(避免反复触发接口限频)
store.fetchStatus();
});
onBeforeUnmount(() => store.stopPolling());
</script>
<template>
<div>
<ScreenerForm :loading="store.loading" @run="store.run" />
<SyncStatusBar :status="store.syncStatus" @sync="store.startSync(90)" />
<div v-if="store.error" class="error-banner">
<i class="pi pi-exclamation-triangle" style="margin-right: 6px;"></i>{{ store.error }}
</div>
<div v-if="store.loading && store.note" class="placeholder">
<i class="pi pi-spin pi-spinner" style="margin-right: 8px;"></i>{{ store.note }}
</div>
<template v-else-if="store.result">
<div class="panel" style="padding: 12px 14px; margin-top: 14px;">
<ConditionChips :conditions="store.result.conditions" />
</div>
<ScreenerTable :result="store.result" />
</template>
<div v-else-if="!store.error" class="placeholder">
输入选股条件开始筛选即可全市场选股
</div>
</div>
</template>