SPB Git forge

spb/market-atlas

Public
12commits 1branches 0releases
1.1 MBsize
maindefault branch
10 days agolast push
TypeScript 96.7% SQL 1.6% CSS 0.8% JavaScript 0.5%
7.6 KB · 165 lines typescript
Raw Blame History
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