Skip to content

Instantly share code, notes, and snippets.

@discountry
Created August 8, 2026 07:00
Show Gist options
  • Select an option

  • Save discountry/662ad635b04208fdee8dc8c284d26308 to your computer and use it in GitHub Desktop.

Select an option

Save discountry/662ad635b04208fdee8dc8c284d26308 to your computer and use it in GitHub Desktop.
// ==UserScript==
// @name Variational Omni — 分红预估 (Dividend Estimator)
// @namespace https://omni.variational.io/
// @version 2.1.0
// @description 在 omni.variational.io 永续合约页面的 Dividends 字段旁展示下次除息日 / 每股金额 / 年化股息率,并根据持仓表中的当前仓位预估应收或应付的分红金额。数据来自 Business Quant Dividends API。
// @author -
// @match https://omni.variational.io/perpetual/*
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @connect data.businessquant.com
// @run-at document-idle
// @noframes
// ==/UserScript==
/* eslint-disable no-console */
(function () {
'use strict';
// ═══════════════════════════════════════════════════════════
// ▼▼▼ 用户配置:在这里填写你的 API Key ▼▼▼
//
// 免费申请: https://businessquant.com/docs/api/dividends
//
// 也可以留空不填,改用 Tampermonkey 菜单里的
// 「设置 Business Quant API Key」录入(保存在本地,不写进脚本)。
// ═══════════════════════════════════════════════════════════
const USER_CONFIG = {
API_KEY: '', // ← 例如: 'bq_live_xxxxxxxxxxxxxxxx'
cacheHours: 12, // 本地缓存时长(小时)
compact: true, // true=紧凑(日期放到 Dividends 标签行,避免撑宽表头)
// false=全部信息挤在数值行(会变长,需横向滚动)
warnScaleMismatch: true, // 合约单位与实际股价不一致时给出提醒
debug: false, // 打开后在控制台输出诊断信息
};
// ═══════════════════════════════════════════════════════════
const CFG = {
endpoint: 'https://data.businessquant.com/dividends',
keyStore: 'vdx:apikey',
cacheTTL: USER_CONFIG.cacheHours * 3600 * 1000,
tickTimer: 1500, // 轮询:检测路由变化 / SPA 重绘 / 持仓变化
lookbackDivs: 9, // 用最近 N 次派息推断节奏
avgWindow: 4, // 用最近 N 次派息估算金额
};
const DAY = 86400000;
const CADENCES = [
{ d: 7, label: '每周' },
{ d: 14, label: '双周' },
{ d: 30.44, label: '每月' },
{ d: 91.31, label: '每季度' },
{ d: 182.62, label: '每半年' },
{ d: 365.25, label: '每年' },
];
const log = (...a) => USER_CONFIG.debug && console.log('[VDX]', ...a);
// ───────────────────────── 工具 ─────────────────────────
const num = (s) => {
if (s == null) return NaN;
const m = String(s).replace(/[,\s$]/g, '').match(/-?\d*\.?\d+/);
return m ? parseFloat(m[0]) : NaN;
};
const fmtMoney = (v, dp = 2) =>
'$' + Math.abs(v).toLocaleString('en-US', { minimumFractionDigits: dp, maximumFractionDigits: dp });
const fmtDate = (d) =>
`${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`;
const parseDay = (s) => Date.parse(String(s || '').slice(0, 10) + 'T00:00:00Z');
const todayUTC = () => { const n = new Date(); return Date.UTC(n.getUTCFullYear(), n.getUTCMonth(), n.getUTCDate()); };
const median = (arr) => {
if (!arr.length) return NaN;
const s = [...arr].sort((a, b) => a - b);
const m = s.length >> 1;
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
};
// ───────────────────────── API Key ─────────────────────────
let cachedKey = null;
async function getApiKey() {
if (USER_CONFIG.API_KEY && USER_CONFIG.API_KEY.trim()) return USER_CONFIG.API_KEY.trim();
if (cachedKey !== null) return cachedKey;
try { cachedKey = (await GM_getValue(CFG.keyStore, '')) || ''; } catch (e) { cachedKey = ''; }
return cachedKey;
}
async function promptApiKey() {
const cur = await getApiKey();
const v = window.prompt('输入 Business Quant API Key\n(免费申请:businessquant.com/docs/api/dividends)', cur || '');
if (v === null) return false;
await GM_setValue(CFG.keyStore, v.trim());
cachedKey = v.trim();
state.lastRenderKey = '';
if (state.ticker) { await load(state.ticker); render(); }
return true;
}
if (typeof GM_registerMenuCommand === 'function') {
GM_registerMenuCommand('设置 Business Quant API Key', promptApiKey);
GM_registerMenuCommand('清空分红数据缓存', async () => {
if (state.ticker) { try { await GM_setValue('vdx:' + state.ticker, ''); } catch (e) {} }
state.lastRenderKey = '';
if (state.ticker) { await load(state.ticker); render(); }
});
}
// ───────────────────────── 数据层 ─────────────────────────
function httpJSON(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET', url, timeout: 15000,
headers: { Accept: 'application/json' },
onload: (r) => {
if (r.status === 401 || r.status === 403) return reject(new Error('API Key 无效或未授权'));
if (r.status === 429) return reject(new Error('请求过于频繁,请稍后再试'));
if (r.status === 404) return reject(new Error('该标的无分红数据'));
if (r.status < 200 || r.status >= 300) return reject(new Error('HTTP ' + r.status));
try { resolve(JSON.parse(r.responseText)); }
catch (e) { reject(new Error('响应不是合法 JSON')); }
},
onerror: () => reject(new Error('网络错误')),
ontimeout: () => reject(new Error('请求超时')),
});
});
}
async function fetchDividends(ticker) {
const key = await getApiKey();
if (!key) { const err = new Error('NO_KEY'); err.code = 'NO_KEY'; throw err; }
const cacheKey = 'vdx:' + ticker;
try {
const raw = await GM_getValue(cacheKey, '');
if (raw) {
const c = typeof raw === 'string' ? JSON.parse(raw) : raw;
if (c && Date.now() - c.at < CFG.cacheTTL) { log('命中缓存', ticker); return c.payload; }
}
} catch (e) { /* 缓存损坏则忽略 */ }
const url = `${CFG.endpoint}?ticker=${encodeURIComponent(ticker)}&mode=dps&api_key=${encodeURIComponent(key)}`;
const json = await httpJSON(url);
if (!json || !Array.isArray(json.data)) throw new Error('接口无数据');
try { await GM_setValue(cacheKey, JSON.stringify({ at: Date.now(), payload: json })); } catch (e) {}
return json;
}
// ───────────────────────── 分析 ─────────────────────────
// 接口返回:
// metadata { ticker, range, divyield, ttmdividend, lastdividend, nextdividend, mode }
// data[] { dividend, ex_date, payment_date } ← 按除息日倒序
function analyze(json) {
const meta = json.metadata || {};
const events = (json.data || [])
.map((d) => ({ t: parseDay(d.ex_date), pt: parseDay(d.payment_date), amount: num(d.dividend) }))
.filter((d) => Number.isFinite(d.t) && Number.isFinite(d.amount) && d.amount > 0)
.sort((a, b) => a.t - b.t);
if (!events.length) return null;
const today = todayUTC();
// 派息节奏:最近 N 次除息日间隔的中位数,吸附到标准周期
const recent = events.slice(-CFG.lookbackDivs);
const gaps = [];
for (let i = 1; i < recent.length; i++) gaps.push((recent[i].t - recent[i - 1].t) / DAY);
const gap = median(gaps);
let cadence = null;
if (Number.isFinite(gap) && gap > 0) {
cadence = CADENCES.reduce((b, c) => (Math.abs(c.d - gap) < Math.abs(b.d - gap) ? c : b), CADENCES[0]);
if (Math.abs(cadence.d - gap) / cadence.d > 0.25) cadence = { d: gap, label: `约 ${Math.round(gap)} 天` };
}
// 除息日 → 派息日的平均间隔
const lags = events.filter((e) => Number.isFinite(e.pt)).slice(-CFG.lookbackDivs).map((e) => (e.pt - e.t) / DAY);
const payLag = Number.isFinite(median(lags)) ? Math.round(median(lags)) : null;
// 下次除息日:优先用官方已公布的 metadata.nextdividend
const last = events[events.length - 1];
const announcedMeta = parseDay(meta.nextdividend);
const futureEvent = events.find((e) => e.t >= today);
let nextT = null, source = 'estimate', skipped = 0, nextAmount = NaN;
if (Number.isFinite(announcedMeta) && announcedMeta >= today) {
nextT = announcedMeta; source = 'announced';
} else if (futureEvent) {
nextT = futureEvent.t; source = 'announced'; nextAmount = futureEvent.amount;
} else if (cadence && cadence.d > 0) {
nextT = last.t + cadence.d * DAY;
while (nextT < today) { nextT += cadence.d * DAY; skipped++; }
}
// 每股金额:最近 N 次的中位数(杠杆 ETF 单期金额波动大,中位数比均值稳)
const win = events.slice(-CFG.avgWindow).map((e) => e.amount);
const estAmount = median(win);
if (!Number.isFinite(nextAmount)) nextAmount = estAmount;
const winMin = Math.min(...win), winMax = Math.max(...win);
const volatile = winMax > 0 && (winMax - winMin) / winMax > 0.35;
// TTM 与股息率:接口已算好,缺失则自行兜底
let ttm = num(meta.ttmdividend);
if (!Number.isFinite(ttm)) ttm = events.filter((e) => e.t > today - 365 * DAY).reduce((s, e) => s + e.amount, 0);
const divYield = num(meta.divyield);
// 由 TTM ÷ 股息率反推的参考股价,用于校验合约单位是否与实际股价同一量级
const impliedPrice = Number.isFinite(divYield) && divYield > 0 ? ttm / divYield : NaN;
// 置信度
let confidence = 'high';
if (source === 'estimate') {
confidence = 'medium';
const spread = gaps.length && gap ? (Math.max(...gaps) - Math.min(...gaps)) / gap : 1;
if (skipped >= 2 || spread > 0.5 || gaps.length < 3) confidence = 'low';
if ((today - last.t) / DAY > (cadence ? cadence.d * 3 : 400)) confidence = 'low';
}
const nextPayT = nextT != null && payLag != null ? nextT + payLag * DAY : null;
return {
ticker: meta.ticker || null,
company: meta.companyname_short || meta.companyname || null,
range: meta.range || null,
source, confidence, cadence, skipped, payLag,
lastEx: new Date(last.t),
lastPay: Number.isFinite(last.pt) ? new Date(last.pt) : null,
lastAmount: last.amount,
nextEx: nextT != null ? new Date(nextT) : null,
nextPay: nextPayT != null ? new Date(nextPayT) : null,
nextAmount, winMin, winMax, volatile,
daysUntil: nextT != null ? Math.round((nextT - today) / DAY) : null,
ttm, divYield, impliedPrice,
count: events.length,
};
}
// ───────────────────────── 页面读取 ─────────────────────────
function getTicker() {
const m = location.pathname.match(/\/perpetual\/([A-Za-z0-9._-]+)/);
return m ? m[1].toUpperCase() : null;
}
function findDividendField() {
const labels = [...document.querySelectorAll('span')].filter(
(el) => el.childElementCount === 0 && el.textContent.trim() === 'Dividends'
);
for (const label of labels) {
const box = label.parentElement;
if (!box) continue;
const value = box.querySelector('[slot="value"]');
if (value) return { box, label, value };
}
return null;
}
function readHeaderPrice() {
const spans = [...document.querySelectorAll('span')].filter((el) => el.childElementCount === 0);
for (const want of ['Index Price', 'Mark Price']) {
const el = spans.find((x) => x.textContent.trim() === want);
const v = el && el.parentElement && el.parentElement.querySelector('[slot="value"]');
const p = num(v && v.textContent);
if (Number.isFinite(p) && p > 0) return p;
}
return NaN;
}
function readPosition(ticker) {
const rows = document.querySelectorAll('[data-testid="positions-table-row"]');
for (const row of rows) {
const nameEl = row.querySelector('[title$="-PERP"], a[href*="/perpetual/"] span[title]');
const name = nameEl && (nameEl.getAttribute('title') || nameEl.textContent).trim().toUpperCase();
if (!name || (name !== `${ticker}-PERP` && name !== ticker)) continue;
const qty = num(row.querySelector('[data-testid="position-quantity"]')?.textContent);
if (!Number.isFinite(qty) || qty === 0) continue;
const cells = row.querySelectorAll('td');
return {
qty,
mark: num(cells[2]?.textContent),
value: num(cells[3]?.textContent),
entry: num(cells[4]?.textContent),
};
}
return null;
}
// ───────────────────────── UI ─────────────────────────
const CSS = `
.vdx-inline{margin-left:6px;font-weight:400;cursor:pointer;white-space:nowrap;
border-bottom:1px dashed currentColor;padding-bottom:1px}
.vdx-inline.vdx-none{opacity:.6;border-bottom:none;cursor:default}
.vdx-inline.vdx-action{opacity:.75;cursor:pointer;border-bottom:1px dashed currentColor}
/* 紧凑模式:日期寄生在 Dividends 标签行,数值行只留金额 */
.vdx-lbl{margin-left:5px;cursor:pointer;font-variant-numeric:tabular-nums;
color:var(--color-theme-foreground-muted,#8a909b)}
.vdx-lbl i{font-style:normal;opacity:.65;margin-left:3px}
.vdx-lbl.vdx-est{border-bottom:1px dotted currentColor}
.vdx-lbl.vdx-c-low{color:#e0b341}
.vdx-val{cursor:pointer;white-space:nowrap;font-variant-numeric:tabular-nums;
border-bottom:1px dashed rgba(255,255,255,.18);padding-bottom:1px}
.vdx-val .vdx-amt{color:var(--color-theme-foreground-foreground,#e6e6e6)}
.vdx-val .vdx-sep{opacity:.35;margin:0 3px}
.vdx-pay{color:var(--color-theme-foreground-destructive,#ff5c5c)}
.vdx-recv{color:var(--color-theme-foreground-affirmative,#3fcf8e)}
.vdx-panel{position:fixed;z-index:99999;width:300px;padding:10px 12px;border-radius:6px;
font-family:Inter,system-ui,sans-serif;font-size:11px;line-height:1.5;
font-variant-numeric:tabular-nums;
color:var(--color-theme-foreground-foreground,#e6e6e6);
background:var(--color-theme-background-background-trade,#14161a);
border:1px solid var(--color-theme-border-border,#2a2e36);
box-shadow:0 8px 28px rgba(0,0,0,.55)}
.vdx-panel h4{margin:0 0 8px;font-size:11px;font-weight:500;letter-spacing:.04em;
text-transform:uppercase;color:var(--color-theme-foreground-muted,#8a909b)}
.vdx-row{display:flex;justify-content:space-between;gap:12px;padding:2px 0}
.vdx-row span:first-child{color:var(--color-theme-foreground-muted,#8a909b);flex:none}
.vdx-row span:last-child{text-align:right}
.vdx-hr{height:1px;margin:8px 0;background:var(--color-theme-border-border,#2a2e36)}
.vdx-note{margin-top:8px;font-size:10px;line-height:1.45;color:var(--color-theme-foreground-muted-more,#6b7280)}
.vdx-note a{color:inherit}
.vdx-warn{margin-top:8px;padding:6px 8px;border-radius:4px;font-size:10px;line-height:1.45;
color:#e0b341;background:rgba(224,179,65,.09);border:1px solid rgba(224,179,65,.28)}
.vdx-tag{display:inline-block;padding:0 5px;border-radius:3px;font-size:10px;line-height:15px;
border:1px solid currentColor;margin-left:6px}
.vdx-c-high{color:var(--color-theme-foreground-affirmative,#3fcf8e)}
.vdx-c-medium{color:#e0b341}
.vdx-c-low{color:var(--color-theme-foreground-destructive,#ff5c5c)}
.vdx-close{float:right;cursor:pointer;opacity:.6}
`;
function injectCSS() {
if (document.getElementById('vdx-style')) return;
const s = document.createElement('style');
s.id = 'vdx-style';
s.textContent = CSS;
document.head.appendChild(s);
}
let panelEl = null;
const closePanel = () => { if (panelEl) { panelEl.remove(); panelEl = null; } };
function openPanel(anchor, html) {
closePanel();
panelEl = document.createElement('div');
panelEl.className = 'vdx-panel';
panelEl.innerHTML = html;
document.body.appendChild(panelEl);
const r = anchor.getBoundingClientRect();
const pr = panelEl.getBoundingClientRect();
let top = r.bottom + 8;
if (top + pr.height > window.innerHeight - 8) top = Math.max(8, r.top - pr.height - 8);
panelEl.style.left = Math.max(8, Math.min(r.left, window.innerWidth - pr.width - 12)) + 'px';
panelEl.style.top = top + 'px';
panelEl.querySelector('.vdx-close').addEventListener('click', closePanel);
}
document.addEventListener('click', (e) => {
if (panelEl && !panelEl.contains(e.target) && !e.target.closest('.vdx-inline')) closePanel();
}, true);
window.addEventListener('scroll', closePanel, true);
// ───────────────────────── 渲染 ─────────────────────────
const CONF_LABEL = { high: '已公布', medium: '推算', low: '低置信' };
// 交易所通行惯例:分红调整由空头支付、多头收取。
function estimateFlow(a, pos) {
if (!a || !pos || !Number.isFinite(a.nextAmount)) return null;
const gross = pos.qty * a.nextAmount; // qty 为负(空头)→ gross 为负 → 需支付
return { gross, receive: gross >= 0 };
}
function scaleInfo(a, price) {
if (!USER_CONFIG.warnScaleMismatch) return null;
if (!Number.isFinite(a.impliedPrice) || a.impliedPrice <= 0 || !Number.isFinite(price) || price <= 0) return null;
const ratio = price / a.impliedPrice;
return ratio < 0.85 || ratio > 1.18 ? { ratio, impliedPrice: a.impliedPrice, pagePrice: price } : null;
}
// 紧凑模式:把内容拆到「标签行」和「数值行」两行,避免单行过长
function buildCompact(a, flow) {
const conf = a.confidence === 'low' ? ' vdx-c-low' : '';
const est = a.source === 'estimate' ? ' vdx-est' : '';
const dateTxt = a.nextEx ? fmtDate(a.nextEx).slice(5).replace('-', '/') : '—';
const prefix = a.source === 'estimate' ? '≈' : '';
const dTxt = a.daysUntil != null ? (a.daysUntil === 0 ? '今日' : a.daysUntil + 'd') : '';
const tip = `${CONF_LABEL[a.confidence]}除息日 ${a.nextEx ? fmtDate(a.nextEx) : '未知'}${a.cadence ? ' · ' + a.cadence.label : ''} — 点击查看明细`;
const labelHTML =
`<span class="vdx-lbl${est}${conf}" title="${tip}">${prefix}${dateTxt}${dTxt ? `<i>${dTxt}</i>` : ''}</span>`;
let valInner = `<span class="vdx-amt">$${Number.isFinite(a.nextAmount) ? a.nextAmount.toFixed(4) : '—'}</span>`;
if (flow) {
const cls = flow.receive ? 'vdx-recv' : 'vdx-pay';
valInner += `<span class="vdx-sep">·</span><span class="${cls}">${flow.receive ? '+' : '−'}${fmtMoney(Math.abs(flow.gross))}</span>`;
}
const valTip = flow
? `每股 $${a.nextAmount.toFixed(4)} × 持仓 → ${flow.receive ? '预计收到' : '预计支付'} ${fmtMoney(Math.abs(flow.gross))}`
: `每股预估 $${Number.isFinite(a.nextAmount) ? a.nextAmount.toFixed(4) : '—'}(未检测到持仓)`;
return { labelHTML, valueHTML: `<span class="vdx-val" title="${valTip}">${valInner}</span>` };
}
function buildInline(a, flow) {
const bits = [];
if (a.nextEx) {
const d = fmtDate(a.nextEx).slice(5);
const n = a.daysUntil;
bits.push(`${d}${n != null ? ` (${n === 0 ? '今日' : (n > 0 ? '+' : '') + n + 'd'})` : ''}`);
}
if (Number.isFinite(a.nextAmount)) bits.push(`$${a.nextAmount.toFixed(4)}/股`);
let inner = `· ${bits.join(' · ')}`;
if (flow) {
const cls = flow.receive ? 'vdx-recv' : 'vdx-pay';
inner += ` <span class="${cls}">${flow.receive ? '预计收到' : '预计支付'} ${fmtMoney(Math.abs(flow.gross))}</span>`;
}
return `<span class="vdx-inline" title="点击查看分红明细">${inner}</span>`;
}
function buildPanel(ticker, a, pos, flow, price) {
const row = (k, v) => `<div class="vdx-row"><span>${k}</span><span>${v}</span></div>`;
const scale = scaleInfo(a, price);
let posBlock;
if (pos && flow) {
const cls = flow.receive ? 'vdx-recv' : 'vdx-pay';
const side = pos.qty < 0 ? '空头 Short' : '多头 Long';
const adj = scale ? `<div class="vdx-row"><span>按价格比例调整</span><span class="${cls}">${flow.receive ? '+' : '-'}${fmtMoney(Math.abs(flow.gross * scale.ratio))}</span></div>` : '';
posBlock = `<div class="vdx-hr"></div>
${row('当前持仓', `${side} ${pos.qty}`)}
${row('每股预估', `$${a.nextAmount.toFixed(4)}`)}
${row(flow.receive ? '预计收到' : '预计支付',
`<b class="${cls}">${flow.receive ? '+' : '-'}${fmtMoney(Math.abs(flow.gross))}</b>`)}
${adj}`;
} else {
posBlock = `<div class="vdx-hr"></div><div class="vdx-note">持仓表中未检测到 ${ticker}-PERP 仓位,无法预估收付。</div>`;
}
const scaleWarn = scale
? `<div class="vdx-warn">合约标价 ${fmtMoney(scale.pagePrice)} 与该标的实际股价 ${fmtMoney(scale.impliedPrice)} 相差约 ${scale.ratio.toFixed(2)}×,
合约单位可能不等于 1 股(或数据源标的不同)。上方"按价格比例调整"为等比换算结果,仅作参考。</div>`
: '';
const volNote = a.volatile
? `<div class="vdx-note">最近 ${CFG.avgWindow} 次派息区间 $${a.winMin.toFixed(4)} – $${a.winMax.toFixed(4)},金额波动较大,单期估算误差可能较高。</div>`
: '';
return `
<span class="vdx-close">✕</span>
<h4>${a.ticker || ticker} 分红<span class="vdx-tag vdx-c-${a.confidence}">${CONF_LABEL[a.confidence]}</span></h4>
${row('下次除息日', a.nextEx ? fmtDate(a.nextEx) : '无法推算')}
${row('预计派息日', a.nextPay ? fmtDate(a.nextPay) : '—')}
${row('距今', a.daysUntil != null ? `${a.daysUntil} 天` : '—')}
${row('派息节奏', a.cadence ? a.cadence.label : '不规则')}
${row('预估每股', Number.isFinite(a.nextAmount) ? `$${a.nextAmount.toFixed(4)}` : '—')}
${row('上次除息', `${fmtDate(a.lastEx)} · $${a.lastAmount.toFixed(4)}`)}
${row('近 12 个月累计', Number.isFinite(a.ttm) ? `$${a.ttm.toFixed(4)}` : '—')}
${row('年化股息率', Number.isFinite(a.divYield) ? (a.divYield * 100).toFixed(2) + '%' : '—')}
${a.range ? row('数据覆盖', a.range) : ''}
${posBlock}
${scaleWarn}
${volNote}
<div class="vdx-note">
未公布的除息日与金额均由历史派息记录外推,仅供参考,实际以基金/公司公告为准;
永续合约的分红结算方式与方向以 Variational 规则为准。
数据来源 <a href="https://businessquant.com/docs/api/dividends" target="_blank" rel="noopener">Business Quant</a>。
</div>`;
}
// ───────────────────────── 主流程 ─────────────────────────
const state = { ticker: null, analysis: null, status: 'idle', error: null, lastRenderKey: '' };
async function load(ticker) {
state.ticker = ticker;
state.analysis = null;
state.error = null;
state.status = 'loading';
state.lastRenderKey = '';
try {
const json = await fetchDividends(ticker);
state.analysis = analyze(json);
state.status = state.analysis ? 'ok' : 'empty';
} catch (e) {
state.status = e && e.code === 'NO_KEY' ? 'nokey' : 'error';
state.error = (e && e.message) || String(e);
log('加载失败', ticker, e);
}
}
function render() {
const field = findDividendField();
if (!field) return;
if (!field.value.dataset.vdxOrig) field.value.dataset.vdxOrig = field.value.innerHTML;
if (!field.label.dataset.vdxOrig) field.label.dataset.vdxOrig = field.label.innerHTML;
const pos = state.ticker ? readPosition(state.ticker) : null;
const price = readHeaderPrice();
const a = state.analysis;
const flow = estimateFlow(a, pos);
const ok = state.status === 'ok' && a;
const compact = USER_CONFIG.compact && ok;
let labelHTML = '', valueHTML;
if (compact) {
// 紧凑:日期上移到标签行,数值行替换 Yes/No,只留金额与收付
const c = buildCompact(a, flow);
labelHTML = c.labelHTML;
valueHTML = c.valueHTML;
} else {
let extra;
if (state.status === 'loading') extra = `<span class="vdx-inline vdx-none">· 加载中…</span>`;
else if (state.status === 'nokey') extra = `<span class="vdx-inline vdx-action" data-vdx-key="1">· 设置 API Key</span>`;
else if (state.status === 'error') extra = `<span class="vdx-inline vdx-none" title="${state.error}">· 不可用</span>`;
else if (!ok) extra = `<span class="vdx-inline vdx-none">· 无记录</span>`;
else extra = buildInline(a, flow);
valueHTML = field.value.dataset.vdxOrig + extra;
}
const key = [state.ticker, state.status, labelHTML, valueHTML, pos && pos.qty, price].join('|');
if (key === state.lastRenderKey && (field.value.querySelector('.vdx-val, .vdx-inline'))) return;
state.lastRenderKey = key;
field.label.innerHTML = field.label.dataset.vdxOrig + labelHTML;
field.value.innerHTML = valueHTML;
const keyBtn = field.value.querySelector('[data-vdx-key]');
if (keyBtn) keyBtn.addEventListener('click', (e) => { e.stopPropagation(); promptApiKey(); });
if (ok) {
const targets = [
field.label.querySelector('.vdx-lbl'),
field.value.querySelector('.vdx-val'),
field.value.querySelector('.vdx-inline:not(.vdx-none):not(.vdx-action)'),
].filter(Boolean);
for (const el of targets) {
el.addEventListener('click', (e) => {
e.stopPropagation();
if (panelEl) return closePanel();
openPanel(el, buildPanel(state.ticker, a, pos, flow, price));
});
}
}
}
function tick() {
const t = getTicker();
if (!t) return;
if (t !== state.ticker) {
closePanel();
// 还原上一个标的残留的内容,避免切换瞬间显示错误日期
const f = findDividendField();
if (f) {
if (f.label.dataset.vdxOrig) f.label.innerHTML = f.label.dataset.vdxOrig;
if (f.value.dataset.vdxOrig) f.value.innerHTML = f.value.dataset.vdxOrig;
}
load(t).then(render);
return;
}
render();
}
injectCSS();
tick();
setInterval(tick, CFG.tickTimer);
['pushState', 'replaceState'].forEach((fn) => {
const orig = history[fn];
history[fn] = function () { const r = orig.apply(this, arguments); setTimeout(tick, 60); return r; };
});
window.addEventListener('popstate', () => setTimeout(tick, 60));
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment