horizon-code 0.5.1 → 0.6.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "horizon-code",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "AI-powered trading strategy terminal for Polymarket",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,5 +1,6 @@
1
1
  import type { Mode } from "../components/mode-bar.ts";
2
2
  import { buildGeneratePrompt } from "../strategy/prompts.ts";
3
+ import { store } from "../state/store.ts";
3
4
 
4
5
  const BASE = `You are Horizon, an AI trading research assistant running in a CLI terminal.
5
6
 
@@ -123,7 +124,25 @@ const VERBOSITY_PREFIX: Record<string, string> = {
123
124
 
124
125
  export function getSystemPrompt(mode: Mode, verbosity: string = "normal"): string {
125
126
  // Strategy mode has its own verbosity rules — don't override
126
- if (mode === "strategy") return buildGeneratePrompt();
127
+ if (mode === "strategy") {
128
+ let prompt = buildGeneratePrompt();
129
+ // Inject user profile context if available
130
+ try {
131
+ const { buildProfileContext } = require("../platform/profile.ts");
132
+ const profileCtx = buildProfileContext();
133
+ if (profileCtx) prompt += "\n\n" + profileCtx;
134
+ } catch {}
135
+ // Inject active strategy context so research tools auto-scope
136
+ const draft = store.getActiveSession()?.strategyDraft;
137
+ if (draft?.code) {
138
+ const marketsMatch = draft.code.match(/markets\s*=\s*\[([\s\S]*?)\]/);
139
+ const slugs = marketsMatch ? [...marketsMatch[1]!.matchAll(/["']([^"']+)["']/g)].map(m => m[1]!) : [];
140
+ if (slugs.length > 0) {
141
+ prompt += `\n\n## Active Strategy Context\nThe user is working on "${draft.name}" trading these markets: ${slugs.join(", ")}. When they ask about "the market" or "what's happening", scope to these markets first. Use these slugs for polymarket_data, scan_correlations, etc.`;
142
+ }
143
+ }
144
+ return prompt;
145
+ }
127
146
  const prefix = VERBOSITY_PREFIX[verbosity] ?? "";
128
147
  return MODE_PROMPTS[mode] + prefix;
129
148
  }
package/src/app.ts CHANGED
@@ -76,6 +76,17 @@ const SLASH_COMMANDS: Record<string, { description: string; usage: string }> = {
76
76
  "/exchanges": { description: "Show exchange connection status", usage: "/exchanges" },
77
77
  "/quit": { description: "Exit Horizon", usage: "/quit" },
78
78
  "/exit": { description: "Exit Horizon", usage: "/exit" },
79
+ "/bt": { description: "Backtest current strategy", usage: "/bt" },
80
+ "/backtest": { description: "Backtest current strategy", usage: "/backtest" },
81
+ "/run": { description: "Run current strategy", usage: "/run" },
82
+ "/dash": { description: "Open strategy dashboard", usage: "/dash" },
83
+ "/dashboard": { description: "Open strategy dashboard", usage: "/dashboard" },
84
+ "/save": { description: "Save current strategy", usage: "/save [name]" },
85
+ "/health": { description: "Check strategy health score", usage: "/health" },
86
+ "/versions": { description: "List strategy versions", usage: "/versions [name]" },
87
+ "/export": { description: "Export strategy to file", usage: "/export" },
88
+ "/report": { description: "Generate strategy ledger report", usage: "/report [name]" },
89
+ "/session-export": { description: "Export chat session to markdown", usage: "/session-export" },
79
90
  };
80
91
 
