horizon-code 0.5.1 → 0.6.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.
@@ -1,7 +1,7 @@
1
1
  import { saveConfig, loadConfig, getApiKey } from "./config.ts";
2
2
  import { platform } from "./client.ts";
3
3
 
4
- export type AuthStatus = "authenticated" | "no_key" | "invalid_key";
4
+ export type AuthStatus = "authenticated" | "no_key" | "invalid_key" | "network_error";
5
5
 
6
6
  export async function checkAuth(): Promise<AuthStatus> {
7
7
  const key = getApiKey();
@@ -13,8 +13,8 @@ export async function checkAuth(): Promise<AuthStatus> {
13
13
  return "authenticated";
14
14
  } catch (err: any) {
15
15
  if (err?.status === 401 || err?.status === 403) return "invalid_key";
16
- // Network error or server down assume key is fine
17
- return "authenticated";
16
+ // Network error can't verify, report as network error
17
+ return "network_error";
18
18
  }
19
19
  }
20
20
 
@@ -0,0 +1,202 @@
1
+ // Cumulative user profile — learns preferences over time
2
+ // Storage: ~/.horizon/profile.json
3
+
4
+ import { homedir } from "os";
5
+ import { resolve } from "path";
6
+ import { existsSync, readFileSync, writeFileSync } from "fs";
7
+
8
+ const PROFILE_PATH = resolve(homedir(), ".horizon", "profile.json");
9
+
10
+ export interface UserProfile {
11
+ // Trading preferences
12
+ preferred_markets: string[]; // most-used market slugs
13
+ preferred_exchanges: string[]; // polymarket, kalshi, etc.
14
+ risk_tolerance: "conservative" | "moderate" | "aggressive";
15
+ typical_spread: number; // average spread parameter used
16
+ typical_position_size: number; // average max_position used
17
+ always_paper_first: boolean; // always tests in paper before live
18
+
19
+ // Workflow patterns
20
+ strategies_created: number;
21
+ strategies_deployed: number;
22
+ backtests_run: number;
23
+ total_sessions: number;
24
+ preferred_strategy_types: string[]; // mm, momentum, arb, etc.
25
+ avg_pipeline_depth: number; // average number of pipeline functions
26
+
27
+ // Usage stats
28
+ first_seen: string;
29
+ last_seen: string;
30
+ total_interactions: number;
31
+ }
32
+
33
+ const DEFAULT_PROFILE: UserProfile = {
34
+ preferred_markets: [],
35
+ preferred_exchanges: [],
36
+ risk_tolerance: "moderate",
37
+ typical_spread: 0.04,
38
+ typical_position_size: 100,
39
+ always_paper_first: true,
40
+ strategies_created: 0,
41
+ strategies_deployed: 0,
42
+ backtests_run: 0,
43
+ total_sessions: 0,
44
+ preferred_strategy_types: [],
45
+ avg_pipeline_depth: 2,
46
+ first_seen: new Date().toISOString(),
47
+ last_seen: new Date().toISOString(),
48
+ total_interactions: 0,
49
+ };
50
+
51
+ /** Load profile from disk */
52
+ export function loadProfile(): UserProfile {
53
+ if (!existsSync(PROFILE_PATH)) return { ...DEFAULT_PROFILE };
54
+ try {
55
+ const data = JSON.parse(readFileSync(PROFILE_PATH, "utf-8"));
56
+ return { ...DEFAULT_PROFILE, ...data };
57
+ } catch {
58
+ return { ...DEFAULT_PROFILE };
59
+ }
60
+ }
61
+
62
+ /** Save profile to disk */
63
+ export function saveProfile(profile: UserProfile): void {
64
+ profile.last_seen = new Date().toISOString();
65
+ writeFileSync(PROFILE_PATH, JSON.stringify(profile, null, 2));
66
+ }
67
+
68
+ /** Update profile based on strategy code analysis */
69
+ export function learnFromStrategy(code: string): void {
70
+ const profile = loadProfile();
71
+
72
+ // Extract market slugs
73
+ const marketsMatch = code.match(/markets\s*=\s*\[([\s\S]*?)\]/);
74
+ if (marketsMatch) {
75
+ const slugs = [...marketsMatch[1]!.matchAll(/["']([^"']+)["']/g)].map(m => m[1]!);
76
+ for (const slug of slugs) {
77
+ if (!profile.preferred_markets.includes(slug)) {
78
+ profile.preferred_markets.push(slug);
79
+ if (profile.preferred_markets.length > 20) profile.preferred_markets.shift();
80
+ }
81
+ }
82
+ }
83
+
84
+ // Extract exchange
85
+ const exchangeMatch = code.match(/exchange\s*=\s*hz\.(\w+)/);
86
+ if (exchangeMatch) {
87
+ const exchange = exchangeMatch[1]!.toLowerCase();
88
+ if (!profile.preferred_exchanges.includes(exchange)) {
89
+ profile.preferred_exchanges.push(exchange);
90
+ }
91
+ }
92
+
93
+ // Extract spread
94
+ const spreadMatch = code.match(/spread\s*[=:]\s*([\d.]+)/);
95
+ if (spreadMatch) {
96
+ const spread = parseFloat(spreadMatch[1]!);
97
+ if (spread > 0 && spread < 1) {
98
+ profile.typical_spread = (profile.typical_spread + spread) / 2;
99
+ }
100
+ }
101
+
102
+ // Extract max_position
103
+ const posMatch = code.match(/max_position\s*=\s*(\d+)/);
104
+ if (posMatch) {
105
+ const pos = parseInt(posMatch[1]!);
106
+ profile.typical_position_size = Math.round((profile.typical_position_size + pos) / 2);
107
+ }
108
+
109
+ // Detect risk tolerance from risk config
110
+ const ddMatch = code.match(/max_drawdown_pct\s*=\s*([\d.]+)/);
111
+ if (ddMatch) {
112
+ const dd = parseFloat(ddMatch[1]!);
113
+ if (dd <= 5) profile.risk_tolerance = "conservative";
114
+ else if (dd <= 15) profile.risk_tolerance = "moderate";
115
+ else profile.risk_tolerance = "aggressive";
116
+ }
117
+
118
+ // Detect strategy type
119
+ const codeLC = code.toLowerCase();
120
+ const types: string[] = [];
121
+ if (/market.?mak|mm_|spread/.test(codeLC)) types.push("market_making");
122
+ if (/momentum|trend|signal/.test(codeLC)) types.push("momentum");
123
+ if (/arb|arbitrage/.test(codeLC)) types.push("arbitrage");
124
+ if (/mean.?rev|revert/.test(codeLC)) types.push("mean_reversion");
125
+ if (/scalp/.test(codeLC)) types.push("scalper");
126
+
127
+ for (const t of types) {
128
+ if (!profile.preferred_strategy_types.includes(t)) {
129
+ profile.preferred_strategy_types.push(t);
130
+ if (profile.preferred_strategy_types.length > 10) profile.preferred_strategy_types.shift();
131
+ }
132
+ }
133
+
134
+ // Pipeline depth
135
+ const pipelineMatch = code.match(/pipeline\s*=\s*\[([\s\S]*?)\]/);
136
+ if (pipelineMatch) {
137
+ const fns = pipelineMatch[1]!.split(",").filter(s => s.trim() && !s.trim().startsWith("#"));
138
+ if (fns.length > 0) {
139
+ profile.avg_pipeline_depth = Math.round((profile.avg_pipeline_depth + fns.length) / 2);
140
+ }
141
+ }
142
+
143
+ // Paper mode preference
144
+ if (/mode\s*=\s*["']paper["']/.test(code)) {
145
+ profile.always_paper_first = true;
146
+ }
147
+
148
+ profile.strategies_created++;
149
+ profile.total_interactions++;
150
+
151
+ saveProfile(profile);
152
+ }
153
+
154
+ /** Record a backtest run */
155
+ export function recordBacktest(): void {
156
+ const profile = loadProfile();
157
+ profile.backtests_run++;
158
+ profile.total_interactions++;
159
+ saveProfile(profile);
160
+ }
161
+
162
+ /** Record a deployment */
163
+ export function recordDeploy(): void {
164
+ const profile = loadProfile();
165
+ profile.strategies_deployed++;
166
+ profile.total_interactions++;
167
+ saveProfile(profile);
168
+ }
169
+
170
+ /** Record a session start */
171
+ export function recordSession(): void {
172
+ const profile = loadProfile();
173
+ profile.total_sessions++;
174
+ saveProfile(profile);
175
+ }
176
+
177
+ /** Build a context string for the LLM system prompt */
178
+ export function buildProfileContext(): string {
179
+ const p = loadProfile();
180
+ if (p.total_interactions === 0) return "";
181
+
182
+ const parts: string[] = [];
183
+ parts.push("## User Profile (learned from usage)");
184
+
185
+ if (p.preferred_markets.length > 0) {
186
+ parts.push(`- Frequently traded markets: ${p.preferred_markets.slice(-5).join(", ")}`);
187
+ }
188
+ if (p.preferred_exchanges.length > 0) {
189
+ parts.push(`- Preferred exchanges: ${p.preferred_exchanges.join(", ")}`);
190
+ }
191
+ if (p.preferred_strategy_types.length > 0) {
192
+ parts.push(`- Strategy style: ${p.preferred_strategy_types.join(", ")}`);
193
+ }
194
+ parts.push(`- Risk tolerance: ${p.risk_tolerance} (typical spread: ${p.typical_spread.toFixed(3)}, typical max_position: ${p.typical_position_size})`);
195
+ parts.push(`- Experience: ${p.strategies_created} strategies created, ${p.backtests_run} backtests, ${p.strategies_deployed} deployments`);
196
+
197
+ if (p.always_paper_first) {
198
+ parts.push("- Always uses paper mode first (respect this preference)");
199
+ }
200
+
201
+ return parts.join("\n");
202
+ }
@@ -84,7 +84,7 @@ export async function loadSessions(): Promise<void> {
84
84
  openTabIds: openIds,
85
85
  });
