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
+14 -1
View File
@@ -3,23 +3,36 @@ import { createRouter, createWebHistory } from "vue-router";
import SystemManagementView from "./views/SystemManagementView.vue";
import WorkspaceView from "./views/WorkspaceView.vue";
import EntityDetailView from "./views/EntityDetailView.vue";
import TrackingPage from "../pages/screener/TrackingPage.vue";
import { findWorkspace } from "./workspaceRegistry";
export default createRouter({
history: createWebHistory(),
routes: [
{ path: "/", redirect: "/workspace/emotion" },
{
path: "/workspace/screener/tracking",
name: "screener-tracking",
component: TrackingPage,
meta: { title: "智能选股" },
},
{
path: "/workspace/:workspace",
name: "workspace",
component: WorkspaceView,
beforeEnter: (to) => (findWorkspace(String(to.params.workspace)) ? true : "/workspace/emotion"),
},
{ path: "/system", name: "system", component: SystemManagementView },
{
path: "/system",
name: "system",
component: SystemManagementView,
meta: { title: "系统管理" },
},
{
path: "/market/:entityType/:identifier",
name: "entity-detail",
component: EntityDetailView,
meta: { title: "行情详情" },
},
{ path: "/:pathMatch(.*)*", redirect: "/workspace/emotion" },
],
+4 -1
View File
@@ -5,7 +5,10 @@ import { useRoute } from "vue-router";
import { findWorkspace } from "../workspaceRegistry";
const route = useRoute();
const title = computed(() => findWorkspace(String(route.params.workspace ?? ""))?.title ?? "系统管理");
const title = computed(() => {
if (typeof route.meta.title === "string") return route.meta.title;
return findWorkspace(String(route.params.workspace ?? ""))?.title ?? "小白复盘";
});
</script>
<template>
+1 -2
View File
@@ -18,8 +18,7 @@ const menuRoot = ref<HTMLElement | null>(null);
const refreshing = ref(false);
const title = computed(() => {
if (route.name === "system") return "系统管理";
if (route.name === "entity-detail") return "行情详情";
if (typeof route.meta.title === "string") return route.meta.title;
return findWorkspace(String(route.params.workspace ?? "emotion"))?.title ?? "小白复盘";
});
@@ -4,6 +4,7 @@ import { useRoute } from "vue-router";
import EmptyState from "../../shared/components/EmptyState.vue";
import MarketWorkspaceView from "../../pages/market/MarketWorkspaceView.vue";
import ScreenerPage from "../../pages/screener/ScreenerPage.vue";
import { useMarketStore } from "../../shared/stores/market";
import { useSessionStore } from "../../shared/stores/session";
import { useUiStore } from "../../shared/stores/ui";
@@ -29,6 +30,7 @@ const implementedMarket = computed(() =>
<template>
<MarketWorkspaceView v-if="implementedMarket" :workspace-key="workspace.key" />
<ScreenerPage v-else-if="workspace.key === 'screener'" />
<main v-else class="page-frame">
<header class="page-header">
<h1>{{ workspace.title }}</h1>
+1
View File
@@ -13,6 +13,7 @@ import "./shared/styles/account.css";
import "./shared/styles/market.css";
import "./shared/styles/market-workspace.css";
import "./shared/styles/market-insights.css";
import "./shared/styles/screener.css";
import "./shared/styles/system.css";
import "./shared/styles/mobile.css";
@@ -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>
+110
View File
@@ -0,0 +1,110 @@
import { api } from "./client";
export type FormulaCondition = { field: string; op: string; value: unknown };
export type FormulaScore = { field: string; weight: number; direction: "asc" | "desc" };
export type ScreenerFormula = {
universe: { exclude_st: boolean; listed_days_min: number };
filters: FormulaCondition[];
score: FormulaScore[];
limit: number;
min_score: number;
meta?: Record<string, string>;
};
export type ScreenerStrategy = {
id: string;
version: number;
kind: "stage" | "curated";
name: string;
display_name: string;
description: string;
regimes: string[];
formula: ScreenerFormula;
};
export type Candidate = {
identifier: string;
code: string;
name: string;
sector: string;
close: number | null;
pct_chg: number | null;
amount_billion: number | null;
score_display: number;
reason: string;
risk_flags: string[];
};
export type ScreenerRun = {
id: number;
mode: "stage" | "curated" | "custom";
strategy_id: string;
strategy_name: string;
selection_date: string;
status: "pending" | "running" | "completed" | "no_signal" | "data_incomplete" | "failed";
coverage: number;
missing_fields: string[];
items: Candidate[];
error_message: string;
};
export type CustomStrategy = {
id: number;
name: string;
version: number;
formula: ScreenerFormula;
};
export type ScreenerCatalog = {
factor_groups: Record<string, string[]>;
factors: Record<string, string>;
stage: ScreenerStrategy[];
curated: ScreenerStrategy[];
};
export type ScreenerWorkspace = {
trade_date: string | null;
message: string;
catalog: ScreenerCatalog;
stage_runs: ScreenerRun[];
curated_runs: ScreenerRun[];
custom_strategies: CustomStrategy[];
custom_runs: ScreenerRun[];
};
export type StrategyTrack = {
id: number;
code: string;
name: string;
sector: string;
selection_date: string;
strategy_name: string;
entry_price: number;
t1_open_return: number | null;
t1_return: number | null;
t3_return: number | null;
t5_return: number | null;
max_gain: number | null;
max_drawdown: number | null;
observed_days: number;
};
export const screenerApi = {
catalog(): Promise<ScreenerCatalog> {
return api.get("/screener/catalog");
},
workspace(date: string): Promise<ScreenerWorkspace> {
return api.get(`/screener?date=${encodeURIComponent(date)}`);
},
saveCustom(name: string, formula: ScreenerFormula): Promise<CustomStrategy> {
return api.put("/screener/custom", { name, formula });
},
deleteCustom(id: number): Promise<{ message: string }> {
return api.delete(`/screener/custom/${id}`);
},
runCustom(id: number, date: string): Promise<ScreenerRun> {
return api.post(`/screener/custom/${id}/run?date=${encodeURIComponent(date)}`);
},
tracks(): Promise<StrategyTrack[]> {
return api.get("/screener/tracks");
},
addTrack(runId: number, identifier: string): Promise<{ id: number }> {
return api.post("/screener/tracks", { run_id: runId, identifier });
},
removeTrack(id: number): Promise<{ message: string }> {
return api.delete(`/screener/tracks/${id}`);
},
};
@@ -0,0 +1,102 @@
.screener-page,
.tracking-page { display: grid; align-content: start; gap: var(--layout-gap); overflow-y: auto; }
.screener-page .page-header { margin-bottom: 0; }
.screener-page .page-actions { display: flex; align-items: center; gap: var(--s-10); }
.tracking-entry { min-height: var(--s-36); display: flex; align-items: center; gap: var(--s-8); padding: var(--s-4) var(--s-10); border: var(--s-1) solid var(--color-warning); border-radius: var(--control-radius); color: var(--color-warning); background: var(--color-warning-soft); }
.tracking-entry span { display: grid; place-items: center; width: var(--s-24); height: var(--s-24); border-radius: var(--radius-round); color: var(--color-surface); background: var(--color-warning); font-size: var(--font-10-5); }
.tracking-entry strong { font-size: var(--font-12); }
.stage-overview { overflow: hidden; }
.stage-heading { display: grid; grid-template-columns: var(--s-260) minmax(0, 1fr) auto; align-items: center; gap: var(--s-20); padding: var(--s-16) var(--s-20); }
.stage-heading > div { display: grid; gap: var(--s-4); }
.stage-heading > div span { color: var(--color-text-secondary); font-size: var(--font-11); }
.stage-heading > div strong { font-size: var(--font-18); }
.stage-heading p { color: var(--color-text-secondary); line-height: var(--s-20); }
.stage-flow { display: flex; align-items: center; justify-content: center; gap: var(--s-8); padding: var(--s-10) var(--s-20); border-top: var(--s-1) solid var(--color-divider); background: var(--color-surface-muted); }
.stage-flow span { color: var(--color-text-secondary); font-size: var(--font-11); }
.stage-flow span.done { color: var(--color-down); }
.stage-flow i { width: var(--s-44); height: var(--s-1); background: var(--color-border); }
.stage-run-tabs { display: flex; gap: var(--s-6); overflow-x: auto; padding: var(--s-8) var(--s-12); border-top: var(--s-1) solid var(--color-divider); }
.stage-run-tabs button { padding: var(--s-6) var(--s-10); border-radius: var(--control-radius); color: var(--color-text-secondary); background: var(--color-surface); }
.stage-run-tabs button.active { color: var(--color-primary); background: var(--color-primary-soft); }
.strategy-layout { display: grid; grid-template-columns: var(--s-360) minmax(0, 1fr); gap: var(--layout-gap); min-height: var(--s-360); }
.strategy-library { min-width: 0; overflow: hidden; }
.strategy-library .card-header .tag { margin-left: auto; }
.strategy-tools { display: grid; grid-template-columns: minmax(0, 1fr) var(--s-200) auto; gap: var(--s-6); padding: var(--s-8); border-bottom: var(--s-1) solid var(--color-divider); }
.strategy-tools .input,
.strategy-tools .select { min-height: var(--s-32); padding: var(--s-4) var(--s-8); }
.strategy-items { max-height: var(--s-320); overflow-y: auto; }
.strategy-items.is-list { display: grid; }
.strategy-items.is-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--s-6); padding: var(--s-6); }
.strategy-items > button { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--s-2) var(--s-8); padding: var(--s-8) var(--s-10); border-bottom: var(--s-1) solid var(--color-divider); background: var(--color-surface); text-align: left; }
.strategy-items.is-grid > button { border: var(--s-1) solid var(--color-border); border-radius: var(--control-radius); }
.strategy-items > button:hover,
.strategy-items > button.active { background: var(--color-primary-soft); }
.strategy-items strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.strategy-items small { grid-column: 1; color: var(--color-text-faint); }
.strategy-status { grid-column: 2; grid-row: 1 / span 2; align-self: center; color: var(--color-text-faint); font-size: var(--font-10-5); }
.strategy-status.completed { color: var(--color-up); }
.strategy-status.data_incomplete { color: var(--color-warning); }
.strategy-detail { min-width: 0; padding: var(--s-20); }
.strategy-detail > header { display: flex; justify-content: space-between; gap: var(--s-12); }
.strategy-detail > header div { display: grid; gap: var(--s-6); }
.strategy-detail > header span { color: var(--color-text-faint); font-size: var(--font-11); }
.strategy-detail h2 { font-size: var(--font-18); }
.strategy-detail > p { max-width: var(--s-dialog-wide); margin-top: var(--s-16); color: var(--color-text-secondary); line-height: var(--s-20); }
.strategy-detail dl { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--s-10); margin: var(--s-20) 0; }
.strategy-detail dl div { display: grid; gap: var(--s-6); padding: var(--s-10); border: var(--s-1) solid var(--color-border); border-radius: var(--control-radius); background: var(--color-surface-muted); }
.strategy-detail dt { color: var(--color-text-faint); font-size: var(--font-11); }
.strategy-detail dd { margin: 0; }
.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); }
.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; }
.screener-results .notice { margin: var(--s-12); }
.screener-empty { min-height: var(--s-64); display: grid; place-content: center; gap: var(--s-4); padding: var(--s-20); color: var(--color-text-secondary); text-align: center; }
.screener-empty strong { color: var(--color-text); }
.custom-builder { display: grid; grid-template-columns: minmax(0, 1fr) var(--s-320); overflow: hidden; }
.custom-factors { min-width: 0; border-right: var(--s-1) solid var(--color-divider); }
.custom-factors .card-header .tag { margin-left: auto; }
.factor-add { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--s-8); padding: var(--s-10); }
.factor-list { display: grid; gap: var(--s-1); background: var(--color-divider); }
.factor-list > div { display: grid; grid-template-columns: minmax(0, 1fr) var(--s-64) var(--s-200) var(--s-64) var(--s-32); align-items: center; gap: var(--s-10); padding: var(--s-8) var(--s-10); background: var(--color-surface); }
.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; }
.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,
.filter-list .select { min-height: var(--s-32); padding: var(--s-4) var(--s-6); }
.filter-list > .btn { width: fit-content; }
.custom-options { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--s-8); padding: var(--s-10); }
.switch-field { grid-column: 1 / -1; display: flex; align-items: center; gap: var(--s-8); color: var(--color-text-secondary); font-size: var(--font-12); }
.custom-save { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: var(--s-8); padding: 0 var(--s-10) var(--s-10); }
.custom-save .btn-primary { width: fit-content; }
.custom-library { overflow: hidden; }
.custom-strategy-list { display: flex; gap: var(--s-8); overflow-x: auto; padding: var(--s-10); }
.custom-strategy-list > button { min-width: var(--s-200); display: flex; justify-content: space-between; gap: var(--s-8); padding: var(--s-10); border: var(--s-1) solid var(--color-border); border-radius: var(--control-radius); background: var(--color-surface); }
.custom-strategy-list > button.active { border-color: var(--color-primary); background: var(--color-primary-soft); }
.custom-strategy-list span { color: var(--color-text-faint); font-size: var(--font-11); }
.custom-actions { display: flex; gap: var(--s-8); padding: 0 var(--s-10) var(--s-10); }
.tracking-page .page-header > div { display: grid; gap: var(--s-6); }
.tracking-page .page-header .btn { width: fit-content; padding-left: 0; }
.tracking-table { overflow: hidden; }
@media (max-width: 1023px) {
.screener-page .page-actions { width: 100%; align-items: stretch; flex-direction: column; }
.screener-page .seg-control { width: 100%; overflow-x: auto; }
.screener-page .seg-control button { flex: 1; }
.stage-heading,
.strategy-layout,
.custom-builder { grid-template-columns: minmax(0, 1fr); }
.stage-heading { gap: var(--s-8); }
.stage-flow { justify-content: flex-start; overflow-x: auto; }
.strategy-library { max-height: var(--s-400); }
.strategy-detail dl { grid-template-columns: minmax(0, 1fr); }
.custom-factors { border-right: 0; border-bottom: var(--s-1) solid var(--color-divider); }
.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); }
.custom-save { grid-template-columns: minmax(0, 1fr); }
}