81
92
  export class App {
@@ -304,6 +315,18 @@ export class App {
304
315
  this.keyHandler.onClear(() => {});
305
316
  this.keyHandler.onSessions(() => this.togglePanel("sessions"));
306
317
  this.keyHandler.onDeployments(() => this.togglePanel("deployments"));
318
+ this.keyHandler.onOpenDashboard(() => {
319
+ if (dashboard.running) {
320
+ Bun.spawn(["open", dashboard.url]);
321
+ } else {
322
+ // Auto-start built-in dashboard if processes running
323
+ const hasLocal = [...runningProcesses.values()].some(m => m.proc.exitCode === null);
324
+ if (hasLocal) {
325
+ const url = dashboard.start("local", 0, true);
326
+ Bun.spawn(["open", url]);
327
+ }
328
+ }
329
+ });
307
330
  this.keyHandler.onTabNext(() => { store.nextTab(); this.switchToActiveTab(); });
308
331
  this.keyHandler.onTabPrev(() => { store.prevTab(); this.switchToActiveTab(); });
309
332
  this.keyHandler.onTabClose(() => { store.closeTab(store.get().activeSessionId); this.switchToActiveTab(); });
@@ -527,6 +550,49 @@ export class App {
527
550
  startedAt: localProcessStartedAt,
528
551
  });
529
552
  this._hasLocalMetrics = true;
553
+
554
+ // Check alerts against current local metrics (fire-and-forget — setInterval is not async)
555
+ ((metrics) => {
556
+ (async () => {
557
+ try {
558
+ const { checkAlerts } = await import("./strategy/alerts.ts");
559
+ const draft = store.getActiveSession()?.strategyDraft;
560
+ const alertResults = await checkAlerts(
561
+ draft?.name ?? "local",
562
+ {
563
+ pnl: metrics.pnl,
564
+ max_dd: metrics.max_dd ?? 0,
565
+ exposure: metrics.exposure ?? 0,
566
+ win_rate: metrics.win_rate ?? 0,
567
+ trades: metrics.trades ?? 0,
568
+ },
569
+ () => {
570
+ // Stop all running processes
571
+ for (const [pid, m] of runningProcesses) {
572
+ if (m.proc.exitCode === null) m.cleanup?.();
573
+ }
574
+ },
575
+ );
576
+ for (const r of alertResults) {
577
+ if (r.triggered && r.actionTaken) {
578
+ this.codePanel.appendLog(r.actionTaken);
579
+ }
580
+ }
581
+ } catch {}
582
+ })();
583
+ })(localMetricsData);
584
+
585
+ // Record replay metrics (fire-and-forget)
586
+ ((metrics) => {
587
+ (async () => {
588
+ try {
589
+ const { recordMetrics, isRecording } = await import("./strategy/replay.ts");
590
+ for (const [pid] of runningProcesses) {
591
+ if (isRecording(pid)) recordMetrics(pid, metrics as any);
592
+ }
593
+ } catch {}
594
+ })();
595
+ })(localMetricsData);
530
596
  } else if (this._hasLocalMetrics && alive === 0) {
531
597
  // Local process stopped — clear local metrics, let platform data take over
532
598
  this._hasLocalMetrics = false;
@@ -845,6 +911,81 @@ export class App {
845
911
  case "/exit":
846
912
  this.quit();
847
913
  break;
914
+ case "/bt":
915
+ case "/backtest": {
916
+ const tool = (await import("./strategy/tools.ts")).strategyTools.backtest_strategy;
917
+ const result = await tool.execute({});
918
+ this.codePanel.appendLog(typeof result === "object" ? JSON.stringify(result, null, 2) : String(result));
919
+ this.codePanel.setTab("logs");
920
+ break;
921
+ }
922
+ case "/run": {
923
+ const tool = (await import("./strategy/tools.ts")).strategyTools.run_strategy;
924
+ const result = await tool.execute({});
925
+ this.codePanel.appendLog(typeof result === "object" ? JSON.stringify(result, null, 2) : String(result));
926
+ this.codePanel.setTab("logs");
927
+ break;
928
+ }
929
+ case "/dash":
930
+ case "/dashboard": {
931
+ const tool = (await import("./strategy/tools.ts")).strategyTools.spawn_dashboard;
932
+ const result = await tool.execute({ strategy_id: "local" });
933
+ if (result?.url) Bun.spawn(["open", result.url]);
934
+ break;
935
+ }
936
+ case "/save": {
937
+ const tool = (await import("./strategy/tools.ts")).strategyTools.save_strategy;
938
+ const draft = store.getActiveSession()?.strategyDraft;
939
+ const result = await tool.execute({ name: draft?.name ?? arg ?? "untitled" });
940
+ this.codePanel.appendLog(`Saved: ${JSON.stringify(result)}`);
941
+ break;
942
+ }
943
+ case "/health": {
944
+ const { computeHealthScore } = await import("./strategy/health.ts");
945
+ const draft = store.getActiveSession()?.strategyDraft;
946
+ if (!draft?.code) { this.codePanel.appendLog("No strategy loaded"); break; }
947
+ const report = computeHealthScore(draft.code);
948
+ this.codePanel.appendLog(`Health: ${report.score}/${report.maxScore} (${report.grade}) — ${report.summary}`);
949
+ break;
950
+ }
951
+ case "/versions": {
952
+ const { listVersions } = await import("./strategy/versioning.ts");
953
+ const draft = store.getActiveSession()?.strategyDraft;
954
+ const versions = await listVersions(draft?.name ?? arg ?? "");
955
+ this.codePanel.appendLog(versions.length === 0 ? "No versions found" : versions.map(v => `v${v.version} [${v.hash}] ${v.label} — ${v.timestamp}`).join("\n"));
956
+ break;
957
+ }
958
+ case "/export": {
959
+ const { exportStrategy } = await import("./strategy/export.ts");
960
+ const draft = store.getActiveSession()?.strategyDraft;
961
+ if (!draft?.code) { this.codePanel.appendLog("No strategy loaded"); break; }
962
+ const result = await exportStrategy(draft.name, draft.code, draft.params ?? {}, null);
963
+ this.codePanel.appendLog(`Exported to ${result.path} (${result.size} bytes)`);
964
+ break;
965
+ }
966
+ case "/report": {
967
+ const { generateReport } = await import("./strategy/ledger.ts");
968
+ const draft = store.getActiveSession()?.strategyDraft;
969
+ const report = generateReport(draft?.name ?? arg ?? "");
970
+ this.codePanel.appendLog(report.summary);
971
+ break;
972
+ }
973
+ case "/session-export": {
974
+ const session = store.getActiveSession();
975
+ if (!session) break;
976
+ const { writeWorkspaceFile } = await import("./strategy/workspace.ts");
977
+ const lines: string[] = [];
978
+ lines.push(`# ${session.id} — ${new Date().toISOString()}\n`);
979
+ for (const msg of session.messages) {
980
+ const role = msg.role === "user" ? "**User**" : "**Horizon**";
981
+ const text = msg.content.map((b: any) => b.text ?? b.markdown ?? "").join("\n");
982
+ lines.push(`### ${role}\n${text}\n`);
983
+ }
984
+ const fileName = `session_${Date.now()}.md`;
985
+ await writeWorkspaceFile(`exports/${fileName}`, lines.join("\n"));
986
+ this.codePanel.appendLog(`Session exported to exports/${fileName}`);
987
+ break;
988
+ }
848
989
  default: {
849
990
  // Unknown command — show as system message
850
991
  const msg: Message = {
@@ -1158,6 +1299,23 @@ export class App {
1158
1299
  contextParts.push(`Active strategy: ${draft.name} (${draft.phase}, ${draft.validationStatus})`);
1159
1300
  }
1160
1301
 
1302
+ // Preserve strategy context through compaction
1303
+ if (draft?.code) {
1304
+ contextParts.push(`\nActive strategy code hash: ${draft.code.length} chars`);
1305
+ // Preserve active market slugs
1306
+ const marketsMatch = draft.code.match(/markets\s*=\s*\[([\s\S]*?)\]/);
1307
+ if (marketsMatch) {
1308
+ const slugs2 = [...marketsMatch[1]!.matchAll(/["']([^"']+)["']/g)].map(m => m[1]!);
1309
+ contextParts.push(`Active markets: ${slugs2.join(", ")}`);
1310
+ }
1311
+ }
1312
+
1313
+ // Preserve running process info
1314
+ const pids = [...runningProcesses.keys()].filter(pid => runningProcesses.get(pid)?.proc.exitCode === null);
1315
+ if (pids.length > 0) {
1316
+ contextParts.push(`Running processes: PIDs ${pids.join(", ")}`);
1317
+ }
1318
+
1161
1319
  contextParts.push("");
1162
1320
  contextParts.push("Conversation summary:");
1163
1321
  contextParts.push(...summaryParts.slice(-15)); // last 15 exchanges max
@@ -33,7 +33,7 @@ export class Footer {
33
33
 
34
34
  this.hintsText = new TextRenderable(renderer, {
35
35
  id: "footer-hints",
36
- content: "esc stop ^N new chat ^L ^H switch ^W close ^R mode ^E chats ^D bots / cmd",
36
+ content: "esc stop ^N new ^L ^H switch ^W close ^R mode ^E chats ^D bots ^O dash / cmd",
37
37
  fg: COLORS.borderDim,
38
38
  });
39
39
  this.container.add(this.hintsText);
@@ -20,6 +20,7 @@ export class KeyHandler {
20
20
  private tabCloseCallback: (() => void) | null = null;
21
21
  private tabNewCallback: (() => void) | null = null;
22
22
  private acNavCallback: ((dir: "up" | "down" | "accept" | "dismiss") => void) | null = null;
23
+ private openDashboardCallback: (() => void) | null = null;
23
24
  acActive = false; // set by app when autocomplete is visible
24
25
 
25
26
  // Panel navigation (shared by session & strategy panels)
@@ -56,6 +57,7 @@ export class KeyHandler {
56
57
  onTabClose(cb: () => void): void { this.tabCloseCallback = cb; }
57
58
  onTabNew(cb: () => void): void { this.tabNewCallback = cb; }
58
59
  onAcNav(cb: (dir: "up" | "down" | "accept" | "dismiss") => void): void { this.acNavCallback = cb; }
60
+ onOpenDashboard(cb: () => void): void { this.openDashboardCallback = cb; }
59
61
 
60
62
  onPanelNav(cb: (delta: number) => void): void { this.panelNavCallback = cb; }
61
63
  onPanelSelect(cb: () => void): void { this.panelSelectCallback = cb; }
@@ -123,6 +125,12 @@ export class KeyHandler {
123
125
  return;
124
126
  }
125
127
 
128
+ // Ctrl+O — open dashboard in browser
129
+ if (key.ctrl && key.name === "o") {
130
+ this.openDashboardCallback?.();
131
+ return;
132
+ }
133
+
126
134
  // Ctrl+L — next tab (was clear, now tab navigation)
127
135
  if (key.ctrl && key.name === "l") {
128
136
  if (this.panelActive === "sessions") { this.panelNewCallback?.(); return; }
@@ -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
+ }
@@ -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
+ }