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) => ` ${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"; 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) => ` `).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"); const publishedRun = strategy.published_run || {}; if (publishedRun.status === "missing_data") { status.innerHTML = `缺少必需数据${escapeHtml(publishedRun.detail || "等待后台同步")}`; } else if (publishedRun.status === "no_signal") { status.innerHTML = `数据完整,暂无信号必需数据已完整,本日没有股票同时满足 ${filters.length} 项准入条件`; } else if (result) { status.innerHTML = `盘后批次已发布${health.required_field_count != null ? `已核验 ${number(health.required_field_count)} 项因子 · ${number(health.complete_rows)} 只股票` : "盘后定格结果已载入"}`; } else if (!strategy.data_ready) { status.innerHTML = `数据尚未完备${escapeHtml((strategy.missing_data || []).join("、") || "等待后台同步")}`; } else { status.innerHTML = "等待成功批次运行中或失败的批次不会覆盖上一成功结果"; } 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) => ` ${group.fields.map((field) => ``).join("")} `).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(); } } 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 = "手动执行自定义选股结果独立保存,不会覆盖阶段选股或策略选股。"; notice.hidden = false; return; } if (!batch) { const statusText = { running: "盘后候选正在生成,完成前不会发布局部结果。", failed: "最近一次盘后任务失败,尚无可展示的成功批次。", partial: "最近一次盘后任务未完整完成,尚无可展示的成功批次。", pending: "盘后候选尚未生成。", }[requestStatus.status] || "尚无成功发布的盘后候选批次。"; notice.innerHTML = `尚未发布${escapeHtml(statusText)}`; 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 = `候选基准 ${escapeHtml(displayCompactDate(batch.trade_date))}${time}${escapeHtml(detail)}`; 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 ` ${escapeHtml(displayCompactDate(row.selection_date))} ${escapeHtml(row.name)}${escapeHtml(row.code)} ${escapeHtml(row.sector)} ${escapeHtml(row.status)} ${escapeHtml(matched)} ${row.score_display == null ? "" : formatNumber(row.score_display, 1)} ${escapeHtml(validity)} `; }).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) => ` ${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: "自定义选股" }; 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); }