86
86
  }
87
- } catch {}
87
+ } catch (e: any) { console.error("[session-sync] loadSessions failed:", e?.message ?? e); }
88
88
  }
89
89
 
90
90
  /**
@@ -132,7 +132,7 @@ export async function saveActiveSession(): Promise<void> {
132
132
 
133
133
  // Update session metadata
134
134
  await updateDbSession(dbId, { name: session.name }).catch(() => {});
135
- } catch {}
135
+ } catch (e: any) { console.error("[session-sync] saveActiveSession failed:", e?.message ?? e); }
136
136
  }
137
137
 
138
138
  /**
@@ -140,7 +140,7 @@ export async function saveActiveSession(): Promise<void> {
140
140
  */
141
141
  export async function deleteSessionFromDb(sessionId: string): Promise<void> {
142
142
  if (!(await isLoggedIn())) return;
143
- try { await deleteDbSession(sessionId); } catch {}
143
+ try { await deleteDbSession(sessionId); } catch (e: any) { console.error("[session-sync] deleteSessionFromDb failed:", e?.message ?? e); }
144
144
  }
145
145
 
146
146
  /**
@@ -34,7 +34,8 @@ export function getSupabase(): SupabaseClient {
34
34
  refresh_token: encryptEnvVar(session.refresh_token),
35
35
  };
36
36
  config.session_encrypted = true;
37
- } catch {
37
+ } catch (e) {
38
+ console.error("[supabase] encryption failed, using plaintext:", e);
38
39
  // Fallback to plaintext if encryption fails
39
40
  config.supabase_session = {
40
41
  access_token: session.access_token,
@@ -67,7 +68,7 @@ function saveSessionTokens(config: ReturnType<typeof loadConfig>, accessToken: s
67
68
  config.supabase_session = { access_token: encryptEnvVar(accessToken), refresh_token: encryptEnvVar(refreshToken) };
68
69
  config.session_encrypted = true;
69
70
  return;
70
- } catch {}
71
+ } catch (e) { console.error("[supabase] saveSessionTokens encryption failed:", e); }
71
72
  }
72
73
  config.supabase_session = { access_token: accessToken, refresh_token: refreshToken };
73
74
  config.session_encrypted = false;
@@ -125,7 +126,7 @@ export async function restoreSession(): Promise<boolean> {
125
126
  if (config.api_key) platform.setApiKey(config.api_key);
126
127
  return true;
127
128
  }
128
- } catch {}
129
+ } catch (e) { console.error("[supabase] setSession failed:", e); }
129
130
 
130
131
  // Access token expired — try refreshing with just the refresh token
131
132
  try {
@@ -142,7 +143,7 @@ export async function restoreSession(): Promise<boolean> {
142
143
  if (config.api_key) platform.setApiKey(config.api_key);
143
144
  return true;
144
145
  }
145
- } catch {}
146
+ } catch (e) { console.error("[supabase] refreshSession failed:", e); }
146
147
 
147
148
  // Both failed — don't delete the session, it might work next time
148
149
  // (network issue, Supabase outage, etc.). The API key still works for chat.
@@ -224,7 +225,7 @@ export async function loginWithBrowser(): Promise<{ success: boolean; error?: st
224
225
  try {
225
226
  const apiKey = await createApiKey();
226
227
  if (apiKey) { config.api_key = apiKey; platform.setApiKey(apiKey); }
227
- } catch {}
228
+ } catch (e) { console.error("[supabase] createApiKey failed:", e); }
228
229
  } else {
229
230
  platform.setApiKey(config.api_key);
230
231
  }
@@ -235,10 +236,10 @@ export async function loginWithBrowser(): Promise<{ success: boolean; error?: st
235
236
  }
236
237
  if (res.status === 410) return { success: false, error: "Session expired. Type /login to sign in again." };
237
238
  if (res.status === 404) return { success: false, error: "Auth session not found." };
238
- } catch {}
239
+ } catch (e) { console.error("[supabase] poll timeout:", e); }
239
240
  }
240
241
 
241
- try { await sb.from("cli_auth_sessions").delete().eq("id", sessionId); } catch {}
242
+ try { await sb.from("cli_auth_sessions").delete().eq("id", sessionId); } catch (e) { console.error("[supabase] cleanup auth session failed:", e); }
242
243
  return { success: false, error: "Login timed out (90s). Try /login again." };
243
244
  }
244
245
 
@@ -260,7 +261,7 @@ export async function loginWithPassword(email: string, password: string): Promis
260
261
  try {
261
262
  const apiKey = await createApiKey();
262
263
  if (apiKey) { config.api_key = apiKey; platform.setApiKey(apiKey); }
263
- } catch {}
264
+ } catch (e) { console.error("[supabase] createApiKey failed:", e); }
264
265
  } else {
265
266
  platform.setApiKey(config.api_key);
266
267
  }
@@ -295,7 +296,7 @@ async function createApiKey(): Promise<string | null> {
295
296
  });
296
297
  if (error) return null;
297
298
  return rawKey;
298
- } catch { return null; }
299
+ } catch (e) { console.error("[supabase] createApiKey failed:", e); return null; }
299
300
  }
300
301
 
301
302
  // ── Logout ──
@@ -8,8 +8,11 @@ const pnlHistories: Map<string, number[]> = new Map();
8
8
 
9
9
  export class PlatformSync {
10
10
  private timer: ReturnType<typeof setInterval> | null = null;
11
+ private _polling = false;
11
12
 
12
13
  async start(intervalMs = 30000): Promise<void> {
14
+ if (this.timer) return; // Prevent duplicate timers
15
+
13
16
  const loggedIn = await isLoggedIn();
14
17
  if (!loggedIn && !platform.authenticated) return;
15
18
 
@@ -22,6 +25,8 @@ export class PlatformSync {
22
25
  }
23
26
 
24
27
  private async poll(): Promise<void> {
28
+ if (this._polling) return;
29
+ this._polling = true;
25
30
  try {
26
31
  const strategies = await platform.listStrategies();
27
32
 
@@ -88,8 +93,11 @@ export class PlatformSync {
88
93
  });
89
94
 
90
95
  store.update({ deployments, connection: "connected" });
91
- } catch {
96
+ } catch (e) {
97
+ console.error("[sync] poll failed:", e);
92
98
  store.update({ connection: "disconnected" });
99
+ } finally {
100
+ this._polling = false;
93
101
  }
94
102
  }
95
103
  }
@@ -111,7 +111,7 @@ export async function gammaEvents(opts: { query?: string; limit?: number } = {})
111
111
  }
112
112
 
113
113
  export async function gammaEventDetail(slug: string): Promise<any> {
114
- const events = await get(`${GAMMA}/events?slug=${slug}`);
114
+ const events = await get(`${GAMMA}/events?slug=${encodeURIComponent(slug)}`);
115
115
  const event = events?.[0];
116
116
  if (!event) throw new Error(`Event not found: ${slug}`);
117
117
 
@@ -149,7 +149,7 @@ export async function gammaEventDetail(slug: string): Promise<any> {
149
149
  }
150
150
 
151
151
  export async function clobPriceHistory(slug: string, interval = "1w", fidelity = 60): Promise<any> {
152
- const events = await get(`${GAMMA}/events?slug=${slug}`);
152
+ const events = await get(`${GAMMA}/events?slug=${encodeURIComponent(slug)}`);
153
153
  const market = events?.[0]?.markets?.[0];
154
154
  if (!market) throw new Error(`Market not found: ${slug}`);
155
155
 
@@ -174,7 +174,7 @@ export async function clobPriceHistory(slug: string, interval = "1w", fidelity =
174
174
  }
175
175
 
176
176
  export async function clobOrderBook(slug: string, marketIndex = 0, outcomeIndex = 0): Promise<any> {
177
- const events = await get(`${GAMMA}/events?slug=${slug}`);
177
+ const events = await get(`${GAMMA}/events?slug=${encodeURIComponent(slug)}`);
178
178
  const market = events?.[0]?.markets?.[marketIndex];
179
179
  if (!market) throw new Error(`Market not found: ${slug}`);
180
180
 
@@ -198,7 +198,7 @@ export async function clobOrderBook(slug: string, marketIndex = 0, outcomeIndex
198
198
  }
199
199
 
200
200
  export async function polymarketTrades(slug: string): Promise<any> {
201
- const events = await get(`${GAMMA}/events?slug=${slug}`);
201
+ const events = await get(`${GAMMA}/events?slug=${encodeURIComponent(slug)}`);
202
202
  const market = events?.[0]?.markets?.[0];
203
203
  if (!market) throw new Error(`Market not found: ${slug}`);
204
204
 
@@ -233,27 +233,27 @@ export async function polymarketTrades(slug: string): Promise<any> {
233
233
 
234
234
  /** Get recent trades for a market (up to 500) */
