rebuild(stage-9): deliver deterministic intelligent screening
This commit is contained in:
@@ -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" },
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>>=</option><option>></option><option><=</option><option><</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>
|
||||
@@ -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); }
|
||||
}
|
||||
Reference in New Issue
Block a user