feat: complete strategy and market data improvements

This commit is contained in:
leefer
2026-07-29 16:50:40 +08:00
parent c30d2107b3
commit 0030bb8cc1
18 changed files with 1622 additions and 162 deletions
+185 -40
View File
@@ -41,7 +41,7 @@ const state = {
yesterdayQuery: "",
yesterdaySortKey: "",
yesterdaySortDirection: "desc",
activeView: "limitPool",
activeView: "sentimentCycleView",
dragonTiger: null,
dragonViewMode: "daily",
dragonFilter: "all",
@@ -89,6 +89,8 @@ const state = {
entityDetailIntraday: null,
entityDetailRequestSequence: 0,
stockPreviewCode: "",
stockPreviewType: "stock",
stockPreviewItem: null,
stockPreviewPayload: null,
stockPreviewChart: "daily",
stockPreviewFallback: null,
@@ -195,7 +197,6 @@ const elements = {
globalSearchResults: document.querySelector("#globalSearchResults"),
entityDetailDialog: document.querySelector("#entityDetailDialog"),
entityDetailChart: document.querySelector("#entityDetailChart"),
themeDetailChart: document.querySelector("#themeDetailChart"),
settingsDialog: document.querySelector("#settingsDialog"),
adminDialog: document.querySelector("#adminDialog"),
priceChart: document.querySelector("#priceChart"),
@@ -366,9 +367,6 @@ function redrawThemeSensitiveVisuals() {
if (state.activeView === "sentimentCycleView" && state.sentimentHistory) {
drawSentimentTrendChart(state.sentimentHistory.rows || []);
}
if (state.activeView === "themeLibraryView" && state.themeDetail?.series) {
drawEntityDetailChart(state.themeDetail.series, elements.themeDetailChart);
}
if (state.activeView === "heavenView") {
if (state.heavenPanel === "fortune" && state.heavenSetup?.field) {
renderQiFieldCanvas(state.heavenSetup.field.balance || [], { intro: false });
@@ -430,11 +428,12 @@ async function initialize() {
syncThemeControl();
refreshIcons();
initializeApplicationShell();
const searchParams = new URLSearchParams(window.location.search);
const requestedDate = searchParams.get("date");
elements.tradeDate.value = /^\d{4}-\d{2}-\d{2}$/.test(requestedDate || "") && requestedDate <= todayString()
? requestedDate
: todayString();
elements.tradeDate.value = todayString();
const initialUrl = new URL(window.location.href);
if (initialUrl.searchParams.has("date")) {
initialUrl.searchParams.delete("date");
history.replaceState(null, "", initialUrl);
}
elements.tradeDate.max = todayString();
document.querySelector("#journalDate").value = elements.tradeDate.value;
document.querySelector("#journalDate").max = todayString();
@@ -581,7 +580,6 @@ function bindEvents() {
state.heavenRequestSequence += 1;
state.heavenManualData = null;
document.querySelector("#qiObservationDate").value = elements.tradeDate.value;
setDateInUrl(elements.tradeDate.value);
loadDashboard();
});
document.querySelector("#prevDate").addEventListener("click", () => shiftDate(-1));
@@ -673,6 +671,7 @@ function bindEvents() {
if (window.innerWidth > 720) toggleMentorDirectory(false);
if (!elements.stockPreview.hidden) closeStockPreview();
updateSidebarControl();
syncNavigationState(state.activeView);
if (state.activeView === "dragonView") layoutDragonCards();
});
document.querySelectorAll("[data-open-view]").forEach((button) => {
@@ -1273,14 +1272,6 @@ function renderSentimentHistory() {
setText("sentimentNormalization", `${latest.normalization} · 当前展示 ${rows.length}`);
const marker = document.querySelector("#sentimentCycleScoreMarker");
marker.className = `sentiment-current-phase-badge ${sentimentPhaseClass(latest.phase)}`;
document.querySelectorAll("[data-sentiment-stage]").forEach((item) => {
const current = item.dataset.sentimentStage === latest.phase;
item.classList.toggle("current", current);
item.hidden = !current;
const label = item.querySelector("strong");
if (label) label.textContent = `${item.dataset.sentimentStage}${current ? "(当前)" : ""}`;
});
document.querySelector("#sentimentComponentList").innerHTML = Object.values(latest.components || {}).map((item) => `
<article class="sentiment-component-item">
<div class="sentiment-component-main">
@@ -2330,7 +2321,7 @@ function scheduleAuctionTransition(meta) {
clearAuctionTimer();
if (state.activeView !== "auctionView") return;
let delay = 0;
if (meta.phase === "selection" && !meta.available) {
if (["selection", "finalized"].includes(meta.phase) && !meta.available) {
delay = 10_000;
} else if (meta.next_transition_at) {
const transitionAt = new Date(meta.next_transition_at).getTime();
@@ -2518,7 +2509,7 @@ function renderThemeDirectory() {
return `
<button type="button" class="theme-directory-item-v2 ${active ? "active" : ""}" data-theme-code="${escapeHtml(item.code)}" aria-pressed="${active}">
<span class="theme-rank-v2">${index + 1}</span>
<span class="theme-directory-copy-v2"><strong>${escapeHtml(item.name)}</strong><small>${number(item.member_count)} ${item.hot_rank ? ` · ${number(item.hot_rank)}` : ""}</small></span>
<span class="theme-directory-copy-v2"><strong class="market-preview-trigger" data-market-preview-type="theme" data-market-preview-id="${escapeHtml(item.code)}" title="悬停预览题材行情">${escapeHtml(item.name)}</strong><small>${number(item.member_count)} ${item.hot_rank ? ` · ${number(item.hot_rank)}` : ""}</small></span>
<b class="${changeClass(item.change)}">${item.has_quote ? `${signed(item.change)}%` : "--"}</b>
</button>`;
}).join("") || '<div class="empty-state">没有匹配的题材</div>';
@@ -2567,7 +2558,6 @@ function renderThemeDetail() {
<td class="number num ${changeClass(row.change)}">${row.has_quote ? signed(row.change) : ""}</td>
<td class="number num">${row.has_quote ? formatNumber(row.price, 2) : ""}</td><td class="number num">${row.has_quote ? formatNumber(row.amount_billion, 2) : ""}</td></tr>`).join("");
bindStockRows(body);
requestAnimationFrame(() => drawEntityDetailChart(payload.series || [], elements.themeDetailChart));
renderThemeDirectory();
}
@@ -3748,15 +3738,24 @@ function curatedStrategySchool(strategy) {
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", 其他: "boxes" }[school] || "boxes";
return {
基本面: "circle-dollar-sign", 趋势: "trending-up", 短线: "zap",
动量: "refresh-cw", 量化: "binary", 事件: "calendar-clock", 资金: "landmark", 其他: "boxes",
}[school] || "boxes";
}
function curatedStrategyRunState(strategy, result) {
@@ -3774,7 +3773,7 @@ function renderCuratedStrategyLibrary() {
if (!state.screenerSetup) return;
const strategies = curatedStrategies();
const categories = ["全部", ...new Set(strategies.map((item) => item.formula?.meta?.category || "其他"))];
const schools = ["全部", "基本面", "趋势", "短线", "动量"];
const schools = ["全部", "基本面", "趋势", "短线", "动量", "量化", "事件", "资金"];
if (!categories.includes(state.curatedCategory)) state.curatedCategory = "全部";
if (!schools.includes(state.curatedSchool)) state.curatedSchool = "全部";
setText("curatedStrategyCount", `${strategies.length}`);
@@ -7315,6 +7314,37 @@ function stockCodeFromTrigger(trigger) {
return matched ? matched[1] : "";
}
function marketPreviewTargetFromTrigger(trigger) {
if (trigger?.classList?.contains("market-preview-trigger")) {
const type = String(trigger.dataset.marketPreviewType || "").trim().toLowerCase();
const id = String(trigger.dataset.marketPreviewId || "").trim().toUpperCase();
if (type === "theme" && id) {
const item = (state.themeLibrary?.items || []).find((row) => String(row.code) === id) || {};
return {
type,
id,
code: id,
name: item.name || trigger.textContent?.trim() || "--",
type_label: "题材",
change: item.change,
value: item.close,
};
}
}
const code = stockCodeFromTrigger(trigger);
return code ? { type: "stock", id: code, code } : null;
}
function previewTriggerFromEvent(event) {
return event.target.closest?.(".stock-preview-trigger, .market-preview-trigger");
}
function showMarketPreview(target, trigger) {
if (!target) return;
if (target.type === "stock") showStockPreview(target.id, trigger);
else showEntityPreview(target, trigger);
}
function findStockFallback(code) {
const dashboardRows = [
...(state.dashboard?.limits || []),
@@ -7346,19 +7376,19 @@ function supportsStockPreviewHover() {
function handleStockPreviewPointerOver(event) {
if (!supportsStockPreviewHover()) return;
const trigger = event.target.closest?.(".stock-preview-trigger");
if (!trigger || trigger === event.relatedTarget?.closest?.(".stock-preview-trigger")) return;
const code = stockCodeFromTrigger(trigger);
if (!code) return;
const trigger = previewTriggerFromEvent(event);
if (!trigger || trigger === event.relatedTarget?.closest?.(".stock-preview-trigger, .market-preview-trigger")) return;
const target = marketPreviewTargetFromTrigger(trigger);
if (!target) return;
cancelStockPreviewClose();
clearTimeout(stockPreviewOpenTimer);
stockPreviewOpenTimer = setTimeout(() => showStockPreview(code, trigger), STOCK_PREVIEW_DELAY);
stockPreviewOpenTimer = setTimeout(() => showMarketPreview(target, trigger), STOCK_PREVIEW_DELAY);
}
function handleStockPreviewPointerOut(event) {
if (!supportsStockPreviewHover()) return;
const trigger = event.target.closest?.(".stock-preview-trigger");
if (!trigger || trigger === event.relatedTarget?.closest?.(".stock-preview-trigger")) return;
const trigger = previewTriggerFromEvent(event);
if (!trigger || trigger === event.relatedTarget?.closest?.(".stock-preview-trigger, .market-preview-trigger")) return;
clearTimeout(stockPreviewOpenTimer);
if (event.relatedTarget instanceof Node && elements.stockPreview.contains(event.relatedTarget)) return;
scheduleStockPreviewClose();
@@ -7424,6 +7454,8 @@ async function showStockPreview(code, trigger) {
if (!/^\d{6}$/.test(String(code))) return;
stockPreviewAnchor = trigger;
state.stockPreviewCode = String(code);
state.stockPreviewType = "stock";
state.stockPreviewItem = null;
state.stockPreviewFallback = findStockFallback(code);
state.stockPreviewPayload = null;
state.stockPreviewChart = "daily";
@@ -7461,8 +7493,80 @@ async function showStockPreview(code, trigger) {
}
}
async function showEntityPreview(item, trigger) {
const type = String(item?.type || "").trim().toLowerCase();
const id = String(item?.id || item?.code || "").trim().toUpperCase();
if (type !== "theme" || !id) return;
clearTimeout(stockPreviewOpenTimer);
cancelStockPreviewClose();
stockPreviewAnchor = trigger;
state.stockPreviewCode = id;
state.stockPreviewType = type;
state.stockPreviewItem = { ...item, id, code: item.code || id, type, type_label: item.type_label || "题材" };
state.stockPreviewFallback = {
code: item.code || id,
name: item.name || "--",
sector: item.type_label || "题材",
price: item.value,
change: item.change,
};
state.stockPreviewPayload = null;
state.stockPreviewChart = "daily";
renderStockPreviewLoading();
elements.stockPreview.hidden = false;
const mobile = window.innerWidth <= 720;
elements.stockPreviewBackdrop.hidden = !mobile;
document.body.classList.toggle("stock-preview-open", mobile);
requestAnimationFrame(repositionStockPreview);
const cacheKey = `${type}:${id}:latest`;
const cached = stockPreviewCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
renderStockPreview(cached.payload);
return;
}
if (cached) stockPreviewCache.delete(cacheKey);
stockPreviewAbortController?.abort();
stockPreviewAbortController = new AbortController();
try {
const params = new URLSearchParams({ type, id, trade_date: todayString() });
const detail = await apiRequest(
`/api/search/detail?${params}`,
"GET",
null,
{ signal: stockPreviewAbortController.signal },
);
if (state.stockPreviewType !== type || state.stockPreviewCode !== id || elements.stockPreview.hidden) return;
const entity = detail.entity || {};
const payload = {
stock: {
code: entity.code || id,
name: entity.name || item.name || "--",
industry: entity.type_label || item.type_label || "题材",
price: entity.value,
change: entity.change,
},
prices: detail.series || [],
intraday: [],
meta: {
trade_date: detail.meta?.trade_date || "",
realtime: Boolean(detail.meta?.realtime),
intraday_status: "idle",
intraday_notice: "",
},
};
stockPreviewCache.set(cacheKey, { payload, expiresAt: Date.now() + STOCK_PREVIEW_CACHE_MS });
while (stockPreviewCache.size > 48) stockPreviewCache.delete(stockPreviewCache.keys().next().value);
renderStockPreview(payload);
} catch (error) {
if (error.name === "AbortError" || state.stockPreviewType !== type || state.stockPreviewCode !== id) return;
renderStockPreviewError(error.message || "题材行情预览加载失败");
}
}
function renderStockPreviewLoading() {
const fallback = state.stockPreviewFallback || {};
selectStockPreviewChart("daily");
setText("stockPreviewCode", state.stockPreviewCode || "--");
setText("stockPreviewName", fallback.name || "正在加载");
setText("stockPreviewSector", fallback.sector || "--");
@@ -7510,6 +7614,16 @@ function selectStockPreviewChart(chart) {
const payload = state.stockPreviewPayload;
if (!payload) return;
if (state.stockPreviewChart === "intraday") {
if (state.stockPreviewType !== "stock" && payload.meta?.intraday_status === "idle") {
payload.meta.intraday_status = "loading";
setText("stockPreviewDate", "正在加载分时");
setText("stockPreviewSource", "正在读取最新分时");
setText("stockPreviewSummary", "等待分时行情数据");
clearStockPreviewChart("");
loadEntityPreviewIntraday();
return;
}
if (state.stockPreviewType !== "stock" && payload.meta?.intraday_status === "loading") return;
setText("stockPreviewDate", payload.meta?.intraday_trade_date || payload.meta?.trade_date || "最新行情");
setText(
"stockPreviewSource",
@@ -7538,6 +7652,36 @@ function selectStockPreviewChart(chart) {
}
}
async function loadEntityPreviewIntraday() {
const type = state.stockPreviewType;
const id = state.stockPreviewCode;
const payload = state.stockPreviewPayload;
if (type === "stock" || !id || !payload) return;
stockPreviewAbortController?.abort();
stockPreviewAbortController = new AbortController();
try {
const params = new URLSearchParams({ type, id });
const intraday = await apiRequest(
`/api/chart/intraday?${params}`,
"GET",
null,
{ signal: stockPreviewAbortController.signal },
);
if (state.stockPreviewType !== type || state.stockPreviewCode !== id || elements.stockPreview.hidden) return;
payload.intraday = intraday.points || [];
payload.meta.intraday_status = payload.intraday.length ? "available" : "empty";
payload.meta.intraday_trade_date = intraday.meta?.trade_date || "";
payload.meta.intraday_previous_close = intraday.meta?.previous_close || 0;
payload.meta.intraday_notice = payload.intraday.length ? "" : "该题材暂无可用分时数据。";
if (state.stockPreviewChart === "intraday") selectStockPreviewChart("intraday");
} catch (error) {
if (error.name === "AbortError" || state.stockPreviewType !== type || state.stockPreviewCode !== id) return;
payload.meta.intraday_status = "unavailable";
payload.meta.intraday_notice = error.message || "题材分时行情暂不可用。";
if (state.stockPreviewChart === "intraday") selectStockPreviewChart("intraday");
}
}
function closeStockPreview() {
clearTimeout(stockPreviewOpenTimer);
clearTimeout(stockPreviewCloseTimer);
@@ -7548,14 +7692,19 @@ function closeStockPreview() {
document.body.classList.remove("stock-preview-open");
state.stockPreviewPayload = null;
state.stockPreviewCode = "";
state.stockPreviewType = "stock";
state.stockPreviewItem = null;
}
function openStockDetailFromPreview() {
const code = state.stockPreviewCode;
const fallback = state.stockPreviewFallback;
const type = state.stockPreviewType;
const item = state.stockPreviewItem;
if (!code) return;
closeStockPreview();
openStock(code, fallback);
if (type === "stock") openStock(code, fallback);
else if (item) openEntityDetail(item);
}
function repositionStockPreview() {
@@ -8484,16 +8633,9 @@ function shiftDate(delta) {
elements.tradeDate.value = next;
state.heavenManualData = null;
document.querySelector("#qiObservationDate").value = next;
setDateInUrl(next);
loadDashboard();
}
function setDateInUrl(value) {
const url = new URL(window.location.href);
url.searchParams.set("date", value);
history.replaceState(null, "", url);
}
function updateDateButtons() {
document.querySelector("#nextDate").disabled = elements.tradeDate.value >= todayString();
}
@@ -9198,7 +9340,10 @@ function syncNavigationState(viewId) {
button.dataset.view === viewId
|| (viewId === "screenerTrackingView" && button.dataset.view === "screenerView"),
);
button.classList.remove("mobile-active");
button.classList.toggle(
"mobile-active",
window.innerWidth <= 720 && marketView && button.dataset.view === "limitPool" && viewId !== "limitPool",
);
});
const selector = document.querySelector("#mobileMarketSelector");
const select = document.querySelector("#mobileMarketViewSelect");