/** * OrderbookService - Shadow logging for orderbook metrics (Phase 1) * * Purpose: Subscribe to Drift orderbook L2 data, compute metrics, expose for logging. * Current mode: SHADOW ONLY - does NOT gate trading decisions. * * Metrics computed: * - Spread bps: (bestAsk - bestBid) / mid * 10000 * - Top-N imbalance: (Σ bidQty - Σ askQty) / (Σ bidQty + Σ askQty) * - Market impact: depth required to move price by X bps * - Liquidity walls: largest opposing side sizes within distance bands * - Same-side depth: support for our direction within distance bands * * Usage: * ```typescript * const obService = await getOrderbookService() * await obService.subscribeToMarket('SOL-PERP') * const metrics = obService.getMetrics('SOL-PERP') * ``` */ import { logger } from '../utils/logger' import { getDriftService } from './client' import { getMarketConfig } from '../../config/trading' export interface OrderbookLevel { price: number size: number sizeUSD: number } export interface OrderbookSnapshot { symbol: string timestamp: number bids: OrderbookLevel[] asks: OrderbookLevel[] midPrice: number } export interface OrderbookMetrics { symbol: string timestamp: number // Spread spreadBps: number // (bestAsk - bestBid) / mid * 10000 // Imbalance (top 10 levels) imbalance: number // (Σ bidQty - Σ askQty) / total, range [-1, 1] // Depth analysis (0-2% from mid) oppDepth0_2pctUSD: number // opposing side cumulative USD within 2% (0.02 * mid) sameDepth0_2pctUSD: number // same side cumulative USD within 2% // Market impact impactBpsAtNotional: number // bps move to trade our typical notional // Largest opposing wall largestOppWallBps: number // distance to largest opposing wall in bps largestOppWallUSD: number // size of that wall in USD // Largest same-side wall (support for our direction) largestSameWallBps: number largestSameWallUSD: number // Quality flags isLiquid: boolean // spread < threshold hasOppWall: boolean // large wall within threshold distance } export interface OrderbookAnalysis { direction: 'long' | 'short' metrics: OrderbookMetrics notionalUSD: number } class OrderbookService { private subscriptions: Map = new Map() private readonly SPREAD_THRESHOLD_BPS = 5 // 5 bps = 0.05% private readonly WALL_THRESHOLD_USD = 50000 // $50k wall private readonly DEPTH_DISTANCE_PCT = 0.02 // 2% from mid private readonly TOP_N_LEVELS = 10 // for imbalance calc constructor() { logger.log('📖 OrderbookService initialized (SHADOW mode - on-demand snapshots)') } /** * Subscribe to orderbook for a market symbol * NOTE: This just maintains connection, no continuous updates */ async subscribeToMarket(symbol: string): Promise { if (this.subscriptions.has(symbol)) { logger.log(`📖 Already subscribed to orderbook: ${symbol}`) return } try { const driftService = await getDriftService() const marketConfig = getMarketConfig(symbol) // Store subscription reference (connection kept alive) this.subscriptions.set(symbol, { marketConfig }) logger.log(`📖 Subscribed to orderbook: ${symbol} (on-demand snapshots)`) } catch (error) { console.error(`❌ Failed to subscribe to orderbook ${symbol}:`, error) throw error } } /** * Unsubscribe from a market */ unsubscribe(symbol: string): void { this.subscriptions.delete(symbol) logger.log(`📖 Unsubscribed from orderbook: ${symbol}`) } /** * Get on-demand orderbook snapshot for a symbol * Phase 1: Simplified - uses oracle price + estimated spread * Phase 2+: Will integrate DLOB L2 data for real depth analysis */ private async getSnapshotNow(symbol: string): Promise { try { const marketConfig = getMarketConfig(symbol) if (!marketConfig) { console.error(`❌ No market config for ${symbol}`) return null } const driftService = getDriftService() // Phase 1: Use oracle price (fast, reliable for shadow logging) const oraclePrice = await driftService.getOraclePrice(marketConfig.driftMarketIndex) const midPrice = oraclePrice // Phase 1: Estimated L2 levels (placeholder for real DLOB data) // Typical SOL spread is 0.01-0.05%, we'll use 0.02% for estimates const spreadBps = 2 // 0.02% const spreadPrice = midPrice * (spreadBps / 10000) const bids: OrderbookLevel[] = [ { price: midPrice - spreadPrice, size: 10000, // Placeholder token size sizeUSD: 10000 * (midPrice - spreadPrice) // Placeholder USD value }, ] const asks: OrderbookLevel[] = [ { price: midPrice + spreadPrice, size: 10000, // Placeholder token size sizeUSD: 10000 * (midPrice + spreadPrice) // Placeholder USD value }, ] return { symbol, timestamp: Date.now(), bids, asks, midPrice } } catch (error) { console.error(`❌ Failed to get snapshot for ${symbol}:`, error) return null } } /** * Compute all orderbook metrics from snapshot */ private computeMetrics(snapshot: OrderbookSnapshot): OrderbookMetrics { const { bids, asks, midPrice } = snapshot // 1. Spread const spreadBps = ((asks[0].price - bids[0].price) / midPrice) * 10000 // 2. Imbalance (top N levels) const topBids = bids.slice(0, this.TOP_N_LEVELS) const topAsks = asks.slice(0, this.TOP_N_LEVELS) const bidQty = topBids.reduce((sum, b) => sum + b.sizeUSD, 0) const askQty = topAsks.reduce((sum, a) => sum + a.sizeUSD, 0) const imbalance = (bidQty - askQty) / (bidQty + askQty) // 3. Depth within 2% bands const depthThreshold = midPrice * this.DEPTH_DISTANCE_PCT const askDepth = asks .filter(a => a.price <= midPrice + depthThreshold) .reduce((sum, a) => sum + a.sizeUSD, 0) const bidDepth = bids .filter(b => b.price >= midPrice - depthThreshold) .reduce((sum, b) => sum + b.sizeUSD, 0) // 4. Find largest walls (for longs, opposing = asks) const largestAsk = asks.reduce((max, a) => a.sizeUSD > max.sizeUSD ? a : max, asks[0]) const largestBid = bids.reduce((max, b) => b.sizeUSD > max.sizeUSD ? b : max, bids[0]) return { symbol: snapshot.symbol, timestamp: snapshot.timestamp, spreadBps: Math.round(spreadBps * 100) / 100, imbalance: Math.round(imbalance * 1000) / 1000, // For LONG: opposing = asks (resistance), same = bids (support) // We'll store both and let consumer pick based on direction oppDepth0_2pctUSD: askDepth, sameDepth0_2pctUSD: bidDepth, impactBpsAtNotional: 0, // TODO: implement impact calculation largestOppWallBps: Math.abs((largestAsk.price - midPrice) / midPrice) * 10000, largestOppWallUSD: Math.round(largestAsk.sizeUSD), largestSameWallBps: Math.abs((largestBid.price - midPrice) / midPrice) * 10000, largestSameWallUSD: Math.round(largestBid.sizeUSD), isLiquid: spreadBps < this.SPREAD_THRESHOLD_BPS, hasOppWall: largestAsk.sizeUSD > this.WALL_THRESHOLD_USD } } /** * Get current metrics for a symbol (with direction context) * This triggers on-demand snapshot + computation */ async getMetricsForDirection(symbol: string, direction: 'long' | 'short', notionalUSD: number): Promise { // Get fresh snapshot right now const snapshot = await this.getSnapshotNow(symbol) if (!snapshot) return null // Compute metrics from snapshot const baseMetrics = this.computeMetrics(snapshot) // Flip opposing/same based on direction const metrics: OrderbookMetrics = direction === 'long' ? { ...baseMetrics, // For LONG: asks = opposing, bids = same oppDepth0_2pctUSD: baseMetrics.oppDepth0_2pctUSD, sameDepth0_2pctUSD: baseMetrics.sameDepth0_2pctUSD, } : { ...baseMetrics, // For SHORT: bids = opposing, asks = same oppDepth0_2pctUSD: baseMetrics.sameDepth0_2pctUSD, // flip sameDepth0_2pctUSD: baseMetrics.oppDepth0_2pctUSD, // flip largestOppWallBps: baseMetrics.largestSameWallBps, // flip largestOppWallUSD: baseMetrics.largestSameWallUSD, // flip largestSameWallBps: baseMetrics.largestOppWallBps, // flip largestSameWallUSD: baseMetrics.largestOppWallUSD, // flip } return { direction, metrics, notionalUSD } } } // Singleton instance let instance: OrderbookService | null = null /** * Get singleton OrderbookService instance */ export function getOrderbookService(): OrderbookService { if (!instance) { instance = new OrderbookService() } return instance } /** * Initialize orderbook subscriptions for active markets */ export async function initializeOrderbookService(symbols: string[]): Promise { const service = getOrderbookService() for (const symbol of symbols) { try { await service.subscribeToMarket(symbol) } catch (error) { console.error(`❌ Failed to subscribe to ${symbol}:`, error) // Continue with other symbols } } logger.log(`📖 OrderbookService initialized for ${symbols.length} symbols`) }