rebuild(runtime): govern market operations and job truth

This commit is contained in:
leefer
2026-07-30 10:14:29 +08:00
parent 4fc8691eee
commit d8f0dd930c
39 changed files with 2224 additions and 79 deletions
+11 -2
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed } from "vue";
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { operationsApi } from "../../shared/api/operations";
import { findWorkspace } from "../workspaceRegistry";
const route = useRoute();
@@ -9,12 +10,20 @@ const title = computed(() => {
if (typeof route.meta.title === "string") return route.meta.title;
return findWorkspace(String(route.params.workspace ?? ""))?.title ?? "小白复盘";
});
const runtimeMessage = ref("正在读取行情状态");
let timer: ReturnType<typeof setInterval> | undefined;
async function loadStatus(): Promise<void> {
try { runtimeMessage.value = (await operationsApi.status()).message; }
catch { runtimeMessage.value = "行情状态暂不可用"; }
}
onMounted(() => { void loadStatus(); timer = setInterval(loadStatus, 15_000); });
onBeforeUnmount(() => { if (timer) clearInterval(timer); });
</script>
<template>
<footer class="statusbar">
<span>{{ title }}</span>
<span class="statusbar-center">股市有风险投资需谨慎</span>
<span class="statusbar-right">等待行情数据</span>
<span class="statusbar-right">{{ runtimeMessage }}</span>
</footer>
</template>
@@ -2,6 +2,8 @@
import { onMounted, reactive, ref } from "vue";
import { api } from "../../shared/api/client";
import { operationsApi, type JobRun } from "../../shared/api/operations";
import { useMarketStore } from "../../shared/stores/market";
import { useUiStore } from "../../shared/stores/ui";
type CredentialStatus = { name: string; configured: boolean; updated_at: string | null };
@@ -12,6 +14,7 @@ const labels: Record<string, string> = {
ifind_access_token: "iFinD Access Token",
};
const ui = useUiStore();
const market = useMarketStore();
const statuses = ref<CredentialStatus[]>([]);
const values = reactive<Record<string, string>>({});
const loading = ref(true);
@@ -19,11 +22,36 @@ const errorMessage = ref("");
const syncing = ref(false);
const syncResult = ref("");
const syncError = ref("");
const jobs = ref<JobRun[]>([]);
const backfill = reactive({ start_date: market.selectedDate, end_date: market.selectedDate });
const backfilling = ref(false);
const operationMessage = ref("");
const event = reactive({
trade_date: market.selectedDate,
identifier: "",
event_type: "limit_up" as "limit_up" | "broken" | "limit_down",
reason: "",
first_time: "",
last_time: "",
open_times: null as number | null,
});
const eventBusy = ref(false);
const eventHistory = ref<Record<string, unknown>[]>([]);
const jobLabels: Record<string, string> = {
"market.refresh": "行情刷新",
"market.backfill": "历史回补",
"auction.collect": "竞价采集",
"market.event-supplement": "事件补充",
"screener.after-close": "盘后选股",
};
const statusLabels = { running: "执行中", completed: "已完成", failed: "失败" };
async function load(): Promise<void> {
loading.value = true;
try {
statuses.value = await api.get<CredentialStatus[]>("/admin/system/credentials");
jobs.value = await operationsApi.jobs();
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : "行情凭据读取失败。";
} finally {
@@ -31,6 +59,70 @@ async function load(): Promise<void> {
}
}
async function loadJobs(): Promise<void> {
try { jobs.value = await operationsApi.jobs(); }
catch (error) { ui.showToast(error instanceof Error ? error.message : "任务状态读取失败"); }
}
async function runBackfill(): Promise<void> {
backfilling.value = true;
operationMessage.value = "";
try {
const result = await operationsApi.backfill(backfill.start_date, backfill.end_date);
operationMessage.value = `已完成 ${Number(result.completed ?? 0)} 个交易日回补`;
ui.showToast("历史数据回补已完成");
await loadJobs();
} catch (error) {
ui.showToast(error instanceof Error ? error.message : "历史回补失败");
} finally { backfilling.value = false; }
}
async function supplementEvents(): Promise<void> {
eventBusy.value = true;
try {
const result = await operationsApi.supplementEvents(event.trade_date);
operationMessage.value = `事件原因已匹配 ${Number(result.matched ?? 0)}`;
ui.showToast("事件原因补充完成");
await loadJobs();
} catch (error) {
ui.showToast(error instanceof Error ? error.message : "事件补充暂不可用");
} finally { eventBusy.value = false; }
}
async function reviseEvent(): Promise<void> {
eventBusy.value = true;
try {
await operationsApi.reviseEvent(event.trade_date, event.identifier.trim(), {
event_type: event.event_type,
reason: event.reason,
first_time: event.first_time,
last_time: event.last_time,
open_times: event.open_times,
});
operationMessage.value = "人工修订已保存,并优先于自动补充内容";
ui.showToast("事件原因修订已保存");
await showEventHistory();
} catch (error) {
ui.showToast(error instanceof Error ? error.message : "事件修订失败");
} finally { eventBusy.value = false; }
}
async function showEventHistory(): Promise<void> {
if (!event.identifier.trim()) return;
try {
eventHistory.value = await operationsApi.eventHistory(
event.trade_date, event.identifier.trim(),
);
} catch (error) {
ui.showToast(error instanceof Error ? error.message : "修订记录读取失败");
}
}
function duration(value: number | null): string {
if (value === null) return "";
return value < 1000 ? `${value} ms` : `${(value / 1000).toFixed(1)} s`;
}
async function save(name: string): Promise<void> {
const value = values[name]?.trim();
if (!value) {
@@ -97,11 +189,32 @@ onMounted(load);
</div>
</section>
<section class="card">
<header class="card-header"><h2>历史数据回补</h2><span class="tag">暂不可用</span></header>
<div class="card-body disabled-row">
<p class="muted">历史数据回补将在行情数据迁移完成后开放</p>
<button class="btn" type="button" disabled>开始回补</button>
</div>
<header class="card-header"><h2>历史数据回补</h2><span class="faint">单次最多15个交易日</span></header>
<form class="card-body operation-form" @submit.prevent="runBackfill">
<label class="field"><span class="field-label">开始日期</span><input v-model="backfill.start_date" class="input" type="date" required /></label>
<label class="field"><span class="field-label">结束日期</span><input v-model="backfill.end_date" class="input" type="date" required /></label>
<button class="btn" type="submit" :disabled="backfilling">{{ backfilling ? "正在回补" : "开始回补" }}</button>
</form>
</section>
<section class="card">
<header class="card-header"><h2>事件原因治理</h2><span class="faint">人工修订优先并保留记录</span></header>
<form class="card-body event-form" @submit.prevent="reviseEvent">
<label class="field"><span class="field-label">数据日期</span><input v-model="event.trade_date" class="input" type="date" required /></label>
<label class="field"><span class="field-label">股票代码</span><input v-model="event.identifier" class="input" placeholder="000001.SZ" required /></label>
<label class="field"><span class="field-label">事件类型</span><select v-model="event.event_type" class="select"><option value="limit_up">涨停</option><option value="broken">炸板</option><option value="limit_down">跌停</option></select></label>
<label class="field event-reason"><span class="field-label">原因</span><input v-model="event.reason" class="input" maxlength="200" required /></label>
<label class="field"><span class="field-label">首次时间</span><input v-model="event.first_time" class="input" type="time" /></label>
<label class="field"><span class="field-label">最后时间</span><input v-model="event.last_time" class="input" type="time" /></label>
<label class="field"><span class="field-label">开板次数</span><input v-model.number="event.open_times" class="input" type="number" min="0" /></label>
<div class="table-actions event-actions"><button class="btn btn-primary" type="submit" :disabled="eventBusy">保存修订</button><button class="btn" type="button" :disabled="eventBusy" @click="supplementEvents">自动补充</button><button class="btn" type="button" @click="showEventHistory">查看记录</button></div>
</form>
<div v-if="eventHistory.length" class="data-table-wrap event-history"><table class="data-table"><thead><tr><th>时间</th><th>来源</th><th>类型</th><th>原因</th><th>修订人</th></tr></thead><tbody><tr v-for="row in eventHistory" :key="String(row.id)"><td>{{ String(row.created_at).replace('T', ' ').slice(0, 19) }}</td><td>{{ row.source === 'admin' ? '人工修订' : '自动补充' }}</td><td>{{ row.event_type }}</td><td>{{ row.reason }}</td><td>{{ row.created_by_name ?? '' }}</td></tr></tbody></table></div>
</section>
<section class="card">
<header class="card-header"><h2>后台任务状态</h2><button class="btn btn-small" type="button" @click="loadJobs">刷新状态</button></header>
<p v-if="operationMessage" class="notice">{{ operationMessage }}</p>
<div v-if="jobs.length" class="data-table-wrap operation-jobs"><table class="data-table"><thead><tr><th>任务</th><th>数据日期</th><th>状态</th><th>开始时间</th><th class="numeric">耗时</th><th class="numeric">覆盖率(%</th><th>说明</th></tr></thead><tbody><tr v-for="job in jobs" :key="job.id"><td>{{ jobLabels[job.kind] ?? job.kind }}</td><td>{{ job.requested_date }}</td><td><span class="tag" :class="{ warning: job.status === 'failed' }">{{ statusLabels[job.status] }}</span></td><td>{{ job.started_at.replace('T', ' ').slice(0, 19) }}</td><td class="numeric">{{ duration(job.duration_ms) }}</td><td class="numeric">{{ job.coverage === null ? '' : (job.coverage * 100).toFixed(1) }}</td><td>{{ job.error_message }}</td></tr></tbody></table></div>
<p v-else class="card-body muted">暂无后台任务记录</p>
</section>
</div>
</template>
@@ -19,13 +19,21 @@ function number(value: number | null, digits = 2): string {
</div>
<span v-if="run" class="tag">{{ run.items.length }} </span>
</header>
<div v-if="run?.status === 'data_incomplete'" class="notice notice-warning">
<div v-if="!run || run.status === 'pending'" class="screener-empty">
<strong>盘后选股尚未执行</strong>
<span>当日收盘行情定稿后由后台自动计算并归档</span>
</div>
<div v-else-if="run.status === 'running'" class="screener-empty">
<strong>正在计算候选结果</strong>
<span>完成后会自动保存本次结果</span>
</div>
<div v-else-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 v-else-if="run.status === 'failed'" class="notice notice-warning">
本次计算失败已隔离该策略不影响其他策略{{ run.error_message }}
</div>
<div v-else-if="!run || run.status === 'no_signal' || !run.items.length" class="screener-empty">
<div v-else-if="run.status === 'no_signal' || !run.items.length" class="screener-empty">
<strong>暂无符合条件个股</strong>
<span>完整数据下无信号会保留为空不补造候选</span>
</div>
@@ -9,7 +9,18 @@ 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));
const strategy = computed(() => props.strategies.find((item) => item.id === (run.value?.strategy_id ?? selected.value)));
const hasRun = computed(() => Boolean(run.value));
const runState = computed<"idle" | "running" | "failed" | "completed">(() => {
if (!run.value || run.value.status === "pending") return "idle";
if (run.value.status === "running") return "running";
if (run.value.status === "failed") return "failed";
return "completed";
});
const runStateLabel = computed(() => ({
idle: "未执行",
running: "执行中",
failed: "失败",
completed: "已完成",
})[runState.value]);
watch(
() => props.runs,
@@ -26,10 +37,13 @@ watch(
<header class="stage-heading">
<div><span>当前阶段自动候选</span><strong>{{ strategy?.display_name ?? "等待盘后判定" }}</strong></div>
<p>{{ strategy?.description ?? "每日收盘数据定稿后自动生成,结果允许为空。" }}</p>
<span class="tag">盘后自动</span>
<span class="tag" :class="{ warning: runState === 'failed' }">{{ runStateLabel }}</span>
</header>
<div class="stage-flow" aria-label="自动选股流程">
<span :class="{ done: hasRun }">阶段识别</span><i></i><span :class="{ done: hasRun }">策略匹配</span><i></i><span :class="{ done: hasRun }">自动计算</span><i></i><span :class="{ done: hasRun }">结果归档</span>
<span :class="{ done: runState !== 'idle' }">阶段识别</span><i></i>
<span :class="{ done: runState !== 'idle' }">策略匹配</span><i></i>
<span :class="{ done: runState === 'running' || runState === 'completed' }">自动计算</span><i></i>
<span :class="{ done: runState === 'completed' }">结果归档</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>
@@ -18,6 +18,18 @@ 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: "退潮" };
function statusLabel(strategyId: string): string {
const status = props.runs.find((item) => item.strategy_id === strategyId)?.status;
return ({
pending: "未执行",
running: "执行中",
completed: "有候选",
no_signal: "暂无信号",
data_incomplete: "数据不足",
failed: "计算失败",
} as Record<string, string>)[status ?? "pending"] ?? "未执行";
}
</script>
<template>
@@ -32,7 +44,7 @@ const regimeLabels: Record<string, string> = { ice: "冰点", repair: "修复",
<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>
<span :class="['strategy-status', runs.find((run) => run.strategy_id === item.id)?.status]">{{ statusLabel(item.id) }}</span>
</button>
</div>
</aside>
@@ -0,0 +1,60 @@
import { api } from "./client";
export type RuntimeStatus = {
state: "idle" | "running" | "ready" | "degraded";
message: string;
trade_date?: string | null;
observed_at?: string | null;
};
export type JobRun = {
id: number;
kind: string;
requested_date: string;
trigger: string;
status: "running" | "completed" | "failed";
attempt: number;
started_at: string;
finished_at: string | null;
duration_ms: number | null;
coverage: number | null;
error_message: string;
};
export type EventRevisionInput = {
event_type: "limit_up" | "broken" | "limit_down";
reason: string;
first_time: string;
last_time: string;
open_times: number | null;
};
export const operationsApi = {
status(): Promise<RuntimeStatus> {
return api.get("/operations/status");
},
jobs(): Promise<JobRun[]> {
return api.get("/admin/operations/jobs");
},
backfill(start_date: string, end_date: string): Promise<Record<string, unknown>> {
return api.post("/admin/operations/backfill", { start_date, end_date });
},
supplementEvents(date: string): Promise<Record<string, unknown>> {
return api.post(`/admin/operations/events/supplement?date=${encodeURIComponent(date)}`);
},
reviseEvent(
date: string,
identifier: string,
value: EventRevisionInput,
): Promise<Record<string, unknown>> {
return api.put(
`/admin/operations/events/${encodeURIComponent(date)}/${encodeURIComponent(identifier)}`,
value,
);
},
eventHistory(date: string, identifier: string): Promise<Record<string, unknown>[]> {
return api.get(
`/admin/operations/events/${encodeURIComponent(date)}/${encodeURIComponent(identifier)}/history`,
);
},
};
+8 -1
View File
@@ -229,11 +229,18 @@
}
.credential-row,
.selection-grid {
.selection-grid,
.operation-form,
.event-form {
grid-template-columns: 1fr;
align-items: stretch;
}
.event-reason,
.event-actions {
grid-column: auto;
}
.member-editor {
position: static;
}
@@ -82,6 +82,30 @@
flex: 1;
}
.operation-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, var(--s-200))) auto;
align-items: end;
gap: var(--s-10);
}
.event-form {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: end;
gap: var(--s-10);
}
.event-reason,
.event-actions {
grid-column: span 3;
}
.event-history,
.operation-jobs {
max-height: var(--s-260);
}
.model-list {
max-height: calc(100vh - var(--s-260));
overflow: auto;