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:
@@ -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",
|
||||
|
||||
18
frontend/pnpm-lock.yaml
generated
18
frontend/pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
51
frontend/src/components/ConditionChips.vue
Normal file
51
frontend/src/components/ConditionChips.vue
Normal 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>
|
||||
50
frontend/src/components/ScreenerForm.vue
Normal file
50
frontend/src/components/ScreenerForm.vue
Normal 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>
|
||||
75
frontend/src/components/ScreenerTable.vue
Normal file
75
frontend/src/components/ScreenerTable.vue
Normal 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>
|
||||
56
frontend/src/components/SyncStatusBar.vue
Normal file
56
frontend/src/components/SyncStatusBar.vue
Normal 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>
|
||||
@@ -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
14
frontend/src/router.ts
Normal 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;
|
||||
70
frontend/src/stores/screener.ts
Normal file
70
frontend/src/stores/screener.ts
Normal 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 };
|
||||
});
|
||||
@@ -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); }
|
||||
|
||||
35
frontend/src/views/HomeView.vue
Normal file
35
frontend/src/views/HomeView.vue
Normal 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>
|
||||
44
frontend/src/views/ScreenerView.vue
Normal file
44
frontend/src/views/ScreenerView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user