import type { AssetClass, CanonicalQuote, Instrument } from "@market-atlas/market-model"; import { PUBLICLY_REDISTRIBUTABLE } from "@market-atlas/market-model"; import { instruments } from "./instruments.js"; import { quoteStore } from "./quotes.js"; /** * Source Coverage Engine — "one instrument → many observers → one canonical market state". * Every quoted instrument gets a redundancy target by tier and a coverage status; the gap list is the * source-expansion queue. Counts are *independent families*, never raw connector counts. */ export type CoverageTier = "A" | "B" | "C" | "D"; export type CoverageStatus = "COVERED" | "UNDERCOVERED" | "SINGLE_SOURCE" | "NO_DATA"; export interface CoverageTarget { tier: CoverageTier; families: number; // minimum independent families observations: number; // fresh observations (any role) aimed for } export interface InstrumentCoverage { instrumentId: string; symbol: string; name: string; assetClass: AssetClass; tier: CoverageTier; target: CoverageTarget; families: number; observations: number; proxies: number; validators: number; liveSources: number; delayedSources: number; officialSources: number; comparability: CanonicalQuote["comparability"]; score: number; // 0..1 status: CoverageStatus; gap: number; // missing independent families to reach the target } /** Tier-A majors: the instruments that must be observed through several independent paths. */ const TIER_A_SYMBOLS = new Set([ "AAPL", "MSFT", "NVDA", "AMZN", "GOOGL", "META", "TSLA", "SPY", "QQQ", "SPX", "_SPX", "NDX", "_NDX", "DJI", "_DJI", "RUT", "_RUT", "VIX", "_VIX", "BTC-USD", "ETH-USD", "BTC-USDT", "ETH-USDT", "SOL-USD", "XRP-USD", "EURUSD", "USDJPY", "GBPUSD", "USDCAD", "USDCHF", "AUDUSD", "US10Y", "US2Y", "US30Y", "US3M", "GC=F", "CL=F", ]); const TIER_A_IDS = new Set(["crypto_btc_usd", "crypto_eth_usd", "crypto_btc_usdt", "crypto_eth_usdt", "fx_eur_usd", "fx_usd_jpy", "fx_gbp_usd", "fx_usd_cad", "index_us_spx", "index_us_ndx", "index_us_dji", "rate_us_us10y"]); export function tierOf(inst: Instrument): CoverageTier { // Official single-source reference series (yields, policy rates): the official publisher is the truth — one source is acceptable. if (inst.assetClass === "TREASURY" || inst.assetClass === "INTEREST_RATE" || inst.assetClass === "BOND") return "D"; if (TIER_A_IDS.has(inst.id) || TIER_A_SYMBOLS.has(inst.symbol) || TIER_A_SYMBOLS.has(inst.symbol.toUpperCase())) return "A"; if (inst.metadata?.featured) return "B"; if (inst.assetClass === "CRYPTO" || inst.assetClass === "FOREX" || inst.assetClass === "INDEX" || inst.assetClass === "ETF") return "B"; return "C"; } export const TARGETS: Record = { A: { tier: "A", families: 3, observations: 5 }, B: { tier: "B", families: 2, observations: 3 }, C: { tier: "C", families: 1, observations: 2 }, D: { tier: "D", families: 1, observations: 1 }, }; export function coverageOf(inst: Instrument, q: CanonicalQuote | undefined): InstrumentCoverage { const tier = tierOf(inst); const target = TARGETS[tier]; const fresh = (q?.contributions ?? []).filter((c) => c.reason !== "stale"); const included = fresh.filter((c) => c.included); const families = q?.sourceCount ?? 0; const observations = q?.observationCount ?? fresh.length; const live = included.filter((c) => c.realtimeStatus === "REALTIME").length; const delayed = included.filter((c) => c.realtimeStatus === "DELAYED").length; const official = fresh.filter((c) => c.rightsStatus === "OFFICIAL_OPEN_DATA").length; const proxies = q?.proxyCount ?? 0; const validators = q?.validatorCount ?? 0; const hasPrice = q?.price != null && PUBLICLY_REDISTRIBUTABLE.has(q.rightsStatus); const famScore = Math.min(1, families / target.families); const obsScore = Math.min(1, (observations + 0.5 * proxies + 0.5 * validators) / target.observations); const score = hasPrice ? Math.round((0.65 * famScore + 0.35 * obsScore) * 100) / 100 : 0; const status: CoverageStatus = !hasPrice ? "NO_DATA" : families >= target.families && observations + proxies + validators >= Math.min(target.observations, target.families) ? "COVERED" : families <= 1 ? "SINGLE_SOURCE" : "UNDERCOVERED"; return { instrumentId: inst.id, symbol: inst.symbol, name: inst.name, assetClass: inst.assetClass, tier, target, families, observations, proxies, validators, liveSources: live, delayedSources: delayed, officialSources: official, comparability: q?.comparability ?? null, score, status, gap: Math.max(0, target.families - families), }; } export interface CoverageSummary { quoted: number; multiSource: number; byFamilies: { ">=5": number; ">=3": number; ">=2": number; "1": number; "0": number }; weightedScore: number; // 0..100 tiers: Record; byAssetClass: Record; proxiesConfirming: number; validatorsConfirming: number; } export function coverageSummary(list: InstrumentCoverage[]): CoverageSummary { const quoted = list.filter((c) => c.status !== "NO_DATA"); const count = (min: number) => quoted.filter((c) => c.families >= min).length; const tiers = {} as CoverageSummary["tiers"]; for (const t of ["A", "B", "C", "D"] as CoverageTier[]) { const items = quoted.filter((c) => c.tier === t); const covered = items.filter((c) => c.status === "COVERED").length; tiers[t] = { instruments: items.length, covered, attainment: items.length ? Math.round((covered / items.length) * 1000) / 10 : 0 }; } const byAssetClass: CoverageSummary["byAssetClass"] = {}; for (const c of quoted) { const b = (byAssetClass[c.assetClass] ??= { instruments: 0, multiSource: 0, meanFamilies: 0 }); b.instruments++; if (c.families >= 2) b.multiSource++; b.meanFamilies += c.families; } for (const b of Object.values(byAssetClass)) b.meanFamilies = b.instruments ? Math.round((b.meanFamilies / b.instruments) * 100) / 100 : 0; // Weighted redundancy: tier A counts 3×, B 2×, C/D 1×. const w = (t: CoverageTier) => (t === "A" ? 3 : t === "B" ? 2 : 1); const num = quoted.reduce((a, c) => a + w(c.tier) * c.score, 0); const den = quoted.reduce((a, c) => a + w(c.tier), 0); return { quoted: quoted.length, multiSource: count(2), byFamilies: { ">=5": count(5), ">=3": count(3), ">=2": count(2), "1": quoted.filter((c) => c.families === 1).length, "0": quoted.filter((c) => c.families === 0).length }, weightedScore: den ? Math.round((num / den) * 1000) / 10 : 0, tiers, byAssetClass, proxiesConfirming: quoted.reduce((a, c) => a + c.proxies, 0), validatorsConfirming: quoted.reduce((a, c) => a + c.validators, 0), }; } /** Coverage of every instrument that has a canonical quote (cheap: in-memory). */ export function coverageAll(): InstrumentCoverage[] { const out: InstrumentCoverage[] = []; for (const q of quoteStore.all()) { const inst = instruments.get(q.instrumentId); if (inst && inst.isActive) out.push(coverageOf(inst, q)); } return out; } /** Source-expansion queue: the most valuable gaps first (tier weight × gap, then lowest score). */ export function expansionQueue(list: InstrumentCoverage[], limit = 50): InstrumentCoverage[] { const w = (t: CoverageTier) => (t === "A" ? 3 : t === "B" ? 2 : 1); return list .filter((c) => c.status !== "NO_DATA" && c.status !== "COVERED") .sort((a, b) => w(b.tier) * (b.gap + 1) - w(a.tier) * (a.gap + 1) || a.score - b.score || a.symbol.localeCompare(b.symbol)) .slice(0, limit); }