feat: track screener candidates across five trading days

This commit is contained in:
leefer
2026-07-22 23:55:33 +08:00
parent 50dbb8a3ff
commit 5ae2791dd3
9 changed files with 510 additions and 1 deletions
+76
View File
@@ -43,6 +43,7 @@ const state = {
selectedRegime: "",
selectedStrategy: null,
screenerResult: null,
screenerTracking: null,
screenerMobileView: "strategy",
sentimentHistory: null,
sentimentRange: 20,
@@ -485,6 +486,7 @@ function bindEvents() {
document.querySelector("#backfillButton").addEventListener("click", backfillData);
document.querySelector("#factorSyncButton").addEventListener("click", syncFactorData);
document.querySelector("#screenerRunButton").addEventListener("click", runScreener);
document.querySelector("#refreshTrackingButton").addEventListener("click", refreshScreenerTracking);
document.querySelectorAll("[data-screener-mobile-view]").forEach((button) => {
button.addEventListener("click", () => selectScreenerMobileView(button.dataset.screenerMobileView));
});
@@ -1590,6 +1592,7 @@ async function loadScreenerSetup() {
|| null;
renderScreenerSetup();
if (state.screenerResult) renderScreenerResult();
await loadScreenerTracking();
} catch (error) {
showToast(error.message || "选股配置加载失败");
}
@@ -1805,6 +1808,7 @@ async function runScreener() {
});
state.screenerResult = payload.result;
renderScreenerResult();
await loadScreenerTracking(true);
setText("screenerRunStatus", `完成 · ${payload.result.candidates.length}`);
updateBacktestTaskStatus();
if (window.innerWidth <= 720) selectScreenerMobileView("results");
@@ -3557,6 +3561,78 @@ function renderScreenerResult() {
updateBacktestTaskStatus();
}
async function loadScreenerTracking(force = false) {
if (state.screenerTracking && !force) {
renderScreenerTracking();
return;
}
try {
state.screenerTracking = await apiRequest("/api/screener/tracking?limit=12");
renderScreenerTracking();
} catch (error) {
showToast(error.message || "策略跟踪加载失败");
}
}
async function refreshScreenerTracking() {
const button = document.querySelector("#refreshTrackingButton");
button.disabled = true;
setStatus("正在更新策略跟踪");
try {
const payload = await apiRequest("/api/screener/tracking/refresh", "POST", {
trade_date: elements.tradeDate.value,
});
state.screenerTracking = payload.tracking;
renderScreenerTracking();
if (payload.notice) showToast(payload.notice);
setStatus("策略跟踪已更新");
} catch (error) {
showToast(error.message || "策略跟踪刷新失败");
setStatus("策略跟踪刷新失败");
} finally {
button.disabled = false;
}
}
function renderScreenerTracking() {
const payload = state.screenerTracking || { batches: [], summary: {} };
const batches = payload.batches || [];
const rows = batches.flatMap((batch) => (batch.items || []).map((item) => ({
...item,
selection_date: batch.selection_date,
strategy_name: batch.strategy_name,
})));
setText("trackingBatchCount", `${batches.length}`);
const summary = payload.summary || {};
document.querySelector("#trackingSummary").innerHTML = [
["跟踪标的", `${number(summary.total)}`],
["已有 T+1", `${number(summary.observed)}`],
["T+1 胜率", trackingPercent(summary.t1_win_rate)],
["T+5 胜率", trackingPercent(summary.t5_win_rate)],
["T+5 平均", trackingReturn(summary.average_t5)],
].map(([label, value]) => `<div><span>${label}</span><strong>${value}</strong></div>`).join("");
document.querySelector("#trackingEmpty").hidden = rows.length > 0;
document.querySelector("#trackingTableBody").innerHTML = rows.map((row) => `
<tr data-code="${escapeHtml(row.code)}">
<td>${displayCompactDate(row.selection_date)}</td>
<td class="tracking-strategy" title="${escapeHtml(row.strategy_name)}">${escapeHtml(row.strategy_name)}</td>
<td><span class="stock-cell"><strong>${escapeHtml(row.name)}</strong><small class="stock-code">${escapeHtml(row.code)}</small></span></td>
<td class="number">${formatNumber(row.entry_price, 2)}</td>
${["t1_open", "t1_close", "t3_close", "t5_close", "max_gain", "max_drawdown"].map((key) => `<td class="number ${row[key] == null ? "" : changeClass(row[key])}">${trackingReturn(row[key])}</td>`).join("")}
<td><span class="tracking-status ${row.status === "已完成" ? "complete" : row.observed_days ? "active" : "pending"}">${escapeHtml(row.status)}</span></td>
</tr>
`).join("");
bindStockRows(document.querySelector("#trackingTableBody"));
}
function trackingReturn(value) {
return value == null ? "--" : `${signed(value)}%`;
}
function trackingPercent(value) {
return value == null ? "--" : `${formatNumber(value, 1)}%`;
}
function renderBacktest(backtest) {
const panel = document.querySelector("#backtestPanel");
panel.hidden = !backtest;
+15
View File
@@ -681,6 +681,7 @@
<div class="toolbar-controls">
<button id="factorSyncButton" class="button" type="button">同步因子数据</button>
<button id="screenerRunButton" class="button primary" type="button">执行选股</button>
<button id="refreshTrackingButton" class="button" type="button"><i data-lucide="refresh-cw"></i>刷新跟踪</button>
<button id="screenerExportButton" class="button" type="button">导出 CSV</button>
</div>
</div>
@@ -768,6 +769,20 @@
<div id="screenerEmpty" class="empty-state">尚未执行选股</div>
</div>
</div>
<section class="strategy-tracking-panel" aria-labelledby="strategyTrackingTitle">
<div class="section-toolbar result-toolbar">
<div class="section-title-group"><h2 id="strategyTrackingTitle">策略持续跟踪</h2><span id="trackingBatchCount" class="count-badge">0 批</span></div>
<span class="section-subtitle">以入选价为基准,跟踪后续五个交易日</span>
</div>
<div id="trackingSummary" class="tracking-summary"></div>
<div class="table-frame tracking-table-frame">
<table class="data-table tracking-table">
<thead><tr><th>入选日</th><th>策略</th><th>股票</th><th class="number">入选价</th><th class="number">T+1 开</th><th class="number">T+1 收</th><th class="number">T+3</th><th class="number">T+5</th><th class="number">最大涨幅</th><th class="number">最大回撤</th><th>状态</th></tr></thead>
<tbody id="trackingTableBody"></tbody>
</table>
<div id="trackingEmpty" class="empty-state">执行选股后自动开始跟踪</div>
</div>
</section>
</section>
<section id="mentorView" class="workspace-view member-feature-view">
+49
View File
@@ -11358,3 +11358,52 @@ button.account-role-badge:focus-visible { outline: 2px solid var(--blue); outlin
transition: none;
}
}
.strategy-tracking-panel {
margin-top: 18px;
border-top: 1px solid var(--border);
background: var(--surface);
}
.tracking-summary {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
background: var(--surface-muted);
}
.tracking-summary > div {
min-width: 0;
padding: 11px 16px;
border-right: 1px solid var(--border);
}
.tracking-summary > div:last-child { border-right: 0; }
.tracking-summary span { display: block; color: var(--text-secondary); font-size: 11px; }
.tracking-summary strong { display: block; margin-top: 4px; font-size: 15px; font-variant-numeric: tabular-nums; }
.tracking-table-frame { border: 0; border-radius: 0; }
.tracking-table { min-width: 1080px; }
.tracking-strategy { max-width: 170px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tracking-status {
display: inline-flex;
min-height: 24px;
align-items: center;
padding: 0 8px;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-secondary);
font-size: 11px;
white-space: nowrap;
}
.tracking-status.active { border-color: #c5d7ed; background: var(--action-soft); color: var(--action); }
.tracking-status.complete { border-color: #b9dfcf; background: var(--market-down-soft); color: var(--market-down); }
@media (max-width: 720px) {
.tracking-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.tracking-summary > div { border-bottom: 1px solid var(--border); }
.tracking-summary > div:nth-child(2n) { border-right: 0; }
.strategy-tracking-panel .section-toolbar { align-items: flex-start; }
}