feat: expand screeners and stabilize interactive feedback

This commit is contained in:
leefer
2026-07-28 22:47:50 +08:00
parent f4b2d7152a
commit 1cc80583b3
22 changed files with 2707 additions and 509 deletions
+257 -207
View File
@@ -53,6 +53,10 @@ const state = {
rotationHistory: null,
rotationHistoryKey: "",
rotationSelectedSector: "",
rotationSelectedDate: "",
rotationMembers: null,
rotationMembersKey: "",
rotationMembersLoading: false,
rotationOrder: localStorage.getItem("xiaobaiRotationOrder") === "latest" ? "latest" : "oldest",
rotationLoading: false,
auctionData: null,
@@ -104,6 +108,7 @@ const state = {
screenerSetupPromise: null,
selectedRegime: "",
selectedStrategy: null,
customStrategyDraft: null,
screenerRunning: false,
screenerRunningMode: "",
screenerResults: { smart: null, curated: null, quant: null },
@@ -114,7 +119,9 @@ const state = {
? localStorage.getItem("xiaobaiScreenerMode")
: "smart",
curatedCategory: "全部",
curatedSchool: "全部",
curatedQuery: "",
curatedViewMode: localStorage.getItem("xiaobaiCuratedViewMode") === "grid" ? "grid" : "list",
selectedCuratedStrategyId: 0,
quantFilters: [],
quantScores: [],
@@ -519,8 +526,6 @@ async function applyAuthenticatedSession(session) {
document.querySelector("#syncButton").hidden = !isAdmin;
document.querySelector("#reasonForm").hidden = !isAdmin;
document.querySelector("#sectorPhaseManager").hidden = !isAdmin;
const factorSyncButton = document.querySelector("#factorSyncButton");
if (factorSyncButton) factorSyncButton.hidden = false;
document.querySelector("#authGate").hidden = true;
applyMembershipAccess();
await startAuthenticatedApp();
@@ -815,8 +820,7 @@ function bindEvents() {
}
renderAuctionTable();
});
document.querySelector("#changeStrategyButton").addEventListener("click", () => openStrategyDrawer("library"));
document.querySelector("#openStrategyDrawerButton").addEventListener("click", () => openStrategyDrawer("editor"));
document.querySelector("#openStrategyDrawerButton").addEventListener("click", openCustomStrategyDrawer);
document.querySelector("#closeStrategyDrawerButton").addEventListener("click", () => document.querySelector("#strategyDrawer").close());
document.querySelector("#strategyDrawer").addEventListener("click", (event) => {
if (event.target === event.currentTarget) event.currentTarget.close();
@@ -911,8 +915,6 @@ function bindEvents() {
document.querySelector("#stockReminderButton").addEventListener("click", openStockReminder);
document.querySelector("#reasonForm").addEventListener("submit", saveReasonOverride);
document.querySelector("#backfillButton").addEventListener("click", backfillData);
document.querySelector("#factorSyncButton").addEventListener("click", syncFactorData);
document.querySelector("#screenerRunButton").addEventListener("click", runScreener);
document.querySelector("#openScreenerTrackingButton").addEventListener("click", async () => {
await loadScreenerTracking(true);
openView("screenerTrackingView");
@@ -943,11 +945,22 @@ function bindEvents() {
state.curatedQuery = event.target.value.trim().toLocaleLowerCase("zh-CN");
renderCuratedStrategyLibrary();
});
document.querySelector("#curatedRunButton").addEventListener("click", runCuratedStrategy);
document.querySelector("#curatedBacktestToggle").addEventListener("change", updateBacktestTaskStatus);
document.querySelector("#closeCuratedDetailButton").addEventListener("click", () => document.querySelector("#curatedDetailDialog").close());
document.querySelector("#curatedDetailDialog").addEventListener("click", (event) => {
if (event.target === event.currentTarget) event.currentTarget.close();
document.querySelector("#curatedCategoryFilter").addEventListener("change", (event) => {
state.curatedCategory = event.target.value;
renderCuratedStrategyLibrary();
});
document.querySelector("#curatedSchoolFilters").addEventListener("click", (event) => {
const button = event.target.closest("[data-curated-school]");
if (!button) return;
state.curatedSchool = button.dataset.curatedSchool;
renderCuratedStrategyLibrary();
});
document.querySelectorAll("[data-curated-view]").forEach((button) => {
button.addEventListener("click", () => {
state.curatedViewMode = button.dataset.curatedView === "grid" ? "grid" : "list";
localStorage.setItem("xiaobaiCuratedViewMode", state.curatedViewMode);
renderCuratedStrategyLibrary();
});
});
document.querySelector("#quantResetButton").addEventListener("click", resetQuantBuilder);
document.querySelector("#addQuantFilterButton").addEventListener("click", () => addQuantFilter());
@@ -1161,7 +1174,7 @@ function renderDashboard() {
renderYesterdayTable(state.dashboard.yesterday_limits || []);
renderPerformance(state.dashboard.limit_performance || []);
renderLadderBoard(ladders || []);
renderRotationTable(state.dashboard.sector_rotation || [], sectors || []);
renderRotationMembers();
}
async function loadSentimentHistory(force = false) {
@@ -1787,6 +1800,7 @@ function updateYesterdayControls() {
}
function renderPerformance(rows) {
rows = normalizePerformanceRows(rows);
const currentDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value);
const previousDate = displayCompactDate(state.dashboard?.meta?.previous_trade_date || "");
setText("performanceDateRange", `昨日 ${previousDate} → 今日 ${currentDate}`);
@@ -1803,6 +1817,39 @@ function renderPerformance(rows) {
renderMarketBreadth(state.dashboard?.overview || {});
}
function normalizePerformanceRows(rows) {
const groups = new Map();
(rows || []).forEach((row) => {
const level = Math.max(1, number(row.level));
const displayLevel = Math.min(level, 5);
const group = groups.get(displayLevel) || {
level: displayLevel,
label: displayLevel === 1 ? "昨日首板" : displayLevel === 5 ? "昨日5板+" : `昨日${displayLevel}`,
count: 0,
advanced: 0,
positive: 0,
changeTotal: 0,
};
const count = number(row.count);
group.count += count;
group.advanced += number(row.advanced);
group.positive += count * number(row.positive_rate) / 100;
group.changeTotal += count * number(row.average_change);
groups.set(displayLevel, group);
});
return [...groups.values()]
.sort((left, right) => right.level - left.level)
.map((group) => ({
level: group.level,
label: group.label,
count: group.count,
advanced: group.advanced,
advance_rate: group.count ? group.advanced / group.count * 100 : 0,
positive_rate: group.count ? group.positive / group.count * 100 : 0,
average_change: group.count ? group.changeTotal / group.count : 0,
}));
}
function performanceRateState(rate) {
const value = number(rate);
if (value === 0) return { label: "失效", className: "is-neutral" };
@@ -1956,8 +2003,9 @@ function renderRotationHistory() {
<button class="rotation-track-cancel" type="button">取消追踪</button>`;
tracker.querySelector(".rotation-track-cancel").addEventListener("click", () => {
state.rotationSelectedSector = "";
state.rotationSelectedDate = "";
renderRotationHistory();
updateRotationTableSelection();
loadRotationMembers("");
});
} else {
tracker.hidden = true;
@@ -1974,7 +2022,7 @@ function renderRotationHistory() {
const strength = clamp(number(sector.strength), 0, 100);
const heatClass = strength >= 90 ? "heat-strong" : strength >= 70 ? "heat-warm" : "heat-mild";
return `
<button type="button" class="rotation-sector-chip ${heatClass} ${selected === sector.name ? "selected" : ""}" data-rotation-sector="${escapeHtml(sector.name)}">
<button type="button" class="rotation-sector-chip ${heatClass} ${selected === sector.name ? "selected" : ""}" data-rotation-sector="${escapeHtml(sector.name)}" data-rotation-date="${escapeHtml(day.trade_date)}">
<span class="rotation-rank rank-${Math.min(number(sector.rank), 4)}">${number(sector.rank)}</span><strong>${escapeHtml(sector.name)}</strong><small><b>${number(sector.count)}</b> · ${formatNumber(sector.strength, 0)}</small>
<span class="rotation-cell-tooltip">${escapeHtml(displayCompactDate(day.trade_date).slice(5))} · ${number(sector.rank)} · 涨停 ${number(sector.count)} · 强度 ${formatNumber(sector.strength, 0)}</span>
</button>`;
@@ -1983,58 +2031,76 @@ function renderRotationHistory() {
}).join("");
container.querySelectorAll("[data-rotation-sector]").forEach((button) => {
button.addEventListener("click", () => {
state.rotationSelectedSector = button.dataset.rotationSector === state.rotationSelectedSector
? ""
: button.dataset.rotationSector;
const clickedSector = button.dataset.rotationSector;
const clickedDate = button.dataset.rotationDate;
const isSameSelection = clickedSector === state.rotationSelectedSector
&& clickedDate === state.rotationSelectedDate;
state.rotationSelectedSector = isSameSelection ? "" : clickedSector;
state.rotationSelectedDate = isSameSelection ? "" : clickedDate;
renderRotationHistory();
updateRotationTableSelection();
loadRotationMembers(state.rotationSelectedSector);
});
});
}
function renderRotationTable(rows, sectors) {
const sectorMap = new Map(sectors.map((sector) => [sector.name, sector]));
async function loadRotationMembers(sector, force = false) {
if (!sector) {
state.rotationMembers = null;
state.rotationMembersKey = "";
renderRotationMembers();
return;
}
const memberDate = state.rotationSelectedDate || elements.tradeDate.value;
const key = `${memberDate}:${sector}`;
if (!force && state.rotationMembersKey === key && state.rotationMembers) {
renderRotationMembers();
return;
}
state.rotationMembersLoading = true;
renderRotationMembers();
try {
const query = new URLSearchParams({ trade_date: memberDate, sector });
state.rotationMembers = await apiRequest(`/api/rotation/members?${query}`);
state.rotationMembersKey = key;
} catch (error) {
state.rotationMembers = { error: error.message || "成分股加载失败", rows: [] };
state.rotationMembersKey = key;
} finally {
state.rotationMembersLoading = false;
renderRotationMembers();
}
}
function renderRotationMembers() {
const body = document.querySelector("#rotationTableBody");
const currentDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value);
setText("rotationDetailMeta", `${currentDate} · ${rows.length} 个板块`);
body.innerHTML = rows.map((row) => {
const sector = sectorMap.get(row.name) || {};
const strength = number(row.strength ?? sector.strength);
const currentCount = number(row.count);
const previousCount = number(row.previous_count);
const delta = row.delta == null ? currentCount - previousCount : number(row.delta);
const trend = previousCount === 0 && currentCount > 0
? "新进"
: currentCount > previousCount
? "升温"
: currentCount < previousCount ? "降温" : "持平";
return `
<tr class="rotation-detail-row ${state.rotationSelectedSector === row.name ? "selected" : ""}" data-rotation-detail-sector="${escapeHtml(row.name)}"><td class="number">${number(row.rank)}</td><td class="stock-name">${escapeHtml(row.name)}</td>
<td><span class="trend-tag ${trendClass(trend)}">${trend}</span></td>
<td class="number up">${currentCount}</td><td class="number muted">${previousCount}</td>
<td class="number ${delta >= 0 ? "delta-positive" : "delta-negative"}" data-sort-value="${delta}">${delta > 0 ? "+" : ""}${delta}</td>
<td data-sort-value="${strength}"><div class="rotation-strength"><span class="strength-cell"><i style="width:${clamp(strength, 0, 100)}%"></i></span><b>${formatNumber(strength, 0)}</b></div></td>
<td class="number streak-value">${streakLabel(row.max_streak || 1)}</td>
<td class="number ${changeClass(sector.change)}" data-sort-value="${number(sector.change)}">${signed(sector.change)}%</td>
<td>${escapeHtml(row.leader || sector.leader || "--")}</td><td class="number" data-sort-value="${number(row.amount_billion)}">${formatNumber(row.amount_billion, 1)} 亿</td></tr>
`;
}).join("");
body.querySelectorAll("[data-rotation-detail-sector]").forEach((row) => {
row.addEventListener("click", () => {
const sector = row.dataset.rotationDetailSector;
state.rotationSelectedSector = state.rotationSelectedSector === sector ? "" : sector;
renderRotationHistory();
updateRotationTableSelection();
document.querySelector("#rotationHistory").scrollIntoView({ behavior: "smooth", block: "center" });
});
});
const empty = document.querySelector("#rotationMembersEmpty");
if (state.rotationMembersLoading) {
body.innerHTML = "";
empty.textContent = `正在核验${state.rotationSelectedSector}成分股`;
empty.hidden = false;
return;
}
const payload = state.rotationMembers;
const rows = payload?.rows || [];
if (!state.rotationSelectedSector || !payload || payload.error || !rows.length) {
body.innerHTML = "";
empty.textContent = payload?.error || (state.rotationSelectedSector ? "该板块暂无可用成分行情" : "点击上方任意板块查看成分股");
empty.hidden = false;
setText("rotationDetailTitle", "板块成分股");
setText("rotationDetailMeta", state.rotationSelectedSector || "--");
return;
}
empty.hidden = true;
setText("rotationDetailTitle", `${payload.meta?.sector_name || state.rotationSelectedSector}成分股`);
setText("rotationDetailMeta", `${displayCompactDate(payload.meta?.trade_date)} · ${number(payload.meta?.quoted_count)} / ${number(payload.meta?.member_count)}`);
body.innerHTML = rows.map((row, index) => `
<tr data-code="${escapeHtml(row.code)}"><td class="number num muted">${index + 1}</td><td class="stock-code">${escapeHtml(row.code)}</td><td class="stock-name">${escapeHtml(row.name)}</td>
<td class="number num ${row.quoted ? changeClass(row.change) : "muted"}" data-sort-value="${row.quoted ? number(row.change) : -999}">${row.quoted ? signed(row.change) : ""}</td>
<td class="number num">${row.quoted ? formatNumber(row.open, 2) : ""}</td><td class="number num">${row.quoted ? formatNumber(row.close, 2) : ""}</td>
<td class="number num" data-sort-value="${number(row.amount_billion)}">${row.quoted ? formatNumber(row.amount_billion, 2) : ""}</td><td>${row.quoted ? "" : ""}</td></tr>
`).join("");
animateRows(body);
}
function updateRotationTableSelection() {
document.querySelectorAll("#rotationTableBody [data-rotation-detail-sector]").forEach((row) => {
row.classList.toggle("selected", row.dataset.rotationDetailSector === state.rotationSelectedSector);
});
bindStockRows(body);
}
function renderLadderMini(ladders) {
@@ -3498,12 +3564,7 @@ function applyScreenerSetup(payload, requestKey) {
if (!latestResults.smart && payload.latest_result) latestResults.smart = payload.latest_result;
const smartLatestMeta = latestResults.smart?.meta || {};
const curatedLatestMeta = latestResults.curated?.meta || {};
const availableRegimes = new Set((payload.regimes || []).map((item) => item.id));
if (!state.selectedRegime || dateChanged) {
state.selectedRegime = availableRegimes.has(smartLatestMeta.regime)
? smartLatestMeta.regime
: payload.regime.id;
}
state.selectedRegime = payload.regime.id;
const selectedId = state.selectedStrategy?.id;
const smartStrategies = payload.strategies.filter((item) => item.formula?.meta?.library !== "curated");
@@ -3617,13 +3678,9 @@ function renderScreenerSetup() {
const selector = document.querySelector("#regimeSelector");
selector.innerHTML = setup.regimes.map((item) => `
<button type="button" class="regime-option ${item.id === state.selectedRegime ? "active" : ""}" data-regime="${item.id}">${escapeHtml(item.label)}</button>
<span class="regime-option ${item.id === state.selectedRegime ? "active" : ""}">${escapeHtml(item.label)}</span>
`).join("");
selector.querySelectorAll("[data-regime]").forEach((button) => {
button.addEventListener("click", () => selectRegime(button.dataset.regime));
});
renderStrategyList();
populateStrategyEditor(state.selectedStrategy);
renderStrategySummary();
renderScreenerMode();
renderCuratedStrategyLibrary();
@@ -3663,7 +3720,7 @@ function renderScreenerMode() {
const results = document.querySelector("#screenerView .screener-results-view");
const resultsSlot = document.querySelector(`[data-screener-results-slot="${mode}"]`);
if (results && resultsSlot && results.parentElement !== resultsSlot) resultsSlot.append(results);
const titles = { smart: "候选结果", curated: "执行结果", quant: "打分结果" };
const titles = { smart: "盘后候选结果", curated: "策略候选结果", quant: "自定义选股结果" };
setText("screenerResultTitle", titles[mode]);
renderScreenerResult();
}
@@ -3677,56 +3734,83 @@ function activeCuratedStrategy() {
return strategies.find((item) => item.id === state.selectedCuratedStrategyId) || strategies[0] || null;
}
function curatedStrategySchool(strategy) {
const category = String(strategy?.formula?.meta?.category || "");
if (["红利价值", "质量价值", "现金流价值", "成长质量", "小盘质量"].includes(category)) return "基本面";
if (["行业轮动", "形态突破", "趋势追踪"].includes(category)) return "趋势";
if (["短线竞价", "连板接力", "低吸反核"].includes(category)) return "短线";
if (["动量反转"].includes(category)) return "动量";
if (/红利|价值|质量|成长|财务|现金流/.test(category)) return "基本面";
if (/趋势|轮动|突破/.test(category)) return "趋势";
if (/竞价|连板|龙头|反核|首阴|反包|打板/.test(category)) return "短线";
if (/动量|反转/.test(category)) return "动量";
return "其他";
}
function curatedSchoolIcon(school) {
return { 基本面: "circle-dollar-sign", 趋势: "trending-up", 短线: "zap", 动量: "refresh-cw", 其他: "boxes" }[school] || "boxes";
}
function curatedStrategyRunState(strategy, result) {
const missingData = strategy?.missing_data || [];
if (!strategy?.data_ready || missingData.length) {
return { label: "数据不足", className: "missing", verifiedEmpty: false };
}
if (!result) return { label: "等待盘后", className: "pending", verifiedEmpty: false };
const count = (result.candidates || []).length;
if (count) return { label: `${count} 只候选`, className: "ready", verifiedEmpty: false };
return { label: "暂无信号", className: "quiet", verifiedEmpty: true };
}
function renderCuratedStrategyLibrary() {
if (!state.screenerSetup) return;
const strategies = curatedStrategies();
const categories = ["全部", ...new Set(strategies.map((item) => item.formula?.meta?.category || "其他"))];
const schools = ["全部", "基本面", "趋势", "短线", "动量"];
if (!categories.includes(state.curatedCategory)) state.curatedCategory = "全部";
if (!schools.includes(state.curatedSchool)) state.curatedSchool = "全部";
setText("curatedStrategyCount", `${strategies.length}`);
const filters = document.querySelector("#curatedCategoryFilters");
filters.innerHTML = categories.map((category) => `
<button class="${category === state.curatedCategory ? "active" : ""}" type="button" data-curated-category="${escapeHtml(category)}">${escapeHtml(category)}</button>
const categorySelect = document.querySelector("#curatedCategoryFilter");
categorySelect.innerHTML = categories.map((category) => `
<option value="${escapeHtml(category)}" ${category === state.curatedCategory ? "selected" : ""}>${escapeHtml(category)}</option>
`).join("");
filters.querySelectorAll("[data-curated-category]").forEach((button) => {
button.addEventListener("click", () => {
state.curatedCategory = button.dataset.curatedCategory;
renderCuratedStrategyLibrary();
});
document.querySelector("#curatedSchoolFilters").innerHTML = schools.map((school) => {
const count = school === "全部" ? strategies.length : strategies.filter((item) => curatedStrategySchool(item) === school).length;
return `<button class="${school === state.curatedSchool ? "active" : ""}" type="button" data-curated-school="${escapeHtml(school)}" aria-pressed="${school === state.curatedSchool}">${escapeHtml(school)}<small>${count}</small></button>`;
}).join("");
document.querySelectorAll("[data-curated-view]").forEach((button) => {
const active = button.dataset.curatedView === state.curatedViewMode;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", String(active));
});
const query = state.curatedQuery;
const visible = strategies.filter((item) => {
const meta = item.formula?.meta || {};
const categoryMatch = state.curatedCategory === "全部" || meta.category === state.curatedCategory;
const queryMatch = !query || `${item.name} ${item.description} ${meta.category}`.toLocaleLowerCase("zh-CN").includes(query);
return categoryMatch && queryMatch;
const school = curatedStrategySchool(item);
const schoolMatch = state.curatedSchool === "全部" || school === state.curatedSchool;
const queryMatch = !query || `${item.name} ${item.description} ${meta.category} ${school} ${meta.suitable_environment} ${meta.failure_risk}`.toLocaleLowerCase("zh-CN").includes(query);
return categoryMatch && schoolMatch && queryMatch;
});
const list = document.querySelector("#curatedStrategyList");
list.classList.toggle("is-grid", state.curatedViewMode === "grid");
list.innerHTML = visible.length ? visible.map((strategy) => {
const meta = strategy.formula?.meta || {};
const school = curatedStrategySchool(strategy);
const rank = strategies.findIndex((item) => item.id === strategy.id) + 1;
const resultKey = screenerResultKey(screenerResultContext("curated", null, {
regime: strategy.regimes[0] || state.selectedRegime,
strategyId: strategy.id,
strategyName: strategy.name,
}));
const result = state.screenerResultStore[resultKey]?.result;
const runState = curatedStrategyRunState(strategy, result);
return `<article class="curated-strategy-card ${strategy.id === activeCuratedStrategy()?.id ? "active" : ""}" data-curated-strategy="${strategy.id}">
<span class="curated-card-head"><i class="curated-strategy-rank">${String(rank).padStart(2, "0")}</i><span><strong>${escapeHtml(strategy.name)}</strong><small>${escapeHtml(meta.category || "")}</small></span><i class="curated-ready-dot ${strategy.data_ready ? "" : "missing"}" title="${strategy.data_ready ? "" : escapeHtml((strategy.missing_data || []).join(""))}"></i></span>
<span class="curated-card-tags"><em>质量 ${escapeHtml(meta.quality || "--")}</em><em>${escapeHtml(meta.frequency || "--")}</em><em> ${escapeHtml(meta.risk || "--")}</em></span>
<span class="curated-card-description">${escapeHtml(strategy.description || "查看策略条件与适用环境。")}</span>
<span class="curated-card-foot"><small>${strategy.data_ready ? "数据已就绪" : `缺少 ${(strategy.missing_data || []).length} 项数据`}</small><span class="curated-card-actions"><button type="button" data-curated-inspect="${strategy.id}"></button><button type="button" class="primary" data-curated-run="${strategy.id}" ${strategy.data_ready ? "" : "disabled"}><i data-lucide="play"></i></button></span></span>
<span class="curated-strategy-icon" aria-hidden="true"><i data-lucide="${curatedSchoolIcon(school)}"></i></span>
<span class="curated-card-head"><i class="curated-strategy-rank">${String(rank).padStart(2, "0")}</i><span><strong>${escapeHtml(strategy.name)}</strong><small>${escapeHtml(school)} · ${escapeHtml(meta.category || "")}</small></span><em class="curated-card-result ${runState.className}">${escapeHtml(runState.label)}</em></span>
<span class="curated-card-tags"><em>${escapeHtml(meta.quality || "--")}</em><em>${escapeHtml(meta.frequency || "--")}</em><em> ${escapeHtml(meta.risk || "--")}</em></span>
</article>`;
}).join("") : '<div class="empty-state">没有符合条件的策略</div>';
list.querySelectorAll("[data-curated-inspect]").forEach((button) => {
button.addEventListener("click", () => {
state.selectedCuratedStrategyId = number(button.dataset.curatedInspect);
renderCuratedStrategyLibrary();
renderScreenerResult();
document.querySelector("#curatedDetailDialog").showModal();
});
});
list.querySelectorAll("[data-curated-run]").forEach((button) => {
button.addEventListener("click", () => {
state.selectedCuratedStrategyId = number(button.dataset.curatedRun);
renderCuratedStrategyLibrary();
renderScreenerResult();
runCuratedStrategy();
});
});
renderCuratedStrategyDetail();
}
@@ -3735,6 +3819,10 @@ function renderCuratedStrategyDetail() {
if (!strategy) return;
const formula = strategy.formula || {};
const meta = formula.meta || {};
const result = activeScreenerResult("curated");
const resultMeta = result?.meta || {};
const health = resultMeta.health || {};
const runState = curatedStrategyRunState(strategy, result);
setText("curatedStrategyCategory", meta.category || "精选策略");
setText("curatedStrategyName", strategy.name);
setText("curatedStrategyDescription", strategy.description);
@@ -3742,6 +3830,8 @@ function renderCuratedStrategyDetail() {
`质量 ${meta.quality || "--"}`, meta.frequency || "--", `风险 ${meta.risk || "--"}`,
meta.data_group || "行情因子",
].map((value) => `<span>${escapeHtml(value)}</span>`).join("");
setText("curatedSuitableEnvironment", meta.suitable_environment || "以策略条件为准");
setText("curatedFailureRisk", meta.failure_risk || "策略可能随市场结构变化而失效");
const filters = formula.filters || [];
setText("curatedFilterCount", `${filters.length}`);
document.querySelector("#curatedFilterList").innerHTML = filters.map((item) => `
@@ -3754,14 +3844,27 @@ function renderCuratedStrategyDetail() {
const percent = number(item.weight) / total * 100;
return `<div class="curated-score-row"><span>${escapeHtml(factorLabel(item.field))}</span><span class="curated-score-track"><i style="width:${Math.min(100, percent)}%"></i></span><strong>${formatNumber(percent, 0)}%</strong></div>`;
}).join("");
const candidateCount = (result?.candidates || []).length;
const statusLabel = runState.className === "ready" ? "运行正常" : runState.label;
const statusClass = runState.className;
let updatedLabel = "--";
if (resultMeta.updated_at) {
const updated = new Date(resultMeta.updated_at);
if (!Number.isNaN(updated.getTime())) {
updatedLabel = `${String(updated.getMonth() + 1).padStart(2, "0")}-${String(updated.getDate()).padStart(2, "0")} ${updated.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false })}`;
}
}
document.querySelector("#curatedHealthMetrics").innerHTML = [
["运行状态", statusLabel, statusClass],
["当日信号", result ? `${candidateCount}` : "--", ""],
["字段覆盖", health.coverage != null ? `${formatNumber(health.coverage, 1)}%` : strategy.data_ready ? "数据已就绪" : "--", ""],
["最近更新", updatedLabel, ""],
].map(([label, value, className]) => `<div><span>${escapeHtml(label)}</span><strong class="${className}">${escapeHtml(value)}</strong></div>`).join("");
const status = document.querySelector("#curatedDataStatus");
status.classList.toggle("missing", !strategy.data_ready);
status.classList.toggle("missing", statusClass === "missing");
status.innerHTML = strategy.data_ready
? '<i data-lucide="database"></i><span><strong>策略数据已就绪</strong><small>可按当前数据日期执行</small></span>'
: `<i data-lucide="circle-alert"></i><span><strong>需要补充数据</strong><small>${escapeHtml((strategy.missing_data || []).join("、") || "请同步因子")}</small></span>`;
const runButton = document.querySelector("#curatedRunButton");
runButton.disabled = !strategy.data_ready;
runButton.title = strategy.data_ready ? "执行当前策略" : `缺少${(strategy.missing_data || []).join("、")}`;
? `<i data-lucide="${runState.verifiedEmpty ? "circle-check" : "database"}"></i><span><strong>${runState.verifiedEmpty ? "本日暂无信号" : "盘后自动更新"}</strong><small>${runState.verifiedEmpty ? `必需数据已完整,本日没有股票同时满足 ${filters.length} 项准入条件` : result ? health.required_field_count != null ? `已核验 ${number(health.required_field_count)} 项因子 · ${number(health.complete_rows)} 只股票` : "盘后定格结果已载入" : "等待当日行情定格后生成"}</small></span>`
: `<i data-lucide="circle-alert"></i><span><strong>数据尚未完备</strong><small>${escapeHtml((strategy.missing_data || []).join("、") || "等待后台同步")}</small></span>`;
refreshIcons();
}
@@ -3973,6 +4076,22 @@ function openStrategyDrawer(target = "editor") {
});
}
function openCustomStrategyDrawer() {
if (!state.customStrategyDraft) {
state.customStrategyDraft = {
id: null,
builtin: false,
name: "自定义选股策略",
description: "",
regimes: [state.selectedRegime],
formula: buildQuantFormula(),
};
}
populateStrategyEditor(state.customStrategyDraft);
renderStrategyList();
openStrategyDrawer("editor");
}
function selectScreenerMobileView(view) {
state.screenerMobileView = view === "results" ? "results" : "strategy";
const workspace = document.querySelector("#screenerView");
@@ -3986,11 +4105,7 @@ function selectScreenerMobileView(view) {
}
function updateBacktestTaskStatus() {
const enabled = document.querySelector("#runBacktestToggle")?.checked;
const backtest = activeScreenerResult("smart")?.backtest;
setText("backtestTaskStatus", state.screenerRunning && state.screenerRunningMode === "smart" && enabled
? "正在回测"
: backtest ? `已完成 · ${number(backtest.samples)} 样本` : enabled ? "随选股执行" : "本次不执行");
setText("backtestTaskStatus", activeScreenerResult("smart") ? "结果已归档" : "等待盘后生成");
renderScreenerProgress();
}
@@ -4003,18 +4118,18 @@ function selectRegime(regime) {
function renderStrategyList() {
const list = document.querySelector("#strategyList");
const strategies = state.screenerSetup.strategies.filter((item) => item.formula?.meta?.library !== "curated");
const strategies = state.screenerSetup.strategies.filter((item) => !item.builtin && item.formula?.meta?.library !== "curated");
list.innerHTML = strategies.map((strategy) => `
<button type="button" class="strategy-item ${strategy.id === state.selectedStrategy?.id ? "active" : ""}" data-strategy-id="${strategy.id}">
<button type="button" class="strategy-item ${strategy.id === state.customStrategyDraft?.id ? "active" : ""}" data-strategy-id="${strategy.id}">
<strong>${escapeHtml(strategy.name)}</strong><span>${escapeHtml(strategy.description || "--")}</span>
<small>${strategy.regimes.map((item) => regimeLabel(item)).join(" / ")}${strategy.builtin ? " · 内置" : ""}</small>
<small>${strategy.regimes.map((item) => regimeLabel(item)).join(" / ")}</small>
</button>
`).join("");
`).join("") || '<div class="empty-state">暂无已保存的自定义公式</div>';
list.querySelectorAll("[data-strategy-id]").forEach((button) => {
button.addEventListener("click", () => {
state.selectedStrategy = state.screenerSetup.strategies.find((item) => item.id === number(button.dataset.strategyId));
state.selectedRegime = state.selectedStrategy.regimes[0] || state.selectedRegime;
renderScreenerSetup();
state.customStrategyDraft = state.screenerSetup.strategies.find((item) => item.id === number(button.dataset.strategyId));
populateStrategyEditor(state.customStrategyDraft);
renderStrategyList();
});
});
}
@@ -4033,32 +4148,6 @@ function populateStrategyEditor(strategy) {
document.querySelector("#formulaEditor").value = JSON.stringify(strategy.formula, null, 2);
}
async function syncFactorData() {
const button = document.querySelector("#factorSyncButton");
button.disabled = true;
setText("factorTaskStatus", "同步中");
setLoading(true, "正在同步 45 个交易日因子数据");
setStatus("正在同步选股因子");
try {
const payload = await apiRequest("/api/screener/sync", "POST", {
trade_date: elements.tradeDate.value,
lookback: 45,
});
const result = payload.result;
showToast(`因子同步完成:${result.calendar_dates} 个交易日,竞价覆盖 ${number(result.auction_dates)}`);
await loadScreenerSetup(true);
setText("factorTaskStatus", `已就绪 · ${number(result.calendar_dates)}`);
setStatus("选股因子已同步");
} catch (error) {
showToast(error.message);
setStatus("选股因子同步失败");
setText("factorTaskStatus", "同步失败");
} finally {
setLoading(false);
button.disabled = false;
}
}
async function compileStrategy() {
const prompt = document.querySelector("#strategyPrompt").value.trim();
const button = document.querySelector("#compileStrategyButton");
@@ -4071,9 +4160,8 @@ async function compileStrategy() {
regime: state.selectedRegime,
});
const strategy = payload.strategy;
state.selectedStrategy = { ...strategy, id: null, builtin: false };
state.customStrategyDraft = { ...strategy, id: null, builtin: false };
document.querySelector("#deleteStrategyButton").hidden = true;
renderStrategySummary();
document.querySelector("#strategyNameInput").value = strategy.name;
document.querySelector("#strategyDescriptionInput").value = strategy.description;
document.querySelector("#formulaEditor").value = JSON.stringify(strategy.formula, null, 2);
@@ -4099,8 +4187,9 @@ async function saveCurrentStrategy() {
formula,
});
state.screenerSetup.strategies = payload.strategies;
state.selectedStrategy = payload.strategies.find((item) => item.id === payload.id);
renderScreenerSetup();
state.customStrategyDraft = payload.strategies.find((item) => item.id === payload.id);
renderStrategyList();
populateStrategyEditor(state.customStrategyDraft);
showToast("自定义策略已保存");
} catch (error) {
showToast(error.message);
@@ -4108,7 +4197,7 @@ async function saveCurrentStrategy() {
}
async function deleteCurrentStrategy() {
const strategy = state.selectedStrategy;
const strategy = state.customStrategyDraft;
if (!strategy?.id || strategy.builtin) {
showToast("只能删除已保存的自定义策略");
return;
@@ -4120,10 +4209,9 @@ async function deleteCurrentStrategy() {
try {
const payload = await apiRequest(`/api/screener/strategies/${strategy.id}`, "DELETE");
state.screenerSetup.strategies = payload.strategies;
state.selectedStrategy = payload.strategies.find((item) => item.formula?.meta?.library !== "curated" && item.regimes.includes(state.selectedRegime))
|| payload.strategies.find((item) => item.formula?.meta?.library !== "curated")
|| null;
renderScreenerSetup();
state.customStrategyDraft = null;
renderStrategyList();
openCustomStrategyDrawer();
showToast("自定义策略已删除");
} catch (error) {
showToast(error.message || "策略删除失败");
@@ -4132,26 +4220,6 @@ async function deleteCurrentStrategy() {
}
}
async function runCuratedStrategy() {
const strategy = activeCuratedStrategy();
if (!strategy) return;
if (!strategy.data_ready) {
showToast(`请先同步${(strategy.missing_data || []).join("、")}`);
return;
}
const regime = strategy.regimes.includes(state.selectedRegime) ? state.selectedRegime : strategy.regimes[0];
await executeScreenerFormula({
mode: "curated",
formula: strategy.formula,
strategyName: strategy.name,
strategyId: strategy.id,
regime,
runBacktest: document.querySelector("#curatedBacktestToggle").checked,
button: document.querySelector("#curatedRunButton"),
loadingText: `正在执行“${strategy.name}”并计算历史样本`,
});
}
async function runQuantStrategy() {
let formula;
try {
@@ -4163,27 +4231,27 @@ async function runQuantStrategy() {
await executeScreenerFormula({
mode: "quant",
formula,
strategyName: "自定义量化公式",
strategyName: "自定义选股公式",
regime: state.selectedRegime,
runBacktest: document.querySelector("#quantBacktestToggle").checked,
button: document.querySelector("#quantRunButton"),
loadingText: "正在执行量化公式并计算因子贡献",
loadingText: "正在执行自定义公式并计算因子贡献",
});
}
function saveQuantAsStrategy() {
try {
const formula = buildQuantFormula();
state.selectedStrategy = {
state.customStrategyDraft = {
id: null,
builtin: false,
name: "自定义量化策略",
description: "由量化因子工作台生成,可在高级公式中继续调整。",
name: "自定义选股策略",
description: "由自定义因子工作台生成,可在高级公式中继续调整。",
regimes: [state.selectedRegime],
formula,
};
populateStrategyEditor(state.selectedStrategy);
document.querySelector("#strategyPrompt").value = "量化因子工作台生成的自定义公式";
populateStrategyEditor(state.customStrategyDraft);
document.querySelector("#strategyPrompt").value = "自定义因子工作台生成的选股公式";
openStrategyDrawer("editor");
} catch (error) {
showToast(error.message);
@@ -4239,26 +4307,6 @@ async function executeScreenerFormula({ mode, formula, strategyName, strategyId
}
}
async function runScreener() {
let formula;
try {
formula = parseFormulaEditor();
} catch (error) {
showToast(error.message);
return;
}
await executeScreenerFormula({
mode: "smart",
formula,
strategyName: document.querySelector("#strategyNameInput").value,
strategyId: state.selectedStrategy?.id,
regime: state.selectedRegime,
runBacktest: document.querySelector("#runBacktestToggle").checked,
button: document.querySelector("#screenerRunButton"),
loadingText: "正在计算因子排名与滚动回测",
});
}
async function loadMentorSetup(force = false) {
const requestedDate = elements.tradeDate.value.replaceAll("-", "");
if (!force && state.mentorSetup?.requestedDate === requestedDate) {
@@ -6619,9 +6667,9 @@ function renderScreenerResult() {
const context = activeScreenerResultContext(mode);
const source = document.querySelector("#screenerResultSource");
const emptyMessages = {
smart: "尚未执行当前阶段与策略的选股",
curated: "尚未执行所选策略",
quant: "尚未执行量化选股",
smart: "当日盘后候选尚未生成",
curated: "所选策略的当日候选尚未生成",
quant: "尚未执行自定义选股",
};
if (!result) {
setText("screenerResultCount", "0 只");
@@ -6639,9 +6687,9 @@ function renderScreenerResult() {
}
const candidates = result.candidates || [];
setText("screenerResultCount", `${candidates.length}`);
const modeLabels = { smart: "阶段选股", curated: "策略选股", quant: "量化选股" };
const modeLabels = { smart: "阶段选股", curated: "策略选股", quant: "自定义选股" };
const sourceParts = [modeLabels[mode]];
if (mode !== "quant" && context?.regime) sourceParts.push(regimeLabel(context.regime));
if (mode === "smart" && context?.regime) sourceParts.push(regimeLabel(context.regime));
sourceParts.push(mode === "quant" ? "自定义因子权重" : context?.strategyName || result.meta?.strategy_name || "未命名策略");
source.textContent = sourceParts.join(" · ");
source.hidden = false;
@@ -6652,7 +6700,9 @@ function renderScreenerResult() {
? `盘中行情 · 历史样本截至 ${displayCompactDate(meta.history_cutoff)} · ${result.disclaimer}`
: `盘后数据 ${displayCompactDate(meta.trade_date)} · ${result.disclaimer}`,
);
document.querySelector("#screenerEmpty").hidden = candidates.length > 0;
const empty = document.querySelector("#screenerEmpty");
empty.textContent = mode === "curated" ? "暂无符合条件个股" : emptyMessages[mode];
empty.hidden = candidates.length > 0;
const body = document.querySelector("#screenerTableBody");
const runId = number(meta.run_id);
body.innerHTML = candidates.map((row, index) => `