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,150 @@
1
+ """
2
+ Open Trademaxxxing - Streamlit dashboard.
3
+
4
+ Run: streamlit run gossip/dashboard.py
5
+
6
+ Real-time view of: portfolio, open positions, trade history,
7
+ news feed, market snapshots, and agent cycle logs.
8
+ All data from SQLite (data/gossip.db).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ import streamlit as st
17
+
18
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
19
+ from gossip.db import GossipDB
20
+
21
+ st.set_page_config(page_title="Open Trademaxxxing", page_icon="📰", layout="wide")
22
+
23
+ @st.cache_resource
24
+ def get_db():
25
+ return GossipDB()
26
+
27
+ db = get_db()
28
+
29
+ st.title("Open Trademaxxxing")
30
+ st.caption("Autonomous prediction market agent — Kalshi")
31
+
32
+ # --- Portfolio metrics ---
33
+
34
+ stats = db.get_stats()
35
+
36
+ col1, col2, col3, col4, col5, col6 = st.columns(6)
37
+ col1.metric("Bankroll", f"${stats['bankroll']:.2f}")
38
+ col2.metric("Total P&L", f"${stats['total_pnl']:+.2f}")
39
+ col3.metric("Trades", stats["total_trades"])
40
+ col4.metric("Win Rate", f"{stats['win_rate']:.0%}" if stats["total_trades"] > 0 else "—")
41
+ col5.metric("Open Positions", stats["open_positions"])
42
+ col6.metric("Agent Cycles", stats["total_agent_cycles"])
43
+
44
+ st.divider()
45
+
46
+ # --- Tabs ---
47
+
48
+ tab_positions, tab_history, tab_news, tab_markets, tab_agent = st.tabs([
49
+ "Open Positions", "Trade History", "News Feed", "Market Snapshots", "Agent Log"
50
+ ])
51
+
52
+ # --- Open Positions ---
53
+
54
+ with tab_positions:
55
+ positions = db.get_open_positions()
56
+ if positions:
57
+ for p in positions:
58
+ with st.container():
59
+ c1, c2, c3, c4, c5 = st.columns([3, 1, 1, 1, 1])
60
+ c1.write(f"**{p['ticker']}**")
61
+ c2.write(f"{p['side'].upper()} x{p['contracts']}")
62
+ c3.write(f"Entry: ${p['entry_price']:.2f}")
63
+ c4.write(f"Edge: {p['edge']:+.1%}" if p['edge'] else "—")
64
+ c5.write(f"{p['confidence']}" if p.get('confidence') else "—")
65
+ if p.get("title"):
66
+ st.caption(p["title"])
67
+ if p.get("reasoning"):
68
+ with st.expander("Reasoning"):
69
+ st.write(p["reasoning"])
70
+ st.divider()
71
+ else:
72
+ st.info("No open positions")
73
+
74
+ # --- Trade History ---
75
+
76
+ with tab_history:
77
+ trades = db.get_trade_history(limit=30)
78
+ if trades:
79
+ for t in trades:
80
+ icon = "✅" if t.get("outcome") == "win" else ("❌" if t.get("outcome") == "loss" else "⏳")
81
+ pnl_str = f"P&L: ${t.get('pnl', 0):+.2f}" if t.get("settled") else "OPEN"
82
+ st.write(f"{icon} **{t['ticker']}** — {t['side'].upper()} x{t['contracts']} @ ${t['entry_price']:.2f} → {pnl_str}")
83
+ if t.get("reasoning"):
84
+ with st.expander("Details"):
85
+ st.write(f"**Reasoning:** {t['reasoning']}")
86
+ if t.get("news_trigger"):
87
+ st.write(f"**News trigger:** {t['news_trigger']}")
88
+ st.write(f"**Confidence:** {t.get('confidence', '?')} | **Edge:** {t.get('edge', 0):+.1%}")
89
+ st.write(f"**Time:** {t['timestamp'][:19]}")
90
+ else:
91
+ st.info("No trades yet")
92
+
93
+ # --- News Feed ---
94
+
95
+ with tab_news:
96
+ news = db.get_recent_news(limit=50)
97
+ if news:
98
+ st.write(f"**{len(news)} recent articles**")
99
+ for n in news:
100
+ source_badge = f"`{n.get('source', '?')}`"
101
+ keyword_badge = f"`{n.get('keyword', '')}`" if n.get("keyword") else ""
102
+ title = n.get("title", "Untitled")
103
+ url = n.get("url", "")
104
+ snippet = n.get("snippet", "")
105
+
106
+ if url:
107
+ st.write(f"{source_badge} {keyword_badge} [{title}]({url})")
108
+ else:
109
+ st.write(f"{source_badge} {keyword_badge} {title}")
110
+ if snippet:
111
+ st.caption(snippet[:200])
112
+ else:
113
+ st.info("No news scraped yet")
114
+
115
+ # --- Market Snapshots ---
116
+
117
+ with tab_markets:
118
+ snapshots = db.get_latest_snapshots(limit=30)
119
+ if snapshots:
120
+ import pandas as pd
121
+ df = pd.DataFrame(snapshots)
122
+ cols_to_show = ["ticker", "title", "category", "yes_bid", "yes_ask", "mid", "volume", "open_interest"]
123
+ available_cols = [c for c in cols_to_show if c in df.columns]
124
+ st.dataframe(df[available_cols], use_container_width=True, hide_index=True)
125
+ else:
126
+ st.info("No market snapshots yet — run a scan first")
127
+
128
+ # --- Agent Log ---
129
+
130
+ with tab_agent:
131
+ cycles = db.get_recent_cycles(limit=20)
132
+ if cycles:
133
+ for c in cycles:
134
+ status_icon = "🟢" if c.get("status") == "ok" else "🔴"
135
+ ts = (c.get("timestamp") or "")[:19]
136
+ dur = c.get("duration_s", "?")
137
+ st.write(f"{status_icon} **{ts}** — {dur}s | Markets: {c.get('markets_scanned', 0)} | News: {c.get('news_scraped', 0)} | Trades: {c.get('trades_made', 0)}")
138
+ if c.get("output_summary"):
139
+ with st.expander("Output"):
140
+ st.write(c["output_summary"][:500])
141
+ else:
142
+ st.info("No agent cycles yet")
143
+
144
+ # --- Refresh ---
145
+
146
+ st.sidebar.button("🔄 Refresh", use_container_width=True)
147
+ st.sidebar.divider()
148
+ st.sidebar.write(f"**DB:** {db.db_path}")
149
+ st.sidebar.write(f"**News articles:** {stats['total_news_articles']}")
150
+ st.sidebar.write(f"**Market snapshots:** {stats['total_market_snapshots']}")
package/gossip/db.py ADDED
@@ -0,0 +1,358 @@
1
+ """
2
+ SQLite database for all Open Trademaxxxing state.
3
+
4
+ Stores: trades, news articles, market snapshots, agent cycle logs.
5
+ Single file at data/gossip.db — zero config, works with Streamlit.
6
+
7
+ Usage from other modules:
8
+ from gossip.db import GossipDB
9
+ db = GossipDB()
10
+ db.insert_trade(...)
11
+ db.get_open_positions()
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import sqlite3
19
+ from datetime import datetime, timezone
20
+ from pathlib import Path
21
+
22
+ DATA_DIR = Path(__file__).resolve().parent.parent / "data"
23
+ DB_PATH = DATA_DIR / "gossip.db"
24
+
25
+
26
+ class GossipDB:
27
+ def __init__(self, db_path: Path | str | None = None):
28
+ self.db_path = Path(db_path) if db_path else DB_PATH
29
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
30
+ self.conn = sqlite3.connect(str(self.db_path))
31
+ self.conn.row_factory = sqlite3.Row
32
+ self.conn.execute("PRAGMA journal_mode=WAL")
33
+ self.conn.execute("PRAGMA foreign_keys=ON")
34
+ self._create_tables()
35
+
36
+ def _create_tables(self):
37
+ self.conn.executescript("""
38
+ CREATE TABLE IF NOT EXISTS trades (
39
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
40
+ timestamp TEXT NOT NULL,
41
+ ticker TEXT NOT NULL,
42
+ title TEXT,
43
+ category TEXT,
44
+ side TEXT NOT NULL,
45
+ action TEXT NOT NULL DEFAULT 'buy',
46
+ contracts INTEGER NOT NULL,
47
+ entry_price REAL NOT NULL,
48
+ cost REAL NOT NULL,
49
+ fee REAL NOT NULL DEFAULT 0,
50
+ estimated_prob REAL,
51
+ edge REAL,
52
+ confidence TEXT,
53
+ reasoning TEXT,
54
+ news_trigger TEXT,
55
+ sources TEXT,
56
+ settled INTEGER NOT NULL DEFAULT 0,
57
+ outcome TEXT DEFAULT '',
58
+ pnl REAL DEFAULT 0,
59
+ exit_price REAL,
60
+ exit_reasoning TEXT,
61
+ exit_timestamp TEXT
62
+ );
63
+
64
+ CREATE TABLE IF NOT EXISTS portfolio (
65
+ id INTEGER PRIMARY KEY CHECK (id = 1),
66
+ bankroll REAL NOT NULL DEFAULT 30.0,
67
+ total_pnl REAL NOT NULL DEFAULT 0.0,
68
+ total_trades INTEGER NOT NULL DEFAULT 0,
69
+ wins INTEGER NOT NULL DEFAULT 0,
70
+ losses INTEGER NOT NULL DEFAULT 0,
71
+ updated_at TEXT
72
+ );
73
+
74
+ CREATE TABLE IF NOT EXISTS news (
75
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
76
+ timestamp TEXT NOT NULL,
77
+ source TEXT,
78
+ keyword TEXT,
79
+ title TEXT,
80
+ url TEXT,
81
+ snippet TEXT,
82
+ full_text TEXT,
83
+ relevance_score REAL,
84
+ matched_ticker TEXT,
85
+ processed INTEGER NOT NULL DEFAULT 0
86
+ );
87
+
88
+ CREATE TABLE IF NOT EXISTS market_snapshots (
89
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
90
+ timestamp TEXT NOT NULL,
91
+ ticker TEXT NOT NULL,
92
+ title TEXT,
93
+ category TEXT,
94
+ yes_bid REAL,
95
+ yes_ask REAL,
96
+ mid REAL,
97
+ volume REAL,
98
+ open_interest REAL,
99
+ close_time TEXT
100
+ );
101
+
102
+ CREATE TABLE IF NOT EXISTS agent_logs (
103
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
104
+ timestamp TEXT NOT NULL,
105
+ cycle_number INTEGER,
106
+ session_id TEXT,
107
+ duration_s REAL,
108
+ status TEXT,
109
+ markets_scanned INTEGER DEFAULT 0,
110
+ news_scraped INTEGER DEFAULT 0,
111
+ trades_made INTEGER DEFAULT 0,
112
+ output_summary TEXT
113
+ );
114
+
115
+ CREATE INDEX IF NOT EXISTS idx_trades_ticker ON trades(ticker);
116
+ CREATE INDEX IF NOT EXISTS idx_trades_settled ON trades(settled);
117
+ CREATE INDEX IF NOT EXISTS idx_news_timestamp ON news(timestamp);
118
+ CREATE INDEX IF NOT EXISTS idx_news_keyword ON news(keyword);
119
+ CREATE INDEX IF NOT EXISTS idx_snapshots_ticker ON market_snapshots(ticker);
120
+ CREATE INDEX IF NOT EXISTS idx_snapshots_timestamp ON market_snapshots(timestamp);
121
+ """)
122
+
123
+ # Ensure portfolio row exists
124
+ row = self.conn.execute("SELECT COUNT(*) FROM portfolio").fetchone()
125
+ if row[0] == 0:
126
+ bankroll = float(os.getenv("BANKROLL", "15.0"))
127
+ self.conn.execute(
128
+ "INSERT INTO portfolio (id, bankroll, updated_at) VALUES (1, ?, ?)",
129
+ (bankroll, _now()),
130
+ )
131
+ self.conn.commit()
132
+
133
+ # --- Portfolio ---
134
+
135
+ def get_portfolio(self) -> dict:
136
+ row = self.conn.execute("SELECT * FROM portfolio WHERE id=1").fetchone()
137
+ return dict(row)
138
+
139
+ def update_portfolio(self, **kwargs) -> None:
140
+ kwargs["updated_at"] = _now()
141
+ sets = ", ".join(f"{k}=?" for k in kwargs)
142
+ vals = list(kwargs.values())
143
+ self.conn.execute(f"UPDATE portfolio SET {sets} WHERE id=1", vals)
144
+ self.conn.commit()
145
+
146
+ # --- Trades ---
147
+
148
+ def insert_trade(self, **kwargs) -> int:
149
+ if "sources" in kwargs and isinstance(kwargs["sources"], list):
150
+ kwargs["sources"] = json.dumps(kwargs["sources"])
151
+ kwargs.setdefault("timestamp", _now())
152
+
153
+ cols = ", ".join(kwargs.keys())
154
+ placeholders = ", ".join("?" * len(kwargs))
155
+ cur = self.conn.execute(
156
+ f"INSERT INTO trades ({cols}) VALUES ({placeholders})",
157
+ list(kwargs.values()),
158
+ )
159
+ self.conn.commit()
160
+
161
+ # Update portfolio
162
+ p = self.get_portfolio()
163
+ cost = kwargs.get("cost", 0) + kwargs.get("fee", 0)
164
+ self.update_portfolio(
165
+ bankroll=round(p["bankroll"] - cost, 2),
166
+ total_trades=p["total_trades"] + 1,
167
+ )
168
+ return cur.lastrowid
169
+
170
+ def get_open_positions(self) -> list[dict]:
171
+ rows = self.conn.execute(
172
+ "SELECT * FROM trades WHERE settled=0 AND action='buy' ORDER BY timestamp DESC"
173
+ ).fetchall()
174
+ return [dict(r) for r in rows]
175
+
176
+ def get_trade_history(self, limit: int = 50) -> list[dict]:
177
+ rows = self.conn.execute(
178
+ "SELECT * FROM trades ORDER BY timestamp DESC LIMIT ?", (limit,)
179
+ ).fetchall()
180
+ return [dict(r) for r in rows]
181
+
182
+ def settle_trade(self, ticker: str, outcome_yes: bool) -> dict | None:
183
+ row = self.conn.execute(
184
+ "SELECT * FROM trades WHERE ticker=? AND settled=0 ORDER BY timestamp DESC LIMIT 1",
185
+ (ticker,),
186
+ ).fetchone()
187
+ if not row:
188
+ return None
189
+
190
+ trade = dict(row)
191
+ won = (trade["side"] == "yes" and outcome_yes) or (trade["side"] == "no" and not outcome_yes)
192
+ if won:
193
+ pnl = round((1.0 - trade["entry_price"]) * trade["contracts"], 2)
194
+ outcome = "win"
195
+ else:
196
+ pnl = round(-trade["entry_price"] * trade["contracts"], 2)
197
+ outcome = "loss"
198
+
199
+ self.conn.execute(
200
+ "UPDATE trades SET settled=1, outcome=?, pnl=?, exit_timestamp=? WHERE id=?",
201
+ (outcome, pnl, _now(), trade["id"]),
202
+ )
203
+ self.conn.commit()
204
+
205
+ p = self.get_portfolio()
206
+ bankroll = round(p["bankroll"] + trade["entry_price"] * trade["contracts"] + pnl, 2)
207
+ wins = p["wins"] + (1 if won else 0)
208
+ losses = p["losses"] + (0 if won else 1)
209
+ self.update_portfolio(bankroll=bankroll, total_pnl=round(p["total_pnl"] + pnl, 2), wins=wins, losses=losses)
210
+
211
+ return {"ticker": ticker, "outcome": outcome, "pnl": pnl, "bankroll": bankroll}
212
+
213
+ def exit_trade(self, ticker: str, exit_price: float, reasoning: str = "") -> dict | None:
214
+ row = self.conn.execute(
215
+ "SELECT * FROM trades WHERE ticker=? AND settled=0 ORDER BY timestamp DESC LIMIT 1",
216
+ (ticker,),
217
+ ).fetchone()
218
+ if not row:
219
+ return None
220
+
221
+ trade = dict(row)
222
+ pnl = round((exit_price - trade["entry_price"]) * trade["contracts"], 2)
223
+ outcome = "win" if pnl > 0 else "loss"
224
+
225
+ self.conn.execute(
226
+ "UPDATE trades SET settled=1, outcome=?, pnl=?, exit_price=?, exit_reasoning=?, exit_timestamp=? WHERE id=?",
227
+ (outcome, pnl, exit_price, reasoning, _now(), trade["id"]),
228
+ )
229
+ self.conn.commit()
230
+
231
+ p = self.get_portfolio()
232
+ proceeds = exit_price * trade["contracts"]
233
+ bankroll = round(p["bankroll"] + proceeds, 2)
234
+ wins = p["wins"] + (1 if pnl > 0 else 0)
235
+ losses = p["losses"] + (0 if pnl > 0 else 1)
236
+ self.update_portfolio(bankroll=bankroll, total_pnl=round(p["total_pnl"] + pnl, 2), wins=wins, losses=losses)
237
+
238
+ return {"ticker": ticker, "outcome": outcome, "pnl": pnl, "exit_price": exit_price, "bankroll": bankroll}
239
+
240
+ # --- News ---
241
+
242
+ def insert_news(self, articles: list[dict]) -> int:
243
+ ts = _now()
244
+ count = 0
245
+ for a in articles:
246
+ self.conn.execute(
247
+ "INSERT INTO news (timestamp, source, keyword, title, url, snippet, full_text) VALUES (?,?,?,?,?,?,?)",
248
+ (ts, a.get("source", ""), a.get("keyword", ""), a.get("title", ""),
249
+ a.get("url", ""), a.get("snippet", ""), a.get("text", "")),
250
+ )
251
+ count += 1
252
+ self.conn.commit()
253
+ return count
254
+
255
+ def get_recent_news(self, hours: int = 24, limit: int = 100) -> list[dict]:
256
+ rows = self.conn.execute(
257
+ "SELECT * FROM news ORDER BY timestamp DESC LIMIT ?", (limit,)
258
+ ).fetchall()
259
+ return [dict(r) for r in rows]
260
+
261
+ def get_news_for_ticker(self, ticker: str, limit: int = 20) -> list[dict]:
262
+ rows = self.conn.execute(
263
+ "SELECT * FROM news WHERE matched_ticker=? ORDER BY timestamp DESC LIMIT ?",
264
+ (ticker, limit),
265
+ ).fetchall()
266
+ return [dict(r) for r in rows]
267
+
268
+ # --- Market snapshots ---
269
+
270
+ def insert_market_snapshot(self, market: dict) -> None:
271
+ self.conn.execute(
272
+ """INSERT INTO market_snapshots
273
+ (timestamp, ticker, title, category, yes_bid, yes_ask, mid, volume, open_interest, close_time)
274
+ VALUES (?,?,?,?,?,?,?,?,?,?)""",
275
+ (_now(), market.get("ticker", ""), market.get("title", ""),
276
+ market.get("category", ""), market.get("yes_bid", 0),
277
+ market.get("yes_ask", 0), market.get("mid", 0),
278
+ market.get("volume", 0), market.get("open_interest", 0),
279
+ market.get("close_time", "")),
280
+ )
281
+ self.conn.commit()
282
+
283
+ def insert_market_snapshots(self, markets: list[dict]) -> int:
284
+ ts = _now()
285
+ for m in markets:
286
+ self.conn.execute(
287
+ """INSERT INTO market_snapshots
288
+ (timestamp, ticker, title, category, yes_bid, yes_ask, mid, volume, open_interest, close_time)
289
+ VALUES (?,?,?,?,?,?,?,?,?,?)""",
290
+ (ts, m.get("ticker", ""), m.get("title", ""),
291
+ m.get("category", ""), m.get("yes_bid", 0),
292
+ m.get("yes_ask", 0), m.get("mid", 0),
293
+ m.get("volume", 0), m.get("open_interest", 0),
294
+ m.get("close_time", "")),
295
+ )
296
+ self.conn.commit()
297
+ return len(markets)
298
+
299
+ def get_market_history(self, ticker: str, limit: int = 100) -> list[dict]:
300
+ rows = self.conn.execute(
301
+ "SELECT * FROM market_snapshots WHERE ticker=? ORDER BY timestamp DESC LIMIT ?",
302
+ (ticker, limit),
303
+ ).fetchall()
304
+ return [dict(r) for r in rows]
305
+
306
+ def get_latest_snapshots(self, limit: int = 50) -> list[dict]:
307
+ rows = self.conn.execute(
308
+ """SELECT ms.* FROM market_snapshots ms
309
+ INNER JOIN (SELECT ticker, MAX(timestamp) as max_ts FROM market_snapshots GROUP BY ticker) latest
310
+ ON ms.ticker = latest.ticker AND ms.timestamp = latest.max_ts
311
+ ORDER BY ms.volume DESC LIMIT ?""",
312
+ (limit,),
313
+ ).fetchall()
314
+ return [dict(r) for r in rows]
315
+
316
+ # --- Agent logs ---
317
+
318
+ def log_cycle(self, **kwargs) -> int:
319
+ kwargs.setdefault("timestamp", _now())
320
+ cols = ", ".join(kwargs.keys())
321
+ placeholders = ", ".join("?" * len(kwargs))
322
+ cur = self.conn.execute(
323
+ f"INSERT INTO agent_logs ({cols}) VALUES ({placeholders})",
324
+ list(kwargs.values()),
325
+ )
326
+ self.conn.commit()
327
+ return cur.lastrowid
328
+
329
+ def get_recent_cycles(self, limit: int = 20) -> list[dict]:
330
+ rows = self.conn.execute(
331
+ "SELECT * FROM agent_logs ORDER BY timestamp DESC LIMIT ?", (limit,)
332
+ ).fetchall()
333
+ return [dict(r) for r in rows]
334
+
335
+ # --- Stats ---
336
+
337
+ def get_stats(self) -> dict:
338
+ p = self.get_portfolio()
339
+ open_count = self.conn.execute("SELECT COUNT(*) FROM trades WHERE settled=0 AND action='buy'").fetchone()[0]
340
+ total_news = self.conn.execute("SELECT COUNT(*) FROM news").fetchone()[0]
341
+ total_snapshots = self.conn.execute("SELECT COUNT(*) FROM market_snapshots").fetchone()[0]
342
+ total_cycles = self.conn.execute("SELECT COUNT(*) FROM agent_logs").fetchone()[0]
343
+
344
+ return {
345
+ **p,
346
+ "open_positions": open_count,
347
+ "total_news_articles": total_news,
348
+ "total_market_snapshots": total_snapshots,
349
+ "total_agent_cycles": total_cycles,
350
+ "win_rate": round(p["wins"] / p["total_trades"], 3) if p["total_trades"] > 0 else 0,
351
+ }
352
+
353
+ def close(self):
354
+ self.conn.close()
355
+
356
+
357
+ def _now() -> str:
358
+ return datetime.now(timezone.utc).isoformat()