opentradex 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 (70) hide show
  1. package/.env.example +8 -0
  2. package/CLAUDE.md +98 -0
  3. package/README.md +246 -0
  4. package/SOUL.md +79 -0
  5. package/SPEC.md +317 -0
  6. package/SUBMISSION.md +30 -0
  7. package/architecture.excalidraw +170 -0
  8. package/architecture.png +0 -0
  9. package/bin/opentradex.mjs +4 -0
  10. package/data/.gitkeep +0 -0
  11. package/data/strategy_notes.md +158 -0
  12. package/gossip/__init__.py +0 -0
  13. package/gossip/dashboard.py +150 -0
  14. package/gossip/db.py +358 -0
  15. package/gossip/kalshi.py +492 -0
  16. package/gossip/news.py +235 -0
  17. package/gossip/trader.py +646 -0
  18. package/main.py +287 -0
  19. package/package.json +47 -0
  20. package/requirements.txt +7 -0
  21. package/src/cli.mjs +124 -0
  22. package/src/index.mjs +420 -0
  23. package/web/AGENTS.md +5 -0
  24. package/web/CLAUDE.md +1 -0
  25. package/web/README.md +36 -0
  26. package/web/components.json +25 -0
  27. package/web/eslint.config.mjs +18 -0
  28. package/web/next.config.ts +7 -0
  29. package/web/package-lock.json +11626 -0
  30. package/web/package.json +37 -0
  31. package/web/postcss.config.mjs +7 -0
  32. package/web/public/file.svg +1 -0
  33. package/web/public/globe.svg +1 -0
  34. package/web/public/next.svg +1 -0
  35. package/web/public/vercel.svg +1 -0
  36. package/web/public/window.svg +1 -0
  37. package/web/src/app/api/agent/route.ts +77 -0
  38. package/web/src/app/api/agent/stream/route.ts +87 -0
  39. package/web/src/app/api/markets/route.ts +15 -0
  40. package/web/src/app/api/news/live/route.ts +77 -0
  41. package/web/src/app/api/news/reddit/route.ts +118 -0
  42. package/web/src/app/api/news/route.ts +10 -0
  43. package/web/src/app/api/news/tiktok/route.ts +115 -0
  44. package/web/src/app/api/news/truthsocial/route.ts +116 -0
  45. package/web/src/app/api/news/twitter/route.ts +186 -0
  46. package/web/src/app/api/portfolio/route.ts +50 -0
  47. package/web/src/app/api/prices/route.ts +18 -0
  48. package/web/src/app/api/trades/route.ts +10 -0
  49. package/web/src/app/favicon.ico +0 -0
  50. package/web/src/app/globals.css +170 -0
  51. package/web/src/app/layout.tsx +36 -0
  52. package/web/src/app/page.tsx +366 -0
  53. package/web/src/components/AgentLog.tsx +71 -0
  54. package/web/src/components/LiveStream.tsx +394 -0
  55. package/web/src/components/MarketScanner.tsx +111 -0
  56. package/web/src/components/NewsFeed.tsx +561 -0
  57. package/web/src/components/PortfolioStrip.tsx +139 -0
  58. package/web/src/components/PositionsPanel.tsx +219 -0
  59. package/web/src/components/TopBar.tsx +127 -0
  60. package/web/src/components/ui/badge.tsx +52 -0
  61. package/web/src/components/ui/button.tsx +60 -0
  62. package/web/src/components/ui/card.tsx +103 -0
  63. package/web/src/components/ui/scroll-area.tsx +55 -0
  64. package/web/src/components/ui/separator.tsx +25 -0
  65. package/web/src/components/ui/tabs.tsx +82 -0
  66. package/web/src/components/ui/tooltip.tsx +66 -0
  67. package/web/src/lib/db.ts +81 -0
  68. package/web/src/lib/types.ts +130 -0
  69. package/web/src/lib/utils.ts +6 -0
  70. package/web/tsconfig.json +34 -0
