import type { MarketServiceClient } from '@/generated/client/worldmonitor/market/v1/service_client'; import { Panel } from './Panel'; import { t } from '@/services/i18n'; import { escapeHtml, unsafeRawHtml } from '@/utils/sanitize'; let _client: MarketServiceClient | null = null; async function getMarketClient(): Promise { if (!_client) { const { MarketServiceClient } = await import('@/generated/client/worldmonitor/market/v1/service_client'); const { getRpcBaseUrl } = await import('@/services/rpc-client'); _client = new MarketServiceClient(getRpcBaseUrl(), { fetch: (...args: Parameters) => globalThis.fetch(...args) }); } return _client; } interface CotInstrumentData { name: string; code: string; reportDate: string; assetManagerLong: string; assetManagerShort: string; leveragedFundsLong: string; leveragedFundsShort: string; dealerLong: string; dealerShort: string; netPct: number; } function toNum(v: string | number): number { return typeof v === 'number' ? v : parseInt(String(v), 10) || 0; } function renderPositionBar(netPct: number, label: string): string { const clamped = Math.max(-100, Math.min(100, netPct)); const halfWidth = Math.abs(clamped) / 100 * 50; const color = clamped >= 0 ? '#2ecc71' : '#e74c3c'; const leftPct = clamped >= 0 ? 50 : 50 - halfWidth; const sign = clamped >= 0 ? '+' : ''; return `
${escapeHtml(label)} ${sign}${clamped.toFixed(1)}%
`; } function renderInstrument(item: CotInstrumentData): string { const levLong = toNum(item.leveragedFundsLong); const levShort = toNum(item.leveragedFundsShort); const amNetPct = item.netPct; const levNetPct = ((levLong - levShort) / Math.max(levLong + levShort, 1)) * 100; return `
${escapeHtml(item.name)} ${escapeHtml(item.code)}
${renderPositionBar(amNetPct, 'Asset Managers')} ${renderPositionBar(levNetPct, 'Leveraged Funds')}
`; } export class CotPositioningPanel extends Panel { private _hasData = false; constructor() { super({ id: 'cot-positioning', title: 'CFTC COT Positioning', showCount: false, infoTooltip: t('components.cotPositioning.infoTooltip') }); } public async fetchData(): Promise { this.showLoading(); try { const client = await getMarketClient(); const resp = await client.getCotPositioning({}); if (resp.unavailable || !resp.instruments || resp.instruments.length === 0) { if (!this._hasData) this.showError('COT data unavailable', () => void this.fetchData()); return false; } this._hasData = true; this.render(resp.instruments as CotInstrumentData[], resp.reportDate ?? ''); return true; } catch (e) { if (!this._hasData) this.showError(e instanceof Error ? e.message : 'Failed to load', () => void this.fetchData()); return false; } } private render(instruments: CotInstrumentData[], reportDate: string): void { const rows = instruments.map(renderInstrument).join(''); const dateFooter = reportDate ? `
Report date: ${escapeHtml(reportDate)}
` : ''; const html = `
${rows} ${dateFooter}
`; this.setSafeContent(unsafeRawHtml(html, 'legacy Panel.setContent() migration')); } }