horizon-code 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,154 @@
1
+ // Exchange credential profiles — defines what keys each exchange needs
2
+ // Keys are stored encrypted via /env system
3
+
4
+ import { listEncryptedEnvNames, getDecryptedEnv, setEncryptedEnv, removeEncryptedEnv } from "./config.ts";
5
+
6
+ export interface ExchangeProfile {
7
+ id: string;
8
+ name: string;
9
+ category: "prediction" | "stock" | "crypto";
10
+ description: string;
11
+ keys: { env: string; label: string; hint: string; required: boolean }[];
12
+ testUrl?: string;
13
+ }
14
+
15
+ export const EXCHANGE_PROFILES: ExchangeProfile[] = [
16
+ {
17
+ id: "polymarket",
18
+ name: "Polymarket",
19
+ category: "prediction",
20
+ description: "Prediction market on Polygon. Requires a funded wallet private key.",
21
+ keys: [
22
+ { env: "POLYMARKET_PRIVATE_KEY", label: "Private Key", hint: "Ethereum private key (0x...)", required: true },
23
+ { env: "CLOB_API_KEY", label: "CLOB API Key", hint: "From Polymarket developer portal (optional)", required: false },
24
+ { env: "CLOB_SECRET", label: "CLOB Secret", hint: "CLOB API secret", required: false },
25
+ { env: "CLOB_PASSPHRASE", label: "CLOB Passphrase", hint: "CLOB API passphrase", required: false },
26
+ ],
27
+ },
28
+ {
29
+ id: "kalshi",
30
+ name: "Kalshi",
31
+ category: "prediction",
32
+ description: "Regulated US prediction market. API key from kalshi.com/settings.",
33
+ keys: [
34
+ { env: "KALSHI_API_KEY", label: "API Key", hint: "From Kalshi account settings", required: true },
35
+ { env: "KALSHI_API_SECRET", label: "API Secret", hint: "Secret paired with API key", required: false },
36
+ ],
37
+ },
38
+ {
39
+ id: "limitless",
40
+ name: "Limitless",
41
+ category: "prediction",
42
+ description: "Emerging prediction market platform.",
43
+ keys: [
44
+ { env: "LIMITLESS_API_KEY", label: "API Key", hint: "From Limitless developer portal", required: true },
45
+ ],
46
+ },
47
+ {
48
+ id: "alpaca",
49
+ name: "Alpaca",
50
+ category: "stock",
51
+ description: "Commission-free stock/ETF trading. Paper + live. alpaca.markets",
52
+ keys: [
53
+ { env: "ALPACA_API_KEY", label: "API Key", hint: "From Alpaca dashboard (paper or live)", required: true },
54
+ { env: "ALPACA_SECRET_KEY", label: "Secret Key", hint: "Secret paired with API key", required: true },
55
+ { env: "ALPACA_PAPER", label: "Paper Mode", hint: "Set to 'true' for paper trading (default: true)", required: false },
56
+ ],
57
+ },
58
+ {
59
+ id: "robinhood",
60
+ name: "Robinhood",
61
+ category: "stock",
62
+ description: "Stock/options/crypto trading. Uses OAuth token.",
63
+ keys: [
64
+ { env: "ROBINHOOD_TOKEN", label: "Access Token", hint: "OAuth token from Robinhood API", required: true },
65
+ ],
66
+ },
67
+ {
68
+ id: "polygon",
69
+ name: "Polygon.io",
70
+ category: "stock",
71
+ description: "Real-time & historical market data for stocks, options, crypto. polygon.io",
72
+ keys: [
73
+ { env: "POLYGON_API_KEY", label: "API Key", hint: "From polygon.io dashboard (free tier available)", required: true },
74
+ ],
75
+ },
76
+ {
77
+ id: "fmp",
78
+ name: "Financial Modeling Prep",
79
+ category: "stock",
80
+ description: "Fundamental data, financials, earnings. financialmodelingprep.com",
81
+ keys: [
82
+ { env: "FMP_API_KEY", label: "API Key", hint: "From FMP dashboard", required: true },
83
+ ],
84
+ },
85
+ ];
86
+
87
+ export type ConnectionStatus = "connected" | "partial" | "disconnected";
88
+
89
+ export interface ExchangeStatus {
90
+ profile: ExchangeProfile;
91
+ status: ConnectionStatus;
92
+ keysSet: string[];
93
+ keysMissing: string[];
94
+ }
95
+
96
+ /** Check which exchanges have credentials configured */
97
+ export function getExchangeStatuses(): ExchangeStatus[] {
98
+ const envNames = new Set(listEncryptedEnvNames());
99
+ // Also check process.env for keys set externally
100
+ const allKeys = new Set([...envNames, ...Object.keys(process.env).filter(k =>
101
+ EXCHANGE_PROFILES.some(p => p.keys.some(key => key.env === k && process.env[k]))
102
+ )]);
103
+
104
+ return EXCHANGE_PROFILES.map(profile => {
105
+ const keysSet = profile.keys.filter(k => allKeys.has(k.env)).map(k => k.env);
106
+ const keysMissing = profile.keys.filter(k => k.required && !allKeys.has(k.env)).map(k => k.env);
107
+ const requiredKeys = profile.keys.filter(k => k.required);
108
+ const requiredSet = requiredKeys.filter(k => allKeys.has(k.env));
109
+
110
+ let status: ConnectionStatus;
111
+ if (requiredSet.length === requiredKeys.length) {
112
+ status = "connected";
113
+ } else if (keysSet.length > 0) {
114
+ status = "partial";
115
+ } else {
116
+ status = "disconnected";
117
+ }
118
+
119
+ return { profile, status, keysSet, keysMissing };
120
+ });
121
+ }
122
+
123
+ /** Get status for a specific exchange */
124
+ export function getExchangeStatus(exchangeId: string): ExchangeStatus | null {
125
+ return getExchangeStatuses().find(s => s.profile.id === exchangeId) ?? null;
126
+ }
127
+
128
+ /** Set a credential for an exchange */
129
+ export function setExchangeKey(envName: string, value: string): boolean {
130
+ try {
131
+ setEncryptedEnv(envName, value);
132
+ return true;
133
+ } catch {
134
+ return false;
135
+ }
136
+ }
137
+
138
+ /** Remove a credential */
139
+ export function removeExchangeKey(envName: string): boolean {
140
+ try {
141
+ removeEncryptedEnv(envName);
142
+ return true;
143
+ } catch {
144
+ return false;
145
+ }
146
+ }
147
+
148
+ /** Get a decrypted credential (for passing to subprocesses) */
149
+ export function getExchangeKey(envName: string): string | null {
150
+ // Check encrypted env first, then process.env
151
+ const decrypted = getDecryptedEnv(envName);
152
+ if (decrypted) return decrypted;
153
+ return process.env[envName] ?? null;
154
+ }
@@ -114,18 +114,33 @@ export async function restoreSession(): Promise<boolean> {
114
114
  }
