Spaces:
Sleeping
Sleeping
Deploy IBKR Workbench with Hugging Face daily data
Browse files- Dockerfile +33 -0
- README.md +366 -3
- api/__init__.py +1 -0
- api/app.py +682 -0
- api/services/__init__.py +1 -0
- api/services/agent_review.py +70 -0
- db/__init__.py +0 -0
- db/database.py +745 -0
- etl/__init__.py +0 -0
- etl/chat_engine.py +332 -0
- frontend/index.html +15 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +39 -0
- frontend/public/favicon.svg +1 -0
- frontend/public/icons.svg +24 -0
- frontend/src/App.css +184 -0
- frontend/src/App.test.tsx +29 -0
- frontend/src/App.tsx +967 -0
- frontend/src/ResearchOps.tsx +277 -0
- frontend/src/assets/hero.png +0 -0
- frontend/src/assets/react.svg +1 -0
- frontend/src/assets/vite.svg +1 -0
- frontend/src/components/ChartPanel.tsx +45 -0
- frontend/src/index.css +336 -0
- frontend/src/main.tsx +10 -0
- frontend/src/test/setup.ts +10 -0
- frontend/tsconfig.app.json +26 -0
- frontend/tsconfig.json +7 -0
- frontend/tsconfig.node.json +23 -0
- frontend/vite.config.ts +45 -0
- requirements-space.txt +10 -0
- scripts/__init__.py +1 -0
- scripts/bootstrap_hf_data.py +60 -0
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:
|
| 5 |
colorTo: gray
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 & 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()
|