egoh33 commited on
Commit
e85a672
·
verified ·
1 Parent(s): 098c418

Deploy IBKR Workbench with Hugging Face daily data

Browse files
Dockerfile ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hugging Face Docker Space: build the React UI, then serve it with FastAPI.
2
+ FROM node:22-alpine AS frontend-builder
3
+
4
+ WORKDIR /app/frontend
5
+ COPY frontend/package.json frontend/package-lock.json ./
6
+ RUN npm ci
7
+ COPY frontend/ ./
8
+ RUN npm run build
9
+
10
+ FROM python:3.12-slim AS runtime
11
+
12
+ WORKDIR /app
13
+ RUN useradd -m -u 1000 user
14
+
15
+ COPY requirements-space.txt ./
16
+ RUN pip install --no-cache-dir -r requirements-space.txt
17
+
18
+ COPY api/ ./api/
19
+ COPY db/ ./db/
20
+ COPY etl/__init__.py etl/chat_engine.py ./etl/
21
+ COPY scripts/ ./scripts/
22
+ COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
23
+
24
+ RUN mkdir -p /app/data && chown -R user:user /app
25
+
26
+ ENV DB_PATH=/app/data/equity.duckdb \
27
+ HF_DATASET_REPO=egoh33/ibkr-daily-stock-data \
28
+ PYTHONUNBUFFERED=1
29
+
30
+ EXPOSE 7860
31
+ USER user
32
+
33
+ CMD ["sh", "-c", "python -m scripts.bootstrap_hf_data && exec uvicorn api.app:app --host 0.0.0.0 --port 7860"]
README.md CHANGED
@@ -1,10 +1,373 @@
1
  ---
2
  title: IBKR Workbench
3
- emoji: 📊
4
- colorFrom: blue
5
  colorTo: gray
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: IBKR Workbench
3
+ emoji: 📈
4
+ colorFrom: yellow
5
  colorTo: gray
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
  ---
10
 