115
115
  }
116
116
 
117
+ // Try setSession first (works if access token still valid)
117
118
  const { data, error } = await sb.auth.setSession({
118
119
  access_token: accessToken,
119
120
  refresh_token: refreshToken,
120
121
  });
121
122
 
122
123
  if (!error && data.session) {
123
- // Session restored — auto-refresh will keep it alive
124
124
  if (config.api_key) platform.setApiKey(config.api_key);
125
125
  return true;
126
126
  }
127
127
 
128
- // Tokens fully expired — user needs to /login again
128
+ // Access token expired — try refreshing with just the refresh token
129
+ const { data: refreshData, error: refreshError } = await sb.auth.refreshSession({
130
+ refresh_token: refreshToken,
131
+ });
132
+
133
+ if (!refreshError && refreshData.session) {
134
+ // Save the new tokens
135
+ saveSessionTokens(config, refreshData.session.access_token, refreshData.session.refresh_token);
136
+ config.user_email = refreshData.session.user.email ?? config.user_email;
137
+ config.user_id = refreshData.session.user.id;
138
+ saveConfig(config);
139
+ if (config.api_key) platform.setApiKey(config.api_key);
140
+ return true;
141
+ }
142
+
143
+ // Both failed — refresh token fully expired
129
144
  delete config.supabase_session;
