Files
xiaobaifupan/app/frontend/pages/screener/page.js
T

1339 lines
65 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
window.XiaobaiPageModules.register("screener", ["screenerView"], {
bind: bindScreenerEvents,
enter: ["loadScreener"],
});
window.XiaobaiPageModules.register("screener", ["screenerTrackingView"]);
function screenerStrategyKey(strategyId, strategyName) {
return strategyId != null && strategyId !== 0 ? `id:${strategyId}` : `name:${strategyName || ""}`;
}
function currentScreenerStrategy(mode) {
if (mode === "curated") return activeCuratedStrategy();
if (mode === "smart") return state.selectedStrategy;
return null;
}
function screenerResultContext(mode, result, { regime, strategyId = null, strategyName = "" } = {}) {
const normalizedMode = ["smart", "curated", "quant"].includes(mode) ? mode : "smart";
return {
mode: normalizedMode,
regime: regime || result?.meta?.regime || state.selectedRegime,
strategyName: strategyName || result?.meta?.strategy_name || "",
strategyKey: screenerStrategyKey(
strategyId,
strategyName || result?.meta?.strategy_name || "",
),
};
}
function screenerResultKey(context) {
if (!context) return "";
if (context.mode === "quant") return "quant";
if (context.mode === "curated") return JSON.stringify(["curated", context.strategyKey]);
return JSON.stringify(["smart", context.regime, context.strategyKey]);
}
function selectedScreenerResultKey(mode) {
const normalizedMode = ["smart", "curated", "quant"].includes(mode) ? mode : "smart";
if (normalizedMode === "quant") return "quant";
const strategy = currentScreenerStrategy(mode);
if (!strategy) return "";
return screenerResultKey(screenerResultContext(normalizedMode, null, {
regime: state.selectedRegime,
strategyId: strategy.id,
strategyName: strategy.name,
}));
}
function activeScreenerResultEntry(mode = state.screenerMode) {
const key = selectedScreenerResultKey(mode);
const stored = key ? state.screenerResultStore[key] || null : null;
if (stored) return stored;
const normalizedMode = ["smart", "curated", "quant"].includes(mode) ? mode : "smart";
const latest = state.screenerResults[normalizedMode];
const context = state.screenerResultContexts[normalizedMode];
if (!latest || !context) return null;
if (normalizedMode === "quant") return { result: latest, context };
const selected = currentScreenerStrategy(normalizedMode);
return selected?.name === context.strategyName ? { result: latest, context } : null;
}
function activeScreenerResult(mode = state.screenerMode) {
return activeScreenerResultEntry(mode)?.result || null;
}
function activeScreenerResultContext(mode = state.screenerMode) {
return activeScreenerResultEntry(mode)?.context || null;
}
function storeScreenerResult(
mode,
result,
{ regime, strategyId = null, strategyName = "" } = {},
updateLatest = true,
) {
const normalizedMode = ["smart", "curated", "quant"].includes(mode) ? mode : "smart";
const context = result
? screenerResultContext(normalizedMode, result, { regime, strategyId, strategyName })
: null;
const key = screenerResultKey(context);
if (key && result) state.screenerResultStore[key] = { result, context };
if (updateLatest) {
state.screenerResults[normalizedMode] = result || null;
state.screenerResultContexts[normalizedMode] = context;
}
}
function setScreenerResult(mode, result, options = {}) {
storeScreenerResult(mode, result, options, true);
}
function applyScreenerSetup(payload, requestKey) {
const dateChanged = Boolean(state.screenerSetupKey && state.screenerSetupKey !== requestKey);
state.screenerResults = { smart: null, curated: null, quant: null };
state.screenerResultContexts = { smart: null, curated: null, quant: null };
state.screenerResultStore = {};
if (dateChanged) state.screenerArchiveView = "latest";
state.screenerSetup = payload;
state.screenerSetupKey = requestKey;
const latestResults = { ...(payload.latest_results || {}) };
if (!latestResults.smart && payload.latest_result) latestResults.smart = payload.latest_result;
const smartLatestMeta = latestResults.smart?.meta || {};
const curatedLatestMeta = latestResults.curated?.meta || {};
state.selectedRegime = payload.regime.id;
const selectedId = state.selectedStrategy?.id;
const smartStrategies = payload.strategies.filter((item) => item.formula?.meta?.library !== "curated");
const curatedStrategies = payload.strategies.filter((item) => item.formula?.meta?.library === "curated");
const latestSmartStrategy = smartStrategies.find((item) => item.name === smartLatestMeta.strategy_name);
state.selectedStrategy = smartStrategies.find((item) => item.id === selectedId)
|| latestSmartStrategy
|| smartStrategies.find((item) => item.regimes.includes(state.selectedRegime))
|| smartStrategies[0]
|| null;
if (!curatedStrategies.some((item) => item.id === state.selectedCuratedStrategyId) || dateChanged) {
state.selectedCuratedStrategyId = curatedStrategies.find(
(item) => item.name === curatedLatestMeta.strategy_name,
)?.id || curatedStrategies[0]?.id || 0;
}
for (const result of [...(payload.recent_results || [])].reverse()) {
const mode = ["smart", "curated", "quant"].includes(result.meta?.mode)
? result.meta.mode
: "smart";
const strategies = mode === "curated" ? curatedStrategies : smartStrategies;
const strategy = strategies.find((item) => item.name === result.meta?.strategy_name);
storeScreenerResult(mode, result, {
regime: result.meta?.regime || payload.regime.id,
strategyId: strategy?.id,
strategyName: result.meta?.strategy_name || strategy?.name || "",
}, false);
}
for (const mode of ["smart", "curated", "quant"]) {
if (state.screenerResults[mode] || !latestResults[mode]) continue;
const result = latestResults[mode];
const meta = result.meta || {};
const strategy = mode === "curated"
? curatedStrategies.find((item) => item.name === meta.strategy_name)
: mode === "smart"
? smartStrategies.find((item) => item.name === meta.strategy_name)
: null;
setScreenerResult(mode, result, {
regime: meta.regime || payload.regime.id,
strategyId: strategy?.id,
strategyName: meta.strategy_name || strategy?.name || "",
});
}
if (!state.quantScores.length) resetQuantBuilder(false);
renderScreenerSetup();
renderScreenerResult();
}
async function loadScreenerSetup(force = false) {
const requestKey = elements.tradeDate.value.replaceAll("-", "");
if (!force && state.screenerSetup && state.screenerSetupKey === requestKey) {
renderScreenerSetup();
renderScreenerResult();
return state.screenerSetup;
}
if (!force && state.screenerSetupPromise && state.screenerSetupRequestKey === requestKey) {
return state.screenerSetupPromise;
}
const request = (async () => {
try {
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
const payload = await apiRequest(`/api/screener/setup?${query}`);
applyScreenerSetup(payload, requestKey);
await loadScreenerTracking();
return payload;
} catch (error) {
showToast(error.message || "选股配置加载失败");
return null;
} finally {
if (state.screenerSetupPromise === request) {
state.screenerSetupPromise = null;
state.screenerSetupRequestKey = "";
}
}
})();
state.screenerSetupRequestKey = requestKey;
state.screenerSetupPromise = request;
return request;
}
function renderScreenerSetup() {
const setup = state.screenerSetup;
if (!setup) return;
const publishedDate = setup.published_batch?.trade_date;
setText(
"screenerDateLabel",
publishedDate && publishedDate !== setup.trade_date
? `数据日期 ${displayCompactDate(setup.trade_date)} · 候选批次 ${displayCompactDate(publishedDate)}`
: `数据日期 ${displayCompactDate(setup.trade_date)}`,
);
setText("regimeLabel", setup.regime.label);
setText("regimeConfidence", `置信度 ${formatNumber(setup.regime.confidence, 0)}%`);
setText("regimeStepStatus", `${setup.regime.label} · 置信度 ${formatNumber(setup.regime.confidence, 0)}%`);
setText("regimeReason", setup.regime.reason);
const evidence = (setup.regime.evidence || []).filter(Boolean);
if (!evidence.some((item) => String(item).includes("情绪温度"))) {
const temperature = formatNumber(state.dashboard?.overview?.sentiment_score, 0);
const direction = state.dashboard?.overview?.sentiment_direction;
evidence.unshift(`情绪温度 ${temperature}${direction ? `,较前一交易日${direction}` : ""}`);
}
document.querySelector("#regimeEvidenceList").textContent = evidence.join(" · ");
setText("factorDateCount", `${number(setup.factor_data.date_count)} 日`);
setText("factorDateRange", setup.factor_data.ready
? `${displayCompactDate(setup.factor_data.start_date)}${displayCompactDate(setup.factor_data.end_date)} · 竞价 ${number(setup.factor_data.auction_date_count)} 日`
: "尚未达到 21 个交易日");
setText("factorTaskStatus", setup.factor_data.ready ? `已就绪 · ${number(setup.factor_data.date_count)} 日` : "需要同步");
setText("compilerStatus", "策略生成已就绪");
setText(
"screenerRunStatus",
activeScreenerResult("smart") ? `已有结果 · ${(activeScreenerResult("smart").candidates || []).length} 只` : "等待执行",
);
setText("strategyCount", `${setup.strategies.filter((item) => item.formula?.meta?.library !== "curated").length} 套`);
updateBacktestTaskStatus();
selectScreenerMobileView(state.screenerMobileView);
const selector = document.querySelector("#regimeSelector");
selector.innerHTML = setup.regimes.map((item) => `
<span class="regime-option ${item.id === state.selectedRegime ? "active" : ""}">${escapeHtml(item.label)}</span>
`).join("");
renderStrategyList();
renderStrategySummary();
renderScreenerMode();
renderCuratedStrategyLibrary();
renderQuantBuilder();
renderScreenerProgress();
}
function renderStrategySummary() {
const strategy = state.selectedStrategy;
setText("activeStrategyHeading", strategy?.name || "--");
setText("activeStrategyEditorHeading", strategy?.name || "--");
setText("activeStrategyDescription", strategy?.description || "等待匹配当前市场阶段的策略。");
setText("strategyStepStatus", strategy?.name || "等待匹配");
document.querySelector("#activeStrategyRegimes").innerHTML = strategy
? `${strategy.regimes.map((item) => `<b>${escapeHtml(regimeLabel(item))}</b>`).join("")}<b class="neutral">${strategy.builtin ? "内置" : "自定义"}</b>`
: "";
}
function selectScreenerMode(mode) {
state.screenerMode = ["smart", "curated", "quant"].includes(mode) ? mode : "smart";
state.screenerArchiveView = "latest";
localStorage.setItem("xiaobaiScreenerMode", state.screenerMode);
state.screenerMobileView = "strategy";
renderScreenerMode();
selectScreenerMobileView("strategy");
}
function renderScreenerMode() {
const mode = state.screenerMode || "smart";
document.querySelectorAll("[data-screener-mode]").forEach((button) => {
const active = button.dataset.screenerMode === mode;
button.classList.toggle("active", active);
button.setAttribute("aria-selected", String(active));
});
document.querySelectorAll("[data-screener-panel]").forEach((panel) => {
panel.hidden = panel.dataset.screenerPanel !== mode;
});
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: "自定义选股结果" };
setText("screenerResultTitle", titles[mode]);
renderScreenerResult();
}
function curatedStrategies() {
return (state.screenerSetup?.strategies || []).filter((item) => item.formula?.meta?.library === "curated");
}
function activeCuratedStrategy() {
const strategies = curatedStrategies();
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 (["元策略", "多因子"].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 "动量";
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", 量化: "binary", 事件: "calendar-clock", 资金: "landmark", 其他: "boxes",
}[school] || "boxes";
}
function curatedStrategyRunState(strategy, result) {
const publishedRun = strategy?.published_run || {};
if (publishedRun.status === "missing_data") {
return { label: "数据不足", className: "missing", verifiedEmpty: false };
}
if (publishedRun.status === "no_signal") {
return { label: "暂无信号", className: "quiet", verifiedEmpty: true };
}
if (publishedRun.status === "ready") {
const count = (result?.candidates || []).length;
return { label: `${count} 只候选`, className: "ready", verifiedEmpty: false };
}
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 categorySelect = document.querySelector("#curatedCategoryFilter");
categorySelect.innerHTML = categories.map((category) => `
<option value="${escapeHtml(category)}" ${category === state.curatedCategory ? "selected" : ""}>${escapeHtml(category)}</option>
`).join("");
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 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-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("") : emptyStateHtml("没有符合条件的策略");
renderCuratedStrategyDetail();
}
function renderCuratedStrategyDetail() {
const strategy = activeCuratedStrategy();
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);
document.querySelector("#curatedStrategyBadges").innerHTML = [
`质量 ${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) => `
<div class="curated-rule-row"><span>${escapeHtml(factorLabel(item.field))}</span><strong>${escapeHtml(formatRuleValue(item))}</strong></div>
`).join("");
const scores = formula.score || [];
const total = scores.reduce((sum, item) => sum + number(item.weight), 0) || 1;
setText("curatedWeightTotal", `${formatNumber(total * 100, 0)}%`);
document.querySelector("#curatedScoreList").innerHTML = scores.map((item) => {
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", statusClass === "missing");
const publishedRun = strategy.published_run || {};
if (publishedRun.status === "missing_data") {
status.innerHTML = `<i data-lucide="circle-alert"></i><span><strong>缺少必需数据</strong><small>${escapeHtml(publishedRun.detail || "等待后台同步")}</small></span>`;
} else if (publishedRun.status === "no_signal") {
status.innerHTML = `<i data-lucide="circle-check"></i><span><strong>数据完整,暂无信号</strong><small>必需数据已完整,本日没有股票同时满足 ${filters.length} 项准入条件</small></span>`;
} else if (result) {
status.innerHTML = `<i data-lucide="database"></i><span><strong>盘后批次已发布</strong><small>${health.required_field_count != null ? `已核验 ${number(health.required_field_count)} 项因子 · ${number(health.complete_rows)} 只股票` : "盘后定格结果已载入"}</small></span>`;
} else if (!strategy.data_ready) {
status.innerHTML = `<i data-lucide="circle-alert"></i><span><strong>数据尚未完备</strong><small>${escapeHtml((strategy.missing_data || []).join("、") || "等待后台同步")}</small></span>`;
} else {
status.innerHTML = "<i data-lucide=\"clock-3\"></i><span><strong>等待成功批次</strong><small>运行中或失败的批次不会覆盖上一成功结果</small></span>";
}
refreshIcons();
}
function factorLabel(field) {
return state.screenerSetup?.factor_fields?.find((item) => item.id === field)?.label || field;
}
function formatRuleValue(item) {
const operator = { between: "介于", ">=": "不低于", "<=": "不高于", ">": "高于", "<": "低于", "==": "等于" }[item.op] || item.op;
const value = Array.isArray(item.value) ? item.value.join(" ") : item.value;
return `${operator} ${value}`;
}
function groupedFactorOptions(selected = "") {
return (state.screenerSetup?.factor_groups || []).map((group) => `
<optgroup label="${escapeHtml(group.name)}">${group.fields.map((field) => `<option value="${escapeHtml(field.id)}" ${field.id === selected ? "selected" : ""}>${escapeHtml(field.label)}</option>`).join("")}</optgroup>
`).join("");
}
function quantId() {
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function resetQuantBuilder(render = true) {
state.quantFilters = [
{ id: quantId(), field: "amount_billion", op: ">=", value: "1" },
{ id: quantId(), field: "above_ma20", op: "==", value: "1" },
];
state.quantScores = [
{ id: quantId(), field: "relative_strength", weight: 30, direction: "desc" },
{ id: quantId(), field: "sector_strength", weight: 25, direction: "desc" },
{ id: quantId(), field: "volume_ratio_5d", weight: 20, direction: "desc" },
{ id: quantId(), field: "amount_billion", weight: 15, direction: "desc" },
{ id: quantId(), field: "volatility_10d", weight: 10, direction: "asc" },
];
if (render) renderQuantBuilder();
}
function addQuantFilter() {
const used = new Set(state.quantFilters.map((item) => item.field));
const field = state.screenerSetup.factor_fields.find((item) => !used.has(item.id))?.id || "pct_chg";
state.quantFilters.push({ id: quantId(), field, op: ">=", value: "0" });
renderQuantBuilder();
}
function addQuantScore() {
const used = new Set(state.quantScores.map((item) => item.field));
const field = state.screenerSetup.factor_fields.find((item) => !used.has(item.id))?.id || "pct_chg";
state.quantScores.push({ id: quantId(), field, weight: 10, direction: "desc" });
renderQuantBuilder();
}
function renderQuantBuilder() {
if (!state.screenerSetup) return;
document.querySelector("#quantFilterRows").innerHTML = state.quantFilters.map((item) => `
<div class="quant-rule-row quant-filter-row" data-quant-filter="${item.id}">
<select aria-label="过滤因子" data-quant-key="field">${groupedFactorOptions(item.field)}</select>
<select aria-label="比较方式" data-quant-key="op">${(state.screenerSetup.operators || []).map((op) => `<option value="${op}" ${op === item.op ? "selected" : ""}>${escapeHtml({ between: "区间", ">=": "≥", "<=": "≤", ">": ">", "<": "<", "==": "=" }[op] || op)}</option>`).join("")}</select>
<input class="quant-value-input" data-quant-key="value" aria-label="条件值" type="text" value="${escapeHtml(item.value)}" placeholder="区间用逗号分隔">
<button class="quant-remove-button" type="button" data-quant-action="remove" aria-label="删除过滤条件"><i data-lucide="trash-2"></i></button>
</div>
`).join("");
document.querySelector("#quantScoreRows").innerHTML = state.quantScores.map((item) => `
<div class="quant-rule-row quant-score-row" data-quant-score="${item.id}">
<span class="quant-factor-identity"><i aria-hidden="true">✓</i><span><select aria-label="评分因子" data-quant-key="field">${groupedFactorOptions(item.field)}</select><button type="button" data-quant-action="direction" data-direction="${item.direction === "desc" ? "asc" : "desc"}">${item.direction === "desc" ? "数值越高越优" : "数值越低越优"}</button></span></span>
<label class="quant-weight-control">
<span class="visually-hidden">权重百分比</span>
<input data-quant-key="weight" aria-label="权重百分比" type="range" min="1" max="100" step="1" value="${number(item.weight)}">
<output>${number(item.weight)}%</output>
</label>
<button class="quant-remove-button" type="button" data-quant-action="remove" aria-label="删除评分因子"><i data-lucide="trash-2"></i></button>
</div>
`).join("");
renderQuantSummary();
refreshIcons();
}
function handleQuantBuilderInput(event) {
const row = event.target.closest("[data-quant-filter], [data-quant-score]");
const key = event.target.dataset.quantKey;
if (!row || !key) return;
const collection = row.dataset.quantFilter ? state.quantFilters : state.quantScores;
const id = row.dataset.quantFilter || row.dataset.quantScore;
const item = collection.find((entry) => entry.id === id);
if (!item) return;
item[key] = key === "weight" ? number(event.target.value) : event.target.value;
if (key === "weight") {
const output = event.target.closest(".quant-weight-control")?.querySelector("output");
if (output) output.textContent = `${number(event.target.value)}%`;
}
renderQuantSummary();
}
function handleQuantBuilderClick(event) {
const button = event.target.closest("[data-quant-action]");
if (!button) return;
const row = button.closest("[data-quant-filter], [data-quant-score]");
const isFilter = Boolean(row?.dataset.quantFilter);
const id = row?.dataset.quantFilter || row?.dataset.quantScore;
const collection = isFilter ? state.quantFilters : state.quantScores;
const item = collection.find((entry) => entry.id === id);
if (button.dataset.quantAction === "remove") {
if (!isFilter && collection.length <= 1) {
showToast("至少保留一个评分因子");
return;
}
const index = collection.findIndex((entry) => entry.id === id);
if (index >= 0) collection.splice(index, 1);
renderQuantBuilder();
} else if (button.dataset.quantAction === "direction" && item) {
item.direction = button.dataset.direction;
renderQuantBuilder();
}
}
function buildQuantFormula() {
const filters = state.quantFilters.map((item) => {
let value;
if (item.op === "between") {
value = String(item.value).split(/[,~]/).map((part) => Number(part.trim()));
if (value.length !== 2 || value.some((part) => !Number.isFinite(part))) throw new Error(`${factorLabel(item.field)}需要两个有效区间值`);
if (value[0] > value[1]) value.reverse();
} else {
value = Number(item.value);
if (!Number.isFinite(value)) throw new Error(`${factorLabel(item.field)}的条件值无效`);
}
return { field: item.field, op: item.op, value };
});
const score = state.quantScores.map((item) => {
const weight = number(item.weight) / 100;
if (weight <= 0 || weight > 1) throw new Error(`${factorLabel(item.field)}的权重应为1%至100%`);
return { field: item.field, weight, direction: item.direction };
});
return {
meta: { library: "custom", category: "量化公式", frequency: "按需", risk: "自定义", data_group: "组合因子" },
universe: {
exclude_st: document.querySelector("#quantExcludeSt").checked,
listed_days_min: Math.max(0, Math.min(5000, number(document.querySelector("#quantListedDays").value))),
},
filters,
score,
limit: Math.max(1, Math.min(50, number(document.querySelector("#quantLimit").value))),
min_score: Math.max(0, Math.min(1, number(document.querySelector("#quantMinScore").value) / 100)),
};
}
function formulaMissingData(formula) {
const health = state.screenerSetup?.factor_data?.health || {};
const fields = new Set([...(formula.filters || []), ...(formula.score || [])].map((item) => item.field));
const missing = [];
if (!state.screenerSetup?.factor_data?.ready) missing.push("基础行情");
if (["pe_ttm", "pb", "ps_ttm", "dividend_yield_ttm", "total_mv_billion"].some((field) => fields.has(field)) && !health.valuation) missing.push("估值数据");
if (["roe", "roa", "roic", "gross_margin", "netprofit_yoy", "revenue_yoy", "ocf_to_opincome"].some((field) => fields.has(field)) && !health.fundamental) missing.push("财务质量");
if (fields.has("dividend_years") && !health.dividend_history) missing.push("历年分红");
if (["auction_change", "auction_amount_million", "auction_turnover_rate", "auction_volume_ratio"].some((field) => fields.has(field)) && !health.auction) missing.push("竞价数据");
return missing;
}
function renderQuantSummary() {
if (!state.screenerSetup) return;
const total = state.quantScores.reduce((sum, item) => sum + number(item.weight), 0);
setText("quantWeightTotal", `${formatNumber(total, 0)}%`);
const bar = document.querySelector("#quantWeightBar");
bar.style.width = `${Math.min(100, total)}%`;
bar.style.background = Math.abs(total - 100) < 0.01 ? "#2563eb" : "#d97706";
const message = document.querySelector("#quantValidationMessage");
try {
const formula = buildQuantFormula();
const missing = formulaMissingData(formula);
message.classList.toggle("error", Boolean(missing.length));
message.textContent = missing.length ? `需要先同步:${missing.join("、")}` : "公式有效,可执行并生成逐股贡献解释。";
document.querySelector("#quantRunButton").disabled = Boolean(missing.length);
} catch (error) {
message.classList.add("error");
message.textContent = error.message;
document.querySelector("#quantRunButton").disabled = true;
}
}
function renderScreenerProgress() {
const hasSetup = Boolean(state.screenerSetup?.regime);
const hasStrategy = Boolean(state.selectedStrategy);
const hasResult = Boolean(activeScreenerResult("smart"));
const states = {
regime: hasSetup ? "complete" : "current",
strategy: hasStrategy ? "complete" : hasSetup ? "current" : "pending",
run: state.screenerRunning ? "current" : hasResult ? "complete" : hasStrategy ? "current" : "pending",
result: hasResult ? "current" : "pending",
};
const steps = [...document.querySelectorAll("[data-screener-step]")];
steps.forEach((step, index) => {
const status = states[step.dataset.screenerStep] || "pending";
step.dataset.state = status;
if (status === "current") step.setAttribute("aria-current", "step");
else step.removeAttribute("aria-current");
const line = step.nextElementSibling;
if (line?.classList.contains("step-line")) line.classList.toggle("complete", status === "complete" && index < steps.length - 1);
});
}
function openStrategyDrawer(target = "editor") {
const drawer = document.querySelector("#strategyDrawer");
openModalDialog(drawer);
requestAnimationFrame(() => {
const focusTarget = target === "library"
? document.querySelector("#strategyList .strategy-item.active") || document.querySelector("#strategyList .strategy-item")
: document.querySelector("#strategyNameInput");
focusTarget?.focus();
});
}
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");
workspace.classList.toggle("mobile-strategy", state.screenerMobileView === "strategy");
workspace.classList.toggle("mobile-results", state.screenerMobileView === "results");
document.querySelectorAll("[data-screener-mobile-view]").forEach((button) => {
const active = button.dataset.screenerMobileView === state.screenerMobileView;
button.classList.toggle("active", active);
button.setAttribute("aria-selected", String(active));
});
}
function updateBacktestTaskStatus() {
setText("backtestTaskStatus", activeScreenerResult("smart") ? "结果已归档" : "等待盘后生成");
renderScreenerProgress();
}
function renderStrategyList() {
const list = document.querySelector("#strategyList");
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.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(" / ")}</small>
</button>
`).join("") || emptyStateHtml("暂无已保存的自定义公式");
list.querySelectorAll("[data-strategy-id]").forEach((button) => {
button.addEventListener("click", () => {
state.customStrategyDraft = state.screenerSetup.strategies.find((item) => item.id === number(button.dataset.strategyId));
populateStrategyEditor(state.customStrategyDraft);
renderStrategyList();
});
});
}
function populateStrategyEditor(strategy) {
const deleteButton = document.querySelector("#deleteStrategyButton");
deleteButton.hidden = !strategy?.id || Boolean(strategy.builtin);
if (!strategy) {
setText("activeStrategyEditorHeading", "--");
return;
}
setText("activeStrategyEditorHeading", strategy.name || "未命名策略");
document.querySelector("#strategyNameInput").value = strategy.name || "";
document.querySelector("#strategyDescriptionInput").value = strategy.description || "";
document.querySelector("#strategyPrompt").value = strategy.builtin ? strategy.description || "" : document.querySelector("#strategyPrompt").value;
document.querySelector("#formulaEditor").value = JSON.stringify(strategy.formula, null, 2);
}
async function compileStrategy() {
const prompt = document.querySelector("#strategyPrompt").value.trim();
const button = document.querySelector("#compileStrategyButton");
button.disabled = true;
setText("compilerStatus", "正在编译");
setStatus("正在编译选股策略");
try {
const payload = await apiRequest("/api/screener/compile", "POST", {
prompt,
regime: state.selectedRegime,
});
const strategy = payload.strategy;
state.customStrategyDraft = { ...strategy, id: null, builtin: false };
document.querySelector("#deleteStrategyButton").hidden = true;
document.querySelector("#strategyNameInput").value = strategy.name;
document.querySelector("#strategyDescriptionInput").value = strategy.description;
document.querySelector("#formulaEditor").value = JSON.stringify(strategy.formula, null, 2);
setText("compilerStatus", "策略生成完成");
if (strategy.notice) showToast(strategy.notice);
setStatus("选股策略已编译");
} catch (error) {
showToast(error.message);
setStatus("策略编译失败");
setText("compilerStatus", "编译失败");
} finally {
button.disabled = false;
}
}
async function saveCurrentStrategy() {
try {
const formula = parseFormulaEditor();
const payload = await apiRequest("/api/screener/strategies", "POST", {
name: document.querySelector("#strategyNameInput").value,
description: document.querySelector("#strategyDescriptionInput").value,
regimes: [state.selectedRegime],
formula,
});
state.screenerSetup.strategies = payload.strategies;
state.customStrategyDraft = payload.strategies.find((item) => item.id === payload.id);
renderStrategyList();
populateStrategyEditor(state.customStrategyDraft);
showToast("自定义策略已保存");
} catch (error) {
showToast(error.message);
}
}
async function deleteCurrentStrategy() {
const strategy = state.customStrategyDraft;
if (!strategy?.id || strategy.builtin) {
showToast("只能删除已保存的自定义策略");
return;
}
if (!window.confirm(`确定删除策略“${strategy.name}”吗?此操作不可撤销。`)) return;
const button = document.querySelector("#deleteStrategyButton");
button.disabled = true;
try {
const payload = await apiRequest(`/api/screener/strategies/${strategy.id}`, "DELETE");
state.screenerSetup.strategies = payload.strategies;
state.customStrategyDraft = null;
renderStrategyList();
openCustomStrategyDrawer();
showToast("自定义策略已删除");
} catch (error) {
showToast(error.message || "策略删除失败");
} finally {
button.disabled = false;
}
}
async function runQuantStrategy() {
let formula;
try {
formula = buildQuantFormula();
} catch (error) {
showToast(error.message);
return;
}
await executeScreenerFormula({
mode: "quant",
formula,
strategyName: "自定义选股公式",
regime: state.selectedRegime,
runBacktest: document.querySelector("#quantBacktestToggle").checked,
button: document.querySelector("#quantRunButton"),
loadingText: "正在执行自定义公式并计算因子贡献",
});
}
function saveQuantAsStrategy() {
try {
const formula = buildQuantFormula();
state.customStrategyDraft = {
id: null,
builtin: false,
name: "自定义选股策略",
description: "由自定义因子工作台生成,可在高级公式中继续调整。",
regimes: [state.selectedRegime],
formula,
};
populateStrategyEditor(state.customStrategyDraft);
document.querySelector("#strategyPrompt").value = "自定义因子工作台生成的选股公式";
openStrategyDrawer("editor");
} catch (error) {
showToast(error.message);
}
}
async function executeScreenerFormula({ mode, formula, strategyName, strategyId = null, regime, runBacktest, button, loadingText }) {
if (!state.screenerSetup?.factor_data?.ready) {
showToast("请先同步至少 21 个交易日的因子数据");
return;
}
const missing = formulaMissingData(formula);
if (missing.length) {
showToast(`请先同步${missing.join("、")}`);
return;
}
const executionMode = ["smart", "curated", "quant"].includes(mode) ? mode : state.screenerMode;
button.disabled = true;
state.screenerRunning = true;
state.screenerRunningMode = executionMode;
if (executionMode === "smart") {
setText("screenerRunStatus", "正在计算");
setText("backtestTaskStatus", runBacktest ? "正在回测" : "本次不执行");
}
setLoading(true, loadingText, "screener");
setStatus(`正在执行${strategyName}`);
try {
const payload = await apiRequest("/api/screener/run", "POST", {
trade_date: elements.tradeDate.value,
regime,
strategy_name: strategyName,
formula,
mode: executionMode,
run_backtest: runBacktest,
});
setScreenerResult(executionMode, payload.result, { regime, strategyId, strategyName });
renderScreenerResult();
if (executionMode === "smart") setText("screenerRunStatus", `完成 · ${payload.result.candidates.length} 只`);
updateBacktestTaskStatus();
if (window.innerWidth <= 767) selectScreenerMobileView("results");
setStatus(`${strategyName}完成 · ${payload.result.candidates.length} 只候选`);
} catch (error) {
showToast(error.message);
setStatus("选股执行失败");
if (executionMode === "smart") setText("screenerRunStatus", "执行失败");
updateBacktestTaskStatus();
} finally {
state.screenerRunning = false;
state.screenerRunningMode = "";
setLoading(false);
button.disabled = false;
updateBacktestTaskStatus();
}
}
function renderScreenerResult() {
renderScreenerArchiveTabs();
renderScreenerBatchNotice();
if (state.screenerArchiveView === "latest") {
renderLatestScreenerResult();
} else {
renderScreenerArchiveResult();
}
}
function renderScreenerArchiveTabs() {
document.querySelectorAll("[data-screener-archive-view]").forEach((button) => {
const active = button.dataset.screenerArchiveView === state.screenerArchiveView;
button.classList.toggle("active", active);
button.setAttribute("aria-selected", String(active));
});
}
function renderScreenerBatchNotice() {
const notice = document.querySelector("#screenerBatchNotice");
const setup = state.screenerSetup || {};
const batch = setup.published_batch;
const requestStatus = setup.automatic_status || {};
notice.classList.toggle("warning", Boolean(batch?.is_fallback) || ["failed", "partial"].includes(requestStatus.status));
if (state.screenerMode === "quant") {
notice.innerHTML = "<strong>手动执行</strong><span>自定义选股结果独立保存,不会覆盖阶段选股或策略选股。</span>";
notice.hidden = false;
return;
}
if (!batch) {
const statusText = {
running: "盘后候选正在生成,完成前不会发布局部结果。",
failed: "最近一次盘后任务失败,尚无可展示的成功批次。",
partial: "最近一次盘后任务未完整完成,尚无可展示的成功批次。",
pending: "盘后候选尚未生成。",
}[requestStatus.status] || "尚无成功发布的盘后候选批次。";
notice.innerHTML = `<strong>尚未发布</strong><span>${escapeHtml(statusText)}</span>`;
notice.hidden = false;
return;
}
const completed = number(batch.completed_count);
const skipped = number(batch.skipped_count);
const detail = batch.notice
|| `盘后候选已完整发布${completed ? ` · 完成 ${completed} 套策略` : ""}${skipped ? ` · ${skipped} 套因数据不足跳过` : ""}`;
const time = batch.finished_at ? ` · ${formatTimestamp(batch.finished_at)}` : "";
notice.innerHTML = `<strong>候选基准 ${escapeHtml(displayCompactDate(batch.trade_date))}${time}</strong><span>${escapeHtml(detail)}</span>`;
notice.hidden = false;
}
function selectedScreenerArchiveRows() {
const mode = state.screenerMode || "smart";
const source = state.screenerArchiveView === "active"
? state.screenerSetup?.active_signals || []
: state.screenerSetup?.candidate_history || [];
const strategy = currentScreenerStrategy(mode);
return source.filter((row) => {
if (row.mode !== mode) return false;
if (mode === "quant") return true;
return (row.hits || []).some((hit) => hit.strategy_name === strategy?.name);
}).map((row) => {
let hits = row.hits || [];
if (mode !== "quant") hits = hits.filter((hit) => hit.strategy_name === strategy?.name);
if (state.screenerArchiveView === "active") hits = hits.filter((hit) => hit.active);
const latestHit = [...hits].sort((left, right) => String(right.selection_date).localeCompare(String(left.selection_date)))[0] || {};
const active = hits.some((hit) => hit.active);
const labels = [...new Set(hits.map((hit) => hit.validity?.label).filter(Boolean))];
return {
...row,
selection_date: latestHit.selection_date || row.selection_date,
score_display: latestHit.score_display ?? row.score_display,
matched_strategies: [...new Set(hits.map((hit) => hit.strategy_name).filter(Boolean))],
status: active ? "持续有效" : "已到期",
active,
validity_label: labels.join(" / ") || row.validity_label || "--",
valid_until: latestHit.valid_until || "",
remaining_trading_days: latestHit.remaining_trading_days,
};
});
}
function renderScreenerArchiveResult() {
const mode = state.screenerMode || "smart";
const rows = selectedScreenerArchiveRows();
setText("screenerResultTitle", state.screenerArchiveView === "active" ? "持续有效信号" : "历史入选记录");
const latestTable = document.querySelector("#screenerLatestTable");
const archiveTable = document.querySelector("#screenerArchiveTable");
latestTable.hidden = true;
archiveTable.hidden = false;
setText("screenerResultCount", `${rows.length} 只`);
const modeLabels = { smart: "阶段选股", curated: "策略选股", quant: "自定义选股" };
const strategy = currentScreenerStrategy(mode);
const source = document.querySelector("#screenerResultSource");
source.textContent = [modeLabels[mode], strategy?.name].filter(Boolean).join(" · ");
source.hidden = false;
setText(
"screenerDisclaimer",
state.screenerArchiveView === "active"
? "持续有效按策略频率与阶段变化计算,不代表未来收益"
: "历史入选仅保留当时策略结果,不使用未来数据回写",
);
const body = document.querySelector("#screenerArchiveTableBody");
body.innerHTML = rows.map((row) => {
const validity = row.valid_until
? `${row.validity_label} · 至 ${displayCompactDate(row.valid_until)}`
: row.validity_label;
const matched = (row.matched_strategies || []).join("、");
return `<tr data-code="${escapeHtml(row.code)}">
<td class="num">${escapeHtml(displayCompactDate(row.selection_date))}</td>
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>
<td>${escapeHtml(row.sector)}</td>
<td><span class="screener-signal-status ${row.active ? "active" : "expired"}">${escapeHtml(row.status)}</span></td>
<td class="screener-archive-strategies" title="${escapeHtml(matched)}">${escapeHtml(matched)}</td>
<td class="number num">${row.score_display == null ? "" : formatNumber(row.score_display, 1)}</td>
<td>${escapeHtml(validity)}</td>
</tr>`;
}).join("");
bindStockRows(body);
const empty = document.querySelector("#screenerEmpty");
empty.textContent = state.screenerArchiveView === "active"
? "当前策略暂无仍在有效期内的信号"
: "当前策略暂无历史入选记录";
empty.hidden = rows.length > 0;
renderBacktest(null);
updateBacktestTaskStatus();
}
function latestScreenerEmptyMessage(mode) {
if (mode === "quant") return "尚未执行自定义选股";
const strategy = currentScreenerStrategy(mode);
const publishedRun = strategy?.published_run || {};
if (publishedRun.status === "missing_data") {
return `缺少必需数据:${publishedRun.detail || "等待后台同步"}`;
}
if (publishedRun.status === "no_signal") return "数据完整,暂无符合条件个股";
const status = state.screenerSetup?.automatic_status?.status;
if (status === "running") return "盘后候选正在生成,成功发布前不会显示局部结果";
if (status === "failed") return "本次盘后任务失败,当前策略暂无已发布结果";
if (status === "partial") return "本次盘后任务未完整完成,当前策略暂无已发布结果";
return "尚无成功发布的盘后候选结果";
}
function renderLatestScreenerResult() {
const mode = state.screenerMode || "smart";
const titles = { smart: "盘后候选结果", curated: "策略候选结果", quant: "自定义选股结果" };
setText("screenerResultTitle", titles[mode]);
const result = activeScreenerResult(mode);
const context = activeScreenerResultContext(mode);
const source = document.querySelector("#screenerResultSource");
const emptyMessage = latestScreenerEmptyMessage(mode);
document.querySelector("#screenerLatestTable").hidden = false;
document.querySelector("#screenerArchiveTable").hidden = true;
if (!result) {
setText("screenerResultCount", "0 只");
source.hidden = true;
source.textContent = "";
setText("screenerDisclaimer", "历史统计不代表未来收益");
document.querySelector("#screenerTableBody").innerHTML = "";
document.querySelector("#screenerEmpty").textContent = emptyMessage;
document.querySelector("#screenerEmpty").hidden = false;
renderBacktest(null);
if (mode === "smart") setText("screenerRunStatus", "等待执行");
updateBacktestTaskStatus();
renderScreenerProgress();
return;
}
const candidates = result.candidates || [];
setText("screenerResultCount", `${candidates.length} 只`);
const modeLabels = { smart: "阶段选股", curated: "策略选股", quant: "自定义选股" };
const sourceParts = [modeLabels[mode]];
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;
const meta = result.meta || {};
setText(
"screenerDisclaimer",
meta.realtime
? `盘中行情 · 历史样本截至 ${displayCompactDate(meta.history_cutoff)} · ${result.disclaimer}`
: `盘后数据 ${displayCompactDate(meta.trade_date)} · ${result.disclaimer}`,
);
const empty = document.querySelector("#screenerEmpty");
const verifiedEmpty = mode === "curated" ? "暂无符合条件个股" : "数据完整,暂无符合条件个股";
empty.textContent = mode === "quant"
? emptyMessage
: `${verifiedEmpty}${mode === "curated" ? "(必需数据已完整)" : ""}`;
empty.hidden = candidates.length > 0;
const body = document.querySelector("#screenerTableBody");
const runId = number(meta.run_id);
body.innerHTML = candidates.map((row, index) => `
<tr data-code="${escapeHtml(row.code)}"><td class="row-number num muted">${index + 1}</td>
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td><td>${escapeHtml(row.sector)}</td>
<td class="number num streak-value">${formatNumber(row.score_display, 1)}</td>
<td class="number num probability-value"><strong>${row.historical_probability === null ? "" : formatNumber(row.historical_probability, 1)}</strong><small>${number(row.probability_samples)} 个样本</small></td>
<td class="number num ${changeClass(row.pct_chg)}">${signed(row.pct_chg)}</td>
<td class="number num ${changeClass(row.return_5d)}">${signed(row.return_5d)}</td>
<td class="number num">${formatNumber(row.volume_ratio_5d, 2)}</td><td class="number num">${formatNumber(row.sector_strength, 1)}</td>
<td class="reason-column" title="${escapeHtml(row.reason)}">${escapeHtml(row.reason)}</td>
<td class="risk-cell" title="${escapeHtml(row.risk_flags.join(""))}">${escapeHtml(row.risk_flags.join(""))}</td>
<td><span class="screener-row-actions"><button class="table-action" type="button" data-screen-detail="${escapeHtml(row.code)}">详情</button><button class="table-action tracking-action ${isCandidateTracked(runId, row.code) ? "tracked" : ""}" type="button" data-add-tracking="${escapeHtml(row.code)}" ${!runId || isCandidateTracked(runId, row.code) ? "disabled" : ""}>${isCandidateTracked(runId, row.code) ? "已跟踪" : "加入跟踪"}</button></span></td></tr>
`).join("");
body.querySelectorAll("[data-screen-detail]").forEach((button) => {
button.addEventListener("click", () => {
const row = candidates.find((item) => item.code === button.dataset.screenDetail);
openStock(row.code, row);
});
});
body.querySelectorAll("[data-add-tracking]").forEach((button) => {
button.addEventListener("click", () => addCandidateToTracking(button.dataset.addTracking, button));
});
bindStockRows(body);
renderBacktest(result.backtest);
if (mode === "smart") setText("screenerRunStatus", `完成 · ${candidates.length} 只`);
updateBacktestTaskStatus();
renderScreenerProgress();
}
async function loadScreenerTracking(force = false) {
if (state.screenerTracking && !force) {
renderScreenerTracking();
return;
}
try {
state.screenerTracking = await apiRequest("/api/screener/tracking?limit=12");
renderScreenerTracking();
if (activeScreenerResult()) renderScreenerResult();
} catch (error) {
showToast(error.message || "策略跟踪加载失败");
}
}
function isCandidateTracked(runId, code) {
if (!runId) return false;
return (state.screenerTracking?.batches || []).some((batch) =>
number(batch.run_id) === number(runId)
&& (batch.items || []).some((item) => item.code === code));
}
async function addCandidateToTracking(code, button) {
const runId = number(activeScreenerResult()?.meta?.run_id);
if (!runId) {
showToast("本次结果缺少选股批次,请重新执行后再加入跟踪");
return;
}
button.disabled = true;
try {
const payload = await apiRequest("/api/screener/tracking", "POST", { run_id: runId, code });
state.screenerTracking = payload.tracking;
renderScreenerTracking();
renderScreenerResult();
showToast(`${code} 已加入策略跟踪`);
} catch (error) {
button.disabled = false;
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,
run_id: batch.run_id,
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 class="sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>
<td class="number num">${row.entry_price == null ? "" : formatNumber(row.entry_price, 2)}</td>
${["t1_open", "t1_close", "t3_close", "t5_close", "max_gain", "max_drawdown"].map((key) => `<td class="number num ${row[key] == null ? "" : changeClass(row[key])}">${trackingReturn(row[key], false)}</td>`).join("")}
<td><span class="tracking-status ${row.status === "已完成" ? "complete" : row.observed_days ? "active" : "pending"}">${escapeHtml(row.status)}</span></td>
<td><button class="table-action danger" type="button" data-remove-tracking="${row.id}">移除</button></td>
</tr>
`).join("");
bindStockRows(document.querySelector("#trackingTableBody"));
}
async function handleTrackingTableAction(event) {
const button = event.target.closest("[data-remove-tracking]");
if (!button) return;
if (!window.confirm("确定停止跟踪这只股票吗?")) return;
button.disabled = true;
try {
const payload = await apiRequest(`/api/screener/tracking/${button.dataset.removeTracking}`, "DELETE");
state.screenerTracking = payload.tracking;
renderScreenerTracking();
if (activeScreenerResult()) renderScreenerResult();
showToast("已移出策略跟踪");
} catch (error) {
button.disabled = false;
showToast(error.message || "移除跟踪失败");
}
}
function trackingReturn(value, includeUnit = true) {
return value == null ? (includeUnit ? "--" : "") : `${signed(value)}${includeUnit ? "%" : ""}`;
}
function trackingPercent(value) {
return value == null ? "--" : `${formatNumber(value, 1)}%`;
}
function renderBacktest(backtest) {
const panel = document.querySelector("#backtestPanel");
panel.hidden = !backtest;
if (!backtest) return;
setText("backtestDefinition", backtest.definition);
document.querySelector("#backtestMetrics").innerHTML = [
["历史样本", `${number(backtest.samples)} 个`],
["条件胜率", `${formatNumber(backtest.win_rate, 1)}%`],
["平均3日收益", `${signed(backtest.average_3d_return)}%`],
["平均最大回撤", `${signed(backtest.average_drawdown)}%`],
].map(([label, value]) => `<div class="dragon-metric"><span>${label}</span><strong>${value}</strong></div>`).join("");
}
function parseFormulaEditor() {
try {
return JSON.parse(document.querySelector("#formulaEditor").value);
} catch {
throw new Error("受控公式不是有效的 JSON");
}
}
function exportScreenerResults() {
const modeLabels = { smart: "阶段选股", curated: "策略选股", quant: "自定义选股" };
if (state.screenerArchiveView !== "latest") {
const viewLabel = state.screenerArchiveView === "active" ? "持续有效" : "入选历史";
const rows = selectedScreenerArchiveRows().map((row) => ({
...row,
matched_strategy_names: (row.matched_strategies || []).join("、"),
}));
exportRows(`${modeLabels[state.screenerMode]}-${viewLabel}`, rows, [
["入选日", "selection_date"], ["股票代码", "code"], ["股票名称", "name"],
["板块", "sector"], ["状态", "status"], ["命中策略", "matched_strategy_names"],
["最新评分", "score_display"], ["有效期", "validity_label"],
]);
return;
}
exportRows(modeLabels[state.screenerMode] || "智能选股", activeScreenerResult()?.candidates || [], [
["股票代码", "code"], ["股票名称", "name"], ["板块", "sector"], ["综合分", "score_display"],
["历史条件估计%", "historical_probability"], ["当日涨幅%", "pct_chg"], ["5日涨幅%", "return_5d"],
["10日涨幅%", "return_10d"], ["量比", "volume_ratio_5d"], ["板块强度", "sector_strength"],
["主要贡献", "reason"], ["风险标记", "risk_flags"],
]);
}
function regimeLabel(regime) {
return state.screenerSetup?.regimes?.find((item) => item.id === regime)?.label || regime;
}
function bindScreenerEvents() {
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();
});
document.querySelector("#openScreenerTrackingButton").addEventListener("click", async () => {
await loadScreenerTracking(true);
openView("screenerTrackingView");
});
document.querySelector("#closeScreenerTrackingButton").addEventListener("click", () => openView("screenerView"));
document.querySelector("#refreshTrackingButton").addEventListener("click", refreshScreenerTracking);
document.querySelector("#trackingTableBody").addEventListener("click", handleTrackingTableAction);
document.querySelectorAll("[data-screener-mobile-view]").forEach((button) => {
button.addEventListener("click", () => selectScreenerMobileView(button.dataset.screenerMobileView));
});
document.querySelector("#compileStrategyButton").addEventListener("click", compileStrategy);
document.querySelector("#saveStrategyButton").addEventListener("click", saveCurrentStrategy);
document.querySelector("#deleteStrategyButton").addEventListener("click", deleteCurrentStrategy);
document.querySelector("#screenerExportButton").addEventListener("click", exportScreenerResults);
document.querySelector("#runBacktestToggle").addEventListener("change", updateBacktestTaskStatus);
document.querySelectorAll("[data-screener-mode]").forEach((button) => {
button.addEventListener("click", () => selectScreenerMode(button.dataset.screenerMode));
});
document.querySelectorAll("[data-screener-archive-view]").forEach((button) => {
button.addEventListener("click", () => {
state.screenerArchiveView = ["active", "history"].includes(button.dataset.screenerArchiveView)
? button.dataset.screenerArchiveView
: "latest";
renderScreenerResult();
});
});
document.querySelector("#curatedStrategyList").addEventListener("click", (event) => {
if (event.target.closest("button")) return;
const card = event.target.closest("[data-curated-strategy]");
if (!card) return;
state.selectedCuratedStrategyId = number(card.dataset.curatedStrategy);
renderCuratedStrategyLibrary();
renderScreenerResult();
});
document.querySelector("#curatedStrategySearch").addEventListener("input", (event) => {
state.curatedQuery = event.target.value.trim().toLocaleLowerCase("zh-CN");
renderCuratedStrategyLibrary();
});
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());
document.querySelector("#addQuantScoreButton").addEventListener("click", () => addQuantScore());
document.querySelector("#quantFilterRows").addEventListener("input", handleQuantBuilderInput);
document.querySelector("#quantFilterRows").addEventListener("change", handleQuantBuilderInput);
document.querySelector("#quantFilterRows").addEventListener("click", handleQuantBuilderClick);
document.querySelector("#quantScoreRows").addEventListener("input", handleQuantBuilderInput);
document.querySelector("#quantScoreRows").addEventListener("change", handleQuantBuilderInput);
document.querySelector("#quantScoreRows").addEventListener("click", handleQuantBuilderClick);
["quantListedDays", "quantLimit", "quantMinScore", "quantExcludeSt"].forEach((id) => {
document.querySelector(`#${id}`).addEventListener("input", renderQuantSummary);
document.querySelector(`#${id}`).addEventListener("change", renderQuantSummary);
});
document.querySelector("#quantRunButton").addEventListener("click", runQuantStrategy);
document.querySelector("#quantSaveButton").addEventListener("click", saveQuantAsStrategy);
document.querySelector("#quantBacktestToggle").addEventListener("change", updateBacktestTaskStatus);
}