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.
@@ -3,9 +3,13 @@ import { tool } from "ai";
3
3
  import { z } from "zod";
4
4
  import {
5
5
  gammaEvents, gammaEventDetail, clobPriceHistory, clobOrderBook,
6
- polymarketTrades, kalshiEvents, exaSearch, exaSentiment,
7
- calaSearch, evCalculator, probabilityCalc,
6
+ polymarketTrades, kalshiEvents, kalshiEventDetail as kalshiEventDetailApi,
7
+ kalshiOrderBook as kalshiOrderBookApi, kalshiPriceHistory as kalshiPriceHistoryApi,
8
+ getMarketTrades, getWalletTrades, getWalletPositions, getWalletProfile,
9
+ resolveConditionId,
10
+ exaSearch, exaSentiment, calaSearch, evCalculator, probabilityCalc,
8
11
  } from "./apis.ts";
12
+ import { yahooQuote, yahooChart, yahooSearch } from "./stock-apis.ts";
9
13
 
10
14
  const t = tool as any;
11
15
 
@@ -127,37 +131,76 @@ export const researchTools: Record<string, any> = {
127
131
  }),
128
132
 
129
133
  historicalVolatility: t({
130
- description: "Calculate realized volatility, Bollinger bands, and volatility regime for a market.",
134
+ description: "Multi-estimator volatility analysis: close-to-close, Parkinson (high-low), Garman-Klass, and EWMA. Shows volatility regime and best estimate.",
131
135
  parameters: z.object({
132
136
  slug: z.string().describe("Event slug"),
133
137
  interval: z.string().optional().describe("Time range: 1d, 1w, 1m, max (default 1w)"),
134
138
  }),
135
139
  execute: async (args: any) => {
136
140
  const data = await clobPriceHistory(args.slug, args.interval ?? "1w", 60);
137
- const prices = data.priceHistory.map((p: any) => p.p);
138
- if (prices.length < 3) return { ...data, realizedVolatility: 0, regime: "insufficient data" };
141
+ const prices: number[] = data.priceHistory.map((p: any) => p.p);
142
+ if (prices.length < 5) return { ...data, realizedVolatility: 0, regime: "insufficient data" };
139
143
 
140
- // Log returns
144
+ const annFactor = Math.sqrt(365 * 24); // hourly data
145
+
146
+ // Close-to-close (realized)
141
147
  const returns: number[] = [];
142
148
  for (let i = 1; i < prices.length; i++) {
143
149
  if (prices[i - 1] > 0) returns.push(Math.log(prices[i] / prices[i - 1]));
144
150
  }
145
-
146
151
  const mean = returns.reduce((a, b) => a + b, 0) / returns.length;
147
152
  const variance = returns.reduce((a, b) => a + (b - mean) ** 2, 0) / returns.length;
148
- const stdDev = Math.sqrt(variance);
149
- const annualized = +(stdDev * Math.sqrt(365 * 24)).toFixed(4);
153
+ const closeToClose = +(Math.sqrt(variance) * annFactor).toFixed(4);
154
+
155
+ // Parkinson (uses range as proxy — estimate from consecutive prices)
156
+ // For each pair, treat as high/low estimate
157
+ let parkinsonSum = 0;
158
+ for (let i = 1; i < prices.length; i++) {
159
+ const hi = Math.max(prices[i], prices[i - 1]);
160
+ const lo = Math.min(prices[i], prices[i - 1]);
161
+ if (lo > 0) parkinsonSum += Math.log(hi / lo) ** 2;
162
+ }
163
+ const parkinson = +(Math.sqrt(parkinsonSum / ((prices.length - 1) * 4 * Math.LN2)) * annFactor).toFixed(4);
164
+
165
+ // Garman-Klass (open-high-low-close)
166
+ // Simplified: uses consecutive 3-bar windows
167
+ let gkSum = 0;
168
+ for (let i = 2; i < prices.length; i++) {
169
+ const open = prices[i - 2], close = prices[i];
170
+ const hi = Math.max(prices[i - 2], prices[i - 1], prices[i]);
171
+ const lo = Math.min(prices[i - 2], prices[i - 1], prices[i]);
172
+ if (lo > 0 && open > 0) {
173
+ gkSum += 0.5 * Math.log(hi / lo) ** 2 - (2 * Math.LN2 - 1) * Math.log(close / open) ** 2;
174
+ }
175
+ }
176
+ const garmanKlass = +(Math.sqrt(Math.max(0, gkSum / (prices.length - 2))) * annFactor).toFixed(4);
150
177
 
151
- // Regime
178
+ // EWMA (exponentially weighted, lambda=0.94)
179
+ const lambda = 0.94;
180
+ let ewmaVar = variance;
181
+ for (let i = returns.length - 1; i >= 0; i--) {
182
+ ewmaVar = lambda * ewmaVar + (1 - lambda) * returns[i] ** 2;
183
+ }
184
+ const ewma = +(Math.sqrt(ewmaVar) * annFactor).toFixed(4);
185
+
186
+ // Best estimate: median of all 4
187
+ const estimates = [closeToClose, parkinson, garmanKlass, ewma].sort((a, b) => a - b);
188
+ const best = +((estimates[1] + estimates[2]) / 2).toFixed(4);
189
+
190
+ // Regime from recent vs full-period vol
152
191
  const recentReturns = returns.slice(-20);
153
- const recentVar = recentReturns.reduce((a, b) => a + (b - mean) ** 2, 0) / recentReturns.length;
154
- const recentVol = Math.sqrt(recentVar);
155
- const regime = recentVol > stdDev * 1.5 ? "high" : recentVol < stdDev * 0.5 ? "low" : "normal";
192
+ const recentVar = recentReturns.reduce((a, b) => a + b ** 2, 0) / recentReturns.length;
193
+ const recentVol = Math.sqrt(recentVar) * annFactor;
194
+ const regime = recentVol > best * 1.5 ? "high" : recentVol < best * 0.5 ? "low" : "normal";
156
195
 
157
196
  return {
158
197
  ...data,
159
- realizedVolatility: annualized,
160
- dailyStdDev: +stdDev.toFixed(6),
198
+ estimators: {
199
+ closeToClose, parkinson, garmanKlass, ewma, best,
200
+ },
201
+ realizedVolatility: best,
202
+ dailyStdDev: +(Math.sqrt(variance)).toFixed(6),
203
+ recentVolatility: +(recentVol).toFixed(4),
161
204
  volatilityRegime: regime,
162
205
  };
163
206
  },
@@ -184,11 +227,11 @@ export const researchTools: Record<string, any> = {
184
227
  const score = overlap / Math.max(pWords.size, kWords.length);
185
228
  if (score > 0.3) {
186
229
  const polyPrice = parseFloat(p.markets?.[0]?.yesPrice ?? "0");
187
- const kalPrice = k.markets?.[0]?.yesBid ?? 0;
230
+ const kalPrice = k.markets?.[0]?.yesBid ?? k.markets?.[0]?.lastPrice ?? 0;
188
231
  comparisons.push({
189
232
  polymarket: { title: p.title, url: p.url, yesPrice: polyPrice },
190
233
  kalshi: { title: k.title, url: k.url, yesPrice: kalPrice },
191
- priceDiff: polyPrice && kalPrice ? +(polyPrice - kalPrice / 100).toFixed(4) : null,
234
+ priceDiff: polyPrice && kalPrice ? +(polyPrice - kalPrice).toFixed(4) : null,
192
235
  });
193
236
  }
194
237
  }
@@ -202,4 +245,873 @@ export const researchTools: Record<string, any> = {
202
245
  };
203
246
  },
204
247
  }),
