commit 4df8d53b1a0a36658ec81b5964de124ebd26c0da Author: tika Date: Tue Feb 24 23:25:51 2026 +0100 feat: trading bot MVP — ICT Order Block + Liquidity Sweep strategy Full-stack trading bot with: - FastAPI backend with ICT strategy (Order Block + Liquidity Sweep detection) - Backtester engine with rolling window, spread simulation, and performance metrics - Hybrid market data service (yfinance + TwelveData with rate limiting + SQLite cache) - Simulated exchange for paper trading - React/TypeScript frontend with TradingView lightweight-charts v5 - Live dashboard with candlestick chart, OHLC legend, trade markers - Backtest page with configurable parameters, equity curve, and trade table - WebSocket support for real-time updates - Bot runner with asyncio loop for automated trading Co-Authored-By: Claude Opus 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6e015b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.Python +*.egg-info/ +dist/ +build/ +.venv/ +venv/ +env/ + +# Secrets — NE JAMAIS COMMITER +backend/.env +*.db +*.sqlite + +# Frontend +frontend/node_modules/ +frontend/dist/ +frontend/.vite/ + +# Editors +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +logs/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b4efd9e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,138 @@ +# Trader Bot — CLAUDE.md + +Bot de trading ICT (Order Block + Liquidity Sweep) avec interface web. + +## Architecture + +``` +trader-bot/ +├── backend/ # Python / FastAPI +│ ├── app/ +│ │ ├── main.py # Entry point FastAPI +│ │ ├── core/ +│ │ │ ├── config.py # Paramètres via .env +│ │ │ ├── database.py # SQLAlchemy async + SQLite +│ │ │ ├── exchange/ +│ │ │ │ ├── base.py # AbstractExchange interface +│ │ │ │ └── oanda.py # Implémentation OANDA +│ │ │ ├── strategy/ +│ │ │ │ ├── base.py # AbstractStrategy + dataclasses +│ │ │ │ └── order_block_sweep.py # Stratégie ICT principale +│ │ │ ├── backtester.py # Moteur backtest +│ │ │ └── bot.py # BotRunner asyncio +│ │ ├── models/ # SQLAlchemy (Candle, Trade, BacktestResult) +│ │ ├── services/ +│ │ │ ├── market_data.py # Fetch + cache candles +│ │ │ └── trade_manager.py # Position sizing + exécution +│ │ └── api/ +│ │ ├── routes/ # GET/POST endpoints +│ │ └── websocket.py # WS broadcast manager +│ ├── requirements.txt +│ └── .env # Clés OANDA (NE PAS COMMITER) +└── frontend/ # React / TypeScript / Vite + └── src/ + ├── components/Chart/ # CandlestickChart (lightweight-charts) + ├── components/Dashboard/ # BotStatus, PnLSummary, TradeList + ├── components/Backtest/ # BacktestForm, BacktestResults + ├── pages/ # Dashboard.tsx, Backtest.tsx + ├── hooks/useWebSocket.ts # WS auto-reconnect + └── lib/api.ts # Axios client typé +``` + +## Stack technique + +- **Backend** : Python 3.11+, FastAPI, SQLAlchemy async, oandapyV20, pandas, pandas-ta +- **Frontend** : React 18, TypeScript, Vite, Tailwind CSS, lightweight-charts (TradingView) +- **Base de données** : SQLite (fichier `backend/trader_bot.db`) +- **Exchange** : OANDA v20 REST API (environment practice/live via `.env`) + +## Commandes de développement + +### Backend +```bash +cd backend +pip install -r requirements.txt +# Copier et configurer les variables d'environnement +cp .env.example .env +# Remplir OANDA_API_KEY et OANDA_ACCOUNT_ID dans .env +uvicorn app.main:app --reload --port 8000 +``` + +### Frontend +```bash +cd frontend +npm install +npm run dev # http://localhost:5173 +``` + +## Configuration OANDA + +Variables dans `backend/.env` : +- `OANDA_API_KEY` : clé API (Settings → API Access sur fxpractice.oanda.com) +- `OANDA_ACCOUNT_ID` : ID du compte (format `001-004-XXXXXXX-001`) +- `OANDA_ENVIRONMENT` : `practice` (paper trading) ou `live` + +Pour retrouver l'Account ID après démarrage du backend : +``` +GET http://localhost:8000/api/bot/status +``` +Ou via curl vers l'API OANDA : +``` +curl -H "Authorization: Bearer " https://api-fxpractice.oanda.com/v3/accounts +``` + +## Instruments supportés + +| Instrument | Description | +|---|---| +| EUR_USD | Euro / Dollar | +| GBP_USD | Livre / Dollar | +| USD_JPY | Dollar / Yen | +| SPX500_USD | S&P 500 (CFD) | +| NAS100_USD | Nasdaq 100 (CFD) | +| XAU_USD | Or / Dollar | + +## Stratégie — Order Block + Liquidity Sweep (ICT/SMC) + +### Logique de détection (order_block_sweep.py) + +1. **Swing Detection** : swing high/low sur `swing_strength` bougies de chaque côté +2. **Equal Highs/Lows** : deux swings proches (< `liquidity_tolerance_pips`) = pool de liquidité +3. **Liquidity Sweep** : wick qui dépasse le niveau ET close de l'autre côté +4. **Order Block** : dernière bougie bearish avant impulse haussière (bullish OB) / inverse +5. **Signal** : sweep confirmé + prix revient dans la zone OB +6. **SL** : au-delà de l'OB · **TP** : R:R fixe (`rr_ratio`) + +### Paramètres configurables + +| Param | Défaut | Description | +|---|---|---| +| `swing_strength` | 5 | N bougies de chaque côté pour valider un swing | +| `liquidity_tolerance_pips` | 2.0 | Tolérance Equal H/L en pips | +| `rr_ratio` | 2.0 | Ratio Risk/Reward cible | +| `min_impulse_candles` | 3 | Bougies min dans l'impulsion | +| `min_impulse_factor` | 1.5 | Taille impulsion vs corps moyen | + +## API Endpoints + +| Method | Route | Description | +|---|---|---| +| GET | `/api/candles` | Candles OANDA (params: instrument, granularity, count) | +| GET | `/api/trades` | Historique des trades (params: source, status) | +| POST | `/api/backtest` | Lance un backtest, retourne métriques + trades | +| GET | `/api/backtest/history` | 20 derniers backtests | +| GET | `/api/bot/status` | État du bot | +| POST | `/api/bot/start` | Démarrer le bot | +| POST | `/api/bot/stop` | Arrêter le bot | +| WS | `/ws/live` | Stream events temps réel | + +## Roadmap / TODO + +- [ ] Remplir `OANDA_ACCOUNT_ID` dans `backend/.env` +- [ ] Tester la connexion OANDA (`GET /api/candles?instrument=EUR_USD`) +- [ ] Valider la détection des Order Blocks sur données réelles +- [ ] Afficher les zones OB et niveaux de liquidité sur le chart (overlay) +- [ ] Ajouter le support multi-timeframe (HTF pour context + LTF pour entrée) +- [ ] Implémenter le trailing stop-loss +- [ ] Ajouter des notifications (email/telegram) sur les trades +- [ ] Ajouter tests unitaires pour la stratégie diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..7f2b61e --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,18 @@ +# TwelveData API (données intraday historiques au-delà des limites yfinance) +# Clé gratuite sur https://twelvedata.com/ — 800 req/jour +TWELVEDATA_API_KEY=your_twelvedata_key_here + +# OANDA API (optionnel — pour le trading réel via broker) +OANDA_API_KEY=your_api_key_here +OANDA_ACCOUNT_ID=your_account_id_here +OANDA_ENVIRONMENT=practice + +# Bot Configuration +BOT_DEFAULT_INSTRUMENT=EUR_USD +BOT_DEFAULT_GRANULARITY=H1 +BOT_RISK_PERCENT=1.0 +BOT_RR_RATIO=2.0 +BOT_INITIAL_BALANCE=10000.0 + +# Database +DATABASE_URL=sqlite+aiosqlite:///./trader_bot.db diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/routes/__init__.py b/backend/app/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/routes/backtest.py b/backend/app/api/routes/backtest.py new file mode 100644 index 0000000..02eeb3e --- /dev/null +++ b/backend/app/api/routes/backtest.py @@ -0,0 +1,147 @@ +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.backtester import Backtester +from app.core.database import get_db +from app.core.strategy.order_block_sweep import OrderBlockSweepParams, OrderBlockSweepStrategy +from app.models.backtest_result import BacktestResult +from app.services.market_data import MarketDataService + +router = APIRouter(prefix="/backtest", tags=["backtest"]) + + +class BacktestRequest(BaseModel): + instrument: str = Field(default="EUR_USD") + granularity: str = Field(default="H1") + candle_count: int = Field(default=500, ge=100, le=5000) + initial_balance: float = Field(default=10_000.0, gt=0) + risk_percent: float = Field(default=1.0, gt=0, le=10) + rr_ratio: float = Field(default=2.0, ge=1.0, le=10.0) + swing_strength: int = Field(default=5, ge=2, le=20) + liquidity_tolerance_pips: float = Field(default=2.0, ge=0.5) + spread_pips: float = Field(default=1.5, ge=0) + + +@router.post("") +async def run_backtest( + req: BacktestRequest, + db: AsyncSession = Depends(get_db), +): + service = MarketDataService(db) + + # Fetch données historiques (yfinance + TwelveData + cache DB) + try: + df = await service.get_candles(req.instrument, req.granularity, req.candle_count) + except Exception as e: + raise HTTPException(502, f"Erreur fetch données: {e}") + + if len(df) < 150: + raise HTTPException(400, "Pas assez de données (min 150 bougies)") + + # Configurer la stratégie + params = OrderBlockSweepParams( + swing_strength=req.swing_strength, + liquidity_tolerance_pips=req.liquidity_tolerance_pips, + rr_ratio=req.rr_ratio, + ) + strategy = OrderBlockSweepStrategy(params) + + # Lancer le backtest + backtester = Backtester( + strategy=strategy, + initial_balance=req.initial_balance, + risk_percent=req.risk_percent, + spread_pips=req.spread_pips, + ) + metrics = backtester.run(df) + + # Sauvegarder en BDD + result = BacktestResult( + instrument=req.instrument, + granularity=req.granularity, + start_date=df.iloc[0]["time"], + end_date=df.iloc[-1]["time"], + initial_balance=metrics.initial_balance, + final_balance=metrics.final_balance, + total_pnl=metrics.total_pnl, + total_trades=metrics.total_trades, + winning_trades=metrics.winning_trades, + losing_trades=metrics.losing_trades, + win_rate=metrics.win_rate, + max_drawdown=metrics.max_drawdown, + sharpe_ratio=metrics.sharpe_ratio, + expectancy=metrics.expectancy, + equity_curve=metrics.equity_curve, + strategy_params=strategy.get_params(), + ) + db.add(result) + await db.commit() + await db.refresh(result) + + # Formater les trades pour la réponse + trades_out = [ + { + "direction": t.direction, + "entry_price": t.entry_price, + "exit_price": t.exit_price, + "stop_loss": t.stop_loss, + "take_profit": t.take_profit, + "entry_time": str(t.entry_time), + "exit_time": str(t.exit_time) if t.exit_time else None, + "pnl_pips": t.pnl_pips, + "status": t.status, + "signal_type": t.signal_type, + } + for t in metrics.trades + ] + + return { + "backtest_id": result.id, + "instrument": req.instrument, + "granularity": req.granularity, + "period": {"start": str(df.iloc[0]["time"]), "end": str(df.iloc[-1]["time"])}, + "metrics": { + "initial_balance": metrics.initial_balance, + "final_balance": round(metrics.final_balance, 2), + "total_pnl": round(metrics.total_pnl, 2), + "total_pnl_pct": round(metrics.total_pnl_pct, 2), + "total_trades": metrics.total_trades, + "winning_trades": metrics.winning_trades, + "losing_trades": metrics.losing_trades, + "win_rate": round(metrics.win_rate, 1), + "avg_win_pips": round(metrics.avg_win_pips, 1), + "avg_loss_pips": round(metrics.avg_loss_pips, 1), + "expectancy": round(metrics.expectancy, 2), + "max_drawdown": round(metrics.max_drawdown, 2), + "max_drawdown_pct": round(metrics.max_drawdown_pct, 2), + "sharpe_ratio": round(metrics.sharpe_ratio, 3), + "profit_factor": round(metrics.profit_factor, 2), + }, + "equity_curve": metrics.equity_curve, + "trades": trades_out, + } + + +@router.get("/history") +async def get_backtest_history(db: AsyncSession = Depends(get_db)): + from sqlalchemy import select + stmt = select(BacktestResult).order_by(BacktestResult.created_at.desc()).limit(20) + result = await db.execute(stmt) + rows = result.scalars().all() + return [ + { + "id": r.id, + "instrument": r.instrument, + "granularity": r.granularity, + "total_pnl": r.total_pnl, + "win_rate": r.win_rate, + "total_trades": r.total_trades, + "sharpe_ratio": r.sharpe_ratio, + "created_at": str(r.created_at), + } + for r in rows + ] diff --git a/backend/app/api/routes/bot.py b/backend/app/api/routes/bot.py new file mode 100644 index 0000000..aa80f03 --- /dev/null +++ b/backend/app/api/routes/bot.py @@ -0,0 +1,46 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from app.core.config import settings + +router = APIRouter(prefix="/bot", tags=["bot"]) + +# Instance globale du bot (initialisée dans main.py) +_bot_runner = None + + +def set_bot_runner(runner) -> None: + global _bot_runner + _bot_runner = runner + + +class BotStartRequest(BaseModel): + instrument: str = "" + granularity: str = "" + + +@router.get("/status") +async def bot_status(): + if _bot_runner is None: + return {"running": False, "message": "Bot non initialisé"} + return _bot_runner.get_status() + + +@router.post("/start") +async def start_bot(req: BotStartRequest): + if _bot_runner is None: + raise HTTPException(503, "Bot non initialisé") + if req.instrument: + _bot_runner.instrument = req.instrument + if req.granularity: + _bot_runner.granularity = req.granularity + await _bot_runner.start() + return {"message": "Bot démarré", "status": _bot_runner.get_status()} + + +@router.post("/stop") +async def stop_bot(): + if _bot_runner is None: + raise HTTPException(503, "Bot non initialisé") + await _bot_runner.stop() + return {"message": "Bot arrêté"} diff --git a/backend/app/api/routes/candles.py b/backend/app/api/routes/candles.py new file mode 100644 index 0000000..a77a8b5 --- /dev/null +++ b/backend/app/api/routes/candles.py @@ -0,0 +1,30 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.services.market_data import MarketDataService + +router = APIRouter(prefix="/candles", tags=["candles"]) + +VALID_GRANULARITIES = {"M1", "M5", "M15", "M30", "H1", "H4", "D"} + + +@router.get("") +async def get_candles( + instrument: str = Query(default="EUR_USD", description="Ex: EUR_USD, GBP_USD, SPX500_USD"), + granularity: str = Query(default="H1", description="M1, M5, M15, M30, H1, H4, D"), + count: int = Query(default=200, ge=10, le=5000), + db: AsyncSession = Depends(get_db), +): + if granularity not in VALID_GRANULARITIES: + raise HTTPException(400, f"Granularité invalide. Valides: {VALID_GRANULARITIES}") + + service = MarketDataService(db) + df = await service.get_candles(instrument, granularity, count) + + return { + "instrument": instrument, + "granularity": granularity, + "count": len(df), + "candles": df.to_dict(orient="records") if not df.empty else [], + } diff --git a/backend/app/api/routes/trades.py b/backend/app/api/routes/trades.py new file mode 100644 index 0000000..9a919dd --- /dev/null +++ b/backend/app/api/routes/trades.py @@ -0,0 +1,53 @@ +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.models.trade import Trade + +router = APIRouter(prefix="/trades", tags=["trades"]) + + +@router.get("") +async def get_trades( + source: Optional[str] = Query(default=None, description="live | backtest"), + status: Optional[str] = Query(default=None, description="open | closed"), + instrument: Optional[str] = Query(default=None), + limit: int = Query(default=100, ge=1, le=1000), + db: AsyncSession = Depends(get_db), +): + stmt = select(Trade).order_by(Trade.opened_at.desc()).limit(limit) + if source: + stmt = stmt.where(Trade.source == source) + if status: + stmt = stmt.where(Trade.status == status) + if instrument: + stmt = stmt.where(Trade.instrument == instrument) + + result = await db.execute(stmt) + trades = result.scalars().all() + + return { + "total": len(trades), + "trades": [ + { + "id": t.id, + "source": t.source, + "instrument": t.instrument, + "direction": t.direction, + "units": t.units, + "entry_price": t.entry_price, + "stop_loss": t.stop_loss, + "take_profit": t.take_profit, + "exit_price": t.exit_price, + "pnl": t.pnl, + "status": t.status, + "signal_type": t.signal_type, + "opened_at": str(t.opened_at), + "closed_at": str(t.closed_at) if t.closed_at else None, + } + for t in trades + ], + } diff --git a/backend/app/api/websocket.py b/backend/app/api/websocket.py new file mode 100644 index 0000000..3258d88 --- /dev/null +++ b/backend/app/api/websocket.py @@ -0,0 +1,57 @@ +""" +WebSocket endpoint pour le streaming de données live. + +Broadcast les événements du bot (ticks, nouveaux trades, etc.) +vers tous les clients connectés. +""" + +import asyncio +import json +import logging +from typing import Set + +from fastapi import WebSocket, WebSocketDisconnect + +logger = logging.getLogger(__name__) + + +class ConnectionManager: + def __init__(self) -> None: + self._active: Set[WebSocket] = set() + + async def connect(self, ws: WebSocket) -> None: + await ws.accept() + self._active.add(ws) + logger.info("WS client connecté — %d total", len(self._active)) + + def disconnect(self, ws: WebSocket) -> None: + self._active.discard(ws) + logger.info("WS client déconnecté — %d restants", len(self._active)) + + async def broadcast(self, data: dict) -> None: + if not self._active: + return + message = json.dumps(data, default=str) + dead: Set[WebSocket] = set() + for ws in list(self._active): + try: + await ws.send_text(message) + except Exception: + dead.add(ws) + for ws in dead: + self.disconnect(ws) + + +manager = ConnectionManager() + + +async def websocket_endpoint(ws: WebSocket) -> None: + await manager.connect(ws) + try: + while True: + # Garder la connexion ouverte, le client peut envoyer des pings + data = await ws.receive_text() + if data == "ping": + await ws.send_text(json.dumps({"type": "pong"})) + except WebSocketDisconnect: + manager.disconnect(ws) diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/backtester.py b/backend/app/core/backtester.py new file mode 100644 index 0000000..d97f9b9 --- /dev/null +++ b/backend/app/core/backtester.py @@ -0,0 +1,269 @@ +""" +Moteur de backtesting vectorisé. + +Simule l'exécution de la stratégie sur des données historiques : +- Rolling window : analyse les N dernières bougies à chaque pas +- Gestion du spread OANDA +- Suivi des positions ouvertes (SL / TP hit) +- Calcul des métriques de performance +""" + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Optional + +import numpy as np +import pandas as pd + +from app.core.strategy.base import AbstractStrategy, TradeSignal + + +@dataclass +class BacktestTrade: + direction: str + entry_price: float + stop_loss: float + take_profit: float + entry_time: datetime + exit_price: Optional[float] = None + exit_time: Optional[datetime] = None + pnl_pips: Optional[float] = None + pnl_pct: Optional[float] = None + status: str = "open" # "win" | "loss" | "open" + signal_type: str = "" + + +@dataclass +class BacktestMetrics: + initial_balance: float + final_balance: float + total_pnl: float + total_pnl_pct: float + total_trades: int + winning_trades: int + losing_trades: int + win_rate: float + avg_win_pips: float + avg_loss_pips: float + expectancy: float + max_drawdown: float + max_drawdown_pct: float + sharpe_ratio: float + profit_factor: float + equity_curve: list[dict] = field(default_factory=list) + trades: list[BacktestTrade] = field(default_factory=list) + + +class Backtester: + def __init__( + self, + strategy: AbstractStrategy, + initial_balance: float = 10_000.0, + risk_percent: float = 1.0, + spread_pips: float = 1.5, + pip_value: float = 0.0001, + ) -> None: + self.strategy = strategy + self.initial_balance = initial_balance + self.risk_percent = risk_percent # % du capital risqué par trade + self.spread_pips = spread_pips + self.pip_value = pip_value + + def run( + self, + df: pd.DataFrame, + window: int = 100, + ) -> BacktestMetrics: + """ + Exécute le backtest sur le DataFrame complet. + + window : nombre de bougies analysées à chaque pas (rolling) + """ + df = df.copy().reset_index(drop=True) + trades: list[BacktestTrade] = [] + balance = self.initial_balance + equity_curve: list[dict] = [{"time": str(df.iloc[0]["time"]), "balance": balance}] + open_trade: Optional[BacktestTrade] = None + last_signal_time: Optional[datetime] = None + used_signals: set[tuple[str, int]] = set() # (direction, entry_price_rounded) + + for i in range(window, len(df)): + candle = df.iloc[i] + + # 1. Gérer la position ouverte (SL/TP hit ?) + if open_trade: + closed, exit_price = self._check_exit(open_trade, candle) + if closed: + pnl_pips = self._calc_pnl_pips(open_trade, exit_price) + pnl_money = self._pips_to_money(pnl_pips, balance) + balance += pnl_money + open_trade.exit_price = exit_price + open_trade.exit_time = candle["time"] + open_trade.pnl_pips = pnl_pips + open_trade.pnl_pct = pnl_money / self.initial_balance * 100 + open_trade.status = "win" if pnl_pips > 0 else "loss" + trades.append(open_trade) + open_trade = None + equity_curve.append({"time": str(candle["time"]), "balance": balance}) + + # 2. Si pas de position ouverte, chercher un signal + if not open_trade: + slice_df = df.iloc[i - window:i + 1] + result = self.strategy.analyze(slice_df) + + if result.signals: + # Filtrer les signaux déjà exploités (éviter doublons) + new_signals = [ + s for s in result.signals + if last_signal_time is None or s.time > last_signal_time + ] + if not new_signals: + continue + signal = new_signals[-1] # Signal le plus récent + # Appliquer le spread + entry, sl, tp = self._apply_spread(signal) + # Éviter de réouvrir le même trade (même direction + même prix) + sig_key = (signal.direction, round(entry / self.pip_value)) + if sig_key in used_signals: + continue + used_signals.add(sig_key) + last_signal_time = signal.time + open_trade = BacktestTrade( + direction=signal.direction, + entry_price=entry, + stop_loss=sl, + take_profit=tp, + entry_time=candle["time"], + signal_type=signal.signal_type, + ) + + # Fermer la position ouverte en fin de période + if open_trade: + last_price = df.iloc[-1]["close"] + pnl_pips = self._calc_pnl_pips(open_trade, last_price) + pnl_money = self._pips_to_money(pnl_pips, balance) + balance += pnl_money + open_trade.exit_price = last_price + open_trade.exit_time = df.iloc[-1]["time"] + open_trade.pnl_pips = pnl_pips + open_trade.pnl_pct = pnl_money / self.initial_balance * 100 + open_trade.status = "win" if pnl_pips > 0 else "loss" + trades.append(open_trade) + + equity_curve.append({"time": str(df.iloc[-1]["time"]), "balance": balance}) + return self._compute_metrics(trades, balance, equity_curve) + + # ─── Helpers ────────────────────────────────────────────────────────────── + + def _check_exit( + self, trade: BacktestTrade, candle: pd.Series + ) -> tuple[bool, float]: + """Vérifie si le SL ou TP est touché sur la bougie. Retourne (closed, exit_price).""" + if trade.direction == "buy": + if candle["low"] <= trade.stop_loss: + return True, trade.stop_loss + if candle["high"] >= trade.take_profit: + return True, trade.take_profit + else: + if candle["high"] >= trade.stop_loss: + return True, trade.stop_loss + if candle["low"] <= trade.take_profit: + return True, trade.take_profit + return False, 0.0 + + def _apply_spread(self, signal: TradeSignal) -> tuple[float, float, float]: + """Applique le spread à l'entrée.""" + half_spread = (self.spread_pips * self.pip_value) / 2 + if signal.direction == "buy": + entry = signal.entry_price + half_spread + sl = signal.stop_loss + tp = signal.take_profit + else: + entry = signal.entry_price - half_spread + sl = signal.stop_loss + tp = signal.take_profit + return entry, sl, tp + + def _calc_pnl_pips(self, trade: BacktestTrade, exit_price: float) -> float: + if trade.direction == "buy": + return (exit_price - trade.entry_price) / self.pip_value + return (trade.entry_price - exit_price) / self.pip_value + + def _pips_to_money(self, pips: float, balance: float) -> float: + """Convertit les pips en unités monétaires selon le risque défini.""" + risk_amount = balance * (self.risk_percent / 100) + # Nombre de pips de risque sur ce trade + return pips * (risk_amount / 10) # Approximation simple + + def _compute_metrics( + self, + trades: list[BacktestTrade], + final_balance: float, + equity_curve: list[dict], + ) -> BacktestMetrics: + if not trades: + return BacktestMetrics( + initial_balance=self.initial_balance, + final_balance=final_balance, + total_pnl=0, total_pnl_pct=0, + total_trades=0, winning_trades=0, losing_trades=0, + win_rate=0, avg_win_pips=0, avg_loss_pips=0, + expectancy=0, max_drawdown=0, max_drawdown_pct=0, + sharpe_ratio=0, profit_factor=0, + equity_curve=equity_curve, trades=trades, + ) + + winners = [t for t in trades if t.status == "win"] + losers = [t for t in trades if t.status == "loss"] + + total_pnl = final_balance - self.initial_balance + win_rate = len(winners) / len(trades) if trades else 0 + + avg_win = np.mean([t.pnl_pips for t in winners]) if winners else 0 + avg_loss = abs(np.mean([t.pnl_pips for t in losers])) if losers else 0 + expectancy = (win_rate * avg_win) - ((1 - win_rate) * avg_loss) + + # Max Drawdown + balances = [eq["balance"] for eq in equity_curve] + peak = balances[0] + max_dd = 0.0 + for b in balances: + if b > peak: + peak = b + dd = (peak - b) / peak + if dd > max_dd: + max_dd = dd + + # Profit Factor + gross_profit = sum(t.pnl_pips for t in winners) if winners else 0 + gross_loss = abs(sum(t.pnl_pips for t in losers)) if losers else 1 + pf = gross_profit / gross_loss if gross_loss > 0 else 0 + + # Sharpe Ratio (simplifié — daily returns) + pnl_series = [t.pnl_pips or 0 for t in trades] + if len(pnl_series) > 1: + mean_ret = np.mean(pnl_series) + std_ret = np.std(pnl_series) + sharpe = (mean_ret / std_ret * np.sqrt(252)) if std_ret > 0 else 0 + else: + sharpe = 0 + + return BacktestMetrics( + initial_balance=self.initial_balance, + final_balance=final_balance, + total_pnl=total_pnl, + total_pnl_pct=total_pnl / self.initial_balance * 100, + total_trades=len(trades), + winning_trades=len(winners), + losing_trades=len(losers), + win_rate=win_rate * 100, + avg_win_pips=float(avg_win), + avg_loss_pips=float(avg_loss), + expectancy=float(expectancy), + max_drawdown=max_dd * self.initial_balance, + max_drawdown_pct=max_dd * 100, + sharpe_ratio=float(sharpe), + profit_factor=float(pf), + equity_curve=equity_curve, + trades=trades, + ) diff --git a/backend/app/core/bot.py b/backend/app/core/bot.py new file mode 100644 index 0000000..3bb3742 --- /dev/null +++ b/backend/app/core/bot.py @@ -0,0 +1,155 @@ +""" +BotRunner — boucle de trading live asynchrone. + +Cycle : +1. Fetch les N dernières bougies +2. Analyser la stratégie +3. Si signal + pas de position ouverte → exécuter +4. Broadcast l'état via WebSocket +5. Attendre le prochain tick +""" + +import asyncio +import logging +from datetime import datetime +from typing import Callable, Optional + +from app.core.config import settings +from app.core.exchange.base import AbstractExchange +from app.core.strategy.base import AbstractStrategy, AnalysisResult +from app.services.trade_manager import TradeManager + +logger = logging.getLogger(__name__) + +# Interval en secondes selon la granularité +GRANULARITY_SECONDS = { + "M1": 60, "M5": 300, "M15": 900, "M30": 1800, + "H1": 3600, "H2": 7200, "H4": 14400, "H6": 21600, + "H8": 28800, "H12": 43200, "D": 86400, +} + + +class BotRunner: + def __init__( + self, + exchange: AbstractExchange, + strategy: AbstractStrategy, + trade_manager: TradeManager, + instrument: str = "", + granularity: str = "", + candles_window: int = 150, + ) -> None: + self._exchange = exchange + self._strategy = strategy + self._trade_manager = trade_manager + self.instrument = instrument or settings.bot_default_instrument + self.granularity = granularity or settings.bot_default_granularity + self.candles_window = candles_window + + self._running = False + self._task: Optional[asyncio.Task] = None + self._last_analysis: Optional[AnalysisResult] = None + self._started_at: Optional[datetime] = None + + # Callback pour broadcaster les événements (WebSocket) + self._on_event: Optional[Callable] = None + + @property + def is_running(self) -> bool: + return self._running + + def set_event_callback(self, callback: Callable) -> None: + self._on_event = callback + + async def start(self) -> None: + if self._running: + logger.warning("Bot déjà en cours d'exécution") + return + self._running = True + self._started_at = datetime.utcnow() + logger.info("Bot démarré — %s %s", self.instrument, self.granularity) + self._task = asyncio.create_task(self._loop()) + + async def stop(self) -> None: + self._running = False + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + logger.info("Bot arrêté") + + async def _loop(self) -> None: + interval = GRANULARITY_SECONDS.get(self.granularity, 3600) + # Décaler d'un peu pour s'assurer que la bougie est fermée + wait_seconds = max(interval // 10, 5) + + while self._running: + try: + await self._tick() + except Exception as e: + logger.error("Erreur dans la boucle du bot: %s", e, exc_info=True) + + await asyncio.sleep(wait_seconds) + + async def _tick(self) -> None: + # 1. Fetch candles + df = await self._exchange.get_candles( + self.instrument, self.granularity, self.candles_window + ) + if df.empty: + return + + # 2. Analyser + result = self._strategy.analyze(df) + self._last_analysis = result + + # 3. Vérifier les positions ouvertes + open_trades = await self._exchange.get_open_trades() + instrument_trades = [t for t in open_trades if t.instrument == self.instrument] + + event = { + "type": "tick", + "instrument": self.instrument, + "granularity": self.granularity, + "last_close": float(df.iloc[-1]["close"]), + "last_time": str(df.iloc[-1]["time"]), + "order_blocks": len(result.order_blocks), + "liquidity_levels": len(result.liquidity_levels), + "signals": len(result.signals), + "open_positions": len(instrument_trades), + } + + # 4. Exécuter le signal si aucune position ouverte + if result.signals and not instrument_trades: + signal = result.signals[-1] + try: + order = await self._trade_manager.execute_signal(signal) + if order: + logger.info( + "Trade exécuté : %s %s @ %.5f", + signal.direction, self.instrument, order.entry_price, + ) + event["new_trade"] = { + "direction": order.direction, + "entry_price": order.entry_price, + "stop_loss": order.stop_loss, + "take_profit": order.take_profit, + } + except Exception as e: + logger.error("Erreur exécution trade: %s", e) + + # 5. Broadcast + if self._on_event: + await self._on_event(event) + + def get_status(self) -> dict: + return { + "running": self._running, + "instrument": self.instrument, + "granularity": self.granularity, + "started_at": str(self._started_at) if self._started_at else None, + "strategy": self._strategy.__class__.__name__, + "strategy_params": self._strategy.get_params(), + } diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..98f8f33 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,22 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") + + # TwelveData (fallback pour l'intraday historique) + # Clé gratuite sur https://twelvedata.com/ + twelvedata_api_key: str = "" + + # Bot defaults + bot_default_instrument: str = "EUR_USD" + bot_default_granularity: str = "H1" + bot_risk_percent: float = 1.0 + bot_rr_ratio: float = 2.0 + bot_initial_balance: float = 10_000.0 + + # Database + database_url: str = "sqlite+aiosqlite:///./trader_bot.db" + + +settings = Settings() diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100644 index 0000000..355d739 --- /dev/null +++ b/backend/app/core/database.py @@ -0,0 +1,21 @@ +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase + +from app.core.config import settings + +engine = create_async_engine(settings.database_url, echo=False) +AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False) + + +class Base(DeclarativeBase): + pass + + +async def init_db() -> None: + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + +async def get_db() -> AsyncSession: + async with AsyncSessionLocal() as session: + yield session diff --git a/backend/app/core/exchange/__init__.py b/backend/app/core/exchange/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/exchange/base.py b/backend/app/core/exchange/base.py new file mode 100644 index 0000000..34676e0 --- /dev/null +++ b/backend/app/core/exchange/base.py @@ -0,0 +1,88 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass +from datetime import datetime +from typing import Optional + +import pandas as pd + + +@dataclass +class OrderResult: + trade_id: str + instrument: str + direction: str # "buy" | "sell" + units: float + entry_price: float + stop_loss: float + take_profit: float + opened_at: datetime + + +@dataclass +class OpenTrade: + trade_id: str + instrument: str + direction: str + units: float + entry_price: float + stop_loss: float + take_profit: float + unrealized_pnl: float + opened_at: datetime + + +@dataclass +class AccountInfo: + balance: float + nav: float # Net Asset Value + unrealized_pnl: float + currency: str + + +class AbstractExchange(ABC): + """Interface commune pour tous les exchanges/brokers.""" + + @abstractmethod + async def get_candles( + self, + instrument: str, + granularity: str, + count: int = 200, + from_time: Optional[datetime] = None, + to_time: Optional[datetime] = None, + ) -> pd.DataFrame: + """ + Retourne un DataFrame avec colonnes : time, open, high, low, close, volume. + """ + ... + + @abstractmethod + async def place_order( + self, + instrument: str, + units: float, # positif = buy, négatif = sell + stop_loss: float, + take_profit: float, + ) -> OrderResult: + """Place un ordre au marché avec SL et TP.""" + ... + + @abstractmethod + async def close_trade(self, trade_id: str) -> float: + """Ferme un trade par son ID. Retourne le PnL réalisé.""" + ... + + @abstractmethod + async def get_open_trades(self) -> list[OpenTrade]: + """Retourne la liste des positions ouvertes.""" + ... + + @abstractmethod + async def get_account_info(self) -> AccountInfo: + """Retourne les infos du compte (solde, PnL, etc.).""" + ... + + @abstractmethod + async def get_price(self, instrument: str) -> float: + """Retourne le prix mid actuel.""" + ... diff --git a/backend/app/core/exchange/oanda.py b/backend/app/core/exchange/oanda.py new file mode 100644 index 0000000..883fac1 --- /dev/null +++ b/backend/app/core/exchange/oanda.py @@ -0,0 +1,171 @@ +from datetime import datetime, timezone +from typing import Optional + +import pandas as pd +from oandapyV20 import API +from oandapyV20.endpoints import accounts, instruments, orders, trades, pricing + +from app.core.config import settings +from app.core.exchange.base import ( + AbstractExchange, + AccountInfo, + OpenTrade, + OrderResult, +) + + +# Granularités OANDA valides +GRANULARITIES = { + "M1", "M5", "M15", "M30", + "H1", "H2", "H4", "H6", "H8", "H12", + "D", "W", "M", +} + +# Nombre max de candles par requête OANDA +MAX_CANDLES_PER_REQUEST = 5000 + + +def _parse_time(ts: str) -> datetime: + """Parse RFC3339 timestamp OANDA → datetime UTC.""" + return datetime.fromisoformat(ts.replace("Z", "+00:00")) + + +class OandaExchange(AbstractExchange): + def __init__(self) -> None: + env = "practice" if settings.oanda_environment == "practice" else "live" + self._client = API( + access_token=settings.oanda_api_key, + environment=env, + ) + self._account_id = settings.oanda_account_id + + async def get_candles( + self, + instrument: str, + granularity: str = "H1", + count: int = 200, + from_time: Optional[datetime] = None, + to_time: Optional[datetime] = None, + ) -> pd.DataFrame: + if granularity not in GRANULARITIES: + raise ValueError(f"Granularité invalide: {granularity}. Valides: {GRANULARITIES}") + + params: dict = {"granularity": granularity, "price": "M"} + + if from_time and to_time: + params["from"] = from_time.strftime("%Y-%m-%dT%H:%M:%SZ") + params["to"] = to_time.strftime("%Y-%m-%dT%H:%M:%SZ") + else: + params["count"] = min(count, MAX_CANDLES_PER_REQUEST) + + r = instruments.InstrumentsCandles(instrument, params=params) + self._client.request(r) + + candles = r.response.get("candles", []) + rows = [] + for c in candles: + if c.get("complete", True): + mid = c["mid"] + rows.append({ + "time": _parse_time(c["time"]), + "open": float(mid["o"]), + "high": float(mid["h"]), + "low": float(mid["l"]), + "close": float(mid["c"]), + "volume": int(c.get("volume", 0)), + }) + + df = pd.DataFrame(rows) + if not df.empty: + df.sort_values("time", inplace=True) + df.reset_index(drop=True, inplace=True) + return df + + async def place_order( + self, + instrument: str, + units: float, + stop_loss: float, + take_profit: float, + ) -> OrderResult: + data = { + "order": { + "type": "MARKET", + "instrument": instrument, + "units": str(int(units)), + "stopLossOnFill": {"price": f"{stop_loss:.5f}"}, + "takeProfitOnFill": {"price": f"{take_profit:.5f}"}, + "timeInForce": "FOK", + } + } + r = orders.OrderCreate(self._account_id, data=data) + self._client.request(r) + + resp = r.response + fill = resp.get("orderFillTransaction") or resp.get("relatedTransactionIDs", {}) + trade_id = resp.get("orderFillTransaction", {}).get("tradeOpened", {}).get("tradeID", "unknown") + entry_price = float(resp.get("orderFillTransaction", {}).get("price", 0)) + direction = "buy" if units > 0 else "sell" + + return OrderResult( + trade_id=trade_id, + instrument=instrument, + direction=direction, + units=abs(units), + entry_price=entry_price, + stop_loss=stop_loss, + take_profit=take_profit, + opened_at=datetime.now(timezone.utc), + ) + + async def close_trade(self, trade_id: str) -> float: + r = trades.TradeClose(self._account_id, tradeID=trade_id) + self._client.request(r) + pnl = float(r.response.get("orderFillTransaction", {}).get("pl", 0)) + return pnl + + async def get_open_trades(self) -> list[OpenTrade]: + r = trades.OpenTrades(self._account_id) + self._client.request(r) + result = [] + for t in r.response.get("trades", []): + units = float(t["currentUnits"]) + direction = "buy" if units > 0 else "sell" + sl = float(t.get("stopLoss", {}).get("price", 0)) if t.get("stopLoss") else 0.0 + tp = float(t.get("takeProfit", {}).get("price", 0)) if t.get("takeProfit") else 0.0 + result.append(OpenTrade( + trade_id=t["id"], + instrument=t["instrument"], + direction=direction, + units=abs(units), + entry_price=float(t["price"]), + stop_loss=sl, + take_profit=tp, + unrealized_pnl=float(t.get("unrealizedPL", 0)), + opened_at=_parse_time(t["openTime"]), + )) + return result + + async def get_account_info(self) -> AccountInfo: + r = accounts.AccountSummary(self._account_id) + self._client.request(r) + acc = r.response["account"] + return AccountInfo( + balance=float(acc["balance"]), + nav=float(acc["NAV"]), + unrealized_pnl=float(acc.get("unrealizedPL", 0)), + currency=acc.get("currency", "USD"), + ) + + async def get_price(self, instrument: str) -> float: + r = pricing.PricingInfo( + self._account_id, + params={"instruments": instrument}, + ) + self._client.request(r) + prices = r.response.get("prices", []) + if not prices: + raise ValueError(f"Aucun prix pour {instrument}") + bid = float(prices[0]["bids"][0]["price"]) + ask = float(prices[0]["asks"][0]["price"]) + return (bid + ask) / 2 diff --git a/backend/app/core/exchange/simulated.py b/backend/app/core/exchange/simulated.py new file mode 100644 index 0000000..07e8643 --- /dev/null +++ b/backend/app/core/exchange/simulated.py @@ -0,0 +1,204 @@ +""" +SimulatedExchange — paper trading 100% local, sans broker. + +Les ordres sont simulés en mémoire. Les prix viennent du MarketDataService +(yfinance + TwelveData + cache DB). +""" + +import logging +import uuid +from datetime import datetime, timezone +from typing import Optional + +import pandas as pd + +from app.core.exchange.base import ( + AbstractExchange, + AccountInfo, + OpenTrade, + OrderResult, +) + +logger = logging.getLogger(__name__) + + +class SimulatedExchange(AbstractExchange): + """ + Exchange simulé pour le paper trading. + Les positions sont gardées en mémoire (réinitialisées au redémarrage). + Les trades fermés sont persistés en DB via le BotRunner. + """ + + def __init__( + self, + market_data_service, # MarketDataService — évite l'import circulaire + initial_balance: float = 10_000.0, + ) -> None: + self._market_data = market_data_service + self._balance = initial_balance + self._initial_balance = initial_balance + self._open_trades: dict[str, OpenTrade] = {} + + # ── AbstractExchange interface ──────────────────────────────────────────── + + async def get_candles( + self, + instrument: str, + granularity: str, + count: int = 200, + from_time=None, + to_time=None, + ) -> pd.DataFrame: + return await self._market_data.get_candles( + instrument, granularity, count, + start=from_time, end=to_time, + ) + + async def place_order( + self, + instrument: str, + units: float, + stop_loss: float, + take_profit: float, + ) -> OrderResult: + price = await self._market_data.get_latest_price(instrument) + if price is None: + raise ValueError(f"Prix introuvable pour {instrument}") + + direction = "buy" if units > 0 else "sell" + trade_id = f"SIM-{uuid.uuid4().hex[:8].upper()}" + now = datetime.now(timezone.utc).replace(tzinfo=None) + + trade = OpenTrade( + trade_id=trade_id, + instrument=instrument, + direction=direction, + units=abs(units), + entry_price=price, + stop_loss=stop_loss, + take_profit=take_profit, + unrealized_pnl=0.0, + opened_at=now, + ) + self._open_trades[trade_id] = trade + + logger.info( + "[SIM] Ordre ouvert %s %s %.2f @ %.5f | SL=%.5f TP=%.5f", + direction.upper(), instrument, abs(units), price, stop_loss, take_profit, + ) + + return OrderResult( + trade_id=trade_id, + instrument=instrument, + direction=direction, + units=abs(units), + entry_price=price, + stop_loss=stop_loss, + take_profit=take_profit, + opened_at=now, + ) + + async def close_trade(self, trade_id: str) -> float: + trade = self._open_trades.pop(trade_id, None) + if trade is None: + raise ValueError(f"Trade {trade_id} introuvable") + + price = await self._market_data.get_latest_price(trade.instrument) + if price is None: + price = trade.entry_price + + pnl = self._calc_pnl(trade, price) + self._balance += pnl + + logger.info( + "[SIM] Trade fermé %s %s @ %.5f | PnL=%.2f | Balance=%.2f", + trade_id, trade.instrument, price, pnl, self._balance, + ) + return pnl + + async def get_open_trades(self) -> list[OpenTrade]: + # Mettre à jour le PnL flottant + updated: list[OpenTrade] = [] + for trade in self._open_trades.values(): + price = await self._market_data.get_latest_price(trade.instrument) + if price is not None: + pnl = self._calc_pnl(trade, price) + updated.append(OpenTrade( + trade_id=trade.trade_id, + instrument=trade.instrument, + direction=trade.direction, + units=trade.units, + entry_price=trade.entry_price, + stop_loss=trade.stop_loss, + take_profit=trade.take_profit, + unrealized_pnl=pnl, + opened_at=trade.opened_at, + )) + else: + updated.append(trade) + return updated + + async def get_account_info(self) -> AccountInfo: + open_trades = await self.get_open_trades() + unrealized = sum(t.unrealized_pnl for t in open_trades) + return AccountInfo( + balance=self._balance, + nav=self._balance + unrealized, + unrealized_pnl=unrealized, + currency="USD", + ) + + async def get_price(self, instrument: str) -> float: + price = await self._market_data.get_latest_price(instrument) + if price is None: + raise ValueError(f"Prix introuvable pour {instrument}") + return price + + # ── Simulation du tick (appelée par BotRunner) ──────────────────────────── + + async def check_sl_tp(self, instrument: str) -> list[tuple[str, float]]: + """ + Vérifie si SL ou TP sont touchés pour les positions ouvertes. + Retourne la liste des (trade_id, pnl) des positions fermées. + """ + price = await self._market_data.get_latest_price(instrument) + if price is None: + return [] + + closed: list[tuple[str, float]] = [] + for trade_id, trade in list(self._open_trades.items()): + if trade.instrument != instrument: + continue + hit = self._is_sl_tp_hit(trade, price) + if hit: + exit_price = trade.stop_loss if hit == "sl" else trade.take_profit + pnl = self._calc_pnl(trade, exit_price) + self._balance += pnl + del self._open_trades[trade_id] + logger.info( + "[SIM] %s touché — %s %s | PnL=%.2f", + hit.upper(), trade_id, trade.instrument, pnl, + ) + closed.append((trade_id, pnl)) + + return closed + + # ── Helpers ─────────────────────────────────────────────────────────────── + + def _calc_pnl(self, trade: OpenTrade, exit_price: float) -> float: + if trade.direction == "buy": + return (exit_price - trade.entry_price) * trade.units + return (trade.entry_price - exit_price) * trade.units + + def _is_sl_tp_hit(self, trade: OpenTrade, current_price: float) -> Optional[str]: + if trade.direction == "buy": + if current_price <= trade.stop_loss: + return "sl" + if current_price >= trade.take_profit: + return "tp" + else: + if current_price >= trade.stop_loss: + return "sl" + if current_price <= trade.take_profit: + return "tp" + return None diff --git a/backend/app/core/strategy/__init__.py b/backend/app/core/strategy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/strategy/base.py b/backend/app/core/strategy/base.py new file mode 100644 index 0000000..6a5a83d --- /dev/null +++ b/backend/app/core/strategy/base.py @@ -0,0 +1,66 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Optional + +import pandas as pd + + +@dataclass +class OrderBlockZone: + """Zone Order Block à surveiller.""" + id: str + direction: str # "bullish" | "bearish" + top: float # Haut de la zone + bottom: float # Bas de la zone + origin_time: pd.Timestamp + mitigated: bool = False # True si le prix a traversé la zone + + +@dataclass +class LiquidityLevel: + """Niveau de liquidité (Equal H/L).""" + id: str + direction: str # "high" | "low" + price: float + origin_time: pd.Timestamp + swept: bool = False # True si le prix a dépassé ce niveau + + +@dataclass +class TradeSignal: + """Signal de trading généré par la stratégie.""" + direction: str # "buy" | "sell" + entry_price: float + stop_loss: float + take_profit: float + signal_type: str # description du setup + time: pd.Timestamp + order_block: Optional[OrderBlockZone] = None + liquidity_level: Optional[LiquidityLevel] = None + + +@dataclass +class AnalysisResult: + """Résultat complet de l'analyse de la stratégie.""" + order_blocks: list[OrderBlockZone] = field(default_factory=list) + liquidity_levels: list[LiquidityLevel] = field(default_factory=list) + signals: list[TradeSignal] = field(default_factory=list) + + +class AbstractStrategy(ABC): + """Interface commune pour toutes les stratégies.""" + + @abstractmethod + def analyze(self, df: pd.DataFrame) -> AnalysisResult: + """ + Analyse un DataFrame de candles et retourne les zones, + niveaux de liquidité et signaux d'entrée. + + df doit avoir les colonnes : time, open, high, low, close, volume + """ + ... + + @abstractmethod + def get_params(self) -> dict: + """Retourne les paramètres configurables de la stratégie.""" + ... diff --git a/backend/app/core/strategy/order_block_sweep.py b/backend/app/core/strategy/order_block_sweep.py new file mode 100644 index 0000000..ce5ac66 --- /dev/null +++ b/backend/app/core/strategy/order_block_sweep.py @@ -0,0 +1,381 @@ +""" +Stratégie ICT — Order Block + Liquidity Sweep + +Logique : +1. Détecter les swing highs/lows (N bougies de chaque côté) +2. Identifier les niveaux de liquidité : Equal Highs (EQH) / Equal Lows (EQL) + - Deux swing H/L proches dans une tolérance de X pips = pool de liquidité +3. Détecter un Liquidity Sweep : + - Le wick d'une bougie dépasse un EQH/EQL, mais la bougie close de l'autre côté + → Confirmation que la liquidité a été absorbée (stop hunt) +4. Identifier l'Order Block dans la direction du renversement : + - Bullish OB : dernière bougie bearish (close < open) avant le mouvement impulsif haussier + - Bearish OB : dernière bougie bullish (close > open) avant le mouvement impulsif bearish +5. Signal d'entrée : le prix revient dans la zone OB après le sweep +6. SL : en dessous du bas de l'OB (buy) ou au-dessus du haut (sell) +7. TP : niveau de liquidité opposé ou R:R fixe +""" + +from dataclasses import dataclass +from typing import Optional + +import pandas as pd + +from app.core.strategy.base import ( + AbstractStrategy, + AnalysisResult, + LiquidityLevel, + OrderBlockZone, + TradeSignal, +) + + +@dataclass +class OrderBlockSweepParams: + swing_strength: int = 5 # N bougies de chaque côté pour valider un swing + liquidity_tolerance_pips: float = 2.0 # Tolérance en pips pour Equal H/L + pip_value: float = 0.0001 # Valeur d'un pip (0.0001 pour Forex, 0.01 pour JPY) + min_impulse_candles: int = 3 # Nombre min de bougies dans l'impulsion + min_impulse_factor: float = 1.5 # Taille impulsion vs bougie moyenne + rr_ratio: float = 2.0 # Ratio Risk/Reward pour le TP + + +class OrderBlockSweepStrategy(AbstractStrategy): + def __init__(self, params: Optional[OrderBlockSweepParams] = None) -> None: + self.params = params or OrderBlockSweepParams() + + def get_params(self) -> dict: + return { + "swing_strength": self.params.swing_strength, + "liquidity_tolerance_pips": self.params.liquidity_tolerance_pips, + "pip_value": self.params.pip_value, + "min_impulse_candles": self.params.min_impulse_candles, + "min_impulse_factor": self.params.min_impulse_factor, + "rr_ratio": self.params.rr_ratio, + } + + def analyze(self, df: pd.DataFrame) -> AnalysisResult: + if len(df) < self.params.swing_strength * 2 + 5: + return AnalysisResult() + + df = df.copy().reset_index(drop=True) + swings = self._detect_swings(df) + liquidity_levels = self._detect_liquidity(df, swings) + order_blocks = self._detect_order_blocks(df) + signals = self._detect_signals(df, liquidity_levels, order_blocks) + + return AnalysisResult( + order_blocks=order_blocks, + liquidity_levels=liquidity_levels, + signals=signals, + ) + + # ─── Swing Detection ────────────────────────────────────────────────────── + + def _detect_swings(self, df: pd.DataFrame) -> pd.DataFrame: + """Retourne un DataFrame avec colonnes swing_high et swing_low (bool).""" + n = self.params.swing_strength + highs = df["high"].values + lows = df["low"].values + swing_high = [False] * len(df) + swing_low = [False] * len(df) + + for i in range(n, len(df) - n): + # Swing High : plus haut que les N bougies avant et après + if all(highs[i] > highs[i - j] for j in range(1, n + 1)) and \ + all(highs[i] > highs[i + j] for j in range(1, n + 1)): + swing_high[i] = True + # Swing Low : plus bas que les N bougies avant et après + if all(lows[i] < lows[i - j] for j in range(1, n + 1)) and \ + all(lows[i] < lows[i + j] for j in range(1, n + 1)): + swing_low[i] = True + + df = df.copy() + df["swing_high"] = swing_high + df["swing_low"] = swing_low + return df + + # ─── Liquidity Detection ────────────────────────────────────────────────── + + def _detect_liquidity( + self, df: pd.DataFrame, swings: pd.DataFrame + ) -> list[LiquidityLevel]: + """ + Identifie les Equal Highs (EQH) et Equal Lows (EQL). + Deux swing H/L sont "égaux" si leur différence est < tolerance_pips. + """ + tol = self.params.liquidity_tolerance_pips * self.params.pip_value + levels: list[LiquidityLevel] = [] + + swing_highs = swings[swings["swing_high"]].copy() + swing_lows = swings[swings["swing_low"]].copy() + + # Equal Highs + sh_prices = swing_highs["high"].values + sh_times = swing_highs["time"].values + for i in range(len(sh_prices)): + for j in range(i + 1, len(sh_prices)): + if abs(sh_prices[i] - sh_prices[j]) <= tol: + level_price = (sh_prices[i] + sh_prices[j]) / 2 + # Vérifier si déjà sweepé dans les données actuelles + swept = self._is_level_swept(df, level_price, "high", sh_times[j]) + levels.append(LiquidityLevel( + id=f"EQH_{i}_{j}", + direction="high", + price=level_price, + origin_time=pd.Timestamp(sh_times[j]), + swept=swept, + )) + + # Equal Lows + sl_prices = swing_lows["low"].values + sl_times = swing_lows["time"].values + for i in range(len(sl_prices)): + for j in range(i + 1, len(sl_prices)): + if abs(sl_prices[i] - sl_prices[j]) <= tol: + level_price = (sl_prices[i] + sl_prices[j]) / 2 + swept = self._is_level_swept(df, level_price, "low", sl_times[j]) + levels.append(LiquidityLevel( + id=f"EQL_{i}_{j}", + direction="low", + price=level_price, + origin_time=pd.Timestamp(sl_times[j]), + swept=swept, + )) + + return levels + + def _is_level_swept( + self, + df: pd.DataFrame, + price: float, + direction: str, + after_time, + ) -> bool: + """Vérifie si un niveau a été sweepé après sa formation.""" + mask = df["time"] > pd.Timestamp(after_time) + future = df[mask] + if future.empty: + return False + if direction == "high": + return bool((future["high"] > price).any()) + return bool((future["low"] < price).any()) + + # ─── Order Block Detection ──────────────────────────────────────────────── + + def _detect_order_blocks(self, df: pd.DataFrame) -> list[OrderBlockZone]: + """ + Détecte les Order Blocks : + - Bullish OB : dernière bougie bearish avant une impulsion haussière significative + - Bearish OB : dernière bougie bullish avant une impulsion bearish significative + """ + blocks: list[OrderBlockZone] = [] + min_imp = self.params.min_impulse_candles + factor = self.params.min_impulse_factor + avg_body = (df["close"] - df["open"]).abs().mean() + + for i in range(1, len(df) - min_imp): + # Chercher une impulsion haussière après la bougie i + impulse_up = self._is_impulse(df, i, "up", min_imp, factor, avg_body) + if impulse_up: + # Chercher la dernière bougie bearish avant i (inclus) + for k in range(i, max(0, i - 10) - 1, -1): + if df.loc[k, "close"] < df.loc[k, "open"]: # bearish + mitigated = self._is_ob_mitigated(df, k, "bullish", i + min_imp) + blocks.append(OrderBlockZone( + id=f"BullishOB_{k}", + direction="bullish", + top=df.loc[k, "open"], # top = open de la bougie bearish + bottom=df.loc[k, "low"], + origin_time=df.loc[k, "time"], + mitigated=mitigated, + )) + break + + # Chercher une impulsion bearish après la bougie i + impulse_down = self._is_impulse(df, i, "down", min_imp, factor, avg_body) + if impulse_down: + for k in range(i, max(0, i - 10) - 1, -1): + if df.loc[k, "close"] > df.loc[k, "open"]: # bullish + mitigated = self._is_ob_mitigated(df, k, "bearish", i + min_imp) + blocks.append(OrderBlockZone( + id=f"BearishOB_{k}", + direction="bearish", + top=df.loc[k, "high"], + bottom=df.loc[k, "open"], # bottom = open de la bougie bullish + origin_time=df.loc[k, "time"], + mitigated=mitigated, + )) + break + + # Dédupliquer (garder le plus récent par zone) + return self._deduplicate_obs(blocks) + + def _is_impulse( + self, + df: pd.DataFrame, + start: int, + direction: str, + min_candles: int, + factor: float, + avg_body: float, + ) -> bool: + """Vérifie si une impulsion directionnelle commence à l'index start.""" + end = min(start + min_candles, len(df)) + segment = df.iloc[start:end] + if len(segment) < min_candles: + return False + + total_move = abs(segment.iloc[-1]["close"] - segment.iloc[0]["open"]) + if total_move < avg_body * factor: + return False + + if direction == "up": + return segment.iloc[-1]["close"] > segment.iloc[0]["open"] + return segment.iloc[-1]["close"] < segment.iloc[0]["open"] + + def _is_ob_mitigated( + self, + df: pd.DataFrame, + ob_idx: int, + direction: str, + after_idx: int, + ) -> bool: + """Vérifie si un OB a été mitié (prix est revenu dans la zone).""" + top = df.loc[ob_idx, "open"] if direction == "bullish" else df.loc[ob_idx, "high"] + bottom = df.loc[ob_idx, "low"] if direction == "bullish" else df.loc[ob_idx, "open"] + future = df.iloc[after_idx:] + if direction == "bullish": + return bool(((future["low"] <= top) & (future["low"] >= bottom)).any()) + return bool(((future["high"] >= bottom) & (future["high"] <= top)).any()) + + def _deduplicate_obs(self, blocks: list[OrderBlockZone]) -> list[OrderBlockZone]: + """Supprime les OB en double (même direction et zone proche).""" + seen_ids: set[str] = set() + unique: list[OrderBlockZone] = [] + for b in blocks: + if b.id not in seen_ids: + seen_ids.add(b.id) + unique.append(b) + return unique + + # ─── Signal Detection ───────────────────────────────────────────────────── + + def _detect_signals( + self, + df: pd.DataFrame, + liquidity_levels: list[LiquidityLevel], + order_blocks: list[OrderBlockZone], + ) -> list[TradeSignal]: + """ + Génère des signaux quand : + 1. Un niveau de liquidité est sweepé (wick dépasse + close de l'autre côté) + 2. Le prix revient dans un OB de direction opposée au sweep + """ + signals: list[TradeSignal] = [] + + # Travailler sur les 50 dernières bougies pour les signaux récents + lookback = df.tail(50).copy() + + if len(lookback) < 2: + return signals + + for level in liquidity_levels: + if level.swept: + continue + + for i in range(1, len(lookback)): + candle = lookback.iloc[i] + + # Sweep d'un Equal High → signal SELL potentiel + if level.direction == "high": + sweep = ( + candle["high"] > level.price and # wick dépasse + candle["close"] < level.price # close en dessous + ) + if sweep: + signal = self._find_entry_signal( + df=lookback, + sweep_idx=i, + direction="sell", + swept_level=level, + order_blocks=order_blocks, + ) + if signal: + signals.append(signal) + break # Un seul signal par niveau + + # Sweep d'un Equal Low → signal BUY potentiel + elif level.direction == "low": + sweep = ( + candle["low"] < level.price and # wick dépasse + candle["close"] > level.price # close au-dessus + ) + if sweep: + signal = self._find_entry_signal( + df=lookback, + sweep_idx=i, + direction="buy", + swept_level=level, + order_blocks=order_blocks, + ) + if signal: + signals.append(signal) + break # Un seul signal par niveau + + return signals + + def _find_entry_signal( + self, + df: pd.DataFrame, + sweep_idx: int, + direction: str, + swept_level: LiquidityLevel, + order_blocks: list[OrderBlockZone], + ) -> Optional[TradeSignal]: + """ + Cherche un OB valide dans la direction du signal après le sweep. + """ + sweep_time = df.iloc[sweep_idx]["time"] + sweep_price = df.iloc[sweep_idx]["close"] + + # Chercher un OB non mitié dans la bonne direction + ob_direction = "bullish" if direction == "buy" else "bearish" + candidate_obs = [ + ob for ob in order_blocks + if ob.direction == ob_direction + and not ob.mitigated + and ob.origin_time <= sweep_time + ] + + if not candidate_obs: + return None + + # Prendre l'OB le plus récent + ob = max(candidate_obs, key=lambda x: x.origin_time) + + # Vérifier que le prix actuel est proche de l'OB ou dans la zone + if direction == "buy": + # Le prix doit être au-dessus ou proche du bas de l'OB + if sweep_price < ob.bottom * 0.998: # trop loin en dessous + return None + entry = ob.top + sl = ob.bottom - 2 * self.params.pip_value + tp = entry + (entry - sl) * self.params.rr_ratio + else: + if sweep_price > ob.top * 1.002: + return None + entry = ob.bottom + sl = ob.top + 2 * self.params.pip_value + tp = entry - (sl - entry) * self.params.rr_ratio + + return TradeSignal( + direction=direction, + entry_price=entry, + stop_loss=sl, + take_profit=tp, + signal_type=f"LiquiditySweep_{swept_level.direction.upper()}+OrderBlock", + time=sweep_time, + order_block=ob, + liquidity_level=swept_level, + ) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..cf032e8 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,90 @@ +import logging + +from fastapi import FastAPI, WebSocket +from fastapi.middleware.cors import CORSMiddleware + +from app.api.routes import backtest, bot, candles, trades +from app.api.routes.bot import set_bot_runner +from app.api.websocket import manager, websocket_endpoint +from app.core.bot import BotRunner +from app.core.config import settings +from app.core.database import AsyncSessionLocal, init_db +from app.core.exchange.simulated import SimulatedExchange +from app.core.strategy.order_block_sweep import OrderBlockSweepStrategy +from app.services.market_data import MarketDataService +from app.services.trade_manager import TradeManager + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +app = FastAPI( + title="Trader Bot API", + description="Bot de trading ICT — Order Block + Liquidity Sweep", + version="0.1.0", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:5173", "http://localhost:3000"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(candles.router, prefix="/api") +app.include_router(trades.router, prefix="/api") +app.include_router(backtest.router, prefix="/api") +app.include_router(bot.router, prefix="/api") + + +@app.websocket("/ws/live") +async def ws_live(websocket: WebSocket): + await websocket_endpoint(websocket) + + +@app.on_event("startup") +async def startup(): + await init_db() + logger.info("Base de données initialisée") + + # Initialiser le bot avec l'exchange simulé (paper trading local) + async with AsyncSessionLocal() as db: + market_data = MarketDataService(db) + exchange = SimulatedExchange( + market_data_service=market_data, + initial_balance=settings.bot_initial_balance, + ) + strategy = OrderBlockSweepStrategy() + trade_mgr = TradeManager(exchange) + runner = BotRunner( + exchange=exchange, + strategy=strategy, + trade_manager=trade_mgr, + ) + + async def broadcast_event(event: dict): + await manager.broadcast(event) + + runner.set_event_callback(broadcast_event) + set_bot_runner(runner) + + td_status = "✓ configurée" if settings.twelvedata_api_key else "✗ manquante (historique limité)" + logger.info( + "Bot initialisé — SimulatedExchange | balance=%.0f$ | TwelveData=%s", + settings.bot_initial_balance, + td_status, + ) + + +@app.get("/") +async def root(): + return { + "name": "Trader Bot API", + "version": "0.1.0", + "docs": "/docs", + "data_sources": { + "primary": "yfinance", + "fallback_historical": "TwelveData", + "twelvedata_configured": bool(settings.twelvedata_api_key), + }, + } diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/models/backtest_result.py b/backend/app/models/backtest_result.py new file mode 100644 index 0000000..ec4c0c2 --- /dev/null +++ b/backend/app/models/backtest_result.py @@ -0,0 +1,32 @@ +from datetime import datetime +from typing import Optional + +from sqlalchemy import Float, Integer, String, DateTime, JSON +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.database import Base + + +class BacktestResult(Base): + __tablename__ = "backtest_results" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + instrument: Mapped[str] = mapped_column(String(20)) + granularity: Mapped[str] = mapped_column(String(10)) + start_date: Mapped[datetime] = mapped_column(DateTime) + end_date: Mapped[datetime] = mapped_column(DateTime) + initial_balance: Mapped[float] = mapped_column(Float, default=10000.0) + final_balance: Mapped[float] = mapped_column(Float) + total_pnl: Mapped[float] = mapped_column(Float) + total_trades: Mapped[int] = mapped_column(Integer) + winning_trades: Mapped[int] = mapped_column(Integer) + losing_trades: Mapped[int] = mapped_column(Integer) + win_rate: Mapped[float] = mapped_column(Float) + max_drawdown: Mapped[float] = mapped_column(Float) + sharpe_ratio: Mapped[Optional[float]] = mapped_column(Float, nullable=True) + expectancy: Mapped[float] = mapped_column(Float) + # Courbe d'équité [{time, balance}] stockée en JSON + equity_curve: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) + # Paramètres de la stratégie utilisés + strategy_params: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) diff --git a/backend/app/models/candle.py b/backend/app/models/candle.py new file mode 100644 index 0000000..c434fb8 --- /dev/null +++ b/backend/app/models/candle.py @@ -0,0 +1,25 @@ +from datetime import datetime + +from sqlalchemy import Float, Integer, String, DateTime, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.database import Base + + +class Candle(Base): + __tablename__ = "candles" + __table_args__ = ( + # Garantit INSERT OR IGNORE sur (instrument, granularity, time) + UniqueConstraint("instrument", "granularity", "time", name="uq_candle"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + instrument: Mapped[str] = mapped_column(String(20), index=True) + granularity: Mapped[str] = mapped_column(String(10)) + time: Mapped[datetime] = mapped_column(DateTime, index=True) + open: Mapped[float] = mapped_column(Float) + high: Mapped[float] = mapped_column(Float) + low: Mapped[float] = mapped_column(Float) + close: Mapped[float] = mapped_column(Float) + volume: Mapped[int] = mapped_column(Integer, default=0) + complete: Mapped[bool] = mapped_column(default=True) diff --git a/backend/app/models/trade.py b/backend/app/models/trade.py new file mode 100644 index 0000000..5a32916 --- /dev/null +++ b/backend/app/models/trade.py @@ -0,0 +1,34 @@ +from datetime import datetime +from typing import Optional + +from sqlalchemy import Float, Integer, String, DateTime +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.database import Base + + +class Trade(Base): + __tablename__ = "trades" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + # "live" | "backtest" + source: Mapped[str] = mapped_column(String(10), default="live") + # identifiant OANDA du trade live (si applicable) + oanda_trade_id: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + instrument: Mapped[str] = mapped_column(String(20), index=True) + # "buy" | "sell" + direction: Mapped[str] = mapped_column(String(4)) + units: Mapped[float] = mapped_column(Float) + entry_price: Mapped[float] = mapped_column(Float) + stop_loss: Mapped[float] = mapped_column(Float) + take_profit: Mapped[float] = mapped_column(Float) + exit_price: Mapped[Optional[float]] = mapped_column(Float, nullable=True) + pnl: Mapped[Optional[float]] = mapped_column(Float, nullable=True) + # "open" | "closed" | "cancelled" + status: Mapped[str] = mapped_column(String(10), default="open") + # Signal ayant déclenché le trade + signal_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + opened_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + closed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + # ID du backtest parent (si applicable) + backtest_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True) diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/data_providers/__init__.py b/backend/app/services/data_providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/data_providers/constants.py b/backend/app/services/data_providers/constants.py new file mode 100644 index 0000000..ec34bf8 --- /dev/null +++ b/backend/app/services/data_providers/constants.py @@ -0,0 +1,82 @@ +""" +Constantes de mapping entre les noms canoniques du projet +et les symboles/intervalles propres à chaque source de données. +""" + +# ── Limites yfinance (jours de données disponibles par granularité) ────────── +YF_MAX_DAYS: dict[str, int] = { + "M1": 7, + "M5": 60, + "M15": 60, + "M30": 60, + "H1": 730, + "H4": 730, + "D": 9999, +} + +# ── Durée d'une bougie en minutes ───────────────────────────────────────────── +GRANULARITY_MINUTES: dict[str, int] = { + "M1": 1, + "M5": 5, + "M15": 15, + "M30": 30, + "H1": 60, + "H4": 240, + "D": 1440, +} + +# ── Mapping vers les intervalles yfinance ───────────────────────────────────── +GRANULARITY_TO_YF: dict[str, str] = { + "M1": "1m", + "M5": "5m", + "M15": "15m", + "M30": "30m", + "H1": "1h", + "H4": "4h", + "D": "1d", +} + +# ── Mapping vers les intervalles TwelveData ─────────────────────────────────── +GRANULARITY_TO_TD: dict[str, str] = { + "M1": "1min", + "M5": "5min", + "M15": "15min", + "M30": "30min", + "H1": "1h", + "H4": "4h", + "D": "1day", +} + +# ── Mapping instrument → symbole yfinance ───────────────────────────────────── +INSTRUMENT_TO_YF: dict[str, str] = { + "EUR_USD": "EURUSD=X", + "GBP_USD": "GBPUSD=X", + "USD_JPY": "USDJPY=X", + "USD_CHF": "USDCHF=X", + "AUD_USD": "AUDUSD=X", + "USD_CAD": "USDCAD=X", + "GBP_JPY": "GBPJPY=X", + "EUR_JPY": "EURJPY=X", + "EUR_GBP": "EURGBP=X", + "SPX500_USD": "^GSPC", + "NAS100_USD": "^NDX", + "XAU_USD": "GC=F", + "US30_USD": "YM=F", +} + +# ── Mapping instrument → symbole TwelveData ─────────────────────────────────── +INSTRUMENT_TO_TD: dict[str, str] = { + "EUR_USD": "EUR/USD", + "GBP_USD": "GBP/USD", + "USD_JPY": "USD/JPY", + "USD_CHF": "USD/CHF", + "AUD_USD": "AUD/USD", + "USD_CAD": "USD/CAD", + "GBP_JPY": "GBP/JPY", + "EUR_JPY": "EUR/JPY", + "EUR_GBP": "EUR/GBP", + "SPX500_USD": "SPY", + "NAS100_USD": "QQQ", + "XAU_USD": "XAU/USD", + "US30_USD": "DJI", +} diff --git a/backend/app/services/data_providers/twelvedata_provider.py b/backend/app/services/data_providers/twelvedata_provider.py new file mode 100644 index 0000000..d7d69fd --- /dev/null +++ b/backend/app/services/data_providers/twelvedata_provider.py @@ -0,0 +1,159 @@ +""" +Provider TwelveData — données OHLCV historiques illimitées. + +Plan gratuit : 800 requêtes/jour, 8 req/min. +Docs : https://twelvedata.com/docs +""" + +import asyncio +import logging +import time +from datetime import datetime, timedelta +from typing import Optional + +import httpx +import pandas as pd + +from app.core.config import settings +from app.services.data_providers.constants import GRANULARITY_TO_TD, INSTRUMENT_TO_TD + +logger = logging.getLogger(__name__) + +TWELVEDATA_BASE_URL = "https://api.twelvedata.com" +# Nombre max de points par requête TwelveData (plan gratuit) +MAX_OUTPUTSIZE = 5000 +# Limite du plan gratuit : 8 req/min +_RATE_LIMIT = 8 +_RATE_WINDOW = 61 # secondes (légèrement au-dessus de 60 pour la marge) +_rate_lock = asyncio.Lock() +_request_times: list[float] = [] + + +async def _rate_limited_get(client: httpx.AsyncClient, url: str, params: dict) -> httpx.Response: + """Wrapper qui respecte la limite de 8 req/min de TwelveData.""" + global _request_times + async with _rate_lock: + now = time.monotonic() + # Purger les timestamps hors fenêtre + _request_times = [t for t in _request_times if now - t < _RATE_WINDOW] + if len(_request_times) >= _RATE_LIMIT: + wait = _RATE_WINDOW - (now - _request_times[0]) + if wait > 0: + logger.info("TwelveData rate limit : attente %.1f s", wait) + await asyncio.sleep(wait) + _request_times = [t for t in _request_times if time.monotonic() - t < _RATE_WINDOW] + _request_times.append(time.monotonic()) + return await client.get(url, params=params) + + +class TwelveDataProvider: + """Fetche des candles depuis l'API TwelveData.""" + + def __init__(self) -> None: + self._api_key = settings.twelvedata_api_key + + def is_configured(self) -> bool: + return bool(self._api_key) + + async def fetch( + self, + instrument: str, + granularity: str, + start: datetime, + end: Optional[datetime] = None, + ) -> pd.DataFrame: + """Fetche les candles pour la période [start, end].""" + if not self.is_configured(): + logger.warning("TwelveData : TWELVEDATA_API_KEY non configurée") + return pd.DataFrame() + + td_symbol = INSTRUMENT_TO_TD.get(instrument) + td_interval = GRANULARITY_TO_TD.get(granularity) + + if not td_symbol or not td_interval: + logger.warning("TwelveData : instrument/granularité non supporté — %s %s", instrument, granularity) + return pd.DataFrame() + + if end is None: + end = datetime.utcnow() + + logger.info( + "TwelveData fetch : %s (%s) %s → %s", + instrument, granularity, start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d"), + ) + + # TwelveData supporte max 5000 points par requête + # Si la période est longue, on fait plusieurs requêtes + all_frames: list[pd.DataFrame] = [] + current_end = end + + while current_end > start: + df_chunk = await self._fetch_chunk(td_symbol, td_interval, start, current_end) + if df_chunk.empty: + break + all_frames.append(df_chunk) + oldest = df_chunk["time"].min() + if oldest <= start: + break + # Reculer pour la prochaine requête + current_end = oldest - timedelta(seconds=1) + + if not all_frames: + return pd.DataFrame() + + df = pd.concat(all_frames, ignore_index=True) + df = df.drop_duplicates(subset=["time"]) + df = df.sort_values("time").reset_index(drop=True) + df = df[(df["time"] >= start) & (df["time"] <= end)] + + logger.info("TwelveData : %d bougies récupérées pour %s %s", len(df), instrument, granularity) + return df + + async def _fetch_chunk( + self, + td_symbol: str, + td_interval: str, + start: datetime, + end: datetime, + ) -> pd.DataFrame: + params = { + "symbol": td_symbol, + "interval": td_interval, + "start_date": start.strftime("%Y-%m-%d %H:%M:%S"), + "end_date": end.strftime("%Y-%m-%d %H:%M:%S"), + "outputsize": MAX_OUTPUTSIZE, + "format": "JSON", + "apikey": self._api_key, + } + + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await _rate_limited_get(client, f"{TWELVEDATA_BASE_URL}/time_series", params=params) + resp.raise_for_status() + data = resp.json() + except Exception as e: + logger.error("TwelveData erreur HTTP : %s", e) + return pd.DataFrame() + + if data.get("status") == "error": + logger.error("TwelveData API erreur : %s", data.get("message")) + return pd.DataFrame() + + values = data.get("values", []) + if not values: + return pd.DataFrame() + + rows = [] + for v in values: + rows.append({ + "time": pd.to_datetime(v["datetime"]), + "open": float(v["open"]), + "high": float(v["high"]), + "low": float(v["low"]), + "close": float(v["close"]), + "volume": int(v.get("volume", 0)), + }) + + df = pd.DataFrame(rows) + df = df.sort_values("time").reset_index(drop=True) + return df diff --git a/backend/app/services/data_providers/yfinance_provider.py b/backend/app/services/data_providers/yfinance_provider.py new file mode 100644 index 0000000..177269f --- /dev/null +++ b/backend/app/services/data_providers/yfinance_provider.py @@ -0,0 +1,134 @@ +""" +Provider yfinance — données OHLCV gratuites. + +Limites : + - M1 : 7 derniers jours + - M5/M15/M30 : 60 derniers jours + - H1/H4 : 730 derniers jours + - D : illimité +""" + +import asyncio +import logging +from datetime import datetime, timedelta, timezone +from typing import Optional + +import pandas as pd + +from app.services.data_providers.constants import ( + GRANULARITY_TO_YF, + INSTRUMENT_TO_YF, + YF_MAX_DAYS, +) + +logger = logging.getLogger(__name__) + + +def _normalize(df: pd.DataFrame) -> pd.DataFrame: + """Normalise un DataFrame yfinance vers le format interne.""" + df = df.copy() + df.index = pd.to_datetime(df.index, utc=True) + df.index = df.index.tz_localize(None) if df.index.tz is not None else df.index + + df.columns = [c.lower() for c in df.columns] + # yfinance peut retourner des colonnes multi-index + if isinstance(df.columns, pd.MultiIndex): + df.columns = df.columns.get_level_values(0) + + df = df.rename(columns={"adj close": "close"})[["open", "high", "low", "close", "volume"]] + df = df.dropna(subset=["open", "high", "low", "close"]) + df.index.name = "time" + df = df.reset_index() + df["time"] = pd.to_datetime(df["time"]).dt.tz_localize(None) + return df + + +def _fetch_sync( + yf_symbol: str, + yf_interval: str, + start: datetime, + end: datetime, +) -> pd.DataFrame: + """Exécution synchrone de yfinance (sera appelée dans un thread).""" + import yfinance as yf + + ticker = yf.Ticker(yf_symbol) + df = ticker.history( + interval=yf_interval, + start=start.strftime("%Y-%m-%d"), + end=(end + timedelta(days=1)).strftime("%Y-%m-%d"), + auto_adjust=True, + prepost=False, + ) + return df + + +class YFinanceProvider: + """Fetche des candles depuis Yahoo Finance.""" + + def yf_cutoff(self, granularity: str) -> Optional[datetime]: + """Retourne la date la plus ancienne que yfinance peut fournir.""" + max_days = YF_MAX_DAYS.get(granularity) + if max_days is None: + return None + return datetime.utcnow() - timedelta(days=max_days - 1) + + def can_provide(self, granularity: str, start: datetime) -> bool: + """Vérifie si yfinance peut fournir des données pour cette période.""" + cutoff = self.yf_cutoff(granularity) + if cutoff is None: + return False + return start >= cutoff + + async def fetch( + self, + instrument: str, + granularity: str, + start: datetime, + end: Optional[datetime] = None, + ) -> pd.DataFrame: + """ + Fetche les candles pour la période [start, end]. + Tronque start à la limite yfinance si nécessaire. + """ + yf_symbol = INSTRUMENT_TO_YF.get(instrument) + yf_interval = GRANULARITY_TO_YF.get(granularity) + + if not yf_symbol or not yf_interval: + logger.warning("yfinance : instrument ou granularité non supporté — %s %s", instrument, granularity) + return pd.DataFrame() + + # Tronquer start à la limite yfinance + cutoff = self.yf_cutoff(granularity) + if cutoff and start < cutoff: + logger.debug("yfinance : start tronqué de %s à %s", start, cutoff) + start = cutoff + + if end is None: + end = datetime.utcnow() + + if start >= end: + return pd.DataFrame() + + logger.info( + "yfinance fetch : %s (%s) %s → %s", + instrument, granularity, start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d"), + ) + + try: + loop = asyncio.get_event_loop() + raw = await loop.run_in_executor( + None, _fetch_sync, yf_symbol, yf_interval, start, end + ) + except Exception as e: + logger.error("yfinance erreur : %s", e) + return pd.DataFrame() + + if raw.empty: + logger.warning("yfinance : aucune donnée pour %s %s", instrument, granularity) + return pd.DataFrame() + + df = _normalize(raw) + df = df[(df["time"] >= start) & (df["time"] <= end)] + logger.info("yfinance : %d bougies récupérées pour %s %s", len(df), instrument, granularity) + return df diff --git a/backend/app/services/market_data.py b/backend/app/services/market_data.py new file mode 100644 index 0000000..15b10b7 --- /dev/null +++ b/backend/app/services/market_data.py @@ -0,0 +1,264 @@ +""" +MarketDataService — source de données hybride avec cache DB. + +Stratégie de fetch pour une période [start, end] demandée : + + 1. DB d'abord → on récupère ce qu'on a déjà, on ne refetch jamais ce qui existe + 2. Gaps récents → yfinance (dans ses limites temporelles) + 3. Gaps historiques → TwelveData (pour tout ce que yfinance ne peut pas couvrir) + 4. Tout est stocké → les prochaines requêtes seront servies depuis la DB + +Exemple (M1, 10 derniers jours demandés) : + - DB : déjà ce qu'on a en cache + - yfinance : J-7 → maintenant (limite M1 = 7 jours) + - TwelveData : J-10 → J-7 (historique au-delà de yfinance) +""" + +import logging +from datetime import datetime, timedelta +from typing import Optional + +import pandas as pd +from sqlalchemy import and_, select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.candle import Candle +from app.services.data_providers.constants import GRANULARITY_MINUTES +from app.services.data_providers.twelvedata_provider import TwelveDataProvider +from app.services.data_providers.yfinance_provider import YFinanceProvider + +logger = logging.getLogger(__name__) + +# Facteur pour compenser weekends + jours fériés dans le calcul de la fenêtre +TRADING_DAYS_FACTOR = 1.5 + + +class MarketDataService: + def __init__(self, db: AsyncSession) -> None: + self._db = db + self._yf = YFinanceProvider() + self._td = TwelveDataProvider() + + # ── API publique ────────────────────────────────────────────────────────── + + async def get_candles( + self, + instrument: str, + granularity: str, + count: int = 200, + start: Optional[datetime] = None, + end: Optional[datetime] = None, + ) -> pd.DataFrame: + """ + Retourne jusqu'à `count` bougies pour instrument/granularity. + Si start/end fournis, ils définissent la plage exacte. + + Processus : + 1. Calcul de la fenêtre temporelle nécessaire + 2. Détection et comblement des gaps (yfinance + TwelveData) + 3. Lecture depuis DB et retour + """ + if end is None: + end = datetime.utcnow() + if start is None: + minutes = GRANULARITY_MINUTES.get(granularity, 60) + start = end - timedelta(minutes=int(minutes * count * TRADING_DAYS_FACTOR)) + + await self._fill_gaps(instrument, granularity, start, end) + return await self._db_fetch(instrument, granularity, start, end, limit=count) + + async def get_latest_price(self, instrument: str) -> Optional[float]: + """Retourne le dernier close connu (DB ou yfinance M1).""" + stmt = ( + select(Candle.close) + .where(Candle.instrument == instrument) + .order_by(Candle.time.desc()) + .limit(1) + ) + result = await self._db.execute(stmt) + price = result.scalar_one_or_none() + if price: + return float(price) + + df = await self.get_candles(instrument, "M1", count=2) + return float(df.iloc[-1]["close"]) if not df.empty else None + + # ── Logique de détection et comblement des gaps ─────────────────────────── + + async def _fill_gaps( + self, + instrument: str, + granularity: str, + start: datetime, + end: datetime, + ) -> None: + gaps = await self._find_gaps(instrument, granularity, start, end) + for gap_start, gap_end in gaps: + await self._fetch_and_store_gap(instrument, granularity, gap_start, gap_end) + + async def _find_gaps( + self, + instrument: str, + granularity: str, + start: datetime, + end: datetime, + ) -> list[tuple[datetime, datetime]]: + """ + Retourne la liste des (gap_start, gap_end) manquants en DB. + + Logique : + - Si rien en DB pour la plage → un seul gap = (start, end) + - Sinon → combler avant le plus ancien et/ou après le plus récent + """ + stmt = ( + select(Candle.time) + .where( + and_( + Candle.instrument == instrument, + Candle.granularity == granularity, + Candle.time >= start, + Candle.time <= end, + ) + ) + .order_by(Candle.time) + ) + result = await self._db.execute(stmt) + times = [r[0] for r in result.fetchall()] + + if not times: + return [(start, end)] + + gaps: list[tuple[datetime, datetime]] = [] + interval = timedelta(minutes=GRANULARITY_MINUTES.get(granularity, 60)) + oldest, newest = times[0], times[-1] + + # Gap avant : demande de données antérieures à ce qu'on a + if start < oldest - interval: + gaps.append((start, oldest)) + + # Gap après : demande de données plus récentes que ce qu'on a + freshness_threshold = interval * 2 + if end > newest + freshness_threshold: + gaps.append((newest, end)) + + return gaps + + async def _fetch_and_store_gap( + self, + instrument: str, + granularity: str, + gap_start: datetime, + gap_end: datetime, + ) -> None: + """ + Fetche un gap : + 1. yfinance pour la partie récente (dans ses limites) + 2. TwelveData en fallback si yfinance échoue, ou pour la partie historique + """ + yf_cutoff = self._yf.yf_cutoff(granularity) + yf_covered = False + + # ── yfinance : partie récente du gap ───────────────────────────────── + if yf_cutoff is not None: + yf_start = max(gap_start, yf_cutoff) + if yf_start < gap_end: + df_yf = await self._yf.fetch(instrument, granularity, yf_start, gap_end) + if not df_yf.empty: + await self._store(df_yf, instrument, granularity) + yf_covered = True + + # ── TwelveData : historique + fallback si yfinance indisponible ─────── + if self._td.is_configured(): + # Partie historique (avant la limite yfinance) + td_end = yf_cutoff if (yf_cutoff and gap_start < yf_cutoff) else None + if td_end and gap_start < td_end: + df_td = await self._td.fetch(instrument, granularity, gap_start, td_end) + if not df_td.empty: + await self._store(df_td, instrument, granularity) + + # Fallback pour la partie récente si yfinance n'a rien retourné + if not yf_covered: + yf_start = max(gap_start, yf_cutoff) if yf_cutoff else gap_start + if yf_start < gap_end: + logger.info( + "yfinance indisponible — fallback TwelveData pour %s %s [%s → %s]", + instrument, granularity, + yf_start.strftime("%Y-%m-%d"), gap_end.strftime("%Y-%m-%d"), + ) + df_td2 = await self._td.fetch(instrument, granularity, yf_start, gap_end) + if not df_td2.empty: + await self._store(df_td2, instrument, granularity) + elif not yf_covered: + logger.warning( + "Gap [%s → %s] pour %s %s — " + "TWELVEDATA_API_KEY manquante et yfinance indisponible.", + gap_start.strftime("%Y-%m-%d"), + gap_end.strftime("%Y-%m-%d"), + instrument, + granularity, + ) + + # ── DB helpers ──────────────────────────────────────────────────────────── + + async def _db_fetch( + self, + instrument: str, + granularity: str, + start: datetime, + end: datetime, + limit: int = 5000, + ) -> pd.DataFrame: + stmt = ( + select(Candle) + .where( + and_( + Candle.instrument == instrument, + Candle.granularity == granularity, + Candle.time >= start, + Candle.time <= end, + ) + ) + .order_by(Candle.time.desc()) + .limit(limit) + ) + result = await self._db.execute(stmt) + rows = result.scalars().all() + + if not rows: + return pd.DataFrame(columns=["time", "open", "high", "low", "close", "volume"]) + + df = pd.DataFrame( + [{"time": r.time, "open": r.open, "high": r.high, + "low": r.low, "close": r.close, "volume": r.volume} + for r in rows] + ) + return df.sort_values("time").reset_index(drop=True) + + async def _store(self, df: pd.DataFrame, instrument: str, granularity: str) -> None: + """ + Insère les bougies en DB avec INSERT OR IGNORE. + Les bougies déjà présentes (même instrument+granularity+time) ne sont jamais modifiées. + """ + if df.empty: + return + + for _, row in df.iterrows(): + await self._db.execute( + text( + "INSERT OR IGNORE INTO candles " + "(instrument, granularity, time, open, high, low, close, volume, complete) " + "VALUES (:instrument, :granularity, :time, :open, :high, :low, :close, :volume, 1)" + ), + { + "instrument": instrument, + "granularity": granularity, + "time": pd.Timestamp(row["time"]).to_pydatetime().replace(tzinfo=None), + "open": float(row["open"]), + "high": float(row["high"]), + "low": float(row["low"]), + "close": float(row["close"]), + "volume": int(row.get("volume", 0)), + }, + ) + + await self._db.commit() diff --git a/backend/app/services/trade_manager.py b/backend/app/services/trade_manager.py new file mode 100644 index 0000000..e516ac1 --- /dev/null +++ b/backend/app/services/trade_manager.py @@ -0,0 +1,67 @@ +""" +Gestion des positions : sizing, validation, suivi. +""" + +import logging +from typing import Optional + +from app.core.config import settings +from app.core.exchange.base import AbstractExchange, AccountInfo, OrderResult +from app.core.strategy.base import TradeSignal + +logger = logging.getLogger(__name__) + + +class TradeManager: + def __init__(self, exchange: AbstractExchange) -> None: + self._exchange = exchange + self._risk_percent = settings.bot_risk_percent + self._pip_value = 0.0001 + + async def execute_signal(self, signal: TradeSignal) -> Optional[OrderResult]: + """ + Calcule le sizing et place l'ordre sur l'exchange. + Retourne None si le signal est invalide ou si le sizing est trop petit. + """ + account = await self._exchange.get_account_info() + + risk_pips = abs(signal.entry_price - signal.stop_loss) / self._pip_value + if risk_pips < 1: + logger.warning("Signal ignoré : risque en pips trop faible (%s)", risk_pips) + return None + + units = self._calculate_units(account, risk_pips, signal) + if abs(units) < 1000: + logger.warning("Signal ignoré : taille de position trop petite (%s)", units) + return None + + logger.info( + "Exécution signal %s %s — entry=%.5f SL=%.5f TP=%.5f units=%d", + signal.direction, signal.signal_type, + signal.entry_price, signal.stop_loss, signal.take_profit, units, + ) + return await self._exchange.place_order( + instrument=signal.signal_type.split("+")[0] if "+" in signal.signal_type else "EUR_USD", + units=units, + stop_loss=signal.stop_loss, + take_profit=signal.take_profit, + ) + + def _calculate_units( + self, + account: AccountInfo, + risk_pips: float, + signal: TradeSignal, + ) -> float: + """ + Calcule la taille de position basée sur le risque défini. + Formula : units = (balance * risk%) / (risk_pips * pip_value_per_unit) + Pour EUR/USD standard : pip_value_per_unit = $0.0001 + """ + risk_amount = account.balance * (self._risk_percent / 100) + # Pour simplification : 1 pip = 0.0001, valeur pip sur mini-lot = $1 + pip_value_per_unit = self._pip_value + raw_units = risk_amount / (risk_pips * pip_value_per_unit) + # Arrondir à la centaine inférieure + units = (int(raw_units) // 100) * 100 + return float(units) if signal.direction == "buy" else float(-units) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..ef9c540 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,11 @@ +fastapi==0.115.6 +uvicorn[standard]==0.32.1 +pandas==2.2.3 +sqlalchemy[asyncio]==2.0.36 +aiosqlite==0.20.0 +pydantic-settings==2.6.1 +python-dotenv==1.0.1 +httpx==0.27.2 +websockets==13.1 +python-multipart==0.0.18 +yfinance==0.2.48 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..dd5e044 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Trader Bot — ICT Dashboard + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..c146ecd --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3377 @@ +{ + "name": "trader-bot-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "trader-bot-frontend", + "version": "0.1.0", + "dependencies": { + "axios": "^1.7.7", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "lightweight-charts": "^5.1.0", + "lucide-react": "^0.454.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0", + "recharts": "^2.13.3", + "tailwind-merge": "^2.5.4" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.14", + "typescript": "^5.6.3", + "vite": "^5.4.10" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.24", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", + "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001766", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001774", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", + "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/fancy-canvas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fancy-canvas/-/fancy-canvas-2.1.0.tgz", + "integrity": "sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightweight-charts": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-5.1.0.tgz", + "integrity": "sha512-jEAYR4ODYeyNZcWUigsoLTl52rbPmgXnvd5FLIv/ZoA/2sSDw63YKnef8n4yhzum7W926yHeFwlm7ididKb7YQ==", + "license": "Apache-2.0", + "dependencies": { + "fancy-canvas": "2.1.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.454.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.454.0.tgz", + "integrity": "sha512-hw7zMDwykCLnEzgncEEjHeA6+45aeEzRYuKHuyRSOPkhko+J3ySGjGIzu+mmMfDFG1vazHepMaYFYHbTFAZAAQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwind-merge": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..fb4f010 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,33 @@ +{ + "name": "trader-bot-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.7.7", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "lightweight-charts": "^5.1.0", + "lucide-react": "^0.454.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0", + "recharts": "^2.13.3", + "tailwind-merge": "^2.5.4" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.14", + "typescript": "^5.6.3", + "vite": "^5.4.10" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..f3f2d04 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,53 @@ +import { BrowserRouter, NavLink, Route, Routes } from 'react-router-dom' +import { BarChart2, Bot, FlaskConical } from 'lucide-react' +import Dashboard from './pages/Dashboard' +import Backtest from './pages/Backtest' + +export default function App() { + return ( + +
+ {/* Sidebar */} + + + {/* Main content */} +
+ + } /> + } /> + +
+
+
+ ) +} diff --git a/frontend/src/components/Backtest/BacktestForm.tsx b/frontend/src/components/Backtest/BacktestForm.tsx new file mode 100644 index 0000000..762df9a --- /dev/null +++ b/frontend/src/components/Backtest/BacktestForm.tsx @@ -0,0 +1,116 @@ +import { useState } from 'react' +import { FlaskConical, Loader2 } from 'lucide-react' +import type { BacktestResult } from '../../lib/api' +import { runBacktest } from '../../lib/api' + +interface Props { + onResult: (result: BacktestResult) => void +} + +const INSTRUMENTS = [ + 'EUR_USD', 'GBP_USD', 'USD_JPY', 'USD_CHF', 'AUD_USD', 'USD_CAD', + 'GBP_JPY', 'EUR_JPY', 'SPX500_USD', 'NAS100_USD', 'XAU_USD', +] + +const GRANULARITIES = ['M15', 'M30', 'H1', 'H4', 'D'] + +export default function BacktestForm({ onResult }: Props) { + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const [form, setForm] = useState({ + instrument: 'EUR_USD', + granularity: 'H1', + candle_count: 500, + initial_balance: 10000, + risk_percent: 1.0, + rr_ratio: 2.0, + swing_strength: 5, + liquidity_tolerance_pips: 2.0, + spread_pips: 1.5, + }) + + const handleChange = (key: string, value: string | number) => { + setForm((prev) => ({ ...prev, [key]: value })) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setError(null) + try { + const result = await runBacktest(form) + onResult(result) + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Erreur inconnue' + setError(msg) + } finally { + setLoading(false) + } + } + + const Field = ({ + label, name, type = 'number', options + }: { + label: string; name: string; type?: string; options?: string[] + }) => ( +
+ + {options ? ( + + ) : ( + handleChange(name, parseFloat(e.target.value) || 0)} + className="w-full bg-[#0f1117] border border-[#2a2d3e] rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-[#6366f1]" + /> + )} +
+ ) + + return ( +
+

+ Paramètres du backtest +

+ +
+ + + + + + + + + +
+ + {error && ( +
+ {error} +
+ )} + + +
+ ) +} diff --git a/frontend/src/components/Backtest/BacktestResults.tsx b/frontend/src/components/Backtest/BacktestResults.tsx new file mode 100644 index 0000000..7214d84 --- /dev/null +++ b/frontend/src/components/Backtest/BacktestResults.tsx @@ -0,0 +1,163 @@ +import { createChart, LineSeries, ColorType, type Time } from 'lightweight-charts' +import { useEffect, useRef } from 'react' +import type { BacktestResult } from '../../lib/api' + +interface Props { + result: BacktestResult +} + +function MetricCard({ label, value, color }: { label: string; value: string; color?: string }) { + return ( +
+
{label}
+
{value}
+
+ ) +} + +export default function BacktestResults({ result }: Props) { + const { metrics, equity_curve, trades } = result + const chartRef = useRef(null) + + useEffect(() => { + if (!chartRef.current || equity_curve.length === 0) return + + const chart = createChart(chartRef.current, { + width: chartRef.current.clientWidth, + height: 220, + layout: { + background: { type: ColorType.Solid, color: '#0f1117' }, + textColor: '#64748b', + }, + grid: { vertLines: { color: '#1a1d27' }, horzLines: { color: '#1a1d27' } }, + rightPriceScale: { borderColor: '#2a2d3e' }, + timeScale: { borderColor: '#2a2d3e', timeVisible: true }, + }) + + const lineSeries = chart.addSeries(LineSeries, { + color: '#6366f1', + lineWidth: 2, + }) + + lineSeries.setData( + equity_curve.map((p) => ({ + time: (new Date(p.time).getTime() / 1000) as Time, + value: p.balance, + })) + ) + chart.timeScale().fitContent() + + const observer = new ResizeObserver(() => { + if (chartRef.current) chart.applyOptions({ width: chartRef.current.clientWidth }) + }) + observer.observe(chartRef.current) + + return () => { + observer.disconnect() + chart.remove() + } + }, [equity_curve]) + + const pnlPositive = metrics.total_pnl >= 0 + + return ( +
+ {/* Métriques clés */} +
+ + = 50 ? 'text-[#26a69a]' : 'text-[#ef5350]'} + /> + + + + + + + +
+ + {/* Equity Curve */} +
+

+ Courbe d'équité +

+
+
+ + {/* Tableau des trades */} +
+
+

+ Trades simulés +

+ {trades.length} trades +
+
+ + + + + + + + + + + + + + + {trades.map((t, i) => ( + + + + + + + + + + + ))} + +
DirectionSignalEntréeSortiePnL (pips)RésultatOuvertureFermeture
+ + {t.direction.toUpperCase()} + + + {t.signal_type.replace('LiquiditySweep_', 'Sweep ').replace('+OrderBlock', ' + OB')} + {t.entry_price.toFixed(5)} + {t.exit_price?.toFixed(5) ?? '—'} + 0 ? 'text-[#26a69a]' : 'text-[#ef5350]' + }`}> + {t.pnl_pips !== null + ? `${t.pnl_pips > 0 ? '+' : ''}${t.pnl_pips.toFixed(1)}` + : '—'} + + + {t.status === 'win' ? '✓ Win' : '✗ Loss'} + + + {new Date(t.entry_time).toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })} + + {t.exit_time + ? new Date(t.exit_time).toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) + : '—'} +
+
+
+
+ ) +} diff --git a/frontend/src/components/Chart/CandlestickChart.tsx b/frontend/src/components/Chart/CandlestickChart.tsx new file mode 100644 index 0000000..3f0759b --- /dev/null +++ b/frontend/src/components/Chart/CandlestickChart.tsx @@ -0,0 +1,229 @@ +import { + createChart, + CandlestickSeries, + createSeriesMarkers, + type IChartApi, + type ISeriesApi, + type CandlestickData, + type SeriesMarker, + type Time, + ColorType, +} from 'lightweight-charts' +import { useEffect, useRef, useState } from 'react' +import type { Candle, BacktestTrade, Trade } from '../../lib/api' + +interface OrderBlock { + direction: 'bullish' | 'bearish' + top: number + bottom: number + origin_time: string + mitigated: boolean +} + +interface LiquidityLevel { + direction: 'high' | 'low' + price: number + origin_time: string + swept: boolean +} + +interface Props { + candles: Candle[] + orderBlocks?: OrderBlock[] + liquidityLevels?: LiquidityLevel[] + trades?: (BacktestTrade | Trade)[] + height?: number +} + +interface OhlcLegend { + time: string + open: number + high: number + low: number + close: number +} + +function toTimestamp(t: string): Time { + return (new Date(t).getTime() / 1000) as Time +} + +function pricePrecision(price: number): { precision: number; minMove: number } { + if (price >= 100) return { precision: 2, minMove: 0.01 } + if (price >= 10) return { precision: 3, minMove: 0.001 } + return { precision: 5, minMove: 0.00001 } +} + +function fmt(v: number): string { + if (v >= 100) return v.toFixed(2) + if (v >= 10) return v.toFixed(3) + return v.toFixed(5) +} + +export default function CandlestickChart({ + candles, + orderBlocks = [], + trades = [], + height = 500, +}: Props) { + const containerRef = useRef(null) + const chartRef = useRef(null) + const candleSeriesRef = useRef | null>(null) + const [legend, setLegend] = useState(null) + + useEffect(() => { + if (!containerRef.current) return + + const chart = createChart(containerRef.current, { + width: containerRef.current.clientWidth, + height, + layout: { + background: { type: ColorType.Solid, color: '#0f1117' }, + textColor: '#64748b', + }, + grid: { + vertLines: { color: '#1a1d27' }, + horzLines: { color: '#1a1d27' }, + }, + crosshair: { + vertLine: { color: '#6366f1', labelBackgroundColor: '#6366f1' }, + horzLine: { color: '#6366f1', labelBackgroundColor: '#6366f1' }, + }, + rightPriceScale: { borderColor: '#2a2d3e' }, + timeScale: { + borderColor: '#2a2d3e', + timeVisible: true, + secondsVisible: false, + }, + }) + + const candleSeries = chart.addSeries(CandlestickSeries, { + upColor: '#26a69a', + downColor: '#ef5350', + borderUpColor: '#26a69a', + borderDownColor: '#ef5350', + wickUpColor: '#26a69a', + wickDownColor: '#ef5350', + }) + + chartRef.current = chart + candleSeriesRef.current = candleSeries + + chart.subscribeCrosshairMove((param) => { + if (!param.seriesData.size) { + setLegend(null) + return + } + const d = param.seriesData.get(candleSeries) as CandlestickData | undefined + if (!d) return + const ts = typeof d.time === 'number' ? d.time * 1000 : 0 + setLegend({ + time: new Date(ts).toUTCString().slice(5, 22), + open: d.open, + high: d.high, + low: d.low, + close: d.close, + }) + }) + + const resizeObserver = new ResizeObserver(() => { + if (containerRef.current) { + chart.applyOptions({ width: containerRef.current.clientWidth }) + } + }) + resizeObserver.observe(containerRef.current) + + return () => { + resizeObserver.disconnect() + chart.remove() + } + }, [height]) + + // Mise à jour des données + useEffect(() => { + if (!candleSeriesRef.current) return + if (candles.length === 0) { + candleSeriesRef.current.setData([]) + return + } + + const { precision, minMove } = pricePrecision(candles[0].close) + candleSeriesRef.current.applyOptions({ + priceFormat: { type: 'price', precision, minMove }, + }) + + const data: CandlestickData[] = candles.map((c) => ({ + time: toTimestamp(c.time), + open: c.open, + high: c.high, + low: c.low, + close: c.close, + })) + + candleSeriesRef.current.setData(data) + + // Markers pour les trades (v5 API) + const markers: SeriesMarker