@@ -0,0 +1,366 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState, useCallback, useRef } from "react";
4
+ import { TopBar } from "@/components/TopBar";
5
+ import { PortfolioStrip } from "@/components/PortfolioStrip";
6
+ import { PositionsPanel } from "@/components/PositionsPanel";
7
+ import { MarketScanner } from "@/components/MarketScanner";
8
+ import { LiveStream } from "@/components/LiveStream";
9
+ import { NewsFeed } from "@/components/NewsFeed";
10
+ import type {
11
+ Portfolio,
12
+ Trade,
13
+ NewsArticle,
14
+ Market,
15
+ StreamLine,
16
+ PositionPrice,
17
+ SocialPost,
18
+ } from "@/lib/types";
19
+
20
+ export default function Dashboard() {
21
+ const [portfolio, setPortfolio] = useState<Portfolio | null>(null);
22
+ const [trades, setTrades] = useState<Trade[]>([]);
23
+ const [news, setNews] = useState<NewsArticle[]>([]);
24
+ const [liveNews, setLiveNews] = useState<NewsArticle[]>([]);
25
+ const [markets, setMarkets] = useState<Market[]>([]);
26
+ const [rationale, setRationale] = useState("");
27
+ const [customPrompt, setCustomPrompt] = useState("");
28
+ const [agentStatus, setAgentStatus] = useState("");
29
+ const [loopInterval, setLoopInterval] = useState(900);
30
+ const [streamLines, setStreamLines] = useState<StreamLine[]>([]);
31
+ const [liveStatus, setLiveStatus] = useState<string>("idle");
32
+ const [prices, setPrices] = useState<PositionPrice[]>([]);
33
+ const [tweets, setTweets] = useState<SocialPost[]>([]);
34
+ const [truthPosts, setTruthPosts] = useState<SocialPost[]>([]);
35
+ const [redditPosts, setRedditPosts] = useState<SocialPost[]>([]);
36
+ const [tiktokPosts, setTiktokPosts] = useState<SocialPost[]>([]);
37
+ const [loadingTweets, setLoadingTweets] = useState(true);
38
+ const [loadingTruth, setLoadingTruth] = useState(true);
39
+ const [loadingReddit, setLoadingReddit] = useState(true);
40
+ const [loadingTiktok, setLoadingTiktok] = useState(true);
41
+ const streamOffset = useRef(0);
42
+
43
+ const fetchAll = useCallback(async () => {
44
+ try {
45
+ const [p, t, n, m] = await Promise.all([
46
+ fetch("/api/portfolio").then((r) => r.json()),
47
+ fetch("/api/trades").then((r) => r.json()),
48
+ fetch("/api/news").then((r) => r.json()),
49
+ fetch("/api/markets").then((r) => r.json()),
50
+ ]);
51
+ setPortfolio(p);
52
+ setTrades(t);
53
+ setNews(n);
54
+ setMarkets(m);
55
+ } catch {
56
+ // DB might not exist yet
57
+ }
58
+ }, []);
59
+
60
+ const fetchLiveNews = useCallback(async () => {
61
+ try {
62
+ const res = await fetch("/api/news/live");
63
+ const data = await res.json();
64
+ setLiveNews(
65
+ data.map((item: Record<string, string | null>, i: number) => ({
66
+ id: -(i + 1),
67
+ timestamp: item.timestamp,
68
+ source: item.source,
69
+ keyword: "",
70
+ title: item.title,
71
+ url: item.url,
72
+ snippet: item.snippet,
73
+ image: item.image || null,
74
+ }))
75
+ );
76
+ } catch {
77
+ // ignore
78
+ }
79
+ }, []);
80
+
81
+ const SHOW_PNL = false;
82
+
83
+ const fetchPrices = useCallback(async () => {
84
+ if (!SHOW_PNL) return;
85
+ try {
86
+ const res = await fetch("/api/prices");
87
+ const data = await res.json();
88
+ setPrices(data.positions || []);
89
+ } catch {
90
+ // ignore
91
+ }
92
+ }, []);
93
+
94
+ const tweetRetries = useRef(0);
95
+ const fetchTweets = useCallback(async () => {
96
+ try {
97
+ const res = await fetch("/api/news/twitter");
98
+ const data = await res.json();
99
+ if (Array.isArray(data) && data.length > 0) {
100
+ setTweets(data);
101
+ setLoadingTweets(false);
102
+ tweetRetries.current = 0;
103
+ } else {
104
+ tweetRetries.current++;
105
+ if (tweetRetries.current > 12) setLoadingTweets(false);
106
+ }
107
+ } catch {
108
+ tweetRetries.current++;
109
+ if (tweetRetries.current > 12) setLoadingTweets(false);
110
+ }
111
+ }, []);
112
+
113
+ const fetchTruthPosts = useCallback(async () => {
114
+ try {
115
+ const res = await fetch("/api/news/truthsocial");
116
+ const data = await res.json();
117
+ if (Array.isArray(data)) setTruthPosts(data);
118
+ } catch {
119
+ // ignore
120
+ } finally {
121
+ setLoadingTruth(false);
122
+ }
123
+ }, []);
124
+
125
+ const redditRetries = useRef(0);
126
+ const fetchRedditPosts = useCallback(async () => {
127
+ try {
128
+ const res = await fetch("/api/news/reddit");
129
+ const data = await res.json();
130
+ if (Array.isArray(data) && data.length > 0) {
131
+ setRedditPosts(data);
132
+ setLoadingReddit(false);
133
+ redditRetries.current = 0;
134
+ } else {
135
+ redditRetries.current++;
136
+ if (redditRetries.current > 12) setLoadingReddit(false);
137
+ }
138
+ } catch {
139
+ redditRetries.current++;
140
+ if (redditRetries.current > 12) setLoadingReddit(false);
141
+ }
142
+ }, []);
143
+
144
+ const tiktokRetries = useRef(0);
145
+ const fetchTiktokPosts = useCallback(async () => {
146
+ try {
147
+ const res = await fetch("/api/news/tiktok");
148
+ const data = await res.json();
149
+ if (Array.isArray(data) && data.length > 0) {
150
+ setTiktokPosts(data);
151
+ setLoadingTiktok(false);
152
+ tiktokRetries.current = 0;
153
+ } else {
154
+ tiktokRetries.current++;
155
+ if (tiktokRetries.current > 12) setLoadingTiktok(false);
156
+ }
157
+ } catch {
158
+ tiktokRetries.current++;
159
+ if (tiktokRetries.current > 12) setLoadingTiktok(false);
160
+ }
161
+ }, []);
162
+
163
+ useEffect(() => {
164
+ fetchAll();
165
+ fetchLiveNews();
166
+ fetchPrices();
167
+ // Stagger Apify fetches to avoid concurrent run limit on free tier
168
+ fetchTruthPosts();
169
+ const redditDelay = setTimeout(fetchRedditPosts, 5_000);
170
+ const tweetDelay = setTimeout(fetchTweets, 15_000);
171
+ const tiktokDelay = setTimeout(fetchTiktokPosts, 25_000);
172
+ const id = setInterval(fetchAll, 5000);
173
+ const newsId = setInterval(fetchLiveNews, 120_000);
174
+ const pricesId = setInterval(fetchPrices, 30_000);
175
+ const tweetsId = setInterval(() => {
176
+ if (tweetRetries.current > 0 && tweetRetries.current <= 12) {
177
+ fetchTweets();
178
+ }
179
+ }, 5_000);
180
+ const tweetsSlowId = setInterval(fetchTweets, 300_000);
181
+ const truthId = setInterval(fetchTruthPosts, 180_000);
182
+ const redditRetryId = setInterval(() => {
183
+ if (redditRetries.current > 0 && redditRetries.current <= 12) {
184
+ fetchRedditPosts();
185
+ }
186
+ }, 5_000);
187
+ const redditSlowId = setInterval(fetchRedditPosts, 300_000);
188
+ const tiktokRetryId = setInterval(() => {
189
+ if (tiktokRetries.current > 0 && tiktokRetries.current <= 12) {
190
+ fetchTiktokPosts();
191
+ }
192
+ }, 5_000);
193
+ const tiktokSlowId = setInterval(fetchTiktokPosts, 300_000);
194
+ return () => {
195
+ clearInterval(id);
196
+ clearInterval(newsId);
197
+ clearInterval(pricesId);
198
+ clearTimeout(tweetDelay);
199
+ clearTimeout(redditDelay);
200
+ clearTimeout(tiktokDelay);
201
+ clearInterval(tweetsId);
202
+ clearInterval(tweetsSlowId);
203
+ clearInterval(truthId);
204
+ clearInterval(redditRetryId);
205
+ clearInterval(redditSlowId);
206
+ clearInterval(tiktokRetryId);
207
+ clearInterval(tiktokSlowId);
208
+ };
209
+ }, [fetchAll, fetchLiveNews, fetchPrices, fetchTweets, fetchTruthPosts, fetchRedditPosts, fetchTiktokPosts]);
210
+
211
+ useEffect(() => {
212
+ const pollStream = async () => {
213
+ try {
214
+ const res = await fetch(
215
+ `/api/agent/stream?offset=${streamOffset.current}`
216
+ );
217
+ const data = await res.json();
218
+ setLiveStatus(data.status?.status || "idle");
219
+ if (data.lines.length > 0) {
220
+ setStreamLines((prev) => {
221
+ const next = [...prev, ...data.lines];
222
+ return next.length > 500 ? next.slice(-500) : next;
223
+ });
224
+ streamOffset.current = data.offset;
225
+ }
226
+ if (data.offset < streamOffset.current) {
227
+ setStreamLines([]);
228
+ streamOffset.current = 0;
229
+ }
230
+ } catch {
231
+ // ignore
232
+ }
233
+ };
234
+ const id = setInterval(pollStream, 1000);
235
+ return () => clearInterval(id);
236
+ }, []);
237
+
238
+ const mergedNews = mergeNews(news, liveNews);
239
+
240
+ const runCycle = async (prompt?: string) => {
241
+ setAgentStatus("Starting cycle...");
242
+ setStreamLines([]);
243
+ streamOffset.current = 0;
244
+ const res = await fetch("/api/agent", {
245
+ method: "POST",
246
+ headers: { "Content-Type": "application/json" },
247
+ body: JSON.stringify({ action: "run_cycle", prompt }),
248
+ });
249
+ const data = await res.json();
250
+ setAgentStatus(data.message || data.status);
251
+ };
252
+
253
+ const startLoop = async () => {
254
+ setAgentStatus(`Starting loop (${loopInterval}s)...`);
255
+ const res = await fetch("/api/agent", {
256
+ method: "POST",
257
+ headers: { "Content-Type": "application/json" },
258
+ body: JSON.stringify({ action: "start_loop", interval: loopInterval }),
259
+ });
260
+ const data = await res.json();
261
+ setAgentStatus(`Loop: ${data.interval}s interval`);
262
+ };
263
+
264
+ const submitRationale = async () => {
265
+ if (!rationale.trim()) return;
266
+ setAgentStatus("Researching thesis...");
267
+ const res = await fetch("/api/agent", {
268
+ method: "POST",
269
+ headers: { "Content-Type": "application/json" },
270
+ body: JSON.stringify({ action: "submit_rationale", rationale }),
271
+ });
272
+ const data = await res.json();
273
+ setAgentStatus(data.message || data.status);
274
+ setRationale("");
275
+ };
276
+
277
+ return (
278
+ <div className="h-screen w-screen max-w-full flex flex-col overflow-hidden bg-background">
279
+ <TopBar
280
+ liveStatus={liveStatus}
281
+ agentStatus={agentStatus}
282
+ rationale={rationale}
283
+ loopInterval={loopInterval}
284
+ onRationaleChange={setRationale}
285
+ onSubmitRationale={submitRationale}
286
+ onRunCycle={() => runCycle()}
287
+ onStartLoop={startLoop}
288
+ onRefresh={() => {
289
+ fetchAll();
290
+ fetchLiveNews();
291
+ }}
292
+ onLoopIntervalChange={setLoopInterval}
293
+ />
294
+
295
+ <PortfolioStrip portfolio={portfolio} unrealizedPnl={prices.reduce((s, p) => s + p.unrealized_pnl, 0)} />
296
+
297
+ <div className="flex-1 grid grid-cols-1 md:grid-cols-[minmax(0,280px)_minmax(0,1fr)] lg:grid-cols-[minmax(0,280px)_minmax(0,1fr)_minmax(0,340px)] min-h-0 overflow-hidden">
298
+ {/* Left column: Positions + Markets */}
299
+ <div className="hidden md:flex flex-col border-r border-border min-h-0 overflow-hidden">
300
+ <div className="flex-1 min-h-0 overflow-hidden">
301
+ <PositionsPanel
302
+ positions={portfolio?.open_positions ?? []}
303
+ trades={trades}
304
+ prices={prices}
305
+ />
306
+ </div>
307
+ <div className="h-[40%] min-h-0 overflow-hidden border-t border-border">
308
+ <MarketScanner markets={markets} />
309
+ </div>
310
+ </div>
311
+
312
+ {/* Center column: Live Stream */}
313
+ <div className="flex flex-col min-h-0">
314
+ <LiveStream
315
+ lines={streamLines}
316
+ liveStatus={liveStatus}
317
+ customPrompt={customPrompt}
318
+ onCustomPromptChange={setCustomPrompt}
319
+ onSendCommand={(prompt) => {
320
+ runCycle(prompt);
321
+ setCustomPrompt("");
322
+ }}
323
+ />
324
+ </div>
325
+
326
+ {/* Right column: News Feed (full height) */}
327
+ <div className="hidden lg:flex flex-col border-l border-border min-h-0 overflow-hidden">
328
+ <NewsFeed
329
+ news={mergedNews}
330
+ tweets={tweets}
331
+ truthPosts={truthPosts}
332
+ redditPosts={redditPosts}
333
+ tiktokPosts={tiktokPosts}
334
+ loadingTweets={loadingTweets}
335
+ loadingTruth={loadingTruth}
336
+ loadingReddit={loadingReddit}
337
+ loadingTiktok={loadingTiktok}
338
+ />
339
+ </div>
340
+ </div>
341
+ </div>
342
+ );
343
+ }
344
+
345
+ function mergeNews(dbNews: NewsArticle[], liveNews: NewsArticle[]): NewsArticle[] {
346
+ const seen = new Set<string>();
347
+ const merged: NewsArticle[] = [];
348
+
349
+ for (const item of dbNews) {
350
+ if (!seen.has(item.title)) {
351
+ seen.add(item.title);
352
+ merged.push(item);
353
+ }
354
+ }
355
+ for (const item of liveNews) {
356
+ if (!seen.has(item.title)) {
357
+ seen.add(item.title);
358
+ merged.push(item);
359
+ }
360
+ }
361
+
362
+ merged.sort(
363
+ (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
364
+ );
365
+ return merged.slice(0, 60);
366
+ }
@@ -0,0 +1,71 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { ScrollArea } from "@/components/ui/scroll-area";
5
+ import { Bot } from "lucide-react";
6
+ import type { AgentCycle } from "@/lib/types";
7
+
8
+ interface AgentLogProps {
9
+ cycles: AgentCycle[];
10
+ }
11
+
12
+ export function AgentLog({ cycles }: AgentLogProps) {
13
+ const [expanded, setExpanded] = useState<number | null>(null);
14
+
15
+ return (
16
+ <div className="flex flex-col h-full border-t border-border">
17
+ <div className="h-9 flex items-center justify-between px-3 border-b border-border bg-card shrink-0">
18
+ <div className="flex items-center gap-2">
19
+ <Bot className="h-3.5 w-3.5 text-muted-foreground" />
20
+ <span className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
21
+ Agent Log
22
+ </span>
23
+ </div>
24
+ <span className="text-[10px] text-muted-foreground/60 font-mono">
25
+ {cycles.length}
26
+ </span>
27
+ </div>
28
+
29
+ <ScrollArea className="flex-1">
30
+ {cycles.length === 0 && (
31
+ <p className="text-xs text-muted-foreground/40 text-center py-8">
32
+ No cycles yet
33
+ </p>
34
+ )}
35
+ <div className="divide-y divide-border/30">
36
+ {cycles.map((c) => (
37
+ <button
38
+ key={c.id}
39
+ onClick={() => setExpanded(expanded === c.id ? null : c.id)}
40
+ className="w-full text-left px-3 py-2 hover:bg-secondary/30 transition-colors"
41
+ >
42
+ <div className="flex items-center gap-2">
43
+ <span
44
+ className={`w-1.5 h-1.5 rounded-full shrink-0 ${
45
+ c.status === "ok" ? "bg-primary" : "bg-destructive"
46
+ }`}
47
+ />
48
+ <span className="text-[10px] text-muted-foreground/60 font-mono">
49
+ {c.timestamp.slice(11, 19)}
50
+ </span>
51
+ <span className="text-[10px] text-muted-foreground/40">
52
+ {c.duration_s}s
53
+ </span>
54
+ {c.trades_made > 0 && (
55
+ <span className="text-[9px] text-primary font-medium ml-auto">
56
+ {c.trades_made} trade{c.trades_made > 1 ? "s" : ""}
57
+ </span>
58
+ )}
59
+ </div>
60
+ {expanded === c.id && c.output_summary && (
61
+ <p className="text-[10px] text-muted-foreground/60 mt-1.5 leading-relaxed whitespace-pre-wrap">
62
+ {c.output_summary.slice(0, 500)}
63
+ </p>
64
+ )}
65
+ </button>
66
+ ))}
67
+ </div>
68
+ </ScrollArea>
69
+ </div>
70
+ );
71
+ }