130
145
  saveConfig(config);
131
146
  }
@@ -229,26 +229,304 @@ export async function polymarketTrades(slug: string): Promise<any> {
229
229
  };
230
230
  }
231
231
 
232
+ // ── Polymarket DATA API — wallet/trade deep analysis ──
233
+
234
+ /** Get recent trades for a market (up to 500) */
235
+ export async function getMarketTrades(conditionId: string, limit = 200): Promise<any[]> {
236
+ const data = await get(`${DATA_API}/trades?market=${conditionId}&limit=${Math.min(limit, 500)}`).catch(() => []);
237
+ return data ?? [];
238
+ }
239
+
240
+ /** Get trades for a specific wallet (up to 500) */
241
+ export async function getWalletTrades(address: string, limit = 200, conditionId?: string): Promise<any[]> {
242
+ let url = `${DATA_API}/trades?maker_address=${address}&limit=${Math.min(limit, 500)}`;
243
+ if (conditionId) url += `&market=${conditionId}`;
244
+ const data = await get(url).catch(() => []);
245
+ return data ?? [];
246
+ }
247
+
248
+ /** Get open positions for a wallet */
249
+ export async function getWalletPositions(address: string, limit = 100): Promise<any[]> {
250
+ const data = await get(`${DATA_API}/positions?user=${address}&limit=${Math.min(limit, 500)}&sort_by=TOKENS`).catch(() => []);
251
+ return data ?? [];
252
+ }
253
+
254
+ /** Get wallet profile from Gamma API */
255
+ export async function getWalletProfile(address: string): Promise<any> {
256
+ const data = await get(`${GAMMA}/users?address=${address}`).catch(() => null);
257
+ const user = Array.isArray(data) ? data[0] : data;
258
+ return user ? {
259
+ address, name: user.pseudonym ?? user.name ?? "Anonymous",
260
+ bio: user.bio ?? "", profileImage: user.profile_image,
261
+ xUsername: user.x_username, createdAt: user.created_at,
262
+ } : { address, name: "Anonymous" };
263
+ }
264
+
265
+ /** Resolve market slug → conditionId */
266
+ export async function resolveConditionId(slug: string): Promise<{ conditionId: string; title: string; market: any }> {
267
+ const events = await get(`${GAMMA}/events?slug=${slug}`);
268
+ const market = events?.[0]?.markets?.[0];
269
+ if (!market) throw new Error(`Market not found: ${slug}`);
270
+ return { conditionId: market.conditionId, title: events[0].title, market };
271
+ }
272
+
232
273
  // ── Kalshi ──
