Spaces:
Sleeping
Sleeping
Commit ·
7a8d58d
0
Parent(s):
Initial commit: Terminal.AI monorepo (PaaS-ready)
Browse files- Frontend: Next.js 16 + React + TypeScript + Plotly
- Backend: FastAPI + VectorBT + yfinance
- LEAPS Scanner: S&P 100 concurrent anomaly detection
- Options Flow: Per-ticker chain analysis
- Dark theme terminal UI
- CORS configured for Vercel + Render deployment
- Legacy Docker files moved to _archive/
This view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +10 -0
- .gitignore +59 -0
- AGENTS.md +156 -0
- GEMINI.md +165 -0
- README.md +236 -0
- _archive/DEPLOY.md +82 -0
- _archive/Dockerfile.backend +16 -0
- _archive/Dockerfile.frontend +33 -0
- _archive/deploy.sh +63 -0
- _archive/docker-compose.yml +75 -0
- _archive/nginx/nginx.conf +139 -0
- _archive/setup-ssl.sh +42 -0
- _archive/test_options.py +54 -0
- _archive/test_options2.py +60 -0
- backend/.dockerignore +8 -0
- backend/.gitignore +16 -0
- backend/data_fetcher.py +47 -0
- backend/encoder.py +85 -0
- backend/leaps_scanner.py +182 -0
- backend/main.py +530 -0
- backend/options_flow.py +268 -0
- backend/requirements.txt +9 -0
- backend/start.sh +22 -0
- backend/strategies.py +277 -0
- frontend/.dockerignore +5 -0
- frontend/.gitignore +45 -0
- frontend/AGENTS.md +5 -0
- frontend/CLAUDE.md +1 -0
- frontend/README.md +36 -0
- frontend/app/age.tsx +0 -0
- frontend/app/api.ts +356 -0
- frontend/app/components/BacktestForm.tsx +376 -0
- frontend/app/components/CandlestickChart.tsx +98 -0
- frontend/app/components/ComparePanel.tsx +126 -0
- frontend/app/components/DrawdownChart.tsx +100 -0
- frontend/app/components/EquityChart.tsx +80 -0
- frontend/app/components/FundamentalsPanel.tsx +101 -0
- frontend/app/components/IndicatorChart.tsx +148 -0
- frontend/app/components/OptionsFlowPanel.tsx +295 -0
- frontend/app/components/PriceChart.tsx +164 -0
- frontend/app/components/StatsPanel.tsx +122 -0
- frontend/app/components/TerminalHeader.tsx +37 -0
- frontend/app/components/VolumeChart.tsx +63 -0
- frontend/app/favicon.ico +0 -0
- frontend/app/globals.css +84 -0
- frontend/app/layout.tsx +21 -0
- frontend/app/page.tsx +548 -0
- frontend/eslint.config.mjs +18 -0
- frontend/next.config.js +6 -0
- frontend/next.config.ts +7 -0
.env.example
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Terminal.AI Production Environment
|
| 2 |
+
# Domain'ini buraya yaz:
|
| 3 |
+
DOMAIN=yourdomain.com
|
| 4 |
+
|
| 5 |
+
# Backend
|
| 6 |
+
BACKEND_PORT=8000
|
| 7 |
+
|
| 8 |
+
# Frontend
|
| 9 |
+
FRONTEND_PORT=3000
|
| 10 |
+
NEXT_PUBLIC_API_URL=/api
|
.gitignore
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ==========================================
|
| 2 |
+
# Terminal.AI — Monorepo .gitignore
|
| 3 |
+
# ==========================================
|
| 4 |
+
|
| 5 |
+
# --- OS & Editor ---
|
| 6 |
+
.DS_Store
|
| 7 |
+
Thumbs.db
|
| 8 |
+
*.swp
|
| 9 |
+
*.swo
|
| 10 |
+
*~
|
| 11 |
+
.idea/
|
| 12 |
+
.vscode/
|
| 13 |
+
*.sublime-*
|
| 14 |
+
|
| 15 |
+
# --- Environment & Secrets ---
|
| 16 |
+
.env
|
| 17 |
+
.env.local
|
| 18 |
+
.env.*.local
|
| 19 |
+
.env.production
|
| 20 |
+
.env.development
|
| 21 |
+
*.pem
|
| 22 |
+
*.key
|
| 23 |
+
|
| 24 |
+
# --- Python (Backend) ---
|
| 25 |
+
backend/venv/
|
| 26 |
+
backend/.venv/
|
| 27 |
+
backend/__pycache__/
|
| 28 |
+
backend/*.pyc
|
| 29 |
+
backend/*.pyo
|
| 30 |
+
backend/.pytest_cache/
|
| 31 |
+
backend/.mypy_cache/
|
| 32 |
+
backend/*.egg-info/
|
| 33 |
+
backend/dist/
|
| 34 |
+
backend/build/
|
| 35 |
+
|
| 36 |
+
# --- Node (Frontend) ---
|
| 37 |
+
frontend/node_modules/
|
| 38 |
+
frontend/.next/
|
| 39 |
+
frontend/out/
|
| 40 |
+
frontend/.cache/
|
| 41 |
+
frontend/.turbo/
|
| 42 |
+
frontend/coverage/
|
| 43 |
+
frontend/*.log
|
| 44 |
+
|
| 45 |
+
# --- Build & Dist (Root) ---
|
| 46 |
+
dist/
|
| 47 |
+
build/
|
| 48 |
+
*.tar.gz
|
| 49 |
+
|
| 50 |
+
# --- Docker (artık kullanılmıyor, arşivde) ---
|
| 51 |
+
# docker-compose.yml
|
| 52 |
+
# nginx/
|
| 53 |
+
# deploy.sh
|
| 54 |
+
# setup-ssl.sh
|
| 55 |
+
|
| 56 |
+
# --- Misc ---
|
| 57 |
+
*.bak
|
| 58 |
+
*.tmp
|
| 59 |
+
.cache/
|
AGENTS.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Terminal.AI — VectorBT Finansal Backtest Terminali
|
| 2 |
+
|
| 3 |
+
## Proje Özeti
|
| 4 |
+
VectorBT kütüphanesini temel alan, profesyonel trading terminallerinden ilham alan (dark theme, monospaced estetiği) bir finansal backtest ve analiz platformu. "Terminal.AI" markası altında siyah zemin üzerine beyaz/turuncu aksanlarla kurgulanmıştır. Opsiyon akışı (options flow) modülüyle LEAPS ve anormal opsiyon aktivitelerini izleme desteği mevcuttur.
|
| 5 |
+
|
| 6 |
+
## Teknik Mimari
|
| 7 |
+
|
| 8 |
+
### Backend (v1.1.0)
|
| 9 |
+
- **Framework:** FastAPI (Python) — Port 8000
|
| 10 |
+
- **Analiz Motoru:** vectorbt==0.26.2
|
| 11 |
+
- **Veri Kaynağı:** yfinance==1.3.0 (curl_cffi tabanlı)
|
| 12 |
+
- **Stratejiler:** SMA Crossover, EMA Crossover, Bollinger Bands
|
| 13 |
+
- **İndikatörler:** RSI, MACD
|
| 14 |
+
- **Sabitler:** init_cash=10000, fees=0.001 (arka planda)
|
| 15 |
+
- **Cache:** 10dk TTL in-memory cache (fundamentals + ticker range), 5dk options flow cache
|
| 16 |
+
|
| 17 |
+
### Frontend
|
| 18 |
+
- **Framework:** Next.js 16.2.6 (App Router, Turbopack) — Port 3000
|
| 19 |
+
- **CSS:** TailwindCSS v4
|
| 20 |
+
- **Grafik:** react-plotly.js ^2.6.0 + plotly.js ^3.5.1
|
| 21 |
+
- **Dil:** TypeScript, React 19
|
| 22 |
+
|
| 23 |
+
## Proje Dizin Yapısı
|
| 24 |
+
```
|
| 25 |
+
HERMES(deneme)/
|
| 26 |
+
├── AGENTS.md / GEMINI.md # Proje bağlam dosyaları
|
| 27 |
+
├── backend/
|
| 28 |
+
│ ├── main.py # FastAPI (v1.1.0) — endpoint'ler + cache
|
| 29 |
+
│ ├── strategies.py # Stratejiler: sma, ema, bollinger + rsi, macd
|
| 30 |
+
│ ├── data_fetcher.py # yfinance ile OHLCV veri çekme
|
| 31 |
+
│ ├── encoder.py # NanEncoder — NaN/Inf/DateTime serializer
|
| 32 |
+
│ ├── options_flow.py # Opsiyon akışı + LEAPS detay (tek hisse)
|
| 33 |
+
│ ├── leaps_scanner.py # S&P 100 concurrent LEAPS anomali tarama
|
| 34 |
+
│ ├── requirements.txt, start.sh, .env
|
| 35 |
+
│ └── venv/ # Sanal ortam
|
| 36 |
+
└── frontend/
|
| 37 |
+
├── app/
|
| 38 |
+
│ ├── page.tsx # Ana sayfa — backtest + compare + options
|
| 39 |
+
│ ├── layout.tsx # Root layout (suppressHydrationWarning)
|
| 40 |
+
│ ├── globals.css # Dark tema CSS değişkenleri
|
| 41 |
+
│ ├── api.ts # Backend API client (options dahil)
|
| 42 |
+
│ └── components/
|
| 43 |
+
│ ├── BacktestForm.tsx # Strateji + ticker + tarih (boş başlar)
|
| 44 |
+
│ ├── FundamentalsPanel.tsx # Şirket çarpanları (P/E, P/B, vb.)
|
| 45 |
+
│ ├── OptionsFlowPanel.tsx # Opsiyon akışı + LEAPS detay
|
| 46 |
+
│ ├── PriceChart.tsx # Candlestick/Line + overlay + sinyaller
|
| 47 |
+
│ ├── CandlestickChart.tsx, VolumeChart.tsx
|
| 48 |
+
│ ├── EquityChart.tsx, DrawdownChart.tsx
|
| 49 |
+
│ ├── IndicatorChart.tsx # RSI/MACD
|
| 50 |
+
│ ├── ComparePanel.tsx # Strateji karşılaştırma
|
| 51 |
+
│ └── TerminalHeader.tsx
|
| 52 |
+
├── AGENTS.md, CLAUDE.md
|
| 53 |
+
└── package.json
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
## API Endpoint'ler
|
| 57 |
+
|
| 58 |
+
### Backtest & Analiz
|
| 59 |
+
- `GET /api/health` → `{"status":"ok","version":"1.1.0"}`
|
| 60 |
+
- `GET /api/strategies` → sma, ema, bollinger listesi
|
| 61 |
+
- `POST /api/backtest` → Strateji seçerek backtest. Parametreler: `strategy`, `fast_ma`, `slow_ma`, `bb_window`, `bb_std`, `rsi_window`, `macd_*`. Response: OHLCV + overlay + RSI/MACD + sinyaller + equity + drawdown.
|
| 62 |
+
- `POST /api/compare` → Tüm stratejileri karşılaştırır.
|
| 63 |
+
- `GET /api/ticker/{ticker}` → Ticker bilgisi
|
| 64 |
+
- `GET /api/ticker/{ticker}/range` → `{earliest, latest, data_points}` (10dk cache)
|
| 65 |
+
- `GET /api/ticker/{ticker}/fundamentals` → Şirket çarpanları (10dk cache)
|
| 66 |
+
|
| 67 |
+
### Opsiyon Akışı (Options Flow)
|
| 68 |
+
- `GET /api/ticker/{ticker}/options-flow` → Tek hisse opsiyon zinciri analizi
|
| 69 |
+
- Per-expiry call/put volumes, OI, volume/OI ratios
|
| 70 |
+
- LEAPS (vade > 365 gün) ayrı işaretlenmiş
|
| 71 |
+
- Anormal hacim/OI spike'ları (vol/OI ≥ 2x)
|
| 72 |
+
- Call/Put oranı
|
| 73 |
+
- Cache: 5 dakika
|
| 74 |
+
- Response: `{ ticker, as_of, has_leaps, leaps_expiries, summary, all_expiries, leaps, unusual_activities }`
|
| 75 |
+
|
| 76 |
+
- `GET /api/options-flow/leaps-board` → 22 ticker taraması, LEAPS aktivitesine göre sıralı (async concurrent)
|
| 77 |
+
- Response: `{ count, scan_tickers, results: [{ ticker, has_leaps, leaps_count, total_call_volume, total_put_volume, call_put_ratio, unusual_count, top_leaps_expiry }] }`
|
| 78 |
+
|
| 79 |
+
- `GET /api/leaps/scanner` → **S&P 100 tabanlı LEAPS anomali tarama** (yeni!)
|
| 80 |
+
- S&P 100 ticker listesini concurrent tarar (~100 hisse, ~10 saniye)
|
| 81 |
+
- LEAPS opsiyonlarda volume/OI ≥ 2x anormal spike yakalar
|
| 82 |
+
- Parametreler: `max_tickers` (1-100, varsayılan 100), `min_spikes` (varsayılan 1)
|
| 83 |
+
- Response: `{ tickers: [{ ticker, total_leaps_call_vol, total_leaps_put_vol, total_leaps_vol, call_put_ratio, unusual_count, top_spike, all_spikes, leaps_expiries_count, nearest_leaps_expiry }], count, scan_info }`
|
| 84 |
+
|
| 85 |
+
## Mevcut Durum
|
| 86 |
+
- [x] **3 strateji:** SMA, EMA, Bollinger
|
| 87 |
+
- [x] RSI + MACD indikatörleri
|
| 88 |
+
- [x] Candlestick/Line toggle, Volume, Drawdown grafikleri
|
| 89 |
+
- [x] Strateji karşılaştırma (Compare All)
|
| 90 |
+
- [x] **Fundamentals paneli** — Market Cap, P/E, P/B, EV/EBITDA, ROE, Beta, vb.
|
| 91 |
+
- [x] Ticker yazınca direkt fundamentals + tarih aralığı (800ms debounce)
|
| 92 |
+
- [x] "All Time" butonu ile full range seçimi
|
| 93 |
+
- [x] Equity curve, pan/zoom modebar
|
| 94 |
+
- [x] 10dk backend cache (rate limit koruması)
|
| 95 |
+
- [x] Dark tema (Terminal.AI — siyah+turuncu)
|
| 96 |
+
- [x] **Options Flow modülü** — `options_flow.py` (yfinance tabanlı, tek hisse detay)
|
| 97 |
+
- [x] **LEAPS Scanner** — `leaps_scanner.py` (S&P 100 concurrent, ~10s)
|
| 98 |
+
- [x] **Options sekmesi** — Frontend'te LEAPS Scanner tablosu + per-ticker options flow
|
| 99 |
+
- [x] Backend + Frontend birlikte çalışıyor
|
| 100 |
+
|
| 101 |
+
## Bilinen Sorunlar ve Çözümler
|
| 102 |
+
|
| 103 |
+
### 1. vectorbt MACD — `histogram` → `hist`
|
| 104 |
+
**Dosya:** `backend/strategies.py` · Çözüm: `macd.histogram` yerine `macd.hist`.
|
| 105 |
+
|
| 106 |
+
### 2. vectorbt'de EMA yok
|
| 107 |
+
**Dosya:** `backend/strategies.py` → `run_ema_crossover()`
|
| 108 |
+
**Çözüm:** `close.ewm(span=N, adjust=False).mean()` ile pandas EMA, crossover manuel.
|
| 109 |
+
|
| 110 |
+
### 3. vectorbt BBANDS.run bug'li
|
| 111 |
+
**Dosya:** `backend/strategies.py` → `run_bollinger_bands()`
|
| 112 |
+
**Çözüm:** `close.rolling(w).mean() + std()` ile manuel hesaplama.
|
| 113 |
+
|
| 114 |
+
### 4. Port cakismasi (Windows/MSYS)
|
| 115 |
+
**Çözüm:** `cmd //c "taskkill /PID <PID> /F"` — start.sh otomatik temizler.
|
| 116 |
+
|
| 117 |
+
### 5. Dark Reader hydration
|
| 118 |
+
**Dosya:** `frontend/app/layout.tsx` → `<html suppressHydrationWarning>`.
|
| 119 |
+
|
| 120 |
+
### 6. yfinance eski surum → 0.2.43 → 1.3.0
|
| 121 |
+
|
| 122 |
+
### 7. Plotly TypeScript tip uyumsuzlukları
|
| 123 |
+
**Sorun:** `react-plotly.js` ile `plotly.js` versiyon mismatch → TS2769 hataları
|
| 124 |
+
**Durum:** Next.js Turbopack bunları ignore eder, runtime'da sorun çıkarmaz. Düşük öncelik.
|
| 125 |
+
|
| 126 |
+
## Options Flow Modülü Detayları
|
| 127 |
+
|
| 128 |
+
### `backend/options_flow.py` (tek hisse detay)
|
| 129 |
+
- `fetch_options_flow(ticker)` — Tek hisse tam opsiyon zinciri analizi
|
| 130 |
+
- `rank_single_ticker(sym)` — Tek hisse özet (LEAPS board için)
|
| 131 |
+
- `rank_tickers_by_leaps_activity(tickers)` — Çoklu ticker sıralama (senkron, fallback)
|
| 132 |
+
- `DEFAULT_SCAN_TICKERS` — 22 ticker statik liste
|
| 133 |
+
|
| 134 |
+
### `backend/leaps_scanner.py` (S&P 100 anomali tarama)
|
| 135 |
+
- `scan_ticker_leaps(sym)` — Tek ticker LEAPS tarama (vol/OI ≥ 2x)
|
| 136 |
+
- `scan_all_sp100_leaps(max_tickers)` — S&P 100 concurrent tarama (ThreadPoolExecutor, 10 worker)
|
| 137 |
+
- `SP100_TICKERS` — S&P 100 ticker listesi (~100 hisse)
|
| 138 |
+
- Sabitler: `LEAPS_MIN_DAYS=365`, `UNUSUAL_VOL_OI_MIN=2.0`, `MAX_WORKERS=10`
|
| 139 |
+
|
| 140 |
+
### `frontend/app/components/OptionsFlowPanel.tsx`
|
| 141 |
+
- Props: `ticker: string`
|
| 142 |
+
- KPI satırı: Call Vol, Put Vol, C/P Ratio, Expiries, LEAPS count
|
| 143 |
+
- Expiry breakdown: Her vade için açılır/kapanır kart (top calls/puts tablosu)
|
| 144 |
+
- Unusual Activity tablosu: vol/OI ≥ 2x olan sinyaller
|
| 145 |
+
|
| 146 |
+
### `frontend/app/api.ts` — Yeni tipler
|
| 147 |
+
- `OptionsFlowResponse`, `OptionsChainExpiry`, `OptionStrike`, `UnusualActivity`
|
| 148 |
+
- `LeapsBoardEntry`, `LeapsBoardResponse` (22 ticker board)
|
| 149 |
+
- `LeapsScannerEntry`, `LeapsScannerResponse` (S&P 100 scanner)
|
| 150 |
+
- Fonksiyonlar: `fetchOptionsFlow(ticker)`, `fetchLeapsBoard(limit)`, `fetchLeapsScanner(max_tickers, min_spikes)`
|
| 151 |
+
|
| 152 |
+
## Hizli Calistirma
|
| 153 |
+
```bash
|
| 154 |
+
cd backend && bash start.sh # port 8000 (otomatik port temizliği)
|
| 155 |
+
cd frontend && npm run dev # port 3000
|
| 156 |
+
```
|
GEMINI.md
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Terminal.AI — VectorBT Finansal Backtest Terminali
|
| 2 |
+
|
| 3 |
+
## Proje Özeti
|
| 4 |
+
VectorBT kütüphanesini temel alan, profesyonel trading terminallerinden ilham alan (dark theme, monospaced estetiği) bir finansal backtest ve analiz platformu. Kullanıcıların teknik göstergeleri (SMA, RSI, MACD) test etmesine ve sonuçları interaktif grafiklerle görmesine olanak tanır. "Terminal.AI" markası altında siyah zemin üzerine beyaz/turuncu aksanlarla kurgulanmıştır. Opsiyon akışı modülüyle LEAPS ve anormal opsiyon aktivitelerini izleme desteği mevcuttur.
|
| 5 |
+
|
| 6 |
+
## Teknik Mimari
|
| 7 |
+
|
| 8 |
+
### Backend (v1.1.0)
|
| 9 |
+
- **Framework:** FastAPI (Python)
|
| 10 |
+
- **Port:** 8000
|
| 11 |
+
- **Analiz Motoru:** vectorbt==0.26.2
|
| 12 |
+
- **Veri Kaynağı:** yfinance==1.3.0 (curl_cffi tabanlı)
|
| 13 |
+
- **Stratejiler:** SMA Crossover, EMA Crossover, Bollinger Bands
|
| 14 |
+
- **İndikatörler:** RSI, MACD
|
| 15 |
+
- **Sabitler:** init_cash=10000, fees=0.001 (arka planda)
|
| 16 |
+
- **Cache:** 10dk TTL in-memory cache (fundamentals + ticker range), 5dk options flow cache
|
| 17 |
+
- **Diğer:** pandas==2.2.3, numpy==1.26.4, pydantic==2.9.2
|
| 18 |
+
- **Çalıştırma:** `cd backend && bash start.sh`
|
| 19 |
+
|
| 20 |
+
### Frontend
|
| 21 |
+
- **Framework:** Next.js 16.2.6 (App Router, Turbopack)
|
| 22 |
+
- **Port:** 3000
|
| 23 |
+
- **CSS:** TailwindCSS v4
|
| 24 |
+
- **Grafik:** react-plotly.js ^2.6.0 + plotly.js ^3.5.1
|
| 25 |
+
- **Dil:** TypeScript, React 19
|
| 26 |
+
- **Çalıştırma:** `cd frontend && npm run dev`
|
| 27 |
+
|
| 28 |
+
## Proje Dizin Yapısı
|
| 29 |
+
```
|
| 30 |
+
HERMES(deneme)/
|
| 31 |
+
├── AGENTS.md / GEMINI.md # Proje bağlam dosyaları
|
| 32 |
+
├── backend/
|
| 33 |
+
│ ├── main.py # FastAPI (v1.1.0) — endpoint'ler + cache
|
| 34 |
+
│ ├── strategies.py # Stratejiler: sma, ema, bollinger + rsi, macd
|
| 35 |
+
│ ├── data_fetcher.py # yfinance ile OHLCV veri çekme
|
| 36 |
+
│ ├── encoder.py # NanEncoder — NaN/Inf/DateTime serializer
|
| 37 |
+
│ ├── options_flow.py # Opsiyon akışı (tek hisse detay)
|
| 38 |
+
│ ├── leaps_scanner.py # S&P 100 concurrent LEAPS anomali tarama
|
| 39 |
+
│ ├── requirements.txt # Python bağımlılıkları
|
| 40 |
+
│ ├── start.sh # Başlatma scripti (port temizliği dahil)
|
| 41 |
+
│ └── venv/ # Sanal ortam
|
| 42 |
+
└── frontend/
|
| 43 |
+
├── app/
|
| 44 |
+
│ ├── page.tsx # Ana sayfa — backtest + compare + options
|
| 45 |
+
│ ├── layout.tsx # Root layout
|
| 46 |
+
│ ├── globals.css # Dark tema CSS değişkenleri
|
| 47 |
+
│ ├── api.ts # Backend API client
|
| 48 |
+
│ └── components/
|
| 49 |
+
│ ├── BacktestForm.tsx # Parametre giriş formu
|
| 50 |
+
│ ├── StatsPanel.tsx # Portföy istatistik paneli
|
| 51 |
+
│ ├── PriceChart.tsx # Fiyat + overlay grafiği
|
| 52 |
+
│ ├── CandlestickChart.tsx # Mum grafiği
|
| 53 |
+
│ ├── VolumeChart.tsx # Hacim grafiği
|
| 54 |
+
│ ├── EquityChart.tsx # Equity curve grafiği
|
| 55 |
+
│ ├── DrawdownChart.tsx # Drawdown grafiği
|
| 56 |
+
│ ├── IndicatorChart.tsx # RSI/MACD indikatör grafiği
|
| 57 |
+
│ ├── ComparePanel.tsx # Strateji karşılaştırma
|
| 58 |
+
│ ├── FundamentalsPanel.tsx # Şirket çarpanları
|
| 59 |
+
│ ├── OptionsFlowPanel.tsx # Opsiyon akışı detay
|
| 60 |
+
│ └── TerminalHeader.tsx # Terminal.AI başlık
|
| 61 |
+
├── AGENTS.md # Next.js uyarısı
|
| 62 |
+
├── CLAUDE.md # AGENTS.md referansı
|
| 63 |
+
└── package.json # Node.js bağımlılıkları
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
## API Endpoint'ler
|
| 67 |
+
|
| 68 |
+
### Backtest & Analiz
|
| 69 |
+
- `GET /api/health` → `{"status": "ok", "version": "1.1.0"}`
|
| 70 |
+
- `GET /api/strategies` → Mevcut stratejileri listeler
|
| 71 |
+
- `POST /api/backtest` → SMA Crossover backtest + RSI + MACD indikatörleri çalıştırır
|
| 72 |
+
- `POST /api/compare` → Tüm stratejileri karşılaştırır
|
| 73 |
+
- `GET /api/ticker/{ticker}` → Ticker bilgisi ve son fiyat
|
| 74 |
+
- `GET /api/ticker/{ticker}/range` → Tarih aralığı (10dk cache)
|
| 75 |
+
- `GET /api/ticker/{ticker}/fundamentals` → Şirket çarpanları (10dk cache)
|
| 76 |
+
|
| 77 |
+
### Opsiyon Akışı
|
| 78 |
+
- `GET /api/ticker/{ticker}/options-flow` → Tek hisse opsiyon zinciri analizi (5dk cache)
|
| 79 |
+
- `GET /api/options-flow/leaps-board` → 22 ticker LEAPS board (async concurrent)
|
| 80 |
+
- `GET /api/leaps/scanner?max_tickers=100&min_spikes=1` → S&P 100 LEAPS anomali tarama (~10s)
|
| 81 |
+
|
| 82 |
+
## Backtest Request/Response
|
| 83 |
+
|
| 84 |
+
**Request:**
|
| 85 |
+
```json
|
| 86 |
+
{
|
| 87 |
+
"ticker": "AAPL",
|
| 88 |
+
"start_date": "2024-01-01",
|
| 89 |
+
"end_date": "2024-06-30",
|
| 90 |
+
"interval": "1d",
|
| 91 |
+
"fast_ma": 10,
|
| 92 |
+
"slow_ma": 30,
|
| 93 |
+
"fees": 0.001,
|
| 94 |
+
"init_cash": 10000,
|
| 95 |
+
"rsi_window": 14,
|
| 96 |
+
"macd_fast": 12,
|
| 97 |
+
"macd_slow": 26,
|
| 98 |
+
"macd_signal": 9
|
| 99 |
+
}
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
**Response:** OHLCV verisi + portföy istatistikleri + trade log + SMA/RSI/MACD indikatör verileri + al/sat sinyalleri + equity curve + drawdown.
|
| 103 |
+
|
| 104 |
+
## Mevcut Durum
|
| 105 |
+
- [x] SMA Crossover stratejisi çalışıyor
|
| 106 |
+
- [x] EMA Crossover stratejisi çalışıyor
|
| 107 |
+
- [x] Bollinger Bands stratejisi çalışıyor
|
| 108 |
+
- [x] RSI ve MACD indikatörleri backend'de hesaplanıyor
|
| 109 |
+
- [x] Frontend'de tüm grafikler (fiyat, equity, RSI, MACD, volume, drawdown) gösteriliyor
|
| 110 |
+
- [x] Strateji karşılaştırma (Compare All)
|
| 111 |
+
- [x] Fundamentals paneli (Market Cap, P/E, P/B, EV/EBITDA, ROE, Beta)
|
| 112 |
+
- [x] Ticker değişince otomatik fundamentals + tarih aralığı (800ms debounce)
|
| 113 |
+
- [x] Options Flow modülü (tek hisse detay + LEAPS board + S&P 100 scanner)
|
| 114 |
+
- [x] Options sekmesi (LEAPS Scanner tablosu + per-ticker options flow)
|
| 115 |
+
- [x] Dark tema (Terminal.AI markası)
|
| 116 |
+
- [x] yfinance 1.3.0'a yükseltildi (curl_cffi tabanlı)
|
| 117 |
+
- [x] Backend + Frontend birlikte çalışıyor
|
| 118 |
+
|
| 119 |
+
## Bilinen Sorunlar ve Çözümler
|
| 120 |
+
|
| 121 |
+
### 1. vectorbt MACD — `histogram` attribute'ı yok
|
| 122 |
+
**Hata:** `'MACD' object has no attribute 'histogram'`
|
| 123 |
+
**Çözüm:** `macd.histogram` yerine `macd.hist` kullanılmalı (vectorbt 0.26.2 API değişikliği).
|
| 124 |
+
**Dosya:** `backend/strategies.py`
|
| 125 |
+
|
| 126 |
+
### 2. Port çakışması (Windows)
|
| 127 |
+
**Sorun:** Eski process kapatılmazsa port 8000 dolu kalır.
|
| 128 |
+
**Çözüm:** `start.sh` otomatik port temizliği yapar. Manuel: `cmd //c "taskkill /PID <PID> /F"`
|
| 129 |
+
|
| 130 |
+
### 3. Dark Reader hydration hatası
|
| 131 |
+
**Sorun:** Dark Reader eklentisi `<html>`'e attribute ekler, hydration uyuşmazlığı oluşur.
|
| 132 |
+
**Çözüm:** `<html lang="en" suppressHydrationWarning>` — layout.tsx'te eklendi.
|
| 133 |
+
|
| 134 |
+
### 4. yfinance eski sürüm
|
| 135 |
+
**Sorun:** yfinance 0.2.43 Yahoo Finance API'den veri çekemiyordu.
|
| 136 |
+
**Çözüm:** yfinance 1.3.0'a yükseltildi (curl_cffi tabanlı, Cloudflare bypass).
|
| 137 |
+
|
| 138 |
+
### 5. Plotly TypeScript tipleri
|
| 139 |
+
**Sorun:** `react-plotly.js` ile `plotly.js` versiyon mismatch → TS2769
|
| 140 |
+
**Durum:** Turbopack ignore eder, runtime sorun yok.
|
| 141 |
+
|
| 142 |
+
## LEAPS Scanner Detayı (`backend/leaps_scanner.py`)
|
| 143 |
+
- S&P 100 ticker listesini ~10 saniyede concurrent tarar
|
| 144 |
+
- LEAPS = vade > 365 gün
|
| 145 |
+
- Anomali = volume/openInterest ≥ 2x
|
| 146 |
+
- Sonuçlar toplam LEAPS hacmine göre sıralı
|
| 147 |
+
- Her ticker için en büyük spike + tüm spike'lar döner
|
| 148 |
+
|
| 149 |
+
## Yapılacaklar / Gelecek Özellikler
|
| 150 |
+
- [ ] Portföy optimizasyonu (çoklu ticker backtest)
|
| 151 |
+
- [ ] LEAPS Scanner sonuçlarını export (CSV)
|
| 152 |
+
- [ ] Canlı veri / WebSocket desteği
|
| 153 |
+
- [ ] Responsive mobil görünüm iyileştirmeleri
|
| 154 |
+
- [ ] Plotly TS tip düzeltmeleri
|
| 155 |
+
|
| 156 |
+
## Çalıştırma (Hızlı Başlangıç)
|
| 157 |
+
```bash
|
| 158 |
+
# Backend
|
| 159 |
+
cd backend && bash start.sh
|
| 160 |
+
# -> http://localhost:8000
|
| 161 |
+
|
| 162 |
+
# Frontend (ayrı terminal)
|
| 163 |
+
cd frontend && npm run dev
|
| 164 |
+
# -> http://localhost:3000
|
| 165 |
+
```
|
README.md
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<div align="center">
|
| 2 |
+
|
| 3 |
+
# ⚡ Terminal.AI
|
| 4 |
+
|
| 5 |
+
**VectorBT Finansal Backtest + LEAPS Opsiyon Scanner**
|
| 6 |
+
|
| 7 |
+
[](https://nextjs.org/)
|
| 8 |
+
[](https://fastapi.tiangolo.com/)
|
| 9 |
+
[](https://python.org/)
|
| 10 |
+
[](https://typescriptlang.org/)
|
| 11 |
+
[](LICENSE)
|
| 12 |
+
|
| 13 |
+
[Demo](https://terminal-ai.vercel.app) · [Hataları Bildir](https://github.com/username/terminal-ai/issues) · [Özellik İste](https://github.com/username/terminal-ai/issues)
|
| 14 |
+
|
| 15 |
+
</div>
|
| 16 |
+
|
| 17 |
+
---
|
| 18 |
+
|
| 19 |
+
## 📖 Hakkında
|
| 20 |
+
|
| 21 |
+
Terminal.AI, **VectorBT** kütüphanesini temel alan, profesyonel trading terminallerinden ilham alan bir **finansal backtest ve opsiyon analiz platformudur**. Siyah zemin üzerine turuncu aksanlarla kurgulanmış terminal estetiğiyle, teknik göstergeleri test etmenizi ve sonuçları interaktif grafiklerle görmenizi sağlar.
|
| 22 |
+
|
| 23 |
+
### Temel Özellikler
|
| 24 |
+
|
| 25 |
+
- **3 Strateji:** SMA Crossover, EMA Crossover, Bollinger Bands
|
| 26 |
+
- **İndikatörler:** RSI, MACD
|
| 27 |
+
- **Grafikler:** Candlestick, Volume, Equity Curve, Drawdown, RSI/MACD
|
| 28 |
+
- **Strateji Karşılaştırma:** Tüm stratejileri tek seferde karşılaştır
|
| 29 |
+
- **Fundamentals Paneli:** Market Cap, P/E, P/B, EV/EBITDA, ROE, Beta
|
| 30 |
+
- **Options Flow:** Tek hisse opsiyon zinciri analizi (LEAPS + anormal hacim/OI)
|
| 31 |
+
- **LEAPS Scanner:** S&P 100 concurrent taraması (~10 saniye), tıklanabilir satırlar
|
| 32 |
+
|
| 33 |
+
---
|
| 34 |
+
|
| 35 |
+
## 🏗️ Mimari
|
| 36 |
+
|
| 37 |
+
Bu proje **monorepo** yapısında olup **PaaS (Platform as a Service)** mimarisinde deploy edilmiştort.
|
| 38 |
+
|
| 39 |
+
```
|
| 40 |
+
┌─────────────────────────────────────────────────────────┐
|
| 41 |
+
│ GitHub (Monorepo) │
|
| 42 |
+
│ ┌───────────────────┐ ┌──────────────────────────┐ │
|
| 43 |
+
│ │ frontend/ │ │ backend/ │ │
|
| 44 |
+
│ │ (Next.js) │ │ (FastAPI) │ │
|
| 45 |
+
│ └────────┬──────────┘ └───────────┬──────────────┘ │
|
| 46 |
+
└───────────┼───────────────────────────┼──────────────────┘
|
| 47 |
+
│ │
|
| 48 |
+
▼ ▼
|
| 49 |
+
┌───────────────┐ ┌────────────────┐
|
| 50 |
+
│ Vercel │ │ Render │
|
| 51 |
+
│ (Frontend) │◄────────►│ (Backend) │
|
| 52 |
+
└───────────────┘ └────────────────┘
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
### Frontend → Vercel
|
| 56 |
+
- **Root Directory:** `frontend/`
|
| 57 |
+
- **Framework:** Next.js 16 (App Router, Turbopack)
|
| 58 |
+
- **Build Command:** `npm run build`
|
| 59 |
+
- **Output Directory:** `.next/`
|
| 60 |
+
- **Environment:** `NEXT_PUBLIC_API_URL=https://your-backend.onrender.com`
|
| 61 |
+
|
| 62 |
+
### Backend → Render
|
| 63 |
+
- **Root Directory:** `backend/`
|
| 64 |
+
- **Runtime:** Python 3.11
|
| 65 |
+
- **Build Command:** `pip install -r requirements.txt`
|
| 66 |
+
- **Start Command:** `uvicorn main:app --host 0.0.0.0 --port 8000`
|
| 67 |
+
- **Environment:** `FRONTEND_URL=https://your-frontend.vercel.app`
|
| 68 |
+
|
| 69 |
+
---
|
| 70 |
+
|
| 71 |
+
## 📁 Klasör Yapısı
|
| 72 |
+
|
| 73 |
+
```
|
| 74 |
+
terminal-ai/
|
| 75 |
+
├── frontend/ # Next.js Frontend
|
| 76 |
+
│ ├── app/
|
| 77 |
+
│ │ ├── page.tsx # Ana sayfa (Backtest / Compare / Options)
|
| 78 |
+
│ │ ├── layout.tsx # Root layout
|
| 79 |
+
│ │ ├── globals.css # Dark tema CSS
|
| 80 |
+
│ │ ├── api.ts # Backend API client
|
| 81 |
+
│ │ └── components/
|
| 82 |
+
│ │ ├── BacktestForm.tsx
|
| 83 |
+
│ │ ├── PriceChart.tsx
|
| 84 |
+
│ │ ├── CandlestickChart.tsx
|
| 85 |
+
│ │ ├── VolumeChart.tsx
|
| 86 |
+
│ │ ├── EquityChart.tsx
|
| 87 |
+
│ │ ├── DrawdownChart.tsx
|
| 88 |
+
│ │ ├── IndicatorChart.tsx
|
| 89 |
+
│ │ ├── ComparePanel.tsx
|
| 90 |
+
│ │ ├── FundamentalsPanel.tsx
|
| 91 |
+
│ │ ├── OptionsFlowPanel.tsx
|
| 92 |
+
│ │ └── TerminalHeader.tsx
|
| 93 |
+
│ ├── public/
|
| 94 |
+
│ └── package.json
|
| 95 |
+
│
|
| 96 |
+
├── backend/ # FastAPI Backend
|
| 97 |
+
│ ├── main.py # Endpoint'ler + CORS + Cache
|
| 98 |
+
│ ├── strategies.py # SMA, EMA, Bollinger + RSI, MACD
|
| 99 |
+
│ ├── data_fetcher.py # yfinance OHLCV
|
| 100 |
+
│ ├── encoder.py # NaN/Inf/DateTime serializer
|
| 101 |
+
│ ├── options_flow.py # Tek hisse opsiyon zinciri
|
| 102 |
+
│ ├── leaps_scanner.py # S&P 100 LEAPS anomali tarama
|
| 103 |
+
│ ├── requirements.txt
|
| 104 |
+
│ └── start.sh
|
| 105 |
+
│
|
| 106 |
+
├── _archive/ # Eski Docker deployment dosyaları
|
| 107 |
+
│ ├── docker-compose.yml
|
| 108 |
+
│ ├── nginx/
|
| 109 |
+
│ ├── deploy.sh
|
| 110 |
+
│ ├── setup-ssl.sh
|
| 111 |
+
│ ├── Dockerfile.backend
|
| 112 |
+
│ ├── Dockerfile.frontend
|
| 113 |
+
│ └── DEPLOY.md
|
| 114 |
+
│
|
| 115 |
+
├── .gitignore # Kök + alt dizin ignore kuralları
|
| 116 |
+
├── AGENTS.md # Proje bağlam dosyası (AI agent'ları için)
|
| 117 |
+
├── GEMINI.md # Proje bağlam dosyası (Gemini için)
|
| 118 |
+
└── README.md # Bu dosya
|
| 119 |
+
```
|
| 120 |
+
|
| 121 |
+
---
|
| 122 |
+
|
| 123 |
+
## 🚀 Deploy Rehberi
|
| 124 |
+
|
| 125 |
+
### Frontend (Vercel)
|
| 126 |
+
|
| 127 |
+
1. [Vercel](https://vercel.com)'e GitHub hesabınla giriş yap
|
| 128 |
+
2. **Add New Project** → Repository'yi seç
|
| 129 |
+
3. **Root Directory:** `frontend` olarak ayarla
|
| 130 |
+
4. **Framework Preset:** Next.js
|
| 131 |
+
5. **Environment Variables:**
|
| 132 |
+
```
|
| 133 |
+
NEXT_PUBLIC_API_URL=https://your-backend.onrender.com
|
| 134 |
+
```
|
| 135 |
+
6. **Deploy** butonuna tıkla
|
| 136 |
+
|
| 137 |
+
### Backend (Render)
|
| 138 |
+
|
| 139 |
+
1. [Render](https://render.com)'e giriş yap
|
| 140 |
+
2. **New +** → **Web Service**
|
| 141 |
+
3. GitHub repo'nu bağla
|
| 142 |
+
4. **Root Directory:** `backend` olarak ayarla
|
| 143 |
+
5. **Runtime:** Python 3
|
| 144 |
+
6. **Build Command:** `pip install -r requirements.txt`
|
| 145 |
+
7. **Start Command:** `uvicorn main:app --host 0.0.0.0 --port 8000`
|
| 146 |
+
8. **Environment Variables:**
|
| 147 |
+
```
|
| 148 |
+
FRONTEND_URL=https://your-frontend.vercel.app
|
| 149 |
+
```
|
| 150 |
+
9. **Create Web Service** butonuna tıkla
|
| 151 |
+
|
| 152 |
+
### Environment Değişkenleri
|
| 153 |
+
|
| 154 |
+
| Değişken | Servis | Açıklama |
|
| 155 |
+
|-----------|--------|----------|
|
| 156 |
+
| `NEXT_PUBLIC_API_URL` | Frontend | Backend URL'si (örn: `https://terminal-ai-api.onrender.com`) |
|
| 157 |
+
| `FRONTEND_URL` | Backend | Frontend URL'si (CORS için, örn: `https://terminal-ai.vercel.app`) |
|
| 158 |
+
|
| 159 |
+
---
|
| 160 |
+
|
| 161 |
+
## 💻 Geliştirme
|
| 162 |
+
|
| 163 |
+
### Gereksinimler
|
| 164 |
+
- Python 3.11+
|
| 165 |
+
- Node.js 20+
|
| 166 |
+
- npm veya yarn
|
| 167 |
+
|
| 168 |
+
### Backend
|
| 169 |
+
|
| 170 |
+
```bash
|
| 171 |
+
cd backend
|
| 172 |
+
python -m venv venv
|
| 173 |
+
source venv/bin/activate # Windows: venv\Scripts\activate
|
| 174 |
+
pip install -r requirements.txt
|
| 175 |
+
python main.py
|
| 176 |
+
# → http://localhost:8000
|
| 177 |
+
```
|
| 178 |
+
|
| 179 |
+
### Frontend
|
| 180 |
+
|
| 181 |
+
```bash
|
| 182 |
+
cd frontend
|
| 183 |
+
npm install
|
| 184 |
+
npm run dev
|
| 185 |
+
# → http://localhost:3000
|
| 186 |
+
```
|
| 187 |
+
|
| 188 |
+
### API Dokümantasyonu
|
| 189 |
+
|
| 190 |
+
Backend çalışırken: `http://localhost:8000/docs` (Swagger UI)
|
| 191 |
+
|
| 192 |
+
---
|
| 193 |
+
|
| 194 |
+
## 📡 API Endpoint'ler
|
| 195 |
+
|
| 196 |
+
### Backtest & Analiz
|
| 197 |
+
| Method | Endpoint | Açıklama |
|
| 198 |
+
|--------|----------|----------|
|
| 199 |
+
| GET | `/api/health` | Health check |
|
| 200 |
+
| GET | `/api/strategies` | Strateji listesi |
|
| 201 |
+
| POST | `/api/backtest` | Backtest çalıştır |
|
| 202 |
+
| POST | `/api/compare` | Tüm stratejileri karşılaştır |
|
| 203 |
+
| GET | `/api/ticker/{ticker}` | Ticker bilgisi |
|
| 204 |
+
| GET | `/api/ticker/{ticker}/range` | Tarih aralığı |
|
| 205 |
+
| GET | `/api/ticker/{ticker}/fundamentals` | Şirket çarpanları |
|
| 206 |
+
|
| 207 |
+
### Opsiyon Akışı
|
| 208 |
+
| Method | Endpoint | Açıklama |
|
| 209 |
+
|--------|----------|----------|
|
| 210 |
+
| GET | `/api/ticker/{ticker}/options-flow` | Tek hisse opsiyon zinciri |
|
| 211 |
+
| GET | `/api/options-flow/leaps-board` | 22 ticker LEAPS board |
|
| 212 |
+
| GET | `/api/leaps/scanner` | S&P 100 LEAPS anomali tarama |
|
| 213 |
+
|
| 214 |
+
---
|
| 215 |
+
|
| 216 |
+
## ⚠️ Bilinen Sorunlar
|
| 217 |
+
|
| 218 |
+
1. **vectorbt MACD:** `histogram` attribute'ı yok, `hist` kullanılıyor
|
| 219 |
+
2. **vectorbt EMA:** Yok, pandas `ewm()` ile manuel hesaplanıyor
|
| 220 |
+
3. **vectorbt BBANDS:** `.run()` bug'lı, pandas `rolling()` ile manuel
|
| 221 |
+
4. **Plotly TS tipleri:** Versiyon mismatch var ama Turbopack ignore ediyor
|
| 222 |
+
5. **yfinance rate limit:** IP bazlı, aşırı sorgu yapılmamalı
|
| 223 |
+
|
| 224 |
+
---
|
| 225 |
+
|
| 226 |
+
## 📄 Lisans
|
| 227 |
+
|
| 228 |
+
MIT License — Detaylar için [LICENSE](LICENSE) dosyasına bakın.
|
| 229 |
+
|
| 230 |
+
---
|
| 231 |
+
|
| 232 |
+
<div align="center">
|
| 233 |
+
|
| 234 |
+
**⭐ Beğendiyseniz yıldız vermeyi unutmayın!**
|
| 235 |
+
|
| 236 |
+
</div>
|
_archive/DEPLOY.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Terminal.AI — Production Deployment
|
| 2 |
+
|
| 3 |
+
VectorBT tabanlı finansal backtest ve LEAPS opsiyon tarama platformu.
|
| 4 |
+
|
| 5 |
+
## Gereksinimler
|
| 6 |
+
|
| 7 |
+
- Docker >= 24.0
|
| 8 |
+
- Docker Compose >= 2.20
|
| 9 |
+
- Domain adı (SSL için)
|
| 10 |
+
- Sunucu: en az 1GB RAM, 1 vCPU
|
| 11 |
+
|
| 12 |
+
## Hızlı Kurulum
|
| 13 |
+
|
| 14 |
+
```bash
|
| 15 |
+
# 1. Projeyi klonla
|
| 16 |
+
git clone https://github.com/username/terminal-ai.git
|
| 17 |
+
cd HERMES(deneme)
|
| 18 |
+
|
| 19 |
+
# 2. Environment dosyasını oluştur
|
| 20 |
+
cp .env.example .env
|
| 21 |
+
# .env dosyasını domain'ine göre düzenle
|
| 22 |
+
|
| 23 |
+
# 3. Deploy et
|
| 24 |
+
chmod +x deploy.sh
|
| 25 |
+
./deploy.sh
|
| 26 |
+
```
|
| 27 |
+
|
| 28 |
+
## SSL Kurulumu (Opsiyonel ama önerilir)
|
| 29 |
+
|
| 30 |
+
```bash
|
| 31 |
+
# Domain'i DNS'de sunucu IP'sine yönlendirdikten sonra:
|
| 32 |
+
chmod +x setup-ssl.sh
|
| 33 |
+
./setup-ssl.sh yourdomain.com
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
## Komutlar
|
| 37 |
+
|
| 38 |
+
```bash
|
| 39 |
+
# Çalıştır
|
| 40 |
+
docker compose up -d
|
| 41 |
+
|
| 42 |
+
# Durdur
|
| 43 |
+
docker compose down
|
| 44 |
+
|
| 45 |
+
# Logları izle
|
| 46 |
+
docker compose logs -f
|
| 47 |
+
|
| 48 |
+
# Sadece backend/backend logları
|
| 49 |
+
docker compose logs -f backend
|
| 50 |
+
docker compose logs -f frontend
|
| 51 |
+
|
| 52 |
+
# Yeniden build & deploy
|
| 53 |
+
docker compose up -d --build
|
| 54 |
+
|
| 55 |
+
# Container durumu
|
| 56 |
+
docker compose ps
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
## Yapı
|
| 60 |
+
|
| 61 |
+
```
|
| 62 |
+
├── backend/ # FastAPI + vectorbt + yfinance
|
| 63 |
+
├── frontend/ # Next.js + React + Plotly
|
| 64 |
+
├── nginx/ # Reverse proxy + SSL
|
| 65 |
+
├── docker-compose.yml
|
| 66 |
+
├── deploy.sh
|
| 67 |
+
└── setup-ssl.sh
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
## Portlar
|
| 71 |
+
|
| 72 |
+
- 80 → Nginx (HTTP)
|
| 73 |
+
- 443 → Nginx (HTTPS, SSL aktifken)
|
| 74 |
+
- 3000 → Next.js (sadece internal)
|
| 75 |
+
- 8000 → FastAPI (sadece internal)
|
| 76 |
+
|
| 77 |
+
## Notlar
|
| 78 |
+
|
| 79 |
+
- Backend cache: 10 dakika (in-memory, container restart'ında temizlenir)
|
| 80 |
+
- LEAPS Scanner: S&P 100 taraması ~10 saniye
|
| 81 |
+
- yfinance rate limit: ~2000 saatlik istek (IP bazlı)
|
| 82 |
+
- SSL olmadan da çalışır (HTTP)
|
_archive/Dockerfile.backend
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Install dependencies
|
| 6 |
+
COPY requirements.txt .
|
| 7 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 8 |
+
|
| 9 |
+
# Copy application code
|
| 10 |
+
COPY . .
|
| 11 |
+
|
| 12 |
+
# Expose port
|
| 13 |
+
EXPOSE 8000
|
| 14 |
+
|
| 15 |
+
# Run with uvicorn
|
| 16 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
|
_archive/Dockerfile.frontend
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Multi-stage build for Next.js frontend
|
| 2 |
+
FROM node:20-alpine AS builder
|
| 3 |
+
|
| 4 |
+
WORKDIR /app
|
| 5 |
+
|
| 6 |
+
# Copy package files
|
| 7 |
+
COPY package.json package-lock.json* ./
|
| 8 |
+
|
| 9 |
+
# Install dependencies
|
| 10 |
+
RUN npm ci
|
| 11 |
+
|
| 12 |
+
# Copy source code
|
| 13 |
+
COPY . .
|
| 14 |
+
|
| 15 |
+
# Build the application
|
| 16 |
+
RUN npm run build
|
| 17 |
+
|
| 18 |
+
# Production stage
|
| 19 |
+
FROM node:20-alpine AS runner
|
| 20 |
+
|
| 21 |
+
WORKDIR /app
|
| 22 |
+
|
| 23 |
+
# Copy built assets from builder
|
| 24 |
+
COPY --from=builder /app/.next ./.next
|
| 25 |
+
COPY --from=builder /app/public ./public
|
| 26 |
+
COPY --from=builder /app/package.json ./package.json
|
| 27 |
+
COPY --from=builder /app/node_modules ./node_modules
|
| 28 |
+
|
| 29 |
+
# Expose port
|
| 30 |
+
EXPOSE 3000
|
| 31 |
+
|
| 32 |
+
# Start the application
|
| 33 |
+
CMD ["npm", "start"]
|
_archive/deploy.sh
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# Terminal.AI Deploy Script
|
| 3 |
+
# Kullanım: ./deploy.sh
|
| 4 |
+
|
| 5 |
+
set -e
|
| 6 |
+
|
| 7 |
+
echo "========================================="
|
| 8 |
+
echo " Terminal.AI Deployment"
|
| 9 |
+
echo "========================================="
|
| 10 |
+
|
| 11 |
+
# Renkler
|
| 12 |
+
RED='\033[0;31m'
|
| 13 |
+
GREEN='\033[0;32m'
|
| 14 |
+
YELLOW='\033[1;33m'
|
| 15 |
+
NC='\033[0m'
|
| 16 |
+
|
| 17 |
+
# Dizin kontrol
|
| 18 |
+
if [ ! -f "docker-compose.yml" ]; then
|
| 19 |
+
echo -e "${RED}Hata: docker-compose.yml bulunamadı!${NC}"
|
| 20 |
+
echo "Lütfen proje kök dizininde çalıştırın."
|
| 21 |
+
exit 1
|
| 22 |
+
fi
|
| 23 |
+
|
| 24 |
+
# SSL dizinleri oluştur
|
| 25 |
+
mkdir -p nginx/ssl
|
| 26 |
+
|
| 27 |
+
# Güncel kodu çek (git kullanıyorsan)
|
| 28 |
+
if [ -d ".git" ]; then
|
| 29 |
+
echo -e "${YELLOW}Güncel kod çekiliyor...${NC}"
|
| 30 |
+
git pull origin main 2>/dev/null || true
|
| 31 |
+
fi
|
| 32 |
+
|
| 33 |
+
# Docker Compose ile build & deploy
|
| 34 |
+
echo -e "${YELLOW}Container'lar build ediliyor...${NC}"
|
| 35 |
+
docker compose build --no-cache
|
| 36 |
+
|
| 37 |
+
echo -e "${YELLOW}Container'lar başlatılıyor...${NC}"
|
| 38 |
+
docker compose up -d
|
| 39 |
+
|
| 40 |
+
# Health check
|
| 41 |
+
echo -e "${YELLOW}Health check...${NC}"
|
| 42 |
+
sleep 5
|
| 43 |
+
|
| 44 |
+
if curl -sf http://localhost/api/health > /dev/null 2>&1; then
|
| 45 |
+
echo -e "${GREEN}Backend: OK${NC}"
|
| 46 |
+
else
|
| 47 |
+
echo -e "${RED}Backend: FAIL${NC}"
|
| 48 |
+
fi
|
| 49 |
+
|
| 50 |
+
if curl -sf http://localhost > /dev/null 2>&1; then
|
| 51 |
+
echo -e "${GREEN}Frontend: OK${NC}"
|
| 52 |
+
else
|
| 53 |
+
echo -e "${RED}Frontend: FAIL${NC}"
|
| 54 |
+
fi
|
| 55 |
+
|
| 56 |
+
echo ""
|
| 57 |
+
echo -e "${GREEN}=========================================${NC}"
|
| 58 |
+
echo -e "${GREEN} Deployment tamamlandı!${NC}"
|
| 59 |
+
echo -e "${GREEN}=========================================${NC}"
|
| 60 |
+
echo ""
|
| 61 |
+
echo "Logları izle: docker compose logs -f"
|
| 62 |
+
echo "Durdur: docker compose down"
|
| 63 |
+
echo "Yeniden başlat: docker compose restart"
|
_archive/docker-compose.yml
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: "3.8"
|
| 2 |
+
|
| 3 |
+
services:
|
| 4 |
+
backend:
|
| 5 |
+
build:
|
| 6 |
+
context: ./backend
|
| 7 |
+
dockerfile: Dockerfile
|
| 8 |
+
container_name: terminal-ai-backend
|
| 9 |
+
restart: unless-stopped
|
| 10 |
+
expose:
|
| 11 |
+
- "8000"
|
| 12 |
+
environment:
|
| 13 |
+
- PYTHONUNBUFFERED=1
|
| 14 |
+
volumes:
|
| 15 |
+
- backend-data:/app/data
|
| 16 |
+
networks:
|
| 17 |
+
- terminal-ai
|
| 18 |
+
healthcheck:
|
| 19 |
+
test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"]
|
| 20 |
+
interval: 30s
|
| 21 |
+
timeout: 10s
|
| 22 |
+
retries: 3
|
| 23 |
+
|
| 24 |
+
frontend:
|
| 25 |
+
build:
|
| 26 |
+
context: ./frontend
|
| 27 |
+
dockerfile: Dockerfile
|
| 28 |
+
container_name: terminal-ai-frontend
|
| 29 |
+
restart: unless-stopped
|
| 30 |
+
expose:
|
| 31 |
+
- "3000"
|
| 32 |
+
environment:
|
| 33 |
+
- NEXT_PUBLIC_API_URL=/api
|
| 34 |
+
depends_on:
|
| 35 |
+
backend:
|
| 36 |
+
condition: service_healthy
|
| 37 |
+
networks:
|
| 38 |
+
- terminal-ai
|
| 39 |
+
|
| 40 |
+
nginx:
|
| 41 |
+
image: nginx:alpine
|
| 42 |
+
container_name: terminal-ai-nginx
|
| 43 |
+
restart: unless-stopped
|
| 44 |
+
ports:
|
| 45 |
+
- "80:80"
|
| 46 |
+
- "443:443"
|
| 47 |
+
volumes:
|
| 48 |
+
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
| 49 |
+
- ./nginx/ssl:/etc/nginx/ssl:ro
|
| 50 |
+
- certbot-webroot:/var/www/certbot:ro
|
| 51 |
+
- certbot-certs:/etc/letsencrypt:ro
|
| 52 |
+
depends_on:
|
| 53 |
+
- frontend
|
| 54 |
+
- backend
|
| 55 |
+
networks:
|
| 56 |
+
- terminal-ai
|
| 57 |
+
|
| 58 |
+
# Opsiyonel: SSL sertifikası için Certbot
|
| 59 |
+
# İlk kurulumda comment out edildi, sonra aktif edilecek
|
| 60 |
+
# certbot:
|
| 61 |
+
# image: certbot/certbot
|
| 62 |
+
# container_name: terminal-ai-certbot
|
| 63 |
+
# volumes:
|
| 64 |
+
# - certbot-webroot:/var/www/certbot
|
| 65 |
+
# - certbot-certs:/etc/letsencrypt
|
| 66 |
+
# entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
|
| 67 |
+
|
| 68 |
+
volumes:
|
| 69 |
+
backend-data:
|
| 70 |
+
certbot-webroot:
|
| 71 |
+
certbot-certs:
|
| 72 |
+
|
| 73 |
+
networks:
|
| 74 |
+
terminal-ai:
|
| 75 |
+
driver: bridge
|
_archive/nginx/nginx.conf
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
events {
|
| 2 |
+
worker_connections 1024;
|
| 3 |
+
}
|
| 4 |
+
|
| 5 |
+
http {
|
| 6 |
+
include /etc/nginx/mime.types;
|
| 7 |
+
default_type application/octet-stream;
|
| 8 |
+
|
| 9 |
+
# Logging
|
| 10 |
+
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
| 11 |
+
'$status $body_bytes_sent "$http_referer" '
|
| 12 |
+
'"$http_user_agent" "$http_x_forwarded_for"';
|
| 13 |
+
access_log /var/log/nginx/access.log main;
|
| 14 |
+
error_log /var/log/nginx/error.log;
|
| 15 |
+
|
| 16 |
+
# Performance
|
| 17 |
+
sendfile on;
|
| 18 |
+
tcp_nopush on;
|
| 19 |
+
tcp_nodelay on;
|
| 20 |
+
keepalive_timeout 65;
|
| 21 |
+
types_hash_max_size 2048;
|
| 22 |
+
|
| 23 |
+
# Gzip compression
|
| 24 |
+
gzip on;
|
| 25 |
+
gzip_vary on;
|
| 26 |
+
gzip_proxied any;
|
| 27 |
+
gzip_comp_level 6;
|
| 28 |
+
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
| 29 |
+
|
| 30 |
+
# Rate limiting
|
| 31 |
+
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
|
| 32 |
+
limit_req_zone $binary_remote_addr zone=general:10m rate=30r/s;
|
| 33 |
+
|
| 34 |
+
# Upstream servers
|
| 35 |
+
upstream frontend {
|
| 36 |
+
server frontend:3000;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
upstream backend {
|
| 40 |
+
server backend:8000;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
# HTTP → HTTPS redirect (SSL aktifken)
|
| 44 |
+
# server {
|
| 45 |
+
# listen 80;
|
| 46 |
+
# server_name yourdomain.com www.yourdomain.com;
|
| 47 |
+
# location /.well-known/acme-challenge/ {
|
| 48 |
+
# root /var/www/certbot;
|
| 49 |
+
# }
|
| 50 |
+
# location / {
|
| 51 |
+
# return 301 https://$server_name$request_uri;
|
| 52 |
+
# }
|
| 53 |
+
# }
|
| 54 |
+
|
| 55 |
+
# HTTPS server (SSL aktifken)
|
| 56 |
+
# server {
|
| 57 |
+
# listen 443 ssl http2;
|
| 58 |
+
# server_name yourdomain.com www.yourdomain.com;
|
| 59 |
+
#
|
| 60 |
+
# ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
|
| 61 |
+
# ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
|
| 62 |
+
# ssl_protocols TLSv1.2 TLSv1.3;
|
| 63 |
+
# ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
|
| 64 |
+
# ssl_prefer_server_ciphers off;
|
| 65 |
+
#
|
| 66 |
+
# # Security headers
|
| 67 |
+
# add_header X-Frame-Options "SAMEORIGIN" always;
|
| 68 |
+
# add_header X-Content-Type-Options "nosniff" always;
|
| 69 |
+
# add_header X-XSS-Protection "1; mode=block" always;
|
| 70 |
+
# add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
| 71 |
+
#
|
| 72 |
+
# # Frontend
|
| 73 |
+
# location / {
|
| 74 |
+
# limit_req zone=general burst=50 nodelay;
|
| 75 |
+
# proxy_pass http://frontend;
|
| 76 |
+
# proxy_http_version 1.1;
|
| 77 |
+
# proxy_set_header Upgrade $http_upgrade;
|
| 78 |
+
# proxy_set_header Connection "upgrade";
|
| 79 |
+
# proxy_set_header Host $host;
|
| 80 |
+
# proxy_set_header X-Real-IP $remote_addr;
|
| 81 |
+
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
| 82 |
+
# proxy_set_header X-Forwarded-Proto $scheme;
|
| 83 |
+
# }
|
| 84 |
+
#
|
| 85 |
+
# # Backend API
|
| 86 |
+
# location /api/ {
|
| 87 |
+
# limit_req zone=api burst=20 nodelay;
|
| 88 |
+
# proxy_pass http://backend;
|
| 89 |
+
# proxy_http_version 1.1;
|
| 90 |
+
# proxy_set_header Host $host;
|
| 91 |
+
# proxy_set_header X-Real-IP $remote_addr;
|
| 92 |
+
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
| 93 |
+
# proxy_set_header X-Forwarded-Proto $scheme;
|
| 94 |
+
# proxy_read_timeout 120s;
|
| 95 |
+
# }
|
| 96 |
+
# }
|
| 97 |
+
|
| 98 |
+
# HTTP server (SSL'siz, ilk kurulum için)
|
| 99 |
+
server {
|
| 100 |
+
listen 80;
|
| 101 |
+
server_name _;
|
| 102 |
+
|
| 103 |
+
# Security headers
|
| 104 |
+
add_header X-Frame-Options "SAMEORIGIN" always;
|
| 105 |
+
add_header X-Content-Type-Options "nosniff" always;
|
| 106 |
+
add_header X-XSS-Protection "1; mode=block" always;
|
| 107 |
+
|
| 108 |
+
# Frontend
|
| 109 |
+
location / {
|
| 110 |
+
limit_req zone=general burst=50 nodelay;
|
| 111 |
+
proxy_pass http://frontend;
|
| 112 |
+
proxy_http_version 1.1;
|
| 113 |
+
proxy_set_header Upgrade $http_upgrade;
|
| 114 |
+
proxy_set_header Connection "upgrade";
|
| 115 |
+
proxy_set_header Host $host;
|
| 116 |
+
proxy_set_header X-Real-IP $remote_addr;
|
| 117 |
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
| 118 |
+
proxy_set_header X-Forwarded-Proto $scheme;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
# Backend API
|
| 122 |
+
location /api/ {
|
| 123 |
+
limit_req zone=api burst=20 nodelay;
|
| 124 |
+
proxy_pass http://backend;
|
| 125 |
+
proxy_http_version 1.1;
|
| 126 |
+
proxy_set_header Host $host;
|
| 127 |
+
proxy_set_header X-Real-IP $remote_addr;
|
| 128 |
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
| 129 |
+
proxy_set_header X-Forwarded-Proto $scheme;
|
| 130 |
+
proxy_read_timeout 120s;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
# Health check
|
| 134 |
+
location /health {
|
| 135 |
+
access_log off;
|
| 136 |
+
return 200 "OK\n";
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
}
|
_archive/setup-ssl.sh
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# SSL Sertifika Kurulumu (Let's Encrypt)
|
| 3 |
+
# Kullanım: ./setup-ssl.sh yourdomain.com
|
| 4 |
+
|
| 5 |
+
set -e
|
| 6 |
+
|
| 7 |
+
DOMAIN=$1
|
| 8 |
+
|
| 9 |
+
if [ -z "$DOMAIN" ]; then
|
| 10 |
+
echo "Kullanım: ./setup-ssh.sh yourdomain.com"
|
| 11 |
+
exit 1
|
| 12 |
+
fi
|
| 13 |
+
|
| 14 |
+
echo "SSL kurulumu başlıyor: $DOMAIN"
|
| 15 |
+
|
| 16 |
+
# Nginx config'i güncelle
|
| 17 |
+
sed -i "s/yourdomain.com/$DOMAIN/g" nginx/nginx.conf
|
| 18 |
+
|
| 19 |
+
# HTTP server'ı comment out, HTTPS server'ı aktif et
|
| 20 |
+
sed -i 's/^ # server {/ server {/' nginx/nginx.conf
|
| 21 |
+
sed -i 's/^ # listen 443/ listen 443/' nginx/nginx.conf
|
| 22 |
+
sed -i 's/^ # ssl_certificate/ ssl_certificate/' nginx/nginx.conf
|
| 23 |
+
# ... (tüm comment'leri kaldır)
|
| 24 |
+
|
| 25 |
+
# Certbot ile sertifika al
|
| 26 |
+
docker run -it --rm \
|
| 27 |
+
-v $(pwd)/certbot-webroot:/var/www/certbot \
|
| 28 |
+
-v $(pwd)/certbot-certs:/etc/letsencrypt \
|
| 29 |
+
certbot/certbot certonly \
|
| 30 |
+
--webroot \
|
| 31 |
+
--webroot-path=/var/www/certbot \
|
| 32 |
+
--email admin@$DOMAIN \
|
| 33 |
+
--agree-tos \
|
| 34 |
+
--no-eff-email \
|
| 35 |
+
-d $DOMAIN \
|
| 36 |
+
-d www.$DOMAIN
|
| 37 |
+
|
| 38 |
+
# Nginx'i yeniden başlat
|
| 39 |
+
docker compose restart nginx
|
| 40 |
+
|
| 41 |
+
echo "SSL kurulumu tamamlandı!"
|
| 42 |
+
echo "https://$DOMAIN adresinden erişebilirsiniz."
|
_archive/test_options.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Test if yfinance has options support and can detect unusual options activity."""
|
| 2 |
+
import yfinance as yf
|
| 3 |
+
import pandas as pd
|
| 4 |
+
|
| 5 |
+
print("=== yfinance Options Support Test ===\n")
|
| 6 |
+
|
| 7 |
+
# Test AAPL options
|
| 8 |
+
ticker = yf.Ticker("AAPL")
|
| 9 |
+
try:
|
| 10 |
+
opts = ticker.options
|
| 11 |
+
print(f"AAPL Option expiry dates available: {len(opts)}")
|
| 12 |
+
if opts:
|
| 13 |
+
print(f" Nearest expiry: {opts[0]}, Farthest: {opts[-1]}")
|
| 14 |
+
|
| 15 |
+
chain = ticker.option_chain(opts[0])
|
| 16 |
+
print(f"\n Calls columns: {list(chain.calls.columns)}")
|
| 17 |
+
print(f" Puts columns: {list(chain.puts.columns)}")
|
| 18 |
+
|
| 19 |
+
print("\n --- Top 5 Calls by volume ---")
|
| 20 |
+
top_calls = chain.calls.nlargest(5, 'volume')[['strike', 'lastPrice', 'volume', 'openInterest', 'impliedVolatility', 'contractSymbol']]
|
| 21 |
+
print(top_calls.to_string(index=False))
|
| 22 |
+
|
| 23 |
+
print("\n --- Top 5 Puts by volume ---")
|
| 24 |
+
top_puts = chain.puts.nlargest(5, 'volume')[['strike', 'lastPrice', 'volume', 'openInterest', 'impliedVolatility', 'contractSymbol']]
|
| 25 |
+
print(top_puts.to_string(index=False))
|
| 26 |
+
except Exception as e:
|
| 27 |
+
print(f"AAPL options error: {e}")
|
| 28 |
+
|
| 29 |
+
# Test multiple tickers for LEAPS (far OTM calls can be LEAPS proxy)
|
| 30 |
+
print("\n\n=== Multi-ticker Options Test (LEAPS focus) ===\n")
|
| 31 |
+
for sym in ['AAPL', 'TSLA', 'NVDA', 'MSFT', 'SPY', 'AMZN']:
|
| 32 |
+
try:
|
| 33 |
+
t = yf.Ticker(sym)
|
| 34 |
+
o = t.options
|
| 35 |
+
if o:
|
| 36 |
+
# Find far-dated expiry (more than 6 months out = potential LEAPS)
|
| 37 |
+
from datetime import datetime
|
| 38 |
+
today = datetime.today()
|
| 39 |
+
far_expiries = [
|
| 40 |
+
exp for exp in o
|
| 41 |
+
if (datetime.strptime(exp, '%Y-%m-%d') - today).days > 180
|
| 42 |
+
]
|
| 43 |
+
print(f"{sym}: {len(o)} expiries, {len(far_expiries)} LEAPS-range (>6mo)")
|
| 44 |
+
|
| 45 |
+
# If far expiries exist, check call volume
|
| 46 |
+
if far_expiries:
|
| 47 |
+
far_chain = t.option_chain(far_expiries[0])
|
| 48 |
+
total_call_vol = far_chain.calls['volume'].sum()
|
| 49 |
+
total_put_vol = far_chain.puts['volume'].sum()
|
| 50 |
+
print(f" Far expiry {far_expiries[0]}: call_vol={total_call_vol}, put_vol={total_put_vol}")
|
| 51 |
+
else:
|
| 52 |
+
print(f"{sym}: No options data")
|
| 53 |
+
except Exception as e:
|
| 54 |
+
print(f"{sym}: Error - {e}")
|
_archive/test_options2.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Explore what 'abnormal options activity' means + yfinance options schema."""
|
| 2 |
+
import yfinance as yf
|
| 3 |
+
import pandas as pd
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
|
| 6 |
+
print("=== Contract Symbol Format ===\n")
|
| 7 |
+
ticker = yf.Ticker("AAPL")
|
| 8 |
+
opts = ticker.options
|
| 9 |
+
today = datetime.today()
|
| 10 |
+
|
| 11 |
+
# Analyze all expiries to find LEAPS pattern and high-volume activities
|
| 12 |
+
print(f"All expiries for AAPL ({len(opts)} total):")
|
| 13 |
+
for i, exp in enumerate(opts):
|
| 14 |
+
try:
|
| 15 |
+
dt = datetime.strptime(exp, "%Y-%m-%d")
|
| 16 |
+
days_out = (dt - today).days
|
| 17 |
+
is_leaps = days_out > 365
|
| 18 |
+
flag = " <<< LEAPS" if is_leaps else ""
|
| 19 |
+
print(f" {exp} ({days_out}d from now){flag}")
|
| 20 |
+
except:
|
| 21 |
+
print(f" {exp} (parse error)")
|
| 22 |
+
|
| 23 |
+
# Fetch the farthest expiry for LEAPS analysis
|
| 24 |
+
if opts:
|
| 25 |
+
far_exp = opts[-1]
|
| 26 |
+
print(f"\n=== Far expiry chain: {far_exp} ===")
|
| 27 |
+
chain = ticker.option_chain(far_exp)
|
| 28 |
+
|
| 29 |
+
calls = chain.calls.copy()
|
| 30 |
+
puts = chain.puts.copy()
|
| 31 |
+
|
| 32 |
+
print(f"\nCalls: {len(calls)} rows")
|
| 33 |
+
print(f"Puts: {len(puts)} rows")
|
| 34 |
+
|
| 35 |
+
# Unusual call activity: high volume vs OI call ratio > 2
|
| 36 |
+
print("\n--- Top 10 calls by volume/oi ratio (> 2 = unusual) ---")
|
| 37 |
+
calls_active = calls[calls['volume'] > 0].copy()
|
| 38 |
+
calls_active['vol_oi_ratio'] = calls_active['volume'] / (calls_active['openInterest'] + 1)
|
| 39 |
+
unusual_calls = calls_active[calls_active['vol_oi_ratio'] > 2].nlargest(10, 'volume')
|
| 40 |
+
print(unusual_calls[['strike', 'volume', 'openInterest', 'vol_oi_ratio', 'impliedVolatility', 'contractSymbol']].to_string(index=False))
|
| 41 |
+
|
| 42 |
+
# Unusual put activity: high volume vs OI put ratio > 2
|
| 43 |
+
print("\n--- Top 10 puts by volume/oi ratio (> 2 = unusual) ---")
|
| 44 |
+
puts_active = puts[puts['volume'] > 0].copy()
|
| 45 |
+
puts_active['vol_oi_ratio'] = puts_active['volume'] / (puts_active['openInterest'] + 1)
|
| 46 |
+
unusual_puts = puts_active[puts_active['vol_oi_ratio'] > 2].nlargest(10, 'volume')
|
| 47 |
+
print(unusual_puts[['strike', 'volume', 'openInterest', 'vol_oi_ratio', 'impliedVolatility', 'contractSymbol']].to_string(index=False))
|
| 48 |
+
|
| 49 |
+
# Overall option flow signal
|
| 50 |
+
total_call_vol = calls['volume'].sum()
|
| 51 |
+
total_put_vol = puts['volume'].sum()
|
| 52 |
+
cpr = total_call_vol / (total_put_vol + 1)
|
| 53 |
+
print(f"\nCall/Put Volume Ratio: {cpr:.2f} (higher = bullish)")
|
| 54 |
+
print(f"Total Call Volume: {total_call_vol:,.0f}")
|
| 55 |
+
print(f"Total Put Volume: {total_put_vol:,.0f}")
|
| 56 |
+
|
| 57 |
+
# Highest IV strikes (potential LEAPS)
|
| 58 |
+
high_iv = calls.nlargest(5, 'impliedVolatility')[['strike', 'volume', 'openInterest', 'impliedVolatility', 'contractSymbol']]
|
| 59 |
+
print(f"\n--- 5 highest IV calls ---")
|
| 60 |
+
print(high_iv.to_string(index=False))
|
backend/.dockerignore
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
venv/
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
.env
|
| 5 |
+
.git/
|
| 6 |
+
*.log
|
| 7 |
+
test_*.py
|
| 8 |
+
tmp/
|
backend/.gitignore
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Backend .gitignore
|
| 2 |
+
venv/
|
| 3 |
+
.venv/
|
| 4 |
+
__pycache__/
|
| 5 |
+
*.pyc
|
| 6 |
+
*.pyo
|
| 7 |
+
.pytest_cache/
|
| 8 |
+
.mypy_cache/
|
| 9 |
+
*.egg-info/
|
| 10 |
+
dist/
|
| 11 |
+
build/
|
| 12 |
+
.env
|
| 13 |
+
.env.local
|
| 14 |
+
*.log
|
| 15 |
+
test_*.py
|
| 16 |
+
tmp/
|
backend/data_fetcher.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Data fetching module using yfinance.
|
| 3 |
+
Provides OHLCV data for given ticker and date range.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import yfinance as yf
|
| 7 |
+
import pandas as pd
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def fetch_ohlcv(
|
| 12 |
+
ticker: str,
|
| 13 |
+
start: str,
|
| 14 |
+
end: str,
|
| 15 |
+
interval: str = "1d"
|
| 16 |
+
) -> pd.DataFrame:
|
| 17 |
+
"""
|
| 18 |
+
Fetch OHLCV data from Yahoo Finance.
|
| 19 |
+
|
| 20 |
+
Args:
|
| 21 |
+
ticker: Stock/crypto ticker symbol (e.g., "AAPL", "BTC-USD")
|
| 22 |
+
start: Start date in YYYY-MM-DD format
|
| 23 |
+
end: End date in YYYY-MM-DD format
|
| 24 |
+
interval: Data interval - "1m", "5m", "15m", "1h", "1d", "1wk", "1mo"
|
| 25 |
+
|
| 26 |
+
Returns:
|
| 27 |
+
pd.DataFrame with columns: Open, High, Low, Close, Volume
|
| 28 |
+
"""
|
| 29 |
+
try:
|
| 30 |
+
asset = yf.Ticker(ticker)
|
| 31 |
+
df = asset.history(start=start, end=end, interval=interval)
|
| 32 |
+
|
| 33 |
+
if df.empty:
|
| 34 |
+
raise ValueError(f"No data found for {ticker} in range {start} to {end}")
|
| 35 |
+
|
| 36 |
+
# Standardize column names
|
| 37 |
+
df.index.name = "Date"
|
| 38 |
+
df = df[["Open", "High", "Low", "Close", "Volume"]]
|
| 39 |
+
|
| 40 |
+
# Remove timezone info from index for JSON compat
|
| 41 |
+
if df.index.tz is not None:
|
| 42 |
+
df.index = df.index.tz_localize(None)
|
| 43 |
+
|
| 44 |
+
return df
|
| 45 |
+
|
| 46 |
+
except Exception as e:
|
| 47 |
+
raise RuntimeError(f"Failed to fetch data for {ticker}: {str(e)}")
|
backend/encoder.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
NanEncoder - Custom JSON serializer for vectorbt output.
|
| 3 |
+
Handles NaN, Inf, datetime, numpy types, and pandas objects.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
import math
|
| 8 |
+
import numpy as np
|
| 9 |
+
import pandas as pd
|
| 10 |
+
from datetime import datetime, date
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class NanEncoder(json.JSONEncoder):
|
| 14 |
+
"""Custom JSON encoder that handles vectorbt/pandas/numpy edge cases."""
|
| 15 |
+
|
| 16 |
+
def default(self, obj):
|
| 17 |
+
# numpy types
|
| 18 |
+
if isinstance(obj, (np.integer,)):
|
| 19 |
+
return int(obj)
|
| 20 |
+
if isinstance(obj, (np.floating,)):
|
| 21 |
+
if np.isnan(obj) or np.isinf(obj):
|
| 22 |
+
return None
|
| 23 |
+
return float(obj)
|
| 24 |
+
if isinstance(obj, np.ndarray):
|
| 25 |
+
return obj.tolist()
|
| 26 |
+
if isinstance(obj, (np.bool_,)):
|
| 27 |
+
return bool(obj)
|
| 28 |
+
|
| 29 |
+
# pandas types
|
| 30 |
+
if isinstance(obj, pd.Timestamp):
|
| 31 |
+
return obj.isoformat()
|
| 32 |
+
if isinstance(obj, pd.Series):
|
| 33 |
+
return obj.to_dict()
|
| 34 |
+
if isinstance(obj, pd.DataFrame):
|
| 35 |
+
return obj.to_dict(orient="records")
|
| 36 |
+
if isinstance(obj, pd.Timedelta):
|
| 37 |
+
return str(obj)
|
| 38 |
+
|
| 39 |
+
# datetime
|
| 40 |
+
if isinstance(obj, (datetime, date)):
|
| 41 |
+
return obj.isoformat()
|
| 42 |
+
|
| 43 |
+
# timedelta
|
| 44 |
+
from datetime import timedelta
|
| 45 |
+
if isinstance(obj, timedelta):
|
| 46 |
+
return str(obj)
|
| 47 |
+
|
| 48 |
+
return super().default(obj)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def safe_json_response(data: dict) -> dict:
|
| 52 |
+
"""
|
| 53 |
+
Convert a dict with numpy/pandas values to JSON-safe dict.
|
| 54 |
+
Replaces NaN/Inf with None, converts timestamps to ISO strings.
|
| 55 |
+
"""
|
| 56 |
+
def _clean(value):
|
| 57 |
+
if isinstance(value, dict):
|
| 58 |
+
return {k: _clean(v) for k, v in value.items()}
|
| 59 |
+
if isinstance(value, (list, tuple)):
|
| 60 |
+
return [_clean(v) for v in value]
|
| 61 |
+
if isinstance(value, (np.floating, float)):
|
| 62 |
+
if math.isnan(value) or math.isinf(value):
|
| 63 |
+
return None
|
| 64 |
+
return float(value)
|
| 65 |
+
if isinstance(value, (np.integer,)):
|
| 66 |
+
return int(value)
|
| 67 |
+
if isinstance(value, (np.bool_,)):
|
| 68 |
+
return bool(value)
|
| 69 |
+
if isinstance(value, np.ndarray):
|
| 70 |
+
return _clean(value.tolist())
|
| 71 |
+
if isinstance(value, pd.Timestamp):
|
| 72 |
+
return value.isoformat()
|
| 73 |
+
if isinstance(value, (datetime, date)):
|
| 74 |
+
return value.isoformat()
|
| 75 |
+
if isinstance(value, pd.Timedelta):
|
| 76 |
+
return str(value)
|
| 77 |
+
if isinstance(value, pd.Series):
|
| 78 |
+
return _clean(value.to_dict())
|
| 79 |
+
if isinstance(value, pd.DataFrame):
|
| 80 |
+
return _clean(value.to_dict(orient="records"))
|
| 81 |
+
if pd.isna(value):
|
| 82 |
+
return None
|
| 83 |
+
return value
|
| 84 |
+
|
| 85 |
+
return _clean(data)
|
backend/leaps_scanner.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LEAPS Scanner — S&P 100 tabanlı concurrent LEAPS anomali tarama.
|
| 3 |
+
Yakın zamanda yüksek miktarda LEAPS opsiyonu alınan hisseleri tespit eder.
|
| 4 |
+
|
| 5 |
+
Kaynak: yfinance (ücretsiz, ~15-20 dakika gecikmeli veri)
|
| 6 |
+
Strateji: S&P 100 ticker listesini concurrent tarayarak volume/OI > 2x LEAPS spike yakalar.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import yfinance as yf
|
| 10 |
+
import pandas as pd
|
| 11 |
+
import numpy as np
|
| 12 |
+
from datetime import datetime, timedelta
|
| 13 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 14 |
+
from typing import Optional
|
| 15 |
+
|
| 16 |
+
# ── Constants ──────────────────────────────────────────────────────────
|
| 17 |
+
|
| 18 |
+
LEAPS_MIN_DAYS = 365 # Vade > 1 yıl = LEAPS
|
| 19 |
+
UNUSUAL_VOL_OI_MIN = 2.0 # volume/openInterest >= 2x = anormal
|
| 20 |
+
MAX_WORKERS = 10 # Concurrent thread sayısı
|
| 21 |
+
TIMEOUT_PER_TICKER = 15 # Ticker başına saniye timeout
|
| 22 |
+
|
| 23 |
+
# S&P 100 ticker listesi (OEX — 100 büyük hacimli hisse)
|
| 24 |
+
SP100_TICKERS = [
|
| 25 |
+
"AAPL", "MSFT", "GOOGL", "GOOG", "AMZN", "NVDA", "TSLA", "META",
|
| 26 |
+
"BRK-B", "JPM", "V", "JNJ", "WMT", "PG", "MA", "UNH", "HD", "DIS",
|
| 27 |
+
"PYPL", "BAC", "XOM", "INTC", "VZ", "ADBE", "NFLX", "CRM", "CSCO",
|
| 28 |
+
"PFE", "ABT", "ACN", "TMO", "AVGO", "DHR", "NKE", "TXN", "QCOM",
|
| 29 |
+
"COST", "NEE", "LIN", "PM", "BMY", "HON", "UNP", "LOW", "IBM",
|
| 30 |
+
"RTX", "SBUX", "AMD", "INTU", "GS", "CAT", "MDT", "AMGN", "BLK",
|
| 31 |
+
"GILD", "ADI", "ISRG", "BKNG", "T", "PLTR", "NOW", "SPGI", "MO",
|
| 32 |
+
"ELV", "SYK", "ZTS", "ADP", "MMC", "CB", "CI", "SO", "DUK",
|
| 33 |
+
"BDX", "TMUS", "SCHW", "EOG", "REGN", "LRCX", "PGR", "APD", "CL",
|
| 34 |
+
"SHW", "SLB", "ETN", "AON", "ITW", "BSX", "CME", "HUM", "MRNA",
|
| 35 |
+
"ABNB", "ORLY", "ATVI", "MCO", "KLAC", "PANW", "SNPS", "CDNS",
|
| 36 |
+
"FTNT", "NXPI", "ADM", "KDP", "MNST", "MCHP", "CTAS", "PAYX",
|
| 37 |
+
"AEP", "EXC", "AZN", "LHX", "NOC", "STZ", "KHC", "MAR", "DXCM",
|
| 38 |
+
]
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# ── Helpers ────────────────────────────────────────────────────────────
|
| 42 |
+
|
| 43 |
+
def _safe_ratio(num, den, default=0.0):
|
| 44 |
+
if den == 0 or pd.isna(den):
|
| 45 |
+
return default
|
| 46 |
+
return float(num) / float(den)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _is_leaps(expiry_date_str: str) -> bool:
|
| 50 |
+
"""Vade > 365 gün ise LEAPS."""
|
| 51 |
+
try:
|
| 52 |
+
dt = datetime.strptime(expiry_date_str, "%Y-%m-%d")
|
| 53 |
+
return (dt - datetime.today()).days >= LEAPS_MIN_DAYS
|
| 54 |
+
except ValueError:
|
| 55 |
+
return False
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def scan_ticker_leaps(sym: str) -> Optional[dict]:
|
| 59 |
+
"""
|
| 60 |
+
Tek ticker tarama: LEAPS opsiyonlarda anormal hacim/OI spike ara.
|
| 61 |
+
Sonuç bulunamazsa None döner.
|
| 62 |
+
"""
|
| 63 |
+
try:
|
| 64 |
+
stock = yf.Ticker(sym)
|
| 65 |
+
expiry_dates = stock.options
|
| 66 |
+
if not expiry_dates:
|
| 67 |
+
return None
|
| 68 |
+
|
| 69 |
+
# LEAPS vadelerini belirle
|
| 70 |
+
leaps_expiries = [exp for exp in expiry_dates if _is_leaps(exp)]
|
| 71 |
+
if not leaps_expiries:
|
| 72 |
+
return None
|
| 73 |
+
|
| 74 |
+
total_leaps_call_vol = 0
|
| 75 |
+
total_leaps_put_vol = 0
|
| 76 |
+
total_leaps_call_oi = 0
|
| 77 |
+
total_leaps_put_oi = 0
|
| 78 |
+
unusual_spikes = []
|
| 79 |
+
|
| 80 |
+
for exp in leaps_expiries:
|
| 81 |
+
try:
|
| 82 |
+
chain = stock.option_chain(exp)
|
| 83 |
+
except Exception:
|
| 84 |
+
continue
|
| 85 |
+
|
| 86 |
+
for is_call, df in [(True, chain.calls), (False, chain.puts)]:
|
| 87 |
+
if df.empty:
|
| 88 |
+
continue
|
| 89 |
+
|
| 90 |
+
active = df[df["volume"] > 0].copy()
|
| 91 |
+
if active.empty:
|
| 92 |
+
continue
|
| 93 |
+
|
| 94 |
+
# Volume/OI ratio hesapla
|
| 95 |
+
active["vol_oi"] = active.apply(
|
| 96 |
+
lambda r: _safe_ratio(r.get("volume", 0), r.get("openInterest", 0)),
|
| 97 |
+
axis=1,
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
# Toplam hacim/OI
|
| 101 |
+
vol_sum = int(active["volume"].sum())
|
| 102 |
+
oi_sum = int(active["openInterest"].sum())
|
| 103 |
+
if is_call:
|
| 104 |
+
total_leaps_call_vol += vol_sum
|
| 105 |
+
total_leaps_call_oi += oi_sum
|
| 106 |
+
else:
|
| 107 |
+
total_leaps_put_vol += vol_sum
|
| 108 |
+
total_leaps_put_oi += oi_sum
|
| 109 |
+
|
| 110 |
+
# Anormal spike'lar: vol/OI >= threshold
|
| 111 |
+
spikes = active[active["vol_oi"] >= UNUSUAL_VOL_OI_MIN]
|
| 112 |
+
for _, row in spikes.iterrows():
|
| 113 |
+
unusual_spikes.append({
|
| 114 |
+
"type": "LEAPS CALL" if is_call else "LEAPS PUT",
|
| 115 |
+
"expiry": exp,
|
| 116 |
+
"strike": float(row.get("strike", 0)),
|
| 117 |
+
"volume": int(row.get("volume", 0)),
|
| 118 |
+
"openInterest": int(row.get("openInterest", 0)),
|
| 119 |
+
"vol_oi_ratio": round(float(row.get("vol_oi", 0)), 1),
|
| 120 |
+
"lastPrice": float(row.get("lastPrice", 0)) if pd.notna(row.get("lastPrice")) else 0,
|
| 121 |
+
"impliedVolatility": round(float(row.get("impliedVolatility", 0)), 4) if pd.notna(row.get("impliedVolatility")) else 0,
|
| 122 |
+
"signal": "BULLISH" if is_call else "BEARISH",
|
| 123 |
+
})
|
| 124 |
+
|
| 125 |
+
if not unusual_spikes:
|
| 126 |
+
return None
|
| 127 |
+
|
| 128 |
+
# Özet hesapla
|
| 129 |
+
total_vol = total_leaps_call_vol + total_leaps_put_vol
|
| 130 |
+
cp_ratio = _safe_ratio(total_leaps_call_vol, total_leaps_put_vol)
|
| 131 |
+
|
| 132 |
+
# En büyük spike'ı bul
|
| 133 |
+
top_spike = max(unusual_spikes, key=lambda x: x["volume"])
|
| 134 |
+
|
| 135 |
+
return {
|
| 136 |
+
"ticker": sym,
|
| 137 |
+
"total_leaps_call_vol": total_leaps_call_vol,
|
| 138 |
+
"total_leaps_put_vol": total_leaps_put_vol,
|
| 139 |
+
"total_leaps_vol": total_vol,
|
| 140 |
+
"call_put_ratio": round(cp_ratio, 2),
|
| 141 |
+
"unusual_count": len(unusual_spikes),
|
| 142 |
+
"top_spike": top_spike,
|
| 143 |
+
"all_spikes": unusual_spikes[:10], # Max 10 spike
|
| 144 |
+
"leaps_expiries_count": len(leaps_expiries),
|
| 145 |
+
"nearest_leaps_expiry": leaps_expiries[0] if leaps_expiries else None,
|
| 146 |
+
"scan_time": datetime.utcnow().isoformat(),
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
except Exception:
|
| 150 |
+
return None
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def scan_all_sp100_leaps(max_tickers: int = 100) -> list[dict]:
|
| 154 |
+
"""
|
| 155 |
+
S&P 100 ticker listesini concurrent tarayarak LEAPS anomali bulur.
|
| 156 |
+
Sonuçları toplam LEAPS hacmine göre sıralı döndürür.
|
| 157 |
+
|
| 158 |
+
Parametre:
|
| 159 |
+
max_tickers: Taranacak maks ticker sayısı (varsayılan 100, S&P 100)
|
| 160 |
+
|
| 161 |
+
Dönüş:
|
| 162 |
+
list[dict]: LEAPS anomali bulunan ticker'lar, hacme göre sıralı
|
| 163 |
+
"""
|
| 164 |
+
tickers = SP100_TICKERS[:max_tickers]
|
| 165 |
+
results = []
|
| 166 |
+
|
| 167 |
+
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
|
| 168 |
+
future_to_sym = {
|
| 169 |
+
executor.submit(scan_ticker_leaps, sym): sym
|
| 170 |
+
for sym in tickers
|
| 171 |
+
}
|
| 172 |
+
for future in as_completed(future_to_sym, timeout=120):
|
| 173 |
+
try:
|
| 174 |
+
result = future.result(timeout=TIMEOUT_PER_TICKER)
|
| 175 |
+
if result is not None:
|
| 176 |
+
results.append(result)
|
| 177 |
+
except Exception:
|
| 178 |
+
continue
|
| 179 |
+
|
| 180 |
+
# Toplam LEAPS hacmine göre sırala
|
| 181 |
+
results.sort(key=lambda x: x["total_leaps_vol"], reverse=True)
|
| 182 |
+
return results
|
backend/main.py
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Terminal.AI - VectorBT Financial Analysis Backend
|
| 3 |
+
FastAPI server providing backtesting and technical analysis endpoints.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from fastapi import FastAPI, HTTPException
|
| 7 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 8 |
+
from pydantic import BaseModel, Field
|
| 9 |
+
from typing import Optional, Literal
|
| 10 |
+
import pandas as pd
|
| 11 |
+
import time
|
| 12 |
+
import os
|
| 13 |
+
from datetime import datetime
|
| 14 |
+
|
| 15 |
+
from data_fetcher import fetch_ohlcv
|
| 16 |
+
from strategies import (
|
| 17 |
+
run_strategy, run_all_strategies, STRATEGIES,
|
| 18 |
+
calculate_rsi, calculate_macd,
|
| 19 |
+
)
|
| 20 |
+
from options_flow import (
|
| 21 |
+
fetch_options_flow, rank_tickers_by_leaps_activity, rank_single_ticker,
|
| 22 |
+
DEFAULT_SCAN_TICKERS,
|
| 23 |
+
)
|
| 24 |
+
from leaps_scanner import scan_all_sp100_leaps
|
| 25 |
+
from encoder import safe_json_response
|
| 26 |
+
|
| 27 |
+
app = FastAPI(
|
| 28 |
+
title="Terminal.AI",
|
| 29 |
+
description="VectorBT-based financial backtesting and analysis platform",
|
| 30 |
+
version="1.1.0"
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
# CORS - allow Next.js frontend
|
| 34 |
+
# Production'da FRONTEND_URL env'den okunur (Vercel URL'i)
|
| 35 |
+
# Development'da localhost:3000 varsayılan
|
| 36 |
+
import os as _os
|
| 37 |
+
_cors_origins = ["http://localhost:3000"]
|
| 38 |
+
_frontend_url = _os.environ.get("FRONTEND_URL", "")
|
| 39 |
+
if _frontend_url:
|
| 40 |
+
_cors_origins.append(_frontend_url)
|
| 41 |
+
# https:// ön ekini de ekle (Vercel bazen https döndürür)
|
| 42 |
+
if _frontend_url.startswith("https://"):
|
| 43 |
+
_cors_origins.append(_frontend_url.replace("https://", "http://"))
|
| 44 |
+
elif _frontend_url.startswith("http://"):
|
| 45 |
+
_cors_origins.append(_frontend_url.replace("http://", "https://"))
|
| 46 |
+
|
| 47 |
+
app.add_middleware(
|
| 48 |
+
CORSMiddleware,
|
| 49 |
+
allow_origins=_cors_origins,
|
| 50 |
+
allow_credentials=True,
|
| 51 |
+
allow_methods=["*"],
|
| 52 |
+
allow_headers=["*"],
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
# ── Simple in-memory cache ──────────────────────────────────────────
|
| 56 |
+
_cache: dict = {}
|
| 57 |
+
_CACHE_TTL = 600 # 10 minutes
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _cached(key: str, ttl: int = _CACHE_TTL):
|
| 61 |
+
"""Decorator-like helper: return cached value or None if expired/missing."""
|
| 62 |
+
entry = _cache.get(key)
|
| 63 |
+
if entry and time.time() - entry["ts"] < ttl:
|
| 64 |
+
return entry["data"]
|
| 65 |
+
return None
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _set_cache(key: str, data):
|
| 69 |
+
_cache[key] = {"data": data, "ts": time.time()}
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# ── Request / Response Models ──────────────────────────────────────
|
| 73 |
+
|
| 74 |
+
class BacktestRequest(BaseModel):
|
| 75 |
+
ticker: str = Field(..., description="Ticker symbol, e.g. AAPL, BTC-USD")
|
| 76 |
+
start_date: str = Field(..., description="Start date YYYY-MM-DD")
|
| 77 |
+
end_date: str = Field(..., description="End date YYYY-MM-DD")
|
| 78 |
+
interval: str = Field(default="1d", description="Data interval: 1d, 1h, 1wk, etc.")
|
| 79 |
+
strategy: str = Field(default="sma", description="Strategy: sma, ema, bollinger")
|
| 80 |
+
fast_ma: int = Field(default=10, ge=2, le=200, description="Fast MA period (SMA/EMA)")
|
| 81 |
+
slow_ma: int = Field(default=30, ge=2, le=500, description="Slow MA period (SMA/EMA)")
|
| 82 |
+
bb_window: int = Field(default=20, ge=2, le=200, description="Bollinger Bands window")
|
| 83 |
+
bb_std: float = Field(default=2.0, ge=0.5, le=4.0, description="Bollinger Bands std dev")
|
| 84 |
+
fees: float = Field(default=0.001, ge=0, le=0.1, description="Transaction fee")
|
| 85 |
+
init_cash: float = Field(default=10000.0, ge=100, description="Initial cash")
|
| 86 |
+
rsi_window: int = Field(default=14, ge=2, le=100, description="RSI period")
|
| 87 |
+
macd_fast: int = Field(default=12, ge=2, le=100, description="MACD fast period")
|
| 88 |
+
macd_slow: int = Field(default=26, ge=2, le=200, description="MACD slow period")
|
| 89 |
+
macd_signal: int = Field(default=9, ge=2, le=100, description="MACD signal period")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class HealthResponse(BaseModel):
|
| 93 |
+
status: str
|
| 94 |
+
version: str
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
# ── Helpers ──────────────────────────────────────────────────────────
|
| 98 |
+
|
| 99 |
+
def _get_strategy_kwargs(request: BacktestRequest) -> dict:
|
| 100 |
+
"""Extract strategy-specific kwargs from request."""
|
| 101 |
+
if request.strategy == "bollinger":
|
| 102 |
+
return {
|
| 103 |
+
"window": request.bb_window,
|
| 104 |
+
"std": request.bb_std,
|
| 105 |
+
"fees": request.fees,
|
| 106 |
+
"init_cash": request.init_cash,
|
| 107 |
+
}
|
| 108 |
+
else:
|
| 109 |
+
return {
|
| 110 |
+
"fast_window": request.fast_ma,
|
| 111 |
+
"slow_window": request.slow_ma,
|
| 112 |
+
"fees": request.fees,
|
| 113 |
+
"init_cash": request.init_cash,
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _build_backtest_response(df: pd.DataFrame, result: dict, request: BacktestRequest) -> dict:
|
| 118 |
+
"""Build the standard backtest response dict."""
|
| 119 |
+
close = df["Close"]
|
| 120 |
+
dates = close.index.tolist()
|
| 121 |
+
|
| 122 |
+
# Calculate indicators
|
| 123 |
+
rsi_data = calculate_rsi(close, window=request.rsi_window)
|
| 124 |
+
macd_data = calculate_macd(
|
| 125 |
+
close=close,
|
| 126 |
+
fast_window=request.macd_fast,
|
| 127 |
+
slow_window=request.macd_slow,
|
| 128 |
+
signal_window=request.macd_signal,
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
response = {
|
| 132 |
+
"ticker": request.ticker,
|
| 133 |
+
"start_date": request.start_date,
|
| 134 |
+
"end_date": request.end_date,
|
| 135 |
+
"interval": request.interval,
|
| 136 |
+
"strategy": request.strategy,
|
| 137 |
+
"data_points": len(df),
|
| 138 |
+
"dates": dates,
|
| 139 |
+
"ohlcv": {
|
| 140 |
+
"open": df["Open"].tolist(),
|
| 141 |
+
"high": df["High"].tolist(),
|
| 142 |
+
"low": df["Low"].tolist(),
|
| 143 |
+
"close": close.tolist(),
|
| 144 |
+
"volume": df["Volume"].tolist(),
|
| 145 |
+
},
|
| 146 |
+
"portfolio": {
|
| 147 |
+
"stats": result["stats"],
|
| 148 |
+
"trades": result["trades"],
|
| 149 |
+
"equity_curve": result["equity_curve"],
|
| 150 |
+
"returns": result["returns"],
|
| 151 |
+
"drawdown": result["drawdown"],
|
| 152 |
+
},
|
| 153 |
+
"indicators": {
|
| 154 |
+
"rsi": rsi_data,
|
| 155 |
+
"macd": macd_data,
|
| 156 |
+
},
|
| 157 |
+
"signals": {
|
| 158 |
+
"entries": result["entries"],
|
| 159 |
+
"exits": result["exits"],
|
| 160 |
+
},
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
# Add strategy-specific indicator data
|
| 164 |
+
if request.strategy == "sma":
|
| 165 |
+
response["indicators"]["sma"] = {
|
| 166 |
+
"fast": result["fast_sma"],
|
| 167 |
+
"slow": result["slow_sma"],
|
| 168 |
+
"fast_window": request.fast_ma,
|
| 169 |
+
"slow_window": request.slow_ma,
|
| 170 |
+
}
|
| 171 |
+
elif request.strategy == "ema":
|
| 172 |
+
response["indicators"]["ema"] = {
|
| 173 |
+
"fast": result["fast_ema"],
|
| 174 |
+
"slow": result["slow_ema"],
|
| 175 |
+
"fast_window": request.fast_ma,
|
| 176 |
+
"slow_window": request.slow_ma,
|
| 177 |
+
}
|
| 178 |
+
elif request.strategy == "bollinger":
|
| 179 |
+
response["indicators"]["bollinger"] = {
|
| 180 |
+
"middle": result["middle"],
|
| 181 |
+
"upper": result["upper"],
|
| 182 |
+
"lower": result["lower"],
|
| 183 |
+
"window": request.bb_window,
|
| 184 |
+
"std": request.bb_std,
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
return response
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
# ── Endpoints ──────────────────────────────────────────────────────
|
| 191 |
+
|
| 192 |
+
@app.get("/api/health", response_model=HealthResponse)
|
| 193 |
+
async def health_check():
|
| 194 |
+
"""Health check endpoint."""
|
| 195 |
+
return {"status": "ok", "version": "1.1.0"}
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
@app.get("/api/strategies")
|
| 199 |
+
async def list_strategies():
|
| 200 |
+
"""List available strategies and their default parameters."""
|
| 201 |
+
return {
|
| 202 |
+
"strategies": [
|
| 203 |
+
{
|
| 204 |
+
"id": "sma",
|
| 205 |
+
"name": "SMA Crossover",
|
| 206 |
+
"description": "Buy when fast SMA crosses above slow SMA",
|
| 207 |
+
"params": {"fast_window": 10, "slow_window": 30},
|
| 208 |
+
},
|
| 209 |
+
{
|
| 210 |
+
"id": "ema",
|
| 211 |
+
"name": "EMA Crossover",
|
| 212 |
+
"description": "Buy when fast EMA crosses above slow EMA",
|
| 213 |
+
"params": {"fast_window": 12, "slow_window": 26},
|
| 214 |
+
},
|
| 215 |
+
{
|
| 216 |
+
"id": "bollinger",
|
| 217 |
+
"name": "Bollinger Bands",
|
| 218 |
+
"description": "Mean reversion: buy at lower band, sell at upper band",
|
| 219 |
+
"params": {"window": 20, "std": 2.0},
|
| 220 |
+
},
|
| 221 |
+
]
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
@app.post("/api/backtest")
|
| 226 |
+
async def backtest(request: BacktestRequest):
|
| 227 |
+
"""Run a backtest with the selected strategy and indicators."""
|
| 228 |
+
try:
|
| 229 |
+
# 1. Fetch data
|
| 230 |
+
df = fetch_ohlcv(
|
| 231 |
+
ticker=request.ticker,
|
| 232 |
+
start=request.start_date,
|
| 233 |
+
end=request.end_date,
|
| 234 |
+
interval=request.interval,
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
close = df["Close"]
|
| 238 |
+
|
| 239 |
+
# 2. Run selected strategy
|
| 240 |
+
strategy_kwargs = _get_strategy_kwargs(request)
|
| 241 |
+
strategy_kwargs["freq"] = request.interval
|
| 242 |
+
result = run_strategy(request.strategy, close=close, **strategy_kwargs)
|
| 243 |
+
|
| 244 |
+
# 3. Build response
|
| 245 |
+
response = _build_backtest_response(df, result, request)
|
| 246 |
+
return safe_json_response(response)
|
| 247 |
+
|
| 248 |
+
except ValueError as e:
|
| 249 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 250 |
+
except RuntimeError as e:
|
| 251 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 252 |
+
except Exception as e:
|
| 253 |
+
raise HTTPException(status_code=500, detail=f"Internal error: {str(e)}")
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
@app.post("/api/compare")
|
| 257 |
+
async def compare_strategies(request: BacktestRequest):
|
| 258 |
+
"""Run all strategies and return comparison results."""
|
| 259 |
+
try:
|
| 260 |
+
df = fetch_ohlcv(
|
| 261 |
+
ticker=request.ticker,
|
| 262 |
+
start=request.start_date,
|
| 263 |
+
end=request.end_date,
|
| 264 |
+
interval=request.interval,
|
| 265 |
+
)
|
| 266 |
+
|
| 267 |
+
close = df["Close"]
|
| 268 |
+
dates = close.index.tolist()
|
| 269 |
+
|
| 270 |
+
# Run all strategies with default params + user's fees/cash/freq
|
| 271 |
+
comparison = run_all_strategies(
|
| 272 |
+
close=close,
|
| 273 |
+
fees=request.fees,
|
| 274 |
+
init_cash=request.init_cash,
|
| 275 |
+
freq=request.interval,
|
| 276 |
+
)
|
| 277 |
+
|
| 278 |
+
# Calculate indicators (same for all strategies)
|
| 279 |
+
rsi_data = calculate_rsi(close, window=request.rsi_window)
|
| 280 |
+
macd_data = calculate_macd(
|
| 281 |
+
close=close,
|
| 282 |
+
fast_window=request.macd_fast,
|
| 283 |
+
slow_window=request.macd_slow,
|
| 284 |
+
signal_window=request.macd_signal,
|
| 285 |
+
)
|
| 286 |
+
|
| 287 |
+
response = {
|
| 288 |
+
"ticker": request.ticker,
|
| 289 |
+
"start_date": request.start_date,
|
| 290 |
+
"end_date": request.end_date,
|
| 291 |
+
"interval": request.interval,
|
| 292 |
+
"data_points": len(df),
|
| 293 |
+
"dates": dates,
|
| 294 |
+
"ohlcv": {
|
| 295 |
+
"open": df["Open"].tolist(),
|
| 296 |
+
"high": df["High"].tolist(),
|
| 297 |
+
"low": df["Low"].tolist(),
|
| 298 |
+
"close": close.tolist(),
|
| 299 |
+
"volume": df["Volume"].tolist(),
|
| 300 |
+
},
|
| 301 |
+
"indicators": {
|
| 302 |
+
"rsi": rsi_data,
|
| 303 |
+
"macd": macd_data,
|
| 304 |
+
},
|
| 305 |
+
"comparison": comparison,
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
return safe_json_response(response)
|
| 309 |
+
|
| 310 |
+
except ValueError as e:
|
| 311 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 312 |
+
except RuntimeError as e:
|
| 313 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 314 |
+
except Exception as e:
|
| 315 |
+
raise HTTPException(status_code=500, detail=f"Internal error: {str(e)}")
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
@app.get("/api/ticker/{ticker}")
|
| 319 |
+
async def get_ticker_info(ticker: str):
|
| 320 |
+
"""Get basic ticker info and latest price."""
|
| 321 |
+
try:
|
| 322 |
+
df = fetch_ohlcv(ticker=ticker, start="2000-01-01", end="2030-12-31")
|
| 323 |
+
return safe_json_response({
|
| 324 |
+
"ticker": ticker,
|
| 325 |
+
"latest_close": df["Close"].iloc[-1] if not df.empty else None,
|
| 326 |
+
"data_points": len(df),
|
| 327 |
+
"date_range": {
|
| 328 |
+
"start": df.index[0].isoformat(),
|
| 329 |
+
"end": df.index[-1].isoformat(),
|
| 330 |
+
}
|
| 331 |
+
})
|
| 332 |
+
except Exception as e:
|
| 333 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
@app.get("/api/ticker/{ticker}/range")
|
| 337 |
+
async def get_ticker_range(ticker: str):
|
| 338 |
+
"""Get the earliest and latest available date for a ticker."""
|
| 339 |
+
cache_key = f"range:{ticker.upper()}"
|
| 340 |
+
cached = _cached(cache_key)
|
| 341 |
+
if cached:
|
| 342 |
+
return cached
|
| 343 |
+
|
| 344 |
+
try:
|
| 345 |
+
df = fetch_ohlcv(ticker=ticker, start="2000-01-01", end="2030-12-31")
|
| 346 |
+
if df.empty:
|
| 347 |
+
raise HTTPException(status_code=404, detail=f"No data found for {ticker}")
|
| 348 |
+
result = {
|
| 349 |
+
"ticker": ticker,
|
| 350 |
+
"earliest": df.index[0].isoformat(),
|
| 351 |
+
"latest": df.index[-1].isoformat(),
|
| 352 |
+
"data_points": len(df),
|
| 353 |
+
}
|
| 354 |
+
_set_cache(cache_key, result)
|
| 355 |
+
return result
|
| 356 |
+
except ValueError as e:
|
| 357 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 358 |
+
except RuntimeError as e:
|
| 359 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 360 |
+
except Exception as e:
|
| 361 |
+
raise HTTPException(status_code=500, detail=f"Internal error: {str(e)}")
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
@app.get("/api/ticker/{ticker}/fundamentals")
|
| 365 |
+
async def get_ticker_fundamentals(ticker: str):
|
| 366 |
+
"""Get company fundamentals and valuation multiples."""
|
| 367 |
+
cache_key = f"fundamentals:{ticker.upper()}"
|
| 368 |
+
cached = _cached(cache_key)
|
| 369 |
+
if cached:
|
| 370 |
+
return cached
|
| 371 |
+
|
| 372 |
+
try:
|
| 373 |
+
import yfinance as yf
|
| 374 |
+
stock = yf.Ticker(ticker)
|
| 375 |
+
info = stock.info
|
| 376 |
+
|
| 377 |
+
def safe(v):
|
| 378 |
+
if v is None or v == "None":
|
| 379 |
+
return None
|
| 380 |
+
return v
|
| 381 |
+
|
| 382 |
+
result = {
|
| 383 |
+
"ticker": ticker,
|
| 384 |
+
"company": {
|
| 385 |
+
"name": safe(info.get("shortName")),
|
| 386 |
+
"sector": safe(info.get("sector")),
|
| 387 |
+
"industry": safe(info.get("industry")),
|
| 388 |
+
"summary": safe(info.get("longBusinessSummary")),
|
| 389 |
+
},
|
| 390 |
+
"valuation": {
|
| 391 |
+
"marketCap": safe(info.get("marketCap")),
|
| 392 |
+
"enterpriseValue": safe(info.get("enterpriseValue")),
|
| 393 |
+
"trailingPE": safe(info.get("trailingPE")),
|
| 394 |
+
"forwardPE": safe(info.get("forwardPE")),
|
| 395 |
+
"priceToBook": safe(info.get("priceToBook")),
|
| 396 |
+
"enterpriseToEbitda": safe(info.get("enterpriseToEbitda")),
|
| 397 |
+
"priceToSales": safe(info.get("priceToSalesTrailing12Months")),
|
| 398 |
+
},
|
| 399 |
+
"financials": {
|
| 400 |
+
"revenuePerShare": safe(info.get("revenuePerShare")),
|
| 401 |
+
"earningsPerShare": safe(info.get("trailingEps")),
|
| 402 |
+
"profitMargins": safe(info.get("profitMargins")),
|
| 403 |
+
"operatingMargins": safe(info.get("operatingMargins")),
|
| 404 |
+
"returnOnEquity": safe(info.get("returnOnEquity")),
|
| 405 |
+
"debtToEquity": safe(info.get("debtToEquity")),
|
| 406 |
+
"currentRatio": safe(info.get("currentRatio")),
|
| 407 |
+
"freeCashflow": safe(info.get("freeCashflow")),
|
| 408 |
+
"revenueGrowth": safe(info.get("revenueGrowth")),
|
| 409 |
+
},
|
| 410 |
+
"trading": {
|
| 411 |
+
"beta": safe(info.get("beta")),
|
| 412 |
+
"dividendYield": safe(info.get("dividendYield")),
|
| 413 |
+
"fiftyTwoWeekHigh": safe(info.get("fiftyTwoWeekHigh")),
|
| 414 |
+
"fiftyTwoWeekLow": safe(info.get("fiftyTwoWeekLow")),
|
| 415 |
+
"averageVolume": safe(info.get("averageVolume")),
|
| 416 |
+
},
|
| 417 |
+
}
|
| 418 |
+
_set_cache(cache_key, result)
|
| 419 |
+
return result
|
| 420 |
+
except Exception as e:
|
| 421 |
+
raise HTTPException(status_code=500, detail=f"Failed to fetch fundamentals for {ticker}: {str(e)}")
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
# ── Options Flow Endpoints ────────────────────────────────────────────
|
| 425 |
+
|
| 426 |
+
@app.get("/api/ticker/{ticker}/options-flow")
|
| 427 |
+
async def get_options_flow(ticker: str):
|
| 428 |
+
"""
|
| 429 |
+
Get full options chain analysis for a ticker:
|
| 430 |
+
- Per-expiry call/put volumes, OI, volume/OI ratios
|
| 431 |
+
- LEAPS (expiry > 1 year) highlighted separately
|
| 432 |
+
- Unusual volume/OI spikes (vol/OI > 2x)
|
| 433 |
+
- Call/Put volume ratio overall
|
| 434 |
+
- Last 24h change data when available
|
| 435 |
+
Cached for 5 minutes (options data doesn't change intra-minute).
|
| 436 |
+
"""
|
| 437 |
+
cache_key = f"options-flow:{ticker.upper()}"
|
| 438 |
+
cached = _cached(cache_key, ttl=300)
|
| 439 |
+
if cached:
|
| 440 |
+
return cached
|
| 441 |
+
|
| 442 |
+
try:
|
| 443 |
+
data = fetch_options_flow(ticker)
|
| 444 |
+
_set_cache(cache_key, data)
|
| 445 |
+
return safe_json_response(data)
|
| 446 |
+
except ValueError as e:
|
| 447 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 448 |
+
except RuntimeError as e:
|
| 449 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 450 |
+
except Exception as e:
|
| 451 |
+
raise HTTPException(status_code=500, detail=f"Options data error for {ticker}: {str(e)}")
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
@app.get("/api/options-flow/leaps-board")
|
| 455 |
+
async def get_leaps_board(limit: int = 15):
|
| 456 |
+
"""
|
| 457 |
+
Scan the top tickers by LEAPS (long-dated >1yr) options activity.
|
| 458 |
+
Returns tickers ranked by total unusual + high-volume options flow.
|
| 459 |
+
Useful for spotting which stocks have unusual long-dated options buying.
|
| 460 |
+
Default scan set: 22 liquid US equities + ETFs.
|
| 461 |
+
Set limit to control how many results returned (max 50).
|
| 462 |
+
"""
|
| 463 |
+
import asyncio
|
| 464 |
+
limit = max(1, min(limit, 50))
|
| 465 |
+
try:
|
| 466 |
+
# Run blocking yfinance calls in thread pool for concurrency
|
| 467 |
+
loop = asyncio.get_event_loop()
|
| 468 |
+
tasks = [
|
| 469 |
+
loop.run_in_executor(None, rank_single_ticker, sym)
|
| 470 |
+
for sym in DEFAULT_SCAN_TICKERS
|
| 471 |
+
]
|
| 472 |
+
results_raw = await asyncio.gather(*tasks, return_exceptions=True)
|
| 473 |
+
ranked = [r for r in results_raw if isinstance(r, dict)]
|
| 474 |
+
ranked.sort(
|
| 475 |
+
key=lambda x: x.get("total_call_volume", 0) + x.get("total_put_volume", 0),
|
| 476 |
+
reverse=True,
|
| 477 |
+
)
|
| 478 |
+
return safe_json_response({
|
| 479 |
+
"count": len(ranked),
|
| 480 |
+
"scan_tickers": DEFAULT_SCAN_TICKERS,
|
| 481 |
+
"results": ranked[:limit],
|
| 482 |
+
})
|
| 483 |
+
except Exception as e:
|
| 484 |
+
raise HTTPException(status_code=500, detail=f"LEAPS board error: {str(e)}")
|
| 485 |
+
|
| 486 |
+
|
| 487 |
+
@app.get("/api/leaps/scanner")
|
| 488 |
+
async def leaps_scanner(max_tickers: int = 100, min_spikes: int = 1):
|
| 489 |
+
"""
|
| 490 |
+
S&P 100 tabanlı LEAPS anomali tarama.
|
| 491 |
+
Yakın zamanda yüksek miktarda LEAPS opsiyonu alınan hisseleri tespit eder.
|
| 492 |
+
|
| 493 |
+
Parametreler:
|
| 494 |
+
max_tickers: Taranacak maks ticker sayısı (varsayılan 100, max 100)
|
| 495 |
+
min_spikes: Minimum anormal spike sayısı filtresi (varsayılan 1)
|
| 496 |
+
|
| 497 |
+
Response:
|
| 498 |
+
tickers: LEAPS anomali bulunan ticker'lar (hacme göre sıralı)
|
| 499 |
+
scan_info: Tarama bilgisi (ticker sayısı, süre, zaman)
|
| 500 |
+
"""
|
| 501 |
+
import time as _time
|
| 502 |
+
max_tickers = max(1, min(max_tickers, 100))
|
| 503 |
+
min_spikes = max(1, min_spikes)
|
| 504 |
+
|
| 505 |
+
try:
|
| 506 |
+
t0 = _time.time()
|
| 507 |
+
results = scan_all_sp100_leaps(max_tickers=max_tickers)
|
| 508 |
+
elapsed = round(_time.time() - t0, 1)
|
| 509 |
+
|
| 510 |
+
# Min spike filtresi
|
| 511 |
+
if min_spikes > 1:
|
| 512 |
+
results = [r for r in results if r["unusual_count"] >= min_spikes]
|
| 513 |
+
|
| 514 |
+
return safe_json_response({
|
| 515 |
+
"tickers": results,
|
| 516 |
+
"count": len(results),
|
| 517 |
+
"scan_info": {
|
| 518 |
+
"max_tickers": max_tickers,
|
| 519 |
+
"min_spikes": min_spikes,
|
| 520 |
+
"scan_time_sec": elapsed,
|
| 521 |
+
"scanned_at": datetime.utcnow().isoformat(),
|
| 522 |
+
},
|
| 523 |
+
})
|
| 524 |
+
except Exception as e:
|
| 525 |
+
raise HTTPException(status_code=500, detail=f"LEAPS scanner error: {str(e)}")
|
| 526 |
+
|
| 527 |
+
|
| 528 |
+
if __name__ == "__main__":
|
| 529 |
+
import uvicorn
|
| 530 |
+
uvicorn.run(app, host="0.0.0.0", port=8000, timeout_keep_alive=300)
|
backend/options_flow.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Options flow and unusual activity module.
|
| 3 |
+
Provides LEAPS detection, call/put ratio, and unusual volume analysis
|
| 4 |
+
via yfinance option chain data.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import yfinance as yf
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import numpy as np
|
| 10 |
+
from datetime import datetime, timedelta
|
| 11 |
+
from typing import Optional
|
| 12 |
+
|
| 13 |
+
# ── Constants ──────────────────────────────────────────────────────────
|
| 14 |
+
|
| 15 |
+
LEAPS_MIN_DAYS = 365 # Expiry > 1 year = LEAPS
|
| 16 |
+
UNUSUAL_VOL_OI_THRESHOLD = 2.0 # volume / openInterest threshold for unusual activity
|
| 17 |
+
CPR_BULLISH = 3.0 # Call/Put ratio above this = bullish
|
| 18 |
+
CPR_BEARISH = 0.33 # Call/Put ratio below this = bearish
|
| 19 |
+
|
| 20 |
+
# Tickers to scan for the top LEAPS movers board
|
| 21 |
+
DEFAULT_SCAN_TICKERS = [
|
| 22 |
+
"AAPL", "MSFT", "GOOGL", "AMZN", "NVDA", "TSLA", "META",
|
| 23 |
+
"SPY", "QQQ", "IWM", "AMD", "INTC", "PLTR", "COIN",
|
| 24 |
+
"JPM", "BAC", "GS", "JNJ", "PFE", "UNH", "XOM", "CVX",
|
| 25 |
+
]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# ── Helpers ────────────────────────────────────────────────────────────
|
| 29 |
+
|
| 30 |
+
def _safe_ratio(num, den, default=0.0):
|
| 31 |
+
"""Avoid division by zero."""
|
| 32 |
+
if den == 0 or pd.isna(den):
|
| 33 |
+
return default
|
| 34 |
+
return float(num) / float(den)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _detect_leaps(expiry_dates: list[str]) -> list[str]:
|
| 38 |
+
"""Filter expiries that are LEAPS-range (> 365 days out)."""
|
| 39 |
+
today = datetime.today()
|
| 40 |
+
leaps = []
|
| 41 |
+
for exp in expiry_dates:
|
| 42 |
+
try:
|
| 43 |
+
dt = datetime.strptime(exp, "%Y-%m-%d")
|
| 44 |
+
if (dt - today).days >= LEAPS_MIN_DAYS:
|
| 45 |
+
leaps.append(exp)
|
| 46 |
+
except ValueError:
|
| 47 |
+
continue
|
| 48 |
+
return leaps
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _analyze_chain(
|
| 52 |
+
calls: pd.DataFrame,
|
| 53 |
+
puts: pd.DataFrame,
|
| 54 |
+
expiry: str,
|
| 55 |
+
is_leaps: bool = False,
|
| 56 |
+
) -> dict:
|
| 57 |
+
"""Analyze a single option chain (calls + puts) for one expiry."""
|
| 58 |
+
result = {
|
| 59 |
+
"expiry": expiry,
|
| 60 |
+
"is_leaps": is_leaps,
|
| 61 |
+
"total_call_volume": 0,
|
| 62 |
+
"total_put_volume": 0,
|
| 63 |
+
"total_call_oi": 0,
|
| 64 |
+
"total_put_oi": 0,
|
| 65 |
+
"call_put_ratio": 0.0,
|
| 66 |
+
"top_calls": [],
|
| 67 |
+
"top_puts": [],
|
| 68 |
+
"unusual_calls": [],
|
| 69 |
+
"unusual_puts": [],
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
# --- Calls ---
|
| 73 |
+
active_calls = calls[calls["volume"] > 0].copy()
|
| 74 |
+
if not active_calls.empty:
|
| 75 |
+
active_calls["vol_oi_ratio"] = active_calls.apply(
|
| 76 |
+
lambda r: _safe_ratio(r["volume"], r["openInterest"]), axis=1
|
| 77 |
+
)
|
| 78 |
+
result["total_call_volume"] = int(calls["volume"].sum())
|
| 79 |
+
result["total_call_oi"] = int(calls["openInterest"].sum())
|
| 80 |
+
# Top calls by volume
|
| 81 |
+
top = active_calls.nlargest(5, "volume")[
|
| 82 |
+
["strike", "lastPrice", "volume", "openInterest",
|
| 83 |
+
"vol_oi_ratio", "impliedVolatility", "inTheMoney", "contractSymbol"]
|
| 84 |
+
]
|
| 85 |
+
result["top_calls"] = _rows_to_dicts(top)
|
| 86 |
+
# Unusual: vol_oi_ratio > threshold
|
| 87 |
+
unusual = active_calls[active_calls["vol_oi_ratio"] >= UNUSUAL_VOL_OI_THRESHOLD].nlargest(10, "volume")
|
| 88 |
+
result["unusual_calls"] = _rows_to_dicts(unusual)
|
| 89 |
+
|
| 90 |
+
# --- Puts ---
|
| 91 |
+
active_puts = puts[puts["volume"] > 0].copy()
|
| 92 |
+
if not active_puts.empty:
|
| 93 |
+
active_puts["vol_oi_ratio"] = active_puts.apply(
|
| 94 |
+
lambda r: _safe_ratio(r["volume"], r["openInterest"]), axis=1
|
| 95 |
+
)
|
| 96 |
+
result["total_put_volume"] = int(puts["volume"].sum())
|
| 97 |
+
result["total_put_oi"] = int(puts["openInterest"].sum())
|
| 98 |
+
top = active_puts.nlargest(5, "volume")[
|
| 99 |
+
["strike", "lastPrice", "volume", "openInterest",
|
| 100 |
+
"vol_oi_ratio", "impliedVolatility", "inTheMoney", "contractSymbol"]
|
| 101 |
+
]
|
| 102 |
+
result["top_puts"] = _rows_to_dicts(top)
|
| 103 |
+
unusual = active_puts[active_puts["vol_oi_ratio"] >= UNUSUAL_VOL_OI_THRESHOLD].nlargest(10, "volume")
|
| 104 |
+
result["unusual_puts"] = _rows_to_dicts(unusual)
|
| 105 |
+
|
| 106 |
+
# Overall call/put ratio
|
| 107 |
+
result["call_put_ratio"] = _safe_ratio(
|
| 108 |
+
result["total_call_volume"], result["total_put_volume"]
|
| 109 |
+
)
|
| 110 |
+
return result
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _rows_to_dicts(df: pd.DataFrame) -> list[dict]:
|
| 114 |
+
"""Convert DataFrame rows to list of dicts with safe JSON types."""
|
| 115 |
+
records = []
|
| 116 |
+
if df.empty:
|
| 117 |
+
return records
|
| 118 |
+
for _, row in df.iterrows():
|
| 119 |
+
rec = {}
|
| 120 |
+
for col in df.columns:
|
| 121 |
+
val = row[col]
|
| 122 |
+
if isinstance(val, (pd.Timestamp,)):
|
| 123 |
+
rec[col] = val.isoformat()
|
| 124 |
+
elif isinstance(val, (np.integer,)):
|
| 125 |
+
rec[col] = int(val)
|
| 126 |
+
elif isinstance(val, (np.floating,)):
|
| 127 |
+
rec[col] = float(val)
|
| 128 |
+
elif pd.isna(val):
|
| 129 |
+
rec[col] = None
|
| 130 |
+
else:
|
| 131 |
+
rec[col] = val
|
| 132 |
+
records.append(rec)
|
| 133 |
+
return records
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
# ── Public API ─────────────────────────────────────────────────────────
|
| 137 |
+
|
| 138 |
+
def fetch_options_flow(ticker: str) -> dict:
|
| 139 |
+
"""
|
| 140 |
+
Fetch full options flow data for a ticker:
|
| 141 |
+
- All expiries (near and far)
|
| 142 |
+
- LEAPS highlighted
|
| 143 |
+
- Unusual volume/OI spikes
|
| 144 |
+
- Call/Put volume ratio
|
| 145 |
+
|
| 146 |
+
Returns a structured dict for JSON response.
|
| 147 |
+
"""
|
| 148 |
+
try:
|
| 149 |
+
stock = yf.Ticker(ticker)
|
| 150 |
+
expiry_dates = stock.options
|
| 151 |
+
if not expiry_dates:
|
| 152 |
+
raise ValueError(f"No options data available for {ticker}")
|
| 153 |
+
|
| 154 |
+
has_leaps = False
|
| 155 |
+
leaps_expiries = _detect_leaps(expiry_dates)
|
| 156 |
+
if leaps_expiries:
|
| 157 |
+
has_leaps = True
|
| 158 |
+
|
| 159 |
+
all_expiries_data = []
|
| 160 |
+
unusual_activities = []
|
| 161 |
+
leaps_data = []
|
| 162 |
+
|
| 163 |
+
for i, exp in enumerate(expiry_dates):
|
| 164 |
+
is_leaps_flag = exp in leaps_expiries
|
| 165 |
+
chain = stock.option_chain(exp)
|
| 166 |
+
expiry_info = _analyze_chain(
|
| 167 |
+
chain.calls, chain.puts, expiry=exp, is_leaps=is_leaps_flag
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
if is_leaps_flag:
|
| 171 |
+
leaps_data.append(expiry_info)
|
| 172 |
+
# Mark highest volume/vol_oi strikes as unusual
|
| 173 |
+
for uc in expiry_info.get("unusual_calls", []):
|
| 174 |
+
uc["expiry"] = exp
|
| 175 |
+
uc["type"] = "leaps_call"
|
| 176 |
+
uc["signal"] = "BULLISH"
|
| 177 |
+
unusual_activities.append(uc)
|
| 178 |
+
for up in expiry_info.get("unusual_puts", []):
|
| 179 |
+
up["expiry"] = exp
|
| 180 |
+
up["type"] = "leaps_put"
|
| 181 |
+
up["signal"] = "BEARISH"
|
| 182 |
+
unusual_activities.append(up)
|
| 183 |
+
|
| 184 |
+
all_expiries_data.append(expiry_info)
|
| 185 |
+
|
| 186 |
+
# Sort unusual activities by volume descending
|
| 187 |
+
unusual_activities.sort(key=lambda x: x.get("volume", 0), reverse=True)
|
| 188 |
+
unusual_activities = unusual_activities[:20] # cap at 20
|
| 189 |
+
|
| 190 |
+
# Compute summary using the NEAREST expiry as primary
|
| 191 |
+
nearest = all_expiries_data[0] if all_expiries_data else {}
|
| 192 |
+
total_callVol = sum(e["total_call_volume"] for e in all_expiries_data)
|
| 193 |
+
total_putVol = sum(e["total_put_volume"] for e in all_expiries_data)
|
| 194 |
+
|
| 195 |
+
return {
|
| 196 |
+
"ticker": ticker,
|
| 197 |
+
"as_of": datetime.utcnow().isoformat(),
|
| 198 |
+
"has_leaps": has_leaps,
|
| 199 |
+
"leaps_expiries": leaps_expiries,
|
| 200 |
+
"summary": {
|
| 201 |
+
"total_call_volume": total_callVol,
|
| 202 |
+
"total_put_volume": total_putVol,
|
| 203 |
+
"call_put_ratio": round(_safe_ratio(total_callVol, total_putVol), 2),
|
| 204 |
+
"total_expiries": len(expiry_dates),
|
| 205 |
+
"nearest_call_put_ratio": nearest.get("call_put_ratio", 0.0),
|
| 206 |
+
"unusual_count": len(unusual_activities),
|
| 207 |
+
},
|
| 208 |
+
"all_expiries": all_expiries_data,
|
| 209 |
+
"leaps": leaps_data,
|
| 210 |
+
"unusual_activities": unusual_activities,
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
except ValueError:
|
| 214 |
+
raise
|
| 215 |
+
except Exception as e:
|
| 216 |
+
raise RuntimeError(f"Failed to fetch options data for {ticker}: {str(e)}")
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def rank_single_ticker(sym: str) -> dict | None:
|
| 220 |
+
"""Fetch options flow for a single ticker, returning a compact summary dict."""
|
| 221 |
+
try:
|
| 222 |
+
data = fetch_options_flow(sym)
|
| 223 |
+
summary = data["summary"]
|
| 224 |
+
return {
|
| 225 |
+
"ticker": sym,
|
| 226 |
+
"has_leaps": data["has_leaps"],
|
| 227 |
+
"leaps_count": len(data["leaps"]),
|
| 228 |
+
"total_call_volume": summary["total_call_volume"],
|
| 229 |
+
"total_put_volume": summary["total_put_volume"],
|
| 230 |
+
"call_put_ratio": summary["call_put_ratio"],
|
| 231 |
+
"nearest_call_put_ratio": summary["nearest_call_put_ratio"],
|
| 232 |
+
"unusual_count": summary["unusual_count"],
|
| 233 |
+
"top_leaps_expiry": data["leaps_expiries"][-1] if data["leaps_expiries"] else None,
|
| 234 |
+
}
|
| 235 |
+
except Exception:
|
| 236 |
+
return None
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def rank_tickers_by_leaps_activity(tickers: list[str]) -> list[dict]:
|
| 240 |
+
"""
|
| 241 |
+
Scan multiple tickers and rank them by options flow activity.
|
| 242 |
+
Returns list of {ticker, leaps_count, leaps_call_vol, leaps_put_vol, call_put_ratio, top_leaps_expiry}
|
| 243 |
+
sorted by total unusual activity.
|
| 244 |
+
"""
|
| 245 |
+
results = []
|
| 246 |
+
for sym in tickers:
|
| 247 |
+
try:
|
| 248 |
+
data = fetch_options_flow(sym)
|
| 249 |
+
summary = data["summary"]
|
| 250 |
+
|
| 251 |
+
results.append({
|
| 252 |
+
"ticker": sym,
|
| 253 |
+
"has_leaps": data["has_leaps"],
|
| 254 |
+
"leaps_count": len(data["leaps"]),
|
| 255 |
+
"total_call_volume": summary["total_call_volume"],
|
| 256 |
+
"total_put_volume": summary["total_put_volume"],
|
| 257 |
+
"call_put_ratio": summary["call_put_ratio"],
|
| 258 |
+
"nearest_call_put_ratio": summary["nearest_call_put_ratio"],
|
| 259 |
+
"unusual_count": summary["unusual_count"],
|
| 260 |
+
"top_leaps_expiry": data["leaps_expiries"][-1] if data["leaps_expiries"] else None,
|
| 261 |
+
})
|
| 262 |
+
except Exception:
|
| 263 |
+
# Skip tickers with no options data
|
| 264 |
+
continue
|
| 265 |
+
|
| 266 |
+
# Sort by total call volume + put volume descending
|
| 267 |
+
results.sort(key=lambda x: x.get("total_call_volume", 0) + x.get("total_put_volume", 0), reverse=True)
|
| 268 |
+
return results
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.115.0
|
| 2 |
+
uvicorn==0.30.6
|
| 3 |
+
vectorbt==0.26.2
|
| 4 |
+
yfinance==0.2.54
|
| 5 |
+
pandas==2.2.3
|
| 6 |
+
numpy==1.26.4
|
| 7 |
+
pydantic==2.9.2
|
| 8 |
+
python-dateutil==2.9.0
|
| 9 |
+
httpx==0.27.0
|
backend/start.sh
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# Backend başlat — önce portu temizle (Windows uyumlu)
|
| 3 |
+
|
| 4 |
+
PORT=8000
|
| 5 |
+
|
| 6 |
+
# Python ile portu temizle
|
| 7 |
+
python -c "
|
| 8 |
+
import subprocess, sys
|
| 9 |
+
result = subprocess.run(['netstat', '-ano'], capture_output=True, text=True)
|
| 10 |
+
for line in result.stdout.splitlines():
|
| 11 |
+
if ':${PORT}' in line and 'LISTENING' in line:
|
| 12 |
+
pid = line.strip().split()[-1]
|
| 13 |
+
print(f'Killing PID {pid} on port ${PORT}')
|
| 14 |
+
subprocess.run(['taskkill', '/PID', pid, '/F'], capture_output=True)
|
| 15 |
+
break
|
| 16 |
+
"
|
| 17 |
+
|
| 18 |
+
sleep 2
|
| 19 |
+
|
| 20 |
+
echo "Starting FastAPI on port $PORT..."
|
| 21 |
+
source venv/Scripts/activate
|
| 22 |
+
python main.py
|
backend/strategies.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Strategy module using vectorbt.
|
| 3 |
+
Implements SMA Crossover, EMA Crossover, Bollinger Bands, RSI, and MACD.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import vectorbt as vbt
|
| 7 |
+
import pandas as pd
|
| 8 |
+
import numpy as np
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def run_sma_crossover(
|
| 12 |
+
close: pd.Series,
|
| 13 |
+
fast_window: int = 10,
|
| 14 |
+
slow_window: int = 30,
|
| 15 |
+
fees: float = 0.001,
|
| 16 |
+
init_cash: float = 10000.0,
|
| 17 |
+
freq: str = "1D"
|
| 18 |
+
) -> dict:
|
| 19 |
+
"""Run SMA Crossover backtest using vectorbt."""
|
| 20 |
+
fast_sma = vbt.MA.run(close, window=fast_window)
|
| 21 |
+
slow_sma = vbt.MA.run(close, window=slow_window)
|
| 22 |
+
|
| 23 |
+
entries = fast_sma.ma_crossed_above(slow_sma)
|
| 24 |
+
exits = fast_sma.ma_crossed_below(slow_sma)
|
| 25 |
+
|
| 26 |
+
portfolio = vbt.Portfolio.from_signals(
|
| 27 |
+
close=close,
|
| 28 |
+
entries=entries,
|
| 29 |
+
exits=exits,
|
| 30 |
+
init_cash=init_cash,
|
| 31 |
+
fees=fees,
|
| 32 |
+
freq=freq
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
stats = portfolio.stats()
|
| 36 |
+
trades = portfolio.trades.records_readable
|
| 37 |
+
equity_curve = portfolio.value()
|
| 38 |
+
returns = portfolio.returns()
|
| 39 |
+
drawdown = portfolio.drawdown()
|
| 40 |
+
|
| 41 |
+
return {
|
| 42 |
+
"stats": stats,
|
| 43 |
+
"trades": trades,
|
| 44 |
+
"equity_curve": equity_curve,
|
| 45 |
+
"returns": returns,
|
| 46 |
+
"drawdown": drawdown,
|
| 47 |
+
"fast_sma": fast_sma.ma,
|
| 48 |
+
"slow_sma": slow_sma.ma,
|
| 49 |
+
"entries": entries,
|
| 50 |
+
"exits": exits,
|
| 51 |
+
"label": f"SMA({fast_window}/{slow_window})",
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def run_ema_crossover(
|
| 56 |
+
close: pd.Series,
|
| 57 |
+
fast_window: int = 12,
|
| 58 |
+
slow_window: int = 26,
|
| 59 |
+
fees: float = 0.001,
|
| 60 |
+
init_cash: float = 10000.0,
|
| 61 |
+
freq: str = "1D"
|
| 62 |
+
) -> dict:
|
| 63 |
+
"""Run EMA Crossover backtest using pandas EWM.
|
| 64 |
+
|
| 65 |
+
Note: vectorbt 0.26.x doesn't export vbt.EMA directly,
|
| 66 |
+
so we compute EMAs via pandas ewm() then detect crossovers manually.
|
| 67 |
+
"""
|
| 68 |
+
fast_ema_series = close.ewm(span=fast_window, adjust=False).mean()
|
| 69 |
+
slow_ema_series = close.ewm(span=slow_window, adjust=False).mean()
|
| 70 |
+
|
| 71 |
+
entries = pd.Series(False, index=close.index)
|
| 72 |
+
exits = pd.Series(False, index=close.index)
|
| 73 |
+
|
| 74 |
+
for i in range(1, len(close)):
|
| 75 |
+
f_prev, f_curr = fast_ema_series.iloc[i - 1], fast_ema_series.iloc[i]
|
| 76 |
+
s_prev, s_curr = slow_ema_series.iloc[i - 1], slow_ema_series.iloc[i]
|
| 77 |
+
if pd.isna(f_curr) or pd.isna(s_curr) or pd.isna(f_prev) or pd.isna(s_prev):
|
| 78 |
+
continue
|
| 79 |
+
if f_prev <= s_prev and f_curr > s_curr:
|
| 80 |
+
entries.iloc[i] = True
|
| 81 |
+
if f_prev >= s_prev and f_curr < s_curr:
|
| 82 |
+
exits.iloc[i] = True
|
| 83 |
+
|
| 84 |
+
portfolio = vbt.Portfolio.from_signals(
|
| 85 |
+
close=close,
|
| 86 |
+
entries=entries,
|
| 87 |
+
exits=exits,
|
| 88 |
+
init_cash=init_cash,
|
| 89 |
+
fees=fees,
|
| 90 |
+
freq=freq
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
stats = portfolio.stats()
|
| 94 |
+
trades = portfolio.trades.records_readable
|
| 95 |
+
equity_curve = portfolio.value()
|
| 96 |
+
returns = portfolio.returns()
|
| 97 |
+
drawdown = portfolio.drawdown()
|
| 98 |
+
|
| 99 |
+
return {
|
| 100 |
+
"stats": stats,
|
| 101 |
+
"trades": trades,
|
| 102 |
+
"equity_curve": equity_curve,
|
| 103 |
+
"returns": returns,
|
| 104 |
+
"drawdown": drawdown,
|
| 105 |
+
"fast_ema": fast_ema_series,
|
| 106 |
+
"slow_ema": slow_ema_series,
|
| 107 |
+
"entries": entries,
|
| 108 |
+
"exits": exits,
|
| 109 |
+
"label": f"EMA({fast_window}/{slow_window})",
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def run_bollinger_bands(
|
| 114 |
+
close: pd.Series,
|
| 115 |
+
window: int = 20,
|
| 116 |
+
std: float = 2.0,
|
| 117 |
+
fees: float = 0.001,
|
| 118 |
+
init_cash: float = 10000.0,
|
| 119 |
+
freq: str = "1D"
|
| 120 |
+
) -> dict:
|
| 121 |
+
"""Run Bollinger Bands mean-reversion backtest using pandas.
|
| 122 |
+
|
| 123 |
+
Note: vectorbt 0.26.x BBANDS.run has a parameter conflict,
|
| 124 |
+
so we compute bands manually with pandas rolling.
|
| 125 |
+
"""
|
| 126 |
+
import numpy as np
|
| 127 |
+
|
| 128 |
+
rolling_mean = close.rolling(window=window).mean()
|
| 129 |
+
rolling_std = close.rolling(window=window).std(ddof=0)
|
| 130 |
+
|
| 131 |
+
middle = rolling_mean
|
| 132 |
+
upper = rolling_mean + (rolling_std * std)
|
| 133 |
+
lower = rolling_mean - (rolling_std * std)
|
| 134 |
+
|
| 135 |
+
# Mean reversion signals
|
| 136 |
+
entries = pd.Series(False, index=close.index)
|
| 137 |
+
exits = pd.Series(False, index=close.index)
|
| 138 |
+
|
| 139 |
+
for i in range(len(close)):
|
| 140 |
+
if pd.isna(lower.iloc[i]) or pd.isna(upper.iloc[i]) or pd.isna(close.iloc[i]):
|
| 141 |
+
continue
|
| 142 |
+
if close.iloc[i] <= lower.iloc[i]:
|
| 143 |
+
entries.iloc[i] = True
|
| 144 |
+
if close.iloc[i] >= upper.iloc[i]:
|
| 145 |
+
exits.iloc[i] = True
|
| 146 |
+
|
| 147 |
+
portfolio = vbt.Portfolio.from_signals(
|
| 148 |
+
close=close,
|
| 149 |
+
entries=entries,
|
| 150 |
+
exits=exits,
|
| 151 |
+
init_cash=init_cash,
|
| 152 |
+
fees=fees,
|
| 153 |
+
freq=freq
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
stats = portfolio.stats()
|
| 157 |
+
trades = portfolio.trades.records_readable
|
| 158 |
+
equity_curve = portfolio.value()
|
| 159 |
+
returns = portfolio.returns()
|
| 160 |
+
drawdown = portfolio.drawdown()
|
| 161 |
+
|
| 162 |
+
return {
|
| 163 |
+
"stats": stats,
|
| 164 |
+
"trades": trades,
|
| 165 |
+
"equity_curve": equity_curve,
|
| 166 |
+
"returns": returns,
|
| 167 |
+
"drawdown": drawdown,
|
| 168 |
+
"middle": middle,
|
| 169 |
+
"upper": upper,
|
| 170 |
+
"lower": lower,
|
| 171 |
+
"entries": entries,
|
| 172 |
+
"exits": exits,
|
| 173 |
+
"label": f"Bollinger({window},{std})",
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
# ── Strategy Registry ────────────────────────────────────────────────
|
| 178 |
+
|
| 179 |
+
STRATEGIES = {
|
| 180 |
+
"sma": {
|
| 181 |
+
"fn": run_sma_crossover,
|
| 182 |
+
"name": "SMA Crossover",
|
| 183 |
+
"params": {"fast_window": 10, "slow_window": 30},
|
| 184 |
+
},
|
| 185 |
+
"ema": {
|
| 186 |
+
"fn": run_ema_crossover,
|
| 187 |
+
"name": "EMA Crossover",
|
| 188 |
+
"params": {"fast_window": 12, "slow_window": 26},
|
| 189 |
+
},
|
| 190 |
+
"bollinger": {
|
| 191 |
+
"fn": run_bollinger_bands,
|
| 192 |
+
"name": "Bollinger Bands",
|
| 193 |
+
"params": {"window": 20, "std": 2.0},
|
| 194 |
+
},
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def run_strategy(
|
| 199 |
+
strategy: str,
|
| 200 |
+
close: pd.Series,
|
| 201 |
+
**kwargs
|
| 202 |
+
) -> dict:
|
| 203 |
+
"""Run a named strategy with given parameters."""
|
| 204 |
+
if strategy not in STRATEGIES:
|
| 205 |
+
raise ValueError(f"Unknown strategy: {strategy}. Available: {list(STRATEGIES.keys())}")
|
| 206 |
+
return STRATEGIES[strategy]["fn"](close=close, **kwargs)
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def run_all_strategies(
|
| 210 |
+
close: pd.Series,
|
| 211 |
+
fees: float = 0.001,
|
| 212 |
+
init_cash: float = 10000.0,
|
| 213 |
+
freq: str = "1D"
|
| 214 |
+
) -> dict:
|
| 215 |
+
"""Run all strategies and return comparison results."""
|
| 216 |
+
results = {}
|
| 217 |
+
for name, cfg in STRATEGIES.items():
|
| 218 |
+
try:
|
| 219 |
+
result = cfg["fn"](
|
| 220 |
+
close=close,
|
| 221 |
+
fees=fees,
|
| 222 |
+
init_cash=init_cash,
|
| 223 |
+
freq=freq,
|
| 224 |
+
**cfg["params"]
|
| 225 |
+
)
|
| 226 |
+
stats = result["stats"]
|
| 227 |
+
results[name] = {
|
| 228 |
+
"label": result["label"],
|
| 229 |
+
"name": cfg["name"],
|
| 230 |
+
"stats": stats,
|
| 231 |
+
"equity_curve": result["equity_curve"],
|
| 232 |
+
"returns": result["returns"],
|
| 233 |
+
"drawdown": result["drawdown"],
|
| 234 |
+
"trades": result["trades"],
|
| 235 |
+
}
|
| 236 |
+
except Exception as e:
|
| 237 |
+
results[name] = {"label": cfg["name"], "error": str(e)}
|
| 238 |
+
return results
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
# ── Indicators ───────────────────────────────────────────────────────
|
| 242 |
+
|
| 243 |
+
def calculate_rsi(
|
| 244 |
+
close: pd.Series,
|
| 245 |
+
window: int = 14
|
| 246 |
+
) -> dict:
|
| 247 |
+
"""Calculate RSI indicator."""
|
| 248 |
+
rsi = vbt.RSI.run(close, window=window)
|
| 249 |
+
return {
|
| 250 |
+
"rsi": rsi.rsi,
|
| 251 |
+
"overbought": 70,
|
| 252 |
+
"oversold": 30,
|
| 253 |
+
"window": window,
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def calculate_macd(
|
| 258 |
+
close: pd.Series,
|
| 259 |
+
fast_window: int = 12,
|
| 260 |
+
slow_window: int = 26,
|
| 261 |
+
signal_window: int = 9
|
| 262 |
+
) -> dict:
|
| 263 |
+
"""Calculate MACD indicator."""
|
| 264 |
+
macd = vbt.MACD.run(
|
| 265 |
+
close,
|
| 266 |
+
fast_window=fast_window,
|
| 267 |
+
slow_window=slow_window,
|
| 268 |
+
signal_window=signal_window
|
| 269 |
+
)
|
| 270 |
+
return {
|
| 271 |
+
"macd": macd.macd,
|
| 272 |
+
"signal": macd.signal,
|
| 273 |
+
"histogram": macd.hist,
|
| 274 |
+
"fast_window": fast_window,
|
| 275 |
+
"slow_window": slow_window,
|
| 276 |
+
"signal_window": signal_window,
|
| 277 |
+
}
|
frontend/.dockerignore
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules/
|
| 2 |
+
.next/
|
| 3 |
+
.git/
|
| 4 |
+
*.log
|
| 5 |
+
.env.local
|
frontend/.gitignore
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
| 2 |
+
|
| 3 |
+
# dependencies
|
| 4 |
+
/node_modules
|
| 5 |
+
/.pnp
|
| 6 |
+
.pnp.*
|
| 7 |
+
.yarn/*
|
| 8 |
+
!.yarn/patches
|
| 9 |
+
!.yarn/plugins
|
| 10 |
+
!.yarn/releases
|
| 11 |
+
!.yarn/versions
|
| 12 |
+
|
| 13 |
+
# testing
|
| 14 |
+
/coverage
|
| 15 |
+
|
| 16 |
+
# next.js
|
| 17 |
+
/.next/
|
| 18 |
+
/out/
|
| 19 |
+
|
| 20 |
+
# production
|
| 21 |
+
/build
|
| 22 |
+
|
| 23 |
+
# misc
|
| 24 |
+
.DS_Store
|
| 25 |
+
*.pem
|
| 26 |
+
|
| 27 |
+
# debug
|
| 28 |
+
npm-debug.log*
|
| 29 |
+
yarn-debug.log*
|
| 30 |
+
yarn-error.log*
|
| 31 |
+
.pnpm-debug.log*
|
| 32 |
+
|
| 33 |
+
# env files
|
| 34 |
+
.env
|
| 35 |
+
.env.local
|
| 36 |
+
.env.*.local
|
| 37 |
+
.env.production
|
| 38 |
+
.env.development
|
| 39 |
+
|
| 40 |
+
# vercel
|
| 41 |
+
.vercel
|
| 42 |
+
|
| 43 |
+
# typescript
|
| 44 |
+
*.tsbuildinfo
|
| 45 |
+
next-env.d.ts
|
frontend/AGENTS.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!-- BEGIN:nextjs-agent-rules -->
|
| 2 |
+
# This is NOT the Next.js you know
|
| 3 |
+
|
| 4 |
+
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
| 5 |
+
<!-- END:nextjs-agent-rules -->
|
frontend/CLAUDE.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
@AGENTS.md
|
frontend/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
| 2 |
+
|
| 3 |
+
## Getting Started
|
| 4 |
+
|
| 5 |
+
First, run the development server:
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
npm run dev
|
| 9 |
+
# or
|
| 10 |
+
yarn dev
|
| 11 |
+
# or
|
| 12 |
+
pnpm dev
|
| 13 |
+
# or
|
| 14 |
+
bun dev
|
| 15 |
+
```
|
| 16 |
+
|
| 17 |
+
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
| 18 |
+
|
| 19 |
+
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
| 20 |
+
|
| 21 |
+
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
| 22 |
+
|
| 23 |
+
## Learn More
|
| 24 |
+
|
| 25 |
+
To learn more about Next.js, take a look at the following resources:
|
| 26 |
+
|
| 27 |
+
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
| 28 |
+
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
| 29 |
+
|
| 30 |
+
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
| 31 |
+
|
| 32 |
+
## Deploy on Vercel
|
| 33 |
+
|
| 34 |
+
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
| 35 |
+
|
| 36 |
+
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
frontend/app/age.tsx
ADDED
|
File without changes
|
frontend/app/api.ts
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
| 2 |
+
|
| 3 |
+
export interface BacktestRequest {
|
| 4 |
+
ticker: string;
|
| 5 |
+
start_date: string;
|
| 6 |
+
end_date: string;
|
| 7 |
+
interval?: string;
|
| 8 |
+
strategy?: string;
|
| 9 |
+
fast_ma?: number;
|
| 10 |
+
slow_ma?: number;
|
| 11 |
+
bb_window?: number;
|
| 12 |
+
bb_std?: number;
|
| 13 |
+
fees?: number;
|
| 14 |
+
init_cash?: number;
|
| 15 |
+
rsi_window?: number;
|
| 16 |
+
macd_fast?: number;
|
| 17 |
+
macd_slow?: number;
|
| 18 |
+
macd_signal?: number;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export interface StrategyInfo {
|
| 22 |
+
id: string;
|
| 23 |
+
name: string;
|
| 24 |
+
description: string;
|
| 25 |
+
params: Record<string, number>;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
export interface BacktestResponse {
|
| 29 |
+
ticker: string;
|
| 30 |
+
start_date: string;
|
| 31 |
+
end_date: string;
|
| 32 |
+
interval: string;
|
| 33 |
+
strategy: string;
|
| 34 |
+
data_points: number;
|
| 35 |
+
dates: string[];
|
| 36 |
+
ohlcv: {
|
| 37 |
+
open: number[];
|
| 38 |
+
high: number[];
|
| 39 |
+
low: number[];
|
| 40 |
+
close: number[];
|
| 41 |
+
volume: number[];
|
| 42 |
+
};
|
| 43 |
+
portfolio: {
|
| 44 |
+
stats: Record<string, unknown>;
|
| 45 |
+
trades: unknown[];
|
| 46 |
+
equity_curve: Record<string, number>;
|
| 47 |
+
returns: Record<string, number>;
|
| 48 |
+
drawdown: Record<string, number>;
|
| 49 |
+
};
|
| 50 |
+
indicators: {
|
| 51 |
+
sma?: {
|
| 52 |
+
fast: Record<string, number>;
|
| 53 |
+
slow: Record<string, number>;
|
| 54 |
+
fast_window: number;
|
| 55 |
+
slow_window: number;
|
| 56 |
+
};
|
| 57 |
+
ema?: {
|
| 58 |
+
fast: Record<string, number>;
|
| 59 |
+
slow: Record<string, number>;
|
| 60 |
+
fast_window: number;
|
| 61 |
+
slow_window: number;
|
| 62 |
+
};
|
| 63 |
+
bollinger?: {
|
| 64 |
+
middle: Record<string, number>;
|
| 65 |
+
upper: Record<string, number>;
|
| 66 |
+
lower: Record<string, number>;
|
| 67 |
+
window: number;
|
| 68 |
+
std: number;
|
| 69 |
+
};
|
| 70 |
+
rsi: {
|
| 71 |
+
rsi: Record<string, number>;
|
| 72 |
+
overbought: number;
|
| 73 |
+
oversold: number;
|
| 74 |
+
window: number;
|
| 75 |
+
};
|
| 76 |
+
macd: {
|
| 77 |
+
macd: Record<string, number>;
|
| 78 |
+
signal: Record<string, number>;
|
| 79 |
+
histogram: Record<string, number>;
|
| 80 |
+
fast_window: number;
|
| 81 |
+
slow_window: number;
|
| 82 |
+
signal_window: number;
|
| 83 |
+
};
|
| 84 |
+
};
|
| 85 |
+
signals: {
|
| 86 |
+
entries: Record<string, boolean>;
|
| 87 |
+
exits: Record<string, boolean>;
|
| 88 |
+
};
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
// ── Options Flow ──────────────────────────────────────────────────────
|
| 92 |
+
|
| 93 |
+
export interface OptionsFlowResponse {
|
| 94 |
+
ticker: string;
|
| 95 |
+
as_of: string;
|
| 96 |
+
has_leaps: boolean;
|
| 97 |
+
leaps_expiries: string[];
|
| 98 |
+
summary: {
|
| 99 |
+
total_call_volume: number;
|
| 100 |
+
total_put_volume: number;
|
| 101 |
+
call_put_ratio: number;
|
| 102 |
+
total_expiries: number;
|
| 103 |
+
nearest_call_put_ratio: number;
|
| 104 |
+
unusual_count: number;
|
| 105 |
+
};
|
| 106 |
+
all_expiries: OptionsChainExpiry[];
|
| 107 |
+
leaps: OptionsChainExpiry[];
|
| 108 |
+
unusual_activities: UnusualActivity[];
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
export interface OptionsChainExpiry {
|
| 112 |
+
expiry: string;
|
| 113 |
+
is_leaps: boolean;
|
| 114 |
+
total_call_volume: number;
|
| 115 |
+
total_put_volume: number;
|
| 116 |
+
total_call_oi: number;
|
| 117 |
+
total_put_oi: number;
|
| 118 |
+
call_put_ratio: number;
|
| 119 |
+
top_calls: OptionStrike[];
|
| 120 |
+
top_puts: OptionStrike[];
|
| 121 |
+
unusual_calls: OptionStrike[];
|
| 122 |
+
unusual_puts: OptionStrike[];
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
export interface OptionStrike {
|
| 126 |
+
strike: number;
|
| 127 |
+
lastPrice?: number;
|
| 128 |
+
volume?: number;
|
| 129 |
+
openInterest?: number;
|
| 130 |
+
vol_oi_ratio?: number;
|
| 131 |
+
impliedVolatility?: number;
|
| 132 |
+
inTheMoney?: boolean;
|
| 133 |
+
contractSymbol?: string;
|
| 134 |
+
contractSize?: number;
|
| 135 |
+
expiry?: string;
|
| 136 |
+
type?: "leaps_call" | "leaps_put";
|
| 137 |
+
signal?: "BULLISH" | "BEARISH";
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
export interface UnusualActivity extends OptionStrike {}
|
| 141 |
+
|
| 142 |
+
// ── LEAPS Board (22 ticker statik liste) ──────────────────────────────
|
| 143 |
+
|
| 144 |
+
export interface LeapsBoardEntry {
|
| 145 |
+
ticker: string;
|
| 146 |
+
has_leaps: boolean;
|
| 147 |
+
leaps_count: number;
|
| 148 |
+
total_call_volume: number;
|
| 149 |
+
total_put_volume: number;
|
| 150 |
+
call_put_ratio: number;
|
| 151 |
+
nearest_call_put_ratio: number;
|
| 152 |
+
unusual_count: number;
|
| 153 |
+
top_leaps_expiry: string | null;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
export interface LeapsBoardResponse {
|
| 157 |
+
count: number;
|
| 158 |
+
scan_tickers: string[];
|
| 159 |
+
results: LeapsBoardEntry[];
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
// ── LEAPS Scanner (S&P 100 concurrent tarama) ─────────────────────────
|
| 163 |
+
|
| 164 |
+
export interface LeapsScannerSpike {
|
| 165 |
+
type: string;
|
| 166 |
+
expiry: string;
|
| 167 |
+
strike: number;
|
| 168 |
+
volume: number;
|
| 169 |
+
openInterest: number;
|
| 170 |
+
vol_oi_ratio: number;
|
| 171 |
+
lastPrice?: number;
|
| 172 |
+
impliedVolatility?: number;
|
| 173 |
+
signal: string;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
export interface LeapsScannerEntry {
|
| 177 |
+
ticker: string;
|
| 178 |
+
total_leaps_call_vol: number;
|
| 179 |
+
total_leaps_put_vol: number;
|
| 180 |
+
total_leaps_vol: number;
|
| 181 |
+
call_put_ratio: number;
|
| 182 |
+
unusual_count: number;
|
| 183 |
+
top_spike: LeapsScannerSpike;
|
| 184 |
+
all_spikes: LeapsScannerSpike[];
|
| 185 |
+
leaps_expiries_count: number;
|
| 186 |
+
nearest_leaps_expiry: string | null;
|
| 187 |
+
scan_time: string;
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
export interface LeapsScannerResponse {
|
| 191 |
+
tickers: LeapsScannerEntry[];
|
| 192 |
+
count: number;
|
| 193 |
+
scan_info: {
|
| 194 |
+
max_tickers: number;
|
| 195 |
+
min_spikes: number;
|
| 196 |
+
scan_time_sec: number;
|
| 197 |
+
scanned_at: string;
|
| 198 |
+
};
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
// ── Fetch Functions ───────────────────────────────���───────────────────
|
| 202 |
+
|
| 203 |
+
export const DEFAULT_SCAN_TICKERS = [
|
| 204 |
+
"AAPL","MSFT","GOOGL","AMZN","NVDA","TSLA","META",
|
| 205 |
+
"SPY","QQQ","IWM","AMD","INTC","PLTR","COIN",
|
| 206 |
+
"JPM","BAC","GS","JNJ","PFE","UNH","XOM","CVX",
|
| 207 |
+
];
|
| 208 |
+
|
| 209 |
+
export async function fetchOptionsFlow(ticker: string): Promise<OptionsFlowResponse> {
|
| 210 |
+
const res = await fetch(`${API_BASE}/api/ticker/${encodeURIComponent(ticker)}/options-flow`);
|
| 211 |
+
if (!res.ok) {
|
| 212 |
+
const err = await res.json().catch(() => ({ detail: "Unknown error" }));
|
| 213 |
+
throw new Error(err.detail || `HTTP ${res.status}`);
|
| 214 |
+
}
|
| 215 |
+
return res.json();
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
export async function fetchLeapsBoard(limit: number = 15): Promise<LeapsBoardResponse> {
|
| 219 |
+
const res = await fetch(
|
| 220 |
+
`${API_BASE}/api/options-flow/leaps-board?limit=${encodeURIComponent(String(limit))}`
|
| 221 |
+
);
|
| 222 |
+
if (!res.ok) {
|
| 223 |
+
const err = await res.json().catch(() => ({ detail: "Unknown error" }));
|
| 224 |
+
throw new Error(err.detail || `HTTP ${res.status}`);
|
| 225 |
+
}
|
| 226 |
+
return res.json();
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
export async function fetchLeapsScanner(
|
| 230 |
+
max_tickers: number = 100,
|
| 231 |
+
min_spikes: number = 1,
|
| 232 |
+
): Promise<LeapsScannerResponse> {
|
| 233 |
+
const res = await fetch(
|
| 234 |
+
`${API_BASE}/api/leaps/scanner?max_tickers=${encodeURIComponent(String(max_tickers))}&min_spikes=${encodeURIComponent(String(min_spikes))}`
|
| 235 |
+
);
|
| 236 |
+
if (!res.ok) {
|
| 237 |
+
const err = await res.json().catch(() => ({ detail: "Unknown error" }));
|
| 238 |
+
throw new Error(err.detail || `HTTP ${res.status}`);
|
| 239 |
+
}
|
| 240 |
+
return res.json();
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
export interface CompareResponse {
|
| 244 |
+
ticker: string;
|
| 245 |
+
start_date: string;
|
| 246 |
+
end_date: string;
|
| 247 |
+
interval: string;
|
| 248 |
+
data_points: number;
|
| 249 |
+
dates: string[];
|
| 250 |
+
ohlcv: {
|
| 251 |
+
open: number[];
|
| 252 |
+
high: number[];
|
| 253 |
+
low: number[];
|
| 254 |
+
close: number[];
|
| 255 |
+
volume: number[];
|
| 256 |
+
};
|
| 257 |
+
indicators: {
|
| 258 |
+
rsi: { rsi: Record<string, number>; overbought: number; oversold: number; window: number };
|
| 259 |
+
macd: {
|
| 260 |
+
macd: Record<string, number>;
|
| 261 |
+
signal: Record<string, number>;
|
| 262 |
+
histogram: Record<string, number>;
|
| 263 |
+
fast_window: number;
|
| 264 |
+
slow_window: number;
|
| 265 |
+
signal_window: number;
|
| 266 |
+
};
|
| 267 |
+
};
|
| 268 |
+
comparison: Record<string, {
|
| 269 |
+
label: string;
|
| 270 |
+
name: string;
|
| 271 |
+
stats: Record<string, unknown>;
|
| 272 |
+
equity_curve: Record<string, number>;
|
| 273 |
+
returns: Record<string, number>;
|
| 274 |
+
drawdown: Record<string, number>;
|
| 275 |
+
trades: unknown[];
|
| 276 |
+
error?: string;
|
| 277 |
+
}>;
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
export async function fetchStrategies(): Promise<StrategyInfo[]> {
|
| 281 |
+
const res = await fetch(`${API_BASE}/api/strategies`);
|
| 282 |
+
if (!res.ok) throw new Error("Failed to load strategies");
|
| 283 |
+
const data = await res.json();
|
| 284 |
+
return data.strategies;
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
export async function runBacktest(request: BacktestRequest): Promise<BacktestResponse> {
|
| 288 |
+
const res = await fetch(`${API_BASE}/api/backtest`, {
|
| 289 |
+
method: "POST",
|
| 290 |
+
headers: { "Content-Type": "application/json" },
|
| 291 |
+
body: JSON.stringify(request),
|
| 292 |
+
});
|
| 293 |
+
|
| 294 |
+
if (!res.ok) {
|
| 295 |
+
const error = await res.json().catch(() => ({ detail: "Unknown error" }));
|
| 296 |
+
throw new Error(error.detail || `HTTP ${res.status}`);
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
return res.json();
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
export async function compareStrategies(request: BacktestRequest): Promise<CompareResponse> {
|
| 303 |
+
const res = await fetch(`${API_BASE}/api/compare`, {
|
| 304 |
+
method: "POST",
|
| 305 |
+
headers: { "Content-Type": "application/json" },
|
| 306 |
+
body: JSON.stringify(request),
|
| 307 |
+
});
|
| 308 |
+
|
| 309 |
+
if (!res.ok) {
|
| 310 |
+
const error = await res.json().catch(() => ({ detail: "Unknown error" }));
|
| 311 |
+
throw new Error(error.detail || `HTTP ${res.status}`);
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
return res.json();
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
export async function healthCheck(): Promise<{ status: string; version: string }> {
|
| 318 |
+
const res = await fetch(`${API_BASE}/api/health`);
|
| 319 |
+
if (!res.ok) throw new Error("Backend unavailable");
|
| 320 |
+
return res.json();
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
export async function fetchTickerRange(ticker: string): Promise<{ earliest: string; latest: string; data_points: number }> {
|
| 324 |
+
const res = await fetch(`${API_BASE}/api/ticker/${encodeURIComponent(ticker)}/range`);
|
| 325 |
+
if (!res.ok) throw new Error(`No data for ${ticker}`);
|
| 326 |
+
return res.json();
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
export interface FundamentalsData {
|
| 330 |
+
ticker: string;
|
| 331 |
+
company: { name?: string; sector?: string; industry?: string; summary?: string };
|
| 332 |
+
valuation: {
|
| 333 |
+
marketCap?: number; enterpriseValue?: number;
|
| 334 |
+
trailingPE?: number; forwardPE?: number;
|
| 335 |
+
priceToBook?: number; enterpriseToEbitda?: number; priceToSales?: number;
|
| 336 |
+
};
|
| 337 |
+
financials: {
|
| 338 |
+
revenuePerShare?: number; earningsPerShare?: number;
|
| 339 |
+
profitMargins?: number; operatingMargins?: number;
|
| 340 |
+
returnOnEquity?: number; debtToEquity?: number;
|
| 341 |
+
currentRatio?: number; freeCashflow?: number; revenueGrowth?: number;
|
| 342 |
+
};
|
| 343 |
+
trading: {
|
| 344 |
+
beta?: number; dividendYield?: number;
|
| 345 |
+
fiftyTwoWeekHigh?: number; fiftyTwoWeekLow?: number; averageVolume?: number;
|
| 346 |
+
};
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
export async function fetchFundamentals(ticker: string): Promise<FundamentalsData> {
|
| 350 |
+
const res = await fetch(`${API_BASE}/api/ticker/${encodeURIComponent(ticker)}/fundamentals`);
|
| 351 |
+
if (!res.ok) {
|
| 352 |
+
const err = await res.json().catch(() => ({ detail: "Unknown error" }));
|
| 353 |
+
throw new Error(err.detail || `HTTP ${res.status}`);
|
| 354 |
+
}
|
| 355 |
+
return res.json();
|
| 356 |
+
}
|
frontend/app/components/BacktestForm.tsx
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect, useRef } from "react";
|
| 4 |
+
import { fetchStrategies, fetchTickerRange, type StrategyInfo } from "../api";
|
| 5 |
+
|
| 6 |
+
interface BacktestFormProps {
|
| 7 |
+
onSubmit: (config: BacktestConfig) => void;
|
| 8 |
+
onCompare?: (config: BacktestConfig) => void;
|
| 9 |
+
onTickerChange?: (ticker: string) => void;
|
| 10 |
+
isLoading: boolean;
|
| 11 |
+
isComparing?: boolean;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export interface BacktestConfig {
|
| 15 |
+
ticker: string;
|
| 16 |
+
start_date: string;
|
| 17 |
+
end_date: string;
|
| 18 |
+
interval: string;
|
| 19 |
+
strategy: string;
|
| 20 |
+
fast_ma: number;
|
| 21 |
+
slow_ma: number;
|
| 22 |
+
bb_window: number;
|
| 23 |
+
bb_std: number;
|
| 24 |
+
fees: number;
|
| 25 |
+
init_cash: number;
|
| 26 |
+
rsi_window: number;
|
| 27 |
+
macd_fast: number;
|
| 28 |
+
macd_slow: number;
|
| 29 |
+
macd_signal: number;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function getDefaultDates() {
|
| 33 |
+
const end = new Date();
|
| 34 |
+
const start = new Date(end);
|
| 35 |
+
start.setFullYear(start.getFullYear() - 1);
|
| 36 |
+
const fmt = (d: Date) => d.toISOString().split("T")[0];
|
| 37 |
+
return { start: fmt(start), end: fmt(end) };
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
const defaults = getDefaultDates();
|
| 41 |
+
|
| 42 |
+
const defaultConfig: BacktestConfig = {
|
| 43 |
+
ticker: "",
|
| 44 |
+
start_date: defaults.start,
|
| 45 |
+
end_date: defaults.end,
|
| 46 |
+
interval: "1d",
|
| 47 |
+
strategy: "sma",
|
| 48 |
+
fast_ma: 10,
|
| 49 |
+
slow_ma: 30,
|
| 50 |
+
bb_window: 20,
|
| 51 |
+
bb_std: 2.0,
|
| 52 |
+
fees: 0.001,
|
| 53 |
+
init_cash: 10000,
|
| 54 |
+
rsi_window: 14,
|
| 55 |
+
macd_fast: 12,
|
| 56 |
+
macd_slow: 26,
|
| 57 |
+
macd_signal: 9,
|
| 58 |
+
};
|
| 59 |
+
|
| 60 |
+
export default function BacktestForm({ onSubmit, onCompare, onTickerChange, isLoading, isComparing }: BacktestFormProps) {
|
| 61 |
+
const [config, setConfig] = useState<BacktestConfig>(defaultConfig);
|
| 62 |
+
const [strategies, setStrategies] = useState<StrategyInfo[]>([]);
|
| 63 |
+
|
| 64 |
+
useEffect(() => {
|
| 65 |
+
fetchStrategies()
|
| 66 |
+
.then(setStrategies)
|
| 67 |
+
.catch(() => setStrategies([
|
| 68 |
+
{ id: "sma", name: "SMA Crossover", description: "", params: {} },
|
| 69 |
+
{ id: "ema", name: "EMA Crossover", description: "", params: {} },
|
| 70 |
+
{ id: "bollinger", name: "Bollinger Bands", description: "", params: {} },
|
| 71 |
+
]));
|
| 72 |
+
}, []);
|
| 73 |
+
|
| 74 |
+
// Auto-fetch full available date range when ticker changes
|
| 75 |
+
const [loadingRange, setLoadingRange] = useState(false);
|
| 76 |
+
const tickerRef = useRef(config.ticker);
|
| 77 |
+
useEffect(() => {
|
| 78 |
+
const t = config.ticker.trim();
|
| 79 |
+
if (!t || t === tickerRef.current) return;
|
| 80 |
+
tickerRef.current = t;
|
| 81 |
+
const timer = setTimeout(() => {
|
| 82 |
+
setLoadingRange(true);
|
| 83 |
+
fetchTickerRange(t)
|
| 84 |
+
.then((range) => {
|
| 85 |
+
setConfig((prev) => ({
|
| 86 |
+
...prev,
|
| 87 |
+
start_date: range.earliest.split("T")[0],
|
| 88 |
+
end_date: range.latest.split("T")[0],
|
| 89 |
+
}));
|
| 90 |
+
})
|
| 91 |
+
.catch(() => {})
|
| 92 |
+
.finally(() => setLoadingRange(false));
|
| 93 |
+
}, 600);
|
| 94 |
+
return () => clearTimeout(timer);
|
| 95 |
+
}, [config.ticker]);
|
| 96 |
+
|
| 97 |
+
const setFullRange = async () => {
|
| 98 |
+
const t = config.ticker.trim();
|
| 99 |
+
if (!t) return;
|
| 100 |
+
setLoadingRange(true);
|
| 101 |
+
try {
|
| 102 |
+
const range = await fetchTickerRange(t);
|
| 103 |
+
setConfig((prev) => ({
|
| 104 |
+
...prev,
|
| 105 |
+
start_date: range.earliest.split("T")[0],
|
| 106 |
+
end_date: range.latest.split("T")[0],
|
| 107 |
+
}));
|
| 108 |
+
} catch {}
|
| 109 |
+
setLoadingRange(false);
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
const handleChange = (field: keyof BacktestConfig, value: string | number) => {
|
| 113 |
+
setConfig((prev) => ({ ...prev, [field]: value }));
|
| 114 |
+
};
|
| 115 |
+
|
| 116 |
+
const handleSubmit = (e: React.FormEvent) => {
|
| 117 |
+
e.preventDefault();
|
| 118 |
+
onSubmit(config);
|
| 119 |
+
};
|
| 120 |
+
|
| 121 |
+
const handleCompare = () => {
|
| 122 |
+
if (onCompare) onCompare(config);
|
| 123 |
+
};
|
| 124 |
+
|
| 125 |
+
const inputClass =
|
| 126 |
+
"w-full px-3 py-2 rounded text-sm outline-none transition-colors focus:border-[var(--accent-orange)]";
|
| 127 |
+
const labelClass = "text-xs text-[var(--text-secondary)] uppercase tracking-wider mb-1 block";
|
| 128 |
+
|
| 129 |
+
const isBollinger = config.strategy === "bollinger";
|
| 130 |
+
|
| 131 |
+
return (
|
| 132 |
+
<form onSubmit={handleSubmit} className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-5">
|
| 133 |
+
<div className="flex items-center gap-2 mb-4">
|
| 134 |
+
<span className="text-[var(--accent-orange)]">$</span>
|
| 135 |
+
<h2 className="text-sm font-bold uppercase tracking-wider">Backtest Configuration</h2>
|
| 136 |
+
</div>
|
| 137 |
+
|
| 138 |
+
{/* Row 1: Ticker + Interval */}
|
| 139 |
+
<div className="grid grid-cols-2 gap-4 mb-4">
|
| 140 |
+
<div>
|
| 141 |
+
<label className={labelClass}>Ticker Symbol</label>
|
| 142 |
+
<input
|
| 143 |
+
type="text"
|
| 144 |
+
value={config.ticker}
|
| 145 |
+
onChange={(e) => {
|
| 146 |
+
handleChange("ticker", e.target.value.toUpperCase());
|
| 147 |
+
onTickerChange?.(e.target.value.toUpperCase());
|
| 148 |
+
}}
|
| 149 |
+
className={inputClass}
|
| 150 |
+
placeholder="e.g. AAPL, TSLA, BTC-USD"
|
| 151 |
+
/>
|
| 152 |
+
</div>
|
| 153 |
+
<div>
|
| 154 |
+
<label className={labelClass}>Interval</label>
|
| 155 |
+
<select
|
| 156 |
+
value={config.interval}
|
| 157 |
+
onChange={(e) => handleChange("interval", e.target.value)}
|
| 158 |
+
className={inputClass}
|
| 159 |
+
>
|
| 160 |
+
<option value="1m">1 Minute</option>
|
| 161 |
+
<option value="5m">5 Minutes</option>
|
| 162 |
+
<option value="15m">15 Minutes</option>
|
| 163 |
+
<option value="1h">1 Hour</option>
|
| 164 |
+
<option value="1d">1 Day</option>
|
| 165 |
+
<option value="1wk">1 Week</option>
|
| 166 |
+
<option value="1mo">1 Month</option>
|
| 167 |
+
</select>
|
| 168 |
+
</div>
|
| 169 |
+
</div>
|
| 170 |
+
|
| 171 |
+
{/* Row 2: Date Range */}
|
| 172 |
+
<div className="grid grid-cols-2 gap-4 mb-4">
|
| 173 |
+
<div>
|
| 174 |
+
<div className="flex items-center justify-between mb-1">
|
| 175 |
+
<label className={labelClass}>Start Date</label>
|
| 176 |
+
<button
|
| 177 |
+
type="button"
|
| 178 |
+
onClick={setFullRange}
|
| 179 |
+
disabled={loadingRange}
|
| 180 |
+
className="text-[10px] text-[var(--accent-orange)] hover:underline uppercase tracking-wider disabled:opacity-40"
|
| 181 |
+
>
|
| 182 |
+
{loadingRange ? "..." : "All Time"}
|
| 183 |
+
</button>
|
| 184 |
+
</div>
|
| 185 |
+
<input
|
| 186 |
+
type="date"
|
| 187 |
+
value={config.start_date}
|
| 188 |
+
onChange={(e) => handleChange("start_date", e.target.value)}
|
| 189 |
+
className={inputClass}
|
| 190 |
+
/>
|
| 191 |
+
</div>
|
| 192 |
+
<div>
|
| 193 |
+
<label className={labelClass}>End Date</label>
|
| 194 |
+
<input
|
| 195 |
+
type="date"
|
| 196 |
+
value={config.end_date}
|
| 197 |
+
onChange={(e) => handleChange("end_date", e.target.value)}
|
| 198 |
+
className={inputClass}
|
| 199 |
+
/>
|
| 200 |
+
</div>
|
| 201 |
+
</div>
|
| 202 |
+
|
| 203 |
+
{/* Row 3: Strategy Select */}
|
| 204 |
+
<div className="border-t border-[var(--border-color)] pt-4 mt-4">
|
| 205 |
+
<h3 className="text-xs text-[var(--accent-orange)] uppercase tracking-wider mb-3">
|
| 206 |
+
Strategy
|
| 207 |
+
</h3>
|
| 208 |
+
<div className="grid grid-cols-3 gap-2 mb-4">
|
| 209 |
+
{strategies.map((s) => (
|
| 210 |
+
<button
|
| 211 |
+
key={s.id}
|
| 212 |
+
type="button"
|
| 213 |
+
onClick={() => handleChange("strategy", s.id)}
|
| 214 |
+
className={`px-3 py-2 rounded text-xs font-bold uppercase tracking-wider transition-colors ${
|
| 215 |
+
config.strategy === s.id
|
| 216 |
+
? "bg-[var(--accent-orange)] text-black"
|
| 217 |
+
: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--border-color)]"
|
| 218 |
+
}`}
|
| 219 |
+
>
|
| 220 |
+
{s.name.split(" ")[0]}
|
| 221 |
+
</button>
|
| 222 |
+
))}
|
| 223 |
+
</div>
|
| 224 |
+
<div className="text-[10px] text-[var(--text-muted)] mb-4 italic">
|
| 225 |
+
{strategies.find((s) => s.id === config.strategy)?.description || ""}
|
| 226 |
+
</div>
|
| 227 |
+
</div>
|
| 228 |
+
|
| 229 |
+
{/* Row 4: Strategy Parameters */}
|
| 230 |
+
<div className="border-t border-[var(--border-color)] pt-4">
|
| 231 |
+
<h3 className="text-xs text-[var(--accent-orange)] uppercase tracking-wider mb-3">
|
| 232 |
+
{isBollinger ? "Bollinger Bands" : "MA Parameters"}
|
| 233 |
+
</h3>
|
| 234 |
+
{isBollinger ? (
|
| 235 |
+
<div className="grid grid-cols-2 gap-4 mb-4">
|
| 236 |
+
<div>
|
| 237 |
+
<label className={labelClass}>Window</label>
|
| 238 |
+
<input
|
| 239 |
+
type="number"
|
| 240 |
+
value={config.bb_window}
|
| 241 |
+
onChange={(e) => handleChange("bb_window", parseInt(e.target.value) || 2)}
|
| 242 |
+
min={2}
|
| 243 |
+
max={200}
|
| 244 |
+
className={inputClass}
|
| 245 |
+
/>
|
| 246 |
+
</div>
|
| 247 |
+
<div>
|
| 248 |
+
<label className={labelClass}>Std Dev</label>
|
| 249 |
+
<input
|
| 250 |
+
type="number"
|
| 251 |
+
value={config.bb_std}
|
| 252 |
+
onChange={(e) => handleChange("bb_std", parseFloat(e.target.value) || 0.5)}
|
| 253 |
+
min={0.5}
|
| 254 |
+
max={4}
|
| 255 |
+
step={0.1}
|
| 256 |
+
className={inputClass}
|
| 257 |
+
/>
|
| 258 |
+
</div>
|
| 259 |
+
</div>
|
| 260 |
+
) : (
|
| 261 |
+
<div className="grid grid-cols-2 gap-4 mb-4">
|
| 262 |
+
<div>
|
| 263 |
+
<label className={labelClass}>Fast MA Period</label>
|
| 264 |
+
<input
|
| 265 |
+
type="number"
|
| 266 |
+
value={config.fast_ma}
|
| 267 |
+
onChange={(e) => handleChange("fast_ma", parseInt(e.target.value) || 2)}
|
| 268 |
+
min={2}
|
| 269 |
+
max={200}
|
| 270 |
+
className={inputClass}
|
| 271 |
+
/>
|
| 272 |
+
</div>
|
| 273 |
+
<div>
|
| 274 |
+
<label className={labelClass}>Slow MA Period</label>
|
| 275 |
+
<input
|
| 276 |
+
type="number"
|
| 277 |
+
value={config.slow_ma}
|
| 278 |
+
onChange={(e) => handleChange("slow_ma", parseInt(e.target.value) || 2)}
|
| 279 |
+
min={2}
|
| 280 |
+
max={500}
|
| 281 |
+
className={inputClass}
|
| 282 |
+
/>
|
| 283 |
+
</div>
|
| 284 |
+
</div>
|
| 285 |
+
)}
|
| 286 |
+
</div>
|
| 287 |
+
|
| 288 |
+
{/* Row 5: Indicator Settings */}
|
| 289 |
+
<div className="border-t border-[var(--border-color)] pt-4">
|
| 290 |
+
<h3 className="text-xs text-[var(--accent-orange)] uppercase tracking-wider mb-3">
|
| 291 |
+
Indicators
|
| 292 |
+
</h3>
|
| 293 |
+
<div className="grid grid-cols-4 gap-3 mb-4">
|
| 294 |
+
<div>
|
| 295 |
+
<label className={labelClass}>RSI Period</label>
|
| 296 |
+
<input
|
| 297 |
+
type="number"
|
| 298 |
+
value={config.rsi_window}
|
| 299 |
+
onChange={(e) => handleChange("rsi_window", parseInt(e.target.value) || 2)}
|
| 300 |
+
min={2}
|
| 301 |
+
max={100}
|
| 302 |
+
className={inputClass}
|
| 303 |
+
/>
|
| 304 |
+
</div>
|
| 305 |
+
<div>
|
| 306 |
+
<label className={labelClass}>MACD Fast</label>
|
| 307 |
+
<input
|
| 308 |
+
type="number"
|
| 309 |
+
value={config.macd_fast}
|
| 310 |
+
onChange={(e) => handleChange("macd_fast", parseInt(e.target.value) || 2)}
|
| 311 |
+
min={2}
|
| 312 |
+
max={100}
|
| 313 |
+
className={inputClass}
|
| 314 |
+
/>
|
| 315 |
+
</div>
|
| 316 |
+
<div>
|
| 317 |
+
<label className={labelClass}>MACD Slow</label>
|
| 318 |
+
<input
|
| 319 |
+
type="number"
|
| 320 |
+
value={config.macd_slow}
|
| 321 |
+
onChange={(e) => handleChange("macd_slow", parseInt(e.target.value) || 2)}
|
| 322 |
+
min={2}
|
| 323 |
+
max={200}
|
| 324 |
+
className={inputClass}
|
| 325 |
+
/>
|
| 326 |
+
</div>
|
| 327 |
+
<div>
|
| 328 |
+
<label className={labelClass}>MACD Signal</label>
|
| 329 |
+
<input
|
| 330 |
+
type="number"
|
| 331 |
+
value={config.macd_signal}
|
| 332 |
+
onChange={(e) => handleChange("macd_signal", parseInt(e.target.value) || 2)}
|
| 333 |
+
min={2}
|
| 334 |
+
max={100}
|
| 335 |
+
className={inputClass}
|
| 336 |
+
/>
|
| 337 |
+
</div>
|
| 338 |
+
</div>
|
| 339 |
+
</div>
|
| 340 |
+
|
| 341 |
+
{/* Submit */}
|
| 342 |
+
<div className="flex gap-2">
|
| 343 |
+
<button
|
| 344 |
+
type="submit"
|
| 345 |
+
disabled={isLoading || isComparing}
|
| 346 |
+
className="flex-1 px-4 py-3 bg-[var(--accent-orange)] hover:bg-[var(--accent-orange-hover)] disabled:opacity-50 disabled:cursor-not-allowed text-black font-bold text-sm uppercase tracking-wider rounded transition-colors glow-orange"
|
| 347 |
+
>
|
| 348 |
+
{isLoading ? (
|
| 349 |
+
<span className="flex items-center justify-center gap-2">
|
| 350 |
+
<span className="inline-block w-4 h-4 border-2 border-black border-t-transparent rounded-full animate-spin" />
|
| 351 |
+
Running...
|
| 352 |
+
</span>
|
| 353 |
+
) : (
|
| 354 |
+
<span className="flex items-center justify-center gap-2">
|
| 355 |
+
<span>{">"}</span> Execute
|
| 356 |
+
</span>
|
| 357 |
+
)}
|
| 358 |
+
</button>
|
| 359 |
+
{onCompare && (
|
| 360 |
+
<button
|
| 361 |
+
type="button"
|
| 362 |
+
onClick={handleCompare}
|
| 363 |
+
disabled={isLoading || isComparing}
|
| 364 |
+
className="px-4 py-3 bg-[var(--bg-tertiary)] hover:bg-[var(--border-color)] disabled:opacity-50 disabled:cursor-not-allowed text-[var(--text-secondary)] font-bold text-sm uppercase tracking-wider rounded transition-colors"
|
| 365 |
+
>
|
| 366 |
+
{isComparing ? (
|
| 367 |
+
<span className="inline-block w-4 h-4 border-2 border-[var(--text-secondary)] border-t-transparent rounded-full animate-spin" />
|
| 368 |
+
) : (
|
| 369 |
+
"Compare All"
|
| 370 |
+
)}
|
| 371 |
+
</button>
|
| 372 |
+
)}
|
| 373 |
+
</div>
|
| 374 |
+
</form>
|
| 375 |
+
);
|
| 376 |
+
}
|
frontend/app/components/CandlestickChart.tsx
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import dynamic from "next/dynamic";
|
| 4 |
+
|
| 5 |
+
const Plot = dynamic(() => import("react-plotly.js"), { ssr: false });
|
| 6 |
+
|
| 7 |
+
interface CandlestickChartProps {
|
| 8 |
+
dates: string[];
|
| 9 |
+
open: number[];
|
| 10 |
+
high: number[];
|
| 11 |
+
low: number[];
|
| 12 |
+
close: number[];
|
| 13 |
+
ticker: string;
|
| 14 |
+
/** Strategy-specific overlay lines e.g. SMA/EMA/BB */
|
| 15 |
+
overlays?: { name: string; values: (number | null)[]; color: string; dash?: string }[];
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export default function CandlestickChart({
|
| 19 |
+
dates,
|
| 20 |
+
open,
|
| 21 |
+
high,
|
| 22 |
+
low,
|
| 23 |
+
close,
|
| 24 |
+
ticker,
|
| 25 |
+
overlays,
|
| 26 |
+
}: CandlestickChartProps) {
|
| 27 |
+
const layout = {
|
| 28 |
+
paper_bgcolor: "rgba(0,0,0,0)",
|
| 29 |
+
plot_bgcolor: "rgba(0,0,0,0)",
|
| 30 |
+
font: { color: "#a0a0a0", family: "JetBrains Mono, monospace", size: 10 },
|
| 31 |
+
margin: { t: 30, r: 20, b: 40, l: 60 },
|
| 32 |
+
xaxis: {
|
| 33 |
+
gridcolor: "#2a2a2a",
|
| 34 |
+
zerolinecolor: "#2a2a2a",
|
| 35 |
+
showgrid: true,
|
| 36 |
+
rangeslider: { visible: false },
|
| 37 |
+
},
|
| 38 |
+
yaxis: {
|
| 39 |
+
gridcolor: "#2a2a2a",
|
| 40 |
+
zerolinecolor: "#2a2a2a",
|
| 41 |
+
showgrid: true,
|
| 42 |
+
title: "Price ($)",
|
| 43 |
+
titlefont: { size: 10 },
|
| 44 |
+
},
|
| 45 |
+
legend: {
|
| 46 |
+
font: { size: 10 },
|
| 47 |
+
bgcolor: "rgba(0,0,0,0)",
|
| 48 |
+
bordercolor: "#2a2a2a",
|
| 49 |
+
borderwidth: 1,
|
| 50 |
+
},
|
| 51 |
+
hovermode: "x unified" as const,
|
| 52 |
+
dragmode: "pan" as const,
|
| 53 |
+
title: {
|
| 54 |
+
text: `${ticker} — Price Chart`,
|
| 55 |
+
font: { size: 13, color: "#ffffff" },
|
| 56 |
+
},
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
const config = { responsive: true, displayModeBar: true, displaylogo: false, scrollZoom: true, modeBarButtonsToRemove: ['sendDataToCloud', 'lasso2d', 'select2d', 'resetScale2d'], modeBarButtons: [['zoom2d', 'pan2d', 'zoomIn2d', 'zoomOut2d', 'autoScale2d', 'resetViews']] };
|
| 60 |
+
|
| 61 |
+
// Build the data array
|
| 62 |
+
const data: unknown[] = [
|
| 63 |
+
{
|
| 64 |
+
x: dates,
|
| 65 |
+
open: open,
|
| 66 |
+
high: high,
|
| 67 |
+
low: low,
|
| 68 |
+
close: close,
|
| 69 |
+
type: "candlestick",
|
| 70 |
+
name: "OHLC",
|
| 71 |
+
increasing: { line: { color: "#10b981" }, fillcolor: "#10b981" },
|
| 72 |
+
decreasing: { line: { color: "#ef4444" }, fillcolor: "#ef4444" },
|
| 73 |
+
},
|
| 74 |
+
];
|
| 75 |
+
|
| 76 |
+
// Add overlay lines
|
| 77 |
+
if (overlays) {
|
| 78 |
+
overlays.forEach((line) => {
|
| 79 |
+
data.push({
|
| 80 |
+
x: dates,
|
| 81 |
+
y: line.values,
|
| 82 |
+
type: "scatter",
|
| 83 |
+
mode: "lines",
|
| 84 |
+
name: line.name,
|
| 85 |
+
line: { color: line.color, width: 1.5, dash: line.dash || "solid" as const },
|
| 86 |
+
});
|
| 87 |
+
});
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
// Add entry/exit markers if we could compute them
|
| 91 |
+
// (They are passed separately in PriceChart for signal visibility)
|
| 92 |
+
|
| 93 |
+
return (
|
| 94 |
+
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-4">
|
| 95 |
+
<Plot data={data} layout={layout} config={config} style={{ width: "100%", height: "450px" }} />
|
| 96 |
+
</div>
|
| 97 |
+
);
|
| 98 |
+
}
|
frontend/app/components/ComparePanel.tsx
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import dynamic from "next/dynamic";
|
| 4 |
+
|
| 5 |
+
const Plot = dynamic(() => import("react-plotly.js"), { ssr: false });
|
| 6 |
+
|
| 7 |
+
interface ComparePanelProps {
|
| 8 |
+
comparison: Record<string, {
|
| 9 |
+
label: string;
|
| 10 |
+
name: string;
|
| 11 |
+
stats: Record<string, unknown>;
|
| 12 |
+
equity_curve: Record<string, number>;
|
| 13 |
+
error?: string;
|
| 14 |
+
}>;
|
| 15 |
+
dates: string[];
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export default function ComparePanel({ comparison, dates }: ComparePanelProps) {
|
| 19 |
+
const entries = Object.entries(comparison).filter(([, v]) => !v.error);
|
| 20 |
+
const errors = Object.entries(comparison).filter(([, v]) => v.error);
|
| 21 |
+
|
| 22 |
+
if (entries.length === 0) {
|
| 23 |
+
return (
|
| 24 |
+
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-5">
|
| 25 |
+
<div className="text-center py-8 text-[var(--text-muted)] text-sm">
|
| 26 |
+
No strategies to compare
|
| 27 |
+
</div>
|
| 28 |
+
</div>
|
| 29 |
+
);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
// Equity curve comparison chart
|
| 33 |
+
const equityData = entries.map(([key, val]) => {
|
| 34 |
+
const curve = val.equity_curve || {};
|
| 35 |
+
const values = dates.map((d) => {
|
| 36 |
+
const v = (curve as Record<string, number>)[d];
|
| 37 |
+
return v !== undefined ? v : null;
|
| 38 |
+
});
|
| 39 |
+
return {
|
| 40 |
+
x: dates,
|
| 41 |
+
y: values,
|
| 42 |
+
type: "scatter",
|
| 43 |
+
mode: "lines",
|
| 44 |
+
name: val.label || key,
|
| 45 |
+
line: { width: 1.5 },
|
| 46 |
+
};
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
+
const equityLayout = {
|
| 50 |
+
paper_bgcolor: "rgba(0,0,0,0)",
|
| 51 |
+
plot_bgcolor: "rgba(0,0,0,0)",
|
| 52 |
+
font: { color: "#a0a0a0", family: "JetBrains Mono, monospace", size: 10 },
|
| 53 |
+
margin: { t: 30, r: 20, b: 40, l: 60 },
|
| 54 |
+
xaxis: { gridcolor: "#2a2a2a", zerolinecolor: "#2a2a2a", showgrid: true },
|
| 55 |
+
yaxis: { gridcolor: "#2a2a2a", zerolinecolor: "#2a2a2a", showgrid: true, titlefont: { size: 10 } },
|
| 56 |
+
hovermode: "x unified" as const,
|
| 57 |
+
dragmode: "pan" as const,
|
| 58 |
+
title: { text: "Strategy Comparison — Equity Curve", font: { size: 12, color: "#ffffff" } },
|
| 59 |
+
legend: { font: { size: 10 }, bgcolor: "rgba(0,0,0,0)", bordercolor: "#2a2a2a", borderwidth: 1 },
|
| 60 |
+
};
|
| 61 |
+
|
| 62 |
+
const colors = ["#f59e0b", "#10b981", "#3b82f6", "#a78bfa", "#ef4444"];
|
| 63 |
+
equityData.forEach((d: unknown, i: number) => {
|
| 64 |
+
(d as Record<string, unknown>).line = { ...(d as Record<string, unknown>).line as object, color: colors[i % colors.length] };
|
| 65 |
+
});
|
| 66 |
+
|
| 67 |
+
const config = { responsive: true, displayModeBar: true, displaylogo: false, modeBarButtonsToRemove: ['sendDataToCloud', 'lasso2d', 'select2d'], modeBarButtons: [['zoom2d', 'pan2d', 'zoomIn2d', 'zoomOut2d', 'autoScale2d', 'resetViews']] };
|
| 68 |
+
|
| 69 |
+
// Key metrics comparison table
|
| 70 |
+
const metricKeys = ["Total Return [%]", "End Value", "Sharpe Ratio", "Max Drawdown [%]", "Total Trades", "Win Rate [%]"];
|
| 71 |
+
|
| 72 |
+
return (
|
| 73 |
+
<div className="space-y-6">
|
| 74 |
+
{/* Equity curve comparison */}
|
| 75 |
+
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-4">
|
| 76 |
+
<Plot data={equityData} layout={equityLayout} config={config} style={{ width: "100%", height: "300px" }} />
|
| 77 |
+
</div>
|
| 78 |
+
|
| 79 |
+
{/* Comparison table */}
|
| 80 |
+
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-5">
|
| 81 |
+
<div className="flex items-center gap-2 mb-4">
|
| 82 |
+
<span className="text-[var(--accent-orange)]">#</span>
|
| 83 |
+
<h2 className="text-sm font-bold uppercase tracking-wider">Strategy Comparison</h2>
|
| 84 |
+
</div>
|
| 85 |
+
<div className="overflow-x-auto">
|
| 86 |
+
<table className="w-full text-xs">
|
| 87 |
+
<thead className="text-[var(--text-muted)] uppercase tracking-wider">
|
| 88 |
+
<tr>
|
| 89 |
+
<th className="text-left py-2 px-3">Metric</th>
|
| 90 |
+
{entries.map(([key, val]) => (
|
| 91 |
+
<th key={key} className="text-right py-2 px-3">{val.label || key}</th>
|
| 92 |
+
))}
|
| 93 |
+
</tr>
|
| 94 |
+
</thead>
|
| 95 |
+
<tbody>
|
| 96 |
+
{metricKeys.map((metric) => (
|
| 97 |
+
<tr key={metric} className="border-t border-[var(--border-color)]">
|
| 98 |
+
<td className="py-2 px-3 text-[var(--text-secondary)]">{metric}</td>
|
| 99 |
+
{entries.map(([key, val]) => {
|
| 100 |
+
const raw = val.stats?.[metric];
|
| 101 |
+
const display = raw != null ? String(raw) : "N/A";
|
| 102 |
+
return (
|
| 103 |
+
<td key={key} className="py-2 px-3 text-right font-bold">{display}</td>
|
| 104 |
+
);
|
| 105 |
+
})}
|
| 106 |
+
</tr>
|
| 107 |
+
))}
|
| 108 |
+
</tbody>
|
| 109 |
+
</table>
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
|
| 113 |
+
{/* Errors */}
|
| 114 |
+
{errors.length > 0 && (
|
| 115 |
+
<div className="bg-red-950/30 border border-red-800 rounded-lg p-4">
|
| 116 |
+
<div className="flex items-center gap-2 text-red-400 text-sm">[ERROR] Some strategies failed</div>
|
| 117 |
+
{errors.map(([key, val]) => (
|
| 118 |
+
<div key={key} className="text-xs text-red-300 mt-1">
|
| 119 |
+
{key}: {val.error}
|
| 120 |
+
</div>
|
| 121 |
+
))}
|
| 122 |
+
</div>
|
| 123 |
+
)}
|
| 124 |
+
</div>
|
| 125 |
+
);
|
| 126 |
+
}
|
frontend/app/components/DrawdownChart.tsx
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import dynamic from "next/dynamic";
|
| 4 |
+
|
| 5 |
+
const Plot = dynamic(() => import("react-plotly.js"), { ssr: false });
|
| 6 |
+
|
| 7 |
+
interface DrawdownChartProps {
|
| 8 |
+
dates: string[];
|
| 9 |
+
drawdown: (number | null)[];
|
| 10 |
+
maxDrawdown?: number;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export default function DrawdownChart({ dates, drawdown, maxDrawdown }: DrawdownChartProps) {
|
| 14 |
+
const layout = {
|
| 15 |
+
paper_bgcolor: "rgba(0,0,0,0)",
|
| 16 |
+
plot_bgcolor: "rgba(0,0,0,0)",
|
| 17 |
+
font: { color: "#a0a0a0", family: "JetBrains Mono, monospace", size: 10 },
|
| 18 |
+
margin: { t: 35, r: 60, b: 45, l: 65 },
|
| 19 |
+
xaxis: {
|
| 20 |
+
gridcolor: "#2a2a2a",
|
| 21 |
+
zerolinecolor: "#2a2a2a",
|
| 22 |
+
showgrid: true,
|
| 23 |
+
},
|
| 24 |
+
yaxis: {
|
| 25 |
+
gridcolor: "#2a2a2a",
|
| 26 |
+
zerolinecolor: "#2a2a2a",
|
| 27 |
+
showgrid: true,
|
| 28 |
+
title: "Drawdown",
|
| 29 |
+
titlefont: { size: 10 },
|
| 30 |
+
tickformat: ".1%",
|
| 31 |
+
range: [Math.min(...drawdown.filter((v): v is number => v !== null)) * 1.15, 0.01],
|
| 32 |
+
},
|
| 33 |
+
hovermode: "x unified" as const,
|
| 34 |
+
dragmode: "pan" as const,
|
| 35 |
+
showlegend: false,
|
| 36 |
+
title: {
|
| 37 |
+
text: maxDrawdown
|
| 38 |
+
? `Drawdown — Max: ${(maxDrawdown * 100).toFixed(2)}%`
|
| 39 |
+
: "Drawdown",
|
| 40 |
+
font: { size: 12, color: "#ffffff" },
|
| 41 |
+
},
|
| 42 |
+
annotations: maxDrawdown
|
| 43 |
+
? [
|
| 44 |
+
{
|
| 45 |
+
x: dates[0],
|
| 46 |
+
y: maxDrawdown,
|
| 47 |
+
xref: "x" as const,
|
| 48 |
+
yref: "y" as const,
|
| 49 |
+
text: `Max DD ${(maxDrawdown * 100).toFixed(2)}%`,
|
| 50 |
+
showarrow: true,
|
| 51 |
+
arrowhead: 2,
|
| 52 |
+
arrowcolor: "#ef4444",
|
| 53 |
+
arrowsize: 1,
|
| 54 |
+
ax: 0,
|
| 55 |
+
ay: -30,
|
| 56 |
+
font: { color: "#ef4444", size: 9 },
|
| 57 |
+
bgcolor: "rgba(0,0,0,0.6)",
|
| 58 |
+
bordercolor: "#ef4444",
|
| 59 |
+
borderwidth: 1,
|
| 60 |
+
borderpad: 3,
|
| 61 |
+
},
|
| 62 |
+
]
|
| 63 |
+
: undefined,
|
| 64 |
+
};
|
| 65 |
+
|
| 66 |
+
const config = {
|
| 67 |
+
responsive: true,
|
| 68 |
+
displayModeBar: true,
|
| 69 |
+
displaylogo: false,
|
| 70 |
+
modeBarButtonsToRemove: ["sendDataToCloud", "lasso2d", "select2d"],
|
| 71 |
+
modeBarButtons: [["zoom2d", "pan2d", "zoomIn2d", "zoomOut2d", "autoScale2d", "resetViews"]],
|
| 72 |
+
};
|
| 73 |
+
|
| 74 |
+
const data = [
|
| 75 |
+
{
|
| 76 |
+
x: dates,
|
| 77 |
+
y: drawdown,
|
| 78 |
+
type: "scatter",
|
| 79 |
+
mode: "lines",
|
| 80 |
+
name: "Drawdown",
|
| 81 |
+
line: { color: "#ef4444", width: 2 },
|
| 82 |
+
fill: "tozeroy",
|
| 83 |
+
fillcolor: "rgba(239, 68, 68, 0.15)",
|
| 84 |
+
},
|
| 85 |
+
{
|
| 86 |
+
x: dates,
|
| 87 |
+
y: drawdown.map(() => 0),
|
| 88 |
+
type: "scatter",
|
| 89 |
+
mode: "lines",
|
| 90 |
+
name: "Zero",
|
| 91 |
+
line: { color: "#ef4444", width: 1, dash: "dot" },
|
| 92 |
+
},
|
| 93 |
+
];
|
| 94 |
+
|
| 95 |
+
return (
|
| 96 |
+
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-4">
|
| 97 |
+
<Plot data={data} layout={layout} config={config} style={{ width: "100%", height: "220px" }} />
|
| 98 |
+
</div>
|
| 99 |
+
);
|
| 100 |
+
}
|
frontend/app/components/EquityChart.tsx
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import dynamic from "next/dynamic";
|
| 4 |
+
|
| 5 |
+
const Plot = dynamic(() => import("react-plotly.js"), { ssr: false });
|
| 6 |
+
|
| 7 |
+
interface EquityChartProps {
|
| 8 |
+
dates: string[];
|
| 9 |
+
equity: (number | null)[];
|
| 10 |
+
initCash: number;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export default function EquityChart({ dates, equity, initCash }: EquityChartProps) {
|
| 14 |
+
const layout = {
|
| 15 |
+
paper_bgcolor: "rgba(0,0,0,0)",
|
| 16 |
+
plot_bgcolor: "rgba(0,0,0,0)",
|
| 17 |
+
font: { color: "#a0a0a0", family: "JetBrains Mono, monospace", size: 10 },
|
| 18 |
+
margin: { t: 30, r: 20, b: 40, l: 60 },
|
| 19 |
+
xaxis: {
|
| 20 |
+
gridcolor: "#2a2a2a",
|
| 21 |
+
zerolinecolor: "#2a2a2a",
|
| 22 |
+
showgrid: true,
|
| 23 |
+
},
|
| 24 |
+
yaxis: {
|
| 25 |
+
gridcolor: "#2a2a2a",
|
| 26 |
+
zerolinecolor: "#2a2a2a",
|
| 27 |
+
showgrid: true,
|
| 28 |
+
title: "Value ($)",
|
| 29 |
+
titlefont: { size: 10 },
|
| 30 |
+
},
|
| 31 |
+
hovermode: "x unified" as const,
|
| 32 |
+
dragmode: "pan" as const,
|
| 33 |
+
title: {
|
| 34 |
+
text: "Equity Curve",
|
| 35 |
+
font: { size: 12, color: "#ffffff" },
|
| 36 |
+
},
|
| 37 |
+
shapes: [
|
| 38 |
+
{
|
| 39 |
+
type: "line" as const,
|
| 40 |
+
x0: dates[0],
|
| 41 |
+
x1: dates[dates.length - 1],
|
| 42 |
+
y0: initCash,
|
| 43 |
+
y1: initCash,
|
| 44 |
+
line: { color: "#666666", width: 1, dash: "dot" as const },
|
| 45 |
+
},
|
| 46 |
+
],
|
| 47 |
+
annotations: [
|
| 48 |
+
{
|
| 49 |
+
x: dates[0],
|
| 50 |
+
y: initCash,
|
| 51 |
+
xref: "x" as const,
|
| 52 |
+
yref: "y" as const,
|
| 53 |
+
text: `$${initCash.toLocaleString()}`,
|
| 54 |
+
showarrow: false,
|
| 55 |
+
font: { color: "#666666", size: 9 },
|
| 56 |
+
},
|
| 57 |
+
],
|
| 58 |
+
};
|
| 59 |
+
|
| 60 |
+
const config = { responsive: true, displayModeBar: true, displaylogo: false, modeBarButtonsToRemove: ['sendDataToCloud', 'lasso2d', 'select2d'], modeBarButtons: [['zoom2d', 'pan2d', 'zoomIn2d', 'zoomOut2d', 'autoScale2d', 'resetViews']] };
|
| 61 |
+
|
| 62 |
+
const data = [
|
| 63 |
+
{
|
| 64 |
+
x: dates,
|
| 65 |
+
y: equity,
|
| 66 |
+
type: "scatter" as const,
|
| 67 |
+
mode: "lines" as const,
|
| 68 |
+
name: "Portfolio Value",
|
| 69 |
+
line: { color: "#f59e0b", width: 1.5 },
|
| 70 |
+
fill: "tozeroy" as const,
|
| 71 |
+
fillcolor: "rgba(245, 158, 11, 0.05)",
|
| 72 |
+
},
|
| 73 |
+
];
|
| 74 |
+
|
| 75 |
+
return (
|
| 76 |
+
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-4">
|
| 77 |
+
<Plot data={data} layout={layout} config={config} style={{ width: "100%", height: "300px" }} />
|
| 78 |
+
</div>
|
| 79 |
+
);
|
| 80 |
+
}
|
frontend/app/components/FundamentalsPanel.tsx
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
interface FundamentalsPanelProps {
|
| 4 |
+
data: {
|
| 5 |
+
ticker: string;
|
| 6 |
+
company: { name?: string; sector?: string; industry?: string; summary?: string };
|
| 7 |
+
valuation: {
|
| 8 |
+
marketCap?: number; enterpriseValue?: number;
|
| 9 |
+
trailingPE?: number; forwardPE?: number;
|
| 10 |
+
priceToBook?: number; enterpriseToEbitda?: number; priceToSales?: number;
|
| 11 |
+
};
|
| 12 |
+
financials: {
|
| 13 |
+
revenuePerShare?: number; earningsPerShare?: number;
|
| 14 |
+
profitMargins?: number; operatingMargins?: number;
|
| 15 |
+
returnOnEquity?: number; debtToEquity?: number;
|
| 16 |
+
currentRatio?: number; freeCashflow?: number; revenueGrowth?: number;
|
| 17 |
+
};
|
| 18 |
+
trading: {
|
| 19 |
+
beta?: number; dividendYield?: number;
|
| 20 |
+
fiftyTwoWeekHigh?: number; fiftyTwoWeekLow?: number; averageVolume?: number;
|
| 21 |
+
};
|
| 22 |
+
};
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
function fmt(v: number | undefined | null, type: "money" | "percent" | "ratio" | "volume" = "ratio"): string {
|
| 26 |
+
if (v == null) return "—";
|
| 27 |
+
if (type === "money") {
|
| 28 |
+
if (v >= 1e12) return `$${(v / 1e12).toFixed(2)}T`;
|
| 29 |
+
if (v >= 1e9) return `$${(v / 1e9).toFixed(2)}B`;
|
| 30 |
+
if (v >= 1e6) return `$${(v / 1e6).toFixed(2)}M`;
|
| 31 |
+
return `$${v.toLocaleString()}`;
|
| 32 |
+
}
|
| 33 |
+
if (type === "percent") return `${(v * 100).toFixed(2)}%`;
|
| 34 |
+
if (type === "volume") return v >= 1e6 ? `${(v / 1e6).toFixed(1)}M` : v.toLocaleString();
|
| 35 |
+
return v.toFixed(2);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
function StatRow({ label, value, color }: { label: string; value: string; color?: string }) {
|
| 39 |
+
return (
|
| 40 |
+
<div className="flex justify-between items-center py-1.5 border-b border-[var(--border-color)] last:border-0">
|
| 41 |
+
<span className="text-[10px] text-[var(--text-secondary)] uppercase tracking-wider">{label}</span>
|
| 42 |
+
<span className={`text-xs font-bold ${color || "text-[var(--text-primary)]"}`}>{value}</span>
|
| 43 |
+
</div>
|
| 44 |
+
);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
export default function FundamentalsPanel({ data }: FundamentalsPanelProps) {
|
| 48 |
+
const { valuation, financials, trading } = data;
|
| 49 |
+
|
| 50 |
+
return (
|
| 51 |
+
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-5">
|
| 52 |
+
{/* Header */}
|
| 53 |
+
<div className="flex items-center gap-2 mb-3">
|
| 54 |
+
<span className="text-[var(--accent-orange)]">$</span>
|
| 55 |
+
<h2 className="text-sm font-bold uppercase tracking-wider">Fundamentals</h2>
|
| 56 |
+
</div>
|
| 57 |
+
<div className="text-xs text-[var(--text-muted)] mb-4 leading-relaxed line-clamp-3">
|
| 58 |
+
{data.company.sector} — {data.company.industry}
|
| 59 |
+
</div>
|
| 60 |
+
|
| 61 |
+
{/* Valuation Multiples */}
|
| 62 |
+
<div className="mb-3">
|
| 63 |
+
<div className="text-[10px] text-[var(--accent-orange)] uppercase tracking-wider mb-1 font-bold">Valuation</div>
|
| 64 |
+
<StatRow label="Market Cap" value={fmt(valuation.marketCap, "money")} />
|
| 65 |
+
<StatRow label="Enterprise Value" value={fmt(valuation.enterpriseValue, "money")} />
|
| 66 |
+
<StatRow label="P/E (Trailing)" value={fmt(valuation.trailingPE)} />
|
| 67 |
+
<StatRow label="P/E (Forward)" value={fmt(valuation.forwardPE)} />
|
| 68 |
+
<StatRow label="P/B" value={fmt(valuation.priceToBook)} />
|
| 69 |
+
<StatRow label="EV/EBITDA" value={fmt(valuation.enterpriseToEbitda)} />
|
| 70 |
+
{valuation.priceToSales != null && (
|
| 71 |
+
<StatRow label="P/S" value={fmt(valuation.priceToSales)} />
|
| 72 |
+
)}
|
| 73 |
+
</div>
|
| 74 |
+
|
| 75 |
+
{/* Financial Health */}
|
| 76 |
+
<div className="mb-3">
|
| 77 |
+
<div className="text-[10px] text-[var(--accent-orange)] uppercase tracking-wider mb-1 font-bold">Financials</div>
|
| 78 |
+
<StatRow label="Revenue / Share" value={fmt(financials.revenuePerShare)} />
|
| 79 |
+
<StatRow label="EPS (TTM)" value={fmt(financials.earningsPerShare)} />
|
| 80 |
+
<StatRow label="Profit Margin" value={fmt(financials.profitMargins, "percent")} />
|
| 81 |
+
<StatRow label="Operating Margin" value={fmt(financials.operatingMargins, "percent")} />
|
| 82 |
+
<StatRow label="ROE" value={fmt(financials.returnOnEquity, "percent")} />
|
| 83 |
+
<StatRow label="Revenue Growth" value={fmt(financials.revenueGrowth, "percent")}
|
| 84 |
+
color={financials.revenueGrowth != null && financials.revenueGrowth >= 0 ? "text-emerald-400" : "text-red-400"} />
|
| 85 |
+
<StatRow label="D/E" value={fmt(financials.debtToEquity)} />
|
| 86 |
+
<StatRow label="Current Ratio" value={fmt(financials.currentRatio)} />
|
| 87 |
+
<StatRow label="Free Cash Flow" value={fmt(financials.freeCashflow, "money")} />
|
| 88 |
+
</div>
|
| 89 |
+
|
| 90 |
+
{/* Trading */}
|
| 91 |
+
<div>
|
| 92 |
+
<div className="text-[10px] text-[var(--accent-orange)] uppercase tracking-wider mb-1 font-bold">Trading</div>
|
| 93 |
+
<StatRow label="Beta" value={fmt(trading.beta)} />
|
| 94 |
+
<StatRow label="Div. Yield" value={fmt(trading.dividendYield, "percent")} />
|
| 95 |
+
<StatRow label="52W High" value={trading.fiftyTwoWeekHigh != null ? `$${trading.fiftyTwoWeekHigh.toFixed(2)}` : "—"} />
|
| 96 |
+
<StatRow label="52W Low" value={trading.fiftyTwoWeekLow != null ? `$${trading.fiftyTwoWeekLow.toFixed(2)}` : "—"} />
|
| 97 |
+
<StatRow label="Avg Volume" value={fmt(trading.averageVolume, "volume")} />
|
| 98 |
+
</div>
|
| 99 |
+
</div>
|
| 100 |
+
);
|
| 101 |
+
}
|
frontend/app/components/IndicatorChart.tsx
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import dynamic from "next/dynamic";
|
| 4 |
+
|
| 5 |
+
const Plot = dynamic(() => import("react-plotly.js"), { ssr: false });
|
| 6 |
+
|
| 7 |
+
interface IndicatorChartProps {
|
| 8 |
+
dates: string[];
|
| 9 |
+
values: (number | null)[];
|
| 10 |
+
title: string;
|
| 11 |
+
color: string;
|
| 12 |
+
overbought?: number;
|
| 13 |
+
oversold?: number;
|
| 14 |
+
secondaryValues?: (number | null)[];
|
| 15 |
+
secondaryColor?: string;
|
| 16 |
+
secondaryName?: string;
|
| 17 |
+
histogramValues?: (number | null)[];
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export default function IndicatorChart({
|
| 21 |
+
dates,
|
| 22 |
+
values,
|
| 23 |
+
title,
|
| 24 |
+
color,
|
| 25 |
+
overbought,
|
| 26 |
+
oversold,
|
| 27 |
+
secondaryValues,
|
| 28 |
+
secondaryColor,
|
| 29 |
+
secondaryName,
|
| 30 |
+
histogramValues,
|
| 31 |
+
}: IndicatorChartProps) {
|
| 32 |
+
const layout = {
|
| 33 |
+
paper_bgcolor: "rgba(0,0,0,0)",
|
| 34 |
+
plot_bgcolor: "rgba(0,0,0,0)",
|
| 35 |
+
font: { color: "#a0a0a0", family: "JetBrains Mono, monospace", size: 10 },
|
| 36 |
+
margin: { t: 30, r: 20, b: 40, l: 60 },
|
| 37 |
+
xaxis: {
|
| 38 |
+
gridcolor: "#2a2a2a",
|
| 39 |
+
zerolinecolor: "#2a2a2a",
|
| 40 |
+
showgrid: true,
|
| 41 |
+
},
|
| 42 |
+
yaxis: {
|
| 43 |
+
gridcolor: "#2a2a2a",
|
| 44 |
+
zerolinecolor: "#2a2a2a",
|
| 45 |
+
showgrid: true,
|
| 46 |
+
},
|
| 47 |
+
legend: {
|
| 48 |
+
font: { size: 10 },
|
| 49 |
+
bgcolor: "rgba(0,0,0,0)",
|
| 50 |
+
bordercolor: "#2a2a2a",
|
| 51 |
+
borderwidth: 1,
|
| 52 |
+
},
|
| 53 |
+
hovermode: "x unified" as const,
|
| 54 |
+
dragmode: "pan" as const,
|
| 55 |
+
title: {
|
| 56 |
+
text: title,
|
| 57 |
+
font: { size: 12, color: "#ffffff" },
|
| 58 |
+
},
|
| 59 |
+
shapes: [
|
| 60 |
+
...(overbought != null
|
| 61 |
+
? [{
|
| 62 |
+
type: "line" as const,
|
| 63 |
+
x0: dates[0],
|
| 64 |
+
x1: dates[dates.length - 1],
|
| 65 |
+
y0: overbought,
|
| 66 |
+
y1: overbought,
|
| 67 |
+
line: { color: "#ef4444", width: 1, dash: "dot" as const },
|
| 68 |
+
}]
|
| 69 |
+
: []),
|
| 70 |
+
...(oversold != null
|
| 71 |
+
? [{
|
| 72 |
+
type: "line" as const,
|
| 73 |
+
x0: dates[0],
|
| 74 |
+
x1: dates[dates.length - 1],
|
| 75 |
+
y0: oversold,
|
| 76 |
+
y1: oversold,
|
| 77 |
+
line: { color: "#10b981", width: 1, dash: "dot" as const },
|
| 78 |
+
}]
|
| 79 |
+
: []),
|
| 80 |
+
],
|
| 81 |
+
annotations: [
|
| 82 |
+
...(overbought != null
|
| 83 |
+
? [{
|
| 84 |
+
x: dates[dates.length - 1],
|
| 85 |
+
y: overbought,
|
| 86 |
+
xref: "x" as const,
|
| 87 |
+
yref: "y" as const,
|
| 88 |
+
text: "OB",
|
| 89 |
+
showarrow: false,
|
| 90 |
+
font: { color: "#ef4444", size: 9 },
|
| 91 |
+
xanchor: "left" as const,
|
| 92 |
+
}]
|
| 93 |
+
: []),
|
| 94 |
+
...(oversold != null
|
| 95 |
+
? [{
|
| 96 |
+
x: dates[dates.length - 1],
|
| 97 |
+
y: oversold,
|
| 98 |
+
xref: "x" as const,
|
| 99 |
+
yref: "y" as const,
|
| 100 |
+
text: "OS",
|
| 101 |
+
showarrow: false,
|
| 102 |
+
font: { color: "#10b981", size: 9 },
|
| 103 |
+
xanchor: "left" as const,
|
| 104 |
+
}]
|
| 105 |
+
: []),
|
| 106 |
+
],
|
| 107 |
+
};
|
| 108 |
+
|
| 109 |
+
const config = { responsive: true, displayModeBar: true, displaylogo: false, modeBarButtonsToRemove: ['sendDataToCloud', 'lasso2d', 'select2d'], modeBarButtons: [['zoom2d', 'pan2d', 'zoomIn2d', 'zoomOut2d', 'autoScale2d', 'resetViews']] };
|
| 110 |
+
|
| 111 |
+
const data: unknown[] = [];
|
| 112 |
+
|
| 113 |
+
if (histogramValues) {
|
| 114 |
+
data.push({
|
| 115 |
+
x: dates,
|
| 116 |
+
y: histogramValues,
|
| 117 |
+
type: "bar",
|
| 118 |
+
name: "Histogram",
|
| 119 |
+
marker: { color: histogramValues.map((v) => (v != null && v >= 0 ? "#10b981" : "#ef4444")), opacity: 0.5 },
|
| 120 |
+
});
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
data.push({
|
| 124 |
+
x: dates,
|
| 125 |
+
y: values,
|
| 126 |
+
type: "scatter",
|
| 127 |
+
mode: "lines",
|
| 128 |
+
name: title.split(" ")[0],
|
| 129 |
+
line: { color, width: 1.5 },
|
| 130 |
+
});
|
| 131 |
+
|
| 132 |
+
if (secondaryValues && secondaryColor && secondaryName) {
|
| 133 |
+
data.push({
|
| 134 |
+
x: dates,
|
| 135 |
+
y: secondaryValues,
|
| 136 |
+
type: "scatter",
|
| 137 |
+
mode: "lines",
|
| 138 |
+
name: secondaryName,
|
| 139 |
+
line: { color: secondaryColor, width: 1, dash: "dash" },
|
| 140 |
+
});
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
return (
|
| 144 |
+
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-4">
|
| 145 |
+
<Plot data={data} layout={layout} config={config} style={{ width: "100%", height: "250px" }} />
|
| 146 |
+
</div>
|
| 147 |
+
);
|
| 148 |
+
}
|
frontend/app/components/OptionsFlowPanel.tsx
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect, useMemo } from "react";
|
| 4 |
+
import { fetchOptionsFlow, type OptionsFlowResponse } from "../api";
|
| 5 |
+
import dynamic from "next/dynamic";
|
| 6 |
+
|
| 7 |
+
// Lazy-load Plotly
|
| 8 |
+
const Plot = dynamic(() => import("react-plotly.js").then((m) => m.default), { ssr: false });
|
| 9 |
+
|
| 10 |
+
// ── Types ──────────────────────────────────────────────────────────────
|
| 11 |
+
|
| 12 |
+
interface StrikeData {
|
| 13 |
+
strike: number;
|
| 14 |
+
volume: number;
|
| 15 |
+
openInterest: number;
|
| 16 |
+
vol_oi_ratio: number;
|
| 17 |
+
contractSymbol?: string;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
interface ExpiryData {
|
| 21 |
+
expiry: string;
|
| 22 |
+
is_leaps: boolean;
|
| 23 |
+
total_call_volume: number;
|
| 24 |
+
total_put_volume: number;
|
| 25 |
+
call_put_ratio: number;
|
| 26 |
+
top_calls: StrikeData[];
|
| 27 |
+
top_puts: StrikeData[];
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
interface UnusualActivity {
|
| 31 |
+
type: string;
|
| 32 |
+
expiry: string;
|
| 33 |
+
strike: number;
|
| 34 |
+
volume: number;
|
| 35 |
+
openInterest: number;
|
| 36 |
+
vol_oi_ratio: number;
|
| 37 |
+
signal: string;
|
| 38 |
+
contractSymbol: string;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
interface SummaryData {
|
| 42 |
+
total_call_volume: number;
|
| 43 |
+
total_put_volume: number;
|
| 44 |
+
call_put_ratio: number;
|
| 45 |
+
total_expiries: number;
|
| 46 |
+
unusual_count: number;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
// ── Helpers ────────────────────────────────────────────────────────────
|
| 50 |
+
|
| 51 |
+
function fmtVol(n: number): string {
|
| 52 |
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
| 53 |
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
| 54 |
+
return String(n);
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
function rpColor(ratio: number): string {
|
| 58 |
+
if (ratio === 0) return "#555";
|
| 59 |
+
return ratio > 1 ? "#22c55e" : ratio < 1 ? "#ef4444" : "#f59e0b";
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
function rpLabel(r: number): string {
|
| 63 |
+
if (r > 3) return "Strongly Bullish";
|
| 64 |
+
if (r > 1.5) return "Bullish";
|
| 65 |
+
if (r > 1) return "Slightly Bullish";
|
| 66 |
+
if (r > 0.67) return "Neutral";
|
| 67 |
+
if (r > 0.33) return "Slightly Bearish";
|
| 68 |
+
return "Strongly Bearish";
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
// ── Sub-components ─────────────────────────────────────────────────────
|
| 72 |
+
|
| 73 |
+
function Kpi({ label, value, color, sub }: { label: string; value: string; color: string; sub?: string }) {
|
| 74 |
+
return (
|
| 75 |
+
<div style={{ background: "#0f0f0f", borderRadius: 4, padding: "8px 10px", border: "1px solid #1a1a1a" }}>
|
| 76 |
+
<p style={{ margin: 0, color: "#555", fontFamily: "monospace", fontSize: 10 }}>{label}</p>
|
| 77 |
+
<p style={{ margin: "2px 0 0", color, fontFamily: "monospace", fontSize: 18, fontWeight: 700 }}>{value}</p>
|
| 78 |
+
{sub && <p style={{ margin: 0, color: "#888", fontFamily: "monospace", fontSize: 9 }}>{sub}</p>}
|
| 79 |
+
</div>
|
| 80 |
+
);
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
function ExpiryCard({ expiry, isLeaps, callVol, putVol, ratio, topCalls, topPuts }: {
|
| 84 |
+
expiry: string; isLeaps: boolean; callVol: number;
|
| 85 |
+
putVol: number; ratio: number; topCalls: StrikeData[]; topPuts: StrikeData[];
|
| 86 |
+
}) {
|
| 87 |
+
const [open, setOpen] = useState(true);
|
| 88 |
+
return (
|
| 89 |
+
<div style={{
|
| 90 |
+
background: "#0f0f0f", border: `1px solid ${isLeaps ? "#f97316" : "#2a2a2a"}`,
|
| 91 |
+
borderRadius: 6, marginBottom: 8, overflow: "hidden",
|
| 92 |
+
}}>
|
| 93 |
+
<button
|
| 94 |
+
onClick={() => setOpen(!open)}
|
| 95 |
+
style={{
|
| 96 |
+
width: "100%", padding: "8px 12px", display: "flex",
|
| 97 |
+
alignItems: "center", justifyContent: "space-between", cursor: "pointer",
|
| 98 |
+
background: "transparent", border: "none", color: "#e5e7eb",
|
| 99 |
+
fontFamily: "monospace", fontSize: 12,
|
| 100 |
+
}}
|
| 101 |
+
>
|
| 102 |
+
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
| 103 |
+
<span>{expiry}</span>
|
| 104 |
+
{isLeaps && <span style={{ color: "#f97316", fontSize: 9, fontWeight: 700 }}>LEAPS</span>}
|
| 105 |
+
</span>
|
| 106 |
+
<span style={{ color: rpColor(ratio), fontSize: 11 }}>
|
| 107 |
+
C/P {ratio.toFixed(2)}
|
| 108 |
+
</span>
|
| 109 |
+
</button>
|
| 110 |
+
{open && (
|
| 111 |
+
<div style={{ padding: "0 12px 12px", display: "flex", gap: 24 }}>
|
| 112 |
+
<div style={{ flex: 1 }}>
|
| 113 |
+
<p style={{ margin: "0 0 4px", color: "#22c55e", fontFamily: "monospace", fontSize: 11, fontWeight: 600 }}>
|
| 114 |
+
CALLS {fmtVol(callVol)}
|
| 115 |
+
</p>
|
| 116 |
+
<MiniTable rows={topCalls.slice(0, 4)} />
|
| 117 |
+
</div>
|
| 118 |
+
<div style={{ flex: 1 }}>
|
| 119 |
+
<p style={{ margin: "0 0 4px", color: "#ef4444", fontFamily: "monospace", fontSize: 11, fontWeight: 600 }}>
|
| 120 |
+
PUTS {fmtVol(putVol)}
|
| 121 |
+
</p>
|
| 122 |
+
<MiniTable rows={topPuts.slice(0, 4)} />
|
| 123 |
+
</div>
|
| 124 |
+
</div>
|
| 125 |
+
)}
|
| 126 |
+
</div>
|
| 127 |
+
);
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
function MiniTable({ rows }: { rows: StrikeData[] }) {
|
| 131 |
+
return (
|
| 132 |
+
<table style={{ width: "100%", borderCollapse: "collapse", tableLayout: "fixed", fontFamily: "monospace", fontSize: 11 }}>
|
| 133 |
+
<thead>
|
| 134 |
+
<tr style={{ color: "#555", borderBottom: "1px solid #222" }}>
|
| 135 |
+
<th style={{ padding: "2px 4px", textAlign: "left" }}>Strike</th>
|
| 136 |
+
<th style={{ padding: "2px 4px", textAlign: "left" }}>Vol</th>
|
| 137 |
+
<th style={{ padding: "2px 4px", textAlign: "left" }}>OI</th>
|
| 138 |
+
<th style={{ padding: "2px 4px", textAlign: "left" }}>V/OI</th>
|
| 139 |
+
</tr>
|
| 140 |
+
</thead>
|
| 141 |
+
<tbody style={{ color: "#e5e7eb" }}>
|
| 142 |
+
{rows.map((s, i) => (
|
| 143 |
+
<tr key={i} style={{ borderBottom: "1px solid #111" }}>
|
| 144 |
+
<td style={{ padding: "2px 4px" }}>{s.strike}</td>
|
| 145 |
+
<td style={{ padding: "2px 4px" }}>{fmtVol(s.volume ?? 0)}</td>
|
| 146 |
+
<td style={{ padding: "2px 4px" }}>{fmtVol(s.openInterest ?? 0)}</td>
|
| 147 |
+
<td style={{ padding: "2px 4px", color: (s.vol_oi_ratio ?? 0) > 2 ? "#f97316" : "#888" }}>
|
| 148 |
+
{(s.vol_oi_ratio ?? 0).toFixed(1)}
|
| 149 |
+
</td>
|
| 150 |
+
</tr>
|
| 151 |
+
))}
|
| 152 |
+
</tbody>
|
| 153 |
+
</table>
|
| 154 |
+
);
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
function UnusualTable({ activities }: { activities: UnusualActivity[] }) {
|
| 158 |
+
if (activities.length === 0) {
|
| 159 |
+
return <p style={{ color: "#555", fontFamily: "monospace", fontSize: 11 }}>No unusual activity detected.</p>;
|
| 160 |
+
}
|
| 161 |
+
return (
|
| 162 |
+
<div style={{ overflowX: "auto" }}>
|
| 163 |
+
<table style={{ width: "100%", borderCollapse: "collapse", fontFamily: "monospace", fontSize: 11 }}>
|
| 164 |
+
<thead>
|
| 165 |
+
<tr style={{ color: "#555", borderBottom: "1px solid #333", textAlign: "left" }}>
|
| 166 |
+
<th style={{ padding: "6px 8px" }}>Type</th>
|
| 167 |
+
<th style={{ padding: "6px 8px" }}>Expiry</th>
|
| 168 |
+
<th style={{ padding: "6px 8px" }}>Strike</th>
|
| 169 |
+
<th style={{ padding: "6px 8px" }}>Volume</th>
|
| 170 |
+
<th style={{ padding: "6px 8px" }}>OI</th>
|
| 171 |
+
<th style={{ padding: "6px 8px" }}>V/OI</th>
|
| 172 |
+
<th style={{ padding: "6px 8px" }}>Signal</th>
|
| 173 |
+
</tr>
|
| 174 |
+
</thead>
|
| 175 |
+
<tbody>
|
| 176 |
+
{activities.map((a, i) => (
|
| 177 |
+
<tr key={i} style={{ borderBottom: "1px solid #1a1a1a" }}>
|
| 178 |
+
<td style={{ padding: "4px 8px", color: a.type === "leaps_call" ? "#22c55e" : "#ef4444", fontWeight: 600 }}>
|
| 179 |
+
{a.type === "leaps_call" ? "LEAPS CALL" : "LEAPS PUT"}
|
| 180 |
+
</td>
|
| 181 |
+
<td style={{ padding: "4px 8px", color: "#888" }}>{a.expiry}</td>
|
| 182 |
+
<td style={{ padding: "4px 8px" }}>${(a.strike ?? 0).toFixed(0)}</td>
|
| 183 |
+
<td style={{ padding: "4px 8px" }}>{fmtVol(a.volume ?? 0)}</td>
|
| 184 |
+
<td style={{ padding: "4px 8px" }}>{fmtVol(a.openInterest ?? 0)}</td>
|
| 185 |
+
<td style={{ padding: "4px 8px", color: (a.vol_oi_ratio ?? 0) > 3 ? "#f97316" : "#bbb" }}>
|
| 186 |
+
{(a.vol_oi_ratio ?? 0).toFixed(1)}
|
| 187 |
+
</td>
|
| 188 |
+
<td style={{ padding: "4px 8px", fontWeight: 700, color: a.signal === "BULLISH" ? "#22c55e" : "#ef4444" }}>
|
| 189 |
+
{a.signal}
|
| 190 |
+
</td>
|
| 191 |
+
</tr>
|
| 192 |
+
))}
|
| 193 |
+
</tbody>
|
| 194 |
+
</table>
|
| 195 |
+
</div>
|
| 196 |
+
);
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
// ── Main Panel ──────────────────────────────────────────────────────────
|
| 200 |
+
|
| 201 |
+
interface OptionsFlowPanelProps {
|
| 202 |
+
ticker: string;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
export default function OptionsFlowPanel({ ticker }: OptionsFlowPanelProps) {
|
| 206 |
+
const [data, setData] = useState<OptionsFlowResponse | null>(null);
|
| 207 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 208 |
+
const [error, setError] = useState<string | null>(null);
|
| 209 |
+
|
| 210 |
+
useEffect(() => {
|
| 211 |
+
if (!ticker) return;
|
| 212 |
+
let cancelled = false;
|
| 213 |
+
setIsLoading(true);
|
| 214 |
+
setError(null);
|
| 215 |
+
fetchOptionsFlow(ticker)
|
| 216 |
+
.then((d) => { if (!cancelled) setData(d); })
|
| 217 |
+
.catch(() => { if (!cancelled) setError("Options data unavailable."); })
|
| 218 |
+
.finally(() => { if (!cancelled) setIsLoading(false); });
|
| 219 |
+
return () => { cancelled = true; };
|
| 220 |
+
}, [ticker]);
|
| 221 |
+
|
| 222 |
+
if (!ticker) return null;
|
| 223 |
+
if (isLoading) return <p style={{ color: "#666", marginTop: 8, fontFamily: "monospace" }}>Loading options flow…</p>;
|
| 224 |
+
if (error) return <p style={{ color: "#ef4444", marginTop: 8, fontFamily: "monospace" }}>{error}</p>;
|
| 225 |
+
if (!data) return null;
|
| 226 |
+
|
| 227 |
+
const summary = data.summary as SummaryData;
|
| 228 |
+
const expiries = (data.all_expiries || []) as ExpiryData[];
|
| 229 |
+
const leaps = (data.leaps || []) as ExpiryData[];
|
| 230 |
+
const unusual = (data.unusual_activities || []) as UnusualActivity[];
|
| 231 |
+
|
| 232 |
+
const nearestIdx = expiries.findIndex((e: ExpiryData) => !e.is_leaps);
|
| 233 |
+
const primaryExpiry = nearestIdx >= 0 ? expiries[nearestIdx] : expiries[0] || null;
|
| 234 |
+
|
| 235 |
+
return (
|
| 236 |
+
<div style={{
|
| 237 |
+
background: "#0a0a0a", border: "1px solid #1e1e1e",
|
| 238 |
+
borderRadius: 6, padding: 16, marginTop: 12,
|
| 239 |
+
}}>
|
| 240 |
+
{/* Header */}
|
| 241 |
+
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
|
| 242 |
+
<h3 style={{ margin: 0, color: "#f97316", fontFamily: "monospace", fontSize: 14, fontWeight: 700, letterSpacing: 1 }}>
|
| 243 |
+
OPTIONS FLOW ─ {data.ticker}
|
| 244 |
+
</h3>
|
| 245 |
+
<span style={{ color: "#555", fontFamily: "monospace", fontSize: 10 }}>
|
| 246 |
+
{new Date(data.as_of).toLocaleString("en-US", { hour12: false })}
|
| 247 |
+
</span>
|
| 248 |
+
</div>
|
| 249 |
+
|
| 250 |
+
{/* KPI row */}
|
| 251 |
+
<div style={{ display: "grid", gridTemplateColumns: "repeat(5, 1fr)", gap: 8, marginBottom: 16 }}>
|
| 252 |
+
<Kpi label="CALL VOL" value={fmtVol(summary.total_call_volume)} color="#22c55e" />
|
| 253 |
+
<Kpi label="PUT VOL" value={fmtVol(summary.total_put_volume)} color="#ef4444" />
|
| 254 |
+
<Kpi label="C/P RATIO" value={summary.call_put_ratio.toFixed(2)} sub={rpLabel(summary.call_put_ratio)} color={rpColor(summary.call_put_ratio)} />
|
| 255 |
+
<Kpi label="EXPIRIES" value={String(summary.total_expiries)} color="#777" />
|
| 256 |
+
<Kpi label="LEAPS" value={String(leaps.length)} sub={leaps.length > 0 ? (data as any).leaps_expiries?.[0] : "—"} color="#f97316" />
|
| 257 |
+
</div>
|
| 258 |
+
|
| 259 |
+
{/* Expiry breakdown */}
|
| 260 |
+
<div style={{ marginBottom: 12 }}>
|
| 261 |
+
<p style={{ margin: "0 0 6px", color: "#888", fontFamily: "monospace", fontSize: 11, fontWeight: 600 }}>
|
| 262 |
+
EXPIRY BREAKDOWN ({expiries.length} total, {leaps.length} LEAPS)
|
| 263 |
+
</p>
|
| 264 |
+
{expiries.slice().reverse().map((e: ExpiryData) => (
|
| 265 |
+
<ExpiryCard
|
| 266 |
+
key={e.expiry}
|
| 267 |
+
expiry={e.expiry}
|
| 268 |
+
isLeaps={e.is_leaps}
|
| 269 |
+
callVol={e.total_call_volume}
|
| 270 |
+
putVol={e.total_put_volume}
|
| 271 |
+
ratio={e.call_put_ratio}
|
| 272 |
+
topCalls={e.top_calls || []}
|
| 273 |
+
topPuts={e.top_puts || []}
|
| 274 |
+
/>
|
| 275 |
+
))}
|
| 276 |
+
</div>
|
| 277 |
+
|
| 278 |
+
{/* Unusual Activity */}
|
| 279 |
+
{unusual.length > 0 && (
|
| 280 |
+
<div>
|
| 281 |
+
<p style={{ margin: "0 0 6px", color: "#f97316", fontFamily: "monospace", fontSize: 11, fontWeight: 700 }}>
|
| 282 |
+
⚑ UNUSUAL ACTIVITY ({summary.unusual_count}) — vol/OI ≥ 2
|
| 283 |
+
</p>
|
| 284 |
+
<UnusualTable activities={unusual} />
|
| 285 |
+
</div>
|
| 286 |
+
)}
|
| 287 |
+
|
| 288 |
+
{unusual.length === 0 && (
|
| 289 |
+
<p style={{ color: "#444", fontFamily: "monospace", fontSize: 10, marginTop: 4 }}>
|
| 290 |
+
No unusual vol/OI spikes detected.
|
| 291 |
+
</p>
|
| 292 |
+
)}
|
| 293 |
+
</div>
|
| 294 |
+
);
|
| 295 |
+
}
|
frontend/app/components/PriceChart.tsx
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import dynamic from "next/dynamic";
|
| 4 |
+
import { useMemo } from "react";
|
| 5 |
+
|
| 6 |
+
const Plot = dynamic(() => import("react-plotly.js"), { ssr: false });
|
| 7 |
+
|
| 8 |
+
interface PriceChartProps {
|
| 9 |
+
dates: string[];
|
| 10 |
+
open: number[];
|
| 11 |
+
high: number[];
|
| 12 |
+
low: number[];
|
| 13 |
+
close: number[];
|
| 14 |
+
entries: boolean[];
|
| 15 |
+
exits: boolean[];
|
| 16 |
+
ticker: string;
|
| 17 |
+
/** Strategy overlays */
|
| 18 |
+
overlays?: { name: string; values: (number | null)[]; color: string; dash?: string }[];
|
| 19 |
+
/** Use candlestick style instead of line */
|
| 20 |
+
candlestick?: boolean;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export default function PriceChart({
|
| 24 |
+
dates,
|
| 25 |
+
open,
|
| 26 |
+
high,
|
| 27 |
+
low,
|
| 28 |
+
close,
|
| 29 |
+
entries,
|
| 30 |
+
exits,
|
| 31 |
+
ticker,
|
| 32 |
+
overlays,
|
| 33 |
+
candlestick,
|
| 34 |
+
}: PriceChartProps) {
|
| 35 |
+
const entryPoints = useMemo(() => {
|
| 36 |
+
const x: string[] = [];
|
| 37 |
+
const y: number[] = [];
|
| 38 |
+
entries.forEach((v, i) => {
|
| 39 |
+
if (v && close[i] != null) {
|
| 40 |
+
x.push(dates[i]);
|
| 41 |
+
y.push(close[i]);
|
| 42 |
+
}
|
| 43 |
+
});
|
| 44 |
+
return { x, y };
|
| 45 |
+
}, [entries, dates, close]);
|
| 46 |
+
|
| 47 |
+
const exitPoints = useMemo(() => {
|
| 48 |
+
const x: string[] = [];
|
| 49 |
+
const y: number[] = [];
|
| 50 |
+
exits.forEach((v, i) => {
|
| 51 |
+
if (v && close[i] != null) {
|
| 52 |
+
x.push(dates[i]);
|
| 53 |
+
y.push(close[i]);
|
| 54 |
+
}
|
| 55 |
+
});
|
| 56 |
+
return { x, y };
|
| 57 |
+
}, [exits, dates, close]);
|
| 58 |
+
|
| 59 |
+
const layout = {
|
| 60 |
+
paper_bgcolor: "rgba(0,0,0,0)",
|
| 61 |
+
plot_bgcolor: "rgba(0,0,0,0)",
|
| 62 |
+
font: { color: "#a0a0a0", family: "JetBrains Mono, monospace", size: 10 },
|
| 63 |
+
margin: { t: 30, r: 20, b: 40, l: 60 },
|
| 64 |
+
xaxis: {
|
| 65 |
+
gridcolor: "#2a2a2a",
|
| 66 |
+
zerolinecolor: "#2a2a2a",
|
| 67 |
+
showgrid: true,
|
| 68 |
+
title: "",
|
| 69 |
+
rangeslider: { visible: candlestick ? false : undefined },
|
| 70 |
+
},
|
| 71 |
+
yaxis: {
|
| 72 |
+
gridcolor: "#2a2a2a",
|
| 73 |
+
zerolinecolor: "#2a2a2a",
|
| 74 |
+
showgrid: true,
|
| 75 |
+
title: "Price ($)",
|
| 76 |
+
titlefont: { size: 10 },
|
| 77 |
+
},
|
| 78 |
+
legend: {
|
| 79 |
+
font: { size: 10 },
|
| 80 |
+
bgcolor: "rgba(0,0,0,0)",
|
| 81 |
+
bordercolor: "#2a2a2a",
|
| 82 |
+
borderwidth: 1,
|
| 83 |
+
},
|
| 84 |
+
hovermode: "x unified" as const,
|
| 85 |
+
dragmode: "pan" as const,
|
| 86 |
+
title: {
|
| 87 |
+
text: `${ticker} — Price Chart`,
|
| 88 |
+
font: { size: 13, color: "#ffffff" },
|
| 89 |
+
},
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
+
const config = { responsive: true, displayModeBar: true, displaylogo: false, scrollZoom: true, modeBarButtonsToRemove: ['sendDataToCloud', 'lasso2d', 'select2d', 'resetScale2d'], modeBarButtons: [['zoom2d', 'pan2d', 'zoomIn2d', 'zoomOut2d', 'autoScale2d', 'resetViews']] };
|
| 93 |
+
|
| 94 |
+
const data: unknown[] = [];
|
| 95 |
+
|
| 96 |
+
if (candlestick && open.length > 0 && high.length > 0) {
|
| 97 |
+
// Candlestick chart
|
| 98 |
+
data.push({
|
| 99 |
+
x: dates,
|
| 100 |
+
open: open,
|
| 101 |
+
high: high,
|
| 102 |
+
low: low,
|
| 103 |
+
close: close,
|
| 104 |
+
type: "candlestick",
|
| 105 |
+
name: "OHLC",
|
| 106 |
+
increasing: { line: { color: "#10b981" }, fillcolor: "#10b981" },
|
| 107 |
+
decreasing: { line: { color: "#ef4444" }, fillcolor: "#ef4444" },
|
| 108 |
+
});
|
| 109 |
+
} else {
|
| 110 |
+
// Line chart
|
| 111 |
+
data.push({
|
| 112 |
+
x: dates,
|
| 113 |
+
y: close,
|
| 114 |
+
type: "scatter",
|
| 115 |
+
mode: "lines",
|
| 116 |
+
name: "Close",
|
| 117 |
+
line: { color: "#ffffff", width: 1.5 },
|
| 118 |
+
});
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
// Overlay lines (SMA/EMA/BB)
|
| 122 |
+
if (overlays) {
|
| 123 |
+
overlays.forEach((line) => {
|
| 124 |
+
data.push({
|
| 125 |
+
x: dates,
|
| 126 |
+
y: line.values,
|
| 127 |
+
type: "scatter",
|
| 128 |
+
mode: "lines",
|
| 129 |
+
name: line.name,
|
| 130 |
+
line: { color: line.color, width: 1, dash: line.dash || "solid" },
|
| 131 |
+
});
|
| 132 |
+
});
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
// Entry markers
|
| 136 |
+
if (entryPoints.x.length > 0) {
|
| 137 |
+
data.push({
|
| 138 |
+
x: entryPoints.x,
|
| 139 |
+
y: entryPoints.y,
|
| 140 |
+
type: "scatter",
|
| 141 |
+
mode: "markers",
|
| 142 |
+
name: "Buy",
|
| 143 |
+
marker: { color: "#10b981", size: 10, symbol: "triangle-up" },
|
| 144 |
+
});
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
// Exit markers
|
| 148 |
+
if (exitPoints.x.length > 0) {
|
| 149 |
+
data.push({
|
| 150 |
+
x: exitPoints.x,
|
| 151 |
+
y: exitPoints.y,
|
| 152 |
+
type: "scatter",
|
| 153 |
+
mode: "markers",
|
| 154 |
+
name: "Sell",
|
| 155 |
+
marker: { color: "#ef4444", size: 10, symbol: "triangle-down" },
|
| 156 |
+
});
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
return (
|
| 160 |
+
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-4">
|
| 161 |
+
<Plot data={data} layout={layout} config={config} style={{ width: "100%", height: "400px" }} />
|
| 162 |
+
</div>
|
| 163 |
+
);
|
| 164 |
+
}
|
frontend/app/components/StatsPanel.tsx
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
interface StatsPanelProps {
|
| 4 |
+
stats: Record<string, unknown> | null;
|
| 5 |
+
ticker: string;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
export default function StatsPanel({ stats, ticker }: StatsPanelProps) {
|
| 9 |
+
if (!stats) {
|
| 10 |
+
return (
|
| 11 |
+
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-5">
|
| 12 |
+
<div className="flex items-center gap-2 mb-4">
|
| 13 |
+
<span className="text-[var(--accent-orange)]">#</span>
|
| 14 |
+
<h2 className="text-sm font-bold uppercase tracking-wider">Portfolio Statistics</h2>
|
| 15 |
+
</div>
|
| 16 |
+
<div className="text-center py-8 text-[var(--text-muted)] text-sm">
|
| 17 |
+
Run a backtest to see statistics
|
| 18 |
+
</div>
|
| 19 |
+
</div>
|
| 20 |
+
);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
const totalReturn = typeof stats["Total Return [%]"] === "number"
|
| 24 |
+
? (stats["Total Return [%]"] as number)
|
| 25 |
+
: typeof stats["Total Return"] === "number"
|
| 26 |
+
? (stats["Total Return"] as number) * 100
|
| 27 |
+
: null;
|
| 28 |
+
|
| 29 |
+
const sharpe = typeof stats["Sharpe Ratio"] === "number"
|
| 30 |
+
? stats["Sharpe Ratio"] as number
|
| 31 |
+
: null;
|
| 32 |
+
|
| 33 |
+
const maxDD = typeof stats["Max Drawdown [%]"] === "number"
|
| 34 |
+
? stats["Max Drawdown [%]"] as number
|
| 35 |
+
: typeof stats["Max Drawdown"] === "number"
|
| 36 |
+
? (stats["Max Drawdown"] as number) * 100
|
| 37 |
+
: null;
|
| 38 |
+
|
| 39 |
+
const endValue = typeof stats["End Value"] === "number"
|
| 40 |
+
? stats["End Value"] as number
|
| 41 |
+
: typeof stats["Final Value"] === "number"
|
| 42 |
+
? stats["Final Value"] as number
|
| 43 |
+
: null;
|
| 44 |
+
|
| 45 |
+
const winRate = typeof stats["Win Rate [%]"] === "number"
|
| 46 |
+
? stats["Win Rate [%]"] as number
|
| 47 |
+
: null;
|
| 48 |
+
|
| 49 |
+
const closedTrades = typeof stats["Total Closed Trades"] === "number"
|
| 50 |
+
? stats["Total Closed Trades"] as number
|
| 51 |
+
: typeof stats["Total Trades"] === "number"
|
| 52 |
+
? stats["Total Trades"] as number
|
| 53 |
+
: null;
|
| 54 |
+
|
| 55 |
+
return (
|
| 56 |
+
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-5">
|
| 57 |
+
<div className="flex items-center justify-between mb-4">
|
| 58 |
+
<div className="flex items-center gap-2">
|
| 59 |
+
<span className="text-[var(--accent-orange)]">#</span>
|
| 60 |
+
<h2 className="text-sm font-bold uppercase tracking-wider">Portfolio Statistics</h2>
|
| 61 |
+
</div>
|
| 62 |
+
<span className="text-xs text-[var(--text-muted)] bg-[var(--bg-tertiary)] px-2 py-1 rounded">
|
| 63 |
+
{ticker}
|
| 64 |
+
</span>
|
| 65 |
+
</div>
|
| 66 |
+
|
| 67 |
+
{/* Key Metrics — only 4 cards */}
|
| 68 |
+
<div className="grid grid-cols-2 gap-3 mb-4">
|
| 69 |
+
{totalReturn !== null && (
|
| 70 |
+
<div className="bg-[var(--bg-tertiary)] rounded p-3 text-center">
|
| 71 |
+
<div className="text-[10px] text-[var(--text-muted)] uppercase tracking-wider mb-1">Total Return</div>
|
| 72 |
+
<div className={`text-lg font-bold ${totalReturn >= 0 ? "text-emerald-400" : "text-red-400"}`}>
|
| 73 |
+
{totalReturn.toFixed(2)}%
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
)}
|
| 77 |
+
{endValue !== null && (
|
| 78 |
+
<div className="bg-[var(--bg-tertiary)] rounded p-3 text-center">
|
| 79 |
+
<div className="text-[10px] text-[var(--text-muted)] uppercase tracking-wider mb-1">Final Value</div>
|
| 80 |
+
<div className="text-lg font-bold text-[var(--text-primary)]">
|
| 81 |
+
${endValue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
| 82 |
+
</div>
|
| 83 |
+
</div>
|
| 84 |
+
)}
|
| 85 |
+
{sharpe !== null && (
|
| 86 |
+
<div className="bg-[var(--bg-tertiary)] rounded p-3 text-center">
|
| 87 |
+
<div className="text-[10px] text-[var(--text-muted)] uppercase tracking-wider mb-1">Sharpe Ratio</div>
|
| 88 |
+
<div className="text-lg font-bold text-[var(--accent-orange)]">
|
| 89 |
+
{sharpe.toFixed(3)}
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
)}
|
| 93 |
+
{maxDD !== null && (
|
| 94 |
+
<div className="bg-[var(--bg-tertiary)] rounded p-3 text-center">
|
| 95 |
+
<div className="text-[10px] text-[var(--text-muted)] uppercase tracking-wider mb-1">Max Drawdown</div>
|
| 96 |
+
<div className="text-lg font-bold text-red-400">
|
| 97 |
+
{maxDD.toFixed(2)}%
|
| 98 |
+
</div>
|
| 99 |
+
</div>
|
| 100 |
+
)}
|
| 101 |
+
</div>
|
| 102 |
+
|
| 103 |
+
{/* Only Win Rate + Closed Trades */}
|
| 104 |
+
<div className="space-y-0">
|
| 105 |
+
{winRate !== null && (
|
| 106 |
+
<div className="flex justify-between items-center py-2 border-b border-[var(--border-color)] last:border-0">
|
| 107 |
+
<span className="text-xs text-[var(--text-secondary)] uppercase tracking-wider">Win Rate</span>
|
| 108 |
+
<span className={`text-sm font-bold ${winRate >= 50 ? "text-emerald-400" : "text-red-400"}`}>
|
| 109 |
+
{winRate.toFixed(1)}%
|
| 110 |
+
</span>
|
| 111 |
+
</div>
|
| 112 |
+
)}
|
| 113 |
+
{closedTrades !== null && closedTrades > 0 && (
|
| 114 |
+
<div className="flex justify-between items-center py-2 border-b border-[var(--border-color)] last:border-0">
|
| 115 |
+
<span className="text-xs text-[var(--text-secondary)] uppercase tracking-wider">Closed Trades</span>
|
| 116 |
+
<span className="text-sm font-bold text-[var(--text-primary)]">{String(closedTrades)}</span>
|
| 117 |
+
</div>
|
| 118 |
+
)}
|
| 119 |
+
</div>
|
| 120 |
+
</div>
|
| 121 |
+
);
|
| 122 |
+
}
|
frontend/app/components/TerminalHeader.tsx
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
interface TerminalHeaderProps {
|
| 4 |
+
status: "connected" | "disconnected" | "loading";
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
export default function TerminalHeader({ status }: TerminalHeaderProps) {
|
| 8 |
+
const statusColor = {
|
| 9 |
+
connected: "bg-emerald-500",
|
| 10 |
+
disconnected: "bg-red-500",
|
| 11 |
+
loading: "bg-amber-500",
|
| 12 |
+
}[status];
|
| 13 |
+
|
| 14 |
+
return (
|
| 15 |
+
<header className="border-b border-[var(--border-color)] bg-[var(--bg-secondary)] px-6 py-3">
|
| 16 |
+
<div className="flex items-center justify-between">
|
| 17 |
+
<div className="flex items-center gap-3">
|
| 18 |
+
<div className="flex items-center gap-2">
|
| 19 |
+
<span className="text-[var(--accent-orange)] text-lg font-bold">{">"}</span>
|
| 20 |
+
<h1 className="text-lg font-bold tracking-wider text-[var(--text-primary)]">
|
| 21 |
+
Terminal<span className="text-[var(--accent-orange)]">.AI</span>
|
| 22 |
+
</h1>
|
| 23 |
+
</div>
|
| 24 |
+
<span className="text-[var(--text-muted)] text-xs">v1.0.0</span>
|
| 25 |
+
<span className="text-[var(--text-muted)] text-xs">|</span>
|
| 26 |
+
<span className="text-[var(--text-muted)] text-xs">VectorBT Engine</span>
|
| 27 |
+
</div>
|
| 28 |
+
<div className="flex items-center gap-2">
|
| 29 |
+
<div className={`w-2 h-2 rounded-full ${statusColor}`} />
|
| 30 |
+
<span className="text-xs text-[var(--text-secondary)] uppercase tracking-wider">
|
| 31 |
+
{status === "connected" ? "ONLINE" : status === "loading" ? "CONNECTING" : "OFFLINE"}
|
| 32 |
+
</span>
|
| 33 |
+
</div>
|
| 34 |
+
</div>
|
| 35 |
+
</header>
|
| 36 |
+
);
|
| 37 |
+
}
|
frontend/app/components/VolumeChart.tsx
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import dynamic from "next/dynamic";
|
| 4 |
+
|
| 5 |
+
const Plot = dynamic(() => import("react-plotly.js"), { ssr: false });
|
| 6 |
+
|
| 7 |
+
interface VolumeChartProps {
|
| 8 |
+
dates: string[];
|
| 9 |
+
volume: number[];
|
| 10 |
+
close: number[];
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export default function VolumeChart({ dates, volume, close }: VolumeChartProps) {
|
| 14 |
+
// Color bars green/red based on price movement
|
| 15 |
+
const colors = close.map((c, i) => {
|
| 16 |
+
if (i === 0) return "#10b981";
|
| 17 |
+
return c >= (close[i - 1] || c) ? "#10b981" : "#ef4444";
|
| 18 |
+
});
|
| 19 |
+
|
| 20 |
+
const layout = {
|
| 21 |
+
paper_bgcolor: "rgba(0,0,0,0)",
|
| 22 |
+
plot_bgcolor: "rgba(0,0,0,0)",
|
| 23 |
+
font: { color: "#a0a0a0", family: "JetBrains Mono, monospace", size: 10 },
|
| 24 |
+
margin: { t: 25, r: 20, b: 40, l: 60 },
|
| 25 |
+
xaxis: {
|
| 26 |
+
gridcolor: "#2a2a2a",
|
| 27 |
+
zerolinecolor: "#2a2a2a",
|
| 28 |
+
showgrid: true,
|
| 29 |
+
},
|
| 30 |
+
yaxis: {
|
| 31 |
+
gridcolor: "#2a2a2a",
|
| 32 |
+
zerolinecolor: "#2a2a2a",
|
| 33 |
+
showgrid: true,
|
| 34 |
+
title: "Volume",
|
| 35 |
+
titlefont: { size: 10 },
|
| 36 |
+
},
|
| 37 |
+
hovermode: "x unified" as const,
|
| 38 |
+
dragmode: "pan" as const,
|
| 39 |
+
showlegend: false,
|
| 40 |
+
title: {
|
| 41 |
+
text: "Volume",
|
| 42 |
+
font: { size: 12, color: "#ffffff" },
|
| 43 |
+
},
|
| 44 |
+
bargap: 0,
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
const config = { responsive: true, displayModeBar: true, displaylogo: false, modeBarButtonsToRemove: ['sendDataToCloud', 'lasso2d', 'select2d'], modeBarButtons: [['zoom2d', 'pan2d', 'zoomIn2d', 'zoomOut2d', 'autoScale2d', 'resetViews']] };
|
| 48 |
+
|
| 49 |
+
const data = [
|
| 50 |
+
{
|
| 51 |
+
x: dates,
|
| 52 |
+
y: volume,
|
| 53 |
+
type: "bar",
|
| 54 |
+
marker: { color: colors, opacity: 0.6 },
|
| 55 |
+
},
|
| 56 |
+
];
|
| 57 |
+
|
| 58 |
+
return (
|
| 59 |
+
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-4">
|
| 60 |
+
<Plot data={data} layout={layout} config={config} style={{ width: "100%", height: "150px" }} />
|
| 61 |
+
</div>
|
| 62 |
+
);
|
| 63 |
+
}
|
frontend/app/favicon.ico
ADDED
|
|
frontend/app/globals.css
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import "tailwindcss";
|
| 2 |
+
|
| 3 |
+
:root {
|
| 4 |
+
--bg-primary: #0a0a0a;
|
| 5 |
+
--bg-secondary: #111111;
|
| 6 |
+
--bg-tertiary: #1a1a1a;
|
| 7 |
+
--border-color: #2a2a2a;
|
| 8 |
+
--accent-orange: #f59e0b;
|
| 9 |
+
--accent-orange-hover: #d97706;
|
| 10 |
+
--accent-green: #10b981;
|
| 11 |
+
--accent-red: #ef4444;
|
| 12 |
+
--text-primary: #ffffff;
|
| 13 |
+
--text-secondary: #a0a0a0;
|
| 14 |
+
--text-muted: #666666;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
* {
|
| 18 |
+
box-sizing: border-box;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
html,
|
| 22 |
+
body {
|
| 23 |
+
margin: 0;
|
| 24 |
+
padding: 0;
|
| 25 |
+
background-color: var(--bg-primary);
|
| 26 |
+
color: var(--text-primary);
|
| 27 |
+
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
| 28 |
+
-webkit-font-smoothing: antialiased;
|
| 29 |
+
-moz-osx-font-smoothing: grayscale;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
/* Scrollbar styling */
|
| 33 |
+
::-webkit-scrollbar {
|
| 34 |
+
width: 6px;
|
| 35 |
+
height: 6px;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
::-webkit-scrollbar-track {
|
| 39 |
+
background: var(--bg-secondary);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
::-webkit-scrollbar-thumb {
|
| 43 |
+
background: var(--border-color);
|
| 44 |
+
border-radius: 3px;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
::-webkit-scrollbar-thumb:hover {
|
| 48 |
+
background: var(--accent-orange);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
/* Terminal cursor blink */
|
| 52 |
+
@keyframes blink {
|
| 53 |
+
0%, 50% { opacity: 1; }
|
| 54 |
+
51%, 100% { opacity: 0; }
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
.cursor-blink {
|
| 58 |
+
animation: blink 1s infinite;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
/* Glow effect for accent elements */
|
| 62 |
+
.glow-orange {
|
| 63 |
+
box-shadow: 0 0 15px rgba(245, 158, 11, 0.15);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
/* Input styling */
|
| 67 |
+
input[type="text"],
|
| 68 |
+
input[type="number"],
|
| 69 |
+
select {
|
| 70 |
+
background-color: var(--bg-tertiary);
|
| 71 |
+
border: 1px solid var(--border-color);
|
| 72 |
+
color: var(--text-primary);
|
| 73 |
+
font-family: inherit;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
input[type="number"]::-webkit-inner-spin-button,
|
| 77 |
+
input[type="number"]::-webkit-outer-spin-button {
|
| 78 |
+
opacity: 1;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
/* Plotly overrides for dark theme */
|
| 82 |
+
.js-plotly-plot .plotly .main-svg {
|
| 83 |
+
background: transparent !important;
|
| 84 |
+
}
|
frontend/app/layout.tsx
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import "./globals.css";
|
| 3 |
+
|
| 4 |
+
export const metadata: Metadata = {
|
| 5 |
+
title: "Terminal.AI — VectorBT Financial Analysis",
|
| 6 |
+
description: "Professional backtesting and technical analysis platform powered by VectorBT",
|
| 7 |
+
};
|
| 8 |
+
|
| 9 |
+
export default function RootLayout({
|
| 10 |
+
children,
|
| 11 |
+
}: {
|
| 12 |
+
children: React.ReactNode;
|
| 13 |
+
}) {
|
| 14 |
+
return (
|
| 15 |
+
<html lang="en" suppressHydrationWarning>
|
| 16 |
+
<body className="min-h-screen antialiased" suppressHydrationWarning>
|
| 17 |
+
{children}
|
| 18 |
+
</body>
|
| 19 |
+
</html>
|
| 20 |
+
);
|
| 21 |
+
}
|
frontend/app/page.tsx
ADDED
|
@@ -0,0 +1,548 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect, useCallback, useRef } from "react";
|
| 4 |
+
import TerminalHeader from "./components/TerminalHeader";
|
| 5 |
+
import BacktestForm, { type BacktestConfig } from "./components/BacktestForm";
|
| 6 |
+
import PriceChart from "./components/PriceChart";
|
| 7 |
+
import IndicatorChart from "./components/IndicatorChart";
|
| 8 |
+
import EquityChart from "./components/EquityChart";
|
| 9 |
+
import VolumeChart from "./components/VolumeChart";
|
| 10 |
+
import DrawdownChart from "./components/DrawdownChart";
|
| 11 |
+
import ComparePanel from "./components/ComparePanel";
|
| 12 |
+
import FundamentalsPanel from "./components/FundamentalsPanel";
|
| 13 |
+
import OptionsFlowPanel from "./components/OptionsFlowPanel";
|
| 14 |
+
import {
|
| 15 |
+
runBacktest, compareStrategies, healthCheck, fetchFundamentals,
|
| 16 |
+
fetchOptionsFlow, fetchLeapsScanner,
|
| 17 |
+
type BacktestResponse, type CompareResponse, type FundamentalsData,
|
| 18 |
+
type OptionsFlowResponse, type LeapsScannerEntry, type LeapsScannerResponse,
|
| 19 |
+
} from "./api";
|
| 20 |
+
|
| 21 |
+
export default function Home() {
|
| 22 |
+
const [status, setStatus] = useState<"connected" | "disconnected" | "loading">("loading");
|
| 23 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 24 |
+
const [isComparing, setIsComparing] = useState(false);
|
| 25 |
+
const [error, setError] = useState<string | null>(null);
|
| 26 |
+
const [result, setResult] = useState<BacktestResponse | null>(null);
|
| 27 |
+
const [compareResult, setCompareResult] = useState<CompareResponse | null>(null);
|
| 28 |
+
const [fundamentals, setFundamentals] = useState<FundamentalsData | null>(null);
|
| 29 |
+
const [useCandlestick, setUseCandlestick] = useState(true);
|
| 30 |
+
const [activeTab, setActiveTab] = useState<"backtest" | "compare" | "options">("backtest");
|
| 31 |
+
const [optionsData, setOptionsData] = useState<OptionsFlowResponse | null>(null);
|
| 32 |
+
const [optionsLoading, setOptionsLoading] = useState(false);
|
| 33 |
+
const [leapsScanner, setLeapsScanner] = useState<LeapsScannerEntry[]>([]);
|
| 34 |
+
const [leapsScannerLoading, setLeapsScannerLoading] = useState(false);
|
| 35 |
+
const [leapsScannerInfo, setLeapsScannerInfo] = useState<LeapsScannerResponse["scan_info"] | null>(null);
|
| 36 |
+
const fundTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
| 37 |
+
|
| 38 |
+
const dictToArray = useCallback(
|
| 39 |
+
(dict: Record<string, number> | undefined, dates: string[]): (number | null)[] => {
|
| 40 |
+
if (!dict) return [];
|
| 41 |
+
return dates.map((d) => {
|
| 42 |
+
const v = dict[d];
|
| 43 |
+
return v !== undefined ? v : null;
|
| 44 |
+
});
|
| 45 |
+
},
|
| 46 |
+
[],
|
| 47 |
+
);
|
| 48 |
+
|
| 49 |
+
useEffect(() => {
|
| 50 |
+
healthCheck().then(() => setStatus("connected")).catch(() => setStatus("disconnected"));
|
| 51 |
+
}, []);
|
| 52 |
+
|
| 53 |
+
useEffect(() => {
|
| 54 |
+
if (activeTab === "options") loadLeapsScanner();
|
| 55 |
+
}, [activeTab]);
|
| 56 |
+
|
| 57 |
+
const loadLeapsScanner = async () => {
|
| 58 |
+
setLeapsScannerLoading(true);
|
| 59 |
+
try {
|
| 60 |
+
const data = await fetchLeapsScanner(100, 1);
|
| 61 |
+
setLeapsScanner(data.tickers);
|
| 62 |
+
setLeapsScannerInfo(data.scan_info);
|
| 63 |
+
} catch {
|
| 64 |
+
setLeapsScanner([]);
|
| 65 |
+
} finally {
|
| 66 |
+
setLeapsScannerLoading(false);
|
| 67 |
+
}
|
| 68 |
+
};
|
| 69 |
+
|
| 70 |
+
const handleBacktest = async (config: BacktestConfig) => {
|
| 71 |
+
setIsLoading(true);
|
| 72 |
+
setError(null);
|
| 73 |
+
setResult(null);
|
| 74 |
+
setCompareResult(null);
|
| 75 |
+
setFundamentals(null);
|
| 76 |
+
setActiveTab("backtest");
|
| 77 |
+
try {
|
| 78 |
+
const [data, fundData] = await Promise.all([
|
| 79 |
+
runBacktest(config),
|
| 80 |
+
fetchFundamentals(config.ticker).catch(() => null),
|
| 81 |
+
]);
|
| 82 |
+
setResult(data);
|
| 83 |
+
if (fundData) setFundamentals(fundData);
|
| 84 |
+
} catch (err) {
|
| 85 |
+
setError(err instanceof Error ? err.message : "Unknown error");
|
| 86 |
+
} finally {
|
| 87 |
+
setIsLoading(false);
|
| 88 |
+
}
|
| 89 |
+
};
|
| 90 |
+
|
| 91 |
+
const handleCompare = async (config: BacktestConfig) => {
|
| 92 |
+
setIsComparing(true);
|
| 93 |
+
setError(null);
|
| 94 |
+
setResult(null);
|
| 95 |
+
setCompareResult(null);
|
| 96 |
+
setFundamentals(null);
|
| 97 |
+
setActiveTab("compare");
|
| 98 |
+
try {
|
| 99 |
+
const [data, fundData] = await Promise.all([
|
| 100 |
+
compareStrategies(config),
|
| 101 |
+
fetchFundamentals(config.ticker).catch(() => null),
|
| 102 |
+
]);
|
| 103 |
+
setCompareResult(data);
|
| 104 |
+
if (fundData) setFundamentals(fundData);
|
| 105 |
+
} catch (err) {
|
| 106 |
+
setError(err instanceof Error ? err.message : "Unknown error");
|
| 107 |
+
} finally {
|
| 108 |
+
setIsComparing(false);
|
| 109 |
+
}
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
// Derived data
|
| 113 |
+
const dates = result?.dates || [];
|
| 114 |
+
const close = result?.ohlcv?.close || [];
|
| 115 |
+
const open = result?.ohlcv?.open || [];
|
| 116 |
+
const high = result?.ohlcv?.high || [];
|
| 117 |
+
const low = result?.ohlcv?.low || [];
|
| 118 |
+
const volume = result?.ohlcv?.volume || [];
|
| 119 |
+
|
| 120 |
+
const strategyName = result?.strategy || "sma";
|
| 121 |
+
const fastOverlay = dictToArray(result?.indicators?.sma?.fast as Record<string, number> | undefined, dates);
|
| 122 |
+
const slowOverlay = dictToArray(result?.indicators?.sma?.slow as Record<string, number> | undefined, dates);
|
| 123 |
+
const emaFastOverlay = dictToArray(result?.indicators?.ema?.fast as Record<string, number> | undefined, dates);
|
| 124 |
+
const emaSlowOverlay = dictToArray(result?.indicators?.ema?.slow as Record<string, number> | undefined, dates);
|
| 125 |
+
const bbUpper = dictToArray(result?.indicators?.bollinger?.upper as Record<string, number> | undefined, dates);
|
| 126 |
+
const bbMiddle = dictToArray(result?.indicators?.bollinger?.middle as Record<string, number> | undefined, dates);
|
| 127 |
+
const bbLower = dictToArray(result?.indicators?.bollinger?.lower as Record<string, number> | undefined, dates);
|
| 128 |
+
|
| 129 |
+
const entries = dictToArray(result?.signals?.entries as unknown as Record<string, number> | undefined, dates).map(Boolean);
|
| 130 |
+
const exits = dictToArray(result?.signals?.exits as unknown as Record<string, number> | undefined, dates).map(Boolean);
|
| 131 |
+
|
| 132 |
+
const rsiValues = dictToArray(result?.indicators?.rsi?.rsi as Record<string, number> | undefined, dates);
|
| 133 |
+
const macdLine = dictToArray(result?.indicators?.macd?.macd as Record<string, number> | undefined, dates);
|
| 134 |
+
const macdSignal = dictToArray(result?.indicators?.macd?.signal as Record<string, number> | undefined, dates);
|
| 135 |
+
const macdHist = dictToArray(result?.indicators?.macd?.histogram as Record<string, number> | undefined, dates);
|
| 136 |
+
const equityArr = dictToArray(result?.portfolio?.equity_curve as Record<string, number> | undefined, dates);
|
| 137 |
+
const drawdownArr = dictToArray(result?.portfolio?.drawdown as Record<string, number> | undefined, dates);
|
| 138 |
+
|
| 139 |
+
const overlays: { name: string; values: (number | null)[]; color: string; dash?: string }[] = [];
|
| 140 |
+
if (strategyName === "sma" && fastOverlay.length > 0) {
|
| 141 |
+
overlays.push({ name: "Fast SMA", values: fastOverlay, color: "#f59e0b", dash: "dot" });
|
| 142 |
+
overlays.push({ name: "Slow SMA", values: slowOverlay, color: "#3b82f6", dash: "dash" });
|
| 143 |
+
} else if (strategyName === "ema" && emaFastOverlay.length > 0) {
|
| 144 |
+
overlays.push({ name: "Fast EMA", values: emaFastOverlay, color: "#f59e0b", dash: "dot" });
|
| 145 |
+
overlays.push({ name: "Slow EMA", values: emaSlowOverlay, color: "#3b82f6", dash: "dash" });
|
| 146 |
+
} else if (strategyName === "bollinger" && bbUpper.length > 0) {
|
| 147 |
+
overlays.push({ name: "Upper BB", values: bbUpper, color: "#a78bfa", dash: "dot" });
|
| 148 |
+
overlays.push({ name: "Middle BB", values: bbMiddle, color: "#a78bfa" });
|
| 149 |
+
overlays.push({ name: "Lower BB", values: bbLower, color: "#a78bfa", dash: "dot" });
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
return (
|
| 153 |
+
<div className="min-h-screen flex flex-col">
|
| 154 |
+
<TerminalHeader status={status} />
|
| 155 |
+
|
| 156 |
+
<main className="flex-1 p-6">
|
| 157 |
+
<div className="max-w-[1600px] mx-auto">
|
| 158 |
+
{/* Terminal prompt line */}
|
| 159 |
+
<div className="flex items-center gap-2 mb-6 text-sm flex-wrap">
|
| 160 |
+
<span className="text-[var(--accent-orange)]">{">"}</span>
|
| 161 |
+
<span className="text-[var(--text-secondary)]">vectorbt</span>
|
| 162 |
+
<span className="text-[var(--text-muted)]">--backtest</span>
|
| 163 |
+
<span className="text-[var(--text-muted)]">--engine={result?.strategy || "sma"}</span>
|
| 164 |
+
<span className="text-[var(--text-muted)]">--indicators=rsi,macd</span>
|
| 165 |
+
<span className="cursor-blink text-[var(--accent-orange)]">_</span>
|
| 166 |
+
|
| 167 |
+
{/* Tabs */}
|
| 168 |
+
<div className="ml-auto flex gap-1">
|
| 169 |
+
<button
|
| 170 |
+
onClick={() => setActiveTab("backtest")}
|
| 171 |
+
className={`px-2 py-1 text-[10px] uppercase tracking-wider rounded ${
|
| 172 |
+
activeTab === "backtest"
|
| 173 |
+
? "bg-[var(--accent-orange)] text-black"
|
| 174 |
+
: "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"
|
| 175 |
+
}`}
|
| 176 |
+
>
|
| 177 |
+
Backtest
|
| 178 |
+
</button>
|
| 179 |
+
{compareResult && (
|
| 180 |
+
<button
|
| 181 |
+
onClick={() => setActiveTab("compare")}
|
| 182 |
+
className={`px-2 py-1 text-[10px] uppercase tracking-wider rounded ${
|
| 183 |
+
activeTab === "compare"
|
| 184 |
+
? "bg-[var(--accent-orange)] text-black"
|
| 185 |
+
: "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"
|
| 186 |
+
}`}
|
| 187 |
+
>
|
| 188 |
+
Compare
|
| 189 |
+
</button>
|
| 190 |
+
)}
|
| 191 |
+
<button
|
| 192 |
+
onClick={() => { setActiveTab("options"); loadLeapsScanner(); }}
|
| 193 |
+
className={`px-2 py-1 text-[10px] uppercase tracking-wider rounded ${
|
| 194 |
+
activeTab === "options"
|
| 195 |
+
? "bg-[var(--accent-orange)] text-black"
|
| 196 |
+
: "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"
|
| 197 |
+
}`}
|
| 198 |
+
>
|
| 199 |
+
Options
|
| 200 |
+
</button>
|
| 201 |
+
</div>
|
| 202 |
+
</div>
|
| 203 |
+
|
| 204 |
+
<div className="grid grid-cols-12 gap-6">
|
| 205 |
+
{/* Left Panel */}
|
| 206 |
+
<div className="col-span-12 lg:col-span-3 space-y-6">
|
| 207 |
+
<BacktestForm
|
| 208 |
+
onSubmit={handleBacktest}
|
| 209 |
+
onCompare={handleCompare}
|
| 210 |
+
onTickerChange={(ticker) => {
|
| 211 |
+
if (!ticker.trim()) return;
|
| 212 |
+
if (fundTimerRef.current) clearTimeout(fundTimerRef.current);
|
| 213 |
+
fundTimerRef.current = setTimeout(() => {
|
| 214 |
+
fetchFundamentals(ticker).then(setFundamentals).catch(() => {});
|
| 215 |
+
}, 800);
|
| 216 |
+
fetchOptionsFlow(ticker).then(setOptionsData).catch(() => {});
|
| 217 |
+
}}
|
| 218 |
+
isLoading={isLoading}
|
| 219 |
+
isComparing={isComparing}
|
| 220 |
+
/>
|
| 221 |
+
{fundamentals && <FundamentalsPanel data={fundamentals} />}
|
| 222 |
+
</div>
|
| 223 |
+
|
| 224 |
+
{/* Right Panel */}
|
| 225 |
+
<div className="col-span-12 lg:col-span-9 space-y-6">
|
| 226 |
+
{/* Error */}
|
| 227 |
+
{error && (
|
| 228 |
+
<div className="bg-red-950/30 border border-red-800 rounded-lg p-4">
|
| 229 |
+
<div className="flex items-center gap-2 text-red-400 text-sm">
|
| 230 |
+
<span>[ERROR]</span>
|
| 231 |
+
<span>{error}</span>
|
| 232 |
+
</div>
|
| 233 |
+
</div>
|
| 234 |
+
)}
|
| 235 |
+
|
| 236 |
+
{/* Loading */}
|
| 237 |
+
{(isLoading || isComparing) && (
|
| 238 |
+
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-12 text-center">
|
| 239 |
+
<div className="inline-block w-8 h-8 border-2 border-[var(--accent-orange)] border-t-transparent rounded-full animate-spin mb-4" />
|
| 240 |
+
<div className="text-[var(--text-secondary)] text-sm">
|
| 241 |
+
{isComparing ? "Running all strategies..." : "Running backtest simulation..."}
|
| 242 |
+
</div>
|
| 243 |
+
<div className="text-[var(--text-muted)] text-xs mt-1">
|
| 244 |
+
Fetching data from Yahoo Finance + VectorBT analysis
|
| 245 |
+
</div>
|
| 246 |
+
</div>
|
| 247 |
+
)}
|
| 248 |
+
|
| 249 |
+
{/* Empty state */}
|
| 250 |
+
{!isLoading && !isComparing && !result && !compareResult && !error && (
|
| 251 |
+
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-12 text-center">
|
| 252 |
+
<div className="text-[var(--text-muted)] text-4xl mb-4">{">"}_</div>
|
| 253 |
+
<div className="text-[var(--text-secondary)] text-sm mb-2">Terminal.AI Ready</div>
|
| 254 |
+
<div className="text-[var(--text-muted)] text-xs">
|
| 255 |
+
Configure parameters and execute a backtest to begin analysis
|
| 256 |
+
</div>
|
| 257 |
+
</div>
|
| 258 |
+
)}
|
| 259 |
+
|
| 260 |
+
{/* Backtest Results */}
|
| 261 |
+
{result && !isLoading && activeTab === "backtest" && (
|
| 262 |
+
<>
|
| 263 |
+
<div className="flex items-center gap-4 text-xs text-[var(--text-muted)] flex-wrap">
|
| 264 |
+
<span>[{result.ticker}] {result.start_date} → {result.end_date}</span>
|
| 265 |
+
<span>|</span>
|
| 266 |
+
<span>{result.data_points} data points</span>
|
| 267 |
+
<span>|</span>
|
| 268 |
+
<span>Interval: {result.interval}</span>
|
| 269 |
+
<span>|</span>
|
| 270 |
+
<span>Strategy: {result.strategy.toUpperCase()}</span>
|
| 271 |
+
<label className="ml-auto flex items-center gap-2 cursor-pointer">
|
| 272 |
+
<span className="text-[var(--text-muted)] text-[10px] uppercase tracking-wider">
|
| 273 |
+
{useCandlestick ? "Candlestick" : "Line"}
|
| 274 |
+
</span>
|
| 275 |
+
<button
|
| 276 |
+
onClick={() => setUseCandlestick(!useCandlestick)}
|
| 277 |
+
className={`w-8 h-4 rounded-full transition-colors ${
|
| 278 |
+
useCandlestick ? "bg-[var(--accent-orange)]" : "bg-[var(--bg-tertiary)]"
|
| 279 |
+
}`}
|
| 280 |
+
>
|
| 281 |
+
<span
|
| 282 |
+
className={`block w-3 h-3 bg-black rounded-full transition-transform mt-0.5 ${
|
| 283 |
+
useCandlestick ? "translate-x-4" : "translate-x-0.5"
|
| 284 |
+
}`}
|
| 285 |
+
/>
|
| 286 |
+
</button>
|
| 287 |
+
</label>
|
| 288 |
+
</div>
|
| 289 |
+
|
| 290 |
+
<PriceChart
|
| 291 |
+
dates={dates} open={open} high={high} low={low} close={close}
|
| 292 |
+
entries={entries} exits={exits} ticker={result.ticker}
|
| 293 |
+
overlays={overlays} candlestick={useCandlestick}
|
| 294 |
+
/>
|
| 295 |
+
|
| 296 |
+
{volume.length > 0 && (
|
| 297 |
+
<VolumeChart dates={dates} volume={volume} close={close} />
|
| 298 |
+
)}
|
| 299 |
+
|
| 300 |
+
<EquityChart
|
| 301 |
+
dates={dates} equity={equityArr}
|
| 302 |
+
initCash={
|
| 303 |
+
result.portfolio.stats && typeof result.portfolio.stats["Start Value"] === "number"
|
| 304 |
+
? (result.portfolio.stats["Start Value"] as number)
|
| 305 |
+
: 10000
|
| 306 |
+
}
|
| 307 |
+
/>
|
| 308 |
+
|
| 309 |
+
{drawdownArr.length > 0 && (
|
| 310 |
+
<DrawdownChart
|
| 311 |
+
dates={dates} drawdown={drawdownArr}
|
| 312 |
+
maxDrawdown={
|
| 313 |
+
result.portfolio.stats && typeof result.portfolio.stats["Max Drawdown [%]"] === "number"
|
| 314 |
+
? (result.portfolio.stats["Max Drawdown [%]"] as number) / 100
|
| 315 |
+
: undefined
|
| 316 |
+
}
|
| 317 |
+
/>
|
| 318 |
+
)}
|
| 319 |
+
|
| 320 |
+
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
| 321 |
+
<IndicatorChart
|
| 322 |
+
dates={dates} values={rsiValues}
|
| 323 |
+
title={`RSI (${result.indicators.rsi.window})`}
|
| 324 |
+
color="#a78bfa"
|
| 325 |
+
overbought={result.indicators.rsi.overbought}
|
| 326 |
+
oversold={result.indicators.rsi.oversold}
|
| 327 |
+
/>
|
| 328 |
+
<IndicatorChart
|
| 329 |
+
dates={dates} values={macdLine}
|
| 330 |
+
title={`MACD (${result.indicators.macd.fast_window}/${result.indicators.macd.slow_window}/${result.indicators.macd.signal_window})`}
|
| 331 |
+
color="#f59e0b"
|
| 332 |
+
secondaryValues={macdSignal}
|
| 333 |
+
secondaryColor="#3b82f6"
|
| 334 |
+
secondaryName="Signal"
|
| 335 |
+
histogramValues={macdHist}
|
| 336 |
+
/>
|
| 337 |
+
</div>
|
| 338 |
+
</>
|
| 339 |
+
)}
|
| 340 |
+
|
| 341 |
+
{/* Compare Results */}
|
| 342 |
+
{compareResult && !isComparing && activeTab === "compare" && (
|
| 343 |
+
<>
|
| 344 |
+
<div className="flex items-center gap-4 text-xs text-[var(--text-muted)] flex-wrap">
|
| 345 |
+
<span>[{compareResult.ticker}] {compareResult.start_date} → {compareResult.end_date}</span>
|
| 346 |
+
<span>|</span>
|
| 347 |
+
<span>{compareResult.data_points} data points</span>
|
| 348 |
+
<span>|</span>
|
| 349 |
+
<span>Interval: {compareResult.interval}</span>
|
| 350 |
+
</div>
|
| 351 |
+
|
| 352 |
+
<ComparePanel
|
| 353 |
+
comparison={compareResult.comparison}
|
| 354 |
+
dates={compareResult.dates}
|
| 355 |
+
/>
|
| 356 |
+
|
| 357 |
+
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
| 358 |
+
<IndicatorChart
|
| 359 |
+
dates={compareResult.dates}
|
| 360 |
+
values={dictToArray(compareResult.indicators.rsi.rsi as Record<string, number>, compareResult.dates)}
|
| 361 |
+
title={`RSI (${compareResult.indicators.rsi.window})`}
|
| 362 |
+
color="#a78bfa"
|
| 363 |
+
overbought={compareResult.indicators.rsi.overbought}
|
| 364 |
+
oversold={compareResult.indicators.rsi.oversold}
|
| 365 |
+
/>
|
| 366 |
+
<IndicatorChart
|
| 367 |
+
dates={compareResult.dates}
|
| 368 |
+
values={dictToArray(compareResult.indicators.macd.macd as Record<string, number>, compareResult.dates)}
|
| 369 |
+
title={`MACD (${compareResult.indicators.macd.fast_window}/${compareResult.indicators.macd.slow_window}/${compareResult.indicators.macd.signal_window})`}
|
| 370 |
+
color="#f59e0b"
|
| 371 |
+
secondaryValues={dictToArray(compareResult.indicators.macd.signal as Record<string, number>, compareResult.dates)}
|
| 372 |
+
secondaryColor="#3b82f6"
|
| 373 |
+
secondaryName="Signal"
|
| 374 |
+
histogramValues={dictToArray(compareResult.indicators.macd.histogram as Record<string, number>, compareResult.dates)}
|
| 375 |
+
/>
|
| 376 |
+
</div>
|
| 377 |
+
</>
|
| 378 |
+
)}
|
| 379 |
+
|
| 380 |
+
{/* Options Tab */}
|
| 381 |
+
{activeTab === "options" && (
|
| 382 |
+
<div className="space-y-6">
|
| 383 |
+
{/* Per-ticker options flow panel */}
|
| 384 |
+
{result && <OptionsFlowPanel ticker={result.ticker} />}
|
| 385 |
+
{!result && (
|
| 386 |
+
<div style={{
|
| 387 |
+
background: "var(--bg-secondary)", border: "1px solid var(--border-color)",
|
| 388 |
+
borderRadius: 8, padding: 48, textAlign: "center",
|
| 389 |
+
}}>
|
| 390 |
+
<div style={{ color: "var(--text-muted)", fontSize: 48, marginBottom: 16 }}>Op_</div>
|
| 391 |
+
<div style={{ color: "var(--text-secondary)", fontSize: 14, marginBottom: 8 }}>Options Flow</div>
|
| 392 |
+
<div style={{ color: "var(--text-muted)", fontSize: 12 }}>
|
| 393 |
+
Run a backtest first to view options flow for that ticker.
|
| 394 |
+
</div>
|
| 395 |
+
</div>
|
| 396 |
+
)}
|
| 397 |
+
|
| 398 |
+
{/* LEAPS Scanner — S&P 100 (en alt) */}
|
| 399 |
+
<div style={{
|
| 400 |
+
background: "#0a0a0a", border: "1px solid #1e1e1e",
|
| 401 |
+
borderRadius: 6, padding: 16,
|
| 402 |
+
}}>
|
| 403 |
+
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
|
| 404 |
+
<h3 style={{
|
| 405 |
+
margin: 0, color: "#f97316", fontFamily: "monospace",
|
| 406 |
+
fontSize: 14, fontWeight: 700, letterSpacing: 1,
|
| 407 |
+
}}>
|
| 408 |
+
⚑ LEAPS SCANNER — S&P 100 — anormal hacim/OI spike
|
| 409 |
+
</h3>
|
| 410 |
+
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
| 411 |
+
{leapsScannerInfo && (
|
| 412 |
+
<span style={{ color: "#555", fontFamily: "monospace", fontSize: 10 }}>
|
| 413 |
+
{leapsScannerInfo.scan_time_sec}s — {leapsScanner.length} anomalili
|
| 414 |
+
</span>
|
| 415 |
+
)}
|
| 416 |
+
<button
|
| 417 |
+
onClick={loadLeapsScanner}
|
| 418 |
+
disabled={leapsScannerLoading}
|
| 419 |
+
style={{
|
| 420 |
+
background: leapsScannerLoading ? "#333" : "#f97316",
|
| 421 |
+
color: leapsScannerLoading ? "#666" : "#000",
|
| 422 |
+
border: "none", borderRadius: 4, padding: "4px 10px",
|
| 423 |
+
fontFamily: "monospace", fontSize: 10, fontWeight: 700,
|
| 424 |
+
cursor: leapsScannerLoading ? "not-allowed" : "pointer",
|
| 425 |
+
}}
|
| 426 |
+
>
|
| 427 |
+
{leapsScannerLoading ? "Scanning…" : "↻ Rescan"}
|
| 428 |
+
</button>
|
| 429 |
+
</div>
|
| 430 |
+
</div>
|
| 431 |
+
|
| 432 |
+
{leapsScannerLoading ? (
|
| 433 |
+
<p style={{ color: "#888", fontFamily: "monospace" }}>Scanning S&P 100 for LEAPS anomalies… (~10s)</p>
|
| 434 |
+
) : leapsScanner.length ? (
|
| 435 |
+
<table style={{
|
| 436 |
+
width: "100%", borderCollapse: "collapse", fontFamily: "monospace", fontSize: 11,
|
| 437 |
+
}}>
|
| 438 |
+
<thead>
|
| 439 |
+
<tr>
|
| 440 |
+
<th style={{ padding: "6px 8px", textAlign: "left", color: "#888" }}>Ticker</th>
|
| 441 |
+
<th style={{ padding: "6px 8px", textAlign: "right", color: "#888" }}>LEAPS Vol</th>
|
| 442 |
+
<th style={{ padding: "6px 8px", textAlign: "right", color: "#888" }}>C/P</th>
|
| 443 |
+
<th style={{ padding: "6px 8px", textAlign: "right", color: "#888" }}>Spikes</th>
|
| 444 |
+
<th style={{ padding: "6px 8px", textAlign: "left", color: "#888" }}>Top Spike</th>
|
| 445 |
+
<th style={{ padding: "6px 8px", textAlign: "right", color: "#888" }}>Signal</th>
|
| 446 |
+
</tr>
|
| 447 |
+
</thead>
|
| 448 |
+
<tbody>
|
| 449 |
+
{leapsScanner.map((entry, i) => {
|
| 450 |
+
const spike = entry.top_spike;
|
| 451 |
+
const handleClickTicker = async () => {
|
| 452 |
+
setActiveTab("backtest");
|
| 453 |
+
setIsLoading(true);
|
| 454 |
+
setError(null);
|
| 455 |
+
setResult(null);
|
| 456 |
+
setCompareResult(null);
|
| 457 |
+
setFundamentals(null);
|
| 458 |
+
try {
|
| 459 |
+
const ticker = entry.ticker;
|
| 460 |
+
// Get full date range for this ticker
|
| 461 |
+
let startDate, endDate;
|
| 462 |
+
try {
|
| 463 |
+
const { default: api } = await import("./api");
|
| 464 |
+
// fetchTickerRange is exported from api
|
| 465 |
+
const rangeRes = await fetch(`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}/api/ticker/${encodeURIComponent(ticker)}/range`);
|
| 466 |
+
if (rangeRes.ok) {
|
| 467 |
+
const range = await rangeRes.json();
|
| 468 |
+
startDate = range.earliest.split("T")[0];
|
| 469 |
+
endDate = range.latest.split("T")[0];
|
| 470 |
+
}
|
| 471 |
+
} catch {}
|
| 472 |
+
if (!startDate) {
|
| 473 |
+
const end = new Date();
|
| 474 |
+
const start = new Date(end);
|
| 475 |
+
start.setFullYear(start.getFullYear() - 1);
|
| 476 |
+
const fmt = (d: Date) => d.toISOString().split("T")[0];
|
| 477 |
+
startDate = fmt(start);
|
| 478 |
+
endDate = fmt(end);
|
| 479 |
+
}
|
| 480 |
+
const config: BacktestConfig = {
|
| 481 |
+
ticker, start_date: startDate, end_date: endDate,
|
| 482 |
+
interval: "1d", strategy: "sma",
|
| 483 |
+
fast_ma: 10, slow_ma: 30, bb_window: 20, bb_std: 2.0,
|
| 484 |
+
fees: 0.001, init_cash: 10000,
|
| 485 |
+
rsi_window: 14, macd_fast: 12, macd_slow: 26, macd_signal: 9,
|
| 486 |
+
};
|
| 487 |
+
const [data, fundData] = await Promise.all([
|
| 488 |
+
runBacktest(config),
|
| 489 |
+
fetchFundamentals(ticker).catch(() => null),
|
| 490 |
+
]);
|
| 491 |
+
setResult(data);
|
| 492 |
+
if (fundData) setFundamentals(fundData);
|
| 493 |
+
} catch (err) {
|
| 494 |
+
setError(err instanceof Error ? err.message : "Unknown error");
|
| 495 |
+
} finally {
|
| 496 |
+
setIsLoading(false);
|
| 497 |
+
}
|
| 498 |
+
};
|
| 499 |
+
return (
|
| 500 |
+
<tr
|
| 501 |
+
key={i}
|
| 502 |
+
onClick={handleClickTicker}
|
| 503 |
+
style={{ borderBottom: "1px solid #1a1a1a", cursor: "pointer" }}
|
| 504 |
+
className="hover:bg-[#1a1a1a] transition-colors"
|
| 505 |
+
>
|
| 506 |
+
<td style={{ padding: "6px 8px", color: "#f97316", fontWeight: 700 }}>{entry.ticker}</td>
|
| 507 |
+
<td style={{ padding: "6px 8px", textAlign: "right", color: "#e5e7eb" }}>{entry.total_leaps_vol.toLocaleString()}</td>
|
| 508 |
+
<td style={{
|
| 509 |
+
padding: "6px 8px", textAlign: "right",
|
| 510 |
+
color: entry.call_put_ratio > 1 ? "#22c55e" : "#ef4444",
|
| 511 |
+
}}>{entry.call_put_ratio.toFixed(2)}</td>
|
| 512 |
+
<td style={{ padding: "6px 8px", textAlign: "right", color: "#f97316" }}>{entry.unusual_count}</td>
|
| 513 |
+
<td style={{ padding: "6px 8px", color: "#888", fontSize: 10 }}>
|
| 514 |
+
{spike?.type} {spike?.expiry} K={spike?.strike?.toFixed(0)} vol={spike?.volume?.toLocaleString()} voi={spike?.vol_oi_ratio}
|
| 515 |
+
</td>
|
| 516 |
+
<td style={{
|
| 517 |
+
padding: "6px 8px", textAlign: "right", fontWeight: 700,
|
| 518 |
+
color: entry.call_put_ratio > 1.5 ? "#22c55e" : entry.call_put_ratio < 0.67 ? "#ef4444" : "#f59e0b",
|
| 519 |
+
}}>
|
| 520 |
+
{entry.call_put_ratio > 1.5 ? "Bullish" : entry.call_put_ratio < 0.67 ? "Bearish" : "Neutral"}
|
| 521 |
+
</td>
|
| 522 |
+
</tr>
|
| 523 |
+
);
|
| 524 |
+
})}
|
| 525 |
+
</tbody>
|
| 526 |
+
</table>
|
| 527 |
+
) : !leapsScannerLoading ? (
|
| 528 |
+
<p style={{ color: "#555", fontFamily: "monospace", fontSize: 10 }}>
|
| 529 |
+
No LEAPS anomalies found. Click Rescan to try again.
|
| 530 |
+
</p>
|
| 531 |
+
) : null}
|
| 532 |
+
</div>
|
| 533 |
+
</div>
|
| 534 |
+
)}
|
| 535 |
+
</div>
|
| 536 |
+
</div>
|
| 537 |
+
</div>
|
| 538 |
+
</main>
|
| 539 |
+
|
| 540 |
+
<footer className="border-t border-[var(--border-color)] bg-[var(--bg-secondary)] px-6 py-3">
|
| 541 |
+
<div className="max-w-[1600px] mx-auto flex items-center justify-between text-xs text-[var(--text-muted)]">
|
| 542 |
+
<span>Terminal.AI v1.1.0 — Powered by VectorBT + FastAPI + Next.js</span>
|
| 543 |
+
<span>Data: Yahoo Finance (yfinance)</span>
|
| 544 |
+
</div>
|
| 545 |
+
</footer>
|
| 546 |
+
</div>
|
| 547 |
+
);
|
| 548 |
+
}
|
frontend/eslint.config.mjs
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig, globalIgnores } from "eslint/config";
|
| 2 |
+
import nextVitals from "eslint-config-next/core-web-vitals";
|
| 3 |
+
import nextTs from "eslint-config-next/typescript";
|
| 4 |
+
|
| 5 |
+
const eslintConfig = defineConfig([
|
| 6 |
+
...nextVitals,
|
| 7 |
+
...nextTs,
|
| 8 |
+
// Override default ignores of eslint-config-next.
|
| 9 |
+
globalIgnores([
|
| 10 |
+
// Default ignores of eslint-config-next:
|
| 11 |
+
".next/**",
|
| 12 |
+
"out/**",
|
| 13 |
+
"build/**",
|
| 14 |
+
"next-env.d.ts",
|
| 15 |
+
]),
|
| 16 |
+
]);
|
| 17 |
+
|
| 18 |
+
export default eslintConfig;
|
frontend/next.config.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('next').NextConfig} */
|
| 2 |
+
const nextConfig = {
|
| 3 |
+
reactStrictMode: true,
|
| 4 |
+
};
|
| 5 |
+
|
| 6 |
+
module.exports = nextConfig;
|
frontend/next.config.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { NextConfig } from "next";
|
| 2 |
+
|
| 3 |
+
const nextConfig: NextConfig = {
|
| 4 |
+
/* config options here */
|
| 5 |
+
};
|
| 6 |
+
|
| 7 |
+
export default nextConfig;
|