rebuild(screener): add controlled formulas and rolling backtests
This commit is contained in:
@@ -16,6 +16,7 @@ const props = defineProps<{
|
||||
factorGroups: Record<string, string[]>;
|
||||
strategies: CustomStrategy[];
|
||||
runs: ScreenerRun[];
|
||||
compiledFormula: ScreenerFormula | null;
|
||||
locked: boolean;
|
||||
busy: boolean;
|
||||
}>();
|
||||
@@ -24,9 +25,11 @@ const emit = defineEmits<{
|
||||
run: [strategy: CustomStrategy];
|
||||
remove: [strategy: CustomStrategy];
|
||||
track: [run: ScreenerRun, candidate: Candidate];
|
||||
compile: [description: string];
|
||||
}>();
|
||||
|
||||
const name = ref("我的选股策略");
|
||||
const naturalLanguage = ref("");
|
||||
const selectedFactor = ref("return_20d");
|
||||
const scores = ref<FormulaScore[]>([
|
||||
{ field: "return_20d", weight: 60, direction: "desc" },
|
||||
@@ -55,6 +58,11 @@ watch(
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.compiledFormula,
|
||||
(formula) => { if (formula) applyFormula(formula); },
|
||||
);
|
||||
|
||||
function addFactor(): void {
|
||||
if (!selectedFactor.value || scores.value.some((item) => item.field === selectedFactor.value)) {
|
||||
return;
|
||||
@@ -66,18 +74,22 @@ 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) => ({
|
||||
function applyFormula(formula: ScreenerFormula): void {
|
||||
scores.value = 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);
|
||||
filters.value = formula.filters.map((item) => ({ ...item }));
|
||||
listedDays.value = formula.universe.listed_days_min;
|
||||
excludeSt.value = formula.universe.exclude_st;
|
||||
outputLimit.value = formula.limit;
|
||||
minimumScore.value = Math.round(formula.min_score * 100);
|
||||
}
|
||||
|
||||
function edit(strategy: CustomStrategy): void {
|
||||
selected.value = strategy.id;
|
||||
name.value = strategy.name;
|
||||
applyFormula(strategy.formula);
|
||||
}
|
||||
|
||||
function save(): void {
|
||||
@@ -128,6 +140,10 @@ function save(): void {
|
||||
</div>
|
||||
<div class="custom-filters">
|
||||
<header class="card-header"><h2>过滤与输出</h2></header>
|
||||
<div class="formula-compiler">
|
||||
<label class="field"><span>自然语言转公式</span><textarea v-model="naturalLanguage" class="input" rows="2" maxlength="500" placeholder="例如:近20日走势较强、成交额不少于3亿元,偏重板块强度"></textarea></label>
|
||||
<button class="btn" type="button" :disabled="busy || naturalLanguage.trim().length < 5" @click="emit('compile', naturalLanguage)">{{ busy ? "转换中" : "转换" }}</button>
|
||||
</div>
|
||||
<div class="filter-list">
|
||||
<div v-for="(item, index) in filters" :key="index">
|
||||
<select v-model="item.field" class="select">
|
||||
@@ -164,6 +180,10 @@ function save(): void {
|
||||
<button class="btn" type="button" :disabled="busy" @click="edit(current)">编辑</button>
|
||||
<button class="btn" type="button" :disabled="busy" @click="emit('remove', current)">删除</button>
|
||||
</div>
|
||||
<div v-if="run?.backtest" class="notice">
|
||||
<template v-if="run.backtest.stable">历史样本 {{ run.backtest.sample_size }} · 条件胜率 {{ run.backtest.win_rate?.toFixed(1) }}% · 平均3日收益 {{ run.backtest.average_return_3d?.toFixed(2) }}%</template>
|
||||
<template v-else>{{ run.backtest.message }}</template>
|
||||
</div>
|
||||
</section>
|
||||
<CandidateTable :run="run" :locked="locked" @track="(candidate) => run && emit('track', run, candidate)" />
|
||||
</template>
|
||||
|
||||
@@ -21,6 +21,7 @@ const workspace = ref<ScreenerWorkspace | null>(null);
|
||||
const loading = ref(false);
|
||||
const busy = ref(false);
|
||||
const error = ref("");
|
||||
const compiledFormula = ref<ScreenerFormula | null>(null);
|
||||
const locked = () => !session.account?.smart_access;
|
||||
|
||||
async function load(): Promise<void> {
|
||||
@@ -41,6 +42,15 @@ async function saveCustom(name: string, formula: ScreenerFormula): Promise<void>
|
||||
catch (reason) { ui.showToast(reason instanceof Error ? reason.message : "保存失败"); }
|
||||
finally { busy.value = false; }
|
||||
}
|
||||
async function compileFormula(description: string): Promise<void> {
|
||||
busy.value = true;
|
||||
try {
|
||||
compiledFormula.value = (await screenerApi.compileFormula(description)).formula;
|
||||
ui.showToast("已转换为受控公式,请核对后保存");
|
||||
} 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(); }
|
||||
@@ -74,7 +84,7 @@ watch(() => market.selectedDate, load);
|
||||
<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" />
|
||||
<CustomPanel v-else :factors="catalog.factors" :factor-groups="catalog.factor_groups" :strategies="workspace?.custom_strategies ?? []" :runs="workspace?.custom_runs ?? []" :compiled-formula="compiledFormula" :locked="locked()" :busy="busy" @compile="compileFormula" @save="saveCustom" @run="runCustom" @remove="removeCustom" @track="track" />
|
||||
</template>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -49,10 +49,11 @@ function statusLabel(strategyId: string): string {
|
||||
</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>
|
||||
<header><div><span>{{ strategy?.formula.meta?.category }}</span><h2>{{ strategy?.display_name }}</h2></div><span class="tag">{{ strategy?.formula.meta?.frequency ?? "每日" }}</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>
|
||||
<dl><div><dt>策略质量</dt><dd>{{ strategy?.formula.meta?.quality ?? "待评估" }}</dd></div><div><dt>风险等级</dt><dd>{{ strategy?.formula.meta?.risk ?? "未标注" }}</dd></div><div><dt>执行频率</dt><dd>{{ strategy?.formula.meta?.frequency ?? "每日" }}</dd></div><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>
|
||||
<div class="strategy-conditions"><h3>评分权重</h3><span v-for="score in strategy?.formula.score" :key="score.field">{{ labels[score.field] }} · {{ Math.round(score.weight * 100) }}% · {{ score.direction === 'desc' ? '高优' : '低优' }}</span></div>
|
||||
</article>
|
||||
</section>
|
||||
<CandidateTable :run="run" :locked="locked" @track="(candidate) => run && emit('track', run, candidate)" />
|
||||
|
||||
@@ -31,6 +31,17 @@ export type Candidate = {
|
||||
score_display: number;
|
||||
reason: string;
|
||||
risk_flags: string[];
|
||||
historical_estimate?: number | null;
|
||||
};
|
||||
export type ScreenerBacktest = {
|
||||
sample_size: number;
|
||||
evaluated_dates: number;
|
||||
stable: boolean;
|
||||
win_rate: number | null;
|
||||
average_return_3d: number | null;
|
||||
period_start: string | null;
|
||||
period_end: string | null;
|
||||
message: string;
|
||||
};
|
||||
export type ScreenerRun = {
|
||||
id: number;
|
||||
@@ -43,6 +54,7 @@ export type ScreenerRun = {
|
||||
missing_fields: string[];
|
||||
items: Candidate[];
|
||||
error_message: string;
|
||||
backtest?: ScreenerBacktest | null;
|
||||
};
|
||||
export type CustomStrategy = {
|
||||
id: number;
|
||||
@@ -90,6 +102,9 @@ export const screenerApi = {
|
||||
workspace(date: string): Promise<ScreenerWorkspace> {
|
||||
return api.get(`/screener?date=${encodeURIComponent(date)}`);
|
||||
},
|
||||
compileFormula(description: string): Promise<{ formula: ScreenerFormula; prompt_version: string }> {
|
||||
return api.post("/screener/formula/compile", { description });
|
||||
},
|
||||
saveCustom(name: string, formula: ScreenerFormula): Promise<CustomStrategy> {
|
||||
return api.put("/screener/custom", { name, formula });
|
||||
},
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
.strategy-conditions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-8); }
|
||||
.strategy-conditions h3 { width: 100%; font-size: var(--font-12); }
|
||||
.strategy-conditions span { padding: var(--s-6) var(--s-8); border-radius: var(--tag-radius); color: var(--color-text-secondary); background: var(--color-surface-muted); font-size: var(--font-11); }
|
||||
.strategy-conditions + .strategy-conditions { margin-top: var(--s-16); }
|
||||
.screener-results { min-width: 0; overflow: hidden; }
|
||||
.screener-results .card-header > div { display: flex; align-items: baseline; gap: var(--s-10); }
|
||||
.screener-results .card-header .tag { margin-left: auto; }
|
||||
@@ -64,6 +65,8 @@
|
||||
.factor-list .input { min-height: var(--s-32); padding: var(--s-4) var(--s-6); }
|
||||
.factor-direction { min-height: var(--s-32); padding: var(--s-4); }
|
||||
.custom-filters { min-width: 0; }
|
||||
.formula-compiler { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: var(--s-8); padding: var(--s-10); border-bottom: var(--s-1) solid var(--color-divider); }
|
||||
.formula-compiler textarea { min-height: var(--s-64); resize: vertical; }
|
||||
.filter-list { display: grid; gap: var(--s-6); padding: var(--s-10); border-bottom: var(--s-1) solid var(--color-divider); }
|
||||
.filter-list > div { display: grid; grid-template-columns: minmax(0, 1fr) var(--s-64) var(--s-64) var(--s-32); gap: var(--s-6); }
|
||||
.filter-list .input,
|
||||
@@ -98,5 +101,6 @@
|
||||
.factor-list > div { grid-template-columns: minmax(0, 1fr) var(--s-64) var(--s-64) var(--s-44); }
|
||||
.factor-list > div input[type="range"] { grid-column: 1 / -1; grid-row: 2; }
|
||||
.custom-options { grid-template-columns: minmax(0, 1fr); }
|
||||
.formula-compiler { grid-template-columns: minmax(0, 1fr); }
|
||||
.custom-save { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user