| import { Panel } from './Panel'; |
| import { getRpcBaseUrl } from '@/services/rpc-client'; |
| import { t } from '@/services/i18n'; |
| import { escapeHtml, unsafeRawHtml } from '@/utils/sanitize'; |
|
|
| import type { ListEtfFlowsResponse } from '@/generated/client/worldmonitor/market/v1/service_client'; |
| import { getHydratedData } from '@/services/bootstrap'; |
| import { MarketServiceClient } from '@/services/generated-rpc-clients'; |
|
|
| type ETFFlowsResult = ListEtfFlowsResponse; |
|
|
| function formatVolume(v: number): string { |
| if (Math.abs(v) >= 1e9) return `${(v / 1e9).toFixed(1)}B`; |
| if (Math.abs(v) >= 1e6) return `${(v / 1e6).toFixed(1)}M`; |
| if (Math.abs(v) >= 1e3) return `${(v / 1e3).toFixed(0)}K`; |
| return v.toLocaleString(); |
| } |
|
|
| function flowClass(direction: string): string { |
| if (direction === 'inflow') return 'flow-inflow'; |
| if (direction === 'outflow') return 'flow-outflow'; |
| return 'flow-neutral'; |
| } |
|
|
| function changeClass(val: number): string { |
| if (val > 0.1) return 'change-positive'; |
| if (val < -0.1) return 'change-negative'; |
| return 'change-neutral'; |
| } |
|
|
| export class ETFFlowsPanel extends Panel { |
| private data: ETFFlowsResult | null = null; |
| private loading = true; |
| private error: string | null = null; |
| constructor() { |
| super({ id: 'etf-flows', title: t('panels.etfFlows'), showCount: false, infoTooltip: t('components.etfFlows.infoTooltip') }); |
| } |
|
|
| public async fetchData(): Promise<void> { |
| const hydrated = getHydratedData('etfFlows') as ETFFlowsResult | undefined; |
| if (hydrated?.etfs?.length) { |
| this.data = hydrated; |
| this.error = null; |
| this.loading = false; |
| this.renderPanel(); |
| void this.refreshFromRpc(); |
| return; |
| } |
| await this.refreshFromRpc(); |
| } |
|
|
| private async refreshFromRpc(): Promise<void> { |
| try { |
| const client = new MarketServiceClient(getRpcBaseUrl(), { fetch: (...args) => globalThis.fetch(...args) }); |
| const fresh = await client.listEtfFlows({}); |
| if (!this.element?.isConnected) return; |
| if (fresh.etfs?.length || !this.data) { |
| this.data = fresh; |
| this.error = null; |
| this.loading = false; |
| this.renderPanel(); |
| } |
| } catch (err) { |
| if (this.isAbortError(err)) return; |
| if (!this.element?.isConnected) return; |
| if (!this.data) { |
| console.warn('[ETFFlows] Fetch error:', err); |
| this.error = t('components.etfFlows.unavailable'); |
| this.loading = false; |
| this.renderPanel(); |
| } |
| } |
| } |
|
|
| private renderPanel(): void { |
| if (this.loading) { |
| this.showLoading(t('common.loadingEtfData')); |
| return; |
| } |
|
|
| if (this.error || !this.data) { |
| this.showError(this.error || t('common.noDataShort'), () => void this.fetchData()); |
| return; |
| } |
|
|
| const d = this.data; |
| if (!d.etfs?.length) { |
| const msg = d.rateLimited ? t('components.etfFlows.rateLimited') : t('components.etfFlows.unavailable'); |
| this.setSafeContent(unsafeRawHtml(`<div class="panel-loading-text">${msg}</div>`, 'legacy Panel.setContent() migration')); |
| return; |
| } |
|
|
| const s = d.summary || { etfCount: 0, totalVolume: 0, totalEstFlow: 0, netDirection: 'NEUTRAL', inflowCount: 0, outflowCount: 0 }; |
| const dirClass = s.netDirection.includes('INFLOW') ? 'flow-inflow' : s.netDirection.includes('OUTFLOW') ? 'flow-outflow' : 'flow-neutral'; |
|
|
| const rows = d.etfs.map(etf => ` |
| <tr class="etf-row ${flowClass(etf.direction)}"> |
| <td class="etf-ticker">${escapeHtml(etf.ticker)}</td> |
| <td class="etf-issuer">${escapeHtml(etf.issuer)}</td> |
| <td class="etf-flow ${flowClass(etf.direction)}">${etf.direction === 'inflow' ? '+' : etf.direction === 'outflow' ? '-' : ''}$${formatVolume(Math.abs(etf.estFlow))}</td> |
| <td class="etf-volume">${formatVolume(etf.volume)}</td> |
| <td class="etf-change ${changeClass(etf.priceChange)}">${etf.priceChange > 0 ? '+' : ''}${etf.priceChange.toFixed(2)}%</td> |
| </tr> |
| `).join(''); |
|
|
| const html = ` |
| <div class="etf-flows-container"> |
| <div class="etf-summary ${dirClass}"> |
| <div class="etf-summary-item"> |
| <span class="etf-summary-label">${t('components.etfFlows.netFlow')}</span> |
| <span class="etf-summary-value ${dirClass}">${s.netDirection.includes('INFLOW') ? t('components.etfFlows.netInflow') : t('components.etfFlows.netOutflow')}</span> |
| </div> |
| <div class="etf-summary-item"> |
| <span class="etf-summary-label">${t('components.etfFlows.estFlow')}</span> |
| <span class="etf-summary-value">$${formatVolume(Math.abs(s.totalEstFlow))}</span> |
| </div> |
| <div class="etf-summary-item"> |
| <span class="etf-summary-label">${t('components.etfFlows.totalVol')}</span> |
| <span class="etf-summary-value">${formatVolume(s.totalVolume)}</span> |
| </div> |
| <div class="etf-summary-item"> |
| <span class="etf-summary-label">${t('components.etfFlows.etfs')}</span> |
| <span class="etf-summary-value">${s.inflowCount}↑ ${s.outflowCount}↓</span> |
| </div> |
| </div> |
| <div class="etf-table-wrap"> |
| <table class="etf-table"> |
| <thead> |
| <tr> |
| <th>${t('components.etfFlows.table.ticker')}</th> |
| <th>${t('components.etfFlows.table.issuer')}</th> |
| <th>${t('components.etfFlows.table.estFlow')}</th> |
| <th>${t('components.etfFlows.table.volume')}</th> |
| <th>${t('components.etfFlows.table.change')}</th> |
| </tr> |
| </thead> |
| <tbody>${rows}</tbody> |
| </table> |
| </div> |
| </div> |
| `; |
|
|
| this.setSafeContent(unsafeRawHtml(html, 'legacy Panel.setContent() migration')); |
| } |
| } |
|
|