horizon-code 0.1.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.
Files changed (54) hide show
  1. package/assets/python/highlights.scm +137 -0
  2. package/assets/python/tree-sitter-python.wasm +0 -0
  3. package/bin/horizon.js +2 -0
  4. package/package.json +40 -0
  5. package/src/ai/client.ts +369 -0
  6. package/src/ai/system-prompt.ts +86 -0
  7. package/src/app.ts +1454 -0
  8. package/src/chat/messages.ts +48 -0
  9. package/src/chat/renderer.ts +243 -0
  10. package/src/chat/types.ts +18 -0
  11. package/src/components/code-panel.ts +329 -0
  12. package/src/components/footer.ts +72 -0
  13. package/src/components/hooks-panel.ts +224 -0
  14. package/src/components/input-bar.ts +193 -0
  15. package/src/components/mode-bar.ts +245 -0
  16. package/src/components/session-panel.ts +294 -0
  17. package/src/components/settings-panel.ts +372 -0
  18. package/src/components/splash.ts +156 -0
  19. package/src/components/strategy-panel.ts +489 -0
  20. package/src/components/tab-bar.ts +112 -0
  21. package/src/components/tutorial-panel.ts +680 -0
  22. package/src/components/widgets/progress-bar.ts +38 -0
  23. package/src/components/widgets/sparkline.ts +57 -0
  24. package/src/hooks/executor.ts +109 -0
  25. package/src/index.ts +22 -0
  26. package/src/keys/handler.ts +198 -0
  27. package/src/platform/auth.ts +36 -0
  28. package/src/platform/client.ts +159 -0
  29. package/src/platform/config.ts +121 -0
  30. package/src/platform/session-sync.ts +158 -0
  31. package/src/platform/supabase.ts +376 -0
  32. package/src/platform/sync.ts +149 -0
  33. package/src/platform/tiers.ts +103 -0
  34. package/src/platform/tools.ts +163 -0
  35. package/src/platform/types.ts +86 -0
  36. package/src/platform/usage.ts +224 -0
  37. package/src/research/apis.ts +367 -0
  38. package/src/research/tools.ts +205 -0
  39. package/src/research/widgets.ts +523 -0
  40. package/src/state/store.ts +256 -0
  41. package/src/state/types.ts +109 -0
  42. package/src/strategy/ascii-chart.ts +74 -0
  43. package/src/strategy/code-stream.ts +146 -0
  44. package/src/strategy/dashboard.ts +140 -0
  45. package/src/strategy/persistence.ts +82 -0
  46. package/src/strategy/prompts.ts +626 -0
  47. package/src/strategy/sandbox.ts +137 -0
  48. package/src/strategy/tools.ts +764 -0
  49. package/src/strategy/validator.ts +216 -0
  50. package/src/strategy/widgets.ts +270 -0
  51. package/src/syntax/setup.ts +54 -0
  52. package/src/theme/colors.ts +107 -0
  53. package/src/theme/icons.ts +27 -0
  54. package/src/util/hyperlink.ts +21 -0
