Created
August 8, 2026 07:00
-
-
Save discountry/662ad635b04208fdee8dc8c284d26308 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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