feat: track screener candidates across five trading days
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user