@@ -0,0 +1,224 @@
1
+ // Usage manager — token budgets, rate limiting, subscription tracking
2
+ // Reads from Supabase, enforces limits locally, syncs usage atomically
3
+
4
+ import { getSupabase, getUser } from "./supabase.ts";
5
+ import {
6
+ type PlanTier, type ModelPower,
7
+ TIER_CONFIG, MODEL_MULTIPLIER, getWindowBounds, formatTimeRemaining, resolveModel,
8
+ } from "./tiers.ts";
9
+
10
+ interface Subscription {
11
+ plan: PlanTier;
12
+ status: string;
13
+ current_period_end: string | null;
14
+ }
15
+
16
+ export interface UsageState {
17
+ tier: PlanTier;
18
+ budget: number;
19
+ consumed: number;
20
+ windowEnd: number;
21
+ overflowing: boolean; // true when paid user exhausted budget, using fast
22
+ }
23
+
24
+ export interface RequestCheck {
25
+ allowed: boolean;
26
+ reason?: "budget" | "rate_limit" | "not_authenticated";
27
+ modelId: string;
28
+ effectivePower: ModelPower;
29
+ downgraded: boolean;
30
+ message?: string;
31
+ timeUntilRefresh?: string;
32
+ }
33
+
34
+ class UsageManager {
35
+ private _sub: Subscription = { plan: "free", status: "active", current_period_end: null };
36
+ private _windowConsumed = 0;
37
+ private _windowEnd = 0;
38
+ private _windowStart = new Date();
39
+ private _lastSubFetch = 0;
40
+ private _initialized = false;
41
+
42
+ get tier(): PlanTier { return this._sub.plan; }
43
+ get state(): UsageState {
44
+ const config = TIER_CONFIG[this._sub.plan];
45
+ return {
46
+ tier: this._sub.plan,
47
+ budget: config.tokenBudget,
48
+ consumed: this._windowConsumed,
49
+ windowEnd: this._windowEnd,
50
+ overflowing: this._windowConsumed >= config.tokenBudget && config.overflowToFast,
51
+ };
52
+ }
53
+
54
+ async initialize(): Promise<void> {
55
+ await this.refreshSubscription();
56
+ await this.refreshWindowUsage();
57
+ this._initialized = true;
58
+ }
59
+
60
+ // ── Subscription ──
61
+
62
+ async refreshSubscription(): Promise<void> {
63
+ const sb = getSupabase();
64
+ const user = getUser();
65
+ if (!user) { this._sub = { plan: "free", status: "active", current_period_end: null }; return; }
66
+
67
+ try {
68
+ const { data } = await sb.from("subscriptions")
69
+ .select("plan, status, current_period_end")
70
+ .eq("user_id", user.id)
71
+ .single();
72
+
73
+ if (data) {
74
+ // Past due or canceled = treat as free
75
+ const plan = (data.status === "active" ? data.plan : "free") as PlanTier;
76
+ this._sub = { plan, status: data.status, current_period_end: data.current_period_end };
77
+ } else {
78
+ this._sub = { plan: "free", status: "active", current_period_end: null };
79
+ }
80
+ } catch {
81
+ // Supabase unreachable — keep current tier
82
+ }
83
+ this._lastSubFetch = Date.now();
84
+ }
85
+
86
+ // ── Window usage ──
87
+
88
+ async refreshWindowUsage(): Promise<void> {
89
+ const sb = getSupabase();
90
+ const user = getUser();
91
+ if (!user) return;
92
+
93
+ const { start, end } = getWindowBounds();
94
+ this._windowStart = start;
95
+ this._windowEnd = end.getTime();
96
+
97
+ try {
98
+ const { data } = await sb.from("token_usage_windows")
99
+ .select("tokens_used")
100
+ .eq("user_id", user.id)
101
+ .eq("window_start", start.toISOString())
102
+ .single();
103
+
104
+ this._windowConsumed = data?.tokens_used ?? 0;
105
+ } catch {
106
+ // No row = 0 usage, or Supabase unreachable
107
+ }
108
+ }
109
+
110
+ // ── Pre-check: can the user make a request? ──
111
+
112
+ async canMakeRequest(power: ModelPower): Promise<RequestCheck> {
113
+ const user = getUser();
114
+ if (!user) {
115
+ return { allowed: false, reason: "not_authenticated", modelId: "", effectivePower: "fast", downgraded: false, message: "Not authenticated. Type /login." };
116
+ }
117
+
118
+ // Refresh subscription every 60s
119
+ if (Date.now() - this._lastSubFetch > 60_000) {
120
+ await this.refreshSubscription();
121
+ }
122
+
123
+ // Refresh window usage (cross-device sync)
124
+ await this.refreshWindowUsage();
125
+
126
+ const config = TIER_CONFIG[this._sub.plan];
127
+ const budgetExhausted = this._windowConsumed >= config.tokenBudget;
128
+ const timeLeft = formatTimeRemaining(this._windowEnd - Date.now());
129
+
130
+ // Resolve model (may downgrade)
131
+ const { modelId, effectivePower, downgraded } = resolveModel(this._sub.plan, power, budgetExhausted);
132
+
133
+ // Budget exhausted
134
+ if (budgetExhausted && !config.overflowToFast) {
135
+ // Free user: blocked
136
+ return {
137
+ allowed: false, reason: "budget", modelId, effectivePower, downgraded,
138
+ message: `Budget exhausted. Free plan: ${(config.tokenBudget / 1000).toFixed(0)}K tokens/window. Refreshes in ${timeLeft}.\nUpgrade at https://mathematicalcompany.com/pricing`,
139
+ timeUntilRefresh: timeLeft,
140
+ };
141
+ }
142
+
143
+ // Rate limit check
144
+ try {
145
+ const sb = getSupabase();
146
+ // Insert rate limit entry
147
+ await sb.from("rate_limit_log").insert({ user_id: user.id });
148
+ // Check count
149
+ const { data } = await sb.rpc("check_rate_limit", { p_user_id: user.id });
150
+ const count = typeof data === "number" ? data : 0;
151
+ if (count > config.ratePerMinute) {
152
+ return {
153
+ allowed: false, reason: "rate_limit", modelId, effectivePower, downgraded,
154
+ message: `Slow down. ${this._sub.plan} plan: ${config.ratePerMinute} requests/min. Try again in a few seconds.`,
155
+ };
156
+ }
157
+ } catch {
158
+ // Rate limit check failed — allow (fail open)
159
+ }
160
+
161
+ let message: string | undefined;
162
+ if (budgetExhausted && config.overflowToFast) {
163
+ message = `Budget used. Switched to Fast mode until refresh in ${timeLeft}.`;
164
+ } else if (downgraded) {
165
+ const tierLabel = this._sub.plan.charAt(0).toUpperCase() + this._sub.plan.slice(1);
166
+ message = `${power} model requires ${power === "ultra" ? "Ultra" : "Pro"} plan. Using ${effectivePower} instead.`;
167
+ }
168
+
169
+ return { allowed: true, modelId, effectivePower, downgraded, message };
170
+ }
171
+
172
+ // ── Post-call: record usage ──
173
+
174
+ async recordUsage(opts: {
175
+ rawInput: number;
176
+ rawOutput: number;
177
+ effectivePower: ModelPower;
178
+ model: string;
179
+ }): Promise<void> {
180
+ const user = getUser();
181
+ if (!user) return;
182
+
183
+ const rawTotal = opts.rawInput + opts.rawOutput;
184
+ const multiplier = MODEL_MULTIPLIER[opts.effectivePower];
185
+ const weighted = Math.round(rawTotal * multiplier);
186
+
187
+ const { start, end } = getWindowBounds();
188
+
189
+ try {
190
+ const sb = getSupabase();
191
+ const { data } = await sb.rpc("increment_token_usage", {
192
+ p_user_id: user.id,
193
+ p_window_start: start.toISOString(),
194
+ p_window_end: end.toISOString(),
195
+ p_weighted_tokens: weighted,
196
+ p_raw_input: opts.rawInput,
197
+ p_raw_output: opts.rawOutput,
198
+ p_model: opts.model,
199
+ });
200
+
201
+ if (typeof data === "number") {
202
+ this._windowConsumed = data;
203
+ } else {
204
+ this._windowConsumed += weighted;
205
+ }
206
+ } catch {
207
+ // Offline: track locally
208
+ this._windowConsumed += weighted;
209
+ }
210
+ }
211
+
212
+ // ── Helpers ──
213
+
214
+ budgetPct(): number {
215
+ const budget = TIER_CONFIG[this._sub.plan].tokenBudget;
216
+ return budget > 0 ? Math.min(1, this._windowConsumed / budget) : 0;
217
+ }
218
+
219
+ budgetRemaining(): number {
220
+ return Math.max(0, TIER_CONFIG[this._sub.plan].tokenBudget - this._windowConsumed);
221
+ }
222
+ }
223
+
224
+ export const usageManager = new UsageManager();
@@ -0,0 +1,367 @@
1
+ // External API clients — Polymarket, Kalshi, Exa, Cala
2
+
3
+ const GAMMA = "https://gamma-api.polymarket.com";
4
+ const CLOB = "https://clob.polymarket.com";
5
+ const KALSHI = "https://api.elections.kalshi.com/trade-api/v2";
6
+ const DATA_API = "https://data-api.polymarket.com";
7
+ // EXA and CALA now proxied through API server — keys held server-side
8
+
9
+ async function get(url: string): Promise<any> {
10
+ const res = await fetch(url, { signal: AbortSignal.timeout(10000) });
11
+ if (!res.ok) throw new Error(`${res.status} ${url}`);
12
+ return res.json();
13
+ }
14
+
15
+ async function post(url: string, body: any, headers: Record<string, string> = {}): Promise<any> {
16
+ const res = await fetch(url, {
17
+ method: "POST",
18
+ headers: { "Content-Type": "application/json", ...headers },
19
+ body: JSON.stringify(body),
20
+ signal: AbortSignal.timeout(10000),
21
+ });
22
+ if (!res.ok) throw new Error(`${res.status} ${url}`);
23
+ return res.json();
24
+ }
25
+
26
+ // ── Polymarket (Gamma API — public, no auth) ──
27
+
28
+ // Gamma API has NO text search — tag param matches predefined categories, not free text.
29
+ // Strategy: always fetch a large batch sorted by volume, filter client-side on title + description.
30
+ // We cache the batch for 60 seconds to avoid hammering the API on repeated searches.
31
+
32
+ let _eventsCache: { data: any[]; ts: number } = { data: [], ts: 0 };
33
+ const CACHE_TTL = 60_000; // 60 seconds
34
+
35
+ async function fetchAllEvents(): Promise<any[]> {
36
+ if (_eventsCache.data.length > 0 && Date.now() - _eventsCache.ts < CACHE_TTL) {
37
+ return _eventsCache.data;
38
+ }
39
+ const params = new URLSearchParams({
40
+ active: "true", closed: "false",
41
+ order: "volume24hr", ascending: "false",
42
+ limit: "200",
43
+ });
44
+ const data = await get(`${GAMMA}/events?${params}`).catch(() => []);
45
+ _eventsCache = { data: data ?? [], ts: Date.now() };
46
+ return _eventsCache.data;
47
+ }
48
+
49
+ export async function gammaEvents(opts: { query?: string; limit?: number } = {}): Promise<any[]> {
50
+ const limit = Math.min(opts.limit ?? 10, 20);
51
+ const query = (opts.query ?? "").toLowerCase().trim();
52
+
53
+ const allEvents = await fetchAllEvents();
54
+
55
+ let data: any[];
56
+
57
+ if (!query) {
58
+ // No query: return top by volume
59
+ data = allEvents.slice(0, limit);
60
+ } else {
61
+ // Split query into words for flexible matching
62
+ const words = query.split(/\s+/).filter((w) => w.length > 1);
63
+
64
+ // First pass: ALL words must match (strict)
65
+ data = allEvents.filter((e: any) => {
66
+ const text = ((e.title ?? "") + " " + (e.description ?? "") + " " +
67
+ (e.tags ?? []).map((t: any) => t.label ?? t).join(" ")).toLowerCase();
68
+ return words.every((w) => text.includes(w));
69
+ });
70
+
71
+ // Second pass: ANY word matches (broader)
72
+ if (data.length === 0 && words.length > 1) {
73
+ data = allEvents.filter((e: any) => {
74
+ const text = ((e.title ?? "") + " " + (e.description ?? "") + " " +
75
+ (e.tags ?? []).map((t: any) => t.label ?? t).join(" ")).toLowerCase();
76
+ return words.some((w) => text.includes(w));
77
+ });
78
+ }
79
+
80
+ // Sort matches by relevance: title match > description match, then by volume
81
+ data.sort((a: any, b: any) => {
82
+ const aTitle = (a.title ?? "").toLowerCase();
83
+ const bTitle = (b.title ?? "").toLowerCase();
84
+ const aInTitle = words.some((w) => aTitle.includes(w)) ? 1 : 0;
85
+ const bInTitle = words.some((w) => bTitle.includes(w)) ? 1 : 0;
86
+ if (aInTitle !== bInTitle) return bInTitle - aInTitle;
87
+ return (b.volume24hr ?? 0) - (a.volume24hr ?? 0);
88
+ });
89
+
90
+ data = data.slice(0, limit);
91
+ }
92
+
93
+ return (data ?? []).map((e: any) => ({
94
+ title: e.title, slug: e.slug, eventId: e.id,
95
+ description: (e.description ?? "").slice(0, 500),
96
+ volume: e.volume, liquidity: e.liquidity,
97
+ volume24hr: e.volume24hr, openInterest: e.openInterest,
98
+ startDate: e.startDate, endDate: e.endDate,
99
+ tags: (e.tags ?? []).map((t: any) => t.label ?? t),
100
+ oneDayPriceChange: e.oneDayPriceChange,
101
+ url: `https://polymarket.com/event/${e.slug}`,
102
+ markets: (e.markets ?? []).map((m: any) => ({
103
+ question: m.question, conditionId: m.conditionId,
104
+ yesPrice: m.outcomePrices ? JSON.parse(m.outcomePrices)[0] : null,
105
+ noPrice: m.outcomePrices ? JSON.parse(m.outcomePrices)[1] : null,
106
+ volume: m.volume, liquidity: m.liquidity,
107
+ bestBid: m.bestBid, bestAsk: m.bestAsk, lastPrice: m.lastTradePrice,
108
+ oneDayPriceChange: m.oneDayPriceChange,
109
+ })),
110
+ }));
111
+ }
112
+
113
+ export async function gammaEventDetail(slug: string): Promise<any> {
114
+ const events = await get(`${GAMMA}/events?slug=${slug}`);
115
+ const event = events?.[0];
116
+ if (!event) throw new Error(`Event not found: ${slug}`);
117
+
118
+ // Fetch price history for first market
119
+ let priceHistory: any[] = [];
120
+ const firstMarket = event.markets?.[0];
121
+ if (firstMarket) {
122
+ const tokenId = firstMarket.clobTokenIds ? JSON.parse(firstMarket.clobTokenIds)[0] : null;
123
+ if (tokenId) {
124
+ try {
125
+ const hist = await get(`${CLOB}/prices-history?market=${tokenId}&interval=max&fidelity=60`);
126
+ priceHistory = hist?.history ?? [];
127
+ } catch {}
128
+ }
129
+ }
130
+
131
+ return {
132
+ title: event.title, slug: event.slug,
133
+ description: (event.description ?? "").slice(0, 1000),
134
+ volume: event.volume, liquidity: event.liquidity,
135
+ volume24hr: event.volume24hr, endDate: event.endDate,
136
+ tags: (event.tags ?? []).map((t: any) => t.label ?? t),
137
+ url: `https://polymarket.com/event/${event.slug}`,
138
+ priceHistory,
139
+ markets: (event.markets ?? []).map((m: any) => ({
140
+ question: m.question, conditionId: m.conditionId,
141
+ yesPrice: m.outcomePrices ? JSON.parse(m.outcomePrices)[0] : null,
142
+ noPrice: m.outcomePrices ? JSON.parse(m.outcomePrices)[1] : null,
143
+ volume: m.volume, liquidity: m.liquidity,
144
+ bestBid: m.bestBid, bestAsk: m.bestAsk,
145
+ spread: m.bestAsk && m.bestBid ? +(m.bestAsk - m.bestBid).toFixed(4) : null,
146
+ lastPrice: m.lastTradePrice,
147
+ })),
148
+ };
149
+ }
150
+
151
+ export async function clobPriceHistory(slug: string, interval = "1w", fidelity = 60): Promise<any> {
152
+ const events = await get(`${GAMMA}/events?slug=${slug}`);
153
+ const market = events?.[0]?.markets?.[0];
154
+ if (!market) throw new Error(`Market not found: ${slug}`);
155
+
156
+ const tokenId = market.clobTokenIds ? JSON.parse(market.clobTokenIds)[0] : null;
157
+ if (!tokenId) throw new Error("No token ID");
158
+
159
+ const hist = await get(`${CLOB}/prices-history?market=${tokenId}&interval=${interval}&fidelity=${fidelity}`);
160
+ const points = hist?.history ?? [];
161
+ const prices = points.map((p: any) => p.p);
162
+
163
+ return {
164
+ title: events[0].title, slug,
165
+ url: `https://polymarket.com/event/${slug}`,
166
+ marketQuestion: market.question,
167
+ interval, dataPoints: points.length,
168
+ current: prices.length > 0 ? prices[prices.length - 1] : null,
169
+ high: prices.length > 0 ? Math.max(...prices) : null,
170
+ low: prices.length > 0 ? Math.min(...prices) : null,
171
+ change: prices.length > 1 ? +((prices[prices.length - 1] - prices[0]) / prices[0]).toFixed(4) : null,
172
+ priceHistory: points,
173
+ };
174
+ }
175
+
176
+ export async function clobOrderBook(slug: string, marketIndex = 0, outcomeIndex = 0): Promise<any> {
177
+ const events = await get(`${GAMMA}/events?slug=${slug}`);
178
+ const market = events?.[0]?.markets?.[marketIndex];
179
+ if (!market) throw new Error(`Market not found: ${slug}`);
180
+
181
+ const tokenId = market.clobTokenIds ? JSON.parse(market.clobTokenIds)[outcomeIndex] : null;
182
+ if (!tokenId) throw new Error("No token ID");
183
+
184
+ const book = await get(`${CLOB}/book?token_id=${tokenId}`);
185
+
186
+ return {
187
+ title: events[0].title, slug,
188
+ url: `https://polymarket.com/event/${slug}`,
189
+ marketQuestion: market.question,
190
+ outcome: outcomeIndex === 0 ? "Yes" : "No",
191
+ bestBid: book?.bids?.[0]?.price ?? null,
192
+ bestAsk: book?.asks?.[0]?.price ?? null,
193
+ spread: book?.bids?.[0] && book?.asks?.[0]
194
+ ? +(book.asks[0].price - book.bids[0].price).toFixed(4) : null,
195
+ bids: (book?.bids ?? []).slice(0, 15).map((l: any) => ({ price: +l.price, size: +l.size })),
196
+ asks: (book?.asks ?? []).slice(0, 15).map((l: any) => ({ price: +l.price, size: +l.size })),
197
+ };
198
+ }
199
+
200
+ export async function polymarketTrades(slug: string): Promise<any> {
201
+ const events = await get(`${GAMMA}/events?slug=${slug}`);
202
+ const market = events?.[0]?.markets?.[0];
203
+ if (!market) throw new Error(`Market not found: ${slug}`);
204
+
205
+ const [trades, holders] = await Promise.all([
206
+ get(`${DATA_API}/trades?market=${market.conditionId}&limit=20`).catch(() => []),
207
+ get(`${DATA_API}/holders?market=${market.conditionId}&limit=10`).catch(() => []),
208
+ ]);
209
+
210
+ const largeTrades = (trades ?? []).filter((t: any) => (t.size ?? 0) >= 500).slice(0, 10);
211
+ const buyVol = largeTrades.filter((t: any) => t.side === "BUY").reduce((s: number, t: any) => s + (t.size ?? 0), 0);
212
+ const sellVol = largeTrades.filter((t: any) => t.side === "SELL").reduce((s: number, t: any) => s + (t.size ?? 0), 0);
213
+
214
+ return {
215
+ title: events[0].title, slug,
216
+ url: `https://polymarket.com/event/${slug}`,
217
+ largeTrades: largeTrades.map((t: any) => ({
218
+ side: t.side ?? "UNKNOWN", name: t.maker_name ?? "Anonymous",
219
+ size: t.size, price: t.price, timestamp: t.created_at,
220
+ })),
221
+ topHolders: (holders ?? []).slice(0, 8).map((h: any) => ({
222
+ name: h.name ?? "Anonymous", amount: h.amount, side: h.side ?? "YES",
223
+ })),
224
+ flow: {
225
+ buyVolume: +buyVol.toFixed(2), sellVolume: +sellVol.toFixed(2),
226
+ netFlow: +(buyVol - sellVol).toFixed(2),
227
+ direction: buyVol >= sellVol ? "inflow" : "outflow",
228
+ },
229
+ };
230
+ }
231
+
232
+ // ── Kalshi ──
233
+
234
+ export async function kalshiEvents(opts: { query?: string; limit?: number } = {}): Promise<any[]> {
235
+ const params = new URLSearchParams({
236
+ status: "open", with_nested_markets: "true",
237
+ limit: String(Math.min(opts.limit ?? 10, 20)),
238
+ });
239
+ if (opts.query) params.set("series_ticker", opts.query);
240
+
241
+ const data = await get(`${KALSHI}/events?${params}`);
242
+ return (data?.events ?? []).map((e: any) => ({
243
+ ticker: e.event_ticker, title: e.title,
244
+ category: e.category, subtitle: e.sub_title,
245
+ url: `https://kalshi.com/markets/${e.event_ticker?.toLowerCase()}`,
246
+ markets: (e.markets ?? []).map((m: any) => ({
247
+ name: m.title ?? m.subtitle, yesBid: m.yes_bid, yesAsk: m.yes_ask,
248
+ lastPrice: m.last_price, volume: m.volume,
249
+ openInterest: m.open_interest, closeTime: m.close_time,
250
+ })),
251
+ }));
252
+ }
253
+
254
+ // ── Exa (web search + sentiment) — via server proxy ──
255
+
256
+ import { getAuthUrl, loadConfig } from "../platform/config.ts";
257
+
258
+ async function searchProxy(provider: "exa" | "cala", action: string, query: string, numResults?: number): Promise<any> {
259
+ const config = loadConfig();
260
+ if (!config.api_key) return { error: "Not authenticated" };
261
+
262
+ try {
263
+ const res = await fetch(`${getAuthUrl()}/api/v1/search`, {
264
+ method: "POST",
265
+ headers: { "Authorization": `Bearer ${config.api_key}`, "Content-Type": "application/json" },
266
+ body: JSON.stringify({ provider, action, query, numResults }),
267
+ signal: AbortSignal.timeout(15000),
268
+ });
269
+
270
+ if (!res.ok) {
271
+ const err = await res.json().catch(() => ({})) as any;
272
+ return { error: err.error ?? `Search failed (${res.status})` };
273
+ }
274
+
275
+ return res.json();
276
+ } catch (e: any) {
277
+ if (e?.name === "TimeoutError" || e?.name === "AbortError") {
278
+ return { error: `${provider} search timed out` };
279
+ }
280
+ return { error: `${provider} search failed: ${e?.message ?? "network error"}` };
281
+ }
282
+ }
283
+
284
+ export async function exaSearch(query: string, numResults = 6): Promise<any[]> {
285
+ const data = await searchProxy("exa", "search", query, numResults);
286
+ if (data.error) return [{ title: data.error, snippet: "" }];
287
+ return data.results ?? [];
288
+ }
289
+
290
+ export async function exaSentiment(slug: string): Promise<any> {
291
+ const event = await gammaEventDetail(slug);
292
+ const articles = await exaSearch(`"${event.title}" latest news analysis`, 8);
293
+
294
+ const bullish = ["positive", "bullish", "surge", "rally", "gain", "rise", "up", "growth", "boost", "strong"];
295
+ const bearish = ["negative", "bearish", "drop", "crash", "fall", "down", "loss", "weak", "decline", "risk"];
296
+
297
+ let bull = 0, bear = 0;
298
+ for (const a of articles) {
299
+ const text = ((a.title ?? "") + " " + (a.snippet ?? "")).toLowerCase();
300
+ for (const w of bullish) if (text.includes(w)) bull++;
301
+ for (const w of bearish) if (text.includes(w)) bear++;
302
+ }
303
+
304
+ const total = bull + bear || 1;
305
+ const score = +((bull - bear) / total).toFixed(2);
306
+ const label = score >= 0.3 ? "Bullish" : score >= 0.1 ? "Slightly Bullish"
307
+ : score <= -0.3 ? "Bearish" : score <= -0.1 ? "Slightly Bearish" : "Neutral";
308
+
309
+ return {
310
+ title: event.title, slug, url: event.url,
311
+ sentiment: { score, label, articleCount: articles.length, bullishSignals: bull, bearishSignals: bear },
312
+ articles: articles.map((a: any) => ({ title: a.title, url: a.url, snippet: (a.snippet ?? "").slice(0, 200) })),
313
+ };
314
+ }
315
+
316
+ // ── Cala — via server proxy (Pro/Ultra only) ──
317
+
318
+ export async function calaSearch(query: string): Promise<any> {
319
+ const data = await searchProxy("cala", "search", query);
320
+ if (data.error) return { content: data.error, sources: [], entities: [] };
321
+ return data;
322
+ }
323
+
324
+ // ── Calculators (local, no API needed) ──
325
+
326
+ export function evCalculator(opts: {
327
+ marketPrice: number; side: "yes" | "no";
328
+ estimatedEdge: number; bankroll: number;
329
+ }): any {
330
+ const { marketPrice, side, estimatedEdge, bankroll } = opts;
331
+ const price = side === "yes" ? marketPrice : 1 - marketPrice;
332
+ const trueProb = Math.min(0.99, Math.max(0.01, price + estimatedEdge));
333
+ const edge = +(trueProb - price).toFixed(4);
334
+ const winPayout = 1 - price;
335
+ const ev = +(trueProb * winPayout - (1 - trueProb) * price).toFixed(4);
336
+ const evPerDollar = +(ev / price).toFixed(4);
337
+ const b = winPayout / price;
338
+ const kelly = Math.max(0, +((b * trueProb - (1 - trueProb)) / b).toFixed(4));
339
+
340
+ return {
341
+ side, marketPrice: +price.toFixed(4), trueProb: +trueProb.toFixed(4),
342
+ edge, ev, evPerDollar,
343
+ kellyFraction: kelly,
344
+ positionSizing: {
345
+ fullKelly: { fraction: kelly, amount: +(bankroll * kelly).toFixed(2) },
346
+ halfKelly: { fraction: +(kelly / 2).toFixed(4), amount: +(bankroll * kelly / 2).toFixed(2) },
347
+ quarterKelly: { fraction: +(kelly / 4).toFixed(4), amount: +(bankroll * kelly / 4).toFixed(2) },
348
+ },
349
+ riskMetrics: {
350
+ maxLoss: +price.toFixed(4), maxProfit: +winPayout.toFixed(4),
351
+ riskReward: +(winPayout / price).toFixed(2),
352
+ breakevenProb: +price.toFixed(4),
353
+ },
354
+ bankroll,
355
+ };
356
+ }
357
+
358
+ export function probabilityCalc(pA: number, pB: number): any {
359
+ const joint = +(pA * pB).toFixed(4);
360
+ return {
361
+ probA: +pA.toFixed(4), probB: +pB.toFixed(4),
362
+ jointProbability: joint,
363
+ pAGivenB: pB > 0 ? +(joint / pB).toFixed(4) : null,
364
+ pBGivenA: pA > 0 ? +(joint / pA).toFixed(4) : null,
365
+ assumingIndependence: Math.abs(joint - pA * pB) < 0.01,
366
+ };
367
+ }