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:
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>
|
||||
Reference in New Issue
Block a user