spb/market-atlas
Public
TypeScript 96.7%
SQL 1.6%
CSS 0.8%
JavaScript 0.5%
1import type { AssetClass, CanonicalQuote, Instrument } from "@market-atlas/market-model";2import { PUBLICLY_REDISTRIBUTABLE } from "@market-atlas/market-model";3import { instruments } from "./instruments.js";4import { quoteStore } from "./quotes.js";56/**7 * Source Coverage Engine — "one instrument → many observers → one canonical market state".8 * Every quoted instrument gets a redundancy target by tier and a coverage status; the gap list is the9 * source-expansion queue. Counts are *independent families*, never raw connector counts.10 */11export type CoverageTier = "A" | "B" | "C" | "D";12export type CoverageStatus = "COVERED" | "UNDERCOVERED" | "SINGLE_SOURCE" | "NO_DATA";1314export interface CoverageTarget {15 tier: CoverageTier;16 families: number; // minimum independent families17 observations: number; // fresh observations (any role) aimed for18}1920export interface InstrumentCoverage {21 instrumentId: string;22 symbol: string;23 name: string;24 assetClass: AssetClass;25 tier: CoverageTier;26 target: CoverageTarget;27 families: number;28 observations: number;29 proxies: number;30 validators: number;31 liveSources: number;32 delayedSources: number;33 officialSources: number;34 comparability: CanonicalQuote["comparability"];35 score: number; // 0..136 status: CoverageStatus;37 gap: number; // missing independent families to reach the target38}3940/** Tier-A majors: the instruments that must be observed through several independent paths. */41const TIER_A_SYMBOLS = new Set([42 "AAPL", "MSFT", "NVDA", "AMZN", "GOOGL", "META", "TSLA", "SPY", "QQQ", "SPX", "_SPX", "NDX", "_NDX", "DJI", "_DJI", "RUT", "_RUT", "VIX", "_VIX",43 "BTC-USD", "ETH-USD", "BTC-USDT", "ETH-USDT", "SOL-USD", "XRP-USD",44 "EURUSD", "USDJPY", "GBPUSD", "USDCAD", "USDCHF", "AUDUSD",45 "US10Y", "US2Y", "US30Y", "US3M", "GC=F", "CL=F",46]);47const 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"]);4849export function tierOf(inst: Instrument): CoverageTier {50 // Official single-source reference series (yields, policy rates): the official publisher is the truth — one source is acceptable.51 if (inst.assetClass === "TREASURY" || inst.assetClass === "INTEREST_RATE" || inst.assetClass === "BOND") return "D";52 if (TIER_A_IDS.has(inst.id) || TIER_A_SYMBOLS.has(inst.symbol) || TIER_A_SYMBOLS.has(inst.symbol.toUpperCase())) return "A";53 if (inst.metadata?.featured) return "B";54 if (inst.assetClass === "CRYPTO" || inst.assetClass === "FOREX" || inst.assetClass === "INDEX" || inst.assetClass === "ETF") return "B";55 return "C";56}5758export const TARGETS: Record<CoverageTier, CoverageTarget> = {59 A: { tier: "A", families: 3, observations: 5 },60 B: { tier: "B", families: 2, observations: 3 },61 C: { tier: "C", families: 1, observations: 2 },62 D: { tier: "D", families: 1, observations: 1 },63};6465export function coverageOf(inst: Instrument, q: CanonicalQuote | undefined): InstrumentCoverage {66 const tier = tierOf(inst);67 const target = TARGETS[tier];68 const fresh = (q?.contributions ?? []).filter((c) => c.reason !== "stale");69 const included = fresh.filter((c) => c.included);70 const families = q?.sourceCount ?? 0;71 const observations = q?.observationCount ?? fresh.length;72 const live = included.filter((c) => c.realtimeStatus === "REALTIME").length;73 const delayed = included.filter((c) => c.realtimeStatus === "DELAYED").length;74 const official = fresh.filter((c) => c.rightsStatus === "OFFICIAL_OPEN_DATA").length;75 const proxies = q?.proxyCount ?? 0;76 const validators = q?.validatorCount ?? 0;77 const hasPrice = q?.price != null && PUBLICLY_REDISTRIBUTABLE.has(q.rightsStatus);78 const famScore = Math.min(1, families / target.families);79 const obsScore = Math.min(1, (observations + 0.5 * proxies + 0.5 * validators) / target.observations);80 const score = hasPrice ? Math.round((0.65 * famScore + 0.35 * obsScore) * 100) / 100 : 0;81 const status: CoverageStatus = !hasPrice ? "NO_DATA" : families >= target.families && observations + proxies + validators >= Math.min(target.observations, target.families) ? "COVERED" : families <= 1 ? "SINGLE_SOURCE" : "UNDERCOVERED";82 return {83 instrumentId: inst.id,84 symbol: inst.symbol,85 name: inst.name,86 assetClass: inst.assetClass,87 tier,88 target,89 families,90 observations,91 proxies,92 validators,93 liveSources: live,94 delayedSources: delayed,95 officialSources: official,96 comparability: q?.comparability ?? null,97 score,98 status,99 gap: Math.max(0, target.families - families),100 };101}102103export interface CoverageSummary {104 quoted: number;105 multiSource: number;106 byFamilies: { ">=5": number; ">=3": number; ">=2": number; "1": number; "0": number };107 weightedScore: number; // 0..100108 tiers: Record<CoverageTier, { instruments: number; covered: number; attainment: number }>;109 byAssetClass: Record<string, { instruments: number; multiSource: number; meanFamilies: number }>;110 proxiesConfirming: number;111 validatorsConfirming: number;112}113114export function coverageSummary(list: InstrumentCoverage[]): CoverageSummary {115 const quoted = list.filter((c) => c.status !== "NO_DATA");116 const count = (min: number) => quoted.filter((c) => c.families >= min).length;117 const tiers = {} as CoverageSummary["tiers"];118 for (const t of ["A", "B", "C", "D"] as CoverageTier[]) {119 const items = quoted.filter((c) => c.tier === t);120 const covered = items.filter((c) => c.status === "COVERED").length;121 tiers[t] = { instruments: items.length, covered, attainment: items.length ? Math.round((covered / items.length) * 1000) / 10 : 0 };122 }123 const byAssetClass: CoverageSummary["byAssetClass"] = {};124 for (const c of quoted) {125 const b = (byAssetClass[c.assetClass] ??= { instruments: 0, multiSource: 0, meanFamilies: 0 });126 b.instruments++;127 if (c.families >= 2) b.multiSource++;128 b.meanFamilies += c.families;129 }130 for (const b of Object.values(byAssetClass)) b.meanFamilies = b.instruments ? Math.round((b.meanFamilies / b.instruments) * 100) / 100 : 0;131 // Weighted redundancy: tier A counts 3×, B 2×, C/D 1×.132 const w = (t: CoverageTier) => (t === "A" ? 3 : t === "B" ? 2 : 1);133 const num = quoted.reduce((a, c) => a + w(c.tier) * c.score, 0);134 const den = quoted.reduce((a, c) => a + w(c.tier), 0);135 return {136 quoted: quoted.length,137 multiSource: count(2),138 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 },139 weightedScore: den ? Math.round((num / den) * 1000) / 10 : 0,140 tiers,141 byAssetClass,142 proxiesConfirming: quoted.reduce((a, c) => a + c.proxies, 0),143 validatorsConfirming: quoted.reduce((a, c) => a + c.validators, 0),144 };145}146147/** Coverage of every instrument that has a canonical quote (cheap: in-memory). */148export function coverageAll(): InstrumentCoverage[] {149 const out: InstrumentCoverage[] = [];150 for (const q of quoteStore.all()) {151 const inst = instruments.get(q.instrumentId);152 if (inst && inst.isActive) out.push(coverageOf(inst, q));153 }154 return out;155}156157/** Source-expansion queue: the most valuable gaps first (tier weight × gap, then lowest score). */158export function expansionQueue(list: InstrumentCoverage[], limit = 50): InstrumentCoverage[] {159 const w = (t: CoverageTier) => (t === "A" ? 3 : t === "B" ? 2 : 1);160 return list161 .filter((c) => c.status !== "NO_DATA" && c.status !== "COVERED")162 .sort((a, b) => w(b.tier) * (b.gap + 1) - w(a.tier) * (a.gap + 1) || a.score - b.score || a.symbol.localeCompare(b.symbol))163 .slice(0, limit);164}165