235
235
  export async function getMarketTrades(conditionId: string, limit = 200): Promise<any[]> {
236
- const data = await get(`${DATA_API}/trades?market=${conditionId}&limit=${Math.min(limit, 500)}`).catch(() => []);
236
+ const data = await get(`${DATA_API}/trades?market=${encodeURIComponent(conditionId)}&limit=${Math.min(limit, 500)}`).catch(() => []);
237
237
  return data ?? [];
238
238
  }
239
239
 
240
240
  /** Get trades for a specific wallet (up to 500) */
241
241
  export async function getWalletTrades(address: string, limit = 200, conditionId?: string): Promise<any[]> {
242
- let url = `${DATA_API}/trades?maker_address=${address}&limit=${Math.min(limit, 500)}`;
243
- if (conditionId) url += `&market=${conditionId}`;
242
+ let url = `${DATA_API}/trades?maker_address=${encodeURIComponent(address)}&limit=${Math.min(limit, 500)}`;
243
+ if (conditionId) url += `&market=${encodeURIComponent(conditionId)}`;
244
244
  const data = await get(url).catch(() => []);
245
245
  return data ?? [];
246
246
  }
247
247
 
248
248
  /** Get open positions for a wallet */
249
249
  export async function getWalletPositions(address: string, limit = 100): Promise<any[]> {
250
- const data = await get(`${DATA_API}/positions?user=${address}&limit=${Math.min(limit, 500)}&sort_by=TOKENS`).catch(() => []);
250
+ const data = await get(`${DATA_API}/positions?user=${encodeURIComponent(address)}&limit=${Math.min(limit, 500)}&sort_by=TOKENS`).catch(() => []);
251
251
  return data ?? [];
252
252
  }
