{"version":3,"file":"price-watcher.mjs","names":[],"sources":["../../../src/services/price-watcher.ts"],"sourcesContent":["/**\n * Price Watcher — polls token prices and fires events when thresholds are crossed.\n *\n * This service is the bridge between the event bus and price triggers defined\n * on plans. It maintains a set of \"watches\" (token + condition + threshold)\n * and periodically checks prices, emitting `price_crossed` events when thresholds\n * are breached.\n *\n * Design decisions:\n * - Uses the simple price-service (DexScreener) for fast, cached lookups.\n * - Deduplicates watches per token — if 3 plans watch ETH at different thresholds,\n *   we fetch ETH once per tick and check all 3 thresholds.\n * - Supports hysteresis (price must move N% past threshold before re-triggering)\n *   and cooldown (minimum time between triggers for same watch).\n * - Does NOT execute plans directly — it emits events that the scheduler consumes.\n */\n\nimport { getEventBus } from './event-bus.js';\nimport type { PriceTrigger } from './plan-types.js';\n\n// ─── Types ──────────────────────────────────────────────────────────────\n\nexport interface PriceWatch {\n  /** Unique ID for this watch (typically planId). */\n  id: string;\n  /** Token symbol to watch. */\n  token: string;\n  /** Trigger condition. */\n  condition: 'above' | 'below' | 'crosses';\n  /** Price threshold in USD. */\n  threshold: number;\n  /** Hysteresis percentage. Default: 1%. */\n  hysteresisPercent: number;\n  /** Cooldown between triggers in ms. Default: 300_000 (5 min). */\n  cooldownMs: number;\n  /** If true, trigger fires repeatedly. If false, fires once then removes itself. */\n  recurring: boolean;\n}\n\ninterface WatchState {\n  /** Last known price for this token. */\n  lastPrice: number | null;\n  /** Whether the condition was met on the previous tick (for hysteresis). */\n  wasTriggered: boolean;\n  /** Timestamp of last trigger fire. */\n  lastFiredAt: number;\n}\n\nexport type PriceFetcher = (token: string) => Promise<number | null>;\n\n// ─── Price Watcher ──────────────────────────────────────────────────────\n\nexport class PriceWatcher {\n  private watches = new Map<string, PriceWatch>();\n  private state = new Map<string, WatchState>();\n  private tickInterval: ReturnType<typeof setInterval> | null = null;\n  private running = false;\n  private priceFetcher: PriceFetcher;\n  private tickMs: number;\n\n  constructor(opts?: {\n    priceFetcher?: PriceFetcher;\n    tickMs?: number;\n  }) {\n    this.priceFetcher = opts?.priceFetcher ?? defaultPriceFetcher;\n    this.tickMs = opts?.tickMs ?? 30_000; // 30s default\n  }\n\n  // ── Watch Management ──────────────────────────────────────────────────\n\n  /** Add a price watch. */\n  addWatch(watch: PriceWatch): void {\n    this.watches.set(watch.id, watch);\n    if (!this.state.has(watch.id)) {\n      this.state.set(watch.id, {\n        lastPrice: null,\n        wasTriggered: false,\n        lastFiredAt: 0,\n      });\n    }\n  }\n\n  /** Create a watch from a PriceTrigger (from a plan). */\n  addFromTrigger(planId: string, trigger: PriceTrigger): void {\n    this.addWatch({\n      id: planId,\n      token: trigger.token,\n      condition: trigger.condition,\n      threshold: trigger.threshold,\n      hysteresisPercent: trigger.hysteresisPercent ?? 1,\n      cooldownMs: trigger.cooldownMs ?? 300_000,\n      recurring: trigger.recurring ?? false,\n    });\n  }\n\n  /** Remove a watch. */\n  removeWatch(id: string): boolean {\n    this.state.delete(id);\n    return this.watches.delete(id);\n  }\n\n  /** Get all active watches. */\n  getWatches(): PriceWatch[] {\n    return Array.from(this.watches.values());\n  }\n\n  /** Get the number of active watches. */\n  get watchCount(): number {\n    return this.watches.size;\n  }\n\n  // ── Lifecycle ─────────────────────────────────────────────────────────\n\n  /** Start the polling loop. */\n  start(): void {\n    if (this.running) return;\n    this.running = true;\n    this.tickInterval = setInterval(() => {\n      this.tick().catch(() => {});\n    }, this.tickMs);\n  }\n\n  /** Stop the polling loop. */\n  stop(): void {\n    this.running = false;\n    if (this.tickInterval) {\n      clearInterval(this.tickInterval);\n      this.tickInterval = null;\n    }\n  }\n\n  /** Check if the watcher is running. */\n  get isRunning(): boolean {\n    return this.running;\n  }\n\n  // ── Core Tick ─────────────────────────────────────────────────────────\n\n  /** Run one price check cycle. Public for testing. */\n  async tick(): Promise<void> {\n    if (this.watches.size === 0) return;\n\n    // Group watches by token to deduplicate fetches\n    const tokenWatches = new Map<string, PriceWatch[]>();\n    for (const watch of this.watches.values()) {\n      const key = watch.token.toUpperCase();\n      if (!tokenWatches.has(key)) {\n        tokenWatches.set(key, []);\n      }\n      tokenWatches.get(key)!.push(watch);\n    }\n\n    // Fetch prices for all watched tokens in parallel\n    const pricePromises = Array.from(tokenWatches.keys()).map(async (token) => {\n      try {\n        const price = await this.priceFetcher(token);\n        return { token, price };\n      } catch {\n        return { token, price: null };\n      }\n    });\n\n    const results = await Promise.all(pricePromises);\n    const prices = new Map<string, number>();\n    for (const { token, price } of results) {\n      if (price !== null && !isNaN(price)) {\n        prices.set(token, price);\n      }\n    }\n\n    // Evaluate each watch against current prices\n    const now = Date.now();\n    const toRemove: string[] = [];\n    const bus = getEventBus();\n\n    for (const watch of this.watches.values()) {\n      const tokenKey = watch.token.toUpperCase();\n      const currentPrice = prices.get(tokenKey);\n      if (currentPrice === undefined) continue;\n\n      const st = this.state.get(watch.id)!;\n      const previousPrice = st.lastPrice;\n      st.lastPrice = currentPrice;\n\n      // Check cooldown\n      if (now - st.lastFiredAt < watch.cooldownMs) continue;\n\n      // Evaluate condition\n      const triggered = this.evaluateCondition(\n        watch, currentPrice, previousPrice, st.wasTriggered,\n      );\n\n      if (triggered) {\n        st.wasTriggered = true;\n        st.lastFiredAt = now;\n\n        bus.emit('price_crossed', {\n          type: 'price_crossed',\n          token: watch.token,\n          condition: watch.condition,\n          threshold: watch.threshold,\n          currentPrice,\n          previousPrice: previousPrice ?? currentPrice,\n          timestamp: now,\n        });\n\n        if (!watch.recurring) {\n          toRemove.push(watch.id);\n        }\n      } else if (st.wasTriggered) {\n        // Check hysteresis: price must move back past threshold by hysteresis%\n        // before the watch can re-trigger\n        const hysteresisMargin = watch.threshold * (watch.hysteresisPercent / 100);\n        const cleared = watch.condition === 'above'\n          ? currentPrice < watch.threshold - hysteresisMargin\n          : watch.condition === 'below'\n            ? currentPrice > watch.threshold + hysteresisMargin\n            : Math.abs(currentPrice - watch.threshold) > hysteresisMargin;\n\n        if (cleared) {\n          st.wasTriggered = false;\n        }\n      }\n    }\n\n    // Remove one-shot watches that fired\n    for (const id of toRemove) {\n      this.watches.delete(id);\n      this.state.delete(id);\n    }\n  }\n\n  // ── Condition Evaluation ──────────────────────────────────────────────\n\n  private evaluateCondition(\n    watch: PriceWatch,\n    currentPrice: number,\n    previousPrice: number | null,\n    wasTriggered: boolean,\n  ): boolean {\n    // Don't re-trigger if still in triggered state (hysteresis not cleared)\n    if (wasTriggered) return false;\n\n    switch (watch.condition) {\n      case 'above':\n        return currentPrice >= watch.threshold;\n      case 'below':\n        return currentPrice <= watch.threshold;\n      case 'crosses':\n        // Crosses: triggered when price moves from one side to the other\n        if (previousPrice === null) return false;\n        return (\n          (previousPrice < watch.threshold && currentPrice >= watch.threshold) ||\n          (previousPrice > watch.threshold && currentPrice <= watch.threshold)\n        );\n      default:\n        return false;\n    }\n  }\n\n  /** Clear all watches and state. */\n  clear(): void {\n    this.watches.clear();\n    this.state.clear();\n  }\n}\n\n// ─── Default Price Fetcher ──────────────────────────────────────────────\n// Uses the price service (DexScreener). Lazy-loaded to avoid circular deps.\n\nconst defaultPriceFetcher: PriceFetcher = async (token: string) => {\n  const { getPrice } = await import('./price-service.js');\n  const result = await getPrice(token);\n  return result?.priceUsd ?? null;\n};\n\n// ─── Singleton ──────────────────────────────────────────────────────────\n\nlet instance: PriceWatcher | null = null;\n\nexport function getPriceWatcher(opts?: {\n  priceFetcher?: PriceFetcher;\n  tickMs?: number;\n}): PriceWatcher {\n  if (!instance) {\n    instance = new PriceWatcher(opts);\n  }\n  return instance;\n}\n\nexport function resetPriceWatcher(): void {\n  instance?.stop();\n  instance?.clear();\n  instance = null;\n}\n"],"mappings":"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"}