|
| 1 | +import type { MarketServiceClient } from '@/generated/client/worldmonitor/market/v1/service_client'; |
| 2 | +import { Panel } from './Panel'; |
| 3 | +import { t } from '@/services/i18n'; |
| 4 | +import { escapeHtml } from '@/utils/sanitize'; |
| 5 | +import { formatChange, getChangeClass } from '@/utils'; |
| 6 | + |
| 7 | +let _client: MarketServiceClient | null = null; |
| 8 | + |
| 9 | +async function getMarketClient(): Promise<MarketServiceClient> { |
| 10 | + if (!_client) { |
| 11 | + const { MarketServiceClient } = await import('@/generated/client/worldmonitor/market/v1/service_client'); |
| 12 | + const { getRpcBaseUrl } = await import('@/services/rpc-client'); |
| 13 | + _client = new MarketServiceClient(getRpcBaseUrl(), { fetch: (...args: Parameters<typeof fetch>) => globalThis.fetch(...args) }); |
| 14 | + } |
| 15 | + return _client; |
| 16 | +} |
| 17 | + |
| 18 | +const TOP_STOCKS = ['AAPL', 'MSFT', 'NVDA', 'AMZN', 'GOOGL', 'META', 'TSLA']; |
| 19 | +const COT_PRIORITY = ['CL', 'GC', 'SI', 'ES', 'NQ']; |
| 20 | + |
| 21 | +// Display aliases for CFTC instrument codes that aren't self-explanatory. |
| 22 | +const INSTRUMENT_LABELS: Record<string, string> = { |
| 23 | + ES: 'S&P 500 futures', |
| 24 | + NQ: 'Nasdaq futures', |
| 25 | +}; |
| 26 | + |
| 27 | +function toNum(v: string | number): number { |
| 28 | + if (typeof v === 'number') return v; |
| 29 | + const n = parseInt(String(v), 10); |
| 30 | + return Number.isFinite(n) ? n : 0; |
| 31 | +} |
| 32 | + |
| 33 | +function renderShiftPill(value: number | null): string { |
| 34 | + if (value === null) return '<span class="commodity-change">—</span>'; |
| 35 | + return `<span class="commodity-change ${getChangeClass(value)}">${formatChange(value)}</span>`; |
| 36 | +} |
| 37 | + |
| 38 | +function pct(longPos: number, shortPos: number): number | null { |
| 39 | + const gross = longPos + shortPos; |
| 40 | + if (gross <= 0) return null; |
| 41 | + return ((longPos - shortPos) / gross) * 100; |
| 42 | +} |
| 43 | + |
| 44 | +function formatLevShift(value: number | null): string { |
| 45 | + if (value === null) return '—'; |
| 46 | + const sign = value >= 0 ? '+' : ''; |
| 47 | + return `${sign}${value.toFixed(1)}%`; |
| 48 | +} |
| 49 | + |
| 50 | +export class LiquidityShiftsPanel extends Panel { |
| 51 | + private _hasData = false; |
| 52 | + |
| 53 | + constructor() { |
| 54 | + super({ |
| 55 | + id: 'liquidity-shifts', |
| 56 | + title: t('components.liquidityShifts.title'), |
| 57 | + showCount: false, |
| 58 | + infoTooltip: t('components.liquidityShifts.infoTooltip'), |
| 59 | + }); |
| 60 | + } |
| 61 | + |
| 62 | + public async fetchData(): Promise<boolean> { |
| 63 | + this.showLoading(); |
| 64 | + try { |
| 65 | + const client = await getMarketClient(); |
| 66 | + const [cotResp, stocksResp] = await Promise.all([ |
| 67 | + client.getCotPositioning({}), |
| 68 | + client.listMarketQuotes({ symbols: TOP_STOCKS }), |
| 69 | + ]); |
| 70 | + |
| 71 | + const cotRows = (cotResp.instruments ?? []) |
| 72 | + .filter((i) => COT_PRIORITY.includes(i.code ?? '')) |
| 73 | + .sort((a, b) => COT_PRIORITY.indexOf(a.code ?? '') - COT_PRIORITY.indexOf(b.code ?? '')); |
| 74 | + |
| 75 | + if (cotRows.length === 0 && (stocksResp.quotes?.length ?? 0) === 0) { |
| 76 | + if (!this._hasData) this.showError(t('components.liquidityShifts.unavailable'), () => void this.fetchData()); |
| 77 | + return false; |
| 78 | + } |
| 79 | + |
| 80 | + this._hasData = true; |
| 81 | + const cotHtml = cotRows.map((row) => { |
| 82 | + const longPos = toNum(row.assetManagerLong ?? 0); |
| 83 | + const shortPos = toNum(row.assetManagerShort ?? 0); |
| 84 | + const net = pct(longPos, shortPos); |
| 85 | + const levLong = toNum(row.leveragedFundsLong ?? 0); |
| 86 | + const levShort = toNum(row.leveragedFundsShort ?? 0); |
| 87 | + // CFTC Disaggregated report (GC/SI/CL) has no Leveraged Funds |
| 88 | + // category — only TFF report (ES/NQ) does. Skip the sub-line entirely |
| 89 | + // rather than render a misleading "Lev +0.0%" for commodity rows. |
| 90 | + const hasLev = levLong > 0 || levShort > 0; |
| 91 | + const levNet = hasLev ? pct(levLong, levShort) : null; |
| 92 | + const code = row.code ?? ''; |
| 93 | + const label = INSTRUMENT_LABELS[code] ?? row.name ?? code; |
| 94 | + const levLine = hasLev |
| 95 | + ? `<div class="market-symbol">${t('components.liquidityShifts.lev')} ${escapeHtml(formatLevShift(levNet))}</div>` |
| 96 | + : ''; |
| 97 | + |
| 98 | + return `<div class="liquidity-row"> |
| 99 | + <div class="liquidity-row__info"> |
| 100 | + <div class="market-name">${escapeHtml(label)}</div> |
| 101 | + <div class="market-symbol">${escapeHtml(code)} • ${t('components.liquidityShifts.longShort', { long: String(longPos), short: String(shortPos) })}</div> |
| 102 | + </div> |
| 103 | + <div class="liquidity-row__values"> |
| 104 | + <div>${renderShiftPill(net)}</div> |
| 105 | + ${levLine} |
| 106 | + </div> |
| 107 | + </div>`; |
| 108 | + }).join(''); |
| 109 | + |
| 110 | + // The RPC preserves seed-bootstrap order, not request order, so re-sort |
| 111 | + // by TOP_STOCKS to keep the panel's row order stable. |
| 112 | + const stockOrder = new Map(TOP_STOCKS.map((sym, i) => [sym, i])); |
| 113 | + const stocks = [...(stocksResp.quotes ?? [])].sort((a, b) => { |
| 114 | + const ai = stockOrder.get(a.symbol ?? '') ?? Number.MAX_SAFE_INTEGER; |
| 115 | + const bi = stockOrder.get(b.symbol ?? '') ?? Number.MAX_SAFE_INTEGER; |
| 116 | + return ai - bi; |
| 117 | + }); |
| 118 | + const stockRows = stocks |
| 119 | + .map((q) => { |
| 120 | + const ch = Number(q.change ?? 0); |
| 121 | + return `<div class="market-item liquidity-stock-row"> |
| 122 | + <div class="market-info"> |
| 123 | + <span class="market-name">${escapeHtml(q.name || q.symbol || '')}</span> |
| 124 | + <span class="market-symbol">${escapeHtml(q.symbol || '')}</span> |
| 125 | + </div> |
| 126 | + <div>${renderShiftPill(ch)}</div> |
| 127 | + </div>`; |
| 128 | + }) |
| 129 | + .join(''); |
| 130 | + |
| 131 | + const emptyCot = `<div class="market-symbol">${t('components.liquidityShifts.noCot')}</div>`; |
| 132 | + const emptyStocks = `<div class="market-symbol">${t('components.liquidityShifts.noStocks')}</div>`; |
| 133 | + const reportDateLine = cotResp.reportDate |
| 134 | + ? `<div class="market-symbol liquidity-report-date">${t('components.liquidityShifts.reportDate', { date: cotResp.reportDate })}</div>` |
| 135 | + : ''; |
| 136 | + |
| 137 | + this.setContent(` |
| 138 | + <div class="liquidity-shifts-panel"> |
| 139 | + <div class="liquidity-shifts-panel__section-title">${t('components.liquidityShifts.cotSection')}</div> |
| 140 | + ${cotHtml || emptyCot} |
| 141 | + <div class="liquidity-shifts-panel__section-title liquidity-shifts-panel__section-title--gap">${t('components.liquidityShifts.stocksSection')}</div> |
| 142 | + ${stockRows || emptyStocks} |
| 143 | + ${reportDateLine} |
| 144 | + </div> |
| 145 | + `); |
| 146 | + return true; |
| 147 | + } catch (e) { |
| 148 | + if (!this._hasData) this.showError(e instanceof Error ? e.message : t('components.liquidityShifts.failed'), () => void this.fetchData()); |
| 149 | + return false; |
| 150 | + } |
| 151 | + } |
| 152 | +} |
0 commit comments