Files
xiaobaifupan/app/frontend/pages/auction/page.js
T

314 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
window.XiaobaiPageModules.register("auction", ["auctionView"], {
bind: bindAuctionEvents,
enter: ["loadAuction"],
leave: ["clearAuction"],
});
async function loadAuctionCenter(force = false) {
if (state.auctionLoading) return;
state.auctionLoading = true;
const button = document.querySelector("#auctionRefreshButton");
button.disabled = true;
setText("auctionDateLabel", "正在读取竞价数据");
try {
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
if (force) query.set("force", "1");
state.auctionData = await apiRequest(`/api/auction?${query}`);
renderAuctionCenter();
scheduleAuctionTransition(state.auctionData.meta || {});
} catch (error) {
document.querySelector("#auctionSummary").innerHTML = "";
document.querySelector("#auctionThemeCarry").innerHTML = "";
document.querySelector("#auctionNewThemes").innerHTML = "";
document.querySelector("#auctionAmountTrend").innerHTML = "";
document.querySelector("#auctionAmountCompare").innerHTML = "";
document.querySelector("#auctionTableBody").innerHTML = "";
document.querySelector("#auctionEmpty").hidden = false;
setText("auctionDateLabel", error.message || "竞价数据暂不可用");
showToast(error.message || "竞价数据加载失败");
} finally {
state.auctionLoading = false;
button.disabled = false;
}
}
function renderAuctionCenter() {
const payload = state.auctionData;
if (!payload) return;
const summary = payload.summary || {};
renderAuctionPhase(payload.meta || {});
setText(
"auctionDateLabel",
`${payload.meta?.carried_forward ? "最近有效竞价" : "竞价日期"} ${payload.meta?.trade_date || "--"}`,
);
document.querySelector("#auctionSummary").innerHTML = [
["竞价覆盖", `${formatNumber(summary.stock_count, 0)} 只`, ""],
["重点异动", `${formatNumber(summary.focus_count, 0)} 只`, "up"],
["竞价一字", `${formatNumber(summary.one_price_count, 0)} 只`, ""],
["竞价成交额", `${formatNumber(summary.amount_billion, 2)} 亿`, ""],
].map(([label, value, tone]) => `<div><span>${label}</span><strong class="${tone}">${value}</strong></div>`).join("");
setText("auctionFocusCount", number(summary.focus_count));
setText("auctionAllCount", number(summary.candidate_count));
setText("auctionOnePriceCount", number(summary.one_price_count));
setText("auctionWatchlistCount", number(payload.watchlist_rows?.length));
renderAuctionInsights(payload);
renderAuctionTable();
}
function renderAuctionInsights(payload) {
const themes = payload.themes || {};
const carry = themes.carry || [];
const tone = { "强承接": "strong", "有承接": "steady", "分歧": "mixed", "承接弱": "weak" };
setText("auctionThemeBaseline", `基于 ${payload.candidate_meta?.baseline_date || "--"}`);
document.querySelector("#auctionThemeCarry").innerHTML = carry.length
? carry.map((item) => `
<div class="auction-theme-row">
<strong class="auction-theme-name">${escapeHtml(item.name)}</strong>
<span class="auction-theme-info">${escapeHtml(item.leader || "--")} · 昨日 ${number(item.prior_limit_count)} 只涨停</span>
<span class="auction-theme-status ${tone[item.status] || "mixed"}">${escapeHtml(item.status)}</span>
<span class="auction-theme-median ${item.median_change == null ? "" : changeClass(item.median_change)}">${item.median_change == null ? "暂无有效候选" : `${signed(item.median_change)}%`}<small>中位</small></span>
</div>`).join("")
: '<div class="auction-inline-empty">暂无昨日强势题材基线</div>';
const newThemes = themes.new_themes || [];
document.querySelector("#auctionNewThemes").innerHTML = newThemes.length
? newThemes.map((item) => `<span title="${escapeHtml((item.leaders || []).join("、"))}">${escapeHtml(item.name)} <strong>${number(item.stock_count)}</strong></span>`).join("")
: '<small>尚未形成多股共振的新线索</small>';
const history = payload.amount_history || [];
const maximum = Math.max(...history.map((item) => number(item.amount_billion)), 1);
const priorFive = history.slice(Math.max(0, history.length - 6), Math.max(0, history.length - 1));
const fiveDayAverage = priorFive.length
? priorFive.reduce((sum, item) => sum + number(item.amount_billion), 0) / priorFive.length
: null;
document.querySelector("#auctionAmountTrend").innerHTML = history.length
? history.map((item, index) => {
const height = Math.max(8, number(item.amount_billion) / maximum * 100);
const current = index === history.length - 1 ? " current" : "";
return `<div class="auction-amount-day${current}" title="${escapeHtml(item.trade_date)} · ${formatNumber(item.amount_billion, 2)} 亿 · ${number(item.stock_count)} 只">
<span style="height:${height.toFixed(1)}%"></span><small>${escapeHtml(String(item.trade_date || "").slice(5))}</small>
</div>`;
}).join("") + (fiveDayAverage === null ? "" : `<div class="auction-amount-average" style="bottom:${(20 + Math.min(fiveDayAverage / maximum, 1) * 82).toFixed(1)}px"><small>5日均 ${formatNumber(fiveDayAverage, 1)}</small></div>`)
: '<div class="auction-inline-empty">历史竞价量能尚未形成</div>';
setText("auctionAmountValue", `${formatNumber(payload.summary?.amount_billion, 2)} 亿`);
const comparison = [
["较昨日", payload.summary?.amount_change_previous],
["较5日均值", payload.summary?.amount_change_5d],
];
document.querySelector("#auctionAmountCompare").innerHTML = comparison.map(([label, value]) => `
<span>${label}<strong class="${value == null ? "" : changeClass(value)}">${value == null ? "--" : `${signed(value)}%`}</strong></span>
`).join("");
}
function renderAuctionPhase(meta) {
const phase = meta.phase || "archive";
const available = Boolean(meta.available);
const copy = {
pending: ["竞价尚未开始", "9:15 进入观察期,9:25 读取最终竞价结果。", "下一阶段 09:15"],
observing: ["竞价观察期", "此阶段先观察盘前变化,系统将在 9:25 自动读取最终结果。", "09:25 定格"],
selection: available
? ["竞价筛选窗口", "最终竞价结果已经定格,请在 9:30 前完成筛选。", "有效至 09:30"]
: ["等待最终竞价", "9:25 数据尚未到达,系统正在自动重试。", "即将更新"],
finalized: ["今日竞价已定格", "9:30 后停止更新,仅保留用于复盘、回测与智能选股。", "已冻结"],
archive: ["历史竞价归档", "当前展示所选交易日的最终竞价结果。", "归档数据"],
}[phase] || ["竞价状态", "当前竞价状态待确认。", "--"];
const notice = document.querySelector("#auctionPhaseNotice");
notice.dataset.phase = phase;
setText("auctionPhaseTitle", copy[0]);
setText("auctionPhaseDetail", copy[1]);
setText("auctionPhaseTime", copy[2]);
const refresh = document.querySelector("#auctionRefreshButton");
refresh.hidden = phase !== "selection";
refresh.disabled = state.auctionLoading;
}
function clearAuctionTimer() {
if (state.auctionTimer) clearTimeout(state.auctionTimer);
state.auctionTimer = null;
}
function scheduleAuctionTransition(meta) {
clearAuctionTimer();
if (state.activeView !== "auctionView") return;
let delay = 0;
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();
if (Number.isFinite(transitionAt)) delay = Math.max(800, transitionAt - Date.now() + 500);
}
if (!delay) return;
state.auctionTimer = setTimeout(() => {
state.auctionTimer = null;
if (state.activeView === "auctionView") loadAuctionCenter(true);
}, Math.min(delay, 2_147_000_000));
}
function renderAuctionTable() {
const rows = currentAuctionRows();
const columns = auctionColumns();
const head = document.querySelector("#auctionTableHead");
head.innerHTML = columns.map((column) => {
const sorted = column.sortKey === state.auctionSortKey;
const arrow = !column.sortKey ? "" : `<span class="arr">${sorted ? (state.auctionSortDirection === "desc" ? "▼" : "▲") : "↕"}</span>`;
return `<th class="${column.numeric ? "number num " : ""}${column.sortKey ? "sortable " : ""}${sorted ? "sorted" : ""}"${column.sortKey ? ` data-auction-sort="${column.sortKey}"` : ""}>${column.label}${arrow}</th>`;
}).join("");
const body = document.querySelector("#auctionTableBody");
body.innerHTML = rows.map((row) => `<tr data-code="${escapeHtml(row.code)}">${columns.map((column) => renderAuctionCell(row, column.key)).join("")}</tr>`).join("");
bindStockRows(body);
const datasetCopy = {
focus: ["重点异动", "优先查看市场核心与显著预期差"],
onePrice: ["竞价一字", "竞价封于当日真实涨停价,不参与普通异动评分"],
watchlist: ["我的自选", "仅展示当前账号关注标的的竞价反馈"],
all: ["全部候选", "昨日涨停、炸板与热榜前20候选"],
}[state.auctionDataset] || ["竞价异动", ""];
setText("auctionWorkspaceTitle", datasetCopy[0]);
setText("auctionWorkspaceSubtitle", datasetCopy[1]);
document.querySelector("#auctionExpectationControls").hidden = state.auctionDataset === "onePrice";
const empty = document.querySelector("#auctionEmpty");
const phase = state.auctionData?.meta?.phase || "archive";
empty.textContent = phase === "selection" && !state.auctionData?.meta?.available
? "正在等待 9:25 最终竞价数据"
: state.auctionDataset === "watchlist"
? "当前账号还没有可观察的自选股"
: state.auctionDataset === "onePrice"
? "当前没有竞价封于涨停价的股票"
: "没有符合条件的竞价候选";
empty.hidden = rows.length > 0;
}
function currentAuctionRows() {
const datasets = {
focus: state.auctionData?.focus_rows || [],
onePrice: state.auctionData?.one_price_rows || [],
watchlist: state.auctionData?.watchlist_rows || [],
all: state.auctionData?.rows || [],
};
let rows = [...(datasets[state.auctionDataset] || [])];
const filter = state.auctionFilter;
const labels = { above: "超预期", matched: "符合预期", below: "低于预期" };
if (labels[filter]) rows = rows.filter((item) => item.expectation === labels[filter]);
if (state.auctionQuery) {
rows = rows.filter((item) => `${item.code} ${item.name} ${item.sector}`.toLocaleLowerCase("zh-CN").includes(state.auctionQuery));
}
const key = state.auctionSortKey;
const direction = state.auctionSortDirection === "asc" ? 1 : -1;
if (key) {
rows.sort((left, right) => {
const leftValue = left[key];
const rightValue = right[key];
if (leftValue == null && rightValue == null) return 0;
if (leftValue == null) return 1;
if (rightValue == null) return -1;
const result = typeof leftValue === "number" || typeof rightValue === "number"
? number(leftValue) - number(rightValue)
: String(leftValue).localeCompare(String(rightValue), "zh-CN", { numeric: true });
return result * direction;
});
}
return rows.slice(0, 300);
}
function auctionColumns() {
const base = [
{ key: "stock", label: "股票" },
{ key: "context", label: "方向与来源" },
{ key: "identity", label: "市场身份" },
];
const metrics = [
{ key: "score", label: "关注分", numeric: true, sortKey: "attention_score" },
{ key: "expectation", label: "预期判断" },
{ key: "change", label: "竞价涨幅(%", numeric: true, sortKey: "change" },
{ key: "amount", label: "竞价额(百万)", numeric: true, sortKey: "amount_million" },
{ key: "volume", label: "量比", numeric: true, sortKey: "volume_ratio" },
];
return state.auctionDataset === "onePrice" ? [...base, ...metrics.slice(2)] : [...base, ...metrics];
}
function renderAuctionCell(row, key) {
const unavailable = row.available === false;
const onePrice = Boolean(row.is_one_price);
const expectationTone = { "超预期": "above", "符合预期": "matched", "低于预期": "below" };
if (key === "stock") return `<td><span class="auction-stock-cell-v2"><strong class="sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>`;
if (key === "context") return `<td><span class="auction-context-cell-v2"><strong>${escapeHtml(row.sector || "其他")}</strong>${renderAuctionSources(row.source_label || (state.auctionDataset === "watchlist" ? "我的自选" : "全市场"))}</span></td>`;
if (key === "identity") return `<td>${renderAuctionCoreTags(row.core_tags)}</td>`;
if (unavailable) return key === "expectation"
? '<td><span class="table-muted">暂无竞价</span></td>'
: `<td class="${["score", "change", "amount", "volume"].includes(key) ? "number num" : ""}"></td>`;
if (key === "score") return `<td class="number num auction-score">${onePrice ? "" : formatNumber(row.attention_score, 1)}</td>`;
if (key === "expectation") {
const tag = onePrice
? '<span class="auction-one-price-tag">竞价一字</span>'
: `<span class="auction-expectation ${expectationTone[row.expectation] || "matched"}">${escapeHtml(row.expectation || "符合预期")}</span>`;
return `<td>${tag}</td>`;
}
if (key === "change") return `<td class="number num ${changeClass(row.change)}">${signed(row.change)}</td>`;
if (key === "amount") return `<td class="number num">${formatNumber(row.amount_million, 2)}</td>`;
if (key === "volume") return `<td class="number num auction-volume-ratio">${formatNumber(row.volume_ratio, 2)}</td>`;
return "<td></td>";
}
function renderAuctionSources(value) {
const sources = String(value || "").split(/[·、/]/).map((item) => item.trim()).filter(Boolean).slice(0, 3);
return `<small class="auction-source-tags-v2">${sources.map((source) => `<b>${escapeHtml(source)}</b>`).join("")}</small>`;
}
function renderAuctionCoreTags(tags) {
const values = Array.isArray(tags) ? tags : [];
return values.length
? `<span class="auction-core-tags">${values.slice(0, 2).map((tag) => `<b>${escapeHtml(tag)}</b>`).join("")}</span>`
: '<span class="auction-identity-empty" aria-label="无市场身份"></span>';
}
function exportAuctionRows() {
const rows = currentAuctionRows();
exportRows("集合竞价", rows, [
["股票代码", "code"], ["股票名称", "name"], ["行业", "sector"], ["来源", "source_label"],
["市场身份", "core_tags"], ["关注分", "attention_score"], ["预期判断", "expectation"],
["竞价涨幅%", "change"], ["竞价额百万", "amount_million"], ["量比", "volume_ratio"],
]);
}
function bindAuctionEvents() {
document.querySelector("#auctionRefreshButton").addEventListener("click", () => loadAuctionCenter(true));
document.querySelector("#auctionExportButton").addEventListener("click", exportAuctionRows);
document.querySelector("#auctionSearch").addEventListener("input", (event) => {
state.auctionQuery = event.target.value.trim().toLocaleLowerCase("zh-CN");
renderAuctionTable();
});
document.querySelectorAll("[data-auction-dataset]").forEach((button) => {
button.addEventListener("click", () => {
state.auctionDataset = button.dataset.auctionDataset || "focus";
state.auctionFilter = "all";
state.auctionSortKey = state.auctionDataset === "onePrice" ? "amount_million" : "attention_score";
state.auctionSortDirection = "desc";
document.querySelectorAll("[data-auction-dataset]").forEach((item) => {
const active = item === button;
item.classList.toggle("active", active);
item.setAttribute("aria-selected", String(active));
});
document.querySelectorAll("[data-auction-filter]").forEach((item) => item.classList.toggle("active", item.dataset.auctionFilter === "all"));
renderAuctionTable();
});
});
document.querySelectorAll("[data-auction-filter]").forEach((button) => {
button.addEventListener("click", () => {
state.auctionFilter = button.dataset.auctionFilter || "all";
document.querySelectorAll("[data-auction-filter]").forEach((item) => item.classList.toggle("active", item === button));
renderAuctionTable();
});
});
document.querySelector("#auctionTable").addEventListener("click", (event) => {
const header = event.target.closest("th[data-auction-sort]");
if (!header) return;
const key = header.dataset.auctionSort;
if (state.auctionSortKey === key) state.auctionSortDirection = state.auctionSortDirection === "asc" ? "desc" : "asc";
else {
state.auctionSortKey = key;
state.auctionSortDirection = "desc";
}
renderAuctionTable();
});
}