import { resolvePriceBasis } from "../market/price-basis"; import type { Quote, QuoteContribution, QuoteContributionMap, SessionConfidence, TickerFinancials, } from "../../types/financials"; import { quoteTradingDay, reconcileQuoteDayRange } from "./day-range"; const RETAINED_DESCRIPTIVE_FIELDS = ["high52w", "low52w", "marketCap", "name", "instrumentType"] as const; const PRICE_DENOMINATED_DESCRIPTIVE_FIELDS: ReadonlySet = new Set(["high52w", "low52w", "marketCap"]); /** * Values that belong to one trading day. A quote from another day that leaves * them out has no anchor for its own day yet, and keeping the old day's value * would show yesterday's close, open or volume as today's. The day range is * reconciled separately (reconcileQuoteDayRange). */ const TRADING_DAY_FIELDS = [ "changeSessionDate", "previousClose", "open", "volume", "mark", "bid", "ask", "bidSize", "askSize", "lastTradePrice", "lastTradeTime", ] as const; /** * Whether two observations from one source describe different trading days. * Only sources that declare the day they anchor to are judged; for the rest a * partial tick may legitimately rely on the fields it already sent. */ function isOtherTradingDay(current: Quote, next: Quote): boolean { if (!current.changeSessionDate && !next.changeSessionDate) return false; const currentDay = quoteTradingDay(current); const nextDay = quoteTradingDay(next); return currentDay !== null && nextDay !== null && nextDay !== currentDay; } function inferQuoteProviderId(quote: Quote | QuoteContribution): string { if (quote.providerId?.trim()) return quote.providerId; return "quote"; } function inferSessionConfidence( quote: Quote | QuoteContribution, providerId: string, ): SessionConfidence { if (quote.sessionConfidence) return quote.sessionConfidence; if (providerId === "ibkr") return "unknown"; if (quote.marketState) return providerId === "gloomberb-cloud" ? "derived" : "derived"; return "unknown"; } function normalizeListingExchange( quote: Quote | QuoteContribution, providerId: string, ): { listingExchangeName?: string; listingExchangeFullName?: string } { const listingExchangeName = quote.listingExchangeName ?? quote.exchangeName; const listingExchangeFullName = quote.listingExchangeFullName ?? quote.fullExchangeName ?? listingExchangeName; if (providerId === "ibkr" && listingExchangeName === "SMART") { return { listingExchangeName: undefined, listingExchangeFullName: undefined, }; } return { listingExchangeName, listingExchangeFullName }; } function shouldProjectSessionPrice(quote: Quote | QuoteContribution): boolean { // An explicit market state identifies the session, not which session supplied // the last trade. Delayed snapshots can still contain the regular close. return quote.dataSource === "live"; } export function finalizeSessionFields( quote: QuoteContribution, options: { allowPriceProjection?: boolean } = {}, ): QuoteContribution { const allowPriceProjection = options.allowPriceProjection !== false; const canProjectSessionPrice = allowPriceProjection && shouldProjectSessionPrice(quote); if (quote.marketState === "PRE") { return { ...quote, preMarketPrice: quote.preMarketPrice ?? (canProjectSessionPrice ? quote.price : undefined), preMarketChange: quote.preMarketChange ?? (canProjectSessionPrice ? quote.change : undefined), preMarketChangePercent: quote.preMarketChangePercent ?? (canProjectSessionPrice ? quote.changePercent : undefined), postMarketPrice: undefined, postMarketChange: undefined, postMarketChangePercent: undefined, }; } if (quote.marketState === "POST") { return { ...quote, preMarketPrice: undefined, preMarketChange: undefined, preMarketChangePercent: undefined, postMarketPrice: quote.postMarketPrice ?? (canProjectSessionPrice ? quote.price : undefined), // A daily move uses the previous day's close, not the after-hours baseline. postMarketChange: quote.postMarketChange, postMarketChangePercent: quote.postMarketChangePercent, }; } if (quote.marketState != null) { return { ...quote, preMarketPrice: undefined, preMarketChange: undefined, preMarketChangePercent: undefined, postMarketPrice: undefined, postMarketChange: undefined, postMarketChangePercent: undefined, }; } return quote; } export function getQuoteContributionKey(quote: Quote | QuoteContribution): string { return inferQuoteProviderId(quote); } export function normalizeQuoteContribution( quote: Quote | QuoteContribution | null | undefined, ): QuoteContribution | undefined { if (!quote) return undefined; const providerId = inferQuoteProviderId(quote); const { listingExchangeName, listingExchangeFullName } = normalizeListingExchange(quote, providerId); const routingExchangeName = quote.routingExchangeName; const routingExchangeFullName = quote.routingExchangeFullName ?? routingExchangeName; return finalizeSessionFields(reconcileQuoteDayRange({ ...quote, providerId, listingExchangeName, listingExchangeFullName, routingExchangeName, routingExchangeFullName, exchangeName: listingExchangeName ?? quote.exchangeName, fullExchangeName: listingExchangeFullName ?? quote.fullExchangeName, sessionConfidence: inferSessionConfidence(quote, providerId), })); } export function mergeQuoteContribution( current: QuoteContribution | undefined, nextQuote: Quote | QuoteContribution, ): QuoteContribution { const next = normalizeQuoteContribution(nextQuote); if (!next) { throw new Error("Cannot merge an empty quote contribution"); } if (!current) return next; const priorBasis = resolvePriceBasis(current.priceBasis, current.instrumentType); const samePriceBasis = priorBasis !== null && priorBasis === resolvePriceBasis(next.priceBasis, next.instrumentType || current.instrumentType); const merged: QuoteContribution = { ...current, ...reconcileQuoteDayRange(next, current), // Close provenance belongs to this observation; never borrow an anchor // from another session, listing or currency when a new quote omits it. ...(!samePriceBasis ? { previousClose: next.previousClose, bid: next.bid, ask: next.ask, open: next.open, mark: next.mark, high52w: next.high52w, low52w: next.low52w, lastTradePrice: next.lastTradePrice, lastTradeTime: next.lastTradeTime, preMarketPrice: next.preMarketPrice, preMarketChange: next.preMarketChange, preMarketChangePercent: next.preMarketChangePercent, postMarketPrice: next.postMarketPrice, postMarketChange: next.postMarketChange, postMarketChangePercent: next.postMarketChangePercent, } : {}), priceBasis: next.priceBasis, regularClose: next.regularClose, regularCloseSessionDate: next.regularClose != null ? next.regularCloseSessionDate : undefined, }; if (isOtherTradingDay(current, next)) { const record = merged as unknown as Record; for (const field of TRADING_DAY_FIELDS) { if (next[field] === undefined) delete record[field]; } } // Leaving a descriptive field out is not a retraction. Price-denominated // ones only carry over while the price convention is unchanged. for (const field of RETAINED_DESCRIPTIVE_FIELDS) { if (merged[field] === undefined && current[field] !== undefined && (samePriceBasis || !PRICE_DENOMINATED_DESCRIPTIVE_FIELDS.has(field))) { (merged as unknown as Record)[field] = current[field]; } } if (next.marketState == null && current.marketState != null) { merged.marketState = current.marketState; } if (next.sessionConfidence == null && current.sessionConfidence != null) { merged.sessionConfidence = current.sessionConfidence; } if ((current.marketState === "PRE" || current.marketState === "POST") && next.marketState == null && samePriceBasis) { const canProjectNextSessionPrice = shouldProjectSessionPrice(next); merged.marketState = current.marketState; if (current.marketState === "PRE") { merged.preMarketPrice = next.preMarketPrice ?? (canProjectNextSessionPrice ? next.price : current.preMarketPrice); merged.preMarketChange = next.preMarketChange ?? (canProjectNextSessionPrice ? next.change : current.preMarketChange); merged.preMarketChangePercent = next.preMarketChangePercent ?? (canProjectNextSessionPrice ? next.changePercent : current.preMarketChangePercent); } else { merged.postMarketPrice = next.postMarketPrice ?? (canProjectNextSessionPrice ? next.price : current.postMarketPrice); const changesPostPrice = next.postMarketPrice != null || canProjectNextSessionPrice; merged.postMarketChange = next.postMarketChange ?? (changesPostPrice ? undefined : current.postMarketChange); merged.postMarketChangePercent = next.postMarketChangePercent ?? (changesPostPrice ? undefined : current.postMarketChangePercent); } } return finalizeSessionFields(merged); } export function mergeQuoteContributionMaps( preferred: QuoteContributionMap | undefined, fallback: QuoteContributionMap | undefined, ): QuoteContributionMap | undefined { const entries = new Map(); for (const source of [fallback, preferred]) { if (!source) continue; for (const [key, quote] of Object.entries(source)) { const normalized = normalizeQuoteContribution(quote); if (!normalized) continue; const contributionKey = key || getQuoteContributionKey(normalized); entries.set( contributionKey, mergeQuoteContribution(entries.get(contributionKey), normalized), ); } } if (entries.size === 0) return undefined; return Object.fromEntries(entries.entries()); } function cloneQuoteContributionMap(map: QuoteContributionMap | undefined): QuoteContributionMap | undefined { return mergeQuoteContributionMaps(map, undefined); } export function quoteContributionValues(map: QuoteContributionMap | undefined): QuoteContribution[] { return Object.values(map ?? {}); } export function seedQuoteContributions(financials: TickerFinancials | null | undefined): QuoteContributionMap | undefined { if (!financials) return undefined; if (financials.quoteContributions && Object.keys(financials.quoteContributions).length > 0) { return cloneQuoteContributionMap(financials.quoteContributions); } const normalizedQuote = normalizeQuoteContribution(financials.quote); if (!normalizedQuote) return undefined; const key = getQuoteContributionKey(normalizedQuote); return { [key]: normalizedQuote }; }