274
+ // API v2 returns prices as dollar strings: "yes_bid_dollars": "0.09"
275
+ // Volumes as float strings: "volume_fp": "55642.00"
276
+
277
+ /** Parse Kalshi dollar string to number (e.g. "0.09" → 0.09, null → null) */
278
+ function kPrice(v: any): number | null {
279
+ if (v == null || v === "" || v === "0.0000") return null;
280
+ const n = typeof v === "string" ? parseFloat(v) : v;
281
+ return isNaN(n) ? null : +n.toFixed(4);
282
+ }
283
+
284
+ /** Parse Kalshi integer/float volume string */
285
+ function kVol(v: any): number {
286
+ if (v == null || v === "") return 0;
287
+ const n = typeof v === "string" ? parseFloat(v) : v;
288
+ return isNaN(n) ? 0 : Math.round(n);
289
+ }
290
+
291
+ /** Parse a Kalshi market from the API response */
292
+ function parseKalshiMarket(m: any): any {
293
+ const yesBid = kPrice(m.yes_bid_dollars ?? m.yes_bid);
294
+ const yesAsk = kPrice(m.yes_ask_dollars ?? m.yes_ask);
295
+ const lastPrice = kPrice(m.last_price_dollars ?? m.last_price);
296
+ return {
297
+ ticker: m.ticker,
298
+ name: m.title ?? m.yes_sub_title ?? m.subtitle ?? m.ticker,
299
+ title: m.title ?? m.yes_sub_title ?? m.subtitle ?? m.ticker,
300
+ yesBid, yesAsk, lastPrice,
301
+ noBid: kPrice(m.no_bid_dollars ?? m.no_bid),
302
+ noAsk: kPrice(m.no_ask_dollars ?? m.no_ask),
303
+ volume: kVol(m.volume_fp ?? m.volume),
304
+ volume24h: kVol(m.volume_24h_fp ?? m.volume_24h),
305
+ openInterest: kVol(m.open_interest_fp ?? m.open_interest),
306
+ spread: yesBid != null && yesAsk != null ? +((yesAsk - yesBid)).toFixed(4) : null,
307
+ closeTime: m.close_time, expirationTime: m.expiration_time,
308
+ status: m.status,
309
+ };
310
+ }
311
+
312
+ let _kalshiCache: { data: any[]; ts: number } = { data: [], ts: 0 };
313
+ const KALSHI_CACHE_TTL = 60_000;
314
+
315
+ /** Fetch events from Kalshi — uses cursor pagination to get more results */
316
+ async function fetchKalshiEvents(limit = 200): Promise<any[]> {
317
+ if (_kalshiCache.data.length > 0 && Date.now() - _kalshiCache.ts < KALSHI_CACHE_TTL) {
318
+ return _kalshiCache.data;
319
+ }
320
+
321
+ const all: any[] = [];
322
+ let cursor = "";
323
+ const batchSize = 100;
324
+
325
+ // Fetch up to `limit` events across pages
326
+ while (all.length < limit) {
327
+ const params = new URLSearchParams({
328
+ status: "open", with_nested_markets: "true",
329
+ limit: String(Math.min(batchSize, limit - all.length)),
330
+ });
331
+ if (cursor) params.set("cursor", cursor);
332
+
333
+ const data = await get(`${KALSHI}/events?${params}`).catch(() => ({ events: [], cursor: "" }));
334
+ const events = data?.events ?? [];
335
+ if (events.length === 0) break;
336
+
337
+ all.push(...events);
338
+ cursor = data?.cursor ?? "";
339
+ if (!cursor) break;
340
+ }
341
+
342
+ _kalshiCache = { data: all, ts: Date.now() };
343
+ return all;
344
+ }
345
+
346
+ /** Build searchable text from a Kalshi event (including nested market titles) */
347
+ function kalshiSearchText(e: any): string {
348
+ const parts = [e.title, e.sub_title, e.category, e.series_ticker, e.event_ticker];
349
+ // Include nested market titles for better search
350
+ for (const m of (e.markets ?? [])) {
351
+ parts.push(m.title, m.subtitle, m.yes_sub_title, m.no_sub_title, m.ticker);
352
+ }
353
+ return parts.filter(Boolean).join(" ").toLowerCase();
354
+ }
355
+
356
+ // Common series tickers for keyword-to-series mapping
357
+ const KALSHI_SERIES_MAP: Record<string, string> = {
358
+ bitcoin: "KXBTC", btc: "KXBTC", crypto: "KXBTC",
359
+ ethereum: "KXETH", eth: "KXETH",
360
+ nasdaq: "KXNASDAQ", spy: "KXSPY", sp500: "KXSPY", "s&p": "KXSPY",
361
+ fed: "KXFED", "interest rate": "KXFED", fomc: "KXFED",
362
+ cpi: "KXCPI", inflation: "KXCPI",
363
+ gdp: "KXGDP", unemployment: "KXUNEMP", jobs: "KXUNEMP",
364
+ oil: "KXOIL", gold: "KXGOLD",
365
+ };
233
366
 
