window.XiaobaiPageModules.register("screener", ["screenerView"], {
enter: ["loadScreener"],
});
window.XiaobaiPageModules.register("screener", ["screenerTrackingView"]);
/* PRESERVATION-SOURCE-BEGIN app.js:3490-4336 */
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);
return key ? state.screenerResultStore[key] || null : 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);
if (dateChanged) {
state.screenerResults = { smart: null, curated: null, quant: null };
state.screenerResultContexts = { smart: null, curated: null, quant: null };
state.screenerResultStore = {};
}
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;
setText("screenerDateLabel", `数据日期 ${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) => `
${escapeHtml(item.label)}
`).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) => `${escapeHtml(regimeLabel(item))}`).join("")}${strategy.builtin ? "内置" : "自定义"}`
: "";
}
function selectScreenerMode(mode) {
state.screenerMode = ["smart", "curated", "quant"].includes(mode) ? mode : "smart";
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 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) => `
`).join("");
document.querySelector("#curatedSchoolFilters").innerHTML = schools.map((school) => {
const count = school === "全部" ? strategies.length : strategies.filter((item) => curatedStrategySchool(item) === school).length;
return ``;
}).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 `
${String(rank).padStart(2, "0")}${escapeHtml(strategy.name)}${escapeHtml(school)} · ${escapeHtml(meta.category || "策略")}${escapeHtml(runState.label)}
${escapeHtml(meta.quality || "--")}${escapeHtml(meta.frequency || "--")}风险 ${escapeHtml(meta.risk || "--")}
`;
}).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) => `${escapeHtml(value)}`).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) => `
${escapeHtml(factorLabel(item.field))}${escapeHtml(formatRuleValue(item))}
`).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 `${escapeHtml(factorLabel(item.field))}${formatNumber(percent, 0)}%
`;
}).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]) => `${escapeHtml(label)}${escapeHtml(value)}
`).join("");
const status = document.querySelector("#curatedDataStatus");
status.classList.toggle("missing", statusClass === "missing");
status.innerHTML = strategy.data_ready
? `${runState.verifiedEmpty ? "本日暂无信号" : "盘后自动更新"}${runState.verifiedEmpty ? `必需数据已完整,本日没有股票同时满足 ${filters.length} 项准入条件` : result ? health.required_field_count != null ? `已核验 ${number(health.required_field_count)} 项因子 · ${number(health.complete_rows)} 只股票` : "盘后定格结果已载入" : "等待当日行情定格后生成"}`
: `数据尚未完备${escapeHtml((strategy.missing_data || []).join("、") || "等待后台同步")}`;
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) => `
`).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) => `
`).join("");
document.querySelector("#quantScoreRows").innerHTML = state.quantScores.map((item) => `
✓
`).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) => `
`).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 <= 720) 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();
}
}
/* PRESERVATION-SOURCE-END app.js:3490-4336 */
/* PRESERVATION-SOURCE-BEGIN app.js:6668-6892 */
function renderScreenerResult() {
const mode = state.screenerMode || "smart";
const result = activeScreenerResult(mode);
const context = activeScreenerResultContext(mode);
const source = document.querySelector("#screenerResultSource");
const emptyMessages = {
smart: "当日盘后候选尚未生成",
curated: "所选策略的当日候选尚未生成",
quant: "尚未执行自定义选股",
};
if (!result) {
setText("screenerResultCount", "0 只");
source.hidden = true;
source.textContent = "";
setText("screenerDisclaimer", "历史统计不代表未来收益");
document.querySelector("#screenerTableBody").innerHTML = "";
document.querySelector("#screenerEmpty").textContent = emptyMessages[mode];
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");
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) => `
| ${index + 1} |
${escapeHtml(row.name)}${escapeHtml(row.code)} | ${escapeHtml(row.sector)} |
${formatNumber(row.score_display, 1)} |
${row.historical_probability === null ? "" : formatNumber(row.historical_probability, 1)}${number(row.probability_samples)} 个样本 |
${signed(row.pct_chg)} |
${signed(row.return_5d)} |
${formatNumber(row.volume_ratio_5d, 2)} | ${formatNumber(row.sector_strength, 1)} |
${escapeHtml(row.reason)} |
${escapeHtml(row.risk_flags.join(";"))} |
|
`).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]) => `${label}${value}
`).join("");
document.querySelector("#trackingEmpty").hidden = rows.length > 0;
document.querySelector("#trackingTableBody").innerHTML = rows.map((row) => `
| ${displayCompactDate(row.selection_date)} |
${escapeHtml(row.strategy_name)} |
${escapeHtml(row.name)}${escapeHtml(row.code)} |
${row.entry_price == null ? "" : formatNumber(row.entry_price, 2)} |
${["t1_open", "t1_close", "t3_close", "t5_close", "max_gain", "max_drawdown"].map((key) => `${trackingReturn(row[key], false)} | `).join("")}
${escapeHtml(row.status)} |
|
`).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]) => `${label}${value}
`).join("");
}
function parseFormulaEditor() {
try {
return JSON.parse(document.querySelector("#formulaEditor").value);
} catch {
throw new Error("受控公式不是有效的 JSON");
}
}
function exportScreenerResults() {
const modeLabels = { smart: "阶段选股", curated: "策略选股", quant: "量化选股" };
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;
}
/* PRESERVATION-SOURCE-END app.js:6668-6892 */