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