11
+ # Equity Workbench
12
+
13
+ A full-stack quantitative research platform — live market data from **Interactive Brokers**, historical **OHLCV + options bars from Polygon.io**, **SEC EDGAR financials**, and an **AI chat interface** to query it all in plain English.
14
+
15
+ ---
16
+
17
+ ## What it does
18
+
19
+ | Layer | What's built |
20
+ |---|---|
21
+ | **Data ingestion** | ETL pipeline pulling from IBKR TWS, Polygon.io, SEC EDGAR, Finviz, and **CFTC COT** |
22
+ | **Storage** | DuckDB (`equity.duckdb`) — 14 tables covering equities, options, forex, futures, indices, and COT |
23
+ | **Vector search** | DuckDB VSS (`vectors.duckdb`) — HNSW index on 384-dim ticker embeddings |
24
+ | **Dashboard** | Streamlit + Plotly — 8 pages including candlestick charts, options chain, and COT positioning |
25
+ | **AI chat** | Text-to-SQL (**DeepSeek / Xiaomi MiMo**) + RAG over EDGAR financials |
26
+ | **Deployment** | Docker Compose — separate dashboard and ETL containers |
27
+
28
+ ---
29
+
30
+ ## Architecture
31
+
32
+ ```
33
+ Data Sources ETL Pipeline Storage
34
+ ──────────── ──────────── ───────
35
+ IBKR TWS API ────────► extract_stocks.py ─────► equity.duckdb
36
+ ────────► extract_options.py ────► stock_quotes
37
+ Polygon.io ────────► extract_polygon.py ─────► option_quotes / option_chains
38
+ ────────► bars, snapshots ─────► polygon_bars
39
+ ────────► options bars ─────► polygon_option_bars
40
+ ────────► option snapshots ─────► polygon_option_snapshots
41
+ ────────► reference ─────► polygon_tickers / snapshots
42
+ SEC EDGAR ────────► extract_edgar.py ─────► edgar_filings / edgar_facts
43
+ CFTC COT ────────► extract_cot.py ─────► cot_reports
44
+ Finviz ────────► update_tickers.py ─────► config/tickers.yaml (11k+ tickers)
45
+ ┌────────────────────────────────┐
46
+ │ vectors.duckdb │
47
+ embed_tickers.py ──────────────────────────────► │ ticker_embeddings (HNSW) │
48
+ └────────────────────────────────┘
49
+
50
+ Interfaces
51
+ ──────────
52
+ equity.duckdb ──► Streamlit Dashboard (8 pages, Plotly charts)
53
+ ──► chat_engine.py (Text-to-SQL via DeepSeek / Xiaomi MiMo)
54
+ vectors.duckdb ► rag_engine.py (LangChain RAG — EDGAR + descriptions)
55
+ query.py ──► Python API (latest_stock_quotes, stock_history, etl_run_log…)
56
+ ```
57
+
58
+ ---
59
+
60
+ ## Prerequisites
61
+
62
+ | Requirement | Notes |
63
+ |---|---|
64
+ | Python 3.11+ | |
65
+ | TWS or IB Gateway | For live IBKR data only — all other jobs work without it |
66
+ | Polygon.io API key | Free tier: 5 req/min; Starter $29/mo for full speed |
67
+ | DeepSeek / Xiaomi key | For the AI chat interface |
68
+ | Docker (optional) | For containerised deployment |
69
+
70
+ ---
71
+
72
+ ## Quick Start
73
+
74
+ ```bash
75
+ # 1. Install dependencies
76
+ pip install -r requirements.txt
77
+
78
+ # 2. Configure
79
+ cp .env.example .env
80
+ # Fill in: POLYGON_API_KEY, DEEPSEEK_API_KEY (or set CHAT_PROVIDER=mimo for Xiaomi)
81
+
82
+ # 3. Fetch 11,000+ US tickers from Finviz
83
+ python -m config.update_tickers
84
+
85
+ # 4. Download full history from Polygon (stocks + options)
86
+ python main.py --job polygon-ref # ticker metadata
87
+ python main.py --job polygon-bars # OHLCV + VWAP daily bars (max history)
88
+ python main.py --job polygon-option-bars # historical options bars
89
+
90
+ # 5. Pull SEC EDGAR financials
91
+ python main.py --job edgar-filings
92
+ python main.py --job edgar-facts
93
+
94
+ # 6. Build vector index for AI chat
95
+ python main.py --job embed-tickers
96
+
97
+ # 7. Launch the dashboard
98
+ streamlit run dashboard/app.py
99
+ ```
100
+
101
+ Or with Docker:
102
+ ```bash
103
+ docker compose up --build
104
+ # Dashboard → http://localhost:8501
105
+ ```
106
+
107
+ ---
108
+
109
+ ## Configuration (`.env`)
110
+
111
+ ### IBKR
112
+ | Variable | Default | Description |
113
+ |---|---|---|
114
+ | `TWS_HOST` | `127.0.0.1` | TWS/Gateway host |
115
+ | `TWS_PORT` | `7497` | 7497 = paper, 7496 = live, 4002 = Gateway |
116
+ | `TWS_CLIENT_ID` | `1` | Unique client ID |
117
+ | `OPTIONS_EXPIRY_CYCLES` | `2` | Nearest N expiries to quote |
118
+
119
+ ### Polygon.io
120
+ | Variable | Default | Description |
121
+ |---|---|---|
122
+ | `POLYGON_API_KEY` | *(required)* | From polygon.io/dashboard/api-keys |
123
+ | `POLYGON_BARS_TIMESPAN` | `day` | `second` / `minute` / `hour` / `day` |
124
+ | `POLYGON_BARS_LOOKBACK` | `9500` | Days of history (~26 years max) |
125
+ | `POLYGON_RATE_DELAY` | `13` | Seconds between calls (free=13, paid=0.1) |
126
+ | `POLYGON_OPTION_BARS_TICKERS` | 12 liquid names | Comma-separated underlyings for options history |
127
+ | `POLYGON_OPTION_BARS_MAX_CONTRACTS` | `250` | Max contracts per underlying |
128
+
129
+ ### AI Chat
130
+ | Variable | Default | Description |
131
+ |---|---|---|
132
+ | `CHAT_PROVIDER` | `deepseek` | `deepseek` / `mimo` |
133
+ | `CHAT_MODEL` | *(provider default)* | Override model name |
134
+ | `DEEPSEEK_API_KEY` | | platform.deepseek.com |
135
+ | `OLLAMA_BASE_URL` | `http://localhost:11434/v1` | Local Ollama endpoint for Xiaomi MiMo |
136
+ | `OLLAMA_MODEL` | `xiaomi/MiMo-7B-RL` | Xiaomi model pulled via `ollama pull` |
137
+
138
+ ### Storage
139
+ | Variable | Default | Description |
140
+ |---|---|---|
141
+ | `DB_PATH` | `./data/equity.duckdb` | Main DuckDB database |
142
+ | `DUCKDB_PATH` | `./data/vectors.duckdb` | Vector store |
143
+ | `TICKERS_YAML` | `config/tickers.yaml` | Ticker universe |
144
+
145
+ ---
146
+
147
+ ## ETL Jobs
148
+
149
+ ```bash
150
+ # ── Polygon.io (no TWS needed) ────────────────────────────────────────────────
151
+ python main.py --job polygon-ref # ticker metadata (name, exchange, description)
152
+ python main.py --job polygon-bars # OHLCV + VWAP daily bars (full history)
153
+ python main.py --job polygon-quotes # delayed stock snapshots (bid/ask/last)
154
+ python main.py --job polygon-options # options chain snapshots + Greeks
155
+ python main.py --job polygon-option-bars # historical OHLCV bars per options contract
156
+ python main.py --job polygon # all polygon jobs
157
+
158
+ # ── SEC EDGAR (no API key needed) ─────────────────────────────────────────────
159
+ python main.py --job edgar-filings # 10-K / 10-Q / 8-K filing history
160
+ python main.py --job edgar-facts # XBRL financials (revenue, EPS, assets…)
161
+
162
+ # ── CFTC COT (no API key needed) ──────────────────────────────────────────────
163
+ python main.py --job cot # Commitments of Traders (Legacy Futures)
164
+
165
+ # ── AI / Vector search ────────────────────────────────────────────────────────
166
+ python main.py --job embed-tickers # embed ticker descriptions → HNSW index
167
+
168
+ # ── IBKR live data (requires TWS running) ────────────────────────────────────
169
+ python main.py --job stocks # live stock quotes
170
+ python main.py --job options # live option quotes
171
+ python main.py --job chain # refresh option chain metadata
172
+ python main.py --schedule # continuous mode
173
+
174
+ # ── Ticker universe ───────────────────────────────────────────────────────────
175
+ python -m config.update_tickers # fetch all ~11,000 US stocks from Finviz
176
+ python -m config.update_tickers --sectors Technology Healthcare
177
+ python -m config.update_tickers --dry-run
178
+ ```
179
+
180
+ ---
181
+
182
+ ## Dashboard
183
+
184
+ ```bash
185
+ streamlit run dashboard/app.py
186
+ ```
187
+
188
+ | Page | Description |
189
+ |---|---|
190
+ | 💬 **Chat** | Natural language queries — SQL mode (Text-to-SQL) or RAG mode (knowledge base) |
191
+ | 📊 **Stock Quotes** | Live IBKR prices, bid-ask spreads, volume |
192
+ | 📉 **Price History** | Candlestick / OHLC / line chart with bid-ask band and volume |
193
+ | 📦 **Polygon OHLCV** | Full-history daily bars with VWAP overlay and period return stats |
194
+ | 🔗 **Options Chain** | IV smile, Greeks heatmap, OI/volume charts, full chain table |
195
+ | 💸 **Cost Calculator** | Round-trip slippage model — spread + IBKR commission + market impact |
196
+ | 🩺 **ETL Health** | Job run log, row counts per table, data freshness per ticker (including COT) |
197
+ | ℹ️ **About** | Platform overview, data source status, quick reference |
198
+
199
+ ---
200
+
201
+ ## AI Chat
202
+
203
+ Two modes available on the Chat page:
204
+
205
+ **SQL mode** — converts your question to DuckDB SQL, executes it, and summarises the result:
206
+ > *"Show AAPL closing prices for the last 30 days"*
207
+ > *"Which 10 tickers had the highest average volume last month?"*
208
+ > *"What was NVDA's revenue for the last 4 quarters?"*
209
+
210
+ **RAG mode** — searches the vector index and EDGAR facts, then answers from context:
211
+ > *"What does Nvidia actually do as a business?"*
212
+ > *"Compare Apple and Microsoft's balance sheets"*
213
+ > *"Which companies in the semiconductor sector have the most cash?"*
214
+
215
+ Switch providers with one line in `.env`:
216
+ ```
217
+ CHAT_PROVIDER=deepseek # or: mimo
218
+ ```
219
+
220
+ ---
221
+
222
+ ## Asset Coverage
223
+
224
+ | Asset class | Tickers | Source |
225
+ |---|---|---|
226
+ | US equities | ~11,200 (NYSE + NASDAQ + AMEX) | Finviz |
227
+ | Forex majors + minors | 17 pairs (EUR/USD, GBP/USD…) | IBKR IDEALPRO |
228
+ | Equity index futures | ES, NQ, RTY, YM + micro | CME |
229
+ | Energy futures | CL, NG, RB, HO, BZ | NYMEX |
230
+ | Metals futures | GC, SI, HG, PL, PA | COMEX |
231
+ | Rate futures | ZB, ZN, ZF, ZT | CBOT |
232
+ | Agricultural futures | ZC, ZS, ZW + 5 more | CBOT |
233
+ | FX futures | 6E, 6B, 6J, 6C, 6A, 6S, 6N | CME |
234
+ | Crypto futures | BTC, ETH, MBT, MET | CME |
235
+ | Cash indices | SPX, VIX, NDX, RUT, DJX + global | CBOE |
236
+ | **COT Positioning** | All major futures above (Legacy) | CFTC |
237
+
238
+ ---
239
+
240
+ ## Database Schema
241
+
242
+ All data in `data/equity.duckdb` (14 tables):
243
+
244
+ | Table | Rows (approx) | Description |
245
+ |---|---|---|
246
+ | `stock_quotes` | live | IBKR snapshots — bid/ask/last/OHLCV/VWAP |
247
+ | `option_quotes` | live | IBKR option quotes — bid/ask/Greeks/IV/OI |
248
+ | `option_chains` | live | Chain metadata (expiry × strike × right) |
249
+ | `polygon_bars` | ~5,800/ticker | Daily OHLCV + VWAP, full history |
250
+ | `polygon_option_bars` | varies | Historical OHLCV bars per contract |
251
+ | `polygon_option_snapshots` | point-in-time | Options chain snapshots with Greeks |
252
+ | `polygon_snapshots` | point-in-time | Stock delayed snapshots |
253
+ | `polygon_tickers` | ~11k | Reference — name, exchange, description |
254
+ | `edgar_filings` | varies | 10-K / 10-Q / 8-K filing history |
255
+ | `edgar_facts` | varies | XBRL facts — revenue, EPS, assets, equity |
256
+ | `cot_reports` | weekly | CFTC Commitments of Traders (Legacy Futures Only) |
257
+ | `ticker_embeddings` | ~11k | 384-dim sentence embeddings (HNSW) |
258
+ | `edgar_embeddings` | optional | EDGAR filing text embeddings |
259
+ | `etl_runs` | grows | Audit log of every ETL job |
260
+
261
+ Vector store in `data/vectors.duckdb`.
262
+
263
+ ---
264
+
265
+ ## Docker
266
+
267
+ ```bash
268
+ # Build and start both services
269
+ docker compose up --build
270
+
271
+ # Dashboard only (read-only)
272
+ docker compose up dashboard
273
+
274
+ # ETL only (scheduled)
275
+ docker compose up etl
276
+ ```
277
+
278
+ - **dashboard** → `http://localhost:8501`
279
+ - **etl** → runs `polygon-bars` on `POLL_INTERVAL_SECONDS` schedule
280
+
281
+ Both containers mount `./data/` as a shared volume.
282
+
283
+ ---
284
+
285
+ ## Python API
286
+
287
+ ```python
288
+ import duckdb
289
+ conn = duckdb.connect("data/equity.duckdb", read_only=True)
290
+
291
+ # Full price history for one ticker
292
+ conn.execute("""
293
+ SELECT ts, open, high, low, close, volume, vwap
294
+ FROM polygon_bars WHERE ticker = 'AAPL' AND timespan = 'day'
295
+ ORDER BY ts
296
+ """).df()
297
+
298
+ # Historical options bars
299
+ conn.execute("""
300
+ SELECT option_ticker, ts, open, high, low, close, volume, vwap
301
+ FROM polygon_option_bars
302
+ WHERE underlying = 'SPY' AND right = 'call' AND expiry >= '2024-01-01'
303
+ ORDER BY option_ticker, ts
304
+ """).df()
305
+
306
+ # Latest EDGAR revenue
307
+ conn.execute("""
308
+ SELECT ticker, period_end, value AS revenue
309
+ FROM edgar_facts
310
+ WHERE concept = 'Revenues' AND form_type = '10-K'
311
+ ORDER BY ticker, period_end DESC
312
+ """).df()
313
+ ```
314
+
315
+ Query helpers:
316
+ ```python
317
+ from query import latest_stock_quotes, stock_history, latest_option_quotes, etl_run_log
318
+ from etl.embed_tickers import search_similar_tickers
319
+
320
+ search_similar_tickers("semiconductor AI chip manufacturer", top_k=10)
321
+ ```
322
+
323
+ ---
324
+
325
+ ## Project Structure
326
+
327
+ ```
328
+ equity_workbench/
329
+ ├── main.py # Entry point — all ETL jobs + scheduler
330
+ ├── query.py # Query helpers + CLI summary
331
+ ├── rag_engine.py # LangChain RAG pipeline
332
+ ├── requirements.txt
333
+ ├── Dockerfile.dashboard # Streamlit container
334
+ ├── Dockerfile.etl # ETL container
335
+ ├── docker-compose.yml
336
+
337
+ ├── config/
338
+ │ ├── tickers.yaml # 11k+ tickers across all asset classes
339
+ │ ├── tickers.py # Loader — get_all_tickers()
340
+ │ └── update_tickers.py # Finviz scraper with checkpoint/resume
341
+
342
+ ├── db/
343
+ │ ├── database.py # DuckDB schema + connection (equity.duckdb)
344
+ │ └── vector_store.py # VSS setup reference (now in database.py)
345
+
346
+ ├── etl/
347
+ │ ├── ibkr_client.py # TWS API wrapper — EWrapper + EClient
348
+ │ ├── extract_stocks.py # IBKR stock snapshot ETL
349
+ │ ├── extract_options.py # IBKR option chain + quote ETL
350
+ │ ├── polygon_client.py # Polygon REST client factory
351
+ │ ├── extract_polygon.py # Polygon bars / snapshots / options / reference
352
+ │ ├── extract_edgar.py # SEC EDGAR filings + XBRL facts
353
+ │ ├── embed_tickers.py # Sentence-transformer embeddings → vector store
354
+ │ ├── chat_engine.py # Text-to-SQL with read-only SQL validation
355
+ │ └── slippage.py # Transaction cost model
356
+
357
+ ├── dashboard/
358
+ │ └── app.py # Streamlit + Plotly (8 pages)
359
+
360
+ ├── data/ # Auto-created — DuckDB files live here
361
+ └── logs/ # Daily rotating ETL logs
362
+ ```
363
+
364
+ ---
365
+
366
+ ## Tips
367
+
368
+ - **Paid Polygon plan** — set `POLYGON_RATE_DELAY=0.1` to cut full-history download from days to ~40 minutes
369
+ - **First IBKR run** — always pass `--refresh-chain` to populate option chain metadata first
370
+ - **Options bars scale** — each contract = 1 API call; use `POLYGON_OPTION_BARS_TICKERS` to limit scope
371
+ - **DuckDB concurrency** — dashboard connects `read_only=True`; only the ETL process writes
372
+ - **EDGAR rate limit** — SEC allows 10 req/s; the ETL sleeps 0.12s automatically
373
+ - **MiMo locally** — `ollama pull xiaomi/MiMo-7B-RL` then set `CHAT_PROVIDER=mimo` for free local AI
api/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """HTTP API package for Equity Workbench."""
api/app.py ADDED
@@ -0,0 +1,682 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI visualization API for the React dashboard."""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+ import os
6
+ from datetime import date, datetime
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import duckdb
11
+ import polars as pl
12
+ from fastapi import FastAPI, HTTPException, Query
13
+ from fastapi.middleware.cors import CORSMiddleware
14
+ from fastapi.responses import FileResponse
15
+
16
+ from etl.chat_engine import chat
17
+
18
+
19
+ DB_PATH = os.getenv("DB_PATH", "./data/equity.duckdb")
20
+ TS_EXACT_DUPLICATE_LIMIT = int(os.getenv("TS_EXACT_DUPLICATE_LIMIT", "1000000"))
21
+
22
+ app = FastAPI(title="Equity Workbench API", version="0.1.0")
23
+ app.add_middleware(
24
+ CORSMiddleware,
25
+ allow_origins=os.getenv("API_CORS_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173").split(","),
26
+ allow_methods=["GET", "POST"],
27
+ allow_headers=["*"],
28
+ )
29
+
30
+
31
+ def _connect() -> duckdb.DuckDBPyConnection:
32
+ return duckdb.connect(DB_PATH, read_only=True)
33
+
34
+
35
+ def _frame(sql: str, params: tuple[Any, ...] = ()) -> pl.DataFrame:
36
+ with _connect() as conn:
37
+ rows = conn.execute(sql, params).fetchall()
38
+ columns = [col[0] for col in conn.description or []]
39
+ return pl.DataFrame(rows, schema=columns, orient="row") if columns else pl.DataFrame()
40
+
41
+
42
+ def _scalar(sql: str, params: tuple[Any, ...] = (), default: Any = None) -> Any:
43
+ try:
44
+ with _connect() as conn:
45
+ row = conn.execute(sql, params).fetchone()
46
+ return row[0] if row else default
47
+ except duckdb.Error:
48
+ return default
49
+
50
+
51
+ def _table_exists(name: str) -> bool:
52
+ return bool(
53
+ _scalar(
54
+ """
55
+ SELECT COUNT(*)
56
+ FROM information_schema.tables
57
+ WHERE table_schema = 'main' AND table_name = ?
58
+ """,
59
+ (name,),
60
+ 0,
61
+ )
62
+ )
63
+
64
+
65
+ def _column_exists(table: str, column: str) -> bool:
66
+ return bool(
67
+ _scalar(
68
+ """
69
+ SELECT COUNT(*)
70
+ FROM information_schema.columns
71
+ WHERE table_schema = 'main' AND table_name = ? AND column_name = ?
72
+ """,
73
+ (table, column),
74
+ 0,
75
+ )
76
+ )
77
+
78
+
79
+ def _table_columns(table: str) -> set[str]:
80
+ if not _table_exists(table):
81
+ return set()
82
+ df = _frame(
83
+ """
84
+ SELECT column_name
85
+ FROM information_schema.columns
86
+ WHERE table_schema = 'main' AND table_name = ?
87
+ """,
88
+ (table,),
89
+ )
90
+ return set(df.get_column("column_name").to_list()) if not df.is_empty() else set()
91
+
92
+
93
+ def _json_value(value: Any) -> Any:
94
+ if isinstance(value, (datetime, date)):
95
+ return value.isoformat()
96
+ if isinstance(value, float) and (math.isnan(value) or math.isinf(value)):
97
+ return None
98
+ return value
99
+
100
+
101
+ def _records(df: pl.DataFrame) -> list[dict[str, Any]]:
102
+ return [{key: _json_value(value) for key, value in row.items()} for row in df.to_dicts()]
103
+
104
+
105
+ def _period_limit(period: str) -> int:
106
+ return {
107
+ "1m": 23,
108
+ "3m": 66,
109
+ "6m": 132,
110
+ "1y": 252,
111
+ "2y": 504,
112
+ "5y": 1260,
113
+ }.get(period, 252)
114
+
115
+
116
+ TIME_SERIES_TABLES = (
117
+ {"name": "polygon_bars", "stage": "bronze", "symbol": "ticker", "time": "ts"},
118
+ {"name": "polygon_option_bars", "stage": "bronze", "symbol": "underlying", "time": "ts"},
119
+ {"name": "silver_stock_features", "stage": "silver", "symbol": "ticker", "time": "trade_date"},
120
+ {"name": "silver_option_greeks", "stage": "silver", "symbol": "underlying", "time": "trade_date"},
121
+ {"name": "silver_option_positioning", "stage": "silver", "symbol": "underlying", "time": "trade_date"},
122
+ {"name": "silver_cot_features", "stage": "silver", "symbol": "ticker", "time": "report_date"},
123
+ {"name": "gold_portfolio", "stage": "gold", "symbol": None, "time": "trade_date"},
124
+ {"name": "gold_trades", "stage": "gold", "symbol": "ticker", "time": "trade_date"},
125
+ )
126
+
127
+
128
+ def _series_profile(
129
+ spec: dict[str, Any],
130
+ include_coverage: bool = False,
131
+ conn: duckdb.DuckDBPyConnection | None = None,
132
+ ) -> dict[str, Any]:
133
+ if conn is None:
134
+ with _connect() as opened:
135
+ return _series_profile(spec, include_coverage=include_coverage, conn=opened)
136
+
137
+ def scalar(sql: str, params: tuple[Any, ...] = (), default: Any = None) -> Any:
138
+ row = conn.execute(sql, params).fetchone()
139
+ return row[0] if row else default
140
+
141
+ table = spec["name"]
142
+ columns = {
143
+ row[0]
144
+ for row in conn.execute(
145
+ """
146
+ SELECT column_name FROM information_schema.columns
147
+ WHERE table_schema = 'main' AND table_name = ?
148
+ """,
149
+ (table,),
150
+ ).fetchall()
151
+ }
152
+ time_col = spec["time"] if spec["time"] in columns else None
153
+ symbol_col = spec["symbol"] if spec.get("symbol") in columns else None
154
+ if not columns:
155
+ return {**spec, "available": False, "status": "missing", "rows": 0, "coverage": []}
156
+
157
+ rows = int(scalar(f"SELECT COUNT(*) FROM {table}", default=0) or 0)
158
+ detailed = rows <= TS_EXACT_DUPLICATE_LIMIT
159
+ profile: dict[str, Any] = {
160
+ **spec,
161
+ "available": True,
162
+ "status": "empty" if rows == 0 else "ready",
163
+ "rows": rows,
164
+ "profile_scope": "exact" if detailed else "inventory_only",
165
+ "symbols": int(scalar(f"SELECT COUNT(DISTINCT {symbol_col}) FROM {table}", default=0) or 0)
166
+ if symbol_col and detailed
167
+ else None,
168
+ "first_timestamp": _json_value(scalar(f"SELECT MIN({time_col}) FROM {table}")) if time_col and detailed else None,
169
+ "last_timestamp": _json_value(scalar(f"SELECT MAX({time_col}) FROM {table}")) if time_col and detailed else None,
170
+ "null_timestamps": int(scalar(f"SELECT COUNT(*) FROM {table} WHERE {time_col} IS NULL", default=0) or 0)
171
+ if time_col and detailed
172
+ else None,
173
+ "duplicate_points": None,
174
+ "duplicate_check": "not_applicable",
175
+ "coverage_check": "not_applicable",
176
+ "coverage": [],
177
+ }
178
+ key_columns = [column for column in (symbol_col, time_col) if column]
179
+ if time_col and detailed:
180
+ keys = ", ".join(key_columns)
181
+ profile["duplicate_points"] = int(
182
+ scalar(
183
+ f"SELECT COALESCE(SUM(n - 1), 0) FROM (SELECT COUNT(*) AS n FROM {table} GROUP BY {keys} HAVING COUNT(*) > 1)",
184
+ default=0,
185
+ )
186
+ or 0
187
+ )
188
+ profile["duplicate_check"] = "exact"
189
+ elif time_col:
190
+ profile["duplicate_check"] = "skipped_large_table"
191
+ if include_coverage and time_col and rows and detailed:
192
+ coverage_rows = conn.execute(
193
+ f"""
194
+ SELECT CAST(DATE_TRUNC('month', TRY_CAST({time_col} AS TIMESTAMP)) AS DATE) AS period,
195
+ COUNT(*) AS rows
196
+ FROM {table}
197
+ WHERE TRY_CAST({time_col} AS TIMESTAMP) IS NOT NULL
198
+ GROUP BY period
199
+ ORDER BY period DESC
200
+ LIMIT 120
201
+ """
202
+ ).fetchall()
203
+ coverage = pl.DataFrame(coverage_rows, schema=["period", "rows"], orient="row") if coverage_rows else pl.DataFrame()
204
+ profile["coverage"] = _records(coverage.sort("period")) if not coverage.is_empty() else []
205
+ profile["coverage_check"] = "exact"
206
+ elif include_coverage and time_col and rows:
207
+ profile["coverage_check"] = "skipped_large_table"
208
+ return profile
209
+
210
+
211
+ def _series_profiles(include_coverage: bool = False) -> list[dict[str, Any]]:
212
+ with _connect() as conn:
213
+ return [_series_profile(spec, include_coverage=include_coverage, conn=conn) for spec in TIME_SERIES_TABLES]
214
+
215
+
216
+ @app.get("/api/health")
217
+ def health() -> dict[str, Any]:
218
+ db_exists = os.path.exists(DB_PATH)
219
+ return {
220
+ "ok": db_exists,
221
+ "db_path": DB_PATH,
222
+ "tables": _scalar("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'main'", default=0)
223
+ if db_exists
224
+ else 0,
225
+ }
226
+
227
+
228
+ @app.get("/api/visualizations/overview")
229
+ def overview() -> dict[str, Any]:
230
+ tables = [
231
+ "polygon_bars",
232
+ "silver_stock_features",
233
+ "silver_option_greeks",
234
+ "silver_option_positioning",
235
+ "cot_reports",
236
+ "edgar_facts",
237
+ "ticker_embeddings",
238
+ ]
239
+ counts = [
240
+ {"table": table, "rows": int(_scalar(f"SELECT COUNT(*) FROM {table}", default=0) or 0)}
241
+ for table in tables
242
+ if _table_exists(table)
243
+ ]
244
+ ret_z = "zscore_ret_20" if _column_exists("silver_stock_features", "zscore_ret_20") else "NULL::DOUBLE"
245
+ movers = _frame(
246
+ f"""
247
+ SELECT ticker, trade_date, close, daily_return, {ret_z} AS zscore_ret_20, zscore_20
248
+ FROM silver_stock_features
249
+ QUALIFY ROW_NUMBER() OVER (PARTITION BY ticker ORDER BY trade_date DESC) = 1
250
+ ORDER BY ABS(COALESCE({ret_z}, zscore_20, 0)) DESC
251
+ LIMIT 12
252
+ """
253
+ )
254
+ return {
255
+ "counts": counts,
256
+ "movers": _records(movers),
257
+ "freshness": {
258
+ "stocks": _json_value(_scalar("SELECT MAX(trade_date) FROM silver_stock_features")),
259
+ "options": _json_value(_scalar("SELECT MAX(trade_date) FROM silver_option_positioning")),
260
+ "polygon": _json_value(_scalar("SELECT MAX(ts) FROM polygon_bars WHERE timespan = 'day'")),
261
+ },
262
+ }
263
+
264
+
265
+ @app.get("/api/visualizations/tickers")
266
+ def tickers() -> dict[str, Any]:
267
+ df = _frame(
268
+ """
269
+ SELECT ticker, COUNT(*) AS rows, MIN(trade_date) AS first_date, MAX(trade_date) AS last_date
270
+ FROM silver_stock_features
271
+ GROUP BY ticker
272
+ ORDER BY ticker
273
+ """
274
+ )
275
+ return {"tickers": _records(df)}
276
+
277
+
278
+ @app.get("/api/visualizations/stock-history")
279
+ def stock_history(
280
+ ticker: str = Query("NVDA", min_length=1, max_length=12),
281
+ period: str = Query("1y", pattern="^(1m|3m|6m|1y|2y|5y|all)$"),
282
+ ) -> dict[str, Any]:
283
+ symbol = ticker.upper()
284
+ limit = 5000 if period == "all" else _period_limit(period)
285
+ df = _frame(
286
+ f"""
287
+ WITH bars AS (
288
+ SELECT ticker, ts, open, high, low, close, volume, vwap
289
+ FROM polygon_bars
290
+ WHERE ticker = ? AND timespan = 'day'
291
+ ORDER BY ts DESC
292
+ LIMIT ?
293
+ )
294
+ SELECT
295
+ b.ticker,
296
+ TRY_CAST(b.ts AS TIMESTAMPTZ)::DATE AS trade_date,
297
+ b.open,
298
+ b.high,
299
+ b.low,
300
+ b.close,
301
+ b.volume,
302
+ b.vwap,
303
+ s.daily_return,
304
+ s.zscore_20,
305
+ {"s.zscore_ret_20" if _column_exists("silver_stock_features", "zscore_ret_20") else "NULL::DOUBLE"} AS zscore_ret_20,
306
+ s.ma_20,
307
+ s.ma_50,
308
+ s.vwap_20
309
+ FROM bars b
310
+ LEFT JOIN silver_stock_features s
311
+ ON s.ticker = b.ticker
312
+ AND s.trade_date = TRY_CAST(b.ts AS TIMESTAMPTZ)::DATE
313
+ ORDER BY trade_date
314
+ """,
315
+ (symbol, limit),
316
+ )
317
+ if df.is_empty():
318
+ raise HTTPException(status_code=404, detail=f"No daily bars found for {symbol}.")
319
+
320
+ latest = df.tail(1).to_dicts()[0]
321
+ first_close = df.select(pl.col("close").drop_nulls().first()).item()
322
+ last_close = latest.get("close")
323
+ period_return = ((last_close / first_close) - 1) if first_close and last_close else None
324
+ return {
325
+ "ticker": symbol,
326
+ "period": period,
327
+ "latest": {key: _json_value(value) for key, value in latest.items()},
328
+ "metrics": {
329
+ "period_return": period_return,
330
+ "high": df.select(pl.col("high").max()).item(),
331
+ "low": df.select(pl.col("low").min()).item(),
332
+ "avg_volume": df.select(pl.col("volume").mean()).item(),
333
+ },
334
+ "series": _records(df),
335
+ }
336
+
337
+
338
+ @app.get("/api/visualizations/zscore-alerts")
339
+ def zscore_alerts(limit: int = Query(25, ge=1, le=100)) -> dict[str, Any]:
340
+ pct_change = (
341
+ "pct_change"
342
+ if _column_exists("silver_stock_features", "pct_change")
343
+ else "daily_return * 100"
344
+ if _column_exists("silver_stock_features", "daily_return")
345
+ else "NULL::DOUBLE"
346
+ )
347
+ zret20 = "zscore_ret_20" if _column_exists("silver_stock_features", "zscore_ret_20") else "NULL::DOUBLE"
348
+ zret50 = "zscore_ret_50" if _column_exists("silver_stock_features", "zscore_ret_50") else "NULL::DOUBLE"
349
+ zret100 = "zscore_ret_100" if _column_exists("silver_stock_features", "zscore_ret_100") else "NULL::DOUBLE"
350
+ sql = f"""
351
+ WITH latest AS (
352
+ SELECT *,
353
+ ROW_NUMBER() OVER (PARTITION BY ticker ORDER BY trade_date DESC) AS rn
354
+ FROM silver_stock_features
355
+ )
356
+ SELECT ticker, trade_date, close, {pct_change} AS pct_change,
357
+ zscore_20, zscore_50, zscore_100,
358
+ {zret20} AS zscore_ret_20, {zret50} AS zscore_ret_50, {zret100} AS zscore_ret_100,
359
+ GREATEST(
360
+ ABS(COALESCE(zscore_20, 0)),
361
+ ABS(COALESCE(zscore_50, 0)),
362
+ ABS(COALESCE(zscore_100, 0)),
363
+ ABS(COALESCE({zret20}, 0)),
364
+ ABS(COALESCE({zret50}, 0)),
365
+ ABS(COALESCE({zret100}, 0))
366
+ ) AS max_abs_zscore,
367
+ false AS any_breach
368
+ FROM latest
369
+ WHERE rn = 1
370
+ ORDER BY max_abs_zscore DESC
371
+ LIMIT ?
372
+ """
373
+ df = _frame(sql, (limit,))
374
+ return {"alerts": _records(df)}
375
+
376
+
377
+ @app.get("/api/visualizations/options-positioning")
378
+ def options_positioning(
379
+ underlying: str = Query("NVDA", min_length=1, max_length=12),
380
+ period: str = Query("1y", pattern="^(1m|3m|6m|1y|2y|all)$"),
381
+ ) -> dict[str, Any]:
382
+ symbol = underlying.upper()
383
+ limit = 5000 if period == "all" else _period_limit(period)
384
+ df = _frame(
385
+ """
386
+ SELECT underlying, trade_date, total_volume, call_volume, put_volume, put_call_ratio,
387
+ atm_iv, call_iv_25d, put_iv_25d, iv_skew_25d, n_contracts
388
+ FROM silver_option_positioning
389
+ WHERE underlying = ?
390
+ ORDER BY trade_date DESC
391
+ LIMIT ?
392
+ """,
393
+ (symbol, limit),
394
+ )
395
+ if df.is_empty():
396
+ return {"underlying": symbol, "period": period, "series": []}
397
+ df = df.sort("trade_date")
398
+ return {"underlying": symbol, "period": period, "series": _records(df)}
399
+
400
+
401
+ @app.get("/api/visualizations/backtests")
402
+ def backtests() -> dict[str, Any]:
403
+ if not _table_exists("gold_backtest_runs"):
404
+ return {"runs": [], "portfolio": [], "metrics": []}
405
+ runs = _frame(
406
+ """
407
+ SELECT run_id, start_date, end_date, created_at, fold_id
408
+ FROM gold_backtest_runs
409
+ ORDER BY created_at DESC
410
+ LIMIT 20
411
+ """
412
+ )
413
+ latest_run = runs["run_id"][0] if not runs.is_empty() else None
414
+ portfolio = (
415
+ _frame(
416
+ """
417
+ SELECT trade_date, nav, cash, drawdown_pct, n_positions
418
+ FROM gold_portfolio
419
+ WHERE run_id = ?
420
+ ORDER BY trade_date
421
+ """,
422
+ (latest_run,),
423
+ )
424
+ if latest_run and _table_exists("gold_portfolio")
425
+ else pl.DataFrame()
426
+ )
427
+ metrics = (
428
+ _frame("SELECT * FROM gold_metrics WHERE run_id = ?", (latest_run,))
429
+ if latest_run and _table_exists("gold_metrics")
430
+ else pl.DataFrame()
431
+ )
432
+ return {"runs": _records(runs), "portfolio": _records(portfolio), "metrics": _records(metrics)}
433
+
434
+
435
+ @app.get("/api/research/methodology")
436
+ def research_methodology() -> dict[str, Any]:
437
+ return {
438
+ "title": "Point-in-time market research methodology",
439
+ "principles": [
440
+ {
441
+ "id": "clock",
442
+ "title": "Event time before ingestion time",
443
+ "body": "Bars, features, signals, and fills are aligned on the market event timestamp. Ingestion and computation timestamps are retained for freshness and replay audits, but never substitute for event time in a backtest.",
444
+ "controls": ["Normalize timestamps to UTC", "Declare exchange session and calendar", "Reject future-dated observations"],
445
+ },
446
+ {
447
+ "id": "alignment",
448
+ "title": "Point-in-time alignment",
449
+ "body": "A decision at time t may only use values published and observable at or before t. Lower-frequency releases are joined backward from their release timestamp, never from the period they describe.",
450
+ "controls": ["Backward as-of joins", "Publication-lag fields", "No forward fill across unknown releases"],
451
+ },
452
+ {
453
+ "id": "features",
454
+ "title": "Windows, resampling, and warmup",
455
+ "body": "OHLCV resampling uses explicit session boundaries. Rolling features require a complete minimum window and remain null during warmup; signals are shifted to the next executable bar.",
456
+ "controls": ["Closed-bar inputs", "Minimum observations", "Signal-to-fill lag"],
457
+ },
458
+ {
459
+ "id": "adjustments",
460
+ "title": "Corporate actions and universe history",
461
+ "body": "Price-return studies must declare split and dividend treatment. Delisted names and historical membership are retained so the tested universe is the universe known at that date.",
462
+ "controls": ["Adjustment policy per run", "Point-in-time membership", "Delisting outcome handling"],
463
+ },
464
+ {
465
+ "id": "validation",
466
+ "title": "Walk-forward validation",
467
+ "body": "Model selection occurs only inside each training window. Validation and out-of-sample windows are chronological, non-overlapping, and separated by an embargo at least as long as the maximum label horizon.",
468
+ "controls": ["Expanding or rolling splits", "Embargo recorded per fold", "Untouched final holdout"],
469
+ },
470
+ {
471
+ "id": "costs",
472
+ "title": "Execution and slippage",
473
+ "body": "Reported performance is net of spread, commission, and configured market impact. Costs are calculated per fill from information available at execution and are preserved beside gross and net P&L.",
474
+ "controls": ["Half-spread per leg", "IBKR commission schedule", "Square-root participation impact", "Cost toggles stored per run"],
475
+ },
476
+ ],
477
+ "slippage_model": {
478
+ "spread": "round-trip half-spread x quantity x multiplier x 2",
479
+ "commission": "asset-specific IBKR schedule with minimums and caps",
480
+ "market_impact": "volatility x sqrt(quantity / ADV) x notional x 2",
481
+ "cost_bps": "total execution cost / entry notional x 10,000",
482
+ "net_pnl": "gross_pnl - slippage_cost - commission_cost",
483
+ },
484
+ "required_run_metadata": [
485
+ "data snapshot and event-time range",
486
+ "universe and corporate-action policy",
487
+ "feature windows and signal lag",
488
+ "walk-forward folds and embargo",
489
+ "slippage toggles and parameters",
490
+ "code version and creation timestamp",
491
+ ],
492
+ }
493
+
494
+
495
+ @app.get("/api/research/analytics/summary")
496
+ def research_analytics_summary() -> dict[str, Any]:
497
+ profiles = _series_profiles(include_coverage=True)
498
+ return {
499
+ "tables": profiles,
500
+ "totals": {
501
+ "rows": sum(item["rows"] for item in profiles),
502
+ "available_tables": sum(1 for item in profiles if item["available"]),
503
+ "tables_with_duplicates": sum(1 for item in profiles if (item.get("duplicate_points") or 0) > 0),
504
+ "tables_with_null_time": sum(1 for item in profiles if (item.get("null_timestamps") or 0) > 0),
505
+ },
506
+ }
507
+
508
+
509
+ def _quality_checks() -> list[dict[str, Any]]:
510
+ checks: list[dict[str, Any]] = []
511
+ for profile in _series_profiles():
512
+ spec = profile
513
+ if not profile["available"]:
514
+ checks.append({"table": spec["name"], "check": "table_available", "status": "not_applicable", "value": None})
515
+ continue
516
+ for field, check in (("null_timestamps", "event_time_not_null"), ("duplicate_points", "unique_series_key")):
517
+ value = profile.get(field)
518
+ checks.append(
519
+ {
520
+ "table": spec["name"],
521
+ "check": check,
522
+ "status": "pass" if value == 0 else "fail" if value is not None else "not_applicable",
523
+ "value": value,
524
+ }
525
+ )
526
+ return checks
527
+
528
+
529
+ @app.get("/api/research/audit")
530
+ def research_audit(limit: int = Query(100, ge=1, le=500)) -> dict[str, Any]:
531
+ runs = (
532
+ _frame(
533
+ """
534
+ SELECT id, run_type, status, rows_written, started_at, finished_at, message
535
+ FROM etl_runs
536
+ ORDER BY TRY_CAST(started_at AS TIMESTAMP) DESC NULLS LAST, id DESC
537
+ LIMIT ?
538
+ """,
539
+ (limit,),
540
+ )
541
+ if _table_exists("etl_runs")
542
+ else pl.DataFrame()
543
+ )
544
+ return {"runs": _records(runs), "checks": _quality_checks()}
545
+
546
+
547
+ @app.get("/api/research/audit/summary")
548
+ def research_audit_summary() -> dict[str, Any]:
549
+ checks = _quality_checks()
550
+ status_rows = (
551
+ _frame("SELECT status, COUNT(*) AS runs FROM etl_runs GROUP BY status ORDER BY runs DESC")
552
+ if _table_exists("etl_runs")
553
+ else pl.DataFrame()
554
+ )
555
+ return {
556
+ "etl_status": _records(status_rows),
557
+ "checks": {
558
+ "passed": sum(1 for item in checks if item["status"] == "pass"),
559
+ "failed": sum(1 for item in checks if item["status"] == "fail"),
560
+ "not_applicable": sum(1 for item in checks if item["status"] == "not_applicable"),
561
+ },
562
+ }
563
+
564
+
565
+ @app.get("/api/research/system")
566
+ def research_system() -> dict[str, Any]:
567
+ profiles = _series_profiles()
568
+ stages = []
569
+ for stage in ("bronze", "silver", "gold"):
570
+ members = [item for item in profiles if item["stage"] == stage]
571
+ available = sum(1 for item in members if item["available"] and item["rows"] > 0)
572
+ stages.append(
573
+ {
574
+ "stage": stage,
575
+ "status": "ready" if available == len(members) else "partial" if available else "missing",
576
+ "available": available,
577
+ "expected": len(members),
578
+ "latest_timestamp": max(
579
+ (str(item["last_timestamp"]) for item in members if item.get("last_timestamp")),
580
+ default=None,
581
+ ),
582
+ }
583
+ )
584
+ return {"database": health(), "stages": stages, "tables": profiles}
585
+
586
+
587
+ def _slippage_available() -> bool:
588
+ required = {"run_id", "ticker", "gross_pnl", "slippage_cost", "commission_cost", "net_pnl"}
589
+ return required.issubset(_table_columns("gold_trades"))
590
+
591
+
592
+ @app.get("/api/research/slippage/summary")
593
+ def slippage_summary() -> dict[str, Any]:
594
+ if not _slippage_available():
595
+ return {"available": False, "reason": "gold_trades with cost fields is not available", "summary": None}
596
+ df = _frame(
597
+ """
598
+ SELECT COUNT(*) AS trades,
599
+ COUNT(DISTINCT run_id) AS runs,
600
+ SUM(COALESCE(gross_pnl, 0)) AS gross_pnl,
601
+ SUM(COALESCE(slippage_cost, 0)) AS slippage_cost,
602
+ SUM(COALESCE(commission_cost, 0)) AS commission_cost,
603
+ SUM(COALESCE(slippage_cost, 0) + COALESCE(commission_cost, 0)) AS total_cost,
604
+ SUM(COALESCE(net_pnl, 0)) AS net_pnl,
605
+ CASE WHEN ABS(SUM(COALESCE(gross_pnl, 0))) > 0
606
+ THEN SUM(COALESCE(slippage_cost, 0) + COALESCE(commission_cost, 0))
607
+ / ABS(SUM(COALESCE(gross_pnl, 0))) END AS cost_drag
608
+ FROM gold_trades
609
+ """
610
+ )
611
+ return {"available": True, "summary": _records(df)[0] if not df.is_empty() else None}
612
+
613
+
614
+ @app.get("/api/research/slippage/by-ticker")
615
+ def slippage_by_ticker(run_id: str | None = None) -> dict[str, Any]:
616
+ if not _slippage_available():
617
+ return {"available": False, "reason": "gold_trades with cost fields is not available", "tickers": []}
618
+ where = "WHERE run_id = ?" if run_id else ""
619
+ params: tuple[Any, ...] = (run_id,) if run_id else ()
620
+ df = _frame(
621
+ f"""
622
+ SELECT ticker, COUNT(*) AS trades,
623
+ SUM(COALESCE(gross_pnl, 0)) AS gross_pnl,
624
+ SUM(COALESCE(slippage_cost, 0)) AS slippage_cost,
625
+ SUM(COALESCE(commission_cost, 0)) AS commission_cost,
626
+ SUM(COALESCE(slippage_cost, 0) + COALESCE(commission_cost, 0)) AS total_cost,
627
+ SUM(COALESCE(net_pnl, 0)) AS net_pnl
628
+ FROM gold_trades
629
+ {where}
630
+ GROUP BY ticker
631
+ ORDER BY total_cost DESC, ticker
632
+ """,
633
+ params,
634
+ )
635
+ return {"available": True, "tickers": _records(df)}
636
+
637
+
638
+ @app.get("/api/backtests/{run_id}/costs")
639
+ def backtest_costs(run_id: str) -> dict[str, Any]:
640
+ if not _slippage_available():
641
+ return {"available": False, "run_id": run_id, "reason": "gold_trades with cost fields is not available", "trades": []}
642
+ columns = _table_columns("gold_trades")
643
+ order_col = "trade_date" if "trade_date" in columns else "ticker"
644
+ selected = ["ticker", "gross_pnl", "slippage_cost", "commission_cost", "net_pnl"]
645
+ for optional in ("trade_date", "asset_type", "quantity", "price", "cost_bps"):
646
+ if optional in columns:
647
+ selected.append(optional)
648
+ df = _frame(
649
+ f"SELECT {', '.join(selected)} FROM gold_trades WHERE run_id = ? ORDER BY {order_col}",
650
+ (run_id,),
651
+ )
652
+ totals = slippage_by_ticker(run_id=run_id)
653
+ return {"available": True, "run_id": run_id, "by_ticker": totals["tickers"], "trades": _records(df)}
654
+
655
+
656
+ @app.post("/api/chat")
657
+ def chat_endpoint(payload: dict[str, Any]) -> dict[str, Any]:
658
+ message = str(payload.get("message", "")).strip()
659
+ if not message:
660
+ raise HTTPException(status_code=400, detail="message is required")
661
+ result = chat(message, history=payload.get("history") or [], max_rows=int(payload.get("max_rows", 100)))
662
+ data = result.get("data")
663
+ if hasattr(data, "to_dict"):
664
+ data = data.to_dict(orient="records")
665
+ return {
666
+ "type": result.get("type"),
667
+ "response": result.get("answer"),
668
+ "sql": result.get("sql"),
669
+ "data": data,
670
+ }
671
+
672
+
673
+ # The Docker Space builds the Vite app into frontend/dist. Keep this route last
674
+ # so every /api endpoint wins before the single-page-app fallback.
675
+ FRONTEND_DIST = Path(__file__).resolve().parent.parent / "frontend" / "dist"
676
+ if FRONTEND_DIST.is_dir():
677
+ @app.get("/{full_path:path}", include_in_schema=False)
678
+ def serve_frontend(full_path: str) -> FileResponse:
679
+ requested = (FRONTEND_DIST / full_path).resolve()
680
+ if requested.is_file() and FRONTEND_DIST.resolve() in requested.parents:
681
+ return FileResponse(requested)
682
+ return FileResponse(FRONTEND_DIST / "index.html")
api/services/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Service integrations used by the API and development workflows."""
api/services/agent_review.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Explicit MiMo implementation and DeepSeek review handoff.
2
+
3
+ Prompts, source diffs, responses, and credentials are deliberately not logged.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ from dataclasses import dataclass
9
+
10
+ from dotenv import load_dotenv
11
+ from openai import OpenAI
12
+
13
+ load_dotenv()
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class AgentResult:
18
+ provider: str
19
+ model: str
20
+ content: str
21
+
22
+
23
+ def _completion(*, provider: str, base_url: str, api_key: str, model: str, system: str, prompt: str) -> AgentResult:
24
+ if not api_key:
25
+ raise ValueError(f"{provider} API key is not configured")
26
+ if not base_url.startswith("https://") and not base_url.startswith("http://localhost"):
27
+ raise ValueError(f"{provider} base URL must use HTTPS or localhost")
28
+ client = OpenAI(api_key=api_key, base_url=base_url, timeout=120.0, max_retries=1)
29
+ response = client.chat.completions.create(
30
+ model=model,
31
+ messages=[{"role": "system", "content": system}, {"role": "user", "content": prompt}],
32
+ temperature=0.1,
33
+ max_completion_tokens=8000,
34
+ )
35
+ content = (response.choices[0].message.content or "").strip()
36
+ if not content:
37
+ raise RuntimeError(f"{provider} returned an empty response")
38
+ return AgentResult(provider=provider, model=model, content=content)
39
+
40
+
41
+ def run_mimo_implementation(prompt: str) -> AgentResult:
42
+ xiaomi_key = os.getenv("XIAOMI_OPENAI_API_KEY", "")
43
+ base_url = (
44
+ os.getenv("XIAOMI_OPENAI_BASE_URL", "https://token-plan-sgp.xiaomimimo.com/v1")
45
+ if xiaomi_key
46
+ else os.getenv("MIMO_BASE_URL", "http://localhost:11434/v1")
47
+ )
48
+ return _completion(
49
+ provider="mimo",
50
+ base_url=base_url.rstrip("/"),
51
+ api_key=xiaomi_key or os.getenv("MIMO_API_KEY", ""),
52
+ model=os.getenv("MIMO_MODEL", "mimo-v2.5-pro"),
53
+ system="You are a senior implementation engineer. Return a scoped patch and its focused tests.",
54
+ prompt=prompt,
55
+ )
56
+
57
+
58
+ def run_deepseek_review(diff_or_files: str) -> AgentResult:
59
+ return _completion(
60
+ provider="deepseek",
61
+ base_url=os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com").rstrip("/"),
62
+ api_key=os.getenv("DEEPSEEK_OPENAI_API_KEY") or os.getenv("DEEPSEEK_API_KEY", ""),
63
+ model=os.getenv("DEEPSEEK_MODEL", "deepseek-v4-flash"),
64
+ system=(
65
+ "Review the supplied implementation for correctness, security, time-series leakage, "
66
+ "DuckDB compatibility, API contract stability, responsive UI behavior, and missing tests. "
67
+ "Lead with actionable findings and file references; say APPROVED only when no blocker remains."
68
+ ),
69
+ prompt=diff_or_files,
70
+ )
db/__init__.py ADDED
File without changes
db/database.py ADDED
@@ -0,0 +1,745 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ db/database.py
3
+ DuckDB schema + connection manager for Equity Workbench ETL.
4
+ """
5
+ import duckdb
6
+ import os
7
+ from pathlib import Path
8
+ from loguru import logger
9
+
10
+
11
+ DB_PATH = os.getenv("DB_PATH", "./data/equity.duckdb")
12
+
13
+
14
+ def get_connection() -> duckdb.DuckDBPyConnection:
15
+ Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
16
+ conn = duckdb.connect(DB_PATH)
17
+ # ── Extension Setup ───────────────────────────────────────────
18
+ try:
19
+ conn.execute("INSTALL vss;")
20
+ conn.execute("LOAD vss;")
21
+ conn.execute("SET hnsw_enable_experimental_persistence = true;")
22
+ except Exception as e:
23
+ logger.warning(f"Failed to load VSS extension: {e}")
24
+ return conn
25
+
26
+
27
+ def init_db():
28
+ """Create all tables if they don't exist."""
29
+ conn = get_connection()
30
+ try:
31
+ # ── Stocks ────────────────────────────────────────────────────────────────
32
+ conn.execute("""
33
+ CREATE SEQUENCE IF NOT EXISTS stock_quotes_id_seq;
34
+ CREATE TABLE IF NOT EXISTS stock_quotes (
35
+ id INTEGER PRIMARY KEY DEFAULT nextval('stock_quotes_id_seq'),
36
+ ticker TEXT NOT NULL,
37
+ ts TEXT NOT NULL, -- ISO-8601 UTC
38
+ bid REAL,
39
+ ask REAL,
40
+ last REAL,
41
+ "close" REAL,
42
+ volume INTEGER,
43
+ "open" REAL,
44
+ high REAL,
45
+ low REAL,
46
+ vwap REAL,
47
+ created_at TIMESTAMP DEFAULT now()
48
+ )
49
+ """)
50
+ conn.execute("""
51
+ CREATE INDEX IF NOT EXISTS idx_sq_ticker_ts
52
+ ON stock_quotes(ticker, ts)
53
+ """)
54
+
55
+ # ── Options ───────────────────────────────────────────────────────────────
56
+ conn.execute("""
57
+ CREATE SEQUENCE IF NOT EXISTS option_quotes_id_seq;
58
+ CREATE TABLE IF NOT EXISTS option_quotes (
59
+ id INTEGER PRIMARY KEY DEFAULT nextval('option_quotes_id_seq'),
60
+ ticker TEXT NOT NULL, -- underlying
61
+ expiry TEXT NOT NULL, -- YYYYMMDD
62
+ strike REAL NOT NULL,
63
+ "right" TEXT NOT NULL, -- 'C' or 'P'
64
+ ts TEXT NOT NULL,
65
+ bid REAL,
66
+ ask REAL,
67
+ last REAL,
68
+ volume INTEGER,
69
+ open_interest INTEGER,
70
+ implied_vol REAL,
71
+ delta REAL,
72
+ gamma REAL,
73
+ theta REAL,
74
+ vega REAL,
75
+ und_price REAL,
76
+ pv_dividend REAL,
77
+ created_at TIMESTAMP DEFAULT now()
78
+ )
79
+ """)
80
+ conn.execute("""
81
+ CREATE INDEX IF NOT EXISTS idx_oq_ticker_expiry
82
+ ON option_quotes(ticker, expiry, strike, "right")
83
+ """)
84
+
85
+ # ── Option Chains (metadata) ───────────────────────────────────────────
86
+ conn.execute("""
87
+ CREATE TABLE IF NOT EXISTS option_chains (
88
+ ticker TEXT NOT NULL,
89
+ expiry TEXT NOT NULL,
90
+ strike REAL NOT NULL,
91
+ "right" TEXT NOT NULL,
92
+ exchange TEXT,
93
+ fetched_at TIMESTAMP DEFAULT now(),
94
+ UNIQUE(ticker, expiry, strike, "right")
95
+ )
96
+ """)
97
+
98
+ # ── ETL Run Log ────────────────────────────────────────────────────────
99
+ conn.execute("""
100
+ CREATE SEQUENCE IF NOT EXISTS etl_runs_id_seq;
101
+ CREATE TABLE IF NOT EXISTS etl_runs (
102
+ id INTEGER PRIMARY KEY DEFAULT nextval('etl_runs_id_seq'),
103
+ run_type TEXT NOT NULL, -- 'stocks' | 'options' | 'chain' | 'polygon_bars_bronze' | ...
104
+ status TEXT NOT NULL, -- 'ok' | 'error'
105
+ message TEXT,
106
+ rows_written INTEGER DEFAULT 0,
107
+ started_at TEXT NOT NULL,
108
+ finished_at TEXT
109
+ )
110
+ """)
111
+
112
+ # ── Polygon: OHLCV bars ───────────────────────────────────────────��───────
113
+ conn.execute("""
114
+ CREATE TABLE IF NOT EXISTS polygon_bars (
115
+ ticker TEXT NOT NULL,
116
+ ts TEXT NOT NULL, -- bar open time, ISO-8601 UTC
117
+ timespan TEXT NOT NULL, -- 'day' | 'minute' | 'hour'
118
+ open REAL,
119
+ high REAL,
120
+ low REAL,
121
+ close REAL,
122
+ volume REAL,
123
+ vwap REAL,
124
+ transactions INTEGER,
125
+ created_at TIMESTAMP DEFAULT now(),
126
+ UNIQUE(ticker, ts, timespan)
127
+ )
128
+ """)
129
+ conn.execute("""
130
+ CREATE INDEX IF NOT EXISTS idx_pb_ticker_ts
131
+ ON polygon_bars(ticker, ts, timespan)
132
+ """)
133
+
134
+ # ── Polygon: real-time / delayed snapshots ────────────────────────────────
135
+ conn.execute("""
136
+ CREATE TABLE IF NOT EXISTS polygon_snapshots (
137
+ ticker TEXT NOT NULL,
138
+ ts TEXT NOT NULL,
139
+ bid REAL,
140
+ ask REAL,
141
+ last REAL,
142
+ prev_close REAL,
143
+ day_volume REAL,
144
+ created_at TIMESTAMP DEFAULT now()
145
+ )
146
+ """)
147
+ conn.execute("""
148
+ CREATE INDEX IF NOT EXISTS idx_ps_ticker_ts
149
+ ON polygon_snapshots(ticker, ts)
150
+ """)
151
+
152
+ # ── Polygon: options chain snapshots ──────────────────────────────────────
153
+ conn.execute("""
154
+ CREATE TABLE IF NOT EXISTS polygon_option_snapshots (
155
+ underlying TEXT NOT NULL,
156
+ expiry TEXT NOT NULL, -- YYYY-MM-DD
157
+ strike REAL NOT NULL,
158
+ "right" TEXT NOT NULL, -- 'call' | 'put'
159
+ ts TEXT NOT NULL,
160
+ day_open REAL,
161
+ day_close REAL,
162
+ day_volume INTEGER,
163
+ open_interest INTEGER,
164
+ implied_vol REAL,
165
+ delta REAL,
166
+ gamma REAL,
167
+ theta REAL,
168
+ vega REAL,
169
+ created_at TIMESTAMP DEFAULT now()
170
+ )
171
+ """)
172
+ conn.execute("""
173
+ CREATE INDEX IF NOT EXISTS idx_pos_underlying
174
+ ON polygon_option_snapshots(underlying, expiry, strike, "right")
175
+ """)
176
+
177
+ # ── Polygon: ticker reference / metadata ──────────────────────────────────
178
+ conn.execute("""
179
+ CREATE TABLE IF NOT EXISTS polygon_tickers (
180
+ ticker TEXT NOT NULL UNIQUE,
181
+ name TEXT,
182
+ market TEXT,
183
+ primary_exchange TEXT,
184
+ type TEXT,
185
+ active INTEGER,
186
+ currency TEXT,
187
+ description TEXT,
188
+ updated_at TEXT NOT NULL
189
+ )
190
+ """)
191
+
192
+ # ── Polygon: historical options OHLCV bars ───────────────────────────
193
+ conn.execute("""
194
+ CREATE TABLE IF NOT EXISTS polygon_option_bars (
195
+ option_ticker TEXT NOT NULL, -- e.g. O:AAPL240119C00150000
196
+ underlying TEXT NOT NULL,
197
+ expiry TEXT, -- YYYY-MM-DD
198
+ strike REAL,
199
+ "right" TEXT, -- 'call' | 'put'
200
+ ts TEXT NOT NULL, -- bar open time, ISO-8601 UTC
201
+ timespan TEXT NOT NULL, -- 'day' | 'minute'
202
+ open REAL,
203
+ high REAL,
204
+ low REAL,
205
+ close REAL,
206
+ volume REAL,
207
+ vwap REAL,
208
+ transactions INTEGER,
209
+ created_at TIMESTAMP DEFAULT now(),
210
+ UNIQUE(option_ticker, ts, timespan)
211
+ )
212
+ """)
213
+ conn.execute("""
214
+ CREATE INDEX IF NOT EXISTS idx_pob_underlying
215
+ ON polygon_option_bars(underlying, expiry, strike, "right")
216
+ """)
217
+ conn.execute("""
218
+ CREATE INDEX IF NOT EXISTS idx_pob_ticker_ts
219
+ ON polygon_option_bars(option_ticker, ts)
220
+ """)
221
+
222
+ # ── Polygon: individual trade ticks ──────────────────────────────────
223
+ conn.execute("""
224
+ CREATE TABLE IF NOT EXISTS polygon_trades (
225
+ ticker TEXT NOT NULL,
226
+ ts TEXT NOT NULL, -- SIP timestamp, ISO-8601 microsecond UTC
227
+ price REAL,
228
+ size REAL,
229
+ conditions TEXT, -- comma-separated condition codes
230
+ exchange INTEGER,
231
+ tape TEXT,
232
+ created_at TIMESTAMP DEFAULT now(),
233
+ UNIQUE(ticker, ts, exchange)
234
+ )
235
+ """)
236
+ conn.execute("""
237
+ CREATE INDEX IF NOT EXISTS idx_ptrades_ticker_ts
238
+ ON polygon_trades(ticker, ts)
239
+ """)
240
+
241
+ # ── EDGAR: filing metadata ────────────────────────────────────────────
242
+ conn.execute("""
243
+ CREATE TABLE IF NOT EXISTS edgar_filings (
244
+ ticker TEXT NOT NULL,
245
+ cik TEXT NOT NULL,
246
+ form_type TEXT NOT NULL,
247
+ filed_date TEXT,
248
+ accession_number TEXT NOT NULL,
249
+ primary_doc TEXT,
250
+ created_at TIMESTAMP DEFAULT now(),
251
+ UNIQUE(accession_number)
252
+ )
253
+ """)
254
+ conn.execute("""
255
+ CREATE INDEX IF NOT EXISTS idx_ef_ticker_form
256
+ ON edgar_filings(ticker, form_type, filed_date)
257
+ """)
258
+
259
+ # ── EDGAR: XBRL financial facts ───────────────────────────────────────
260
+ conn.execute("""
261
+ CREATE TABLE IF NOT EXISTS edgar_facts (
262
+ ticker TEXT NOT NULL,
263
+ cik TEXT NOT NULL,
264
+ taxonomy TEXT NOT NULL,
265
+ concept TEXT NOT NULL,
266
+ label TEXT,
267
+ unit TEXT,
268
+ value REAL,
269
+ period_start TEXT,
270
+ period_end TEXT,
271
+ form_type TEXT,
272
+ filed_date TEXT,
273
+ accession_number TEXT,
274
+ created_at TIMESTAMP DEFAULT now(),
275
+ UNIQUE(ticker, concept, unit, period_end, form_type)
276
+ )
277
+ """)
278
+ conn.execute("""
279
+ CREATE INDEX IF NOT EXISTS idx_edgar_facts_ticker
280
+ ON edgar_facts(ticker, concept, period_end)
281
+ """)
282
+
283
+ # ── COT: Commitments of Traders (CFTC) ────────────────────────────────
284
+ conn.execute("""
285
+ CREATE TABLE IF NOT EXISTS cot_reports (
286
+ market_name TEXT NOT NULL,
287
+ ticker TEXT, -- Optional mapping to IBKR ticker
288
+ report_date TEXT NOT NULL, -- ISO-8601
289
+ noncomm_long INTEGER,
290
+ noncomm_short INTEGER,
291
+ comm_long INTEGER,
292
+ comm_short INTEGER,
293
+ total_long INTEGER,
294
+ total_short INTEGER,
295
+ noncomm_spreads INTEGER,
296
+ open_interest INTEGER,
297
+ created_at TIMESTAMP DEFAULT now(),
298
+ UNIQUE(market_name, report_date)
299
+ )
300
+ """)
301
+ conn.execute("""
302
+ CREATE INDEX IF NOT EXISTS idx_cot_market_date
303
+ ON cot_reports(market_name, report_date)
304
+ """)
305
+
306
+ # ── Vector Storage ────────────────────────────────────────────────────────
307
+ conn.execute("""
308
+ CREATE TABLE IF NOT EXISTS ticker_embeddings (
309
+ ticker TEXT PRIMARY KEY,
310
+ industry TEXT,
311
+ source TEXT,
312
+ text TEXT,
313
+ embedding FLOAT[384], -- all-MiniLM-L6-v2 dimension
314
+ updated_at TIMESTAMP DEFAULT now()
315
+ )
316
+ """)
317
+ try:
318
+ conn.execute("""
319
+ CREATE INDEX IF NOT EXISTS idx_ticker_emb
320
+ ON ticker_embeddings USING HNSW (embedding)
321
+ WITH (metric = 'cosine')
322
+ """)
323
+ except Exception as e:
324
+ logger.warning(f"Failed to create HNSW index on ticker_embeddings: {e}")
325
+
326
+ conn.execute("""
327
+ CREATE SEQUENCE IF NOT EXISTS edgar_embeddings_id_seq;
328
+ CREATE TABLE IF NOT EXISTS edgar_embeddings (
329
+ id INTEGER PRIMARY KEY DEFAULT nextval('edgar_embeddings_id_seq'),
330
+ ticker TEXT,
331
+ accession TEXT,
332
+ text TEXT,
333
+ embedding FLOAT[384],
334
+ updated_at TIMESTAMP DEFAULT now()
335
+ )
336
+ """)
337
+ try:
338
+ conn.execute("""
339
+ CREATE INDEX IF NOT EXISTS idx_edgar_emb
340
+ ON edgar_embeddings USING HNSW (embedding)
341
+ WITH (metric = 'cosine')
342
+ """)
343
+ except Exception as e:
344
+ logger.warning(f"Failed to create HNSW index on edgar_embeddings: {e}")
345
+
346
+ # ══ SILVER LAYER ═══════════════════════════════════════════════════════
347
+ # Derived, recomputable feature tables built from bronze bars.
348
+ # Grain: one row per entity per trading day. Rebuilt with INSERT OR REPLACE.
349
+
350
+ # ── Silver: per-stock daily technical features ─────────────────────────
351
+ # Source: polygon_bars WHERE timespan='day'. Windows are trailing N days.
352
+ conn.execute("""
353
+ CREATE TABLE IF NOT EXISTS silver_stock_features (
354
+ ticker TEXT NOT NULL,
355
+ ts TEXT NOT NULL, -- trading day, ISO-8601 (bronze join key)
356
+ trade_date DATE, -- typed date for window ordering
357
+ close DOUBLE, -- from bronze polygon_bars (day)
358
+ volume DOUBLE,
359
+ daily_return DOUBLE, -- close / prev_close - 1
360
+ -- simple moving averages of close
361
+ ma_20 DOUBLE,
362
+ ma_50 DOUBLE,
363
+ ma_100 DOUBLE,
364
+ -- rolling sample standard deviation of close
365
+ std_20 DOUBLE,
366
+ std_50 DOUBLE,
367
+ std_100 DOUBLE,
368
+ pct_change DOUBLE, -- daily_return * 100 (percent)
369
+ -- price z-score = (close - ma_N) / std_N
370
+ zscore_20 DOUBLE,
371
+ zscore_50 DOUBLE,
372
+ zscore_100 DOUBLE,
373
+ -- sigma band flag on price: '+3s' | 'normal' | '-3s' | NULL
374
+ sigma_flag_20 TEXT,
375
+ sigma_flag_50 TEXT,
376
+ sigma_flag_100 TEXT,
377
+ -- return z-score = (pct_change - mean_ret_N) / std_ret_N
378
+ zscore_ret_20 DOUBLE,
379
+ zscore_ret_50 DOUBLE,
380
+ zscore_ret_100 DOUBLE,
381
+ -- sigma band flag on returns
382
+ sigma_flag_ret_20 TEXT,
383
+ sigma_flag_ret_50 TEXT,
384
+ sigma_flag_ret_100 TEXT,
385
+ -- rolling VWAP = sum(typical_price*volume)/sum(volume), typical=(h+l+c)/3
386
+ vwap_20 DOUBLE,
387
+ vwap_50 DOUBLE,
388
+ vwap_100 DOUBLE,
389
+ computed_at TIMESTAMP DEFAULT now(),
390
+ UNIQUE(ticker, ts)
391
+ )
392
+ """)
393
+ # ── Migrate: add sigma_flag columns if they don't exist yet ───────────
394
+ for col in ("sigma_flag_20", "sigma_flag_50", "sigma_flag_100"):
395
+ try:
396
+ conn.execute(f"ALTER TABLE silver_stock_features ADD COLUMN {col} TEXT")
397
+ logger.info(f"Migrated silver_stock_features: added {col}")
398
+ except Exception:
399
+ pass # column already exists
400
+ for col in ("pct_change",):
401
+ try:
402
+ conn.execute(f"ALTER TABLE silver_stock_features ADD COLUMN {col} DOUBLE")
403
+ logger.info(f"Migrated silver_stock_features: added {col}")
404
+ except Exception:
405
+ pass
406
+ for col in ("zscore_ret_20", "zscore_ret_50", "zscore_ret_100"):
407
+ try:
408
+ conn.execute(f"ALTER TABLE silver_stock_features ADD COLUMN {col} DOUBLE")
409
+ logger.info(f"Migrated silver_stock_features: added {col}")
410
+ except Exception:
411
+ pass
412
+ for col in ("sigma_flag_ret_20", "sigma_flag_ret_50", "sigma_flag_ret_100"):
413
+ try:
414
+ conn.execute(f"ALTER TABLE silver_stock_features ADD COLUMN {col} TEXT")
415
+ logger.info(f"Migrated silver_stock_features: added {col}")
416
+ except Exception:
417
+ pass
418
+ conn.execute("""
419
+ CREATE INDEX IF NOT EXISTS idx_ssf_ticker_date
420
+ ON silver_stock_features(ticker, trade_date)
421
+ """)
422
+
423
+ # ── View: latest z-score alerts per ticker ─────────────────────────────
424
+ # Query this view to see which tickers are currently outside +-3 std devs.
425
+ # sigma_flag values: '+3s' (above), '-3s' (below), 'normal', NULL (warm-up)
426
+ conn.execute("DROP VIEW IF EXISTS v_zscore_alerts")
427
+ conn.execute("""
428
+ CREATE VIEW v_zscore_alerts AS
429
+ WITH latest AS (
430
+ SELECT *,
431
+ ROW_NUMBER() OVER (PARTITION BY ticker ORDER BY trade_date DESC) AS rn
432
+ FROM silver_stock_features
433
+ )
434
+ SELECT
435
+ ticker,
436
+ trade_date,
437
+ close,
438
+ pct_change,
439
+ zscore_20, sigma_flag_20,
440
+ zscore_50, sigma_flag_50,
441
+ zscore_100, sigma_flag_100,
442
+ zscore_ret_20, sigma_flag_ret_20,
443
+ zscore_ret_50, sigma_flag_ret_50,
444
+ zscore_ret_100, sigma_flag_ret_100,
445
+ CASE
446
+ WHEN sigma_flag_20 IN ('+3s', '-3s')
447
+ OR sigma_flag_50 IN ('+3s', '-3s')
448
+ OR sigma_flag_100 IN ('+3s', '-3s')
449
+ OR sigma_flag_ret_20 IN ('+3s', '-3s')
450
+ OR sigma_flag_ret_50 IN ('+3s', '-3s')
451
+ OR sigma_flag_ret_100 IN ('+3s', '-3s')
452
+ THEN true ELSE false
453
+ END AS any_breach,
454
+ GREATEST(
455
+ ABS(COALESCE(zscore_20, 0)),
456
+ ABS(COALESCE(zscore_50, 0)),
457
+ ABS(COALESCE(zscore_100, 0)),
458
+ ABS(COALESCE(zscore_ret_20, 0)),
459
+ ABS(COALESCE(zscore_ret_50, 0)),
460
+ ABS(COALESCE(zscore_ret_100, 0))
461
+ ) AS max_abs_zscore
462
+ FROM latest
463
+ WHERE rn = 1
464
+ ORDER BY max_abs_zscore DESC
465
+ """)
466
+
467
+ # ── Silver: per-contract daily option greeks (Black-Scholes-Merton) ────
468
+ # Source: polygon_option_bars (option price) JOIN polygon_bars (underlying
469
+ # close). implied_vol solved from the option's market close; greeks analytic.
470
+ conn.execute("""
471
+ CREATE TABLE IF NOT EXISTS silver_option_greeks (
472
+ option_ticker TEXT NOT NULL, -- OPRA symbol
473
+ underlying TEXT NOT NULL,
474
+ expiry TEXT, -- YYYY-MM-DD
475
+ strike DOUBLE,
476
+ "right" TEXT, -- 'call' | 'put'
477
+ ts TEXT NOT NULL, -- trading day, ISO-8601
478
+ trade_date DATE,
479
+ option_close DOUBLE, -- option price from bronze bar
480
+ und_close DOUBLE, -- underlying close (S)
481
+ time_to_expiry DOUBLE, -- years to expiry (ACT/365)
482
+ moneyness DOUBLE, -- und_close / strike
483
+ risk_free_rate DOUBLE, -- r assumption used
484
+ dividend_yield DOUBLE, -- q assumption used (default 0)
485
+ implied_vol DOUBLE, -- sigma solved from option_close
486
+ delta DOUBLE,
487
+ gamma DOUBLE,
488
+ theta DOUBLE, -- per calendar day
489
+ vega DOUBLE, -- per 1 vol point
490
+ rho DOUBLE,
491
+ computed_at TIMESTAMP DEFAULT now(),
492
+ UNIQUE(option_ticker, ts)
493
+ )
494
+ """)
495
+ conn.execute("""
496
+ CREATE INDEX IF NOT EXISTS idx_sog_underlying_date
497
+
498
+ ON silver_option_greeks(underlying, trade_date)
499
+ """)
500
+
501
+ # -- Silver: per-underlying daily options POSITIONING --
502
+ conn.execute("""
503
+ CREATE TABLE IF NOT EXISTS silver_option_positioning (
504
+ underlying TEXT NOT NULL,
505
+ ts TEXT NOT NULL,
506
+ trade_date DATE,
507
+ total_volume DOUBLE,
508
+ call_volume DOUBLE,
509
+ put_volume DOUBLE,
510
+ put_call_ratio DOUBLE,
511
+ atm_iv DOUBLE,
512
+ call_iv_25d DOUBLE,
513
+ put_iv_25d DOUBLE,
514
+ iv_skew_25d DOUBLE,
515
+ n_contracts INTEGER,
516
+ computed_at TIMESTAMP DEFAULT now(),
517
+ UNIQUE(underlying, ts)
518
+ )
519
+ """)
520
+ conn.execute("""
521
+ CREATE INDEX IF NOT EXISTS idx_sop_underlying_date
522
+ ON silver_option_positioning(underlying, trade_date)
523
+ """)
524
+
525
+ # ── Silver: COT (Commitments of Traders) positioning features ─────────
526
+ # Source: cot_reports (Bronze, weekly). Grain: one row per (ticker, report_date).
527
+ conn.execute("""
528
+ CREATE TABLE IF NOT EXISTS silver_cot_features (
529
+ report_date DATE NOT NULL,
530
+ ticker TEXT NOT NULL,
531
+ noncomm_long BIGINT,
532
+ noncomm_short BIGINT,
533
+ noncomm_net BIGINT,
534
+ comm_long BIGINT,
535
+ comm_short BIGINT,
536
+ comm_net BIGINT,
537
+ n_weeks INTEGER,
538
+ net_pos_mean_52w DOUBLE,
539
+ net_pos_std_52w DOUBLE,
540
+ net_pos_zscore_52w DOUBLE,
541
+ comm_net_mean_52w DOUBLE,
542
+ comm_net_std_52w DOUBLE,
543
+ comm_net_zscore_52w DOUBLE,
544
+ spec_comm_divergence DOUBLE,
545
+ crowd_flag TEXT,
546
+ computed_at TIMESTAMP DEFAULT now(),
547
+ PRIMARY KEY (report_date, ticker)
548
+ )
549
+ """)
550
+ conn.execute("""
551
+ CREATE INDEX IF NOT EXISTS idx_scf_ticker_date
552
+ ON silver_cot_features(ticker, report_date)
553
+ """)
554
+
555
+ # ── Silver: futures continuous-contract price features ────────────────
556
+ # Source: polygon_bars (Bronze, day bars) for continuous futures tickers
557
+ # like 'ES1:COM'. Grain: one row per (ticker, trade_date).
558
+ conn.execute("""
559
+ CREATE TABLE IF NOT EXISTS silver_futures_features (
560
+ trade_date DATE NOT NULL,
561
+ ticker TEXT NOT NULL,
562
+ open_price DOUBLE,
563
+ high_price DOUBLE,
564
+ low_price DOUBLE,
565
+ close_price DOUBLE,
566
+ volume BIGINT,
567
+ n20 INTEGER,
568
+ ma_20 DOUBLE,
569
+ std_20 DOUBLE,
570
+ zscore_20 DOUBLE,
571
+ n_ret20 INTEGER,
572
+ ret DOUBLE,
573
+ mean_ret_20 DOUBLE,
574
+ std_ret_20 DOUBLE,
575
+ zscore_ret_20 DOUBLE,
576
+ vx_term_slope DOUBLE,
577
+ regime_flag TEXT,
578
+ computed_at TIMESTAMP DEFAULT now(),
579
+ PRIMARY KEY (trade_date, ticker)
580
+ )
581
+ """)
582
+ conn.execute("""
583
+ CREATE INDEX IF NOT EXISTS idx_sff_ticker_date
584
+ ON silver_futures_features(ticker, trade_date)
585
+ """)
586
+
587
+ # ── View: latest COT positioning + futures price context per ticker ───
588
+ conn.execute("DROP VIEW IF EXISTS v_cot_positioning")
589
+ conn.execute("""
590
+ CREATE VIEW v_cot_positioning AS
591
+ WITH latest_cot AS (
592
+ SELECT *, ROW_NUMBER() OVER (PARTITION BY ticker ORDER BY report_date DESC) AS rn
593
+ FROM silver_cot_features
594
+ ),
595
+ latest_fut AS (
596
+ SELECT *, ROW_NUMBER() OVER (PARTITION BY ticker ORDER BY trade_date DESC) AS rn
597
+ FROM silver_futures_features
598
+ )
599
+ SELECT
600
+ c.ticker,
601
+ c.report_date,
602
+ c.noncomm_net,
603
+ c.net_pos_zscore_52w,
604
+ c.comm_net_zscore_52w,
605
+ c.spec_comm_divergence,
606
+ c.crowd_flag,
607
+ f.trade_date AS futures_date,
608
+ f.close_price AS futures_close,
609
+ f.zscore_20 AS futures_zscore_20,
610
+ f.vx_term_slope,
611
+ f.regime_flag
612
+ FROM latest_cot c
613
+ LEFT JOIN latest_fut f ON f.ticker = c.ticker || '1:COM' AND f.rn = 1
614
+ WHERE c.rn = 1
615
+ ORDER BY ABS(COALESCE(c.net_pos_zscore_52w, 0)) DESC
616
+ """)
617
+
618
+ # ══ GOLD LAYER ════════════════════
619
+ # Backtest results: runs, trades, portfolio snapshots, metrics, signals.
620
+
621
+ # ── Gold: backtest run metadata ───────────────────────────────────────
622
+ conn.execute("""
623
+ CREATE TABLE IF NOT EXISTS gold_backtest_runs (
624
+ run_id TEXT NOT NULL PRIMARY KEY,
625
+ config TEXT,
626
+ universe TEXT,
627
+ start_date DATE NOT NULL,
628
+ end_date DATE NOT NULL,
629
+ created_at TIMESTAMP DEFAULT now(),
630
+ fold_id INTEGER, -- walk-forward OOS fold (NULL = plain run)
631
+ wf_run_id TEXT -- parent walk-forward run (NULL = plain run)
632
+ )
633
+ """)
634
+ # ── Migrate: add fold_id / wf_run_id if the table predates walk-forward
635
+ try:
636
+ conn.execute("ALTER TABLE gold_backtest_runs ADD COLUMN fold_id INTEGER")
637
+ logger.info("Migrated gold_backtest_runs: added fold_id")
638
+ except Exception:
639
+ pass # column already exists
640
+ try:
641
+ conn.execute("ALTER TABLE gold_backtest_runs ADD COLUMN wf_run_id TEXT")
642
+ logger.info("Migrated gold_backtest_runs: added wf_run_id")
643
+ except Exception:
644
+ pass # column already exists
645
+
646
+ # ── Gold: individual simulated trades ─────────────────────────────────
647
+ conn.execute("""
648
+ CREATE TABLE IF NOT EXISTS gold_trades (
649
+ trade_id TEXT NOT NULL PRIMARY KEY,
650
+ run_id TEXT NOT NULL,
651
+ ticker TEXT NOT NULL,
652
+ entry_date DATE NOT NULL,
653
+ exit_date DATE,
654
+ direction INTEGER NOT NULL,
655
+ shares DOUBLE,
656
+ entry_price DOUBLE,
657
+ exit_price DOUBLE,
658
+ gross_pnl DOUBLE,
659
+ slippage_cost DOUBLE,
660
+ commission_cost DOUBLE,
661
+ net_pnl DOUBLE,
662
+ entry_regime TEXT,
663
+ exit_regime TEXT,
664
+ signal_type TEXT
665
+ )
666
+ """)
667
+ conn.execute("""
668
+ CREATE INDEX IF NOT EXISTS idx_gt_run_ticker
669
+ ON gold_trades(run_id, ticker, entry_date)
670
+ """)
671
+
672
+ # ── Gold: daily portfolio snapshots ───────────────────────────────────
673
+ conn.execute("""
674
+ CREATE TABLE IF NOT EXISTS gold_portfolio (
675
+ run_id TEXT NOT NULL,
676
+ trade_date DATE NOT NULL,
677
+ nav DOUBLE,
678
+ cash DOUBLE,
679
+ drawdown_pct DOUBLE,
680
+ n_positions INTEGER,
681
+ UNIQUE(run_id, trade_date)
682
+ )
683
+ """)
684
+ conn.execute("""
685
+ CREATE INDEX IF NOT EXISTS idx_gp_run_date
686
+ ON gold_portfolio(run_id, trade_date)
687
+ """)
688
+
689
+ # ── Gold: aggregated metrics per run ──────────────────────────────────
690
+ conn.execute("""
691
+ CREATE TABLE IF NOT EXISTS gold_metrics (
692
+ run_id TEXT NOT NULL PRIMARY KEY,
693
+ sharpe DOUBLE,
694
+ sortino DOUBLE,
695
+ mdd_pct DOUBLE,
696
+ mdd_duration_days INTEGER,
697
+ mdd_recovery_days INTEGER,
698
+ calmar DOUBLE,
699
+ win_rate DOUBLE,
700
+ profit_factor DOUBLE,
701
+ expectancy DOUBLE,
702
+ cost_drag DOUBLE,
703
+ ann_return DOUBLE,
704
+ ann_vol DOUBLE
705
+ )
706
+ """)
707
+
708
+ # ── Gold: walk-forward OOS aggregate summary per walk-forward run ─────
709
+ conn.execute("""
710
+ CREATE TABLE IF NOT EXISTS gold_oos_summary (
711
+ run_id TEXT NOT NULL,
712
+ run_ts TIMESTAMPTZ DEFAULT current_timestamp,
713
+ n_folds INTEGER,
714
+ mean_sharpe DOUBLE,
715
+ consistency_ratio DOUBLE,
716
+ combined_sharpe DOUBLE,
717
+ worst_fold_mdd DOUBLE,
718
+ split_type TEXT,
719
+ embargo_days INTEGER,
720
+ PRIMARY KEY (run_id)
721
+ )
722
+ """)
723
+
724
+ # ── Gold: immutable signal audit log ──────────────────────────────────
725
+ conn.execute("""
726
+ CREATE TABLE IF NOT EXISTS gold_signals (
727
+ signal_id TEXT NOT NULL PRIMARY KEY,
728
+ run_id TEXT NOT NULL,
729
+ ticker TEXT NOT NULL,
730
+ signal_date DATE NOT NULL,
731
+ direction INTEGER NOT NULL,
732
+ strength DOUBLE,
733
+ signal_type TEXT,
734
+ computed_at TIMESTAMP DEFAULT now()
735
+ )
736
+ """)
737
+ conn.execute("""
738
+ CREATE INDEX IF NOT EXISTS idx_gs_run_date
739
+ ON gold_signals(run_id, signal_date, ticker)
740
+ """)
741
+
742
+ finally:
743
+ conn.close()
744
+
745
+ logger.info(f"Database initialised at {DB_PATH}")
etl/__init__.py ADDED
File without changes
etl/chat_engine.py ADDED
@@ -0,0 +1,332 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Natural-language chat interface over the Equity Workbench/Polygon/EDGAR DuckDB database.
3
+
4
+ Supported providers:
5
+ CHAT_PROVIDER = deepseek | mimo | openai | anthropic | ollama
6
+ CHAT_MODEL = optional model override
7
+ """
8
+ import os
9
+ import re
10
+ from typing import Optional
11
+
12
+ import anthropic
13
+ import duckdb
14
+ import pandas as pd
15
+ from loguru import logger
16
+ from openai import OpenAI
17
+ from dotenv import load_dotenv
18
+
19
+ load_dotenv()
20
+
21
+ DB_PATH = os.getenv("DB_PATH", "./data/equity.duckdb")
22
+ _XIAOMI_OPENAI_KEY = os.getenv("XIAOMI_OPENAI_API_KEY", "")
23
+ _MIMO_BASE_URL = (
24
+ os.getenv("XIAOMI_OPENAI_BASE_URL", "https://token-plan-sgp.xiaomimimo.com/v1")
25
+ if _XIAOMI_OPENAI_KEY
26
+ else os.getenv("MIMO_BASE_URL", "http://localhost:11434/v1")
27
+ )
28
+
29
+ _PROVIDERS = {
30
+ # Pipeline stage 1 - Xiaomi MiMo via its OpenAI-compatible endpoint.
31
+ "mimo": {
32
+ "sdk": "openai",
33
+ "base_url": _MIMO_BASE_URL,
34
+ "model": os.getenv("MIMO_MODEL", "mimo-v2.5-pro"),
35
+ "api_key_env": "XIAOMI_OPENAI_API_KEY",
36
+ "allow_blank_key": False,
37
+ },
38
+ # Pipeline stage 2 - DeepSeek via its OpenAI-compatible endpoint.
39
+ "deepseek": {
40
+ "sdk": "openai",
41
+ "base_url": os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
42
+ "model": os.getenv("DEEPSEEK_MODEL", "deepseek-v4-flash"),
43
+ "api_key_env": "DEEPSEEK_OPENAI_API_KEY",
44
+ "allow_blank_key": False,
45
+ },
46
+ # Pipeline stage 3 / single-provider default — final review (Claude Sonnet)
47
+ "anthropic": {
48
+ "sdk": "anthropic",
49
+ "base_url": "https://api.anthropic.com",
50
+ "model": "claude-sonnet-4-6",
51
+ "api_key_env": "ANTHROPIC_API_KEY",
52
+ "allow_blank_key": False,
53
+ },
54
+ # Native OpenAI (unchanged)
55
+ "openai": {
56
+ "sdk": "openai",
57
+ "base_url": "https://api.openai.com/v1",
58
+ "model": "gpt-4o",
59
+ "api_key_env": "OPENAI_API_KEY",
60
+ "allow_blank_key": False,
61
+ },
62
+ # Local Ollama (unchanged)
63
+ "ollama": {
64
+ "sdk": "openai",
65
+ "base_url": os.getenv("OLLAMA_BASE_URL", "http://localhost:11434/v1"),
66
+ "model": os.getenv("OLLAMA_MODEL", "llama3.2"),
67
+ "api_key_env": "OLLAMA_API_KEY",
68
+ "allow_blank_key": True,
69
+ },
70
+ }
71
+
72
+ _PROVIDER = os.getenv("CHAT_PROVIDER", "deepseek").lower()
73
+ _CFG = _PROVIDERS.get(_PROVIDER, _PROVIDERS["deepseek"])
74
+ _MODEL = os.getenv("CHAT_MODEL") or _CFG["model"] # CHAT_MODEL env overrides provider default
75
+
76
+ SCHEMA = """
77
+ You have access to a DuckDB financial database with these tables:
78
+
79
+ IBKR live data:
80
+ - stock_quotes(ticker, ts, bid, ask, last, close, open, high, low, volume, vwap, created_at)
81
+ - option_quotes(ticker, expiry, strike, right, ts, bid, ask, last, volume, open_interest, implied_vol, delta, gamma, theta, vega, und_price, pv_dividend)
82
+ - option_chains(ticker, expiry, strike, right, exchange, fetched_at)
83
+ - etl_runs(id, run_type, status, rows_written, started_at, finished_at, message)
84
+
85
+ Polygon historical data:
86
+ - polygon_bars(ticker, ts, timespan, open, high, low, close, volume, vwap, transactions)
87
+ - polygon_snapshots(ticker, ts, bid, ask, last, prev_close, day_volume)
88
+ - polygon_option_snapshots(underlying, expiry, strike, right, ts, day_open, day_close, day_volume, open_interest, implied_vol, delta, gamma, theta, vega)
89
+ - polygon_tickers(ticker, name, market, primary_exchange, type, active, currency, description)
90
+
91
+ SEC EDGAR financials:
92
+ - edgar_filings(ticker, cik, form_type, filed_date, accession_number, primary_doc)
93
+ - edgar_facts(ticker, cik, taxonomy, concept, label, unit, value, period_start, period_end, form_type, filed_date)
94
+
95
+ Notes:
96
+ - Use DuckDB SQL syntax.
97
+ - Dates are stored as TEXT in ISO-8601 format. Cast with ::TIMESTAMP or ::DATE as needed.
98
+ - Always LIMIT results to 100 rows unless the user asks for more.
99
+ - For latest queries use QUALIFY ROW_NUMBER() OVER (PARTITION BY ticker ORDER BY ts DESC) = 1.
100
+ """
101
+
102
+ SYSTEM_PROMPT = f"""You are a financial data analyst assistant. The user will ask questions about their market data.
103
+
104
+ {SCHEMA}
105
+
106
+ Rules:
107
+ 1. If the question requires data, respond with ONLY a valid DuckDB SQL query. No markdown, no explanation.
108
+ 2. If the question is conversational or cannot be answered with SQL, respond with a plain English answer starting with "ANSWER:".
109
+ 3. Never make up data. Only query what exists in the schema above.
110
+ 4. Keep SQL readable and add brief inline comments for complex logic.
111
+ 5. Only generate read-only SELECT or WITH queries.
112
+ """
113
+
114
+
115
+ def _call_provider(provider: str, messages: list, max_tokens: int = 1024, model: Optional[str] = None) -> str:
116
+ """Call a specific provider by name and return the text reply."""
117
+ cfg = _PROVIDERS.get(provider, _PROVIDERS["anthropic"])
118
+ model = model or cfg["model"]
119
+ api_key = os.getenv(cfg["api_key_env"], "")
120
+ if not api_key and not cfg["allow_blank_key"]:
121
+ raise ValueError(f"{cfg['api_key_env']} is not set in .env (provider={provider}).")
122
+
123
+ if cfg.get("sdk", "openai") == "anthropic":
124
+ client = anthropic.Anthropic(api_key=api_key)
125
+ system = next((m["content"] for m in messages if m["role"] == "system"), "")
126
+ user_msgs = [m for m in messages if m["role"] != "system"]
127
+ resp = client.messages.create(
128
+ model=model, max_tokens=max_tokens, system=system, messages=user_msgs,
129
+ )
130
+ text_block = next(b for b in resp.content if b.type == "text")
131
+ return text_block.text
132
+ else:
133
+ client = OpenAI(api_key=api_key or "local", base_url=cfg["base_url"])
134
+ resp = client.chat.completions.create(
135
+ model=model, messages=messages, temperature=0.1, max_tokens=max_tokens,
136
+ )
137
+ return (resp.choices[0].message.content or "").strip()
138
+
139
+
140
+ def _call_llm(messages: list, max_tokens: int = 1024) -> str:
141
+ """Send messages to the configured provider, respecting CHAT_MODEL override."""
142
+ return _call_provider(_PROVIDER, messages, max_tokens, model=_MODEL)
143
+
144
+
145
+ _REVIEW_PROMPT = """You are reviewing a DuckDB SQL query generated by another model.
146
+ Check for:
147
+ 1. Correctness — does it answer the user's question?
148
+ 2. Safety — read-only SELECT/WITH only, no data mutation.
149
+ 3. DuckDB syntax — valid functions, correct quoting.
150
+
151
+ Respond with one of:
152
+ - APPROVED: <brief reason>
153
+ - CORRECTED: <brief reason>
154
+ ```sql
155
+ <corrected query>
156
+ ```
157
+ """
158
+
159
+
160
+ def chat_pipeline(question: str, history: Optional[list] = None, max_rows: int = 100) -> dict:
161
+ """
162
+ 3-stage pipeline: MiMo generates SQL → DeepSeek reviews → Claude approves.
163
+
164
+ Each stage can correct the SQL before passing it forward.
165
+ Falls back gracefully if a review stage fails.
166
+ """
167
+ messages = [{"role": "system", "content": SYSTEM_PROMPT}]
168
+ if history:
169
+ messages.extend(history)
170
+ messages.append({"role": "user", "content": question})
171
+
172
+ # Stage 1: MiMo generates SQL
173
+ try:
174
+ raw = _call_provider("mimo", messages, max_tokens=1024)
175
+ except Exception as e:
176
+ logger.error(f"MiMo generation failed: {e}")
177
+ return {"type": "error", "sql": None, "data": None, "answer": f"Generation error: {e}"}
178
+
179
+ if raw.startswith("ANSWER:"):
180
+ return {"type": "text", "sql": None, "data": None, "answer": raw[len("ANSWER:"):].strip()}
181
+
182
+ sql = _clean_sql(raw)
183
+
184
+ def _extract_corrected(review: str) -> Optional[str]:
185
+ m = re.search(r"```sql\n(.*?)\n```", review, re.DOTALL)
186
+ return m.group(1).strip() if m else None
187
+
188
+ # Stage 2: DeepSeek first review
189
+ try:
190
+ ds_review = _call_provider("deepseek", [
191
+ {"role": "system", "content": _REVIEW_PROMPT},
192
+ {"role": "user", "content": f"Question: {question}\n\nSQL:\n```sql\n{sql}\n```"},
193
+ ], max_tokens=512)
194
+ logger.info(f"DeepSeek review: {ds_review[:80]}")
195
+ if ds_review.startswith("CORRECTED:"):
196
+ corrected = _extract_corrected(ds_review)
197
+ if corrected:
198
+ sql = corrected
199
+ except Exception as e:
200
+ logger.warning(f"DeepSeek review skipped: {e}")
201
+
202
+ # Stage 3: Claude final review
203
+ try:
204
+ claude_review = _call_provider("anthropic", [
205
+ {"role": "system", "content": _REVIEW_PROMPT},
206
+ {"role": "user", "content": f"Question: {question}\n\nSQL:\n```sql\n{sql}\n```"},
207
+ ], max_tokens=512)
208
+ logger.info(f"Claude review: {claude_review[:80]}")
209
+ if claude_review.startswith("CORRECTED:"):
210
+ corrected = _extract_corrected(claude_review)
211
+ if corrected:
212
+ sql = corrected
213
+ elif not claude_review.startswith("APPROVED"):
214
+ return {"type": "error", "sql": sql, "data": None, "answer": f"Review rejected: {claude_review}"}
215
+ except Exception as e:
216
+ logger.warning(f"Claude review skipped: {e}")
217
+
218
+ validation_error = _validate_read_only_sql(sql)
219
+ if validation_error:
220
+ return {"type": "error", "sql": sql, "data": None, "answer": validation_error}
221
+
222
+ try:
223
+ with duckdb.connect(DB_PATH, read_only=True) as conn:
224
+ try:
225
+ conn.execute("SET enable_external_access=false")
226
+ except Exception as _e:
227
+ logger.debug(f"Could not set enable_external_access=false: {_e}")
228
+ df = conn.sql(sql).limit(max_rows).df()
229
+ except Exception as e:
230
+ logger.warning(f"SQL execution failed: {e}\nSQL: {sql}")
231
+ return {"type": "error", "sql": sql, "data": None, "answer": f"SQL error: {e}"}
232
+
233
+ answer = "The query returned no results." if df.empty else _summarise(question, df)
234
+ return {"type": "table", "sql": sql, "data": df.head(max_rows), "answer": answer}
235
+
236
+
237
+ def chat(question: str, history: Optional[list] = None, max_rows: int = 100) -> dict:
238
+ """
239
+ Ask a natural-language question about the database.
240
+
241
+ Returns a dict with type, sql, data, and answer fields.
242
+ """
243
+ messages = [{"role": "system", "content": SYSTEM_PROMPT}]
244
+ if history:
245
+ messages.extend(history)
246
+ messages.append({"role": "user", "content": question})
247
+
248
+ try:
249
+ reply = _call_llm(messages, max_tokens=1024)
250
+ except Exception as e:
251
+ logger.error(f"{_PROVIDER} API error: {e}")
252
+ return {"type": "error", "sql": None, "data": None, "answer": f"API error: {e}"}
253
+
254
+ if reply.startswith("ANSWER:"):
255
+ return {
256
+ "type": "text",
257
+ "sql": None,
258
+ "data": None,
259
+ "answer": reply[len("ANSWER:"):].strip(),
260
+ }
261
+
262
+ sql = _clean_sql(reply)
263
+ validation_error = _validate_read_only_sql(sql)
264
+ if validation_error:
265
+ return {"type": "error", "sql": sql, "data": None, "answer": validation_error}
266
+
267
+ try:
268
+ with duckdb.connect(DB_PATH, read_only=True) as conn:
269
+ try:
270
+ conn.execute("SET enable_external_access=false")
271
+ except Exception as _e:
272
+ logger.debug(f"Could not set enable_external_access=false: {_e}")
273
+ df = conn.sql(sql).limit(max_rows).df()
274
+ except Exception as e:
275
+ logger.warning(f"SQL execution failed: {e}\nSQL: {sql}")
276
+ return {"type": "error", "sql": sql, "data": None, "answer": f"SQL error: {e}"}
277
+
278
+ answer = "The query returned no results." if df.empty else _summarise(question, df)
279
+ return {"type": "table", "sql": sql, "data": df.head(max_rows), "answer": answer}
280
+
281
+
282
+ def _clean_sql(text: str) -> str:
283
+ text = text.strip()
284
+ if text.startswith("```"):
285
+ lines = text.splitlines()
286
+ text = "\n".join(lines[1:-1] if lines[-1] == "```" else lines[1:])
287
+ return text.strip()
288
+
289
+
290
+ def _strip_sql_comments(sql: str) -> str:
291
+ sql = re.sub(r"/\*.*?\*/", " ", sql, flags=re.DOTALL)
292
+ sql = re.sub(r"--[^\n\r]*", " ", sql)
293
+ return sql.strip()
294
+
295
+
296
+ def _validate_read_only_sql(sql: str) -> Optional[str]:
297
+ compact = _strip_sql_comments(sql)
298
+ if not compact:
299
+ return "The model did not return a SQL query."
300
+ if ";" in compact:
301
+ return "Rejected SQL with semicolons or multiple statements."
302
+
303
+ first = compact.lstrip().split(None, 1)[0].lower()
304
+ if first not in {"select", "with"}:
305
+ return "Rejected SQL because only SELECT and WITH queries are allowed."
306
+
307
+ blocked = {
308
+ "attach", "call", "copy", "create", "delete", "detach", "drop",
309
+ "export", "from_csv", "glob", "httpfs", "import", "insert",
310
+ "install", "load", "pragma", "read_blob", "read_csv", "read_json",
311
+ "read_parquet", "read_text", "set", "update",
312
+ }
313
+ tokens = set(re.findall(r"\b[a-z_][a-z0-9_]*\b", compact.lower()))
314
+ found = sorted(tokens & blocked)
315
+ if found:
316
+ return f"Rejected SQL containing blocked keyword/function: {', '.join(found)}."
317
+ return None
318
+
319
+
320
+ def _summarise(question: str, df: pd.DataFrame) -> str:
321
+ preview = df.head(5).to_markdown(index=False)
322
+ try:
323
+ return _call_llm([{
324
+ "role": "user",
325
+ "content": (
326
+ f'The user asked: "{question}"\n\n'
327
+ f"Query returned {len(df)} rows. Here are the first 5:\n{preview}\n\n"
328
+ "Write a concise 1-2 sentence plain-English answer. No markdown."
329
+ ),
330
+ }], max_tokens=200)
331
+ except Exception:
332
+ return f"Query returned {len(df)} rows."
frontend/index.html ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <meta name="theme-color" content="#0A0C10" />
8
+ <meta name="description" content="Evidence-first market research and execution analytics workbench." />
9
+ <title>IBKR Workbench</title>
10
+ </head>
11
+ <body>
12
+ <div id="root"></div>
13
+ <script type="module" src="/src/main.tsx"></script>
14
+ </body>
15
+ </html>
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "frontend",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc -b && vite build",
9
+ "lint": "oxlint",
10
+ "test": "vitest run",
11
+ "preview": "vite preview"
12
+ },
13
+ "dependencies": {
14
+ "axios": "^1.18.1",
15
+ "echarts": "^6.1.0",
16
+ "echarts-for-react": "^3.0.6",
17
+ "lucide-react": "^1.23.0",
18
+ "react": "^19.2.7",
19
+ "react-dom": "^19.2.7",
20
+ "react-markdown": "^10.1.0",
21
+ "react-router-dom": "^7.18.1",
22
+ "remark-gfm": "^4.0.1"
23
+ },
24
+ "devDependencies": {
25
+ "@tailwindcss/vite": "^4.3.2",
26
+ "@testing-library/jest-dom": "^6.9.1",
27
+ "@testing-library/react": "^16.3.2",
28
+ "@types/node": "^24.13.2",
29
+ "@types/react": "^19.2.17",
30
+ "@types/react-dom": "^19.2.3",
31
+ "@vitejs/plugin-react": "^6.0.3",
32
+ "jsdom": "^29.1.1",
33
+ "oxlint": "^1.71.0",
34
+ "tailwindcss": "^4.3.2",
35
+ "typescript": "~6.0.2",
36
+ "vite": "^8.1.1",
37
+ "vitest": "^4.1.10"
38
+ }
39
+ }
frontend/public/favicon.svg ADDED
frontend/public/icons.svg ADDED
frontend/src/App.css ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .counter {
2
+ font-size: 16px;
3
+ padding: 5px 10px;
4
+ border-radius: 5px;
5
+ color: var(--accent);
6
+ background: var(--accent-bg);
7
+ border: 2px solid transparent;
8
+ transition: border-color 0.3s;
9
+ margin-bottom: 24px;
10
+
11
+ &:hover {
12
+ border-color: var(--accent-border);
13
+ }
14
+ &:focus-visible {
15
+ outline: 2px solid var(--accent);
16
+ outline-offset: 2px;
17
+ }
18
+ }
19
+
20
+ .hero {
21
+ position: relative;
22
+
23
+ .base,
24
+ .framework,
25
+ .vite {
26
+ inset-inline: 0;
27
+ margin: 0 auto;
28
+ }
29
+
30
+ .base {
31
+ width: 170px;
32
+ position: relative;
33
+ z-index: 0;
34
+ }
35
+
36
+ .framework,
37
+ .vite {
38
+ position: absolute;
39
+ }
40
+
41
+ .framework {
42
+ z-index: 1;
43
+ top: 34px;
44
+ height: 28px;
45
+ transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
46
+ scale(1.4);
47
+ }
48
+
49
+ .vite {
50
+ z-index: 0;
51
+ top: 107px;
52
+ height: 26px;
53
+ width: auto;
54
+ transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
55
+ scale(0.8);
56
+ }
57
+ }
58
+
59
+ #center {
60
+ display: flex;
61
+ flex-direction: column;
62
+ gap: 25px;
63
+ place-content: center;
64
+ place-items: center;
65
+ flex-grow: 1;
66
+
67
+ @media (max-width: 1024px) {
68
+ padding: 32px 20px 24px;
69
+ gap: 18px;
70
+ }
71
+ }
72
+
73
+ #next-steps {
74
+ display: flex;
75
+ border-top: 1px solid var(--border);
76
+ text-align: left;
77
+
78
+ & > div {
79
+ flex: 1 1 0;
80
+ padding: 32px;
81
+ @media (max-width: 1024px) {
82
+ padding: 24px 20px;
83
+ }
84
+ }
85
+
86
+ .icon {
87
+ margin-bottom: 16px;
88
+ width: 22px;
89
+ height: 22px;
90
+ }
91
+
92
+ @media (max-width: 1024px) {
93
+ flex-direction: column;
94
+ text-align: center;
95
+ }
96
+ }
97
+
98
+ #docs {
99
+ border-right: 1px solid var(--border);
100
+
101
+ @media (max-width: 1024px) {
102
+ border-right: none;
103
+ border-bottom: 1px solid var(--border);
104
+ }
105
+ }
106
+
107
+ #next-steps ul {
108
+ list-style: none;
109
+ padding: 0;
110
+ display: flex;
111
+ gap: 8px;
112
+ margin: 32px 0 0;
113
+
114
+ .logo {
115
+ height: 18px;
116
+ }
117
+
118
+ a {
119
+ color: var(--text-h);
120
+ font-size: 16px;
121
+ border-radius: 6px;
122
+ background: var(--social-bg);
123
+ display: flex;
124
+ padding: 6px 12px;
125
+ align-items: center;
126
+ gap: 8px;
127
+ text-decoration: none;
128
+ transition: box-shadow 0.3s;
129
+
130
+ &:hover {
131
+ box-shadow: var(--shadow);
132
+ }
133
+ .button-icon {
134
+ height: 18px;
135
+ width: 18px;
136
+ }
137
+ }
138
+
139
+ @media (max-width: 1024px) {
140
+ margin-top: 20px;
141
+ flex-wrap: wrap;
142
+ justify-content: center;
143
+
144
+ li {
145
+ flex: 1 1 calc(50% - 8px);
146
+ }
147
+
148
+ a {
149
+ width: 100%;
150
+ justify-content: center;
151
+ box-sizing: border-box;
152
+ }
153
+ }
154
+ }
155
+
156
+ #spacer {
157
+ height: 88px;
158
+ border-top: 1px solid var(--border);
159
+ @media (max-width: 1024px) {
160
+ height: 48px;
161
+ }
162
+ }
163
+
164
+ .ticks {
165
+ position: relative;
166
+ width: 100%;
167
+
168
+ &::before,
169
+ &::after {
170
+ content: '';
171
+ position: absolute;
172
+ top: -4.5px;
173
+ border: 5px solid transparent;
174
+ }
175
+
176
+ &::before {
177
+ left: 0;
178
+ border-left-color: var(--border);
179
+ }
180
+ &::after {
181
+ right: 0;
182
+ border-right-color: var(--border);
183
+ }
184
+ }
frontend/src/App.test.tsx ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { cleanup, render, screen } from '@testing-library/react';
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import App from './App';
4
+
5
+ afterEach(() => {
6
+ cleanup();
7
+ window.history.pushState({}, '', '/');
8
+ });
9
+
10
+ describe('Workbench accessibility', () => {
11
+ it('provides skip navigation and a labeled chat composer', async () => {
12
+ window.history.pushState({}, '', '/chat');
13
+ render(<App />);
14
+
15
+ expect(screen.getByRole('link', { name: 'Skip to main content' })).toHaveAttribute('href', '#main-content');
16
+ expect(screen.getByRole('main')).toHaveAttribute('id', 'main-content');
17
+ expect(screen.getByRole('textbox', { name: 'Ask about portfolio data' })).toBeInTheDocument();
18
+ expect(screen.getByRole('link', { name: 'Chat' })).toHaveAttribute('aria-current', 'page');
19
+ });
20
+
21
+ it('exposes the selected history period to assistive technology', async () => {
22
+ window.history.pushState({}, '', '/history');
23
+ render(<App />);
24
+
25
+ expect(screen.getByRole('button', { name: '1Y history period' })).toHaveAttribute('aria-pressed', 'true');
26
+ expect(screen.getByRole('button', { name: '1M history period' })).toHaveAttribute('aria-pressed', 'false');
27
+ expect(screen.getByRole('combobox', { name: 'Ticker' })).toBeInTheDocument();
28
+ });
29
+ });
frontend/src/App.tsx ADDED
@@ -0,0 +1,967 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useRef, useEffect, useMemo } from 'react';
2
+ import type { ReactNode } from 'react';
3
+ import { BrowserRouter as Router, Routes, Route, Navigate, NavLink } from 'react-router-dom';
4
+ import {
5
+ MessageSquare, TrendingUp, BarChart2, Package, Link2,
6
+ Calculator as CalculatorIcon, Activity, Send, RefreshCw, ChevronUp, ChevronDown,
7
+ Minus, Database, CheckCircle, XCircle,
8
+ AlertCircle, Search, Zap, Cpu, Menu, X,
9
+ BookOpen, LineChart, ScrollText, Server
10
+ } from 'lucide-react';
11
+ import axios from 'axios';
12
+ import ReactMarkdown from 'react-markdown';
13
+ import remarkGfm from 'remark-gfm';
14
+ import { Analytics, AuditLog, Methodology, SystemOverview } from './ResearchOps';
15
+ import { ChartPanel } from './components/ChartPanel';
16
+ import type { ChartOption } from './components/ChartPanel';
17
+
18
+ const API = axios.create({ baseURL: '/api', timeout: 60000 });
19
+
20
+ // ── Helpers ───────────────────────────────────────────────────────────────────
21
+
22
+ const fmt = (n: number | null | undefined, dec = 2) =>
23
+ n == null ? '-' : n.toLocaleString('en-US', { minimumFractionDigits: dec, maximumFractionDigits: dec });
24
+ const fmtPct = (n: number | null | undefined) =>
25
+ n == null ? '-' : `${n >= 0 ? '+' : ''}${(n * 100).toFixed(2)}%`;
26
+ const clsPnl = (n: number | null | undefined) =>
27
+ n == null ? '' : n > 0 ? 'text-[var(--color-bullish)]' : n < 0 ? 'text-[var(--color-bearish)]' : 'text-[var(--color-secondary)]';
28
+
29
+ // ── Reusable components ───────────────────────────────────────────────────────
30
+
31
+ const StatCard = ({ label, value, sub, up }: { label: string; value: string; sub?: string; up?: boolean | null }) => (
32
+ <div className="stat-card min-w-0">
33
+ <p className="text-[0.7rem] font-semibold uppercase tracking-widest text-[var(--color-secondary)] mb-1">{label}</p>
34
+ <p className={`text-xl font-semibold tabular-nums truncate ${up == null ? '' : up ? 'text-[var(--color-bullish)]' : 'text-[var(--color-bearish)]'}`} title={value}>
35
+ {value}
36
+ </p>
37
+ {sub && <p className="text-xs text-[var(--color-muted)] mt-0.5">{sub}</p>}
38
+ </div>
39
+ );
40
+
41
+ const PnlArrow = ({ v }: { v: number }) =>
42
+ v > 0 ? <ChevronUp size={14} className="text-[var(--color-bullish)]" />
43
+ : v < 0 ? <ChevronDown size={14} className="text-[var(--color-bearish)]" />
44
+ : <Minus size={14} className="text-[var(--color-secondary)]" />;
45
+
46
+ const chartText = '#B8BDC5';
47
+ const chartGrid = 'rgba(199,203,209,0.10)';
48
+
49
+ // ── Layout ────────────────────────────────────────────────────────────────────
50
+
51
+ const MARKET_NAV = [
52
+ { to: '/chat', icon: MessageSquare, label: 'Chat' },
53
+ { to: '/quotes', icon: TrendingUp, label: 'Quotes' },
54
+ { to: '/history', icon: BarChart2, label: 'History' },
55
+ { to: '/polygon', icon: Package, label: 'Polygon' },
56
+ { to: '/options', icon: Link2, label: 'Options' },
57
+ { to: '/calculator', icon: CalculatorIcon, label: 'Calculator' },
58
+ { to: '/health', icon: Activity, label: 'Health' },
59
+ ];
60
+
61
+ const RESEARCH_NAV = [
62
+ { to: '/methodology', icon: BookOpen, label: 'Methodology' },
63
+ { to: '/analytics', icon: LineChart, label: 'Analytics' },
64
+ { to: '/audit', icon: ScrollText, label: 'Audit Log' },
65
+ { to: '/system', icon: Server, label: 'System Overview' },
66
+ ];
67
+
68
+ const Layout = ({ children }: { children: ReactNode }) => {
69
+ const [sidebarOpen, setSidebarOpen] = useState(false);
70
+ const [isDesktop, setIsDesktop] = useState(() =>
71
+ typeof window.matchMedia === 'function' ? window.matchMedia('(min-width: 1024px)').matches : true
72
+ );
73
+
74
+ useEffect(() => {
75
+ if (typeof window.matchMedia !== 'function') return;
76
+ const media = window.matchMedia('(min-width: 1024px)');
77
+ const updateViewport = (event: MediaQueryListEvent) => setIsDesktop(event.matches);
78
+ media.addEventListener('change', updateViewport);
79
+ return () => media.removeEventListener('change', updateViewport);
80
+ }, []);
81
+
82
+ useEffect(() => {
83
+ if (!sidebarOpen || isDesktop) return;
84
+ const closeOnEscape = (event: KeyboardEvent) => {
85
+ if (event.key === 'Escape') setSidebarOpen(false);
86
+ };
87
+ document.addEventListener('keydown', closeOnEscape);
88
+ return () => document.removeEventListener('keydown', closeOnEscape);
89
+ }, [sidebarOpen, isDesktop]);
90
+
91
+ return (
92
+ <div className="flex min-h-dvh" style={{ background: 'var(--color-background)' }}>
93
+ <a href="#main-content" className="skip-link">Skip to main content</a>
94
+ {sidebarOpen && (
95
+ <button
96
+ type="button"
97
+ aria-label="Close navigation overlay"
98
+ className="fixed inset-0 bg-black/60 backdrop-blur-sm z-30 lg:hidden"
99
+ onClick={() => setSidebarOpen(false)}
100
+ />
101
+ )}
102
+
103
+ <aside
104
+ aria-label="IBKR Workbench"
105
+ {...(!isDesktop && !sidebarOpen ? { inert: true, 'aria-hidden': true } : {})}
106
+ className={`glass-sidebar w-72 lg:w-[248px] flex flex-col px-4 py-5 gap-1 fixed lg:sticky inset-y-0 left-0 top-0 h-screen z-40 shrink-0 transition-transform duration-300 ease-out ${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}`}
107
+ >
108
+ <div className="px-1 pb-6">
109
+ <div className="flex items-center justify-between gap-2">
110
+ <div className="flex items-center gap-3">
111
+ <div className="w-9 h-9 rounded-lg flex items-center justify-center bg-[var(--color-accent-fill)]">
112
+ <Search size={18} className="text-[var(--color-accent-ink)]" />
113
+ </div>
114
+ <p className="text-lg font-semibold leading-none tracking-tight">IBKR Workbench</p>
115
+ </div>
116
+ <button
117
+ type="button"
118
+ className="lg:hidden btn-ghost !p-2"
119
+ onClick={() => setSidebarOpen(false)}
120
+ aria-label="Close navigation"
121
+ >
122
+ <X size={16} />
123
+ </button>
124
+ </div>
125
+ </div>
126
+
127
+ <p className="text-[0.625rem] font-semibold uppercase tracking-[0.12em] text-[var(--color-muted)] px-2 mb-2">For Users</p>
128
+
129
+ {MARKET_NAV.map(({ to, icon: Icon, label }) => (
130
+ <NavLink key={to} to={to}
131
+ onClick={() => setSidebarOpen(false)}
132
+ className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
133
+ <Icon size={15} strokeWidth={1.8} />
134
+ {label}
135
+ </NavLink>
136
+ ))}
137
+
138
+ <p className="text-[0.625rem] font-semibold uppercase tracking-[0.12em] text-[var(--color-muted)] px-2 mb-2 mt-5">Research &amp; Diagnostics</p>
139
+
140
+ {RESEARCH_NAV.map(({ to, icon: Icon, label }) => (
141
+ <NavLink key={to} to={to}
142
+ onClick={() => setSidebarOpen(false)}
143
+ className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
144
+ <Icon size={15} strokeWidth={1.8} />
145
+ {label}
146
+ </NavLink>
147
+ ))}
148
+
149
+ <div className="mt-auto pt-4 border-t border-[var(--color-border)]">
150
+ <div className="flex items-center gap-2 px-2 py-1">
151
+ <div className="w-1.5 h-1.5 rounded-full bg-[var(--color-bullish)] status-pulse" />
152
+ <span className="text-xs text-[var(--color-secondary)]">DB connected</span>
153
+ </div>
154
+ </div>
155
+ </aside>
156
+
157
+ <main
158
+ id="main-content"
159
+ tabIndex={-1}
160
+ {...(!isDesktop && sidebarOpen ? { inert: true, 'aria-hidden': true } : {})}
161
+ className="flex-1 flex flex-col min-w-0 relative z-10"
162
+ >
163
+ <header className="glass-header h-14 flex items-center px-6 gap-3 sticky top-0 z-10">
164
+ <button
165
+ type="button"
166
+ className="lg:hidden btn-ghost !p-2"
167
+ onClick={() => setSidebarOpen(true)}
168
+ aria-label="Open navigation"
169
+ >
170
+ <Menu size={16} />
171
+ </button>
172
+ <div className="flex-1" />
173
+ <div className="glass-input flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--color-secondary)]">
174
+ <Search size={13} />
175
+ <span>Search tickers...</span>
176
+ </div>
177
+ <div className="flex items-center gap-1.5">
178
+ <div className="w-1.5 h-1.5 rounded-full bg-[var(--color-bullish)] status-pulse" />
179
+ <span className="text-xs text-[var(--color-secondary)]">Live</span>
180
+ </div>
181
+ </header>
182
+ <div className="flex-1 overflow-auto p-4 sm:p-6">{children}</div>
183
+ </main>
184
+ </div>
185
+ );
186
+ };
187
+
188
+ // ── Page: Chat ────────────────────────────────────────────────────────────────
189
+
190
+ interface Msg { id: number; role: 'user' | 'assistant'; content: string; sql?: string; }
191
+ let _msgId = 0;
192
+ const nextId = () => ++_msgId;
193
+
194
+ const SUGGESTED = [
195
+ 'Show me the top 5 semiconductors by 30-day return',
196
+ "What is NVDA's average daily volume this year?",
197
+ 'List all options with IV > 80% expiring in July',
198
+ 'Compare MU and AMD price performance year-to-date',
199
+ ];
200
+
201
+ const Chat = () => {
202
+ const [msgs, setMsgs] = useState<Msg[]>([]);
203
+ const [input, setInput] = useState('');
204
+ const [loading, setLoading] = useState(false);
205
+ const bottomRef = useRef<HTMLDivElement>(null);
206
+
207
+ useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [msgs]);
208
+
209
+ const send = async (text: string) => {
210
+ if (!text.trim() || loading) return;
211
+ setMsgs(m => [...m, { id: nextId(), role: 'user', content: text }]);
212
+ setInput('');
213
+ setLoading(true);
214
+ try {
215
+ const { data } = await API.post('/chat', { message: text });
216
+ setMsgs(m => [...m, { id: nextId(), role: 'assistant', content: data.response || data.message || '(no response)', sql: data.sql }]);
217
+ } catch {
218
+ setMsgs(m => [...m, { id: nextId(), role: 'assistant', content: 'Could not reach the chat API. Ensure the backend is running.' }]);
219
+ } finally {
220
+ setLoading(false);
221
+ }
222
+ };
223
+
224
+ return (
225
+ <div className="max-w-3xl mx-auto flex flex-col min-h-[calc(100dvh-7.5rem)] gap-4">
226
+ <div>
227
+ <h1 className="section-heading">Ask anything</h1>
228
+ <p className="text-xs text-[var(--color-secondary)] mt-1">Text-to-SQL over your DuckDB: stocks, options, EDGAR, COT</p>
229
+ </div>
230
+
231
+ <div className="flex-1 overflow-y-auto flex flex-col gap-3 pr-1">
232
+ {msgs.length === 0 && (
233
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-4">
234
+ {SUGGESTED.map(s => (
235
+ <button key={s} onClick={() => send(s)}
236
+ className="glass-card text-left p-3 text-xs text-[var(--color-secondary)] hover:text-[var(--color-primary)] transition-colors">
237
+ {s}
238
+ </button>
239
+ ))}
240
+ </div>
241
+ )}
242
+ {msgs.map((m) => (
243
+ <div key={m.id} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
244
+ <div className={`max-w-[85%] px-4 py-3 text-sm ${m.role === 'user' ? 'msg-user' : 'msg-assistant'}`}>
245
+ <div className="prose-refined">
246
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>{m.content}</ReactMarkdown>
247
+ </div>
248
+ {m.sql && (
249
+ <details className="mt-2">
250
+ <summary className="text-[0.7rem] text-[var(--color-secondary)] cursor-pointer">SQL</summary>
251
+ <pre className="text-[0.7rem] mt-1 overflow-x-auto">{m.sql}</pre>
252
+ </details>
253
+ )}
254
+ </div>
255
+ </div>
256
+ ))}
257
+ {loading && (
258
+ <div className="flex justify-start">
259
+ <div className="msg-assistant px-4 py-3">
260
+ <div className="flex gap-1">
261
+ {[0,1,2].map(i => (
262
+ <div key={i} className="w-1.5 h-1.5 rounded-full bg-[var(--color-accent)]"
263
+ style={{ animation: `pulse 1.2s ${i * 0.2}s infinite` }} />
264
+ ))}
265
+ </div>
266
+ </div>
267
+ </div>
268
+ )}
269
+ <div ref={bottomRef} />
270
+ </div>
271
+
272
+ <div className="glass-input flex items-end gap-2 p-3">
273
+ <label htmlFor="workbench-question" className="sr-only">Ask about portfolio data</label>
274
+ <textarea
275
+ id="workbench-question"
276
+ className="flex-1 bg-transparent text-sm text-[var(--color-primary)] placeholder-[var(--color-muted)] resize-none outline-none min-h-[2.5rem] max-h-32"
277
+ placeholder="Ask about your portfolio data..."
278
+ value={input}
279
+ onChange={e => setInput(e.target.value)}
280
+ onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(input); } }}
281
+ rows={1}
282
+ />
283
+ <button onClick={() => send(input)} disabled={loading || !input.trim()}
284
+ className="btn-primary py-2 px-3 disabled:opacity-40 disabled:cursor-not-allowed">
285
+ <Send size={14} />
286
+ </button>
287
+ </div>
288
+ </div>
289
+ );
290
+ };
291
+
292
+ // ── Page: Quotes ──────────────────────────────────────────────────────────────
293
+
294
+ const MOCK_QUOTES = [
295
+ { ticker:'NVDA', last:131.20, chg: 0.0312, vol:42_183_000, high:133.50, low:129.10 },
296
+ { ticker:'AMD', last:168.45, chg: 0.0187, vol:28_441_000, high:170.00, low:166.20 },
297
+ { ticker:'AVGO', last:210.30, chg:-0.0093, vol:12_002_000, high:213.80, low:209.50 },
298
+ { ticker:'MU', last: 98.72, chg: 0.0421, vol:19_837_000, high:100.10, low: 97.30 },
299
+ { ticker:'INTC', last: 22.58, chg:-0.0215, vol:45_992_000, high: 23.20, low: 22.30 },
300
+ { ticker:'QCOM', last:156.80, chg: 0.0068, vol: 8_774_000, high:157.90, low:155.10 },
301
+ { ticker:'AMAT', last:178.50, chg: 0.0143, vol:10_209_000, high:180.00, low:177.20 },
302
+ { ticker:'KLAC', last:625.40, chg:-0.0055, vol: 1_437_000, high:630.10, low:622.50 },
303
+ { ticker:'LRCX', last:685.90, chg: 0.0212, vol: 1_882_000, high:690.00, low:680.10 },
304
+ { ticker:'TSM', last:178.65, chg: 0.0337, vol:14_553_000, high:180.00, low:177.00 },
305
+ ];
306
+
307
+ const Quotes = () => {
308
+ const [q, setQ] = useState(MOCK_QUOTES);
309
+ const [loading, setLoading] = useState(false);
310
+
311
+ const refresh = async () => {
312
+ setLoading(true);
313
+ try {
314
+ const { data } = await API.get('/quotes');
315
+ if (Array.isArray(data)) setQ(data);
316
+ } catch { /* use mock */ }
317
+ finally { setLoading(false); }
318
+ };
319
+
320
+ return (
321
+ <div className="space-y-6">
322
+ <div className="flex items-center justify-between">
323
+ <div>
324
+ <h1 className="section-heading">Stock Quotes</h1>
325
+ <p className="text-xs text-[var(--color-secondary)] mt-1">Semiconductor universe / Last update: just now</p>
326
+ </div>
327
+ <button onClick={refresh} disabled={loading} className="btn-ghost text-xs gap-1.5">
328
+ <RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
329
+ Refresh
330
+ </button>
331
+ </div>
332
+
333
+ <div className="grid grid-cols-2 xl:grid-cols-4 gap-3">
334
+ <StatCard label="Universe" value={String(q.length)} sub="tickers" />
335
+ <StatCard label="Gainers" value={String(q.filter(r=>r.chg>0).length)} up={true} />
336
+ <StatCard label="Losers" value={String(q.filter(r=>r.chg<0).length)} up={false} />
337
+ <StatCard label="Avg Chg" value={fmtPct(q.reduce((s,r)=>s+r.chg,0)/q.length)}
338
+ up={q.reduce((s,r)=>s+r.chg,0)>0} />
339
+ </div>
340
+
341
+ <div className="glass-card overflow-hidden">
342
+ <table className="data-table">
343
+ <thead>
344
+ <tr>
345
+ <th>Ticker</th>
346
+ <th className="text-right">Last</th>
347
+ <th className="text-right">Chg %</th>
348
+ <th className="text-right">High</th>
349
+ <th className="text-right">Low</th>
350
+ <th className="text-right">Volume</th>
351
+ </tr>
352
+ </thead>
353
+ <tbody>
354
+ {q.map(row => (
355
+ <tr key={row.ticker}>
356
+ <td>
357
+ <span className="font-semibold text-[var(--color-accent-bright)]">{row.ticker}</span>
358
+ </td>
359
+ <td className="text-right font-semibold">${fmt(row.last)}</td>
360
+ <td className={`text-right font-semibold ${clsPnl(row.chg)}`}>
361
+ <span className="flex items-center justify-end gap-0.5">
362
+ <PnlArrow v={row.chg} />
363
+ {fmtPct(row.chg)}
364
+ </span>
365
+ </td>
366
+ <td className="text-right text-[var(--color-secondary)]">${fmt(row.high)}</td>
367
+ <td className="text-right text-[var(--color-secondary)]">${fmt(row.low)}</td>
368
+ <td className="text-right text-[var(--color-secondary)]">{(row.vol/1e6).toFixed(1)}M</td>
369
+ </tr>
370
+ ))}
371
+ </tbody>
372
+ </table>
373
+ </div>
374
+ </div>
375
+ );
376
+ };
377
+
378
+ // ── Page: History ─────────────────────────────────────────────────────────────
379
+
380
+ const TICKERS_LIST = ['NVDA','AMD','MU','AVGO','INTC','QCOM','AMAT','KLAC','LRCX','TSM'];
381
+
382
+ function genHistory(ticker: string) {
383
+ // Deterministic seed-ish price per ticker
384
+ const seeds: Record<string, number> = { NVDA:80, AMD:120, MU:60, AVGO:180, INTC:35,
385
+ QCOM:140, AMAT:160, KLAC:560, LRCX:610, TSM:140 };
386
+ let price = seeds[ticker] ?? 80;
387
+ return Array.from({ length: 90 }, (_, i) => {
388
+ price = price * (1 + (Math.random() - 0.47) * 0.025);
389
+ const d = new Date(Date.now() - (89 - i) * 86400000);
390
+ return {
391
+ date: `${d.getMonth()+1}/${d.getDate()}`,
392
+ close: +price.toFixed(2),
393
+ volume: Math.round(Math.random() * 30e6 + 5e6),
394
+ };
395
+ });
396
+ }
397
+
398
+ type StockBar = {
399
+ trade_date: string;
400
+ open: number | null;
401
+ high: number | null;
402
+ low: number | null;
403
+ close: number | null;
404
+ volume: number | null;
405
+ vwap: number | null;
406
+ daily_return: number | null;
407
+ zscore_20: number | null;
408
+ zscore_ret_20: number | null;
409
+ ma_20: number | null;
410
+ ma_50: number | null;
411
+ vwap_20: number | null;
412
+ };
413
+
414
+ type StockHistoryResponse = {
415
+ ticker: string;
416
+ period: string;
417
+ latest: StockBar;
418
+ metrics: {
419
+ period_return: number | null;
420
+ high: number | null;
421
+ low: number | null;
422
+ avg_volume: number | null;
423
+ };
424
+ series: StockBar[];
425
+ };
426
+
427
+ type TickerMeta = { ticker: string; rows: number; first_date: string; last_date: string };
428
+
429
+ const PERIODS = [
430
+ { label: '1M', value: '1m' },
431
+ { label: '3M', value: '3m' },
432
+ { label: '6M', value: '6m' },
433
+ { label: '1Y', value: '1y' },
434
+ { label: '2Y', value: '2y' },
435
+ ];
436
+
437
+ const History = () => {
438
+ const [ticker, setTicker] = useState('NVDA');
439
+ const [period, setPeriod] = useState('1y');
440
+ const [tickers, setTickers] = useState<TickerMeta[]>([]);
441
+ const [history, setHistory] = useState<StockHistoryResponse | null>(null);
442
+ const [loading, setLoading] = useState(false);
443
+ const [error, setError] = useState<string | null>(null);
444
+
445
+ useEffect(() => {
446
+ API.get('/visualizations/tickers')
447
+ .then(({ data }) => {
448
+ if (Array.isArray(data.tickers)) setTickers(data.tickers);
449
+ })
450
+ .catch(() => setTickers([]));
451
+ }, []);
452
+
453
+ useEffect(() => {
454
+ setLoading(true);
455
+ setError(null);
456
+ API.get('/visualizations/stock-history', { params: { ticker, period } })
457
+ .then(({ data }) => setHistory(data))
458
+ .catch(() => {
459
+ setHistory(null);
460
+ setError('Could not load daily bars from the visualization API.');
461
+ })
462
+ .finally(() => setLoading(false));
463
+ }, [ticker, period]);
464
+
465
+ const data = history?.series ?? [];
466
+ const latest = history?.latest;
467
+ const ret = history?.metrics.period_return ?? null;
468
+ const isUp = (ret ?? 0) >= 0;
469
+ const accentColor = isUp ? '#34D399' : '#F87171';
470
+ const axisDates = data.map(d => d.trade_date);
471
+
472
+ const tooltip = {
473
+ trigger: 'axis',
474
+ backgroundColor: 'rgba(24,27,32,0.98)',
475
+ borderColor: '#32363D',
476
+ textStyle: { color: '#F3F1EA', fontSize: 12 },
477
+ } as const;
478
+
479
+ const priceOption: ChartOption = {
480
+ animation: false,
481
+ color: ['#43D19E', '#FF727F', '#D6B65A', '#C7CBD1'],
482
+ tooltip: { ...tooltip, axisPointer: { type: 'cross' } },
483
+ grid: { top: 24, right: 28, bottom: 38, left: 54 },
484
+ xAxis: {
485
+ type: 'category',
486
+ data: axisDates,
487
+ axisLine: { lineStyle: { color: chartGrid } },
488
+ axisLabel: { color: chartText, fontSize: 10 },
489
+ },
490
+ yAxis: {
491
+ type: 'value',
492
+ scale: true,
493
+ axisLabel: { color: chartText, formatter: '${value}' },
494
+ splitLine: { lineStyle: { color: chartGrid } },
495
+ },
496
+ dataZoom: [{ type: 'inside' }, { type: 'slider', height: 18, bottom: 6, borderColor: 'rgba(214,182,90,0.24)' }],
497
+ series: [
498
+ {
499
+ name: 'OHLC',
500
+ type: 'candlestick',
501
+ data: data.map(d => [d.open, d.close, d.low, d.high]),
502
+ itemStyle: {
503
+ color: '#34D399',
504
+ color0: '#F87171',
505
+ borderColor: '#34D399',
506
+ borderColor0: '#F87171',
507
+ },
508
+ },
509
+ {
510
+ name: 'MA 20',
511
+ type: 'line',
512
+ data: data.map(d => d.ma_20),
513
+ smooth: true,
514
+ showSymbol: false,
515
+ lineStyle: { width: 1.3, color: '#D6B65A' },
516
+ },
517
+ {
518
+ name: 'VWAP 20',
519
+ type: 'line',
520
+ data: data.map(d => d.vwap_20),
521
+ smooth: true,
522
+ showSymbol: false,
523
+ lineStyle: { width: 1.2, color: '#C7CBD1' },
524
+ },
525
+ ],
526
+ };
527
+
528
+ const volumeOption: ChartOption = {
529
+ animation: false,
530
+ tooltip,
531
+ grid: { top: 12, right: 24, bottom: 24, left: 54 },
532
+ xAxis: { type: 'category', data: axisDates, axisLabel: { show: false }, axisLine: { lineStyle: { color: chartGrid } } },
533
+ yAxis: {
534
+ type: 'value',
535
+ axisLabel: { color: chartText, formatter: (v: number) => `${(v / 1_000_000).toFixed(0)}M` },
536
+ splitLine: { lineStyle: { color: chartGrid } },
537
+ },
538
+ series: [{
539
+ name: 'Volume',
540
+ type: 'bar',
541
+ data: data.map(d => d.volume),
542
+ itemStyle: { color: 'rgba(214,182,90,0.42)', borderRadius: [2, 2, 0, 0] },
543
+ }],
544
+ };
545
+
546
+ const zscoreOption: ChartOption = {
547
+ animation: false,
548
+ tooltip,
549
+ grid: { top: 18, right: 24, bottom: 28, left: 44 },
550
+ xAxis: { type: 'category', data: axisDates, axisLabel: { color: chartText, fontSize: 10 }, axisLine: { lineStyle: { color: chartGrid } } },
551
+ yAxis: { type: 'value', axisLabel: { color: chartText }, splitLine: { lineStyle: { color: chartGrid } } },
552
+ series: [{
553
+ name: 'Price Z 20',
554
+ type: 'line',
555
+ data: data.map(d => d.zscore_20),
556
+ showSymbol: false,
557
+ lineStyle: { width: 1.4, color: accentColor },
558
+ markLine: {
559
+ symbol: 'none',
560
+ label: { color: chartText },
561
+ lineStyle: { color: 'rgba(248,113,113,0.5)', type: 'dashed' },
562
+ data: [{ yAxis: 3 }, { yAxis: -3 }],
563
+ },
564
+ }],
565
+ };
566
+
567
+ return (
568
+ <div className="space-y-6">
569
+ <div className="flex items-center justify-between flex-wrap gap-3">
570
+ <div>
571
+ <h1 className="section-heading">Price History</h1>
572
+ <p className="text-xs text-[var(--color-secondary)] mt-1">Daily OHLCV, VWAP and silver-layer z-scores from DuckDB</p>
573
+ </div>
574
+ <div className="flex items-center gap-2">
575
+ <label htmlFor="history-ticker" className="sr-only">Ticker</label>
576
+ <select id="history-ticker" value={ticker} onChange={e=>setTicker(e.target.value)}
577
+ className="glass-input px-3 py-1.5 text-sm text-[var(--color-primary)] outline-none cursor-pointer">
578
+ {(tickers.length ? tickers.map(t => t.ticker) : TICKERS_LIST).map(t=><option key={t} value={t}>{t}</option>)}
579
+ </select>
580
+ {PERIODS.map(p=>(
581
+ <button key={p.value} onClick={()=>setPeriod(p.value)}
582
+ aria-label={`${p.label} history period`}
583
+ aria-pressed={period === p.value}
584
+ className={`px-3 py-1.5 text-xs rounded-md transition-all ${period===p.value
585
+ ? 'bg-[var(--color-accent)] text-white'
586
+ : 'btn-ghost'}`}>{p.label}</button>
587
+ ))}
588
+ </div>
589
+ </div>
590
+
591
+ <div className="grid grid-cols-2 xl:grid-cols-4 gap-3">
592
+ <StatCard label="Current" value={loading ? 'Loading' : `$${fmt(latest?.close)}`} />
593
+ <StatCard label={`${period.toUpperCase()} Return`} value={fmtPct(ret)} up={ret == null ? null : isUp} />
594
+ <StatCard label="Period High" value={`$${fmt(history?.metrics.high)}`} />
595
+ <StatCard label="Avg Volume" value={history?.metrics.avg_volume == null ? '-' : `${(history.metrics.avg_volume/1e6).toFixed(1)}M`} />
596
+ </div>
597
+
598
+ {error && <div className="glass-card p-4 text-sm text-[var(--color-bearish)]">{error}</div>}
599
+
600
+ <div className="glass-card p-4">
601
+ <p className="text-sm font-semibold mb-4">{ticker} · {period.toUpperCase()} OHLC</p>
602
+ <ChartPanel option={priceOption} height={340} label={`${ticker} ${period.toUpperCase()} OHLC price chart`} />
603
+ </div>
604
+
605
+ <div className="glass-card p-4">
606
+ <p className="text-sm font-semibold mb-4">Volume</p>
607
+ <ChartPanel option={volumeOption} height={120} label={`${ticker} trading volume chart`} />
608
+ </div>
609
+
610
+ <div className="glass-card p-4">
611
+ <p className="text-sm font-semibold mb-4">20D Price Z-Score</p>
612
+ <ChartPanel option={zscoreOption} height={180} label={`${ticker} 20-day price z-score chart`} />
613
+ </div>
614
+ </div>
615
+ );
616
+ };
617
+
618
+ // ── Page: Polygon OHLCV ───────────────────────────────────────────────────────
619
+
620
+ const Polygon = () => {
621
+ const [ticker, setTicker] = useState('NVDA');
622
+ // Memoize so OHLC values don't re-randomize on every render
623
+ const rows = useMemo(() => genHistory(ticker).slice(-20).reverse().map(row => {
624
+ const o = row.close * (1 - Math.random()*0.01);
625
+ const h = row.close * (1 + Math.random()*0.015);
626
+ const l = row.close * (1 - Math.random()*0.015);
627
+ return { ...row, open: o, high: h, low: l, up: row.close > o };
628
+ }), [ticker]);
629
+
630
+ return (
631
+ <div className="space-y-6">
632
+ <div className="flex items-center justify-between">
633
+ <div>
634
+ <h1 className="section-heading">Polygon OHLCV</h1>
635
+ <p className="text-xs text-[var(--color-secondary)] mt-1">Bronze-layer daily bars / equity.duckdb to polygon_bars</p>
636
+ </div>
637
+ <label htmlFor="polygon-ticker" className="sr-only">Polygon ticker</label>
638
+ <select id="polygon-ticker" value={ticker} onChange={e=>setTicker(e.target.value)}
639
+ className="glass-input px-3 py-1.5 text-sm text-[var(--color-primary)] outline-none">
640
+ {TICKERS_LIST.map(t=><option key={t} value={t}>{t}</option>)}
641
+ </select>
642
+ </div>
643
+
644
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
645
+ <StatCard label="Bronze Rows" value="2,996,665" sub="polygon_bars total" />
646
+ <StatCard label="Day Bars" value="2021 - Jul 2026" sub="45 tickers" />
647
+ <StatCard label="Minute Bars" value="5wk stale" sub="92 tickers / needs refresh" up={false} />
648
+ </div>
649
+
650
+ <div className="glass-card overflow-hidden">
651
+ <table className="data-table">
652
+ <thead>
653
+ <tr>
654
+ <th>Date</th>
655
+ <th className="text-right">Open</th>
656
+ <th className="text-right">High</th>
657
+ <th className="text-right">Low</th>
658
+ <th className="text-right">Close</th>
659
+ <th className="text-right">Volume</th>
660
+ </tr>
661
+ </thead>
662
+ <tbody>
663
+ {rows.map((row, i) => (
664
+ <tr key={i}>
665
+ <td className="text-[var(--color-secondary)]">{row.date}</td>
666
+ <td className="text-right">${fmt(row.open)}</td>
667
+ <td className="text-right text-[var(--color-bullish)]">${fmt(row.high)}</td>
668
+ <td className="text-right text-[var(--color-bearish)]">${fmt(row.low)}</td>
669
+ <td className={`text-right font-semibold ${row.up ? 'text-[var(--color-bullish)]' : 'text-[var(--color-bearish)]'}`}>
670
+ ${fmt(row.close)}
671
+ </td>
672
+ <td className="text-right text-[var(--color-secondary)]">{(row.volume/1e6).toFixed(1)}M</td>
673
+ </tr>
674
+ ))}
675
+ </tbody>
676
+ </table>
677
+ </div>
678
+ </div>
679
+ );
680
+ };
681
+
682
+ // ── Page: Options Chain ───────────────────────────────────────────────────────
683
+
684
+ function genOptions(underlying: number) {
685
+ const strikes = [-15,-10,-5,0,5,10,15].map(d => Math.round(underlying + d));
686
+ return strikes.map(K => {
687
+ const iv_c = 0.25 + (K < underlying ? 0.05 : 0) + Math.random()*0.05;
688
+ const iv_p = iv_c + 0.02 + Math.random()*0.03;
689
+ return {
690
+ strike: K,
691
+ call_iv: iv_c, call_delta: +(0.45 + (underlying - K) * 0.02).toFixed(3),
692
+ call_gamma: +(0.02 + Math.random()*0.01).toFixed(4),
693
+ call_theta: +(-0.04 - Math.random()*0.02).toFixed(4),
694
+ call_vol: Math.round(Math.random()*5000),
695
+ put_iv: iv_p, put_delta: +(-(0.55 - (underlying - K) * 0.02)).toFixed(3),
696
+ put_gamma: +(0.02 + Math.random()*0.01).toFixed(4),
697
+ put_theta: +(-0.04 - Math.random()*0.02).toFixed(4),
698
+ put_vol: Math.round(Math.random()*5000),
699
+ atm: Math.abs(K - underlying) < 6,
700
+ };
701
+ });
702
+ }
703
+
704
+ const Options = () => {
705
+ const [ticker, setTicker] = useState('NVDA');
706
+ const PRICES: Record<string,number> = { NVDA:131.20, AMD:168.45, MU:98.72, AVGO:210.30 };
707
+ const underlying = PRICES[ticker] ?? 100;
708
+ const rows = useMemo(() => genOptions(underlying), [underlying]);
709
+
710
+ return (
711
+ <div className="space-y-6">
712
+ <div className="flex items-center justify-between">
713
+ <div>
714
+ <h1 className="section-heading">Options Chain</h1>
715
+ <p className="text-xs text-[var(--color-secondary)] mt-1">Silver-layer greeks / BSM IV / 7.1M rows</p>
716
+ </div>
717
+ <label htmlFor="options-ticker" className="sr-only">Options ticker</label>
718
+ <select id="options-ticker" value={ticker} onChange={e=>setTicker(e.target.value)}
719
+ className="glass-input px-3 py-1.5 text-sm text-[var(--color-primary)] outline-none">
720
+ {['NVDA','AMD','MU','AVGO'].map(t=><option key={t}>{t}</option>)}
721
+ </select>
722
+ </div>
723
+
724
+ <div className="grid grid-cols-2 xl:grid-cols-4 gap-3">
725
+ <StatCard label="Underlying" value={`$${fmt(underlying)}`} />
726
+ <StatCard label="Option Bars" value="7,100,137" sub="Jul 2024 - Jul 2026" />
727
+ <StatCard label="Contracts" value="303,076" sub="unique" />
728
+ <StatCard label="Silver Rows" value="7.1M greeks" up={true} />
729
+ </div>
730
+
731
+ <div className="glass-card overflow-x-auto">
732
+ <div className="px-4 py-2 border-b border-[var(--color-border)] flex gap-8 text-xs font-semibold text-[var(--color-secondary)]">
733
+ <span className="flex-1 text-center">CALLS</span>
734
+ <span className="w-20 text-center">STRIKE</span>
735
+ <span className="flex-1 text-center">PUTS</span>
736
+ </div>
737
+ <table className="data-table">
738
+ <thead>
739
+ <tr>
740
+ <th>IV</th><th><abbr title="Delta">Δ</abbr></th><th><abbr title="Gamma">Γ</abbr></th><th><abbr title="Theta">Θ</abbr></th><th className="text-right">Vol</th>
741
+ <th className="text-center w-20">Strike</th>
742
+ <th>IV</th><th><abbr title="Delta">Δ</abbr></th><th><abbr title="Gamma">Γ</abbr></th><th><abbr title="Theta">Θ</abbr></th><th className="text-right">Vol</th>
743
+ </tr>
744
+ </thead>
745
+ <tbody>
746
+ {rows.map(r => (
747
+ <tr key={r.strike} className={r.atm ? 'bg-[rgba(214,182,90,0.08)]' : ''}>
748
+ <td className="text-[var(--color-accent-bright)]">{(r.call_iv*100).toFixed(1)}%</td>
749
+ <td>{r.call_delta}</td>
750
+ <td className="text-[var(--color-secondary)]">{r.call_gamma}</td>
751
+ <td className="text-[var(--color-bearish)]">{r.call_theta}</td>
752
+ <td className="text-right text-[var(--color-secondary)]">{r.call_vol.toLocaleString()}</td>
753
+ <td className="text-center font-bold text-[var(--color-primary)]">
754
+ {r.atm && <span className="badge badge-purple mr-1 text-[0.6rem]">ATM</span>}
755
+ ${r.strike}
756
+ </td>
757
+ <td className="text-[var(--color-accent-bright)]">{(r.put_iv*100).toFixed(1)}%</td>
758
+ <td>{r.put_delta}</td>
759
+ <td className="text-[var(--color-secondary)]">{r.put_gamma}</td>
760
+ <td className="text-[var(--color-bearish)]">{r.put_theta}</td>
761
+ <td className="text-right text-[var(--color-secondary)]">{r.put_vol.toLocaleString()}</td>
762
+ </tr>
763
+ ))}
764
+ </tbody>
765
+ </table>
766
+ </div>
767
+ </div>
768
+ );
769
+ };
770
+
771
+ // ── Page: Calculator ──────────────────────────────────────────────────────────
772
+
773
+ type CalcForm = { shares: number; price: number; spread: number; impact: number; fee_per_share: number; rounds: number; };
774
+ type CalcFormKey = keyof CalcForm;
775
+
776
+ // Extracted to module scope — avoids remounting on every Calculator render
777
+ const CalcField = ({ label, k, step = 1, prefix = '', form, setForm }: {
778
+ label: string; k: CalcFormKey; step?: number; prefix?: string;
779
+ form: CalcForm; setForm: React.Dispatch<React.SetStateAction<CalcForm>>;
780
+ }) => (
781
+ <div>
782
+ <label htmlFor={`calc-${k}`} className="block text-xs text-[var(--color-secondary)] mb-1">{label}</label>
783
+ <div className="glass-input flex items-center px-3 py-2">
784
+ {prefix && <span className="text-[var(--color-muted)] mr-1 text-sm">{prefix}</span>}
785
+ <input id={`calc-${k}`} type="number" step={step} value={form[k]}
786
+ onChange={e => setForm(f => ({ ...f, [k]: parseFloat(e.target.value) || 0 }))}
787
+ className="flex-1 bg-transparent text-sm text-[var(--color-primary)] outline-none w-full" />
788
+ </div>
789
+ </div>
790
+ );
791
+
792
+ const Calculator = () => {
793
+ const [form, setForm] = useState<CalcForm>({
794
+ shares: 10000, price: 131.20, spread: 0.02,
795
+ impact: 0.1, fee_per_share: 0.005, rounds: 1,
796
+ });
797
+ const notional = form.shares * form.price;
798
+ const spread_cost = form.shares * form.spread * 0.5;
799
+ const impact_cost = notional * (form.impact / 100);
800
+ const fees = form.shares * form.fee_per_share;
801
+ const one_way = spread_cost + impact_cost + fees;
802
+ const round_trip = one_way * 2 * form.rounds;
803
+ const bps = (round_trip / notional) * 10000;
804
+
805
+ return (
806
+ <div className="max-w-2xl space-y-6">
807
+ <div>
808
+ <h1 className="section-heading">Cost Calculator</h1>
809
+ <p className="text-xs text-[var(--color-secondary)] mt-1">Round-trip slippage model / market impact + spread + fees</p>
810
+ </div>
811
+
812
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-4 glass-card p-5">
813
+ <CalcField label="Shares" k="shares" form={form} setForm={setForm} />
814
+ <CalcField label="Price per Share" k="price" step={0.01} prefix="$" form={form} setForm={setForm} />
815
+ <CalcField label="Bid-Ask Spread" k="spread" step={0.001} prefix="$" form={form} setForm={setForm} />
816
+ <CalcField label="Market Impact (%)" k="impact" step={0.01} form={form} setForm={setForm} />
817
+ <CalcField label="Fee per Share" k="fee_per_share" step={0.001} prefix="$" form={form} setForm={setForm} />
818
+ <CalcField label="Round Trips" k="rounds" form={form} setForm={setForm} />
819
+ </div>
820
+
821
+ <div className="grid grid-cols-2 gap-3">
822
+ <StatCard label="Notional" value={`$${fmt(notional,0)}`} />
823
+ <StatCard label="Spread Cost" value={`$${fmt(spread_cost)}`} />
824
+ <StatCard label="Market Impact" value={`$${fmt(impact_cost)}`} />
825
+ <StatCard label="Fees" value={`$${fmt(fees)}`} />
826
+ </div>
827
+
828
+ <div className="glass-card p-5 accent-glow">
829
+ <p className="text-xs text-[var(--color-secondary)] mb-2">Total Round-Trip Cost</p>
830
+ <p className="text-3xl font-bold text-[var(--color-accent-bright)]">${fmt(round_trip)}</p>
831
+ <p className="text-sm text-[var(--color-secondary)] mt-1">
832
+ {fmt(bps, 1)} bps / {((round_trip/notional)*100).toFixed(4)}% of notional
833
+ </p>
834
+ </div>
835
+ </div>
836
+ );
837
+ };
838
+
839
+ // ── Page: ETL Health ────────────���─────────────────────────────────────────────
840
+
841
+ // Hoisted — avoids recreation on every Health render
842
+ const PIPELINE_STATUS = [
843
+ { label:'Bronze / day bars', ok:true as true },
844
+ { label:'Bronze / option bars', ok:true as true },
845
+ { label:'Bronze / minute bars', ok:null as null },
846
+ { label:'Silver / stock features', ok:true as true },
847
+ { label:'Silver / option greeks', ok:true as true },
848
+ { label:'Silver / positioning', ok:true as true },
849
+ { label:'EDGAR', ok:false as false },
850
+ { label:'Embeddings', ok:false as false },
851
+ { label:'Gold layer', ok:false as false },
852
+ ];
853
+
854
+ const NEXT_ACTIONS = [
855
+ { icon: RefreshCw, label: 'Sync minute bars', cmd: 'python main.py --job polygon-bars' },
856
+ { icon: Database, label: 'Load EDGAR data', cmd: 'docker compose run --rm edgar_sync' },
857
+ { icon: Cpu, label: 'Run embeddings', cmd: 'python main.py --job embed-tickers' },
858
+ { icon: Zap, label: 'Build gold layer', cmd: 'etl/gold_*.py (not built yet)' },
859
+ ];
860
+
861
+ const TABLE_COUNTS = [
862
+ { name:'polygon_bars', rows: 2_996_665, ok: true, note:'Day bars current through Jul 2' },
863
+ { name:'polygon_option_bars', rows: 7_100_137, ok: true, note:'Options current through Jul 2' },
864
+ { name:'silver_stock_features', rows: 42_573, ok: true, note:'All 32 semi tickers' },
865
+ { name:'silver_option_greeks', rows: 7_100_137, ok: true, note:'BSM IV computed' },
866
+ { name:'silver_option_positioning', rows: 16_452, ok: true, note:'Put/call ratios' },
867
+ { name:'polygon_tickers', rows: 12_311, ok: true, note:'Reference data' },
868
+ { name:'cot_reports', rows: 2_009, ok: true, note:'CFTC COT' },
869
+ { name:'edgar_facts', rows: 0, ok: false, note:'Pending edgar_sync run' },
870
+ { name:'edgar_filings', rows: 0, ok: false, note:'Run edgar-filings job' },
871
+ { name:'edgar_embeddings', rows: 0, ok: false, note:'Pending edgar_sync run' },
872
+ { name:'ticker_embeddings', rows: 0, ok: false, note:'Run embed-tickers job' },
873
+ { name:'stock_quotes', rows: 0, ok: null, note:'Live IBKR feed (optional)' },
874
+ ];
875
+
876
+ const Health = () => (
877
+ <div className="space-y-6">
878
+ <div>
879
+ <h1 className="section-heading">ETL Health</h1>
880
+ <p className="text-xs text-[var(--color-secondary)] mt-1">Data freshness / row counts / pipeline status</p>
881
+ </div>
882
+
883
+ <div className="grid grid-cols-2 xl:grid-cols-4 gap-3">
884
+ <StatCard label="DB Size" value="3.2 GB" sub="equity.duckdb" />
885
+ <StatCard label="Total Rows" value="17.3M" sub="across all tables" />
886
+ <StatCard label="Healthy" value={String(TABLE_COUNTS.filter(t=>t.ok===true).length)} up={true} />
887
+ <StatCard label="Missing" value={String(TABLE_COUNTS.filter(t=>t.ok===false).length)} up={false} />
888
+ </div>
889
+
890
+ <div className="glass-card p-5">
891
+ <p className="text-sm font-semibold mb-4">Medallion Pipeline</p>
892
+ <div className="flex items-center gap-2 flex-wrap">
893
+ {PIPELINE_STATUS.map(s => (
894
+ <div key={s.label} className="flex items-center gap-1.5 glass-card px-3 py-1.5">
895
+ {s.ok === true && <CheckCircle size={12} className="text-[var(--color-bullish)]" />}
896
+ {s.ok === false && <XCircle size={12} className="text-[var(--color-bearish)]" />}
897
+ {s.ok === null && <AlertCircle size={12} className="text-yellow-400" />}
898
+ <span className="text-xs">{s.label}</span>
899
+ </div>
900
+ ))}
901
+ </div>
902
+ </div>
903
+
904
+ <div className="glass-card overflow-hidden">
905
+ <table className="data-table">
906
+ <thead>
907
+ <tr>
908
+ <th>Table</th>
909
+ <th className="text-right">Rows</th>
910
+ <th>Status</th>
911
+ <th>Note</th>
912
+ </tr>
913
+ </thead>
914
+ <tbody>
915
+ {TABLE_COUNTS.map(t => (
916
+ <tr key={t.name}>
917
+ <td className="font-mono text-xs text-[var(--color-accent-bright)]">{t.name}</td>
918
+ <td className="text-right">{t.rows.toLocaleString()}</td>
919
+ <td>
920
+ {t.ok === true && <span className="badge badge-green">OK</span>}
921
+ {t.ok === false && <span className="badge badge-red">Missing</span>}
922
+ {t.ok === null && <span className="badge badge-muted">Optional</span>}
923
+ </td>
924
+ <td className="text-[var(--color-secondary)] text-xs">{t.note}</td>
925
+ </tr>
926
+ ))}
927
+ </tbody>
928
+ </table>
929
+ </div>
930
+
931
+ <div className="glass-card p-5 space-y-2">
932
+ <p className="text-sm font-semibold mb-3">Next actions</p>
933
+ {NEXT_ACTIONS.map(a => (
934
+ <div key={a.label} className="flex items-center gap-3 py-2 border-b border-[var(--color-border)] last:border-0">
935
+ <a.icon size={14} className="text-[var(--color-accent)] shrink-0" />
936
+ <span className="text-sm">{a.label}</span>
937
+ <code className="ml-auto text-xs font-mono text-[var(--color-secondary)] glass-card px-2 py-0.5">{a.cmd}</code>
938
+ </div>
939
+ ))}
940
+ </div>
941
+ </div>
942
+ );
943
+
944
+ // ── App root ──────────────────────────────────────────────────────────────────
945
+
946
+ export default function App() {
947
+ return (
948
+ <Router>
949
+ <Layout>
950
+ <Routes>
951
+ <Route path="/" element={<Navigate to="/chat" replace />} />
952
+ <Route path="/chat" element={<Chat />} />
953
+ <Route path="/quotes" element={<Quotes />} />
954
+ <Route path="/history" element={<History />} />
955
+ <Route path="/polygon" element={<Polygon />} />
956
+ <Route path="/options" element={<Options />} />
957
+ <Route path="/calculator" element={<Calculator />} />
958
+ <Route path="/health" element={<Health />} />
959
+ <Route path="/methodology" element={<Methodology />} />
960
+ <Route path="/analytics" element={<Analytics />} />
961
+ <Route path="/audit" element={<AuditLog />} />
962
+ <Route path="/system" element={<SystemOverview />} />
963
+ </Routes>
964
+ </Layout>
965
+ </Router>
966
+ );
967
+ }
frontend/src/ResearchOps.tsx ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useMemo, useState } from 'react';
2
+ import axios from 'axios';
3
+ import { AlertCircle, CheckCircle, Clock3, Database, ShieldCheck, XCircle } from 'lucide-react';
4
+ import { ChartPanel } from './components/ChartPanel';
5
+ import type { ChartOption } from './components/ChartPanel';
6
+
7
+ const API = axios.create({ baseURL: '/api', timeout: 60000 });
8
+ const chartText = '#B8BDC5';
9
+ const chartGrid = 'rgba(199,203,209,0.10)';
10
+
11
+ type SeriesProfile = {
12
+ name: string;
13
+ stage: string;
14
+ available: boolean;
15
+ status: string;
16
+ rows: number;
17
+ symbols: number | null;
18
+ first_timestamp: string | null;
19
+ last_timestamp: string | null;
20
+ null_timestamps: number | null;
21
+ duplicate_points: number | null;
22
+ coverage: { period: string; rows: number }[];
23
+ };
24
+
25
+ type SlippageTicker = {
26
+ ticker: string;
27
+ trades: number;
28
+ gross_pnl: number;
29
+ slippage_cost: number;
30
+ commission_cost: number;
31
+ total_cost: number;
32
+ net_pnl: number;
33
+ };
34
+
35
+ const fmt = (value: number | null | undefined, digits = 0) =>
36
+ value == null ? '-' : value.toLocaleString('en-US', { maximumFractionDigits: digits });
37
+
38
+ const money = (value: number | null | undefined) =>
39
+ value == null ? '-' : value.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 2 });
40
+
41
+ const PageTitle = ({ title, subtitle }: { title: string; subtitle: string }) => (
42
+ <div>
43
+ <h1 className="section-heading">{title}</h1>
44
+ <p className="text-xs text-[var(--color-secondary)] mt-1">{subtitle}</p>
45
+ </div>
46
+ );
47
+
48
+ const Stat = ({ label, value, sub }: { label: string; value: string; sub?: string }) => (
49
+ <div className="stat-card min-w-0">
50
+ <p className="text-[0.7rem] font-semibold uppercase text-[var(--color-secondary)] mb-1">{label}</p>
51
+ <p className="text-xl font-semibold tabular-nums truncate">{value}</p>
52
+ {sub && <p className="text-xs text-[var(--color-muted)] mt-1 truncate">{sub}</p>}
53
+ </div>
54
+ );
55
+
56
+ const Loading = () => <div className="glass-card p-6 text-sm text-[var(--color-secondary)]">Loading research data...</div>;
57
+
58
+ const ErrorState = ({ message }: { message: string }) => (
59
+ <div className="glass-card p-5 flex items-center gap-3 text-sm text-[var(--color-bearish)]">
60
+ <AlertCircle size={16} /> {message}
61
+ </div>
62
+ );
63
+
64
+ export function Methodology() {
65
+ const [data, setData] = useState<any>(null);
66
+ const [error, setError] = useState('');
67
+ useEffect(() => {
68
+ API.get('/research/methodology').then(response => setData(response.data)).catch(error => setError(error.message));
69
+ }, []);
70
+ if (error) return <ErrorState message={error} />;
71
+ if (!data) return <Loading />;
72
+
73
+ return (
74
+ <div className="space-y-6">
75
+ <PageTitle title="Methodology" subtitle="Point-in-time controls for market data, research, and execution costs" />
76
+ <div className="grid grid-cols-1 xl:grid-cols-2 gap-3">
77
+ {data.principles.map((item: any, index: number) => (
78
+ <section key={item.id} className="glass-card p-5">
79
+ <div className="flex items-start gap-3">
80
+ <span className="w-7 h-7 shrink-0 rounded-md bg-[var(--color-accent-dim)] text-[var(--color-accent-bright)] flex items-center justify-center text-xs font-semibold">
81
+ {index + 1}
82
+ </span>
83
+ <div className="min-w-0">
84
+ <h2 className="text-sm font-semibold">{item.title}</h2>
85
+ <p className="text-sm leading-6 text-[var(--color-secondary)] mt-2">{item.body}</p>
86
+ <div className="flex flex-wrap gap-2 mt-4">
87
+ {item.controls.map((control: string) => <span key={control} className="badge badge-muted">{control}</span>)}
88
+ </div>
89
+ </div>
90
+ </div>
91
+ </section>
92
+ ))}
93
+ </div>
94
+ <section className="glass-card p-5">
95
+ <h2 className="text-sm font-semibold mb-4">Slippage cost contract</h2>
96
+ <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-5 gap-3">
97
+ {Object.entries(data.slippage_model).map(([label, value]) => (
98
+ <div key={label} className="glass-sm p-3 min-w-0">
99
+ <p className="text-[0.68rem] uppercase font-semibold text-[var(--color-secondary)]">{label.replaceAll('_', ' ')}</p>
100
+ <p className="text-xs leading-5 mt-2">{String(value)}</p>
101
+ </div>
102
+ ))}
103
+ </div>
104
+ </section>
105
+ </div>
106
+ );
107
+ }
108
+
109
+ export function Analytics() {
110
+ const [analytics, setAnalytics] = useState<any>(null);
111
+ const [slippage, setSlippage] = useState<any>(null);
112
+ const [byTicker, setByTicker] = useState<SlippageTicker[]>([]);
113
+ const [error, setError] = useState('');
114
+ useEffect(() => {
115
+ Promise.all([
116
+ API.get('/research/analytics/summary'),
117
+ API.get('/research/slippage/summary'),
118
+ API.get('/research/slippage/by-ticker'),
119
+ ]).then(([a, s, t]) => {
120
+ setAnalytics(a.data);
121
+ setSlippage(s.data);
122
+ setByTicker(t.data.tickers || []);
123
+ }).catch(error => setError(error.message));
124
+ }, []);
125
+
126
+ const coverageOption = useMemo<ChartOption>(() => ({
127
+ color: ['#D6B65A', '#C7CBD1', '#43D19E', '#E7C96C', '#FF727F', '#8D949E'],
128
+ tooltip: { trigger: 'axis', backgroundColor: '#181B20', borderColor: '#32363D', textStyle: { color: '#F3F1EA' } },
129
+ legend: { type: 'scroll', textStyle: { color: chartText }, top: 0 },
130
+ grid: { left: 58, right: 20, top: 48, bottom: 42 },
131
+ xAxis: { type: 'time', axisLabel: { color: chartText }, axisLine: { lineStyle: { color: chartGrid } } },
132
+ yAxis: { type: 'value', name: 'rows / month', nameTextStyle: { color: chartText }, axisLabel: { color: chartText }, splitLine: { lineStyle: { color: chartGrid } } },
133
+ series: (analytics?.tables || []).filter((table: SeriesProfile) => table.coverage.length).map((table: SeriesProfile) => ({
134
+ name: table.name,
135
+ type: 'line',
136
+ showSymbol: false,
137
+ smooth: false,
138
+ data: table.coverage.map(point => [point.period, point.rows]),
139
+ })),
140
+ }), [analytics]);
141
+
142
+ const costOption = useMemo<ChartOption>(() => ({
143
+ color: ['#D6B65A', '#C7CBD1'],
144
+ tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, backgroundColor: '#181B20', borderColor: '#32363D', textStyle: { color: '#F3F1EA' } },
145
+ legend: { textStyle: { color: chartText } },
146
+ grid: { left: 58, right: 20, top: 42, bottom: 42 },
147
+ xAxis: { type: 'category', data: byTicker.slice(0, 16).map(row => row.ticker), axisLabel: { color: chartText }, axisLine: { lineStyle: { color: chartGrid } } },
148
+ yAxis: { type: 'value', name: 'USD', nameTextStyle: { color: chartText }, axisLabel: { color: chartText }, splitLine: { lineStyle: { color: chartGrid } } },
149
+ series: [
150
+ { name: 'Slippage', type: 'bar', stack: 'cost', data: byTicker.slice(0, 16).map(row => row.slippage_cost) },
151
+ { name: 'Commission', type: 'bar', stack: 'cost', data: byTicker.slice(0, 16).map(row => row.commission_cost) },
152
+ ],
153
+ }), [byTicker]);
154
+
155
+ if (error) return <ErrorState message={error} />;
156
+ if (!analytics || !slippage) return <Loading />;
157
+ const cost = slippage.summary;
158
+ return (
159
+ <div className="space-y-6">
160
+ <PageTitle title="Research Analytics" subtitle="Coverage, quality, freshness, and execution cost across time-series datasets" />
161
+ <div className="grid grid-cols-2 xl:grid-cols-4 gap-3">
162
+ <Stat label="Observed rows" value={fmt(analytics.totals.rows)} />
163
+ <Stat label="Available tables" value={fmt(analytics.totals.available_tables)} sub={`${analytics.tables.length} tracked`} />
164
+ <Stat label="Duplicate keys" value={fmt(analytics.totals.tables_with_duplicates)} sub="tables affected" />
165
+ <Stat label="Null event time" value={fmt(analytics.totals.tables_with_null_time)} sub="tables affected" />
166
+ </div>
167
+ <section className="glass-card p-4">
168
+ <h2 className="text-sm font-semibold mb-2">Monthly data coverage</h2>
169
+ <ChartPanel option={coverageOption} height={340} label="Monthly data coverage by research table" />
170
+ </section>
171
+ <div className="grid grid-cols-1 xl:grid-cols-[1fr_2fr] gap-3">
172
+ <section className="glass-card p-5">
173
+ <h2 className="text-sm font-semibold">Execution cost</h2>
174
+ {slippage.available && cost ? (
175
+ <div className="grid grid-cols-2 gap-3 mt-4">
176
+ <Stat label="Gross P&L" value={money(cost.gross_pnl)} />
177
+ <Stat label="Net P&L" value={money(cost.net_pnl)} />
178
+ <Stat label="Slippage" value={money(cost.slippage_cost)} />
179
+ <Stat label="Commission" value={money(cost.commission_cost)} />
180
+ <Stat label="Total cost" value={money(cost.total_cost)} />
181
+ <Stat label="Cost drag" value={cost.cost_drag == null ? '-' : `${(cost.cost_drag * 100).toFixed(2)}%`} />
182
+ </div>
183
+ ) : <p className="text-sm text-[var(--color-secondary)] mt-4">Gold trade costs are not available yet. This is distinct from a measured zero cost.</p>}
184
+ </section>
185
+ <section className="glass-card p-4">
186
+ <h2 className="text-sm font-semibold mb-2">Cost by ticker</h2>
187
+ {byTicker.length ? <ChartPanel option={costOption} height={340} label="Slippage and commission costs by ticker" /> : <div className="h-[340px] flex items-center justify-center text-sm text-[var(--color-secondary)]">No cost observations</div>}
188
+ </section>
189
+ </div>
190
+ <section className="glass-card overflow-x-auto">
191
+ <table className="data-table min-w-[840px]">
192
+ <thead><tr><th>Dataset</th><th>Stage</th><th className="text-right">Rows</th><th className="text-right">Symbols</th><th>First event</th><th>Latest event</th><th className="text-right">Duplicates</th></tr></thead>
193
+ <tbody>{analytics.tables.map((table: SeriesProfile) => (
194
+ <tr key={table.name}>
195
+ <td className="font-mono text-xs text-[var(--color-accent-bright)]">{table.name}</td><td><span className="badge badge-muted">{table.stage}</span></td>
196
+ <td className="text-right">{fmt(table.rows)}</td><td className="text-right">{fmt(table.symbols)}</td><td>{table.first_timestamp || '-'}</td><td>{table.last_timestamp || '-'}</td><td className="text-right">{fmt(table.duplicate_points)}</td>
197
+ </tr>
198
+ ))}</tbody>
199
+ </table>
200
+ </section>
201
+ </div>
202
+ );
203
+ }
204
+
205
+ export function AuditLog() {
206
+ const [data, setData] = useState<any>(null);
207
+ const [summary, setSummary] = useState<any>(null);
208
+ const [error, setError] = useState('');
209
+ useEffect(() => {
210
+ Promise.all([API.get('/research/audit'), API.get('/research/audit/summary')])
211
+ .then(([detail, totals]) => { setData(detail.data); setSummary(totals.data); })
212
+ .catch(error => setError(error.message));
213
+ }, []);
214
+ if (error) return <ErrorState message={error} />;
215
+ if (!data || !summary) return <Loading />;
216
+ return (
217
+ <div className="space-y-6">
218
+ <PageTitle title="Audit Log" subtitle="ETL run lineage and derived time-series integrity checks" />
219
+ <div className="grid grid-cols-3 gap-3">
220
+ <Stat label="Checks passed" value={fmt(summary.checks.passed)} />
221
+ <Stat label="Checks failed" value={fmt(summary.checks.failed)} />
222
+ <Stat label="Not applicable" value={fmt(summary.checks.not_applicable)} />
223
+ </div>
224
+ <section className="glass-card p-5">
225
+ <h2 className="text-sm font-semibold mb-4">Data controls</h2>
226
+ <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-2">
227
+ {data.checks.map((check: any) => (
228
+ <div key={`${check.table}-${check.check}`} className="glass-sm p-3 flex items-center gap-3 min-w-0">
229
+ {check.status === 'pass' ? <CheckCircle size={15} className="text-[var(--color-bullish)] shrink-0" /> : check.status === 'fail' ? <XCircle size={15} className="text-[var(--color-bearish)] shrink-0" /> : <Clock3 size={15} className="text-[var(--color-muted)] shrink-0" />}
230
+ <div className="min-w-0"><p className="text-xs font-mono truncate">{check.table}</p><p className="text-xs text-[var(--color-secondary)] truncate">{check.check}: {check.value ?? check.status}</p></div>
231
+ </div>
232
+ ))}
233
+ </div>
234
+ </section>
235
+ <section className="glass-card overflow-x-auto">
236
+ <table className="data-table min-w-[900px]">
237
+ <thead><tr><th>ID</th><th>Run</th><th>Status</th><th className="text-right">Rows</th><th>Started</th><th>Finished</th><th>Message</th></tr></thead>
238
+ <tbody>{data.runs.map((run: any) => <tr key={run.id}><td>{run.id}</td><td>{run.run_type}</td><td><span className={`badge ${String(run.status).toLowerCase().includes('success') ? 'badge-green' : 'badge-muted'}`}>{run.status}</span></td><td className="text-right">{fmt(run.rows_written)}</td><td>{run.started_at || '-'}</td><td>{run.finished_at || '-'}</td><td className="max-w-[22rem] truncate text-[var(--color-secondary)]">{run.message || '-'}</td></tr>)}</tbody>
239
+ </table>
240
+ </section>
241
+ </div>
242
+ );
243
+ }
244
+
245
+ export function SystemOverview() {
246
+ const [data, setData] = useState<any>(null);
247
+ const [error, setError] = useState('');
248
+ useEffect(() => { API.get('/research/system').then(response => setData(response.data)).catch(error => setError(error.message)); }, []);
249
+ if (error) return <ErrorState message={error} />;
250
+ if (!data) return <Loading />;
251
+ return (
252
+ <div className="space-y-6">
253
+ <PageTitle title="System Overview" subtitle="Live database inventory and medallion-stage readiness" />
254
+ <div className="grid grid-cols-2 xl:grid-cols-4 gap-3">
255
+ <Stat label="Database" value={data.database.ok ? 'Connected' : 'Unavailable'} />
256
+ <Stat label="Tables" value={fmt(data.database.tables)} />
257
+ <Stat label="Tracked series" value={fmt(data.tables.length)} />
258
+ <Stat label="Ready series" value={fmt(data.tables.filter((table: SeriesProfile) => table.status === 'ready').length)} />
259
+ </div>
260
+ <section className="grid grid-cols-1 md:grid-cols-3 gap-3">
261
+ {data.stages.map((stage: any) => (
262
+ <div key={stage.stage} className="glass-card p-5">
263
+ <div className="flex items-center justify-between"><div className="flex items-center gap-2"><Database size={16} className="text-[var(--color-accent-bright)]" /><h2 className="text-sm font-semibold capitalize">{stage.stage}</h2></div><span className={`badge ${stage.status === 'ready' ? 'badge-green' : stage.status === 'missing' ? 'badge-red' : 'badge-muted'}`}>{stage.status}</span></div>
264
+ <p className="text-2xl font-semibold mt-5">{stage.available}<span className="text-sm text-[var(--color-secondary)]"> / {stage.expected}</span></p>
265
+ <p className="text-xs text-[var(--color-secondary)] mt-2 truncate">Latest: {stage.latest_timestamp || '-'}</p>
266
+ </div>
267
+ ))}
268
+ </section>
269
+ <section className="glass-card p-5">
270
+ <div className="flex items-center gap-2 mb-4"><ShieldCheck size={16} className="text-[var(--color-accent-bright)]" /><h2 className="text-sm font-semibold">Tracked datasets</h2></div>
271
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-2">
272
+ {data.tables.map((table: SeriesProfile) => <div key={table.name} className="glass-sm p-3 flex items-center justify-between gap-3"><div className="min-w-0"><p className="font-mono text-xs truncate">{table.name}</p><p className="text-xs text-[var(--color-secondary)] mt-1 truncate">{table.last_timestamp || 'No observations'}</p></div><span className={`badge ${table.status === 'ready' ? 'badge-green' : table.status === 'missing' ? 'badge-red' : 'badge-muted'}`}>{table.status}</span></div>)}
273
+ </div>
274
+ </section>
275
+ </div>
276
+ );
277
+ }
frontend/src/assets/hero.png ADDED
frontend/src/assets/react.svg ADDED
frontend/src/assets/vite.svg ADDED
frontend/src/components/ChartPanel.tsx ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ReactEChartsCore from 'echarts-for-react/lib/core';
2
+ import { BarChart, CandlestickChart, LineChart } from 'echarts/charts';
3
+ import {
4
+ DataZoomComponent,
5
+ GridComponent,
6
+ LegendComponent,
7
+ MarkLineComponent,
8
+ TooltipComponent,
9
+ } from 'echarts/components';
10
+ import * as echarts from 'echarts/core';
11
+ import type { EChartsCoreOption } from 'echarts/core';
12
+ import { CanvasRenderer } from 'echarts/renderers';
13
+
14
+ echarts.use([
15
+ BarChart,
16
+ CandlestickChart,
17
+ LineChart,
18
+ CanvasRenderer,
19
+ DataZoomComponent,
20
+ GridComponent,
21
+ LegendComponent,
22
+ MarkLineComponent,
23
+ TooltipComponent,
24
+ ]);
25
+
26
+ export type ChartOption = EChartsCoreOption;
27
+
28
+ export function ChartPanel({ option, height = 320, label = 'Financial data chart' }: {
29
+ option: ChartOption;
30
+ height?: number;
31
+ label?: string;
32
+ }) {
33
+ return (
34
+ <div role="img" aria-label={label}>
35
+ <ReactEChartsCore
36
+ echarts={echarts}
37
+ option={option}
38
+ notMerge
39
+ lazyUpdate
40
+ style={{ height, width: '100%' }}
41
+ opts={{ renderer: 'canvas' }}
42
+ />
43
+ </div>
44
+ );
45
+ }
frontend/src/index.css ADDED
@@ -0,0 +1,336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import "tailwindcss";
2
+
3
+ @theme {
4
+ /* ── Origin-inspired dark purple palette ─────────────────────── */
5
+ --color-background: #090A0C;
6
+ --color-surface: #111317;
7
+ --color-surface-elevated: #181B20;
8
+ --color-primary: #F3F1EA;
9
+ --color-secondary: #B8BDC5;
10
+ --color-muted: #818791;
11
+ --color-border: #32363D;
12
+ --color-border-subtle: #22262C;
13
+
14
+ /* Accent — violet/purple */
15
+ --color-accent: #D6B65A;
16
+ --color-accent-bright: #F1D77A;
17
+ --color-accent-fill: #C9A227;
18
+ --color-accent-fill-hover: #DEBA3E;
19
+ --color-accent-ink: #171205;
20
+ --color-accent-dim: rgba(214, 182, 90, 0.14);
21
+ --color-accent-glow: rgba(214, 182, 90, 0.2);
22
+ --color-silver: #C7CBD1;
23
+ --color-silver-dim: #8D949E;
24
+
25
+ /* Financial */
26
+ --color-bullish: #43D19E;
27
+ --color-bearish: #FF727F;
28
+ --color-neutral: #A8B0BC;
29
+
30
+ --font-sans: "Geist", "Segoe UI Variable", "SF Pro Display", system-ui, -apple-system, sans-serif;
31
+ --font-serif: "Georgia", "Times New Roman", serif;
32
+ --font-mono: "JetBrains Mono", "SF Mono", "Geist Mono", "Fira Code", ui-monospace, monospace;
33
+
34
+ --radius-xl: 12px;
35
+ --radius-lg: 12px;
36
+ --radius-md: 8px;
37
+ --radius-sm: 6px;
38
+ }
39
+
40
+ @layer base {
41
+ html {
42
+ background-color: var(--color-background);
43
+ color: var(--color-primary);
44
+ color-scheme: dark;
45
+ -webkit-font-smoothing: antialiased;
46
+ -moz-osx-font-smoothing: grayscale;
47
+ }
48
+
49
+ body {
50
+ font-family: var(--font-sans);
51
+ letter-spacing: 0;
52
+ background: var(--color-background);
53
+ }
54
+
55
+ body::before {
56
+ content: "";
57
+ position: fixed;
58
+ inset: 0;
59
+ z-index: -1;
60
+ pointer-events: none;
61
+ opacity: 0.18;
62
+ background-image:
63
+ linear-gradient(rgba(255,255,255,0.018) 1px, transparent 1px),
64
+ linear-gradient(90deg, rgba(255,255,255,0.014) 1px, transparent 1px);
65
+ background-size: 48px 48px;
66
+ mask-image: linear-gradient(to bottom, rgba(0,0,0,0.8), transparent 78%);
67
+ }
68
+
69
+ table, .tabular-nums, [class*="price"], [class*="amount"], [class*="value"] { font-variant-numeric: tabular-nums; }
70
+
71
+ ::-webkit-scrollbar { width: 5px; height: 5px; }
72
+ ::-webkit-scrollbar-track { background: transparent; }
73
+ ::-webkit-scrollbar-thumb { background: rgba(214,182,90,0.28); border-radius: 3px; }
74
+ ::-webkit-scrollbar-thumb:hover { background: rgba(214,182,90,0.46); }
75
+ ::selection { background: var(--color-accent); color: var(--color-background); }
76
+
77
+ :where(button, a, input, select, textarea, summary, [role="button"]):focus-visible {
78
+ outline: 2px solid var(--color-accent);
79
+ outline-offset: 2px;
80
+ }
81
+
82
+ @media (max-width: 767px) {
83
+ html { font-size: 16px; }
84
+ body { -webkit-tap-highlight-color: transparent; }
85
+ button, [role="button"], a[role="button"] { min-height: 44px; min-width: 44px; }
86
+ input { font-size: 16px; }
87
+ }
88
+
89
+ @media (prefers-reduced-motion: reduce) {
90
+ *,*::before,*::after { animation-duration:0.01ms !important; animation-iteration-count:1 !important; transition-duration:0.01ms !important; }
91
+ }
92
+ }
93
+
94
+ @layer components {
95
+
96
+ .skip-link {
97
+ position: fixed;
98
+ top: 0.75rem;
99
+ left: 0.75rem;
100
+ z-index: 1000;
101
+ transform: translateY(-160%);
102
+ border: 1px solid var(--color-accent);
103
+ border-radius: var(--radius-md);
104
+ background: var(--color-surface-elevated);
105
+ color: var(--color-primary);
106
+ padding: 0.65rem 0.9rem;
107
+ font-size: 0.875rem;
108
+ font-weight: 600;
109
+ transition: transform 0.15s ease;
110
+ }
111
+
112
+ .skip-link:focus { transform: translateY(0); }
113
+
114
+ .glass {
115
+ background: var(--color-surface-elevated);
116
+ border: 1px solid var(--color-border);
117
+ border-radius: var(--radius-lg);
118
+ }
119
+
120
+ .glass-sm {
121
+ background: var(--color-surface);
122
+ border: 1px solid var(--color-border-subtle);
123
+ border-radius: var(--radius-md);
124
+ }
125
+
126
+ .fintech-card,
127
+ .glass-card {
128
+ background: var(--color-surface-elevated);
129
+ border: 1px solid var(--color-border);
130
+ border-radius: var(--radius-lg);
131
+ transition: border-color 0.16s ease, background-color 0.16s ease;
132
+ }
133
+ .fintech-card:hover,
134
+ .glass-card:hover {
135
+ background: #1A1F28;
136
+ border-color: #3A4250;
137
+ }
138
+
139
+ .glass-sidebar {
140
+ background: var(--color-surface);
141
+ border-right: 1px solid var(--color-border);
142
+ }
143
+
144
+ .glass-header {
145
+ background: var(--color-surface);
146
+ border-bottom: 1px solid var(--color-border);
147
+ }
148
+
149
+ .glass-input {
150
+ background: var(--color-surface-elevated);
151
+ border: 1px solid var(--color-border);
152
+ border-radius: var(--radius-md);
153
+ transition: border-color 0.16s ease, box-shadow 0.16s ease;
154
+ }
155
+ .glass-input:focus-within {
156
+ border-color: var(--color-accent);
157
+ box-shadow: 0 0 0 1px rgba(214,182,90,0.16);
158
+ }
159
+
160
+ .fintech-button,
161
+ .btn-primary {
162
+ background: var(--color-accent-fill);
163
+ color: var(--color-accent-ink);
164
+ border-radius: var(--radius-lg);
165
+ font-weight: 600;
166
+ font-size: 0.875rem;
167
+ padding: 0.5rem 1.25rem;
168
+ border: 1px solid var(--color-accent-fill);
169
+ transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
170
+ cursor: pointer;
171
+ display: inline-flex;
172
+ align-items: center;
173
+ gap: 0.4rem;
174
+ }
175
+ .fintech-button:not(:disabled):hover,
176
+ .btn-primary:not(:disabled):hover {
177
+ background: var(--color-accent-fill-hover);
178
+ border-color: var(--color-accent-fill-hover);
179
+ }
180
+ .fintech-button:not(:disabled):active,
181
+ .btn-primary:not(:disabled):active { transform: translateY(1px) scale(0.98); }
182
+
183
+ .fintech-button:disabled,
184
+ .btn-primary:disabled {
185
+ background: #242A34;
186
+ border-color: #353D49;
187
+ color: #9BA4B1;
188
+ opacity: 1;
189
+ cursor: not-allowed;
190
+ }
191
+
192
+ .glass-button,
193
+ .btn-ghost {
194
+ background: var(--color-surface-elevated);
195
+ color: var(--color-secondary);
196
+ border: 1px solid var(--color-border);
197
+ border-radius: var(--radius-md);
198
+ font-size: 0.875rem;
199
+ font-weight: 500;
200
+ padding: 0.4rem 0.9rem;
201
+ transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, transform 0.15s ease;
202
+ cursor: pointer;
203
+ display: inline-flex;
204
+ align-items: center;
205
+ gap: 0.4rem;
206
+ }
207
+ .glass-button:hover,
208
+ .btn-ghost:hover { background: #1D222C; border-color: #3A4250; color: var(--color-primary); }
209
+ .glass-button:active,
210
+ .btn-ghost:active { transform: scale(0.98); }
211
+
212
+ .nav-item {
213
+ position: relative;
214
+ display: flex;
215
+ align-items: center;
216
+ gap: 0.6rem;
217
+ min-height: 2.5rem;
218
+ padding: 0.55rem 0.75rem 0.55rem 0.875rem;
219
+ border-radius: var(--radius-md);
220
+ font-size: 0.8375rem;
221
+ font-weight: 500;
222
+ color: var(--color-secondary);
223
+ border: 1px solid transparent;
224
+ transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
225
+ cursor: pointer;
226
+ background: transparent;
227
+ width: 100%;
228
+ text-align: left;
229
+ text-decoration: none;
230
+ white-space: nowrap;
231
+ }
232
+ .nav-item:hover { color: var(--color-primary); background: #1D222C; }
233
+ .nav-item.active {
234
+ background: var(--color-accent-dim);
235
+ border-color: color-mix(in srgb, var(--color-accent) 22%, transparent);
236
+ color: var(--color-accent-bright);
237
+ }
238
+ .nav-item.active::before {
239
+ content: "";
240
+ position: absolute;
241
+ left: -0.25rem;
242
+ top: 0.5rem;
243
+ bottom: 0.5rem;
244
+ width: 2px;
245
+ border-radius: 2px;
246
+ background: var(--color-accent);
247
+ }
248
+
249
+ .stat-card {
250
+ background: linear-gradient(135deg,rgba(214,182,90,0.08) 0%,rgba(17,19,23,0.96) 70%);
251
+ border: 1px solid var(--color-border);
252
+ border-radius: var(--radius-lg);
253
+ padding: 1rem 1.25rem;
254
+ transition: border-color 0.2s;
255
+ }
256
+ .stat-card:hover { border-color: rgba(214,182,90,0.3); }
257
+
258
+ .data-table { width: 100%; border-collapse: collapse; font-size: 0.84rem; font-variant-numeric: tabular-nums; }
259
+ .data-table thead th {
260
+ text-align: left; font-weight: 600; color: var(--color-secondary);
261
+ text-transform: uppercase; font-size: 0.7rem; letter-spacing: 0.06em;
262
+ padding: 0.55rem 0.75rem; background: var(--color-surface);
263
+ border-bottom: 1px solid var(--color-border); white-space: nowrap;
264
+ }
265
+ .data-table tbody td {
266
+ padding: 0.5rem 0.75rem;
267
+ border-bottom: 1px solid rgba(255,255,255,0.04);
268
+ color: var(--color-primary); vertical-align: middle;
269
+ }
270
+ .data-table tbody tr:hover { background: #1D222C; }
271
+ .data-table .text-right { text-align: right; }
272
+
273
+ .msg-user {
274
+ background: var(--color-accent-fill);
275
+ color: var(--color-accent-ink); border-radius: 16px 16px 4px 16px;
276
+ }
277
+ .msg-assistant {
278
+ background: var(--color-surface-elevated);
279
+ border: 1px solid var(--color-border);
280
+ border-radius: 16px 16px 16px 4px;
281
+ }
282
+
283
+ .badge {
284
+ display: inline-flex; align-items: center; gap: 0.3rem;
285
+ padding: 0.15rem 0.5rem; border-radius: 999px;
286
+ font-size: 0.7rem; font-weight: 600; letter-spacing: 0.02em;
287
+ }
288
+ .badge-green { background: rgba(52,211,153,0.12); color: #34D399; border: 1px solid rgba(52,211,153,0.2); }
289
+ .badge-red { background: rgba(248,113,113,0.12); color: #F87171; border: 1px solid rgba(248,113,113,0.2); }
290
+ .badge-purple { background: var(--color-accent-dim); color: var(--color-accent-bright); border: 1px solid rgba(214,182,90,0.25); }
291
+ .badge-muted { background: rgba(255,255,255,0.06); color: var(--color-secondary); border: 1px solid var(--color-border-subtle); }
292
+
293
+ .section-heading {
294
+ font-family: var(--font-serif);
295
+ font-style: italic;
296
+ font-weight: 400;
297
+ font-size: 1.5rem;
298
+ letter-spacing: -0.02em;
299
+ background: linear-gradient(135deg,#F8F6EF 0%,var(--color-accent-bright) 100%);
300
+ -webkit-background-clip: text;
301
+ -webkit-text-fill-color: transparent;
302
+ background-clip: text;
303
+ }
304
+
305
+ .prose-refined { font-size: 0.9rem; line-height: 1.65; }
306
+ .prose-refined code {
307
+ font-family: var(--font-mono); font-size: 0.83em;
308
+ padding: 0.15em 0.4em;
309
+ background: rgba(214,182,90,0.1); border-radius: 4px;
310
+ border: 1px solid rgba(214,182,90,0.18);
311
+ }
312
+ .prose-refined pre {
313
+ font-family: var(--font-mono); font-size: 0.8rem; line-height: 1.6;
314
+ background: rgba(15,15,26,0.8); border: 1px solid var(--color-border);
315
+ border-radius: var(--radius-md); padding: 0.75rem 1rem; overflow-x: auto;
316
+ }
317
+ .prose-refined strong { font-weight: 600; color: var(--color-primary); }
318
+ .prose-refined a { color: var(--color-accent-bright); }
319
+ .prose-refined table { width: 100%; border-collapse: collapse; font-size: 0.85em; }
320
+ .prose-refined thead th {
321
+ text-align: left; padding: 0.4em 0.6em; color: var(--color-secondary);
322
+ font-size: 0.78em; text-transform: uppercase; border-bottom: 1px solid var(--color-border);
323
+ }
324
+ .prose-refined tbody td { padding: 0.45em 0.6em; border-bottom: 1px solid rgba(255,255,255,0.04); }
325
+ .prose-refined h1,.prose-refined h2,.prose-refined h3 {
326
+ font-weight: 600; color: var(--color-primary); margin: 1em 0 0.4em;
327
+ }
328
+ .prose-refined ul { list-style: disc; padding-left: 1.4em; margin: 0.5em 0; }
329
+ .prose-refined li { margin: 0.2em 0; }
330
+ .prose-refined li::marker { color: var(--color-accent); }
331
+
332
+ .status-pulse { animation: pulse 2s cubic-bezier(0.4,0,0.6,1) infinite; }
333
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
334
+
335
+ .accent-glow { box-shadow: 0 0 20px rgba(214,182,90,0.2),0 0 60px rgba(214,182,90,0.08); }
336
+ }
frontend/src/main.tsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { StrictMode } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import './index.css'
4
+ import App from './App.tsx'
5
+
6
+ createRoot(document.getElementById('root')!).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ )
frontend/src/test/setup.ts ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import '@testing-library/jest-dom/vitest';
2
+
3
+ class ResizeObserverStub {
4
+ observe() {}
5
+ unobserve() {}
6
+ disconnect() {}
7
+ }
8
+
9
+ globalThis.ResizeObserver = ResizeObserverStub;
10
+ HTMLElement.prototype.scrollIntoView = () => {};
frontend/tsconfig.app.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4
+ "target": "es2023",
5
+ "lib": ["ES2023", "DOM"],
6
+ "module": "esnext",
7
+ "types": ["vite/client"],
8
+ "allowArbitraryExtensions": true,
9
+ "skipLibCheck": true,
10
+
11
+ /* Bundler mode */
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "moduleDetection": "force",
16
+ "noEmit": true,
17
+ "jsx": "react-jsx",
18
+
19
+ /* Linting */
20
+ "noUnusedLocals": true,
21
+ "noUnusedParameters": true,
22
+ "erasableSyntaxOnly": true,
23
+ "noFallthroughCasesInSwitch": true
24
+ },
25
+ "include": ["src"]
26
+ }
frontend/tsconfig.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "files": [],
3
+ "references": [
4
+ { "path": "./tsconfig.app.json" },
5
+ { "path": "./tsconfig.node.json" }
6
+ ]
7
+ }
frontend/tsconfig.node.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4
+ "target": "es2023",
5
+ "lib": ["ES2023"],
6
+ "types": ["node"],
7
+ "skipLibCheck": true,
8
+
9
+ /* Bundler mode */
10
+ "module": "nodenext",
11
+ "allowImportingTsExtensions": true,
12
+ "verbatimModuleSyntax": true,
13
+ "moduleDetection": "force",
14
+ "noEmit": true,
15
+
16
+ /* Linting */
17
+ "noUnusedLocals": true,
18
+ "noUnusedParameters": true,
19
+ "erasableSyntaxOnly": true,
20
+ "noFallthroughCasesInSwitch": true
21
+ },
22
+ "include": ["vite.config.ts"]
23
+ }
frontend/vite.config.ts ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import react from '@vitejs/plugin-react'
2
+ import tailwindcss from '@tailwindcss/vite'
3
+ import { defineConfig } from 'vitest/config'
4
+
5
+ // https://vite.dev/config/
6
+ export default defineConfig({
7
+ plugins: [react(), tailwindcss()],
8
+ test: {
9
+ environment: 'jsdom',
10
+ setupFiles: './src/test/setup.ts',
11
+ css: false,
12
+ },
13
+ envDir: '../',
14
+ server: {
15
+ proxy: {
16
+ '/api': {
17
+ target: 'http://127.0.0.1:8000',
18
+ changeOrigin: true,
19
+ },
20
+ },
21
+ },
22
+ build: {
23
+ rollupOptions: {
24
+ output: {
25
+ manualChunks(id: string) {
26
+ if (id.includes('node_modules/echarts') || id.includes('node_modules/zrender')) {
27
+ return 'charts';
28
+ }
29
+ if (id.includes('node_modules/react') || id.includes('node_modules/react-dom')) {
30
+ return 'react-vendor';
31
+ }
32
+ if (id.includes('node_modules/react-markdown') || id.includes('node_modules/remark') || id.includes('node_modules/unified') || id.includes('node_modules/mdast')) {
33
+ return 'markdown';
34
+ }
35
+ if (id.includes('node_modules/lucide-react')) {
36
+ return 'icons';
37
+ }
38
+ if (id.includes('node_modules/axios')) {
39
+ return 'vendor-core';
40
+ }
41
+ },
42
+ },
43
+ },
44
+ },
45
+ })
requirements-space.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ anthropic>=0.40.0,<1
2
+ duckdb>=1.1.0,<2
3
+ fastapi>=0.115.0,<1
4
+ huggingface-hub>=0.30.0,<2
5
+ loguru>=0.7.0,<1
6
+ openai>=1.0.0,<2
7
+ pandas>=2.2.0,<3
8
+ polars>=1.0.0,<2
9
+ python-dotenv>=1.0.0,<2
10
+ uvicorn>=0.30.0,<1
scripts/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Deployment and data-publication helpers."""
scripts/bootstrap_hf_data.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build the Space's read-only DuckDB from the published daily Parquet files."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import os
6
+ from pathlib import Path
7
+
8
+ import duckdb
9
+ from huggingface_hub import hf_hub_download
10
+
11
+
12
+ DEFAULT_REPO = "egoh33/ibkr-daily-stock-data"
13
+ FILES = ("polygon_bars.parquet", "silver_stock_features.parquet")
14
+
15
+
16
+ def materialize_database(files: dict[str, Path], db_path: Path) -> None:
17
+ """Atomically create a DuckDB containing the two public daily-stock tables."""
18
+ db_path.parent.mkdir(parents=True, exist_ok=True)
19
+ temp_path = db_path.with_suffix(db_path.suffix + ".tmp")
20
+ temp_path.unlink(missing_ok=True)
21
+ with duckdb.connect(str(temp_path)) as conn:
22
+ for filename, source in files.items():
23
+ table = Path(filename).stem
24
+ safe_source = source.as_posix().replace("'", "''")
25
+ conn.execute(f"CREATE TABLE {table} AS SELECT * FROM read_parquet('{safe_source}')")
26
+ conn.execute("CHECKPOINT")
27
+ db_path.unlink(missing_ok=True)
28
+ temp_path.replace(db_path)
29
+
30
+
31
+ def download_files(repo_id: str, token: str | None = None) -> dict[str, Path]:
32
+ return {
33
+ filename: Path(
34
+ hf_hub_download(repo_id=repo_id, repo_type="dataset", filename=filename, token=token)
35
+ )
36
+ for filename in FILES
37
+ }
38
+
39
+
40
+ def main() -> None:
41
+ parser = argparse.ArgumentParser(description=__doc__)
42
+ parser.add_argument("--repo", default=os.getenv("HF_DATASET_REPO", DEFAULT_REPO))
43
+ parser.add_argument("--db-path", default=os.getenv("DB_PATH", "./data/equity.duckdb"))
44
+ parser.add_argument("--source-dir", help="Use local Parquet files instead of downloading")
45
+ args = parser.parse_args()
46
+
47
+ if args.source_dir:
48
+ source_dir = Path(args.source_dir)
49
+ files = {filename: source_dir / filename for filename in FILES}
50
+ else:
51
+ files = download_files(args.repo, token=os.getenv("HF_TOKEN"))
52
+ missing = [str(path) for path in files.values() if not path.is_file()]
53
+ if missing:
54
+ raise FileNotFoundError(f"Missing required daily-stock files: {', '.join(missing)}")
55
+ materialize_database(files, Path(args.db_path))
56
+ print(f"Materialized {args.db_path} from {args.repo}")
57
+
58
+
59
+ if __name__ == "__main__":
60
+ main()