rebuild(stage-9): deliver deterministic intelligent screening

This commit is contained in:
leefer
2026-07-30 05:15:17 +08:00
parent 6cb52e864a
commit 158257ebb8
46 changed files with 7322 additions and 34 deletions
@@ -0,0 +1,47 @@
<script setup lang="ts">
import type { Candidate, ScreenerRun } from "../../shared/api/screener";
defineProps<{ run?: ScreenerRun; locked?: boolean }>();
const emit = defineEmits<{ track: [candidate: Candidate] }>();
function number(value: number | null, digits = 2): string {
return value === null ? "" : value.toFixed(digits);
}
</script>
<template>
<section class="card screener-results" :class="{ 'locked-content': locked }" :aria-disabled="locked">
<header class="card-header">
<div>
<h2>候选结果</h2>
<p v-if="run" class="faint">{{ run.strategy_name }} · {{ run.selection_date }}</p>
</div>
<span v-if="run" class="tag">{{ run.items.length }} </span>
</header>
<div v-if="run?.status === 'data_incomplete'" class="notice notice-warning">
数据尚不完整{{ run.missing_fields.join("") }}
</div>
<div v-else-if="run?.status === 'failed'" class="notice notice-warning">
本次计算失败已隔离该策略不影响其他策略
</div>
<div v-else-if="!run || run.status === 'no_signal' || !run.items.length" class="screener-empty">
<strong>暂无符合条件个股</strong>
<span>完整数据下无信号会保留为空不补造候选</span>
</div>
<div v-else class="data-table-wrap">
<table class="data-table">
<thead><tr><th>代码</th><th>股票</th><th>行业</th><th class="numeric">收盘价</th><th class="numeric">涨跌幅%</th><th class="numeric">综合分</th><th>主要依据</th><th>操作</th></tr></thead>
<tbody>
<tr v-for="item in run.items" :key="item.identifier">
<td class="code-column">{{ item.code }}</td><td>{{ item.name }}</td><td>{{ item.sector }}</td>
<td class="numeric">{{ number(item.close) }}</td>
<td class="numeric" :class="{ up: Number(item.pct_chg) > 0, down: Number(item.pct_chg) < 0 }">{{ number(item.pct_chg) }}</td>
<td class="numeric"><strong>{{ number(item.score_display, 1) }}</strong></td>
<td class="wide-column">{{ item.reason }}</td>
<td><button class="btn btn-small" type="button" :disabled="locked" @click="emit('track', item)">加入跟踪</button></td>
</tr>
</tbody>
</table>
</div>
</section>
</template>
@@ -0,0 +1,169 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import type {
Candidate,
CustomStrategy,
FormulaCondition,
FormulaScore,
ScreenerFormula,
ScreenerRun,
} from "../../shared/api/screener";
import CandidateTable from "./CandidateTable.vue";
const props = defineProps<{
factors: Record<string, string>;
factorGroups: Record<string, string[]>;
strategies: CustomStrategy[];
runs: ScreenerRun[];
locked: boolean;
busy: boolean;
}>();
const emit = defineEmits<{
save: [name: string, formula: ScreenerFormula];
run: [strategy: CustomStrategy];
remove: [strategy: CustomStrategy];
track: [run: ScreenerRun, candidate: Candidate];
}>();
const name = ref("我的选股策略");
const selectedFactor = ref("return_20d");
const scores = ref<FormulaScore[]>([
{ field: "return_20d", weight: 60, direction: "desc" },
{ field: "sector_strength", weight: 40, direction: "desc" },
]);
const filters = ref<FormulaCondition[]>([
{ field: "amount_billion", op: ">=", value: 1 },
]);
const listedDays = ref(120);
const outputLimit = ref(30);
const minimumScore = ref(50);
const excludeSt = ref(true);
const selected = ref<number | null>(props.strategies[0]?.id ?? null);
const current = computed(() => props.strategies.find((item) => item.id === selected.value));
const run = computed(() =>
props.runs.find((item) => item.strategy_id === `custom-${selected.value}`),
);
const total = computed(() =>
scores.value.reduce((sum, item) => sum + Number(item.weight), 0),
);
watch(
() => props.strategies,
(items) => {
if (!items.some((item) => item.id === selected.value)) selected.value = items[0]?.id ?? null;
},
);
function addFactor(): void {
if (!selectedFactor.value || scores.value.some((item) => item.field === selectedFactor.value)) {
return;
}
scores.value.push({ field: selectedFactor.value, weight: 0, direction: "desc" });
}
function addFilter(): void {
filters.value.push({ field: "amount_billion", op: ">=", value: 1 });
}
function edit(strategy: CustomStrategy): void {
selected.value = strategy.id;
name.value = strategy.name;
scores.value = strategy.formula.score.map((item) => ({
...item,
weight: Math.round(item.weight * 100),
}));
filters.value = strategy.formula.filters.map((item) => ({ ...item }));
listedDays.value = strategy.formula.universe.listed_days_min;
excludeSt.value = strategy.formula.universe.exclude_st;
outputLimit.value = strategy.formula.limit;
minimumScore.value = Math.round(strategy.formula.min_score * 100);
}
function save(): void {
emit("save", name.value, {
universe: {
exclude_st: excludeSt.value,
listed_days_min: Number(listedDays.value),
},
filters: filters.value.map((item) => ({ ...item, value: Number(item.value) })),
score: scores.value.map((item) => ({
...item,
weight: Number(item.weight) / 100,
})),
limit: Number(outputLimit.value),
min_score: Number(minimumScore.value) / 100,
});
}
</script>
<template>
<section class="custom-builder card" :class="{ 'locked-content': locked }" :aria-disabled="locked">
<div class="custom-factors">
<header class="card-header">
<h2>因子与权重</h2>
<span :class="['tag', { warning: total !== 100 }]">合计 {{ total }}%</span>
</header>
<div class="factor-add">
<select v-model="selectedFactor" class="select">
<optgroup v-for="(fields, group) in factorGroups" :key="group" :label="group">
<option v-for="field in fields" :key="field" :value="field">
{{ factors[field] }}
</option>
</optgroup>
</select>
<button class="btn" type="button" @click="addFactor">添加因子</button>
</div>
<div class="factor-list">
<div v-for="(item, index) in scores" :key="item.field">
<label>{{ factors[item.field] }}</label>
<select v-model="item.direction" class="select factor-direction">
<option value="desc">高优</option><option value="asc">低优</option>
</select>
<input v-model.number="item.weight" type="range" min="0" max="100">
<input v-model.number="item.weight" class="input numeric" type="number" min="0" max="100">
<button class="icon-button" type="button" title="移除因子" @click="scores.splice(index, 1)">×</button>
</div>
</div>
</div>
<div class="custom-filters">
<header class="card-header"><h2>过滤与输出</h2></header>
<div class="filter-list">
<div v-for="(item, index) in filters" :key="index">
<select v-model="item.field" class="select">
<option v-for="(label, field) in factors" :key="field" :value="field">{{ label }}</option>
</select>
<select v-model="item.op" class="select"><option>&gt;=</option><option>&gt;</option><option>&lt;=</option><option>&lt;</option><option>==</option><option>!=</option></select>
<input v-model.number="item.value" class="input numeric" type="number">
<button class="icon-button" type="button" title="移除条件" @click="filters.splice(index, 1)">×</button>
</div>
<button class="btn btn-small" type="button" @click="addFilter">添加条件</button>
</div>
<div class="custom-options">
<label class="field"><span>上市天数</span><input v-model.number="listedDays" class="input numeric" type="number" min="0" max="5000"></label>
<label class="field"><span>输出数量</span><input v-model.number="outputLimit" class="input numeric" type="number" min="1" max="50"></label>
<label class="field"><span>最低综合分%</span><input v-model.number="minimumScore" class="input numeric" type="number" min="0" max="100"></label>
<label class="switch-field"><input v-model="excludeSt" type="checkbox"><span>剔除ST与退市风险</span></label>
</div>
<div class="custom-save">
<label class="field"><span>策略名称</span><input v-model="name" class="input" maxlength="30"></label>
<button class="btn btn-primary" type="button" :disabled="busy || total !== 100 || !scores.length" @click="save">保存策略</button>
</div>
</div>
</section>
<section class="card custom-library" :class="{ 'locked-content': locked }" :aria-disabled="locked">
<header class="card-header"><h2>我的策略</h2><span class="tag">手动执行</span></header>
<div class="custom-strategy-list">
<button v-for="item in strategies" :key="item.id" type="button" :class="{ active: selected === item.id }" @click="selected = item.id">
<strong>{{ item.name }}</strong><span> {{ item.version }} </span>
</button>
<div v-if="!strategies.length" class="screener-empty"><span>保存后的自定义策略会出现在这里</span></div>
</div>
<div v-if="current" class="custom-actions">
<button class="btn btn-primary" type="button" :disabled="busy" @click="emit('run', current)">执行选股</button>
<button class="btn" type="button" :disabled="busy" @click="edit(current)">编辑</button>
<button class="btn" type="button" :disabled="busy" @click="emit('remove', current)">删除</button>
</div>
</section>
<CandidateTable :run="run" :locked="locked" @track="(candidate) => run && emit('track', run, candidate)" />
</template>
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { screenerApi, type Candidate, type CustomStrategy, type ScreenerCatalog, type ScreenerFormula, type ScreenerRun, type ScreenerWorkspace } from "../../shared/api/screener";
import EmptyState from "../../shared/components/EmptyState.vue";
import { useMarketStore } from "../../shared/stores/market";
import { useSessionStore } from "../../shared/stores/session";
import { useUiStore } from "../../shared/stores/ui";
import CustomPanel from "./CustomPanel.vue";
import StagePanel from "./StagePanel.vue";
import StrategyPanel from "./StrategyPanel.vue";
const session = useSessionStore();
const market = useMarketStore();
const ui = useUiStore();
const router = useRouter();
const mode = ref<"stage" | "curated" | "custom">("stage");
const catalog = ref<ScreenerCatalog | null>(null);
const workspace = ref<ScreenerWorkspace | null>(null);
const loading = ref(false);
const busy = ref(false);
const error = ref("");
const locked = () => !session.account?.smart_access;
async function load(): Promise<void> {
loading.value = true;
error.value = "";
try {
catalog.value = await screenerApi.catalog();
workspace.value = locked() ? null : await screenerApi.workspace(market.selectedDate);
} catch (reason) {
error.value = reason instanceof Error ? reason.message : "智能选股数据读取失败";
} finally {
loading.value = false;
}
}
async function saveCustom(name: string, formula: ScreenerFormula): Promise<void> {
busy.value = true;
try { await screenerApi.saveCustom(name, formula); ui.showToast("自定义策略已保存"); await load(); }
catch (reason) { ui.showToast(reason instanceof Error ? reason.message : "保存失败"); }
finally { busy.value = false; }
}
async function runCustom(strategy: CustomStrategy): Promise<void> {
busy.value = true;
try { await screenerApi.runCustom(strategy.id, market.selectedDate); ui.showToast("选股计算已完成"); await load(); }
catch (reason) { ui.showToast(reason instanceof Error ? reason.message : "执行失败"); }
finally { busy.value = false; }
}
async function removeCustom(strategy: CustomStrategy): Promise<void> {
busy.value = true;
try { await screenerApi.deleteCustom(strategy.id); ui.showToast("自定义策略已删除"); await load(); }
catch (reason) { ui.showToast(reason instanceof Error ? reason.message : "删除失败"); }
finally { busy.value = false; }
}
async function track(run: ScreenerRun, candidate: Candidate): Promise<void> {
try { await screenerApi.addTrack(run.id, candidate.identifier); ui.showToast(`${candidate.name} 已加入策略跟踪`); }
catch (reason) { ui.showToast(reason instanceof Error ? reason.message : "加入跟踪失败"); }
}
onMounted(load);
watch(() => market.selectedDate, load);
</script>
<template>
<main class="page-frame screener-page">
<header class="page-header market-page-header">
<div><h1>智能选股</h1><p class="page-subtitle">数据日期 {{ workspace?.trade_date ?? market.selectedDate }} · 确定性条件与盘后归档</p></div>
<div class="page-actions"><button class="tracking-entry" type="button" @click="router.push('/workspace/screener/tracking')"><span>持续</span><strong>策略跟踪</strong></button><div class="seg-control"><button type="button" :class="{ active: mode === 'stage' }" @click="mode = 'stage'">阶段选股</button><button type="button" :class="{ active: mode === 'curated' }" @click="mode = 'curated'">策略选股</button><button type="button" :class="{ active: mode === 'custom' }" @click="mode = 'custom'">自定义选股</button></div></div>
</header>
<div v-if="locked()" class="notice notice-warning membership-lock"><span><strong>智能选股仅对会员开放</strong>,开通会员后可查看盘后候选并使用自定义选股。</span><button class="btn btn-small" type="button" @click="ui.openDialog('membership')">查看会员状态</button></div>
<div v-if="loading" class="card workspace-state">正在读取本地选股归档</div>
<EmptyState v-else-if="error" class="card" title="智能选股暂不可用" :description="error" />
<template v-else-if="catalog">
<StagePanel v-if="mode === 'stage'" :strategies="catalog.stage" :runs="workspace?.stage_runs ?? []" :locked="locked()" @track="track" />
<StrategyPanel v-else-if="mode === 'curated'" :strategies="catalog.curated" :runs="workspace?.curated_runs ?? []" :labels="catalog.factors" :locked="locked()" @track="track" />
<CustomPanel v-else :factors="catalog.factors" :factor-groups="catalog.factor_groups" :strategies="workspace?.custom_strategies ?? []" :runs="workspace?.custom_runs ?? []" :locked="locked()" :busy="busy" @save="saveCustom" @run="runCustom" @remove="removeCustom" @track="track" />
</template>
</main>
</template>
@@ -0,0 +1,29 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import type { Candidate, ScreenerRun, ScreenerStrategy } from "../../shared/api/screener";
import CandidateTable from "./CandidateTable.vue";
const props = defineProps<{ strategies: ScreenerStrategy[]; runs: ScreenerRun[]; locked: boolean }>();
const emit = defineEmits<{ track: [run: ScreenerRun, candidate: Candidate] }>();
const selected = ref(props.runs[0]?.strategy_id ?? props.strategies[0]?.id ?? "");
const run = computed(() => props.runs.find((item) => item.strategy_id === selected.value) ?? props.runs[0]);
const strategy = computed(() => props.strategies.find((item) => item.id === (run.value?.strategy_id ?? selected.value)));
</script>
<template>
<section class="stage-overview card" :class="{ 'locked-content': locked }" :aria-disabled="locked">
<header class="stage-heading">
<div><span>当前阶段自动候选</span><strong>{{ strategy?.display_name ?? "等待盘后判定" }}</strong></div>
<p>{{ strategy?.description ?? "每日收盘数据定稿后自动生成,结果允许为空。" }}</p>
<span class="tag">盘后自动</span>
</header>
<div class="stage-flow" aria-label="自动选股流程">
<span class="done">阶段识别</span><i></i><span class="done">策略匹配</span><i></i><span class="done">自动计算</span><i></i><span>结果归档</span>
</div>
<nav v-if="runs.length > 1" class="stage-run-tabs" aria-label="当日阶段策略">
<button v-for="item in runs" :key="item.id" type="button" :class="{ active: selected === item.strategy_id }" @click="selected = item.strategy_id">{{ item.strategy_name }}</button>
</nav>
</section>
<CandidateTable :run="run" :locked="locked" @track="(candidate) => run && emit('track', run, candidate)" />
</template>
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import type { Candidate, ScreenerRun, ScreenerStrategy } from "../../shared/api/screener";
import CandidateTable from "./CandidateTable.vue";
const props = defineProps<{ strategies: ScreenerStrategy[]; runs: ScreenerRun[]; labels: Record<string, string>; locked: boolean }>();
const emit = defineEmits<{ track: [run: ScreenerRun, candidate: Candidate] }>();
const query = ref("");
const category = ref("全部流派");
const view = ref<"list" | "grid">("list");
const categories = computed(() => ["全部流派", ...new Set(props.strategies.map((item) => item.formula.meta?.category ?? "其他"))]);
const filtered = computed(() => props.strategies.filter((item) => {
const matchCategory = category.value === "全部流派" || item.formula.meta?.category === category.value;
return matchCategory && (!query.value.trim() || item.name.includes(query.value.trim()));
}));
const selected = ref(props.strategies[0]?.id ?? "");
const strategy = computed(() => props.strategies.find((item) => item.id === selected.value) ?? filtered.value[0]);
const run = computed(() => props.runs.find((item) => item.strategy_id === strategy.value?.id));
const regimeLabels: Record<string, string> = { ice: "冰点", repair: "修复", fermentation: "发酵", climax: "高潮", divergence: "分化", retreat: "退潮" };
</script>
<template>
<section class="strategy-layout" :class="{ 'locked-content': locked }" :aria-disabled="locked">
<aside class="card strategy-library">
<header class="card-header"><h2>策略库</h2><span class="tag">{{ filtered.length }} </span></header>
<div class="strategy-tools">
<input v-model="query" class="input" type="search" placeholder="搜索策略">
<select v-model="category" class="select"><option v-for="item in categories" :key="item">{{ item }}</option></select>
<div class="seg-control" aria-label="排列方式"><button type="button" :class="{ active: view === 'list' }" title="列表排列" @click="view = 'list'"></button><button type="button" :class="{ active: view === 'grid' }" title="图标排列" @click="view = 'grid'"></button></div>
</div>
<div class="strategy-items" :class="`is-${view}`">
<button v-for="item in filtered" :key="item.id" type="button" :class="{ active: strategy?.id === item.id }" @click="selected = item.id">
<strong>{{ item.display_name }}</strong><small>{{ item.formula.meta?.category }}</small>
<span :class="['strategy-status', runs.find((run) => run.strategy_id === item.id)?.status]">{{ runs.find((run) => run.strategy_id === item.id)?.status === 'completed' ? '有候选' : runs.find((run) => run.strategy_id === item.id)?.status === 'data_incomplete' ? '数据不足' : '暂无信号' }}</span>
</button>
</div>
</aside>
<article class="card strategy-detail">
<header><div><span>{{ strategy?.formula.meta?.category }}</span><h2>{{ strategy?.display_name }}</h2></div><span class="tag">每日盘后</span></header>
<p>{{ strategy?.description }}</p>
<dl><div><dt>适用环境</dt><dd>{{ strategy?.formula.meta?.suitable_environment }}</dd></div><div><dt>主要失效风险</dt><dd>{{ strategy?.formula.meta?.failure_risk }}</dd></div><div><dt>适用阶段</dt><dd>{{ strategy?.regimes.map((item) => regimeLabels[item]).join(" · ") }}</dd></div></dl>
<div class="strategy-conditions"><h3>选股条件</h3><span v-for="condition in strategy?.formula.filters" :key="`${condition.field}-${condition.op}`">{{ labels[condition.field] }} {{ condition.op }} {{ Array.isArray(condition.value) ? condition.value.join(' ') : condition.value }}</span></div>
</article>
</section>
<CandidateTable :run="run" :locked="locked" @track="(candidate) => run && emit('track', run, candidate)" />
</template>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { screenerApi, type StrategyTrack } from "../../shared/api/screener";
import EmptyState from "../../shared/components/EmptyState.vue";
import { useUiStore } from "../../shared/stores/ui";
const router = useRouter();
const ui = useUiStore();
const rows = ref<StrategyTrack[]>([]);
const loading = ref(true);
const error = ref("");
async function load(): Promise<void> {
loading.value = true;
try { rows.value = await screenerApi.tracks(); }
catch (reason) { error.value = reason instanceof Error ? reason.message : "策略跟踪读取失败"; }
finally { loading.value = false; }
}
async function remove(row: StrategyTrack): Promise<void> {
try { await screenerApi.removeTrack(row.id); ui.showToast(`${row.name} 已停止跟踪`); await load(); }
catch (reason) { ui.showToast(reason instanceof Error ? reason.message : "操作失败"); }
}
function number(value: number | null): string { return value === null ? "" : value.toFixed(2); }
onMounted(load);
</script>
<template>
<main class="page-frame tracking-page">
<header class="page-header"><div><button class="btn btn-ghost" type="button" @click="router.push('/workspace/screener')"> 返回智能选股</button><h1>策略持续跟踪</h1><p class="page-subtitle">仅跟踪手动加入的候选按账户独立保存</p></div></header>
<div v-if="loading" class="card workspace-state">正在读取跟踪记录</div>
<EmptyState v-else-if="error" class="card" title="跟踪记录暂不可用" :description="error" />
<EmptyState v-else-if="!rows.length" class="card" title="暂无跟踪记录" description="从任一候选结果中点击“加入跟踪”后,记录会出现在这里。" />
<section v-else class="card tracking-table"><div class="data-table-wrap"><table class="data-table"><thead><tr><th>代码</th><th>股票</th><th>策略</th><th>入选日期</th><th class="numeric">入选价</th><th class="numeric">T+1开盘%</th><th class="numeric">T+1收盘%</th><th class="numeric">T+3收盘%</th><th class="numeric">T+5收盘%</th><th class="numeric">最大涨幅%</th><th class="numeric">最大回撤%</th><th>操作</th></tr></thead><tbody><tr v-for="row in rows" :key="row.id"><td>{{ row.code }}</td><td>{{ row.name }}</td><td>{{ row.strategy_name }}</td><td>{{ row.selection_date }}</td><td class="numeric">{{ number(row.entry_price) }}</td><td class="numeric">{{ number(row.t1_open_return) }}</td><td class="numeric">{{ number(row.t1_return) }}</td><td class="numeric">{{ number(row.t3_return) }}</td><td class="numeric">{{ number(row.t5_return) }}</td><td class="numeric up">{{ number(row.max_gain) }}</td><td class="numeric down">{{ number(row.max_drawdown) }}</td><td><button class="btn btn-small" type="button" @click="remove(row)">停止</button></td></tr></tbody></table></div></section>
</main>
</template>