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