234
367
  export async function kalshiEvents(opts: { query?: string; limit?: number } = {}): Promise<any[]> {
235
- const params = new URLSearchParams({
236
- status: "open", with_nested_markets: "true",
237
- limit: String(Math.min(opts.limit ?? 10, 20)),
368
+ const limit = Math.min(opts.limit ?? 10, 20);
369
+ const query = (opts.query ?? "").toLowerCase().trim();
370
+
371
+ if (!query) {
372
+ const allEvents = await fetchKalshiEvents();
373
+ return allEvents.slice(0, limit).map(formatKalshiEvent);
374
+ }
375
+
376
+ // 1. Try series_ticker mapping first (crypto, fed, etc.)
377
+ const seriesTicker = KALSHI_SERIES_MAP[query] ??
378
+ Object.entries(KALSHI_SERIES_MAP).find(([k]) => query.includes(k))?.[1];
379
+
380
+ if (seriesTicker) {
381
+ try {
382
+ const params = new URLSearchParams({
383
+ status: "open", with_nested_markets: "true",
384
+ series_ticker: seriesTicker, limit: String(limit),
385
+ });
386
+ const data = await get(`${KALSHI}/events?${params}`);
387
+ const events = data?.events ?? [];
388
+ if (events.length > 0) return events.slice(0, limit).map(formatKalshiEvent);
389
+ } catch {}
390
+ }
391
+
392
+ // 2. Search cached events (title + market titles)
393
+ const allEvents = await fetchKalshiEvents();
394
+ const words = query.split(/\s+/).filter(w => w.length > 1);
395
+
396
+ let events = allEvents.filter((e: any) => {
397
+ const text = kalshiSearchText(e);
398
+ return words.every(w => text.includes(w));
399
+ });
400
+
401
+ // Fallback: any word match
402
+ if (events.length === 0 && words.length > 1) {
403
+ events = allEvents.filter((e: any) => {
404
+ const text = kalshiSearchText(e);
405
+ return words.some(w => text.includes(w));
406
+ });
407
+ }
408
+
409
+ // 3. If still empty, try series_ticker as-is (user might pass a ticker directly)
410
+ if (events.length === 0) {
411
+ try {
412
+ const params = new URLSearchParams({
413
+ status: "open", with_nested_markets: "true",
414
+ series_ticker: query.toUpperCase(), limit: String(limit),
415
+ });
416
+ const data = await get(`${KALSHI}/events?${params}`);
417
+ events = data?.events ?? [];
418
+ } catch {}
419
+ }
420
+
421
+ // Sort by relevance
422
+ events.sort((a: any, b: any) => {
423
+ const aTitle = (a.title ?? "").toLowerCase();
424
+ const bTitle = (b.title ?? "").toLowerCase();
425
+ const aInTitle = words.some(w => aTitle.includes(w)) ? 1 : 0;
426
+ const bInTitle = words.some(w => bTitle.includes(w)) ? 1 : 0;
427
+ if (aInTitle !== bInTitle) return bInTitle - aInTitle;
428
+ const aVol = (a.markets ?? []).reduce((s: number, m: any) => s + kVol(m.volume_fp ?? m.volume), 0);
429
+ const bVol = (b.markets ?? []).reduce((s: number, m: any) => s + kVol(m.volume_fp ?? m.volume), 0);
430
+ return bVol - aVol;
238
431
  });
239
- if (opts.query) params.set("series_ticker", opts.query);
240
432
 
241
- const data = await get(`${KALSHI}/events?${params}`);
242
- return (data?.events ?? []).map((e: any) => ({
433
+ return events.slice(0, limit).map(formatKalshiEvent);
434
+ }
435
+
436
+ function formatKalshiEvent(e: any): any {
437
+ return {
243
438
  ticker: e.event_ticker, title: e.title,
244
439
  category: e.category, subtitle: e.sub_title,
245
- url: `https://kalshi.com/markets/${e.event_ticker?.toLowerCase()}`,
246
- markets: (e.markets ?? []).map((m: any) => ({
247
- name: m.title ?? m.subtitle, yesBid: m.yes_bid, yesAsk: m.yes_ask,
248
- lastPrice: m.last_price, volume: m.volume,
249
- openInterest: m.open_interest, closeTime: m.close_time,
250
- })),
251
- }));
440
+ url: `https://kalshi.com/markets/${(e.event_ticker ?? "").toLowerCase()}`,
441
+ markets: (e.markets ?? []).slice(0, 5).map(parseKalshiMarket),
442
+ };
443
+ }
444
+
445
+ export async function kalshiEventDetail(ticker: string): Promise<any> {
446
+ const data = await get(`${KALSHI}/events/${ticker}?with_nested_markets=true`);
447
+ const event = data?.event ?? data;
448
+ if (!event) throw new Error(`Kalshi event not found: ${ticker}`);
449
+
450
+ return {
451
+ ticker: event.event_ticker, title: event.title,
452
+ category: event.category, subtitle: event.sub_title,
453
+ description: (event.strike_description ?? event.sub_title ?? "").slice(0, 500),
454
+ url: `https://kalshi.com/markets/${(event.event_ticker ?? "").toLowerCase()}`,
455
+ markets: (event.markets ?? []).map(parseKalshiMarket),
456
+ };
457
+ }
458
+
459
+ export async function kalshiOrderBook(ticker: string): Promise<any> {
460
+ const book = await get(`${KALSHI}/markets/${ticker}/orderbook`);
461
+ const ob = book?.orderbook ?? {};
462
+ // yes array: [[price_cents, size], ...] — bids for YES outcome
463
+ // no array: [[price_cents, size], ...] — asks (complement pricing)
464
+ const bids = (ob.yes ?? []).map((l: any) => {
465
+ const price = Array.isArray(l) ? l[0] / 100 : (l.price ?? 0) / 100;
466
+ const size = Array.isArray(l) ? l[1] : l.size ?? 0;
467
+ return { price: +price.toFixed(4), size };
468
+ });
469
+ const asks = (ob.no ?? []).map((l: any) => {
470
+ const price = Array.isArray(l) ? 1 - l[0] / 100 : 1 - (l.price ?? 0) / 100;
471
+ const size = Array.isArray(l) ? l[1] : l.size ?? 0;
472
+ return { price: +price.toFixed(4), size };
473
+ });
474
+ bids.sort((a: any, b: any) => b.price - a.price);
475
+ asks.sort((a: any, b: any) => a.price - b.price);
476
+
477
+ return {
478
+ ticker, title: ticker,
479
+ url: `https://kalshi.com/markets/${ticker.toLowerCase()}`,
480
+ bestBid: bids[0]?.price ?? null,
481
+ bestAsk: asks[0]?.price ?? null,
482
+ spread: bids[0] && asks[0] ? +(asks[0].price - bids[0].price).toFixed(4) : null,
483
+ bids: bids.slice(0, 15),
484
+ asks: asks.slice(0, 15),
485
+ };
486
+ }
487
+
488
+ export async function kalshiPriceHistory(ticker: string, period = "1w"): Promise<any> {
489
+ const intervalMap: Record<string, number> = { "1h": 1, "6h": 1, "1d": 60, "1w": 60, "1m": 1440, "max": 1440 };
490
+ const periodInterval = intervalMap[period] ?? 60;
491
+
492
+ const data = await get(`${KALSHI}/markets/${ticker}/candlesticks?period_interval=${periodInterval}`).catch(() => ({ candlesticks: [] }));
493
+ const candles = data?.candlesticks ?? [];
494
+
495
+ // Prices can be dollar strings or cent integers — normalize to decimal
496
+ const normalize = (v: any): number => {
497
+ if (v == null) return 0;
498
+ const n = typeof v === "string" ? parseFloat(v) : v;
499
+ // If > 1, it's cents (old format); if <= 1, it's dollars (new format)
500
+ return n > 1 ? n / 100 : n;
501
+ };
502
+
503
+ const prices = candles.map((c: any) => {
504
+ const bid = normalize(c.yes_bid_dollars ?? c.yes_bid ?? c.yes_price);
505
+ const ask = normalize(c.yes_ask_dollars ?? c.yes_ask);
506
+ return ask > 0 ? (bid + ask) / 2 : bid;
507
+ }).filter((p: number) => p > 0);
508
+
509
+ const timestamps = candles.map((c: any) => {
510
+ const ts = c.end_period_ts ?? c.ts;
511
+ return typeof ts === "string" ? new Date(ts).getTime() / 1000 : ts;
512
+ });
513
+
514
+ return {
515
+ ticker, title: ticker,
516
+ url: `https://kalshi.com/markets/${ticker.toLowerCase()}`,
517
+ interval: period,
518
+ dataPoints: prices.length,
519
+ current: prices.length > 0 ? +prices[prices.length - 1].toFixed(4) : null,
520
+ high: prices.length > 0 ? +Math.max(...prices).toFixed(4) : null,
521
+ low: prices.length > 0 ? +Math.min(...prices).toFixed(4) : null,
522
+ change: prices.length > 1 ? +((prices[prices.length - 1] - prices[0]) / (prices[0] || 1)).toFixed(4) : null,
523
+ priceHistory: candles.map((c: any, i: number) => ({
524
+ t: timestamps[i], p: prices[i] ?? 0,
525
+ volume: kVol(c.volume_fp ?? c.volume),
526
+ open: normalize(c.open_dollars ?? c.open),
527
+ close: normalize(c.close_dollars ?? c.close),
528
+ })).filter((p: any) => p.p > 0),
529
+ };
252
530
  }
253
531
 
254
532
  // ── Exa (web search + sentiment) — via server proxy ──
@@ -0,0 +1,117 @@
1
+ // Yahoo Finance API — free, no auth required
2
+ // Uses v8 chart API (still public) + search API
3
+ // Quote data extracted from chart metadata (v7 quote endpoint now requires crumb auth)
4
+
5
+ const YF_CHART = "https://query1.finance.yahoo.com/v8/finance/chart";
6
+ const YF_SEARCH = "https://query2.finance.yahoo.com/v1/finance/search";
7
+
8
+ async function yfGet(url: string): Promise<any> {
9
+ const res = await fetch(url, {
10
+ headers: {
11
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
12
+ "Accept": "application/json",
13
+ },
14
+ signal: AbortSignal.timeout(10000),
15
+ });
16
+ if (!res.ok) throw new Error(`Yahoo Finance ${res.status}`);
17
+ return res.json();
18
+ }
19
+
20
+ /** Get real-time quote for one or more symbols — uses chart endpoint meta */
21
+ export async function yahooQuote(symbols: string[]): Promise<any[]> {
22
+ const results = await Promise.all(
23
+ symbols.map(async (sym) => {
24
+ try {
25
+ const data = await yfGet(`${YF_CHART}/${sym.toUpperCase()}?range=5d&interval=1d&includePrePost=false`);
26
+ const result = data?.chart?.result?.[0];
27
+ if (!result) return null;
28
+ const meta = result.meta ?? {};
29
+ const closes: number[] = (result.indicators?.quote?.[0]?.close ?? []).filter((p: any) => p != null);
30
+ const volumes: number[] = (result.indicators?.quote?.[0]?.volume ?? []).filter((v: any) => v != null);
31
+ const prevClose = meta.chartPreviousClose ?? meta.previousClose ?? (closes.length > 1 ? closes[closes.length - 2] : 0);
32
+ const price = meta.regularMarketPrice ?? (closes.length > 0 ? closes[closes.length - 1] : 0);
33
+ const change = prevClose > 0 ? price - prevClose : 0;
34
+ const changePct = prevClose > 0 ? (change / prevClose) * 100 : 0;
35
+
36
+ return {
37
+ symbol: meta.symbol ?? sym.toUpperCase(),
38
+ name: meta.shortName ?? meta.longName ?? sym.toUpperCase(),
39
+ price: +price.toFixed(2),
40
+ change: +change.toFixed(2),
41
+ changePct: +changePct.toFixed(2),
42
+ previousClose: +prevClose.toFixed(2),
43
+ open: meta.regularMarketOpen ?? 0,
44
+ dayHigh: meta.regularMarketDayHigh ?? meta.dayHigh ?? 0,
45
+ dayLow: meta.regularMarketDayLow ?? meta.dayLow ?? 0,
46
+ volume: meta.regularMarketVolume ?? (volumes.length > 0 ? volumes[volumes.length - 1] : 0),
47
+ avgVolume: meta.averageDailyVolume3Month ?? 0,
48
+ marketCap: meta.marketCap ?? 0,
49
+ peRatio: meta.trailingPE ?? null,
50
+ forwardPE: meta.forwardPE ?? null,
51
+ dividendYield: meta.dividendYield ?? null,
52
+ fiftyTwoWeekHigh: meta.fiftyTwoWeekHigh ?? null,
53
+ fiftyTwoWeekLow: meta.fiftyTwoWeekLow ?? null,
54
+ fiftyDayAvg: meta.fiftyDayAverage ?? null,
55
+ twoHundredDayAvg: meta.twoHundredDayAverage ?? null,
56
+ beta: meta.beta ?? null,
57
+ exchange: meta.exchangeName ?? meta.exchange ?? "",
58
+ quoteType: meta.instrumentType ?? "",
59
+ currency: meta.currency ?? "USD",
60
+ marketState: meta.marketState ?? "CLOSED",
61
+ };
62
+ } catch {
63
+ return { symbol: sym.toUpperCase(), name: sym.toUpperCase(), price: 0, error: "Failed to fetch" };
64
+ }
65
+ })
66
+ );
67
+ return results.filter(Boolean) as any[];
68
+ }
69
+
70
+ /** Get price history (OHLCV) for a symbol */
71
+ export async function yahooChart(symbol: string, range = "1mo", interval = "1d"): Promise<any> {
72
+ const data = await yfGet(`${YF_CHART}/${symbol.toUpperCase()}?range=${range}&interval=${interval}&includePrePost=false`);
73
+ const result = data?.chart?.result?.[0];
74
+ if (!result) throw new Error(`No chart data for ${symbol}`);
75
+
76
+ const timestamps = result.timestamp ?? [];
77
+ const quote = result.indicators?.quote?.[0] ?? {};
78
+ const closes: number[] = quote.close ?? [];
79
+ const opens: number[] = quote.open ?? [];
80
+ const highs: number[] = quote.high ?? [];
81
+ const lows: number[] = quote.low ?? [];
82
+ const volumes: number[] = quote.volume ?? [];
83
+
84
+ const meta = result.meta ?? {};
85
+ const prices = closes.filter((p: any) => p !== null && p !== undefined);
86
+
87
+ return {
88
+ symbol: meta.symbol ?? symbol.toUpperCase(),
89
+ name: meta.shortName ?? meta.longName ?? symbol,
90
+ currency: meta.currency ?? "USD",
91
+ exchange: meta.exchangeName ?? "",
92
+ range, interval,
93
+ dataPoints: prices.length,
94
+ current: prices.length > 0 ? +prices[prices.length - 1].toFixed(2) : null,
95
+ high: prices.length > 0 ? +Math.max(...prices).toFixed(2) : null,
96
+ low: prices.length > 0 ? +Math.min(...prices).toFixed(2) : null,
97
+ change: prices.length > 1 ? +((prices[prices.length - 1] - prices[0]) / prices[0]).toFixed(4) : null,
98
+ priceHistory: timestamps.map((t: number, i: number) => ({
99
+ t, p: closes[i], o: opens[i], h: highs[i], l: lows[i], v: volumes[i],
100
+ })).filter((p: any) => p.p !== null && p.p !== undefined),
101
+ };
102
+ }
103
+
104
+ /** Search for tickers */
105
+ export async function yahooSearch(query: string, limit = 8): Promise<any[]> {
106
+ const data = await yfGet(`${YF_SEARCH}?q=${encodeURIComponent(query)}&quotesCount=${limit}&newsCount=0`);
107
+ const quotes = data?.quotes ?? [];
108
+
109
+ return quotes.map((q: any) => ({
110
+ symbol: q.symbol,
111
+ name: q.shortname ?? q.longname ?? q.symbol,
112
+ type: q.quoteType ?? "",
113
+ exchange: q.exchange ?? "",
114
+ sector: q.sector ?? "",
115
+ industry: q.industry ?? "",
116
+ }));
117
+ }