253
253
 
254
254
  /** Get wallet profile from Gamma API */
255
255
  export async function getWalletProfile(address: string): Promise<any> {
256
- const data = await get(`${GAMMA}/users?address=${address}`).catch(() => null);
256
+ const data = await get(`${GAMMA}/users?address=${encodeURIComponent(address)}`).catch(() => null);
257
257
  const user = Array.isArray(data) ? data[0] : data;
258
258
  return user ? {
259
259
  address, name: user.pseudonym ?? user.name ?? "Anonymous",
@@ -264,7 +264,7 @@ export async function getWalletProfile(address: string): Promise<any> {
264
264
 
265
265
  /** Resolve market slug → conditionId */
266
266
  export async function resolveConditionId(slug: string): Promise<{ conditionId: string; title: string; market: any }> {
267
- const events = await get(`${GAMMA}/events?slug=${slug}`);
267
+ const events = await get(`${GAMMA}/events?slug=${encodeURIComponent(slug)}`);
268
268
  const market = events?.[0]?.markets?.[0];
269
269
  if (!market) throw new Error(`Market not found: ${slug}`);
270
270
  return { conditionId: market.conditionId, title: events[0].title, market };
@@ -443,7 +443,7 @@ function formatKalshiEvent(e: any): any {
443
443
  }
444
444
 
445
445
  export async function kalshiEventDetail(ticker: string): Promise<any> {
446
- const data = await get(`${KALSHI}/events/${ticker}?with_nested_markets=true`);
446
+ const data = await get(`${KALSHI}/events/${encodeURIComponent(ticker)}?with_nested_markets=true`);
447
447
  const event = data?.event ?? data;
448
448
  if (!event) throw new Error(`Kalshi event not found: ${ticker}`);
449
449
 
@@ -457,7 +457,7 @@ export async function kalshiEventDetail(ticker: string): Promise<any> {
457
457
  }
458
458
 
459
459
  export async function kalshiOrderBook(ticker: string): Promise<any> {
460
- const book = await get(`${KALSHI}/markets/${ticker}/orderbook`);
460
+ const book = await get(`${KALSHI}/markets/${encodeURIComponent(ticker)}/orderbook`);
461
461
  const ob = book?.orderbook ?? {};
462
462
  // yes array: [[price_cents, size], ...] — bids for YES outcome
463
463
  // no array: [[price_cents, size], ...] — asks (complement pricing)
@@ -489,7 +489,7 @@ export async function kalshiPriceHistory(ticker: string, period = "1w"): Promise
489
489
  const intervalMap: Record<string, number> = { "1h": 1, "6h": 1, "1d": 60, "1w": 60, "1m": 1440, "max": 1440 };
490
490
  const periodInterval = intervalMap[period] ?? 60;
491
491
 
492
- const data = await get(`${KALSHI}/markets/${ticker}/candlesticks?period_interval=${periodInterval}`).catch(() => ({ candlesticks: [] }));
492
+ const data = await get(`${KALSHI}/markets/${encodeURIComponent(ticker)}/candlesticks?period_interval=${periodInterval}`).catch(() => ({ candlesticks: [] }));
493
493
  const candles = data?.candlesticks ?? [];
494
494
 
495
495
  // Prices can be dollar strings or cent integers — normalize to decimal
@@ -0,0 +1,212 @@
1
+ // Market scanner + correlation analysis
2
+ // Scans for trading opportunities across Polymarket markets
3
+
4
+ import { gammaEvents } from "./apis.ts";
5
+
6
+ export interface MarketOpportunity {
7
+ slug: string;
8
+ title: string;
9
+ type: "wide_spread" | "high_volume" | "mispricing" | "volatile";
10
+ score: number; // 0-100
11
+ spread?: number;
12
+ volume?: number;
13
+ price?: number;
14
+ detail: string;
15
+ }
16
+
17
+ export interface CorrelationResult {
18
+ marketA: string;
19
+ marketB: string;
20
+ correlation: number; // -1 to 1
21
+ direction: string; // "positive", "negative", "neutral"
22
+ strength: string; // "strong", "moderate", "weak"
23
+ }
24
+
25
+ export interface CorrelationMatrix {
26
+ markets: string[];
27
+ matrix: number[][];
28
+ pairs: CorrelationResult[];
29
+ high_correlation_warning: string | null;
30
+ }
31
+
32
+ /**
33
+ * Scan top Polymarket markets for trading opportunities.
34
+ * Returns ranked list of opportunities for market making, edge trading, or arbitrage.
35
+ */
36
+ export async function scanOpportunities(limit: number = 30): Promise<{
37
+ opportunities: MarketOpportunity[];
38
+ total_scanned: number;
39
+ summary: string;
40
+ }> {
41
+ // Fetch active events
42
+ const events = await gammaEvents({ query: "", limit: limit * 2 });
43
+ if (!events || events.length === 0) {
44
+ return { opportunities: [], total_scanned: 0, summary: "No markets available." };
45
+ }
46
+
47
+ const opportunities: MarketOpportunity[] = [];
48
+
49
+ for (const event of events) {
50
+ const markets = event.markets ?? [event];
51
+
52
+ for (const market of markets) {
53
+ const slug = market.slug ?? market.conditionId ?? "";
54
+ const title = market.question ?? market.title ?? slug;
55
+ const spread = market.spread ?? (market.bestAsk && market.bestBid ? market.bestAsk - market.bestBid : null);
56
+ const volume = market.volume ?? market.volumeNum ?? 0;
57
+ const price = market.lastTradePrice ?? market.outcomePrices?.[0] ?? 0.5;
58
+ const priceNum = typeof price === "string" ? parseFloat(price) : price;
59
+
60
+ // Wide spread opportunity (market making)
61
+ if (spread && spread > 0.04) {
62
+ const score = Math.min(100, Math.round(spread * 500));
63
+ opportunities.push({
64
+ slug, title, type: "wide_spread", score, spread, volume: volume,
65
+ detail: `Spread ${(spread * 100).toFixed(1)}c — room for market making`,
66
+ });
67
+ }
68
+
69
+ // High volume but price near 50% (uncertain market = good for MM)
70
+ if (volume > 50000 && priceNum > 0.3 && priceNum < 0.7) {
71
+ const uncertainty = 1 - Math.abs(priceNum - 0.5) * 2;
72
+ const score = Math.min(100, Math.round(uncertainty * 60 + (volume / 100000) * 40));
73
+ opportunities.push({
74
+ slug, title, type: "high_volume", score, volume, price: priceNum,
75
+ detail: `$${(volume / 1000).toFixed(0)}K vol, price ${(priceNum * 100).toFixed(0)}c — high uncertainty`,
76
+ });
77
+ }
78
+
79
+ // Extreme prices that might indicate mispricing
80
+ if (priceNum > 0.02 && priceNum < 0.08) {
81
+ opportunities.push({
82
+ slug, title, type: "mispricing", score: 65, price: priceNum,
83
+ detail: `Low price ${(priceNum * 100).toFixed(1)}c — potential long tail value`,
84
+ });
85
+ }
86
+ if (priceNum > 0.92 && priceNum < 0.98) {
87
+ opportunities.push({
88
+ slug, title, type: "mispricing", score: 55, price: priceNum,
89
+ detail: `High price ${(priceNum * 100).toFixed(1)}c — potential short opportunity`,
90
+ });
91
+ }
92
+ }
93
+ }
94
+
95
+ // Sort by score descending and deduplicate by slug
96
+ const seen = new Set<string>();
97
+ const unique = opportunities
98
+ .sort((a, b) => b.score - a.score)
99
+ .filter(o => {
100
+ if (seen.has(o.slug)) return false;
101
+ seen.add(o.slug);
102
+ return true;
103
+ })
104
+ .slice(0, limit);
105
+
106
+ const byType = {
107
+ wide_spread: unique.filter(o => o.type === "wide_spread").length,
108
+ high_volume: unique.filter(o => o.type === "high_volume").length,
109
+ mispricing: unique.filter(o => o.type === "mispricing").length,
110
+ };
111
+
112
+ const summary = `Scanned ${events.length} markets. Found ${unique.length} opportunities: ` +
113
+ `${byType.wide_spread} wide spreads, ${byType.high_volume} high volume, ${byType.mispricing} mispricings.`;
114
+
115
+ return { opportunities: unique, total_scanned: events.length, summary };
116
+ }
117
+
118
+ /**
119
+ * Compute pairwise correlations between a set of markets.
120
+ * Uses simple price-based correlation from available data.
121
+ */
122
+ export function computeCorrelations(
123
+ marketPrices: { slug: string; prices: number[] }[],
124
+ ): CorrelationMatrix {
125
+ const n = marketPrices.length;
126
+ const matrix: number[][] = Array.from({ length: n }, () => Array(n).fill(0));
127
+ const pairs: CorrelationResult[] = [];
128
+
129
+ for (let i = 0; i < n; i++) {
130
+ matrix[i]![i] = 1.0;
131
+ for (let j = i + 1; j < n; j++) {
132
+ const corr = pearsonCorrelation(marketPrices[i]!.prices, marketPrices[j]!.prices);
133
+ matrix[i]![j] = corr;
134
+ matrix[j]![i] = corr;
135
+
136
+ const absCorr = Math.abs(corr);
137
+ pairs.push({
138
+ marketA: marketPrices[i]!.slug,
139
+ marketB: marketPrices[j]!.slug,
140
+ correlation: Math.round(corr * 100) / 100,
141
+ direction: corr > 0.1 ? "positive" : corr < -0.1 ? "negative" : "neutral",
142
+ strength: absCorr > 0.7 ? "strong" : absCorr > 0.4 ? "moderate" : "weak",
143
+ });
144
+ }
145
+ }
146
+
147
+ // Find high correlations that indicate concentration risk
148
+ const highCorr = pairs.filter(p => Math.abs(p.correlation) > 0.7);
149
+ const warning = highCorr.length > 0
150
+ ? `WARNING: ${highCorr.length} pair(s) with >0.7 correlation — portfolio is concentrated: ${highCorr.map(p => `${p.marketA}/${p.marketB} (${p.correlation})`).join(", ")}`
151
+ : null;
152
+
153
+ return {
154
+ markets: marketPrices.map(m => m.slug),
155
+ matrix,
156
+ pairs: pairs.sort((a, b) => Math.abs(b.correlation) - Math.abs(a.correlation)),
157
+ high_correlation_warning: warning,
158
+ };
159
+ }
160
+
161
+ /** Pearson correlation between two arrays */
162
+ function pearsonCorrelation(x: number[], y: number[]): number {
163
+ const n = Math.min(x.length, y.length);
164
+ if (n < 3) return 0;
165
+
166
+ let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0, sumY2 = 0;
167
+ for (let i = 0; i < n; i++) {
168
+ sumX += x[i]!;
169
+ sumY += y[i]!;
170
+ sumXY += x[i]! * y[i]!;
171
+ sumX2 += x[i]! * x[i]!;
172
+ sumY2 += y[i]! * y[i]!;
173
+ }
174
+
175
+ const numerator = n * sumXY - sumX * sumY;
176
+ const denominator = Math.sqrt((n * sumX2 - sumX * sumX) * (n * sumY2 - sumY * sumY));
177
+
178
+ if (denominator === 0) return 0;
179
+ return numerator / denominator;
180
+ }
181
+
182
+ /** Build ASCII correlation matrix for terminal display */
183
+ export function formatCorrelationMatrix(result: CorrelationMatrix): string {
184
+ const { markets, matrix } = result;
185
+ const n = markets.length;
186
+ const maxSlugLen = Math.min(12, Math.max(...markets.map(m => m.length)));
187
+
188
+ const lines: string[] = [];
189
+
190
+ // Header
191
+ const header = " ".repeat(maxSlugLen + 2) + markets.map(m => m.slice(0, 6).padStart(7)).join("");
192
+ lines.push(header);
193
+ lines.push("-".repeat(header.length));
194
+
195
+ // Rows
196
+ for (let i = 0; i < n; i++) {
197
+ const slug = markets[i]!.slice(0, maxSlugLen).padEnd(maxSlugLen);
198
+ const vals = matrix[i]!.map((v, j) => {
199
+ if (i === j) return " 1.00";
200
+ const str = v.toFixed(2);
201
+ return (v >= 0 ? " " : "") + str;
202
+ }).map(s => s.padStart(7)).join("");
203
+ lines.push(`${slug} ${vals}`);
204
+ }
205
+
206
+ if (result.high_correlation_warning) {
207
+ lines.push("");
208
+ lines.push(result.high_correlation_warning);
209
+ }
210
+
211
+ return lines.join("\n");
212
+ }