248
+
249
+ // ── Kalshi deep tools ──
250
+
251
+ kalshiEventDetail: t({
252
+ description: "Get detailed info on a specific Kalshi event including all markets, spreads, and volumes. Requires event ticker from kalshiEvents.",
253
+ parameters: z.object({
254
+ ticker: z.string().describe("Kalshi event ticker (e.g. KXBTC-25MAR14)"),
255
+ }),
256
+ execute: async (args: any) => kalshiEventDetailApi(args.ticker),
257
+ }),
258
+
259
+ kalshiOrderBook: t({
260
+ description: "Get order book depth and bid/ask levels for a Kalshi market. Shows liquidity and spread.",
261
+ parameters: z.object({
262
+ ticker: z.string().describe("Kalshi market ticker (e.g. KXBTC-25MAR14-T84999)"),
263
+ }),
264
+ execute: async (args: any) => kalshiOrderBookApi(args.ticker),
265
+ }),
266
+
267
+ kalshiPriceHistory: t({
268
+ description: "Get price history (candlesticks) for a Kalshi market. Good for trend analysis.",
269
+ parameters: z.object({
270
+ ticker: z.string().describe("Kalshi market ticker"),
271
+ period: z.string().optional().describe("Time range: 1h, 6h, 1d, 1w, 1m, max (default 1w)"),
272
+ }),
273
+ execute: async (args: any) => kalshiPriceHistoryApi(args.ticker, args.period),
274
+ }),
275
+
276
+ // ── Analysis tools ──
277
+
278
+ marketMicrostructure: t({
279
+ description: "Analyze market microstructure: depth imbalance, effective spread, Kyle's lambda (price impact), and liquidity score. Uses order book data.",
280
+ parameters: z.object({
281
+ slug: z.string().describe("Polymarket event slug"),
282
+ marketIndex: z.number().optional().describe("Sub-market index (default 0)"),
283
+ }),
284
+ execute: async (args: any) => {
285
+ const book = await clobOrderBook(args.slug, args.marketIndex ?? 0, 0);
286
+ const bids: { price: number; size: number }[] = book.bids ?? [];
287
+ const asks: { price: number; size: number }[] = book.asks ?? [];
288
+
289
+ // Depth imbalance: (bid_volume - ask_volume) / (bid_volume + ask_volume)
290
+ const bidVol = bids.reduce((s, l) => s + l.size, 0);
291
+ const askVol = asks.reduce((s, l) => s + l.size, 0);
292
+ const totalVol = bidVol + askVol || 1;
293
+ const depthImbalance = +((bidVol - askVol) / totalVol).toFixed(4);
294
+
295
+ // Effective spread: difference between best bid and ask
296
+ const bestBid = bids[0]?.price ?? 0;
297
+ const bestAsk = asks[0]?.price ?? 1;
298
+ const midPrice = (bestBid + bestAsk) / 2;
299
+ const effectiveSpread = +(bestAsk - bestBid).toFixed(4);
300
+ const spreadBps = midPrice > 0 ? +((effectiveSpread / midPrice) * 10000).toFixed(0) : 0;
301
+
302
+ // Kyle's lambda estimate: price impact per unit of order flow
303
+ // Approximation: spread / (2 * average_depth_at_top_3_levels)
304
+ const top3BidDepth = bids.slice(0, 3).reduce((s, l) => s + l.size, 0);
305
+ const top3AskDepth = asks.slice(0, 3).reduce((s, l) => s + l.size, 0);
306
+ const avgDepth = (top3BidDepth + top3AskDepth) / 2 || 1;
307
+ const kyleLambda = +(effectiveSpread / (2 * avgDepth)).toFixed(6);
308
+
309
+ // Depth at price levels
310
+ const depthLevels = [];
311
+ for (let i = 0; i < Math.min(5, Math.max(bids.length, asks.length)); i++) {
312
+ depthLevels.push({
313
+ level: i + 1,
314
+ bidPrice: bids[i]?.price ?? null,
315
+ bidSize: bids[i]?.size ?? 0,
316
+ askPrice: asks[i]?.price ?? null,
317
+ askSize: asks[i]?.size ?? 0,
318
+ });
319
+ }
320
+
321
+ // Liquidity score: composite (0-100)
322
+ // Based on: tighter spread = better, more depth = better, more balanced = better
323
+ const spreadScore = Math.max(0, 100 - spreadBps * 2); // 50bps = 0 score
324
+ const depthScore = Math.min(100, (totalVol / 100) * 10); // 1000 contracts = 100
325
+ const balanceScore = (1 - Math.abs(depthImbalance)) * 100;
326
+ const liquidityScore = Math.round((spreadScore * 0.4 + depthScore * 0.4 + balanceScore * 0.2));
327
+
328
+ return {
329
+ title: book.title, slug: args.slug, url: book.url,
330
+ midPrice: +midPrice.toFixed(4),
331
+ effectiveSpread, spreadBps,
332
+ depthImbalance,
333
+ depthImbalanceLabel: depthImbalance > 0.3 ? "bid heavy" : depthImbalance < -0.3 ? "ask heavy" : "balanced",
334
+ kyleLambda,
335
+ kyleLambdaLabel: kyleLambda > 0.01 ? "high impact" : kyleLambda > 0.001 ? "moderate" : "low impact",
336
+ bidVolume: bidVol, askVolume: askVol,
337
+ liquidityScore,
338
+ liquidityLabel: liquidityScore >= 70 ? "excellent" : liquidityScore >= 40 ? "adequate" : "thin",
339
+ depthLevels,
340
+ };
341
+ },
342
+ }),
343
+
344
+ liquidityScanner: t({
345
+ description: "Scan top Polymarket markets and rank them by liquidity, spread, and volume. Great for finding the best markets to trade.",
346
+ parameters: z.object({
347
+ query: z.string().optional().describe("Optional filter (e.g. crypto, politics)"),
348
+ limit: z.number().optional().describe("Number of markets to scan (1-10, default 5)"),
349
+ }),
350
+ execute: async (args: any) => {
351
+ const events = await gammaEvents({ query: args.query, limit: Math.min(args.limit ?? 5, 10) });
352
+ const results = [];
353
+
354
+ for (const e of events) {
355
+ const market = e.markets?.[0];
356
+ if (!market) continue;
357
+
358
+ const bid = parseFloat(market.bestBid ?? "0");
359
+ const ask = parseFloat(market.bestAsk ?? "1");
360
+ const spread = ask - bid;
361
+ const spreadPct = bid > 0 ? spread / ((bid + ask) / 2) : 0;
362
+
363
+ results.push({
364
+ title: e.title, slug: e.slug,
365
+ yesPrice: market.yesPrice ? parseFloat(market.yesPrice) : null,
366
+ volume24h: e.volume24hr ?? 0,
367
+ liquidity: e.liquidity ?? 0,
368
+ spread: +spread.toFixed(4),
369
+ spreadPct: +(spreadPct * 100).toFixed(2),
370
+ bestBid: bid, bestAsk: ask,
371
+ });
372
+ }
373
+
374
+ // Sort by liquidity descending
375
+ results.sort((a, b) => b.liquidity - a.liquidity);
376
+
377
+ return {
378
+ query: args.query ?? "top markets",
379
+ markets: results,
380
+ summary: {
381
+ avgSpread: +(results.reduce((s, r) => s + r.spreadPct, 0) / (results.length || 1)).toFixed(2),
382
+ totalLiquidity: results.reduce((s, r) => s + r.liquidity, 0),
383
+ totalVolume24h: results.reduce((s, r) => s + r.volume24h, 0),
384
+ tightestSpread: results.length > 0 ? results.reduce((best, r) => r.spreadPct < best.spreadPct ? r : best).title : null,
385
+ deepestLiquidity: results.length > 0 ? results[0].title : null,
386
+ },
387
+ };
388
+ },
389
+ }),
390
+
391
+ correlationAnalysis: t({
392
+ description: "Analyze price correlation between two Polymarket markets. Shows if markets move together, apart, or independently.",
393
+ parameters: z.object({
394
+ slugA: z.string().describe("First market slug"),
395
+ slugB: z.string().describe("Second market slug"),
396
+ interval: z.string().optional().describe("Time range: 1d, 1w, 1m (default 1w)"),
397
+ }),
398
+ execute: async (args: any) => {
399
+ const interval = args.interval ?? "1w";
400
+ const [a, b] = await Promise.all([
401
+ clobPriceHistory(args.slugA, interval, 60),
402
+ clobPriceHistory(args.slugB, interval, 60),
403
+ ]);
404
+
405
+ const pricesA = (a.priceHistory ?? []).map((p: any) => p.p);
406
+ const pricesB = (b.priceHistory ?? []).map((p: any) => p.p);
407
+
408
+ // Align to same length (shorter series)
409
+ const len = Math.min(pricesA.length, pricesB.length);
410
+ if (len < 5) {
411
+ return { error: "Not enough overlapping data points (need at least 5)" };
412
+ }
413
+
414
+ const pA = pricesA.slice(-len);
415
+ const pB = pricesB.slice(-len);
416
+
417
+ // Compute returns
418
+ const rA: number[] = [], rB: number[] = [];
419
+ for (let i = 1; i < len; i++) {
420
+ rA.push(pA[i] - pA[i - 1]);
421
+ rB.push(pB[i] - pB[i - 1]);
422
+ }
423
+
424
+ // Pearson correlation
425
+ const meanA = rA.reduce((s, v) => s + v, 0) / rA.length;
426
+ const meanB = rB.reduce((s, v) => s + v, 0) / rB.length;
427
+ let cov = 0, varA = 0, varB = 0;
428
+ for (let i = 0; i < rA.length; i++) {
429
+ const dA = rA[i] - meanA, dB = rB[i] - meanB;
430
+ cov += dA * dB;
431
+ varA += dA * dA;
432
+ varB += dB * dB;
433
+ }
434
+ const correlation = varA > 0 && varB > 0 ? +(cov / Math.sqrt(varA * varB)).toFixed(4) : 0;
435
+ const label = correlation > 0.7 ? "strong positive" : correlation > 0.3 ? "moderate positive"
436
+ : correlation > -0.3 ? "uncorrelated" : correlation > -0.7 ? "moderate negative" : "strong negative";
437
+
438
+ // Beta: how much B moves per unit of A
439
+ const beta = varA > 0 ? +(cov / varA).toFixed(4) : 0;
440
+
441
+ return {
442
+ marketA: { title: a.title, slug: args.slugA, current: a.current, change: a.change, priceHistory: pA },
443
+ marketB: { title: b.title, slug: args.slugB, current: b.current, change: b.change, priceHistory: pB },
444
+ correlation, label, beta,
445
+ dataPoints: len,
446
+ interval,
447
+ tradingImplication: correlation > 0.7 ? "Markets move together — hedge one with the other, or avoid doubling exposure"
448
+ : correlation < -0.7 ? "Markets move inversely — natural hedge pair, or pairs-trade opportunity"
449
+ : "Markets are largely independent — good for portfolio diversification",
450
+ };
451
+ },
452
+ }),
453
+
454
+ // ── Whale / Wallet analysis tools ──
455
+
456
+ walletProfiler: t({
457
+ description: "Analyze a Polymarket wallet: win rate, PnL, Sharpe ratio, edge category (smart_money/neutral/weak_hand/bot). Requires a wallet address (0x...).",
458
+ parameters: z.object({
459
+ address: z.string().describe("Wallet address (0x...)"),
460
+ }),
461
+ execute: async (args: any) => {
462
+ const [profile, positions, trades] = await Promise.all([
463
+ getWalletProfile(args.address),
464
+ getWalletPositions(args.address, 100),
465
+ getWalletTrades(args.address, 200),
466
+ ]);
467
+
468
+ // Win rate from positions
469
+ const closedPositions = positions.filter((p: any) => p.pnl !== undefined && p.pnl !== null);
470
+ const wins = closedPositions.filter((p: any) => (p.pnl ?? 0) > 0).length;
471
+ const winRate = closedPositions.length > 0 ? +(wins / closedPositions.length).toFixed(4) : 0;
472
+
473
+ // PnL stats
474
+ const pnls = closedPositions.map((p: any) => p.pnl_percent ?? p.pnl ?? 0);
475
+ const totalPnl = pnls.reduce((s: number, p: number) => s + p, 0);
476
+ const meanPnl = pnls.length > 0 ? totalPnl / pnls.length : 0;
477
+ const pnlVariance = pnls.length > 1 ? pnls.reduce((s: number, p: number) => s + (p - meanPnl) ** 2, 0) / pnls.length : 0;
478
+ const pnlStdDev = Math.sqrt(pnlVariance);
479
+ const sharpe = pnlStdDev > 0 ? +(meanPnl / pnlStdDev).toFixed(4) : 0;
480
+
481
+ // Sortino (downside deviation)
482
+ const downsidePnls = pnls.filter((p: number) => p < 0);
483
+ const downsideVar = downsidePnls.length > 0 ? downsidePnls.reduce((s: number, p: number) => s + p ** 2, 0) / downsidePnls.length : 0;
484
+ const sortino = Math.sqrt(downsideVar) > 0 ? +(meanPnl / Math.sqrt(downsideVar)).toFixed(4) : 0;
485
+
486
+ // Composite score (-1 to +1)
487
+ const normWinRate = (winRate - 0.5) * 2; // 0.5 → 0, 1.0 → 1
488
+ const normSharpe = Math.tanh(sharpe * 0.5);
489
+ const normPnl = Math.tanh(totalPnl * 0.01);
490
+ const composite = +(0.3 * normSharpe + 0.3 * normWinRate + 0.4 * normPnl).toFixed(4);
491
+
492
+ // Edge category
493
+ const edgeCategory = composite > 0.5 ? "smart_money" : composite > 0.1 ? "neutral"
494
+ : composite > -0.3 ? "weak_hand" : "bot_or_losing";
495
+
496
+ // Trade stats
497
+ const buyCount = trades.filter((t: any) => t.side === "BUY").length;
498
+ const avgSize = trades.length > 0 ? +(trades.reduce((s: number, t: any) => s + (t.size ?? 0), 0) / trades.length).toFixed(0) : 0;
499
+
500
+ // Top markets by position size
501
+ const topPositions = positions
502
+ .sort((a: any, b: any) => Math.abs(b.current_value ?? 0) - Math.abs(a.current_value ?? 0))
503
+ .slice(0, 5)
504
+ .map((p: any) => ({
505
+ market: p.market_slug ?? p.condition_id ?? "unknown",
506
+ outcome: p.outcome ?? "YES",
507
+ size: p.size ?? 0, avgPrice: p.avg_price ?? 0,
508
+ currentValue: p.current_value ?? 0,
509
+ pnl: p.pnl ?? 0, pnlPct: p.pnl_percent ?? 0,
510
+ }));
511
+
512
+ return {
513
+ ...profile,
514
+ stats: {
515
+ winRate, totalPnl: +totalPnl.toFixed(2), meanPnl: +meanPnl.toFixed(4),
516
+ sharpe, sortino, composite, edgeCategory,
517
+ totalPositions: positions.length, closedPositions: closedPositions.length,
518
+ totalTrades: trades.length, buyRatio: trades.length > 0 ? +(buyCount / trades.length).toFixed(2) : 0,
519
+ avgTradeSize: avgSize,
520
+ },
521
+ topPositions,
522
+ };
523
+ },
524
+ }),
525
+
526
+ botDetector: t({
527
+ description: "Detect bots in a Polymarket market. Analyzes top traders' timing, sizing, and behavior to classify as bot/human with strategy type (market_maker, grid_bot, momentum, sniper, human).",
528
+ parameters: z.object({
529
+ slug: z.string().describe("Polymarket event slug"),
530
+ }),
531
+ execute: async (args: any) => {
532
+ const { conditionId, title } = await resolveConditionId(args.slug);
533
+ const trades = await getMarketTrades(conditionId, 500);
534
+
535
+ // Group trades by wallet
536
+ const byWallet = new Map<string, any[]>();
537
+ for (const t of trades) {
538
+ const addr = t.maker_address ?? t.taker_address ?? "unknown";
539
+ if (!byWallet.has(addr)) byWallet.set(addr, []);
540
+ byWallet.get(addr)!.push(t);
541
+ }
542
+
543
+ // Analyze top traders (by trade count)
544
+ const wallets = [...byWallet.entries()]
545
+ .sort((a, b) => b[1].length - a[1].length)
546
+ .slice(0, 10);
547
+
548
+ const results = [];
549
+ for (const [address, wTrades] of wallets) {
550
+ if (wTrades.length < 3) continue;
551
+
552
+ // Timing analysis
553
+ const timestamps = wTrades.map((t: any) => new Date(t.created_at ?? 0).getTime()).sort((a: number, b: number) => a - b);
554
+ const intervals: number[] = [];
555
+ for (let i = 1; i < timestamps.length; i++) intervals.push(timestamps[i] - timestamps[i - 1]);
556
+
557
+ const meanInterval = intervals.length > 0 ? intervals.reduce((a, b) => a + b, 0) / intervals.length : 0;
558
+ const stdInterval = intervals.length > 1 ? Math.sqrt(intervals.reduce((s, v) => s + (v - meanInterval) ** 2, 0) / intervals.length) : 0;
559
+ const timingCV = meanInterval > 0 ? +(stdInterval / meanInterval).toFixed(3) : 999;
560
+
561
+ // Size analysis
562
+ const sizes = wTrades.map((t: any) => t.size ?? 0);
563
+ const meanSize = sizes.reduce((a: number, b: number) => a + b, 0) / sizes.length;
564
+ const stdSize = Math.sqrt(sizes.reduce((s: number, v: number) => s + (v - meanSize) ** 2, 0) / sizes.length);
565
+ const sizeCV = meanSize > 0 ? +(stdSize / meanSize).toFixed(3) : 999;
566
+
567
+ // Trade frequency (trades per hour)
568
+ const timeSpanHrs = timestamps.length > 1 ? (timestamps[timestamps.length - 1] - timestamps[0]) / 3600000 : 1;
569
+ const tradeFreq = +(wTrades.length / Math.max(timeSpanHrs, 0.01)).toFixed(2);
570
+
571
+ // Buy ratio
572
+ const buys = wTrades.filter((t: any) => t.side === "BUY").length;
573
+ const buyRatio = +(buys / wTrades.length).toFixed(3);
574
+ const imbalance = Math.abs(buyRatio - 0.5) * 2;
575
+
576
+ // Sub-second trades
577
+ const fastTrades = intervals.filter(i => i < 2000).length;
578
+ const fastRatio = intervals.length > 0 ? +(fastTrades / intervals.length).toFixed(3) : 0;
579
+
580
+ // Size regularity
581
+ const sizeRegularity = sizeCV < 0.3 ? 1 : sizeCV < 0.6 ? 0.5 : 0;
582
+
583
+ // Classify strategy type
584
+ let strategyType = "human";
585
+ let isBot = false;
586
+
587
+ if (imbalance < 0.15 && tradeFreq > 10 && sizeRegularity > 0.5 && timingCV < 0.5) {
588
+ strategyType = "market_maker"; isBot = true;
589
+ } else if (sizeRegularity > 0.7 && imbalance < 0.3 && tradeFreq >= 2 && tradeFreq <= 50) {
590
+ strategyType = "grid_bot"; isBot = true;
591
+ } else if (imbalance > 0.6 && tradeFreq > 5) {
592
+ strategyType = "momentum"; isBot = true;
593
+ } else if (fastRatio > 0.3 && meanSize > 100 && wTrades.length < 20) {
594
+ strategyType = "sniper"; isBot = true;
595
+ } else if (timingCV < 0.3 || (tradeFreq > 20 && sizeCV < 0.4)) {
596
+ strategyType = "unknown_bot"; isBot = true;
597
+ }
598
+
599
+ const name = wTrades[0]?.maker_name ?? address.slice(0, 8) + "...";
600
+ results.push({
601
+ address: address.slice(0, 10) + "...", name,
602
+ trades: wTrades.length, isBot, strategyType,
603
+ tradeFreq, timingCV, sizeCV, buyRatio, fastRatio,
604
+ avgSize: +meanSize.toFixed(0),
605
+ totalVolume: +sizes.reduce((a: number, b: number) => a + b, 0).toFixed(0),
606
+ });
607
+ }
608
+
609
+ const botCount = results.filter(r => r.isBot).length;
610
+ const humanCount = results.length - botCount;
611
+
612
+ return {
613
+ title, slug: args.slug,
614
+ totalTradesAnalyzed: trades.length,
615
+ uniqueWallets: byWallet.size,
616
+ topTraders: results,
617
+ summary: {
618
+ bots: botCount, humans: humanCount,
619
+ botPct: results.length > 0 ? +((botCount / results.length) * 100).toFixed(0) : 0,
620
+ strategies: {
621
+ market_maker: results.filter(r => r.strategyType === "market_maker").length,
622
+ grid_bot: results.filter(r => r.strategyType === "grid_bot").length,
623
+ momentum: results.filter(r => r.strategyType === "momentum").length,
624
+ sniper: results.filter(r => r.strategyType === "sniper").length,
625
+ human: humanCount,
626
+ },
627
+ },
628
+ };
629
+ },
630
+ }),
631
+
632
+ marketFlow: t({
633
+ description: "Deep flow analysis for a Polymarket market: buy/sell volume split, wallet concentration (Herfindahl index), top movers, unique wallets.",
634
+ parameters: z.object({
635
+ slug: z.string().describe("Polymarket event slug"),
636
+ limit: z.number().optional().describe("Number of trades to analyze (50-500, default 200)"),
637
+ }),
638
+ execute: async (args: any) => {
639
+ const { conditionId, title } = await resolveConditionId(args.slug);
640
+ const trades = await getMarketTrades(conditionId, Math.min(args.limit ?? 200, 500));
641
+
642
+ // Basic flow
643
+ let buyVol = 0, sellVol = 0, buyCount = 0, sellCount = 0;
644
+ const walletVolumes = new Map<string, { buy: number; sell: number; name: string }>();
645
+
646
+ for (const t of trades) {
647
+ const size = t.size ?? 0;
648
+ const usdc = t.usdc_size ?? size * (t.price ?? 0.5);
649
+ const addr = t.maker_address ?? "unknown";
650
+ const name = t.maker_name ?? addr.slice(0, 8) + "...";
651
+
652
+ if (t.side === "BUY") { buyVol += usdc; buyCount++; }
653
+ else { sellVol += usdc; sellCount++; }
654
+
655
+ if (!walletVolumes.has(addr)) walletVolumes.set(addr, { buy: 0, sell: 0, name });
656
+ const w = walletVolumes.get(addr)!;
657
+ if (t.side === "BUY") w.buy += usdc; else w.sell += usdc;
658
+ }
659
+
660
+ const totalVol = buyVol + sellVol || 1;
661
+
662
+ // Herfindahl index (concentration)
663
+ const shares = [...walletVolumes.values()].map(w => ((w.buy + w.sell) / totalVol) ** 2);
664
+ const herfindahl = +(shares.reduce((s, v) => s + v, 0)).toFixed(4);
665
+ const concentrationLabel = herfindahl > 0.25 ? "highly concentrated"
666
+ : herfindahl > 0.15 ? "moderately concentrated" : "well distributed";
667
+
668
+ // Top movers
669
+ const topMovers = [...walletVolumes.entries()]
670
+ .map(([addr, w]) => ({
671
+ address: addr.slice(0, 10) + "...", name: w.name,
672
+ buyVol: +w.buy.toFixed(2), sellVol: +w.sell.toFixed(2),
673
+ total: +(w.buy + w.sell).toFixed(2),
674
+ direction: w.buy > w.sell ? "buyer" : "seller",
675
+ share: +((w.buy + w.sell) / totalVol * 100).toFixed(1),
676
+ }))
677
+ .sort((a, b) => b.total - a.total)
678
+ .slice(0, 8);
679
+
680
+ // Time distribution (bucket trades into 4 quarters)
681
+ const timestamps = trades.map((t: any) => new Date(t.created_at ?? 0).getTime()).filter((t: number) => t > 0);
682
+ const minTs = Math.min(...timestamps);
683
+ const maxTs = Math.max(...timestamps);
684
+ const range = maxTs - minTs || 1;
685
+ const buckets = [0, 0, 0, 0];
686
+ for (const ts of timestamps) {
687
+ const idx = Math.min(3, Math.floor(((ts - minTs) / range) * 4));
688
+ buckets[idx]++;
689
+ }
690
+
691
+ return {
692
+ title, slug: args.slug,
693
+ flow: {
694
+ buyVolume: +buyVol.toFixed(2), sellVolume: +sellVol.toFixed(2),
695
+ netFlow: +(buyVol - sellVol).toFixed(2),
696
+ direction: buyVol >= sellVol ? "inflow" : "outflow",
697
+ buyCount, sellCount, totalTrades: trades.length,
698
+ buyPct: +((buyVol / totalVol) * 100).toFixed(1),
699
+ },
700
+ concentration: {
701
+ herfindahl, label: concentrationLabel,
702
+ uniqueWallets: walletVolumes.size,
703
+ top3Share: topMovers.slice(0, 3).reduce((s, m) => s + m.share, 0),
704
+ },
705
+ topMovers,
706
+ timeDistribution: buckets,
707
+ };
708
+ },
709
+ }),
710
+
711
+ // ── Quant calculation tools ──
712
+
713
+ riskMetrics: t({
714
+ description: "Calculate risk metrics from price history: VaR, CVaR (expected shortfall), max drawdown, Sharpe ratio, Sortino ratio, Calmar ratio. Essential for position sizing.",
715
+ parameters: z.object({
716
+ slug: z.string().describe("Polymarket event slug"),
717
+ interval: z.string().optional().describe("Time range: 1d, 1w, 1m, max (default 1w)"),
718
+ confidenceLevel: z.number().optional().describe("VaR confidence level 0.90-0.99 (default 0.95)"),
719
+ }),
720
+ execute: async (args: any) => {
721
+ const data = await clobPriceHistory(args.slug, args.interval ?? "1w", 60);
722
+ const prices: number[] = data.priceHistory.map((p: any) => p.p);
723
+ if (prices.length < 10) return { ...data, error: "Need at least 10 data points" };
724
+
725
+ const confidence = args.confidenceLevel ?? 0.95;
726
+
727
+ // Returns
728
+ const returns: number[] = [];
729
+ for (let i = 1; i < prices.length; i++) {
730
+ if (prices[i - 1] > 0) returns.push((prices[i] - prices[i - 1]) / prices[i - 1]);
731
+ }
732
+
733
+ const meanReturn = returns.reduce((a, b) => a + b, 0) / returns.length;
734
+ const variance = returns.reduce((a, b) => a + (b - meanReturn) ** 2, 0) / returns.length;
735
+ const stdDev = Math.sqrt(variance);
736
+
737
+ // VaR (historical method)
738
+ const sortedReturns = [...returns].sort((a, b) => a - b);
739
+ const varIdx = Math.floor((1 - confidence) * sortedReturns.length);
740
+ const historicalVaR = +Math.abs(sortedReturns[varIdx] ?? 0).toFixed(6);
741
+
742
+ // CVaR (expected shortfall — average of returns worse than VaR)
743
+ const tailReturns = sortedReturns.slice(0, varIdx + 1);
744
+ const cvar = tailReturns.length > 0 ? +Math.abs(tailReturns.reduce((a, b) => a + b, 0) / tailReturns.length).toFixed(6) : historicalVaR;
745
+
746
+ // Max drawdown
747
+ let peak = prices[0], maxDD = 0, maxDDStart = 0, maxDDEnd = 0;
748
+ let currentDDStart = 0;
749
+ for (let i = 1; i < prices.length; i++) {
750
+ if (prices[i] > peak) { peak = prices[i]; currentDDStart = i; }
751
+ const dd = (peak - prices[i]) / peak;
752
+ if (dd > maxDD) { maxDD = dd; maxDDStart = currentDDStart; maxDDEnd = i; }
753
+ }
754
+
755
+ // Sharpe (annualized, assuming hourly data)
756
+ const annFactor = Math.sqrt(365 * 24);
757
+ const sharpe = stdDev > 0 ? +((meanReturn * annFactor * 365 * 24) / (stdDev * annFactor)).toFixed(4) : 0;
758
+
759
+ // Sortino
760
+ const downsideReturns = returns.filter(r => r < 0);
761
+ const downsideVar = downsideReturns.length > 0 ? downsideReturns.reduce((s, r) => s + r ** 2, 0) / downsideReturns.length : 0;
762
+ const downsideDev = Math.sqrt(downsideVar);
763
+ const sortino = downsideDev > 0 ? +((meanReturn * 365 * 24) / (downsideDev * annFactor)).toFixed(4) : 0;
764
+
765
+ // Calmar (annualized return / max drawdown)
766
+ const annualizedReturn = meanReturn * 365 * 24;
767
+ const calmar = maxDD > 0 ? +(annualizedReturn / maxDD).toFixed(4) : 0;
768
+
769
+ // Skewness and kurtosis
770
+ const skewness = stdDev > 0 ? +(returns.reduce((s, r) => s + ((r - meanReturn) / stdDev) ** 3, 0) / returns.length).toFixed(4) : 0;
771
+ const kurtosis = stdDev > 0 ? +(returns.reduce((s, r) => s + ((r - meanReturn) / stdDev) ** 4, 0) / returns.length - 3).toFixed(4) : 0;
772
+
773
+ return {
774
+ title: data.title, slug: args.slug, interval: args.interval ?? "1w",
775
+ dataPoints: prices.length,
776
+ riskMetrics: {
777
+ var: historicalVaR, varPct: +((historicalVaR * 100)).toFixed(2),
778
+ cvar, cvarPct: +((cvar * 100)).toFixed(2),
779
+ confidenceLevel: confidence,
780
+ maxDrawdown: +maxDD.toFixed(4), maxDrawdownPct: +((maxDD * 100)).toFixed(2),
781
+ drawdownPeriod: { start: maxDDStart, end: maxDDEnd, length: maxDDEnd - maxDDStart },
782
+ },
783
+ performanceMetrics: {
784
+ sharpe, sortino, calmar,
785
+ meanReturn: +meanReturn.toFixed(6),
786
+ annualizedReturn: +(annualizedReturn * 100).toFixed(2),
787
+ volatility: +(stdDev * annFactor * 100).toFixed(2),
788
+ },
789
+ distribution: {
790
+ skewness: +skewness, kurtosis: +kurtosis,
791
+ skewnessLabel: +skewness < -0.5 ? "left-skewed (tail risk)" : +skewness > 0.5 ? "right-skewed (upside)" : "symmetric",
792
+ kurtosisLabel: +kurtosis > 1 ? "fat tails (extreme events)" : +kurtosis < -1 ? "thin tails" : "normal",
793
+ },
794
+ priceHistory: prices,
795
+ };
796
+ },
797
+ }),
798
+
799
+ marketRegime: t({
800
+ description: "Detect market regime: is it trending, mean-reverting, or random? Uses Hurst exponent, variance ratio test, and return entropy.",
801
+ parameters: z.object({
802
+ slug: z.string().describe("Polymarket event slug"),
803
+ interval: z.string().optional().describe("Time range: 1d, 1w, 1m, max (default 1w)"),
804
+ }),
805
+ execute: async (args: any) => {
806
+ const data = await clobPriceHistory(args.slug, args.interval ?? "1w", 60);
807
+ const prices: number[] = data.priceHistory.map((p: any) => p.p);
808
+ if (prices.length < 20) return { ...data, error: "Need at least 20 data points" };
809
+
810
+ // Returns
811
+ const returns: number[] = [];
812
+ for (let i = 1; i < prices.length; i++) {
813
+ if (prices[i - 1] > 0) returns.push(Math.log(prices[i] / prices[i - 1]));
814
+ }
815
+
816
+ // ── Hurst Exponent (R/S method) ──
817
+ function computeHurst(series: number[]): number {
818
+ const n = series.length;
819
+ if (n < 20) return 0.5;
820
+
821
+ const rsValues: number[] = [];
822
+ const nValues: number[] = [];
823
+
824
+ for (let size = 10; size <= Math.floor(n / 2); size = Math.floor(size * 1.5)) {
825
+ const chunks = Math.floor(n / size);
826
+ let rsSum = 0;
827
+ for (let c = 0; c < chunks; c++) {
828
+ const chunk = series.slice(c * size, (c + 1) * size);
829
+ const mean = chunk.reduce((a, b) => a + b, 0) / chunk.length;
830
+ const deviations = chunk.map(v => v - mean);
831
+ const cumDev: number[] = [];
832
+ let cumSum = 0;
833
+ for (const d of deviations) { cumSum += d; cumDev.push(cumSum); }
834
+ const range = Math.max(...cumDev) - Math.min(...cumDev);
835
+ const stdDev = Math.sqrt(chunk.reduce((s, v) => s + (v - mean) ** 2, 0) / chunk.length);
836
+ if (stdDev > 0) rsSum += range / stdDev;
837
+ }
838
+ if (chunks > 0) {
839
+ rsValues.push(Math.log(rsSum / chunks));
840
+ nValues.push(Math.log(size));
841
+ }
842
+ }
843
+
844
+ // Linear regression log(R/S) = H * log(n) + c
845
+ if (rsValues.length < 2) return 0.5;
846
+ const meanX = nValues.reduce((a, b) => a + b, 0) / nValues.length;
847
+ const meanY = rsValues.reduce((a, b) => a + b, 0) / rsValues.length;
848
+ let num = 0, den = 0;
849
+ for (let i = 0; i < nValues.length; i++) {
850
+ num += (nValues[i] - meanX) * (rsValues[i] - meanY);
851
+ den += (nValues[i] - meanX) ** 2;
852
+ }
853
+ return den > 0 ? Math.max(0, Math.min(1, num / den)) : 0.5;
854
+ }
855
+
856
+ const hurst = +computeHurst(returns).toFixed(4);
857
+ const hurstLabel = hurst < 0.4 ? "mean-reverting" : hurst > 0.6 ? "trending" : "random walk";
858
+
859
+ // ── Variance Ratio Test (VR at lag 2, 5, 10) ──
860
+ function varianceRatio(series: number[], lag: number): number {
861
+ if (series.length < lag * 2) return 1;
862
+ const singleVar = series.reduce((s, r) => s + r ** 2, 0) / series.length;
863
+ const lagReturns: number[] = [];
864
+ for (let i = lag; i < series.length; i++) {
865
+ lagReturns.push(series.slice(i - lag, i).reduce((a, b) => a + b, 0));
866
+ }
867
+ const lagVar = lagReturns.reduce((s, r) => s + r ** 2, 0) / lagReturns.length;
868
+ return singleVar > 0 ? lagVar / (lag * singleVar) : 1;
869
+ }
870
+
871
+ const vr2 = +varianceRatio(returns, 2).toFixed(4);
872
+ const vr5 = +varianceRatio(returns, 5).toFixed(4);
873
+ const vr10 = +varianceRatio(returns, 10).toFixed(4);
874
+
875
+ // ── Shannon Entropy of return distribution ──
876
+ const bins = 20;
877
+ const minR = Math.min(...returns);
878
+ const maxR = Math.max(...returns);
879
+ const binWidth = (maxR - minR) / bins || 1;
880
+ const histogram = new Array(bins).fill(0);
881
+ for (const r of returns) {
882
+ const idx = Math.min(bins - 1, Math.floor((r - minR) / binWidth));
883
+ histogram[idx]++;
884
+ }
885
+ const totalR = returns.length;
886
+ let entropy = 0;
887
+ for (const count of histogram) {
888
+ if (count > 0) {
889
+ const p = count / totalR;
890
+ entropy -= p * Math.log2(p);
891
+ }
892
+ }
893
+ const maxEntropy = Math.log2(bins);
894
+ const normalizedEntropy = maxEntropy > 0 ? +(entropy / maxEntropy).toFixed(4) : 0;
895
+ const entropyLabel = normalizedEntropy > 0.85 ? "high (unpredictable)" : normalizedEntropy > 0.6 ? "moderate" : "low (patterned)";
896
+
897
+ // ── Autocorrelation at lags 1-5 ──
898
+ const mean = returns.reduce((a, b) => a + b, 0) / returns.length;
899
+ const totalVar = returns.reduce((s, r) => s + (r - mean) ** 2, 0);
900
+ const autocorrelations: number[] = [];
901
+ for (let lag = 1; lag <= 5; lag++) {
902
+ let cov = 0;
903
+ for (let i = lag; i < returns.length; i++) {
904
+ cov += (returns[i] - mean) * (returns[i - lag] - mean);
905
+ }
906
+ autocorrelations.push(totalVar > 0 ? +(cov / totalVar).toFixed(4) : 0);
907
+ }
908
+
909
+ // Overall regime determination
910
+ let regime: string;
911
+ if (hurst < 0.4 && vr2 < 0.85) regime = "mean-reverting";
912
+ else if (hurst > 0.6 && vr2 > 1.15) regime = "trending";
913
+ else if (normalizedEntropy > 0.85 && Math.abs(vr2 - 1) < 0.15) regime = "efficient (random walk)";
914
+ else regime = "mixed / transitioning";
915
+
916
+ const strategyHint = regime === "mean-reverting" ? "Fade moves, buy dips, sell rallies. Bollinger bands + mean-reversion strategies work well."
917
+ : regime === "trending" ? "Follow the trend. Momentum strategies, breakout entries. Don't fade."
918
+ : regime === "efficient (random walk)" ? "Market is efficient — edge comes from information, not price patterns. Focus on news/sentiment."
919
+ : "Mixed regime — use adaptive strategies or wait for clearer signals.";
920
+
921
+ return {
922
+ title: data.title, slug: args.slug, interval: args.interval ?? "1w",
923
+ dataPoints: prices.length,
924
+ regime, strategyHint,
925
+ hurst: { value: hurst, label: hurstLabel },
926
+ varianceRatio: { vr2, vr5, vr10, label: vr2 > 1.15 ? "trending" : vr2 < 0.85 ? "mean-reverting" : "efficient" },
927
+ entropy: { value: +entropy.toFixed(4), normalized: normalizedEntropy, label: entropyLabel },
928
+ autocorrelations: { lag1: autocorrelations[0], lag2: autocorrelations[1], lag3: autocorrelations[2], lag4: autocorrelations[3], lag5: autocorrelations[4] },
929
+ priceHistory: prices,
930
+ };
931
+ },
932
+ }),
933
+
934
+ // ── Stock market tools (Yahoo Finance — free, no API key) ──
935
+
936
+ stockQuote: t({
937
+ description: "Get real-time stock quotes: price, change, volume, market cap, P/E, 52-week range, moving averages. Supports stocks, ETFs, indices, crypto. No API key needed.",
938
+ parameters: z.object({
939
+ symbols: z.string().describe("Comma-separated symbols (e.g. AAPL,MSFT,TSLA or ^SPX,BTC-USD)"),
940
+ }),
941
+ execute: async (args: any) => {
942
+ const symbols = args.symbols.split(",").map((s: string) => s.trim()).filter(Boolean);
943
+ if (symbols.length === 0) return { error: "No symbols provided" };
944
+ if (symbols.length > 10) return { error: "Max 10 symbols at once" };
945
+
946
+ const quotes = await yahooQuote(symbols);
947
+ if (quotes.length === 0) return { error: "No results found" };
948
+
949
+ return {
950
+ quotes,
951
+ summary: quotes.length > 1 ? {
952
+ avgChange: +(quotes.reduce((s: number, q: any) => s + (q.changePct ?? 0), 0) / quotes.length).toFixed(2),
953
+ topGainer: quotes.reduce((best: any, q: any) => (q.changePct ?? 0) > (best.changePct ?? 0) ? q : best),
954
+ topLoser: quotes.reduce((worst: any, q: any) => (q.changePct ?? 0) < (worst.changePct ?? 0) ? q : worst),
955
+ } : undefined,
956
+ };
957
+ },
958
+ }),
959
+
960
+ stockChart: t({
961
+ description: "Get stock price chart with OHLCV data. Renders as sparkline + stats. No API key needed.",
962
+ parameters: z.object({
963
+ symbol: z.string().describe("Stock symbol (e.g. AAPL, TSLA, ^SPX, BTC-USD)"),
964
+ range: z.string().optional().describe("Time range: 1d, 5d, 1mo, 3mo, 6mo, 1y, 2y, 5y, max (default 1mo)"),
965
+ interval: z.string().optional().describe("Candle interval: 1m, 5m, 15m, 1h, 1d, 1wk, 1mo (default auto)"),
966
+ }),
967
+ execute: async (args: any) => {
968
+ const range = args.range ?? "1mo";
969
+ // Auto-select interval based on range
970
+ const intervalMap: Record<string, string> = {
971
+ "1d": "5m", "5d": "15m", "1mo": "1d", "3mo": "1d",
972
+ "6mo": "1d", "1y": "1wk", "2y": "1wk", "5y": "1mo", "max": "1mo",
973
+ };
974
+ const interval = args.interval ?? intervalMap[range] ?? "1d";
975
+ return yahooChart(args.symbol, range, interval);
976
+ },
977
+ }),
978
+
979
+ stockSearch: t({
980
+ description: "Search for stock tickers by name, company, or keyword. No API key needed.",
981
+ parameters: z.object({
982
+ query: z.string().describe("Search query (e.g. 'Apple', 'electric vehicle', 'gold ETF')"),
983
+ }),
984
+ execute: async (args: any) => {
985
+ const results = await yahooSearch(args.query);
986
+ return { query: args.query, results };
987
+ },
988
+ }),
989
+
990
+ stockScreener: t({
991
+ description: "Compare multiple stocks side-by-side: prices, P/E, market cap, 52-week ranges, moving averages. Great for sector analysis.",
992
+ parameters: z.object({
993
+ symbols: z.string().describe("Comma-separated symbols (e.g. AAPL,MSFT,GOOG,AMZN,META)"),
994
+ }),
995
+ execute: async (args: any) => {
996
+ const symbols = args.symbols.split(",").map((s: string) => s.trim()).filter(Boolean).slice(0, 10);
997
+ const quotes = await yahooQuote(symbols);
998
+
999
+ return {
1000
+ stocks: quotes.map((q: any) => ({
1001
+ symbol: q.symbol, name: q.name, price: q.price,
1002
+ change: q.change, changePct: q.changePct,
1003
+ marketCap: q.marketCap, peRatio: q.peRatio,
1004
+ volume: q.volume, avgVolume: q.avgVolume,
1005
+ fiftyTwoWeekHigh: q.fiftyTwoWeekHigh,
1006
+ fiftyTwoWeekLow: q.fiftyTwoWeekLow,
1007
+ fiftyDayAvg: q.fiftyDayAvg,
1008
+ twoHundredDayAvg: q.twoHundredDayAvg,
1009
+ beta: q.beta, dividendYield: q.dividendYield,
1010
+ distFromHigh: q.fiftyTwoWeekHigh ? +((q.price - q.fiftyTwoWeekHigh) / q.fiftyTwoWeekHigh * 100).toFixed(1) : null,
1011
+ aboveFiftyDayMA: q.fiftyDayAvg ? q.price > q.fiftyDayAvg : null,
1012
+ aboveTwoHundredDayMA: q.twoHundredDayAvg ? q.price > q.twoHundredDayAvg : null,
1013
+ })),
1014
+ };
1015
+ },
1016
+ }),
1017
+
1018
+ // ── Browser chart plotting ──
1019
+
1020
+ openChart: t({
1021
+ description: "Open an interactive Chart.js chart in the browser. Works for both prediction markets (by slug) and stocks (by symbol). Shows candlesticks, volume, and moving averages.",
1022
+ parameters: z.object({
1023
+ source: z.enum(["polymarket", "stock"]).describe("Data source: polymarket or stock"),
1024
+ identifier: z.string().describe("Market slug (polymarket) or stock symbol (stock)"),
1025
+ range: z.string().optional().describe("Time range (default: 1mo for stocks, 1w for prediction markets)"),
1026
+ title: z.string().optional().describe("Custom chart title"),
1027
+ }),
1028
+ execute: async (args: any) => {
1029
+ let labels: string[] = [];
1030
+ let prices: number[] = [];
1031
+ let volumes: number[] = [];
1032
+ let chartTitle = args.title ?? args.identifier;
1033
+
1034
+ if (args.source === "stock") {
1035
+ const range = args.range ?? "1mo";
1036
+ const intervalMap: Record<string, string> = {
1037
+ "1d": "5m", "5d": "15m", "1mo": "1d", "3mo": "1d",
1038
+ "6mo": "1d", "1y": "1wk", "2y": "1wk", "5y": "1mo", "max": "1mo",
1039
+ };
1040
+ const interval = intervalMap[range] ?? "1d";
1041
+ const data = await yahooChart(args.identifier, range, interval);
1042
+ chartTitle = `${data.symbol} — ${data.name} (${range})`;
1043
+ for (const p of data.priceHistory) {
1044
+ if (p.p != null) {
1045
+ labels.push(new Date(p.t * 1000).toLocaleDateString());
1046
+ prices.push(+p.p.toFixed(2));
1047
+ volumes.push(p.v ?? 0);
1048
+ }
1049
+ }
1050
+ } else {
1051
+ const range = args.range ?? "1w";
1052
+ const data = await clobPriceHistory(args.identifier, range, 60);
1053
+ chartTitle = `${data.title} (${range})`;
1054
+ for (const p of data.priceHistory) {
1055
+ labels.push(new Date(p.t * 1000).toLocaleString());
1056
+ prices.push(+p.p.toFixed(4));
1057
+ }
1058
+ }
1059
+
1060
+ if (prices.length === 0) return { error: "No data to chart" };
1061
+
1062
+ // Compute 20-period SMA
1063
+ const sma: (number | null)[] = [];
1064
+ for (let i = 0; i < prices.length; i++) {
1065
+ if (i < 19) { sma.push(null); continue; }
1066
+ const window = prices.slice(i - 19, i + 1);
1067
+ sma.push(+(window.reduce((a, b) => a + b, 0) / 20).toFixed(4));
1068
+ }
1069
+
1070
+ const html = `<!DOCTYPE html>
1071
+ <html><head><meta charset="utf-8"><title>${chartTitle}</title>
1072
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
1073
+ <style>
1074
+ body{margin:0;padding:20px;background:#0d1117;color:#c9d1d9;font-family:system-ui}
1075
+ h2{text-align:center;font-weight:400;color:#58a6ff}
1076
+ .container{max-width:1200px;margin:0 auto}
1077
+ canvas{background:#161b22;border-radius:8px}
1078
+ </style></head><body>
1079
+ <div class="container">
1080
+ <h2>${chartTitle}</h2>
1081
+ <canvas id="price" height="300"></canvas>
1082
+ ${volumes.length > 0 ? '<canvas id="vol" height="100" style="margin-top:12px"></canvas>' : ''}
1083
+ </div>
1084
+ <script>
1085
+ const labels=${JSON.stringify(labels)};
1086
+ const prices=${JSON.stringify(prices)};
1087
+ const sma=${JSON.stringify(sma)};
1088
+ const volumes=${JSON.stringify(volumes)};
1089
+ new Chart(document.getElementById('price'),{type:'line',data:{labels,datasets:[
1090
+ {label:'Price',data:prices,borderColor:'#58a6ff',backgroundColor:'rgba(88,166,255,0.1)',fill:true,tension:0.1,pointRadius:0,borderWidth:2},
1091
+ {label:'SMA(20)',data:sma,borderColor:'#f0883e',borderDash:[5,5],pointRadius:0,borderWidth:1.5}
1092
+ ]},options:{animation:false,responsive:true,scales:{x:{display:true,ticks:{color:'#484f58',maxTicksLimit:12}},y:{display:true,ticks:{color:'#484f58'}}},plugins:{legend:{labels:{color:'#c9d1d9'}}}}});
1093
+ ${volumes.length > 0 ? `new Chart(document.getElementById('vol'),{type:'bar',data:{labels,datasets:[{label:'Volume',data:volumes,backgroundColor:'rgba(88,166,255,0.3)',borderWidth:0}]},options:{animation:false,responsive:true,scales:{x:{display:false},y:{ticks:{color:'#484f58'}}},plugins:{legend:{display:false}}}});` : ''}
1094
+ </script></body></html>`;
1095
+
1096
+ // Write to temp file and open
1097
+ const tmpDir = `${process.env.HOME ?? "/tmp"}/.horizon/charts`;
1098
+ const fs = require("fs");
1099
+ fs.mkdirSync(tmpDir, { recursive: true });
1100
+ const filename = `chart-${Date.now()}.html`;
1101
+ const filepath = `${tmpDir}/${filename}`;
1102
+ fs.writeFileSync(filepath, html);
1103
+
1104
+ // Open in browser
1105
+ const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
1106
+ Bun.spawn([openCmd, filepath], { stdout: "ignore", stderr: "ignore" });
1107
+
1108
+ return {
1109
+ opened: true,
1110
+ path: filepath,
1111
+ title: chartTitle,
1112
+ dataPoints: prices.length,
1113
+ hint: "Chart opened in your browser. Data includes price line + 20-period SMA.",
1114
+ };
1115
+ },
1116
+ }),
205
1117
  };