314 lines
16 KiB
JavaScript
314 lines
16 KiB
JavaScript
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();
|
||
});
|
||
}
|