horizon-code 0.2.0 → 0.3.0
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.
- package/package.json +1 -1
- package/src/ai/client.ts +33 -8
- package/src/ai/system-prompt.ts +48 -6
- package/src/app.ts +82 -3
- package/src/components/code-panel.ts +82 -3
- package/src/components/footer.ts +3 -0
- package/src/components/settings-panel.ts +14 -0
- package/src/platform/exchanges.ts +154 -0
- package/src/research/apis.ts +208 -11
- package/src/research/stock-apis.ts +117 -0
- package/src/research/tools.ts +929 -17
- package/src/research/widgets.ts +1042 -29
package/src/research/tools.ts
CHANGED
|
@@ -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,
|
|
7
|
-
|
|
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: "
|
|
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 <
|
|
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
|
-
//
|
|
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
|
|
149
|
-
|
|
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
|
-
//
|
|
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 +
|
|
154
|
-
const recentVol = Math.sqrt(recentVar);
|
|
155
|
-
const regime = recentVol >
|
|
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
|
-
|
|
160
|
-
|
|
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
|
|
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
|
};
|