From cc60da49e7ce941ffa3b40789b1734d74cb3f37b Mon Sep 17 00:00:00 2001 From: kfox Date: Fri, 26 Dec 2025 01:15:43 -0500 Subject: [PATCH] Local changes: Updated model training, removed debug instrumentation, and configuration improvements --- .coveragerc | 32 + .github/workflows/docs.yml | 39 + .github/workflows/tests.yml | 41 + .gitignore | 69 + Dockerfile | 51 + README.md | 344 + backend/README.md | 47 + backend/__init__.py | 1 + backend/api/__init__.py | 1 + backend/api/alerts.py | 144 + backend/api/autopilot.py | 564 ++ backend/api/backtesting.py | 78 + backend/api/exchanges.py | 42 + backend/api/market_data.py | 280 + backend/api/portfolio.py | 84 + backend/api/reporting.py | 272 + backend/api/reports.py | 155 + backend/api/settings.py | 359 + backend/api/strategies.py | 310 + backend/api/trading.py | 206 + backend/api/websocket.py | 242 + backend/core/__init__.py | 1 + backend/core/dependencies.py | 70 + backend/core/schemas.py | 213 + backend/main.py | 185 + backend/requirements.txt | 4 + check_db.py | 21 + config/config.yaml | 94 + config/logging.yaml | 54 + docker-compose.yml | 23 + docs/api/Makefile | 20 + docs/api/make.bat | 35 + docs/api/source/alerts.rst | 29 + docs/api/source/backtesting.rst | 37 + docs/api/source/conf.py | 80 + docs/api/source/data.rst | 94 + docs/api/source/exchanges.rst | 40 + docs/api/source/index.rst | 27 + docs/api/source/market_data.rst | 312 + docs/api/source/modules.rst | 18 + docs/api/source/portfolio.rst | 21 + docs/api/source/risk.rst | 37 + docs/api/source/security.rst | 29 + docs/api/source/strategies.rst | 120 + docs/api/source/trading.rst | 45 + docs/architecture/autopilot.md | 378 + docs/architecture/backtesting.md | 135 + docs/architecture/data_flow.md | 233 + docs/architecture/database_schema.md | 288 + docs/architecture/exchange_integration.md | 176 + docs/architecture/overview.md | 185 + docs/architecture/risk_management.md | 165 + docs/architecture/security.md | 135 + docs/architecture/strategy_framework.md | 208 + docs/architecture/ui_architecture.md | 270 + docs/deployment/README.md | 116 + docs/deployment/postgresql.md | 171 + docs/deployment/updates.md | 131 + docs/deployment/web_architecture.md | 262 + docs/developer/README.md | 40 + docs/developer/adding_exchanges.md | 216 + docs/developer/coding_standards.md | 226 + docs/developer/contributing.md | 115 + docs/developer/creating_strategies.md | 233 + docs/developer/frontend_testing.md | 270 + docs/developer/pricing_providers.md | 243 + docs/developer/release_process.md | 180 + docs/developer/setup.md | 315 + docs/developer/testing.md | 421 ++ docs/developer/ui_development.md | 346 + docs/frontend_changes_summary.md | 156 + docs/guides/pairs_trading_setup.md | 192 + docs/migration_guide.md | 222 + docs/requirements.txt | 5 + docs/user_manual/ALGORITHM_IMPROVEMENTS.md | 182 + docs/user_manual/README.md | 23 + docs/user_manual/alerts.md | 141 + docs/user_manual/backtesting.md | 217 + docs/user_manual/configuration.md | 359 + docs/user_manual/faq.md | 235 + docs/user_manual/getting_started.md | 157 + docs/user_manual/portfolio.md | 134 + docs/user_manual/reporting.md | 120 + docs/user_manual/strategies.md | 286 + docs/user_manual/trading.md | 332 + docs/user_manual/troubleshooting.md | 254 + frontend/README.md | 53 + frontend/e2e/dashboard.spec.ts | 89 + frontend/e2e/settings.spec.ts | 73 + frontend/e2e/strategies.spec.ts | 79 + frontend/e2e/trading.spec.ts | 96 + frontend/index.html | 13 + frontend/package-lock.json | 6715 +++++++++++++++++ frontend/package.json | 51 + frontend/playwright.config.ts | 56 + frontend/public/logo.png | Bin 0 -> 422172 bytes frontend/public/logo.svg | 1 + frontend/src/App.tsx | 71 + frontend/src/api/__init__.ts | 5 + frontend/src/api/__tests__/autopilot.test.ts | 105 + frontend/src/api/__tests__/marketData.test.ts | 167 + frontend/src/api/__tests__/strategies.test.ts | 136 + frontend/src/api/__tests__/trading.test.ts | 126 + frontend/src/api/alerts.ts | 53 + frontend/src/api/autopilot.ts | 200 + frontend/src/api/backtesting.ts | 14 + frontend/src/api/client.ts | 37 + frontend/src/api/exchanges.ts | 19 + frontend/src/api/marketData.ts | 142 + frontend/src/api/portfolio.ts | 41 + frontend/src/api/reporting.ts | 33 + frontend/src/api/settings.ts | 137 + frontend/src/api/strategies.ts | 76 + frontend/src/api/trading.ts | 51 + frontend/src/assets/logo.png | Bin 0 -> 422172 bytes frontend/src/components/AlertHistory.tsx | 99 + frontend/src/components/Chart.tsx | 99 + frontend/src/components/ChartGrid.tsx | 126 + frontend/src/components/DataFreshness.tsx | 64 + frontend/src/components/ErrorDisplay.tsx | 39 + frontend/src/components/HelpTooltip.tsx | 18 + frontend/src/components/InfoCard.tsx | 39 + frontend/src/components/Layout.tsx | 132 + frontend/src/components/LoadingSkeleton.tsx | 64 + frontend/src/components/OperationsPanel.tsx | 97 + .../components/OrderConfirmationDialog.tsx | 120 + frontend/src/components/OrderForm.tsx | 316 + frontend/src/components/PositionCard.tsx | 240 + frontend/src/components/ProgressOverlay.tsx | 45 + frontend/src/components/ProviderStatus.tsx | 132 + frontend/src/components/RealtimePrice.tsx | 185 + frontend/src/components/SpreadChart.tsx | 253 + frontend/src/components/StatusIndicator.tsx | 63 + frontend/src/components/StrategyDialog.tsx | 404 + .../src/components/StrategyParameterForm.tsx | 792 ++ frontend/src/components/SystemHealth.tsx | 83 + frontend/src/components/WebSocketProvider.tsx | 31 + frontend/src/components/__init__.ts | 2 + .../__tests__/ErrorDisplay.test.tsx | 65 + .../__tests__/PositionCard.test.tsx | 166 + .../__tests__/StatusIndicator.test.tsx | 71 + .../src/contexts/AutopilotSettingsContext.tsx | 96 + frontend/src/contexts/SnackbarContext.tsx | 54 + .../hooks/__tests__/useProviderStatus.test.ts | 96 + .../hooks/__tests__/useRealtimeData.test.ts | 134 + .../src/hooks/__tests__/useWebSocket.test.ts | 214 + frontend/src/hooks/useProviderStatus.ts | 60 + frontend/src/hooks/useRealtimeData.ts | 69 + frontend/src/hooks/useWebSocket.ts | 116 + frontend/src/main.tsx | 62 + frontend/src/pages/BacktestPage.tsx | 488 ++ frontend/src/pages/DashboardPage.tsx | 733 ++ frontend/src/pages/PortfolioPage.tsx | 497 ++ frontend/src/pages/SettingsPage.tsx | 1744 +++++ frontend/src/pages/StrategiesPage.tsx | 402 + frontend/src/pages/TradingPage.tsx | 554 ++ frontend/src/pages/__init__.ts | 4 + .../pages/__tests__/DashboardPage.test.tsx | 196 + frontend/src/test/setup.ts | 65 + frontend/src/test/utils.tsx | 80 + frontend/src/types/index.ts | 150 + frontend/src/utils/errorHandler.ts | 48 + frontend/src/utils/formatters.ts | 58 + frontend/src/vite-env.d.ts | 10 + frontend/tsconfig.json | 29 + frontend/tsconfig.node.json | 10 + frontend/vite.config.ts | 26 + frontend/vitest.config.ts | 30 + pytest.ini | 23 + requirements.txt | 62 + scripts/add_running_column.sql | 8 + scripts/fetch_historical_data.py | 153 + scripts/reset_database.py | 68 + scripts/start_all.sh | 52 + scripts/verify_redis.py | 53 + setup.py | 43 + src/__init__.py | 0 src/alerts/__init__.py | 0 src/alerts/channels.py | 26 + src/alerts/engine.py | 139 + src/alerts/manager.py | 78 + src/autopilot/__init__.py | 150 + src/autopilot/intelligent_autopilot.py | 717 ++ src/autopilot/market_analyzer.py | 485 ++ src/autopilot/models.py | 519 ++ src/autopilot/performance_tracker.py | 351 + src/autopilot/strategy_groups.py | 190 + src/autopilot/strategy_selector.py | 581 ++ src/backtesting/__init__.py | 0 src/backtesting/data_provider.py | 51 + src/backtesting/engine.py | 207 + src/backtesting/metrics.py | 85 + src/backtesting/slippage.py | 175 + src/core/__init__.py | 0 src/core/config.py | 256 + src/core/database.py | 416 + src/core/logger.py | 128 + src/core/pubsub.py | 261 + src/core/redis.py | 70 + src/core/repositories.py | 99 + src/data/__init__.py | 27 + src/data/cache_manager.py | 221 + src/data/collector.py | 139 + src/data/health_monitor.py | 317 + src/data/indicators.py | 569 ++ src/data/news_collector.py | 447 ++ src/data/pricing_service.py | 406 + src/data/providers/__init__.py | 7 + src/data/providers/base_provider.py | 150 + src/data/providers/ccxt_provider.py | 333 + src/data/providers/coingecko_provider.py | 376 + src/data/quality.py | 116 + src/data/redis_cache.py | 225 + src/data/storage.py | 75 + src/exchanges/__init__.py | 14 + src/exchanges/base.py | 309 + src/exchanges/coinbase.py | 392 + src/exchanges/factory.py | 165 + src/exchanges/public_data.py | 433 ++ src/optimization/__init__.py | 0 src/optimization/bayesian.py | 76 + src/optimization/genetic.py | 111 + src/optimization/grid_search.py | 57 + src/portfolio/__init__.py | 0 src/portfolio/analytics.py | 265 + src/portfolio/tracker.py | 144 + src/rebalancing/__init__.py | 0 src/rebalancing/engine.py | 196 + src/rebalancing/strategies.py | 36 + src/reporting/__init__.py | 0 src/reporting/csv_exporter.py | 120 + src/reporting/pdf_generator.py | 111 + src/reporting/tax_reporter.py | 127 + src/resilience/__init__.py | 0 src/resilience/health_monitor.py | 100 + src/resilience/recovery.py | 103 + src/resilience/state_manager.py | 90 + src/risk/__init__.py | 0 src/risk/limits.py | 166 + src/risk/manager.py | 91 + src/risk/position_sizing.py | 144 + src/risk/stop_loss.py | 229 + src/security/__init__.py | 0 src/security/audit.py | 94 + src/security/encryption.py | 108 + src/security/key_manager.py | 173 + src/strategies/__init__.py | 45 + src/strategies/base.py | 450 ++ src/strategies/dca/__init__.py | 0 src/strategies/dca/dca_strategy.py | 90 + src/strategies/ensemble/__init__.py | 6 + src/strategies/ensemble/consensus_strategy.py | 244 + src/strategies/grid/__init__.py | 0 src/strategies/grid/grid_strategy.py | 109 + src/strategies/market_making/__init__.py | 5 + .../market_making/market_making_strategy.py | 206 + src/strategies/momentum/__init__.py | 0 src/strategies/momentum/momentum_strategy.py | 138 + src/strategies/scheduler.py | 370 + src/strategies/sentiment/__init__.py | 5 + .../sentiment/sentiment_strategy.py | 208 + src/strategies/technical/__init__.py | 0 .../technical/bollinger_mean_reversion.py | 227 + .../technical/confirmed_strategy.py | 246 + .../technical/divergence_strategy.py | 154 + src/strategies/technical/macd_strategy.py | 72 + .../technical/moving_avg_strategy.py | 79 + src/strategies/technical/pairs_trading.py | 146 + src/strategies/technical/rsi_strategy.py | 67 + .../technical/volatility_breakout.py | 177 + src/strategies/timeframe_manager.py | 103 + src/trading/__init__.py | 0 src/trading/advanced_orders.py | 409 + src/trading/engine.py | 245 + src/trading/fee_calculator.py | 278 + src/trading/futures.py | 122 + src/trading/order_manager.py | 305 + src/trading/paper_trading.py | 377 + src/utils/__init__.py | 0 src/worker/app.py | 55 + src/worker/tasks.py | 544 ++ start_log.txt | 5 + test_output.txt | 158 + tests/__init__.py | 2 + tests/conftest.py | 86 + tests/e2e/__init__.py | 2 + tests/e2e/test_backtest_e2e.py | 18 + tests/e2e/test_paper_trading_e2e.py | 51 + tests/e2e/test_pricing_data_e2e.py | 340 + tests/e2e/test_strategy_lifecycle.py | 34 + tests/fixtures/__init__.py | 2 + tests/fixtures/mock_exchange.py | 76 + tests/fixtures/sample_data.py | 81 + tests/integration/__init__.py | 2 + tests/integration/backend/__init__.py | 2 + .../integration/backend/test_api_workflows.py | 95 + .../backend/test_frontend_api_workflows.py | 323 + .../backend/test_websocket_connections.py | 32 + tests/integration/test_autopilot_workflow.py | 138 + .../integration/test_backtesting_workflow.py | 18 + tests/integration/test_data_pipeline.py | 21 + tests/integration/test_portfolio_tracking.py | 31 + tests/integration/test_pricing_providers.py | 71 + tests/integration/test_strategy_execution.py | 36 + tests/integration/test_trading_workflow.py | 47 + .../integration/test_ui_backtest_workflow.py | 65 + .../integration/test_ui_strategy_workflow.py | 62 + tests/integration/test_ui_trading_workflow.py | 77 + tests/performance/__init__.py | 2 + .../performance/test_backtest_performance.py | 26 + tests/requirements.txt | 8 + tests/unit/__init__.py | 2 + tests/unit/alerts/__init__.py | 2 + tests/unit/alerts/test_engine.py | 33 + tests/unit/autopilot/__init__.py | 2 + .../autopilot/test_intelligent_autopilot.py | 175 + tests/unit/autopilot/test_strategy_groups.py | 161 + tests/unit/backend/__init__.py | 2 + tests/unit/backend/api/__init__.py | 2 + tests/unit/backend/api/test_autopilot.py | 379 + tests/unit/backend/api/test_exchanges.py | 81 + tests/unit/backend/api/test_portfolio.py | 65 + tests/unit/backend/api/test_strategies.py | 108 + tests/unit/backend/api/test_trading.py | 293 + tests/unit/backend/core/test_dependencies.py | 77 + tests/unit/backend/core/test_schemas.py | 128 + tests/unit/backtesting/__init__.py | 2 + tests/unit/backtesting/test_engine.py | 26 + tests/unit/backtesting/test_slippage.py | 85 + tests/unit/core/__init__.py | 1 + tests/unit/core/test_config.py | 44 + tests/unit/core/test_database.py | 97 + tests/unit/core/test_logger.py | 43 + tests/unit/core/test_redis.py | 92 + tests/unit/data/__init__.py | 1 + .../unit/data/providers/test_ccxt_provider.py | 139 + .../data/providers/test_coingecko_provider.py | 113 + tests/unit/data/test_cache_manager.py | 120 + tests/unit/data/test_health_monitor.py | 145 + tests/unit/data/test_indicators.py | 68 + tests/unit/data/test_indicators_divergence.py | 80 + tests/unit/data/test_pricing_service.py | 135 + tests/unit/data/test_redis_cache.py | 118 + tests/unit/exchanges/__init__.py | 2 + tests/unit/exchanges/test_base.py | 24 + tests/unit/exchanges/test_coinbase.py | 56 + tests/unit/exchanges/test_factory.py | 40 + tests/unit/exchanges/test_websocket.py | 44 + tests/unit/optimization/__init__.py | 2 + tests/unit/optimization/test_grid_search.py | 44 + tests/unit/portfolio/__init__.py | 2 + tests/unit/portfolio/test_analytics.py | 35 + tests/unit/portfolio/test_tracker.py | 29 + tests/unit/reporting/__init__.py | 2 + tests/unit/reporting/test_csv_exporter.py | 35 + tests/unit/resilience/__init__.py | 2 + tests/unit/resilience/test_state_manager.py | 31 + tests/unit/risk/__init__.py | 2 + tests/unit/risk/test_manager.py | 55 + tests/unit/risk/test_position_sizing.py | 41 + tests/unit/risk/test_stop_loss_atr.py | 122 + tests/unit/security/__init__.py | 2 + tests/unit/security/test_encryption.py | 35 + tests/unit/strategies/__init__.py | 2 + tests/unit/strategies/test_base.py | 89 + .../test_bollinger_mean_reversion.py | 55 + .../strategies/test_confirmed_strategy.py | 61 + .../strategies/test_consensus_strategy.py | 53 + tests/unit/strategies/test_dca_strategy.py | 52 + .../strategies/test_divergence_strategy.py | 59 + tests/unit/strategies/test_grid_strategy.py | 69 + tests/unit/strategies/test_macd_strategy.py | 45 + .../unit/strategies/test_momentum_strategy.py | 72 + .../strategies/test_moving_avg_strategy.py | 45 + tests/unit/strategies/test_pairs_trading.py | 89 + tests/unit/strategies/test_rsi_strategy.py | 67 + tests/unit/strategies/test_trend_filter.py | 88 + tests/unit/test_autopilot_training.py | 284 + tests/unit/trading/__init__.py | 2 + tests/unit/trading/test_engine.py | 88 + tests/unit/trading/test_fee_calculator.py | 161 + tests/unit/trading/test_order_manager.py | 70 + tests/unit/trading/test_paper_trading.py | 32 + tests/unit/worker/__init__.py | 1 + tests/unit/worker/test_tasks.py | 178 + tests/utils/__init__.py | 2 + tests/utils/helpers.py | 26 + tests/utils/ui_helpers.py | 60 + 388 files changed, 57127 insertions(+) create mode 100644 .coveragerc create mode 100644 .github/workflows/docs.yml create mode 100644 .github/workflows/tests.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 backend/README.md create mode 100644 backend/__init__.py create mode 100644 backend/api/__init__.py create mode 100644 backend/api/alerts.py create mode 100644 backend/api/autopilot.py create mode 100644 backend/api/backtesting.py create mode 100644 backend/api/exchanges.py create mode 100644 backend/api/market_data.py create mode 100644 backend/api/portfolio.py create mode 100644 backend/api/reporting.py create mode 100644 backend/api/reports.py create mode 100644 backend/api/settings.py create mode 100644 backend/api/strategies.py create mode 100644 backend/api/trading.py create mode 100644 backend/api/websocket.py create mode 100644 backend/core/__init__.py create mode 100644 backend/core/dependencies.py create mode 100644 backend/core/schemas.py create mode 100644 backend/main.py create mode 100644 backend/requirements.txt create mode 100644 check_db.py create mode 100644 config/config.yaml create mode 100644 config/logging.yaml create mode 100644 docker-compose.yml create mode 100644 docs/api/Makefile create mode 100644 docs/api/make.bat create mode 100644 docs/api/source/alerts.rst create mode 100644 docs/api/source/backtesting.rst create mode 100644 docs/api/source/conf.py create mode 100644 docs/api/source/data.rst create mode 100644 docs/api/source/exchanges.rst create mode 100644 docs/api/source/index.rst create mode 100644 docs/api/source/market_data.rst create mode 100644 docs/api/source/modules.rst create mode 100644 docs/api/source/portfolio.rst create mode 100644 docs/api/source/risk.rst create mode 100644 docs/api/source/security.rst create mode 100644 docs/api/source/strategies.rst create mode 100644 docs/api/source/trading.rst create mode 100644 docs/architecture/autopilot.md create mode 100644 docs/architecture/backtesting.md create mode 100644 docs/architecture/data_flow.md create mode 100644 docs/architecture/database_schema.md create mode 100644 docs/architecture/exchange_integration.md create mode 100644 docs/architecture/overview.md create mode 100644 docs/architecture/risk_management.md create mode 100644 docs/architecture/security.md create mode 100644 docs/architecture/strategy_framework.md create mode 100644 docs/architecture/ui_architecture.md create mode 100644 docs/deployment/README.md create mode 100644 docs/deployment/postgresql.md create mode 100644 docs/deployment/updates.md create mode 100644 docs/deployment/web_architecture.md create mode 100644 docs/developer/README.md create mode 100644 docs/developer/adding_exchanges.md create mode 100644 docs/developer/coding_standards.md create mode 100644 docs/developer/contributing.md create mode 100644 docs/developer/creating_strategies.md create mode 100644 docs/developer/frontend_testing.md create mode 100644 docs/developer/pricing_providers.md create mode 100644 docs/developer/release_process.md create mode 100644 docs/developer/setup.md create mode 100644 docs/developer/testing.md create mode 100644 docs/developer/ui_development.md create mode 100644 docs/frontend_changes_summary.md create mode 100644 docs/guides/pairs_trading_setup.md create mode 100644 docs/migration_guide.md create mode 100644 docs/requirements.txt create mode 100644 docs/user_manual/ALGORITHM_IMPROVEMENTS.md create mode 100644 docs/user_manual/README.md create mode 100644 docs/user_manual/alerts.md create mode 100644 docs/user_manual/backtesting.md create mode 100644 docs/user_manual/configuration.md create mode 100644 docs/user_manual/faq.md create mode 100644 docs/user_manual/getting_started.md create mode 100644 docs/user_manual/portfolio.md create mode 100644 docs/user_manual/reporting.md create mode 100644 docs/user_manual/strategies.md create mode 100644 docs/user_manual/trading.md create mode 100644 docs/user_manual/troubleshooting.md create mode 100644 frontend/README.md create mode 100644 frontend/e2e/dashboard.spec.ts create mode 100644 frontend/e2e/settings.spec.ts create mode 100644 frontend/e2e/strategies.spec.ts create mode 100644 frontend/e2e/trading.spec.ts create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/playwright.config.ts create mode 100644 frontend/public/logo.png create mode 100644 frontend/public/logo.svg create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api/__init__.ts create mode 100644 frontend/src/api/__tests__/autopilot.test.ts create mode 100644 frontend/src/api/__tests__/marketData.test.ts create mode 100644 frontend/src/api/__tests__/strategies.test.ts create mode 100644 frontend/src/api/__tests__/trading.test.ts create mode 100644 frontend/src/api/alerts.ts create mode 100644 frontend/src/api/autopilot.ts create mode 100644 frontend/src/api/backtesting.ts create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/api/exchanges.ts create mode 100644 frontend/src/api/marketData.ts create mode 100644 frontend/src/api/portfolio.ts create mode 100644 frontend/src/api/reporting.ts create mode 100644 frontend/src/api/settings.ts create mode 100644 frontend/src/api/strategies.ts create mode 100644 frontend/src/api/trading.ts create mode 100644 frontend/src/assets/logo.png create mode 100644 frontend/src/components/AlertHistory.tsx create mode 100644 frontend/src/components/Chart.tsx create mode 100644 frontend/src/components/ChartGrid.tsx create mode 100644 frontend/src/components/DataFreshness.tsx create mode 100644 frontend/src/components/ErrorDisplay.tsx create mode 100644 frontend/src/components/HelpTooltip.tsx create mode 100644 frontend/src/components/InfoCard.tsx create mode 100644 frontend/src/components/Layout.tsx create mode 100644 frontend/src/components/LoadingSkeleton.tsx create mode 100644 frontend/src/components/OperationsPanel.tsx create mode 100644 frontend/src/components/OrderConfirmationDialog.tsx create mode 100644 frontend/src/components/OrderForm.tsx create mode 100644 frontend/src/components/PositionCard.tsx create mode 100644 frontend/src/components/ProgressOverlay.tsx create mode 100644 frontend/src/components/ProviderStatus.tsx create mode 100644 frontend/src/components/RealtimePrice.tsx create mode 100644 frontend/src/components/SpreadChart.tsx create mode 100644 frontend/src/components/StatusIndicator.tsx create mode 100644 frontend/src/components/StrategyDialog.tsx create mode 100644 frontend/src/components/StrategyParameterForm.tsx create mode 100644 frontend/src/components/SystemHealth.tsx create mode 100644 frontend/src/components/WebSocketProvider.tsx create mode 100644 frontend/src/components/__init__.ts create mode 100644 frontend/src/components/__tests__/ErrorDisplay.test.tsx create mode 100644 frontend/src/components/__tests__/PositionCard.test.tsx create mode 100644 frontend/src/components/__tests__/StatusIndicator.test.tsx create mode 100644 frontend/src/contexts/AutopilotSettingsContext.tsx create mode 100644 frontend/src/contexts/SnackbarContext.tsx create mode 100644 frontend/src/hooks/__tests__/useProviderStatus.test.ts create mode 100644 frontend/src/hooks/__tests__/useRealtimeData.test.ts create mode 100644 frontend/src/hooks/__tests__/useWebSocket.test.ts create mode 100644 frontend/src/hooks/useProviderStatus.ts create mode 100644 frontend/src/hooks/useRealtimeData.ts create mode 100644 frontend/src/hooks/useWebSocket.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/BacktestPage.tsx create mode 100644 frontend/src/pages/DashboardPage.tsx create mode 100644 frontend/src/pages/PortfolioPage.tsx create mode 100644 frontend/src/pages/SettingsPage.tsx create mode 100644 frontend/src/pages/StrategiesPage.tsx create mode 100644 frontend/src/pages/TradingPage.tsx create mode 100644 frontend/src/pages/__init__.ts create mode 100644 frontend/src/pages/__tests__/DashboardPage.test.tsx create mode 100644 frontend/src/test/setup.ts create mode 100644 frontend/src/test/utils.tsx create mode 100644 frontend/src/types/index.ts create mode 100644 frontend/src/utils/errorHandler.ts create mode 100644 frontend/src/utils/formatters.ts create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 frontend/vitest.config.ts create mode 100644 pytest.ini create mode 100644 requirements.txt create mode 100644 scripts/add_running_column.sql create mode 100644 scripts/fetch_historical_data.py create mode 100644 scripts/reset_database.py create mode 100644 scripts/start_all.sh create mode 100644 scripts/verify_redis.py create mode 100644 setup.py create mode 100644 src/__init__.py create mode 100644 src/alerts/__init__.py create mode 100644 src/alerts/channels.py create mode 100644 src/alerts/engine.py create mode 100644 src/alerts/manager.py create mode 100644 src/autopilot/__init__.py create mode 100644 src/autopilot/intelligent_autopilot.py create mode 100644 src/autopilot/market_analyzer.py create mode 100644 src/autopilot/models.py create mode 100644 src/autopilot/performance_tracker.py create mode 100644 src/autopilot/strategy_groups.py create mode 100644 src/autopilot/strategy_selector.py create mode 100644 src/backtesting/__init__.py create mode 100644 src/backtesting/data_provider.py create mode 100644 src/backtesting/engine.py create mode 100644 src/backtesting/metrics.py create mode 100644 src/backtesting/slippage.py create mode 100644 src/core/__init__.py create mode 100644 src/core/config.py create mode 100644 src/core/database.py create mode 100644 src/core/logger.py create mode 100644 src/core/pubsub.py create mode 100644 src/core/redis.py create mode 100644 src/core/repositories.py create mode 100644 src/data/__init__.py create mode 100644 src/data/cache_manager.py create mode 100644 src/data/collector.py create mode 100644 src/data/health_monitor.py create mode 100644 src/data/indicators.py create mode 100644 src/data/news_collector.py create mode 100644 src/data/pricing_service.py create mode 100644 src/data/providers/__init__.py create mode 100644 src/data/providers/base_provider.py create mode 100644 src/data/providers/ccxt_provider.py create mode 100644 src/data/providers/coingecko_provider.py create mode 100644 src/data/quality.py create mode 100644 src/data/redis_cache.py create mode 100644 src/data/storage.py create mode 100644 src/exchanges/__init__.py create mode 100644 src/exchanges/base.py create mode 100644 src/exchanges/coinbase.py create mode 100644 src/exchanges/factory.py create mode 100644 src/exchanges/public_data.py create mode 100644 src/optimization/__init__.py create mode 100644 src/optimization/bayesian.py create mode 100644 src/optimization/genetic.py create mode 100644 src/optimization/grid_search.py create mode 100644 src/portfolio/__init__.py create mode 100644 src/portfolio/analytics.py create mode 100644 src/portfolio/tracker.py create mode 100644 src/rebalancing/__init__.py create mode 100644 src/rebalancing/engine.py create mode 100644 src/rebalancing/strategies.py create mode 100644 src/reporting/__init__.py create mode 100644 src/reporting/csv_exporter.py create mode 100644 src/reporting/pdf_generator.py create mode 100644 src/reporting/tax_reporter.py create mode 100644 src/resilience/__init__.py create mode 100644 src/resilience/health_monitor.py create mode 100644 src/resilience/recovery.py create mode 100644 src/resilience/state_manager.py create mode 100644 src/risk/__init__.py create mode 100644 src/risk/limits.py create mode 100644 src/risk/manager.py create mode 100644 src/risk/position_sizing.py create mode 100644 src/risk/stop_loss.py create mode 100644 src/security/__init__.py create mode 100644 src/security/audit.py create mode 100644 src/security/encryption.py create mode 100644 src/security/key_manager.py create mode 100644 src/strategies/__init__.py create mode 100644 src/strategies/base.py create mode 100644 src/strategies/dca/__init__.py create mode 100644 src/strategies/dca/dca_strategy.py create mode 100644 src/strategies/ensemble/__init__.py create mode 100644 src/strategies/ensemble/consensus_strategy.py create mode 100644 src/strategies/grid/__init__.py create mode 100644 src/strategies/grid/grid_strategy.py create mode 100644 src/strategies/market_making/__init__.py create mode 100644 src/strategies/market_making/market_making_strategy.py create mode 100644 src/strategies/momentum/__init__.py create mode 100644 src/strategies/momentum/momentum_strategy.py create mode 100644 src/strategies/scheduler.py create mode 100644 src/strategies/sentiment/__init__.py create mode 100644 src/strategies/sentiment/sentiment_strategy.py create mode 100644 src/strategies/technical/__init__.py create mode 100644 src/strategies/technical/bollinger_mean_reversion.py create mode 100644 src/strategies/technical/confirmed_strategy.py create mode 100644 src/strategies/technical/divergence_strategy.py create mode 100644 src/strategies/technical/macd_strategy.py create mode 100644 src/strategies/technical/moving_avg_strategy.py create mode 100644 src/strategies/technical/pairs_trading.py create mode 100644 src/strategies/technical/rsi_strategy.py create mode 100644 src/strategies/technical/volatility_breakout.py create mode 100644 src/strategies/timeframe_manager.py create mode 100644 src/trading/__init__.py create mode 100644 src/trading/advanced_orders.py create mode 100644 src/trading/engine.py create mode 100644 src/trading/fee_calculator.py create mode 100644 src/trading/futures.py create mode 100644 src/trading/order_manager.py create mode 100644 src/trading/paper_trading.py create mode 100644 src/utils/__init__.py create mode 100644 src/worker/app.py create mode 100644 src/worker/tasks.py create mode 100644 start_log.txt create mode 100644 test_output.txt create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/e2e/__init__.py create mode 100644 tests/e2e/test_backtest_e2e.py create mode 100644 tests/e2e/test_paper_trading_e2e.py create mode 100644 tests/e2e/test_pricing_data_e2e.py create mode 100644 tests/e2e/test_strategy_lifecycle.py create mode 100644 tests/fixtures/__init__.py create mode 100644 tests/fixtures/mock_exchange.py create mode 100644 tests/fixtures/sample_data.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/backend/__init__.py create mode 100644 tests/integration/backend/test_api_workflows.py create mode 100644 tests/integration/backend/test_frontend_api_workflows.py create mode 100644 tests/integration/backend/test_websocket_connections.py create mode 100644 tests/integration/test_autopilot_workflow.py create mode 100644 tests/integration/test_backtesting_workflow.py create mode 100644 tests/integration/test_data_pipeline.py create mode 100644 tests/integration/test_portfolio_tracking.py create mode 100644 tests/integration/test_pricing_providers.py create mode 100644 tests/integration/test_strategy_execution.py create mode 100644 tests/integration/test_trading_workflow.py create mode 100644 tests/integration/test_ui_backtest_workflow.py create mode 100644 tests/integration/test_ui_strategy_workflow.py create mode 100644 tests/integration/test_ui_trading_workflow.py create mode 100644 tests/performance/__init__.py create mode 100644 tests/performance/test_backtest_performance.py create mode 100644 tests/requirements.txt create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/alerts/__init__.py create mode 100644 tests/unit/alerts/test_engine.py create mode 100644 tests/unit/autopilot/__init__.py create mode 100644 tests/unit/autopilot/test_intelligent_autopilot.py create mode 100644 tests/unit/autopilot/test_strategy_groups.py create mode 100644 tests/unit/backend/__init__.py create mode 100644 tests/unit/backend/api/__init__.py create mode 100644 tests/unit/backend/api/test_autopilot.py create mode 100644 tests/unit/backend/api/test_exchanges.py create mode 100644 tests/unit/backend/api/test_portfolio.py create mode 100644 tests/unit/backend/api/test_strategies.py create mode 100644 tests/unit/backend/api/test_trading.py create mode 100644 tests/unit/backend/core/test_dependencies.py create mode 100644 tests/unit/backend/core/test_schemas.py create mode 100644 tests/unit/backtesting/__init__.py create mode 100644 tests/unit/backtesting/test_engine.py create mode 100644 tests/unit/backtesting/test_slippage.py create mode 100644 tests/unit/core/__init__.py create mode 100644 tests/unit/core/test_config.py create mode 100644 tests/unit/core/test_database.py create mode 100644 tests/unit/core/test_logger.py create mode 100644 tests/unit/core/test_redis.py create mode 100644 tests/unit/data/__init__.py create mode 100644 tests/unit/data/providers/test_ccxt_provider.py create mode 100644 tests/unit/data/providers/test_coingecko_provider.py create mode 100644 tests/unit/data/test_cache_manager.py create mode 100644 tests/unit/data/test_health_monitor.py create mode 100644 tests/unit/data/test_indicators.py create mode 100644 tests/unit/data/test_indicators_divergence.py create mode 100644 tests/unit/data/test_pricing_service.py create mode 100644 tests/unit/data/test_redis_cache.py create mode 100644 tests/unit/exchanges/__init__.py create mode 100644 tests/unit/exchanges/test_base.py create mode 100644 tests/unit/exchanges/test_coinbase.py create mode 100644 tests/unit/exchanges/test_factory.py create mode 100644 tests/unit/exchanges/test_websocket.py create mode 100644 tests/unit/optimization/__init__.py create mode 100644 tests/unit/optimization/test_grid_search.py create mode 100644 tests/unit/portfolio/__init__.py create mode 100644 tests/unit/portfolio/test_analytics.py create mode 100644 tests/unit/portfolio/test_tracker.py create mode 100644 tests/unit/reporting/__init__.py create mode 100644 tests/unit/reporting/test_csv_exporter.py create mode 100644 tests/unit/resilience/__init__.py create mode 100644 tests/unit/resilience/test_state_manager.py create mode 100644 tests/unit/risk/__init__.py create mode 100644 tests/unit/risk/test_manager.py create mode 100644 tests/unit/risk/test_position_sizing.py create mode 100644 tests/unit/risk/test_stop_loss_atr.py create mode 100644 tests/unit/security/__init__.py create mode 100644 tests/unit/security/test_encryption.py create mode 100644 tests/unit/strategies/__init__.py create mode 100644 tests/unit/strategies/test_base.py create mode 100644 tests/unit/strategies/test_bollinger_mean_reversion.py create mode 100644 tests/unit/strategies/test_confirmed_strategy.py create mode 100644 tests/unit/strategies/test_consensus_strategy.py create mode 100644 tests/unit/strategies/test_dca_strategy.py create mode 100644 tests/unit/strategies/test_divergence_strategy.py create mode 100644 tests/unit/strategies/test_grid_strategy.py create mode 100644 tests/unit/strategies/test_macd_strategy.py create mode 100644 tests/unit/strategies/test_momentum_strategy.py create mode 100644 tests/unit/strategies/test_moving_avg_strategy.py create mode 100644 tests/unit/strategies/test_pairs_trading.py create mode 100644 tests/unit/strategies/test_rsi_strategy.py create mode 100644 tests/unit/strategies/test_trend_filter.py create mode 100644 tests/unit/test_autopilot_training.py create mode 100644 tests/unit/trading/__init__.py create mode 100644 tests/unit/trading/test_engine.py create mode 100644 tests/unit/trading/test_fee_calculator.py create mode 100644 tests/unit/trading/test_order_manager.py create mode 100644 tests/unit/trading/test_paper_trading.py create mode 100644 tests/unit/worker/__init__.py create mode 100644 tests/unit/worker/test_tasks.py create mode 100644 tests/utils/__init__.py create mode 100644 tests/utils/helpers.py create mode 100644 tests/utils/ui_helpers.py diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..50fde8dc --- /dev/null +++ b/.coveragerc @@ -0,0 +1,32 @@ +[run] +source = src +omit = + */tests/* + */__pycache__/* + */venv/* + */site-packages/* + */migrations/* + setup.py + +[report] +exclude_lines = + pragma: no cover + def __repr__ + raise AssertionError + raise NotImplementedError + if __name__ == .__main__.: + if TYPE_CHECKING: + @abstractmethod + @property + @staticmethod + @classmethod + pass + ... # ellipsis + +precision = 2 +show_missing = True +skip_covered = False + +[html] +directory = htmlcov + diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..fb33cb80 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,39 @@ +name: Documentation + +on: + push: + branches: [ main ] + paths: + - 'docs/**' + - 'src/**' + workflow_dispatch: + +jobs: + build-docs: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r docs/requirements.txt + + - name: Build documentation + run: | + cd docs/api + make html + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + if: github.ref == 'refs/heads/main' + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./docs/api/build/html + diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..46481893 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,41 @@ +name: Tests + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + python-version: ['3.11', '3.12', '3.13', '3.14'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r tests/requirements.txt + + - name: Run tests + run: | + pytest --cov=src --cov-report=xml --cov-report=term + + - name: Upload coverage + uses: codecov/codecov-action@v3 + with: + file: ./coverage.xml + flags: unittests + name: codecov-umbrella + diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..39296226 --- /dev/null +++ b/.gitignore @@ -0,0 +1,69 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual Environment +venv/ +env/ +ENV/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Environment +.env +.env.local + +# Database +*.db +*.sqlite +*.sqlite3 + +# Logs +*.log +logs/ + +# Data +data/historical/* +!data/historical/.gitkeep + +# AppImage +*.AppImage + +# OS +.DS_Store +Thumbs.db + +# Testing +.pytest_cache/ +.coverage +.coverage.* +coverage.xml +htmlcov/ + +# Packaging +packaging/AppDir/ +*.AppImage + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..5caa6409 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,51 @@ +# Multi-stage build for Crypto Trader +FROM node:18-alpine AS frontend-builder + +WORKDIR /app/frontend + +# Copy frontend files +COPY frontend/package*.json ./ +RUN npm ci + +COPY frontend/ ./ +RUN npm run build + +# Python backend +FROM python:3.11-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + g++ \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements and install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Install FastAPI and uvicorn +RUN pip install --no-cache-dir fastapi uvicorn[standard] python-multipart + +# Copy backend code +COPY backend/ ./backend/ +COPY src/ ./src/ +COPY config/ ./config/ + +# Copy built frontend from builder +COPY --from=frontend-builder /app/frontend/dist ./frontend/dist + +# Create data directory +RUN mkdir -p /app/data + +# Expose port +EXPOSE 8000 + +# Set environment variables +ENV PYTHONPATH=/app +ENV PYTHONUNBUFFERED=1 + +# Run the application +WORKDIR /app +CMD ["python", "-m", "uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 00000000..3c08008f --- /dev/null +++ b/README.md @@ -0,0 +1,344 @@ +# Cryptocurrency Trading Platform + +[![Python](https://img.shields.io/badge/Python-3.11+-blue.svg)](https://www.python.org/) +[![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) + +A comprehensive cryptocurrency trading platform with multi-exchange support, real-time trading, backtesting, advanced risk management, and portfolio analytics. Built with a modern web architecture (React frontend + FastAPI backend) while preserving the core Python trading engine. + +## Features + +- **Modern Web UI**: React + TypeScript + Material-UI with comprehensive feature coverage + - Strategy Management: Full CRUD operations with parameter configuration + - Manual Trading: Order placement, management, and position closing + - Dashboard: AutoPilot controls, system health monitoring, real-time updates + - Portfolio: Allocation charts, position management, performance analytics + - Backtesting: Historical strategy testing with progress tracking + - Settings: Exchange management, alerts, alert history, risk configuration +- **RESTful API**: FastAPI with auto-generated documentation +- **Real-Time Updates**: WebSocket integration for live order, position, and price updates +- **Intelligent Autopilot**: ML-based trading automation + - ML strategy selection with LightGBM/XGBoost ensemble models + - Configurable training: historical days, timeframe, symbols + - Background model training via Celery with real-time progress + - Auto-reload of trained models without restart + - Pre-flight order validation (prevents failed orders) + - Smart order types: LIMIT for better entries, MARKET for urgency + - Stop-loss vs take-profit detection for optimal exit strategy + - Multi-symbol support with independent autopilot instances +- **Multi-Tier Pricing Data**: Robust pricing data system with automatic failover + - Primary providers: CCXT-based (Kraken, Coinbase, Binance) with automatic failover + - Fallback provider: CoinGecko API (free tier, no API keys required) + - Smart caching with configurable TTL + - Health monitoring with circuit breaker pattern + - Works without exchange integrations for paper trading, ML, and backtesting +- **Multi-Exchange Support**: Trade on multiple exchanges (starting with Coinbase) +- **Paper Trading**: Test strategies with virtual funds ($100 default, configurable) + - Configurable fee exchange model (Coinbase, Kraken, Binance) + - Realistic fee simulation with maker/taker rates + - Immediate order execution (no pending orders) +- **Advanced Backtesting**: Realistic backtesting with slippage, fees, and order book simulation +- **Strategy Framework**: Multi-timeframe strategies with scheduling and optimization +- **Risk Management**: Stop-loss, position sizing (Kelly Criterion), drawdown limits, daily loss limits +- **Portfolio Analytics**: Advanced metrics (Sharpe ratio, Sortino ratio, drawdown analysis) +- **Alert System**: Price, indicator, risk, and system alerts with history tracking +- **Export & Reporting**: CSV, PDF, and tax reporting (FIFO/LIFO/specific identification) +- **Futures & Leverage**: Support for futures trading and leverage +- **Fully Local**: No telemetry, all data stored locally with encryption +- **Transparent Operations**: System health indicators, data freshness tracking, operation visibility +- **Background Task Processing**: Celery-powered ML training and report generation +- **Distributed State Management**: Redis-backed autopilot state persistence across restarts + +## Quick Start + +### Docker (Recommended) + +```bash +# Build and run +docker-compose up --build + +# Access application +open http://localhost:8000 +``` + +### Development + +**Prerequisites**: +- Python 3.11+ +- Node.js 18+ +- PostgreSQL 14+ +- Redis 5.0+ + +**Quick Setup**: +```bash +# Create virtual environment +python -m venv venv +source venv/bin/activate + +# Install dependencies +pip install -r requirements.txt + +# Install frontend dependencies +cd frontend && npm install && cd .. + +# Start all services (Redis, Celery, Backend, Frontend) +./scripts/start_all.sh +``` + +**Manual Setup**: +```bash +# 1. Start Redis (choose one option) +sudo service redis-server start # With sudo +# OR: redis-server --daemonize yes # Without sudo (for containers) + +# 2. Start Celery worker (background tasks) +celery -A src.worker.app worker --loglevel=info & + +# 3. Start backend API +uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000 & + +# 4. Start frontend +cd frontend && npm run dev +``` + +Access frontend at: http://localhost:3000 +API docs at: http://localhost:8000/docs + +**Verify Setup**: +```bash +python scripts/verify_redis.py +``` + +## Architecture + +``` +Frontend (React) → FastAPI → Python Services → Database +``` + +- **Frontend**: React + TypeScript + Material-UI +- **Backend**: FastAPI (Python) +- **Services**: Existing Python code (trading engine, strategies, etc.) +- **Database**: PostgreSQL + +## Project Structure + +``` +crypto_trader/ +├── backend/ # FastAPI application +│ ├── api/ # API endpoints +│ └── core/ # Dependencies, schemas +├── frontend/ # React application +│ └── src/ +│ ├── pages/ # Page components (Dashboard, Strategies, Trading, Portfolio, Backtesting, Settings) +│ ├── components/ # Reusable components (StatusIndicator, SystemHealth, etc.) +│ ├── api/ # API client functions +│ ├── hooks/ # Custom React hooks (useWebSocket, useRealtimeData) +│ ├── contexts/ # React contexts (SnackbarContext) +│ ├── types/ # TypeScript type definitions +│ └── utils/ # Utility functions +├── src/ # Core Python code +│ ├── trading/ # Trading engine +│ ├── strategies/ # Strategy framework +│ ├── portfolio/ # Portfolio tracker +│ ├── backtesting/ # Backtesting engine +│ └── ... +├── tests/ # Test suite +│ ├── unit/ # Unit tests +│ ├── integration/ # Integration tests (including frontend API workflows) +│ └── e2e/ # End-to-end tests +├── docs/ # Documentation +│ ├── user_manual/ # User documentation +│ ├── developer/ # Developer documentation +│ └── architecture/ # Architecture documentation +└── config/ # Configuration files +``` + +## API Endpoints + +- **Trading**: `/api/trading/*` - Orders, positions, balance +- **Portfolio**: `/api/portfolio/*` - Portfolio data and history +- **Strategies**: `/api/strategies/*` - Strategy management +- **Backtesting**: `/api/backtesting/*` - Run backtests +- **Exchanges**: `/api/exchanges/*` - Exchange management +- **Autopilot**: `/api/autopilot/*` - Intelligent autopilot +- **Market Data**: `/api/market-data/*` - Market data endpoints +- **WebSocket**: `/ws/` - Real-time updates + +Full API documentation: http://localhost:8000/docs + +## Configuration + +Configuration files are stored in `~/.config/crypto_trader/` following XDG Base Directory Specification. + +Data is stored in `~/.local/share/crypto_trader/`: +- `trading.db` - Legacy SQLite database (removed) +- `historical/` - Historical market data +- `logs/` - Application logs + +See [Configuration Guide](docs/user_manual/configuration.md) for details. + +## Environment Variables + +Create `.env` file (optional): + +```env +VITE_API_URL=http://localhost:8000 +VITE_WS_URL=ws://localhost:8000/ws/ +DATABASE_URL=postgresql+asyncpg://user:password@localhost/dbname +``` + +## Requirements + +- **Python**: 3.11 or higher +- **Node.js**: 18+ (for frontend) +- **PostgreSQL**: 14+ (required for database) +- **Redis**: 5.0+ (required for state management) +- **Celery**: Included in requirements.txt (for background tasks) +- **Docker**: Optional, for containerized deployment + +## Features in Detail + +### Trading + +- Market and limit orders +- Advanced order types (stop-loss, take-profit, trailing stop, OCO, iceberg) +- Real-time position tracking +- Paper trading simulator +- Futures and leverage trading + +### Strategies + +- Pre-built strategies: + - **Technical**: RSI, MACD, Moving Average, Bollinger Mean Reversion + - **Composite**: Confirmed Strategy, Divergence Strategy + - **Accumulation**: DCA (Dollar Cost Averaging), Grid Trading + - **Advanced**: Momentum, Consensus (multi-strategy voting) + - **Quantitative**: Statistical Arbitrage (Pairs Trading), Volatility Breakout + - **Alternative Data**: Sentiment/News Trading (with Fear & Greed Index) + - **Market Making**: Bid/Ask spread capture with inventory management +- Custom strategy development +- Multi-timeframe support +- Strategy scheduling with real-time status tracking +- Parameter optimization (grid search, genetic, Bayesian) +- **See [Pairs Trading Guide](docs/guides/pairs_trading_setup.md)** for advanced strategy configuration + +### Backtesting + +- Historical data replay +- Realistic simulation (slippage, fees) +- Performance metrics (Sharpe, Sortino, drawdown) +- Parameter optimization +- Export results + +### Risk Management + +- Position sizing (fixed, Kelly Criterion, volatility-based) +- Stop-loss orders +- Maximum drawdown limits +- Daily loss limits +- Portfolio allocation limits + +### Portfolio Management + +- Real-time P&L tracking +- Advanced analytics +- Portfolio rebalancing +- Performance charts +- Export and reporting + +### Pricing Data Providers + +- **Multi-Tier Architecture**: Primary CCXT providers with CoinGecko fallback +- **Automatic Failover**: Seamless switching between providers on failure +- **Health Monitoring**: Real-time provider status with circuit breakers +- **Smart Caching**: Configurable TTL-based caching for performance +- **No API Keys Required**: Works with public data APIs for paper trading +- **WebSocket Support**: Real-time price updates via WebSocket +- **Provider Configuration**: Full UI for managing provider priorities and settings + +## Documentation + +- **[User Manual](docs/user_manual/README.md)** - Complete user guide +- **[Developer Guide](docs/developer/README.md)** - Development documentation +- **[API Documentation](http://localhost:8000/docs)** - Interactive API docs +- **[Architecture Docs](docs/architecture/overview.md)** - System architecture +- **[Deployment Guide](docs/deployment/README.md)** - Deployment instructions + +### Quick Links + +- [Getting Started](docs/user_manual/getting_started.md) +- [Trading Guide](docs/user_manual/trading.md) +- [Strategy Development](docs/user_manual/strategies.md) +- [Pairs Trading Guide](docs/guides/pairs_trading_setup.md) +- [Backtesting Guide](docs/user_manual/backtesting.md) +- [Configuration](docs/user_manual/configuration.md) +- [Troubleshooting](docs/user_manual/troubleshooting.md) +- [FAQ](docs/user_manual/faq.md) + +## Testing + +Run the test suite: + +```bash +# Install test dependencies +pip install -r tests/requirements.txt + +# Run all tests +pytest + +# Run with coverage +pytest --cov=src --cov-report=html + +# Run specific test category +pytest -m unit +pytest -m integration +pytest -m e2e +``` + +## Docker Deployment + +```bash +# Build +docker build -t crypto-trader:latest . + +# Run +docker run -d \ + -p 8000:8000 \ + -v $(pwd)/data:/app/data \ + -v $(pwd)/config:/app/config \ + crypto-trader:latest +``` + +## Contributing + +We welcome contributions! See [Contributing Guidelines](docs/developer/contributing.md) for details. + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Write/update tests +5. Submit a pull request + +## Development + +See [Developer Guide](docs/developer/README.md) for: + +- Development environment setup +- Coding standards +- Testing guidelines +- Architecture overview +- Adding new features + +## License + +MIT License + +## Support + +- **Documentation**: See [docs/](docs/) directory +- **API Docs**: http://localhost:8000/docs (when running) +- **Issues**: Report bugs and request features via GitHub Issues +- **FAQ**: Check [FAQ](docs/user_manual/faq.md) for common questions + +## Migration from PyQt6 + +The application has been migrated from PyQt6 desktop app to web architecture while preserving 90% of existing Python code. See [Migration Guide](docs/migration_guide.md) for details. diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 00000000..4e89a638 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,47 @@ +# Crypto Trader Backend API + +FastAPI backend for the Crypto Trader application. + +## Setup + +```bash +pip install -r requirements.txt +pip install -r backend/requirements.txt +``` + +## Development + +```bash +python -m uvicorn backend.main:app --reload --port 8000 +``` + +Access API docs at: http://localhost:8000/docs + +## API Endpoints + +- **Trading**: `/api/trading/*` +- **Portfolio**: `/api/portfolio/*` +- **Strategies**: `/api/strategies/*` +- **Backtesting**: `/api/backtesting/*` +- **Exchanges**: `/api/exchanges/*` +- **WebSocket**: `/ws/` + +## Project Structure + +``` +backend/ +├── api/ # API route handlers +├── core/ # Core utilities (dependencies, schemas) +└── main.py # FastAPI application +``` + +## Dependencies + +The backend uses existing Python code from `src/`: +- Trading engine +- Strategy framework +- Portfolio tracker +- Backtesting engine +- All other services + +These are imported via `sys.path` modification in `main.py`. diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 00000000..747ed4ee --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1 @@ +"""Backend API package.""" diff --git a/backend/api/__init__.py b/backend/api/__init__.py new file mode 100644 index 00000000..010fe3a2 --- /dev/null +++ b/backend/api/__init__.py @@ -0,0 +1 @@ +"""API routers package.""" diff --git a/backend/api/alerts.py b/backend/api/alerts.py new file mode 100644 index 00000000..87fcff50 --- /dev/null +++ b/backend/api/alerts.py @@ -0,0 +1,144 @@ +"""Alerts API endpoints.""" + +from typing import List, Optional +from fastapi import APIRouter, HTTPException, Depends +from pydantic import BaseModel +from datetime import datetime +from sqlalchemy import select + +from src.core.database import Alert, get_database + +router = APIRouter() + + +def get_alert_manager(): + """Get alert manager instance.""" + from src.alerts.manager import get_alert_manager as _get_alert_manager + return _get_alert_manager() + + +class AlertCreate(BaseModel): + """Create alert request.""" + name: str + alert_type: str # price, indicator, risk, system + condition: dict + + +class AlertUpdate(BaseModel): + """Update alert request.""" + name: Optional[str] = None + condition: Optional[dict] = None + enabled: Optional[bool] = None + + +class AlertResponse(BaseModel): + """Alert response.""" + id: int + name: str + alert_type: str + condition: dict + enabled: bool + triggered: bool + triggered_at: Optional[datetime] = None + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +@router.get("/", response_model=List[AlertResponse]) +async def list_alerts( + enabled_only: bool = False, + manager=Depends(get_alert_manager) +): + """List all alerts.""" + try: + alerts = await manager.list_alerts(enabled_only=enabled_only) + return [AlertResponse.model_validate(alert) for alert in alerts] + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/", response_model=AlertResponse) +async def create_alert( + alert_data: AlertCreate, + manager=Depends(get_alert_manager) +): + """Create a new alert.""" + try: + alert = await manager.create_alert( + name=alert_data.name, + alert_type=alert_data.alert_type, + condition=alert_data.condition + ) + return AlertResponse.model_validate(alert) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/{alert_id}", response_model=AlertResponse) +async def get_alert(alert_id: int): + """Get alert by ID.""" + try: + db = get_database() + async with db.get_session() as session: + stmt = select(Alert).where(Alert.id == alert_id) + result = await session.execute(stmt) + alert = result.scalar_one_or_none() + if not alert: + raise HTTPException(status_code=404, detail="Alert not found") + return AlertResponse.model_validate(alert) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.put("/{alert_id}", response_model=AlertResponse) +async def update_alert(alert_id: int, alert_data: AlertUpdate): + """Update an alert.""" + try: + db = get_database() + async with db.get_session() as session: + stmt = select(Alert).where(Alert.id == alert_id) + result = await session.execute(stmt) + alert = result.scalar_one_or_none() + if not alert: + raise HTTPException(status_code=404, detail="Alert not found") + + if alert_data.name is not None: + alert.name = alert_data.name + if alert_data.condition is not None: + alert.condition = alert_data.condition + if alert_data.enabled is not None: + alert.enabled = alert_data.enabled + + await session.commit() + await session.refresh(alert) + return AlertResponse.model_validate(alert) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/{alert_id}") +async def delete_alert(alert_id: int): + """Delete an alert.""" + try: + db = get_database() + async with db.get_session() as session: + stmt = select(Alert).where(Alert.id == alert_id) + result = await session.execute(stmt) + alert = result.scalar_one_or_none() + if not alert: + raise HTTPException(status_code=404, detail="Alert not found") + + await session.delete(alert) + await session.commit() + return {"status": "deleted", "alert_id": alert_id} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/api/autopilot.py b/backend/api/autopilot.py new file mode 100644 index 00000000..175345ee --- /dev/null +++ b/backend/api/autopilot.py @@ -0,0 +1,564 @@ +"""AutoPilot API endpoints.""" + +from typing import Dict, Any, Optional, List +from fastapi import APIRouter, HTTPException, BackgroundTasks +from pydantic import BaseModel + +from ..core.dependencies import get_database +from ..core.schemas import OrderSide +from src.core.database import get_database as get_db +# Import autopilot - path should be set up in main.py +from src.autopilot import ( + stop_all_autopilots, + get_intelligent_autopilot, + get_strategy_selector, + get_performance_tracker, + get_performance_tracker, + get_autopilot_mode_info, +) +from src.worker.tasks import train_model_task +from src.core.config import get_config +from celery.result import AsyncResult + +router = APIRouter() + + + + + +class BootstrapConfig(BaseModel): + """Bootstrap training data configuration.""" + days: int = 90 + timeframe: str = "1h" + min_samples_per_strategy: int = 10 + symbols: List[str] = ["BTC/USD", "ETH/USD"] + + +class MultiSymbolAutopilotConfig(BaseModel): + """Multi-symbol autopilot configuration.""" + symbols: List[str] + mode: str = "intelligent" + auto_execute: bool = False + timeframe: str = "1h" + exchange_id: int = 1 + paper_trading: bool = True + interval: float = 60.0 + + + + + + +# ============================================================================= +# Intelligent Autopilot Endpoints +# ============================================================================= + +class IntelligentAutopilotConfig(BaseModel): + symbol: str + exchange_id: int = 1 + timeframe: str = "1h" + interval: float = 60.0 + paper_trading: bool = True + + +# ============================================================================= +# Unified Autopilot Endpoints +# ============================================================================= + +class UnifiedAutopilotConfig(BaseModel): + """Unified autopilot configuration (Inteligent Mode).""" + symbol: str + mode: str = "intelligent" # Kept for compatibility but only "intelligent" is supported + auto_execute: bool = False + interval: float = 60.0 + exchange_id: int = 1 + timeframe: str = "1h" + paper_trading: bool = True + + +@router.post("/intelligent/start", deprecated=True) +async def start_intelligent_autopilot( + config: IntelligentAutopilotConfig, + background_tasks: BackgroundTasks +): + """Start the Intelligent Autopilot engine. + + .. deprecated:: Use /start-unified instead with mode='intelligent' + """ + try: + autopilot = get_intelligent_autopilot( + symbol=config.symbol, + exchange_id=config.exchange_id, + timeframe=config.timeframe, + interval=config.interval, + paper_trading=config.paper_trading + ) + + if not autopilot.is_running: + background_tasks.add_task(autopilot.start) + + return { + "status": "started", + "symbol": config.symbol, + "timeframe": config.timeframe + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/intelligent/stop") +async def stop_intelligent_autopilot(symbol: str, timeframe: str = "1h"): + """Stop the Intelligent Autopilot engine.""" + try: + autopilot = get_intelligent_autopilot(symbol=symbol, timeframe=timeframe) + autopilot.stop() + return {"status": "stopped", "symbol": symbol} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/intelligent/status/{symbol:path}") +async def get_intelligent_status(symbol: str, timeframe: str = "1h"): + """Get Intelligent Autopilot status.""" + try: + autopilot = get_intelligent_autopilot(symbol=symbol, timeframe=timeframe) + return autopilot.get_status() + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/intelligent/performance") +async def get_intelligent_performance( + strategy_name: Optional[str] = None, + days: int = 30 +): + """Get strategy performance metrics.""" + try: + tracker = get_performance_tracker() + if strategy_name: + metrics = tracker.calculate_metrics(strategy_name, period_days=days) + return {"strategy": strategy_name, "metrics": metrics} + else: + # Get all strategies + history = tracker.get_performance_history(days=days) + if history.empty: + return {"strategies": []} + + strategies = history['strategy_name'].unique() + results = {} + for strat in strategies: + results[strat] = tracker.calculate_metrics(strat, period_days=days) + + return {"strategies": results} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/intelligent/training-stats") +async def get_training_stats(days: int = 365): + """Get statistics about available training data. + + Returns: + Dictionary with total samples and per-strategy counts + """ + try: + tracker = get_performance_tracker() + counts = await tracker.get_strategy_sample_counts(days=days) + + return { + "total_samples": sum(counts.values()), + "strategy_counts": counts + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/intelligent/retrain") +async def retrain_model(force: bool = False, bootstrap: bool = True): + """Manually trigger model retraining (Background Task). + + Offloads training to Celery worker. + """ + try: + # Get all bootstrap config to pass to worker + config = get_config() + symbols = config.get("autopilot.intelligent.bootstrap.symbols", ["BTC/USD", "ETH/USD"]) + days = config.get("autopilot.intelligent.bootstrap.days", 90) + timeframe = config.get("autopilot.intelligent.bootstrap.timeframe", "1h") + min_samples = config.get("autopilot.intelligent.bootstrap.min_samples_per_strategy", 10) + + # Submit to Celery with all configured parameters + task = train_model_task.delay( + force_retrain=force, + bootstrap=bootstrap, + symbols=symbols, + days=days, + timeframe=timeframe, + min_samples_per_strategy=min_samples + ) + + return { + "status": "queued", + "message": "Model retraining started in background", + "task_id": task.id + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/intelligent/model-info") +async def get_model_info(): + """Get ML model information.""" + try: + selector = get_strategy_selector() + return selector.get_model_info() + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/intelligent/reset") +async def reset_model(): + """Reset/delete all saved ML models and training data. + + This clears all persisted model files AND training data from database, + allowing for a fresh start with new features. + """ + try: + from pathlib import Path + from src.core.database import get_database, MarketConditionsSnapshot, StrategyPerformance + from sqlalchemy import delete + + # Get model directory + model_dir = Path.home() / ".local" / "share" / "crypto_trader" / "models" + + deleted_count = 0 + if model_dir.exists(): + # Delete all strategy selector model files + for model_file in model_dir.glob("strategy_selector_*.joblib"): + model_file.unlink() + deleted_count += 1 + + # Clear training data from database + db = get_database() + db_cleared = 0 + try: + async with db.get_session() as session: + # Delete all market conditions snapshots + result1 = await session.execute(delete(MarketConditionsSnapshot)) + # Delete all strategy performance records + result2 = await session.execute(delete(StrategyPerformance)) + await session.commit() + db_cleared = result1.rowcount + result2.rowcount + except Exception as e: + # Database clearing is optional - continue even if it fails + pass + + # Reset the in-memory model state + selector = get_strategy_selector() + from src.autopilot.models import StrategySelectorModel + selector.model = StrategySelectorModel(model_type="classifier") + + return { + "status": "success", + "message": f"Deleted {deleted_count} model file(s) and {db_cleared} training records. Model reset to untrained state.", + "deleted_count": deleted_count, + "db_records_cleared": db_cleared + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ============================================================================= +# Multi-Symbol Autopilot Endpoints +# ============================================================================= + +@router.post("/multi-symbol/start") +async def start_multi_symbol_autopilot( + config: MultiSymbolAutopilotConfig, + background_tasks: BackgroundTasks +): + """Start autopilot for multiple symbols simultaneously. + + Args: + config: Multi-symbol autopilot configuration + background_tasks: FastAPI background tasks + """ + try: + results = [] + for symbol in config.symbols: + # Always use intelligent mode + autopilot = get_intelligent_autopilot( + symbol=symbol, + exchange_id=config.exchange_id, + timeframe=config.timeframe, + interval=config.interval, + paper_trading=config.paper_trading + ) + + autopilot.enable_auto_execution = config.auto_execute + + if not autopilot.is_running: + # Set running flag synchronously before scheduling background task + autopilot._running = True + background_tasks.add_task(autopilot.start) + results.append({"symbol": symbol, "status": "started"}) + else: + results.append({"symbol": symbol, "status": "already_running"}) + + return { + "status": "success", + "mode": "intelligent", + "symbols": results + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/multi-symbol/stop") +async def stop_multi_symbol_autopilot( + symbols: List[str], + mode: str = "intelligent", + timeframe: str = "1h" +): + """Stop autopilot for multiple symbols. + + Args: + symbols: List of symbols to stop + mode: Autopilot mode (pattern or intelligent) + timeframe: Timeframe for intelligent mode + """ + try: + results = [] + for symbol in symbols: + # Always use intelligent mode + autopilot = get_intelligent_autopilot(symbol=symbol, timeframe=timeframe) + autopilot.stop() + results.append({"symbol": symbol, "status": "stopped"}) + + return { + "status": "success", + "symbols": results + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/multi-symbol/status") +async def get_multi_symbol_status( + symbols: str = "", # Comma-separated list + mode: str = "intelligent", + timeframe: str = "1h" +): + """Get status for multiple symbols. + + Args: + symbols: Comma-separated list of symbols (empty = all running) + mode: Autopilot mode + timeframe: Timeframe for intelligent mode + """ + from src.autopilot.intelligent_autopilot import _intelligent_autopilots + + try: + results = [] + + if symbols: + symbol_list = [s.strip() for s in symbols.split(",")] + else: + # Get all running autopilots (intelligent only) + symbol_list = [key.split(":")[0] for key in _intelligent_autopilots.keys()] + + for symbol in symbol_list: + try: + autopilot = get_intelligent_autopilot(symbol=symbol, timeframe=timeframe) + status = autopilot.get_status() + status["symbol"] = symbol + status["mode"] = "intelligent" + results.append(status) + except Exception: + results.append({ + "symbol": symbol, + "mode": "intelligent", + "running": False, + "error": "Not found" + }) + + return { + "mode": mode, + "symbols": results, + "total_running": sum(1 for r in results if r.get("running", False)) + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ============================================================================= +# Unified Autopilot Endpoints (New) +# ============================================================================= + +@router.get("/modes") +async def get_autopilot_modes(): + """Get information about available autopilot modes. + + Returns mode descriptions, capabilities, tradeoffs, and comparison data. + """ + try: + return get_autopilot_mode_info() + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/start-unified") +async def start_unified_autopilot( + config: UnifiedAutopilotConfig, + background_tasks: BackgroundTasks +): + """Start autopilot with unified interface (Intelligent Mode only).""" + try: + # Validate mode (for backward compatibility of API clients sending mode) + if config.mode and config.mode != "intelligent": + # We allow it but will treat it as intelligent if possible, or raise error if critical + pass + + # Start ML-based autopilot + autopilot = get_intelligent_autopilot( + symbol=config.symbol, + exchange_id=config.exchange_id, + timeframe=config.timeframe, + interval=config.interval, + paper_trading=config.paper_trading + ) + + # Set auto-execution if enabled + if config.auto_execute: + autopilot.enable_auto_execution = True + + if not autopilot.is_running: + # Schedule background task (state management handled by autopilot.start via Redis) + background_tasks.add_task(autopilot.start) + + return { + "status": "started", + "mode": "intelligent", + "symbol": config.symbol, + "timeframe": config.timeframe, + "auto_execute": config.auto_execute + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/stop-unified") +async def stop_unified_autopilot(symbol: str, mode: str, timeframe: str = "1h"): + """Stop autopilot for a symbol.""" + try: + autopilot = get_intelligent_autopilot(symbol=symbol, timeframe=timeframe) + autopilot.stop() + + return {"status": "stopped", "symbol": symbol, "mode": "intelligent"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/status-unified/{symbol:path}") +async def get_unified_status(symbol: str, mode: str, timeframe: str = "1h"): + """Get autopilot status for a symbol.""" + try: + autopilot = get_intelligent_autopilot(symbol=symbol, timeframe=timeframe) + # Use distributed status check (Redis) + status = await autopilot.get_distributed_status() + status["mode"] = "intelligent" + return status + except Exception as e: + logger.error(f"Error getting unified status: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/bootstrap-config", response_model=BootstrapConfig) +async def get_bootstrap_config(): + """Get bootstrap training data configuration.""" + from src.core.config import get_config + config = get_config() + + return BootstrapConfig( + days=config.get("autopilot.intelligent.bootstrap.days", 90), + timeframe=config.get("autopilot.intelligent.bootstrap.timeframe", "1h"), + min_samples_per_strategy=config.get("autopilot.intelligent.bootstrap.min_samples_per_strategy", 10), + symbols=config.get("autopilot.intelligent.bootstrap.symbols", ["BTC/USD", "ETH/USD"]), + ) + + +@router.put("/bootstrap-config") +async def update_bootstrap_config(settings: BootstrapConfig): + """Update bootstrap training data configuration.""" + from src.core.config import get_config + config = get_config() + + try: + config.set("autopilot.intelligent.bootstrap.days", settings.days) + config.set("autopilot.intelligent.bootstrap.timeframe", settings.timeframe) + config.set("autopilot.intelligent.bootstrap.min_samples_per_strategy", settings.min_samples_per_strategy) + config.set("autopilot.intelligent.bootstrap.symbols", settings.symbols) + + # Also update the strategy selector instance if it exists + selector = get_strategy_selector() + selector.bootstrap_days = settings.days + selector.bootstrap_timeframe = settings.timeframe + selector.min_samples_per_strategy = settings.min_samples_per_strategy + selector.bootstrap_symbols = settings.symbols + + return {"status": "success", "message": "Bootstrap configuration updated"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/tasks/{task_id}") +async def get_task_status(task_id: str): + """Get status of a background task.""" + try: + task_result = AsyncResult(task_id) + + try: + # Accessing status or result might raise an exception if deserialization fails + status = task_result.status + result_data = task_result.result if task_result.ready() else None + meta_data = task_result.info if status == 'PROGRESS' else None + + # serialized exception handling + if isinstance(result_data, Exception): + result_data = { + "error": str(result_data), + "type": type(result_data).__name__, + "detail": str(result_data) + } + elif status == "FAILURE" and (not result_data or result_data == {}): + # If failure but empty result, try to get traceback or use a default message + tb = getattr(task_result, 'traceback', None) + if tb: + result_data = {"error": "Task failed", "detail": str(tb)} + else: + result_data = {"error": "Task failed with no error info", "detail": "Check worker logs for details"} + + result = { + "task_id": task_id, + "status": status, + "result": result_data + } + + if meta_data: + result["meta"] = meta_data + + return result + + except Exception as inner_e: + # If Celery fails to get status/result (e.g. serialization error), return FAILURE + # This prevents 500 errors in the API when the task itself failed badly + return { + "task_id": task_id, + "status": "FAILURE", + "result": {"error": str(inner_e), "detail": "Failed to retrieve task status"}, + "meta": {"error": "Task retrieval failed"} + } + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + diff --git a/backend/api/backtesting.py b/backend/api/backtesting.py new file mode 100644 index 00000000..ee545523 --- /dev/null +++ b/backend/api/backtesting.py @@ -0,0 +1,78 @@ +"""Backtesting API endpoints.""" + +from fastapi import APIRouter, HTTPException, Depends, BackgroundTasks +from typing import Dict, Any +from sqlalchemy import select +import uuid + +from ..core.dependencies import get_backtesting_engine, get_strategy_registry +from ..core.schemas import BacktestRequest, BacktestResponse +from src.core.database import Strategy, get_database + +router = APIRouter() + +# Store running backtests +_backtests: Dict[str, Dict[str, Any]] = {} + + +@router.post("/run", response_model=BacktestResponse) +async def run_backtest( + backtest_data: BacktestRequest, + background_tasks: BackgroundTasks, + backtest_engine=Depends(get_backtesting_engine) +): + """Run a backtest.""" + try: + db = get_database() + async with db.get_session() as session: + # Get strategy + stmt = select(Strategy).where(Strategy.id == backtest_data.strategy_id) + result = await session.execute(stmt) + strategy_db = result.scalar_one_or_none() + if not strategy_db: + raise HTTPException(status_code=404, detail="Strategy not found") + + # Create strategy instance + registry = get_strategy_registry() + strategy_instance = registry.create_instance( + strategy_id=strategy_db.id, + name=strategy_db.class_name, + parameters=strategy_db.parameters, + timeframes=strategy_db.timeframes or [backtest_data.timeframe] + ) + + if not strategy_instance: + raise HTTPException(status_code=400, detail="Failed to create strategy instance") + + # Run backtest + results = backtest_engine.run_backtest( + strategy=strategy_instance, + symbol=backtest_data.symbol, + exchange=backtest_data.exchange, + timeframe=backtest_data.timeframe, + start_date=backtest_data.start_date, + end_date=backtest_data.end_date, + initial_capital=backtest_data.initial_capital, + slippage=backtest_data.slippage, + fee_rate=backtest_data.fee_rate + ) + + if "error" in results: + raise HTTPException(status_code=400, detail=results["error"]) + + return BacktestResponse( + results=results, + status="completed" + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/results/{backtest_id}") +async def get_backtest_results(backtest_id: str): + """Get backtest results by ID.""" + if backtest_id not in _backtests: + raise HTTPException(status_code=404, detail="Backtest not found") + return _backtests[backtest_id] diff --git a/backend/api/exchanges.py b/backend/api/exchanges.py new file mode 100644 index 00000000..1a451d0a --- /dev/null +++ b/backend/api/exchanges.py @@ -0,0 +1,42 @@ +"""Exchange API endpoints.""" + +from typing import List +from fastapi import APIRouter, HTTPException +from sqlalchemy import select + +from ..core.schemas import ExchangeResponse +from src.core.database import Exchange, get_database + +router = APIRouter() + + +@router.get("/", response_model=List[ExchangeResponse]) +async def list_exchanges(): + """List all exchanges.""" + try: + db = get_database() + async with db.get_session() as session: + stmt = select(Exchange).order_by(Exchange.name) + result = await session.execute(stmt) + exchanges = result.scalars().all() + return [ExchangeResponse.model_validate(e) for e in exchanges] + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/{exchange_id}", response_model=ExchangeResponse) +async def get_exchange(exchange_id: int): + """Get exchange by ID.""" + try: + db = get_database() + async with db.get_session() as session: + stmt = select(Exchange).where(Exchange.id == exchange_id) + result = await session.execute(stmt) + exchange = result.scalar_one_or_none() + if not exchange: + raise HTTPException(status_code=404, detail="Exchange not found") + return ExchangeResponse.model_validate(exchange) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/api/market_data.py b/backend/api/market_data.py new file mode 100644 index 00000000..e34633c8 --- /dev/null +++ b/backend/api/market_data.py @@ -0,0 +1,280 @@ +"""Market Data API endpoints.""" + +from typing import List, Optional, Dict, Any +from datetime import datetime, timedelta +from fastapi import APIRouter, HTTPException, Query, Body +from pydantic import BaseModel +import pandas as pd + +from src.core.database import MarketData, get_database +from src.data.pricing_service import get_pricing_service +from src.core.config import get_config + +router = APIRouter() + + +@router.get("/ohlcv/{symbol:path}") +async def get_ohlcv( + symbol: str, + timeframe: str = "1h", + limit: int = 100, + exchange: str = "coinbase" # Default exchange +): + """Get OHLCV data for a symbol.""" + from sqlalchemy import select + try: + # Try database first + try: + db = get_database() + async with db.get_session() as session: + # Use select() for async compatibility + stmt = select(MarketData).filter_by( + symbol=symbol, + timeframe=timeframe, + exchange=exchange + ).order_by(MarketData.timestamp.desc()).limit(limit) + + result = await session.execute(stmt) + data = result.scalars().all() + + if data: + return [ + { + "time": int(d.timestamp.timestamp()), + "open": float(d.open), + "high": float(d.high), + "low": float(d.low), + "close": float(d.close), + "volume": float(d.volume) + } + for d in reversed(data) + ] + except Exception as db_error: + import sys + print(f"Database query failed, falling back to live data: {db_error}", file=sys.stderr) + + # If no data in DB or DB error, fetch live from pricing service + try: + pricing_service = get_pricing_service() + # pricing_service.get_ohlcv is currently sync in its implementation but we call it from our async handler + ohlcv_data = pricing_service.get_ohlcv( + symbol=symbol, + timeframe=timeframe, + limit=limit + ) + + if ohlcv_data: + # Convert to frontend format: [timestamp, open, high, low, close, volume] -> {time, open, high, low, close, volume} + return [ + { + "time": int(candle[0] / 1000), # Convert ms to seconds + "open": float(candle[1]), + "high": float(candle[2]), + "low": float(candle[3]), + "close": float(candle[4]), + "volume": float(candle[5]) + } + for candle in ohlcv_data + ] + except Exception as fetch_error: + import sys + print(f"Failed to fetch live data: {fetch_error}", file=sys.stderr) + + # If all else fails, return empty list + return [] + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/ticker/{symbol:path}") +async def get_ticker(symbol: str): + """Get current ticker data for a symbol. + + Returns ticker data with provider information. + """ + try: + pricing_service = get_pricing_service() + ticker_data = pricing_service.get_ticker(symbol) + + if not ticker_data: + raise HTTPException(status_code=404, detail=f"Ticker data not available for {symbol}") + + active_provider = pricing_service.get_active_provider() + + return { + "symbol": symbol, + "bid": float(ticker_data.get('bid', 0)), + "ask": float(ticker_data.get('ask', 0)), + "last": float(ticker_data.get('last', 0)), + "high": float(ticker_data.get('high', 0)), + "low": float(ticker_data.get('low', 0)), + "volume": float(ticker_data.get('volume', 0)), + "timestamp": ticker_data.get('timestamp'), + "provider": active_provider, + } + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/providers/health") +async def get_provider_health(provider: Optional[str] = Query(None, description="Specific provider name")): + """Get health status for pricing providers. + + Args: + provider: Optional provider name to get health for specific provider + """ + try: + pricing_service = get_pricing_service() + health_data = pricing_service.get_provider_health(provider) + + return { + "active_provider": pricing_service.get_active_provider(), + "health": health_data, + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/providers/status") +async def get_provider_status(): + """Get detailed status for all pricing providers.""" + try: + pricing_service = get_pricing_service() + health_data = pricing_service.get_provider_health() + cache_stats = pricing_service.get_cache_stats() + + return { + "active_provider": pricing_service.get_active_provider(), + "providers": health_data, + "cache": cache_stats, + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/providers/config") +async def get_provider_config(): + """Get provider configuration.""" + try: + config = get_config() + provider_config = config.get("data_providers", {}) + return provider_config + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +class ProviderConfigUpdate(BaseModel): + """Provider configuration update model.""" + primary: Optional[List[Dict[str, Any]]] = None + fallback: Optional[Dict[str, Any]] = None + caching: Optional[Dict[str, Any]] = None + websocket: Optional[Dict[str, Any]] = None + + +@router.put("/providers/config") +async def update_provider_config(config_update: ProviderConfigUpdate = Body(...)): + """Update provider configuration.""" + try: + config = get_config() + current_config = config.get("data_providers", {}) + + # Update configuration + if config_update.primary is not None: + current_config["primary"] = config_update.primary + if config_update.fallback is not None: + current_config["fallback"] = {**current_config.get("fallback", {}), **config_update.fallback} + if config_update.caching is not None: + current_config["caching"] = {**current_config.get("caching", {}), **config_update.caching} + if config_update.websocket is not None: + current_config["websocket"] = {**current_config.get("websocket", {}), **config_update.websocket} + + # Save configuration + config.set("data_providers", current_config) + config.save() + + return {"message": "Configuration updated successfully", "config": current_config} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/spread") +async def get_spread_data( + primary_symbol: str = Query(..., description="Primary symbol (e.g., SOL/USD)"), + secondary_symbol: str = Query(..., description="Secondary symbol (e.g., AVAX/USD)"), + timeframe: str = Query("1h", description="Timeframe"), + lookback: int = Query(50, description="Number of candles to fetch"), +): + """Get spread and Z-Score data for pairs trading visualization. + + Returns spread ratio and Z-Score time series for the given symbol pair. + """ + try: + pricing_service = get_pricing_service() + + # Fetch OHLCV for both symbols + ohlcv_a = pricing_service.get_ohlcv( + symbol=primary_symbol, + timeframe=timeframe, + limit=lookback + ) + + ohlcv_b = pricing_service.get_ohlcv( + symbol=secondary_symbol, + timeframe=timeframe, + limit=lookback + ) + + if not ohlcv_a or not ohlcv_b: + raise HTTPException(status_code=404, detail="Could not fetch data for one or both symbols") + + # Convert to DataFrames + df_a = pd.DataFrame(ohlcv_a, columns=['timestamp', 'open', 'high', 'low', 'close', 'volume']) + df_b = pd.DataFrame(ohlcv_b, columns=['timestamp', 'open', 'high', 'low', 'close', 'volume']) + + # Align by length + min_len = min(len(df_a), len(df_b)) + df_a = df_a.tail(min_len).reset_index(drop=True) + df_b = df_b.tail(min_len).reset_index(drop=True) + + # Calculate spread (ratio) + closes_a = df_a['close'].astype(float) + closes_b = df_b['close'].astype(float) + spread = closes_a / closes_b + + # Calculate Z-Score with rolling window + lookback_window = min(20, min_len - 1) + rolling_mean = spread.rolling(window=lookback_window).mean() + rolling_std = spread.rolling(window=lookback_window).std() + z_score = (spread - rolling_mean) / rolling_std + + # Build response + result = [] + for i in range(min_len): + result.append({ + "timestamp": int(df_a['timestamp'].iloc[i]), + "spread": float(spread.iloc[i]) if not pd.isna(spread.iloc[i]) else None, + "zScore": float(z_score.iloc[i]) if not pd.isna(z_score.iloc[i]) else None, + "priceA": float(closes_a.iloc[i]), + "priceB": float(closes_b.iloc[i]), + }) + + # Filter out entries with null Z-Score (during warmup period) + result = [r for r in result if r["zScore"] is not None] + + return { + "primarySymbol": primary_symbol, + "secondarySymbol": secondary_symbol, + "timeframe": timeframe, + "lookbackWindow": lookback_window, + "data": result, + "currentSpread": result[-1]["spread"] if result else None, + "currentZScore": result[-1]["zScore"] if result else None, + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + diff --git a/backend/api/portfolio.py b/backend/api/portfolio.py new file mode 100644 index 00000000..1a49c41f --- /dev/null +++ b/backend/api/portfolio.py @@ -0,0 +1,84 @@ +"""Portfolio API endpoints.""" + +from typing import Optional +from fastapi import APIRouter, HTTPException, Depends, Query +from datetime import datetime, timedelta + +from ..core.dependencies import get_portfolio_tracker +from ..core.schemas import PortfolioResponse, PortfolioHistoryResponse + +router = APIRouter() + +# Import portfolio analytics +def get_portfolio_analytics(): + """Get portfolio analytics instance.""" + from src.portfolio.analytics import get_portfolio_analytics as _get_analytics + return _get_analytics() + + +@router.get("/current", response_model=PortfolioResponse) +async def get_current_portfolio( + paper_trading: bool = True, + tracker=Depends(get_portfolio_tracker) +): + """Get current portfolio state.""" + try: + portfolio = await tracker.get_current_portfolio(paper_trading=paper_trading) + return PortfolioResponse(**portfolio) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/history", response_model=PortfolioHistoryResponse) +async def get_portfolio_history( + days: int = Query(30, ge=1, le=365), + paper_trading: bool = True, + tracker=Depends(get_portfolio_tracker) +): + """Get portfolio history.""" + try: + history = await tracker.get_portfolio_history(days=days, paper_trading=paper_trading) + + dates = [h['timestamp'] if isinstance(h['timestamp'], str) else h['timestamp'].isoformat() + for h in history] + values = [float(h['total_value']) for h in history] + pnl = [float(h.get('total_pnl', 0)) for h in history] + + return PortfolioHistoryResponse( + dates=dates, + values=values, + pnl=pnl + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/positions/update-prices") +async def update_positions_prices( + prices: dict, + paper_trading: bool = True, + tracker=Depends(get_portfolio_tracker) +): + """Update current prices for positions.""" + try: + from decimal import Decimal + price_dict = {k: Decimal(str(v)) for k, v in prices.items()} + await tracker.update_positions_prices(price_dict, paper_trading=paper_trading) + return {"status": "updated"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/risk-metrics") +async def get_risk_metrics( + days: int = Query(30, ge=1, le=365), + paper_trading: bool = True, + analytics=Depends(get_portfolio_analytics) +): + """Get portfolio risk metrics.""" + try: + metrics = await analytics.get_performance_metrics(days=days, paper_trading=paper_trading) + return metrics + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + diff --git a/backend/api/reporting.py b/backend/api/reporting.py new file mode 100644 index 00000000..09cd39a8 --- /dev/null +++ b/backend/api/reporting.py @@ -0,0 +1,272 @@ +"""Reporting API endpoints for CSV and PDF export.""" + +from fastapi import APIRouter, HTTPException, Query, Body +from fastapi.responses import StreamingResponse +from typing import Optional, Dict, Any +from datetime import datetime +from sqlalchemy import select +import io +import csv +import tempfile +from pathlib import Path + +from src.core.database import Trade, get_database + +router = APIRouter() + + +def get_csv_exporter(): + """Get CSV exporter instance.""" + from src.reporting.csv_exporter import get_csv_exporter as _get_csv_exporter + return _get_csv_exporter() + + +def get_pdf_generator(): + """Get PDF generator instance.""" + from src.reporting.pdf_generator import get_pdf_generator as _get_pdf_generator + return _get_pdf_generator() + + +@router.post("/backtest/csv") +async def export_backtest_csv( + results: Dict[str, Any] = Body(...), +): + """Export backtest results as CSV.""" + try: + output = io.StringIO() + writer = csv.writer(output) + + # Write header + writer.writerow(['Metric', 'Value']) + + # Write metrics + writer.writerow(['Total Return', f"{(results.get('total_return', 0) * 100):.2f}%"]) + writer.writerow(['Sharpe Ratio', f"{results.get('sharpe_ratio', 0):.2f}"]) + writer.writerow(['Sortino Ratio', f"{results.get('sortino_ratio', 0):.2f}"]) + writer.writerow(['Max Drawdown', f"{(results.get('max_drawdown', 0) * 100):.2f}%"]) + writer.writerow(['Win Rate', f"{(results.get('win_rate', 0) * 100):.2f}%"]) + writer.writerow(['Total Trades', results.get('total_trades', 0)]) + writer.writerow(['Final Value', f"${results.get('final_value', 0):.2f}"]) + writer.writerow(['Initial Capital', f"${results.get('initial_capital', 0):.2f}"]) + + # Write trades if available + if results.get('trades'): + writer.writerow([]) + writer.writerow(['Trades']) + writer.writerow(['Timestamp', 'Side', 'Price', 'Quantity', 'Value']) + for trade in results['trades']: + writer.writerow([ + trade.get('timestamp', ''), + trade.get('side', ''), + f"${trade.get('price', 0):.2f}", + trade.get('quantity', 0), + f"${(trade.get('price', 0) * trade.get('quantity', 0)):.2f}", + ]) + + output.seek(0) + + return StreamingResponse( + iter([output.getvalue()]), + media_type="text/csv", + headers={"Content-Disposition": f"attachment; filename=backtest_results_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"} + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/backtest/pdf") +async def export_backtest_pdf( + results: Dict[str, Any] = Body(...), +): + """Export backtest results as PDF.""" + try: + pdf_generator = get_pdf_generator() + + # Create temporary file + with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as tmp_file: + tmp_path = Path(tmp_file.name) + + # Convert results to metrics format expected by PDF generator + metrics = { + 'total_return_percent': (results.get('total_return', 0) * 100), + 'sharpe_ratio': results.get('sharpe_ratio', 0), + 'sortino_ratio': results.get('sortino_ratio', 0), + 'max_drawdown': results.get('max_drawdown', 0), + 'win_rate': results.get('win_rate', 0), + } + + # Generate PDF + success = pdf_generator.generate_performance_report( + tmp_path, + metrics, + "Backtest Report" + ) + + if not success: + raise HTTPException(status_code=500, detail="Failed to generate PDF") + + # Read PDF and return as stream + with open(tmp_path, 'rb') as f: + pdf_content = f.read() + + # Clean up + tmp_path.unlink() + + return StreamingResponse( + io.BytesIO(pdf_content), + media_type="application/pdf", + headers={"Content-Disposition": f"attachment; filename=backtest_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"} + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/trades/csv") +async def export_trades_csv( + start_date: Optional[str] = None, + end_date: Optional[str] = None, + paper_trading: bool = True, +): + """Export trades as CSV.""" + try: + csv_exporter = get_csv_exporter() + + # Parse dates if provided + start = datetime.fromisoformat(start_date) if start_date else None + end = datetime.fromisoformat(end_date) if end_date else None + + # Create temporary file + with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp_file: + tmp_path = Path(tmp_file.name) + + # Export to file + success = csv_exporter.export_trades( + filepath=tmp_path, + paper_trading=paper_trading, + start_date=start, + end_date=end + ) + + if not success: + raise HTTPException(status_code=500, detail="Failed to export trades") + + # Read and return + with open(tmp_path, 'r') as f: + csv_content = f.read() + + tmp_path.unlink() + + return StreamingResponse( + iter([csv_content]), + media_type="text/csv", + headers={"Content-Disposition": f"attachment; filename=trades_{datetime.now().strftime('%Y%m%d')}.csv"} + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/portfolio/csv") +async def export_portfolio_csv( + paper_trading: bool = True, +): + """Export portfolio as CSV.""" + try: + csv_exporter = get_csv_exporter() + + # Create temporary file + with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp_file: + tmp_path = Path(tmp_file.name) + + # Export to file + success = csv_exporter.export_portfolio(filepath=tmp_path) + + if not success: + raise HTTPException(status_code=500, detail="Failed to export portfolio") + + # Read and return + with open(tmp_path, 'r') as f: + csv_content = f.read() + + tmp_path.unlink() + + return StreamingResponse( + iter([csv_content]), + media_type="text/csv", + headers={"Content-Disposition": f"attachment; filename=portfolio_{datetime.now().strftime('%Y%m%d')}.csv"} + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/tax/{method}") +async def generate_tax_report( + method: str, # fifo, lifo, specific_id + symbol: Optional[str] = Query(None), + year: Optional[int] = Query(None), + paper_trading: bool = Query(True), +): + """Generate tax report using specified method.""" + try: + if year is None: + year = datetime.now().year + + tax_reporter = get_tax_reporter() + + if method == "fifo": + if symbol: + events = tax_reporter.generate_fifo_report(symbol, year, paper_trading) + else: + # Generate for all symbols + events = [] + # Get all symbols from trades + db = get_database() + async with db.get_session() as session: + stmt = select(Trade.symbol).distinct() + result = await session.execute(stmt) + symbols = result.scalars().all() + for sym in symbols: + events.extend(tax_reporter.generate_fifo_report(sym, year, paper_trading)) + elif method == "lifo": + if symbol: + events = tax_reporter.generate_lifo_report(symbol, year, paper_trading) + else: + events = [] + db = get_database() + async with db.get_session() as session: + stmt = select(Trade.symbol).distinct() + result = await session.execute(stmt) + symbols = result.scalars().all() + for sym in symbols: + events.extend(tax_reporter.generate_lifo_report(sym, year, paper_trading)) + else: + raise HTTPException(status_code=400, detail=f"Unsupported tax method: {method}") + + # Generate CSV + output = io.StringIO() + writer = csv.writer(output) + writer.writerow(['Date', 'Symbol', 'Quantity', 'Cost Basis', 'Proceeds', 'Gain/Loss', 'Buy Date']) + for event in events: + writer.writerow([ + event.get('date', ''), + event.get('symbol', ''), + event.get('quantity', 0), + event.get('cost_basis', 0), + event.get('proceeds', 0), + event.get('gain_loss', 0), + event.get('buy_date', ''), + ]) + output.seek(0) + + return StreamingResponse( + iter([output.getvalue()]), + media_type="text/csv", + headers={"Content-Disposition": f"attachment; filename=tax_report_{method}_{year}_{datetime.now().strftime('%Y%m%d')}.csv"} + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +def get_tax_reporter(): + """Get tax reporter instance.""" + from src.reporting.tax_reporter import get_tax_reporter as _get_tax_reporter + return _get_tax_reporter() diff --git a/backend/api/reports.py b/backend/api/reports.py new file mode 100644 index 00000000..0b3d098d --- /dev/null +++ b/backend/api/reports.py @@ -0,0 +1,155 @@ +"""Reports API endpoints for background report generation.""" + +from typing import Optional +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel + +router = APIRouter() + + +class ReportRequest(BaseModel): + """Request model for report generation.""" + report_type: str # 'performance', 'trades', 'tax', 'backtest' + format: str = "pdf" # 'pdf' or 'csv' + year: Optional[int] = None # For tax reports + method: Optional[str] = "fifo" # For tax reports: 'fifo', 'lifo' + + +class ExportRequest(BaseModel): + """Request model for data export.""" + export_type: str # 'orders', 'positions' + + +@router.post("/generate") +async def generate_report(request: ReportRequest): + """Generate a report in the background. + + This endpoint queues a report generation task and returns immediately. + Use /api/tasks/{task_id} to monitor progress. + + Supported report types: + - performance: Portfolio performance report + - trades: Trade history export + - tax: Tax report with capital gains calculation + + Returns: + Task ID for monitoring + """ + try: + from src.worker.tasks import generate_report_task + + params = { + "format": request.format, + } + + if request.report_type == "tax": + from datetime import datetime + params["year"] = request.year or datetime.now().year + params["method"] = request.method + + task = generate_report_task.delay( + report_type=request.report_type, + params=params + ) + + return { + "task_id": task.id, + "status": "queued", + "report_type": request.report_type, + "format": request.format + } + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/export") +async def export_data(request: ExportRequest): + """Export data in the background. + + This endpoint queues a data export task and returns immediately. + Use /api/tasks/{task_id} to monitor progress. + + Supported export types: + - orders: Order history + - positions: Current/historical positions + + Returns: + Task ID for monitoring + """ + try: + from src.worker.tasks import export_data_task + + task = export_data_task.delay( + export_type=request.export_type, + params={} + ) + + return { + "task_id": task.id, + "status": "queued", + "export_type": request.export_type + } + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/list") +async def list_reports(): + """List available reports in the reports directory.""" + try: + from pathlib import Path + import os + + reports_dir = Path(os.path.expanduser("~/.local/share/crypto_trader/reports")) + + if not reports_dir.exists(): + return {"reports": []} + + reports = [] + for f in reports_dir.iterdir(): + if f.is_file(): + stat = f.stat() + reports.append({ + "name": f.name, + "path": str(f), + "size": stat.st_size, + "created": stat.st_mtime, + "type": f.suffix.lstrip(".") + }) + + # Sort by creation time, newest first + reports.sort(key=lambda x: x["created"], reverse=True) + + return {"reports": reports} + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/{filename}") +async def delete_report(filename: str): + """Delete a generated report.""" + try: + from pathlib import Path + import os + + reports_dir = Path(os.path.expanduser("~/.local/share/crypto_trader/reports")) + filepath = reports_dir / filename + + if not filepath.exists(): + raise HTTPException(status_code=404, detail="Report not found") + + # Security check: ensure the file is actually in reports dir + if not str(filepath.resolve()).startswith(str(reports_dir.resolve())): + raise HTTPException(status_code=403, detail="Access denied") + + filepath.unlink() + + return {"status": "deleted", "filename": filename} + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/api/settings.py b/backend/api/settings.py new file mode 100644 index 00000000..c4e194f1 --- /dev/null +++ b/backend/api/settings.py @@ -0,0 +1,359 @@ +from typing import Dict, Any, Optional +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from sqlalchemy import select + +from src.core.database import Exchange, get_database +from src.core.config import get_config +from src.security.key_manager import get_key_manager +from src.trading.paper_trading import get_paper_trading + +router = APIRouter() + + +class RiskSettings(BaseModel): + max_drawdown_percent: float + daily_loss_limit_percent: float + position_size_percent: float + + +class PaperTradingSettings(BaseModel): + initial_capital: float + fee_exchange: str = "coinbase" # Which exchange's fee model to use + + +class LoggingSettings(BaseModel): + level: str + dir: str + retention_days: int + + +class GeneralSettings(BaseModel): + timezone: str = "UTC" + theme: str = "dark" + currency: str = "USD" + + +class ExchangeCreate(BaseModel): + name: str + api_key: Optional[str] = None + api_secret: Optional[str] = None + sandbox: bool = False + read_only: bool = True + enabled: bool = True + + +class ExchangeUpdate(BaseModel): + api_key: Optional[str] = None + api_secret: Optional[str] = None + sandbox: Optional[bool] = None + read_only: Optional[bool] = None + enabled: Optional[bool] = None + + +@router.get("/risk") +async def get_risk_settings(): + """Get risk management settings.""" + try: + config = get_config() + return { + "max_drawdown_percent": config.get("risk.max_drawdown_percent", 20.0), + "daily_loss_limit_percent": config.get("risk.daily_loss_limit_percent", 5.0), + "position_size_percent": config.get("risk.position_size_percent", 2.0), + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.put("/risk") +async def update_risk_settings(settings: RiskSettings): + """Update risk management settings.""" + try: + config = get_config() + config.set("risk.max_drawdown_percent", settings.max_drawdown_percent) + config.set("risk.daily_loss_limit_percent", settings.daily_loss_limit_percent) + config.set("risk.position_size_percent", settings.position_size_percent) + config.save() + return {"status": "success"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/paper-trading") +async def get_paper_trading_settings(): + """Get paper trading settings.""" + try: + config = get_config() + fee_exchange = config.get("paper_trading.fee_exchange", "coinbase") + + # Get fee rates for current exchange + fee_rates = config.get(f"trading.exchanges.{fee_exchange}.fees", + config.get("trading.default_fees", {"maker": 0.001, "taker": 0.001})) + + return { + "initial_capital": config.get("paper_trading.default_capital", 100.0), + "fee_exchange": fee_exchange, + "fee_rates": fee_rates, + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.put("/paper-trading") +async def update_paper_trading_settings(settings: PaperTradingSettings): + """Update paper trading settings.""" + try: + config = get_config() + config.set("paper_trading.default_capital", settings.initial_capital) + config.set("paper_trading.fee_exchange", settings.fee_exchange) + config.save() + return {"status": "success"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/paper-trading/fee-exchanges") +async def get_available_fee_exchanges(): + """Get available exchange fee models for paper trading.""" + try: + config = get_config() + exchanges_config = config.get("trading.exchanges", {}) + default_fees = config.get("trading.default_fees", {"maker": 0.001, "taker": 0.001}) + current = config.get("paper_trading.fee_exchange", "coinbase") + + exchanges = [{"name": "default", "fees": default_fees}] + for name, data in exchanges_config.items(): + if "fees" in data: + exchanges.append({ + "name": name, + "fees": data["fees"] + }) + + return { + "exchanges": exchanges, + "current": current, + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/paper-trading/reset") +async def reset_paper_account(): + """Reset paper trading account.""" + try: + paper_trading = get_paper_trading() + # Reset to capital from config + success = await paper_trading.reset() + if success: + return {"status": "success", "message": "Paper account reset successfully"} + else: + raise HTTPException(status_code=500, detail="Failed to reset paper account") + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/logging") +async def get_logging_settings(): + """Get logging settings.""" + try: + config = get_config() + return { + "level": config.get("logging.level", "INFO"), + "dir": config.get("logging.dir", ""), + "retention_days": config.get("logging.retention_days", 30), + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.put("/logging") +async def update_logging_settings(settings: LoggingSettings): + """Update logging settings.""" + try: + config = get_config() + config.set("logging.level", settings.level) + config.set("logging.dir", settings.dir) + config.set("logging.retention_days", settings.retention_days) + config.save() + return {"status": "success"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/general") +async def get_general_settings(): + """Get general settings.""" + try: + config = get_config() + return { + "timezone": config.get("general.timezone", "UTC"), + "theme": config.get("general.theme", "dark"), + "currency": config.get("general.currency", "USD"), + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.put("/general") +async def update_general_settings(settings: GeneralSettings): + """Update general settings.""" + try: + config = get_config() + config.set("general.timezone", settings.timezone) + config.set("general.theme", settings.theme) + config.set("general.currency", settings.currency) + config.save() + return {"status": "success"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/exchanges") +async def create_exchange(exchange: ExchangeCreate): + """Create a new exchange.""" + try: + db = get_database() + async with db.get_session() as session: + from src.exchanges.factory import ExchangeFactory + from src.exchanges.public_data import PublicDataAdapter + + # Check if this is a public data exchange + adapter_class = None + try: + if hasattr(ExchangeFactory, '_adapters'): + adapter_class = ExchangeFactory._adapters.get(exchange.name.lower()) + except: + pass + is_public_data = adapter_class == PublicDataAdapter if adapter_class else False + + # Only require API keys for non-public-data exchanges + if not is_public_data: + if not exchange.api_key or not exchange.api_secret: + raise HTTPException(status_code=400, detail="API key and secret are required") + + new_exchange = Exchange( + name=exchange.name, + enabled=exchange.enabled + ) + session.add(new_exchange) + await session.flush() + + # Save credentials + key_manager = get_key_manager() + key_manager.update_exchange( + new_exchange.id, + api_key=exchange.api_key or "", + api_secret=exchange.api_secret or "", + read_only=exchange.read_only if not is_public_data else True, + sandbox=exchange.sandbox if not is_public_data else False + ) + + await session.commit() + return {"id": new_exchange.id, "name": new_exchange.name, "status": "created"} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.put("/exchanges/{exchange_id}") +async def update_exchange(exchange_id: int, exchange: ExchangeUpdate): + """Update an existing exchange.""" + try: + db = get_database() + async with db.get_session() as session: + stmt = select(Exchange).where(Exchange.id == exchange_id) + result = await session.execute(stmt) + exchange_obj = result.scalar_one_or_none() + if not exchange_obj: + raise HTTPException(status_code=404, detail="Exchange not found") + + key_manager = get_key_manager() + credentials = key_manager.get_exchange_credentials(exchange_id) + + # Update credentials if provided + if exchange.api_key is not None or exchange.api_secret is not None: + key_manager.update_exchange( + exchange_id, + api_key=exchange.api_key or credentials.get('api_key', ''), + api_secret=exchange.api_secret or credentials.get('api_secret', ''), + read_only=exchange.read_only if exchange.read_only is not None else credentials.get('read_only', True), + sandbox=exchange.sandbox if exchange.sandbox is not None else credentials.get('sandbox', False) + ) + + if exchange.enabled is not None: + exchange_obj.enabled = exchange.enabled + await session.commit() + + return {"status": "success"} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/exchanges/{exchange_id}") +async def delete_exchange(exchange_id: int): + """Delete an exchange.""" + try: + db = get_database() + async with db.get_session() as session: + stmt = select(Exchange).where(Exchange.id == exchange_id) + result = await session.execute(stmt) + exchange = result.scalar_one_or_none() + if not exchange: + raise HTTPException(status_code=404, detail="Exchange not found") + + await session.delete(exchange) + await session.commit() + return {"status": "success"} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/exchanges/{exchange_id}/test") +async def test_exchange_connection(exchange_id: int): + """Test exchange connection.""" + try: + db = get_database() + async with db.get_session() as session: + stmt = select(Exchange).where(Exchange.id == exchange_id) + result = await session.execute(stmt) + exchange = result.scalar_one_or_none() + if not exchange: + raise HTTPException(status_code=404, detail="Exchange not found") + + from src.exchanges.factory import ExchangeFactory + from src.exchanges.public_data import PublicDataAdapter + + # Get adapter class safely + adapter_class = None + try: + if hasattr(ExchangeFactory, '_adapters'): + adapter_class = ExchangeFactory._adapters.get(exchange.name.lower()) + except: + pass + is_public_data = adapter_class == PublicDataAdapter if adapter_class else False + + key_manager = get_key_manager() + if not is_public_data and not key_manager.get_exchange_credentials(exchange_id): + raise HTTPException(status_code=400, detail="No credentials found for this exchange") + + adapter = ExchangeFactory.create(exchange_id) + if adapter and adapter.connect(): + try: + if is_public_data: + adapter.get_ticker("BTC/USDT") + else: + adapter.get_balance() + return {"status": "success", "message": "Connection successful"} + except Exception as e: + return {"status": "error", "message": f"Connected but failed to fetch data: {str(e)}"} + else: + return {"status": "error", "message": "Failed to connect"} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/api/strategies.py b/backend/api/strategies.py new file mode 100644 index 00000000..af18a9d9 --- /dev/null +++ b/backend/api/strategies.py @@ -0,0 +1,310 @@ +"""Strategy API endpoints.""" + +from typing import List +from fastapi import APIRouter, HTTPException, Depends +from sqlalchemy import select + +from ..core.dependencies import get_strategy_registry +from ..core.schemas import ( + StrategyCreate, StrategyUpdate, StrategyResponse +) +from src.core.database import Strategy, get_database +from src.strategies.scheduler import get_scheduler as _get_scheduler + +def get_strategy_scheduler(): + """Get strategy scheduler instance.""" + return _get_scheduler() + +router = APIRouter() + + +@router.get("/", response_model=List[StrategyResponse]) +async def list_strategies(): + """List all strategies.""" + try: + db = get_database() + async with db.get_session() as session: + stmt = select(Strategy).order_by(Strategy.created_at.desc()) + result = await session.execute(stmt) + strategies = result.scalars().all() + return [StrategyResponse.model_validate(s) for s in strategies] + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/available") +async def list_available_strategies( + registry=Depends(get_strategy_registry) +): + """List available strategy types.""" + try: + return {"strategies": registry.list_available()} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/", response_model=StrategyResponse) +async def create_strategy(strategy_data: StrategyCreate): + """Create a new strategy.""" + try: + db = get_database() + async with db.get_session() as session: + strategy = Strategy( + name=strategy_data.name, + description=strategy_data.description, + strategy_type=strategy_data.strategy_type, + class_name=strategy_data.class_name, + parameters=strategy_data.parameters, + timeframes=strategy_data.timeframes, + paper_trading=strategy_data.paper_trading, + schedule=strategy_data.schedule, + enabled=False + ) + session.add(strategy) + await session.commit() + await session.refresh(strategy) + return StrategyResponse.model_validate(strategy) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/{strategy_id}", response_model=StrategyResponse) +async def get_strategy(strategy_id: int): + """Get strategy by ID.""" + try: + db = get_database() + async with db.get_session() as session: + stmt = select(Strategy).where(Strategy.id == strategy_id) + result = await session.execute(stmt) + strategy = result.scalar_one_or_none() + if not strategy: + raise HTTPException(status_code=404, detail="Strategy not found") + return StrategyResponse.model_validate(strategy) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.put("/{strategy_id}", response_model=StrategyResponse) +async def update_strategy(strategy_id: int, strategy_data: StrategyUpdate): + """Update a strategy.""" + try: + db = get_database() + async with db.get_session() as session: + stmt = select(Strategy).where(Strategy.id == strategy_id) + result = await session.execute(stmt) + strategy = result.scalar_one_or_none() + if not strategy: + raise HTTPException(status_code=404, detail="Strategy not found") + + if strategy_data.name is not None: + strategy.name = strategy_data.name + if strategy_data.description is not None: + strategy.description = strategy_data.description + if strategy_data.parameters is not None: + strategy.parameters = strategy_data.parameters + if strategy_data.timeframes is not None: + strategy.timeframes = strategy_data.timeframes + if strategy_data.enabled is not None: + strategy.enabled = strategy_data.enabled + if strategy_data.schedule is not None: + strategy.schedule = strategy_data.schedule + + await session.commit() + await session.refresh(strategy) + return StrategyResponse.model_validate(strategy) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/{strategy_id}") +async def delete_strategy(strategy_id: int): + """Delete a strategy.""" + try: + db = get_database() + async with db.get_session() as session: + stmt = select(Strategy).where(Strategy.id == strategy_id) + result = await session.execute(stmt) + strategy = result.scalar_one_or_none() + if not strategy: + raise HTTPException(status_code=404, detail="Strategy not found") + + await session.delete(strategy) + await session.commit() + return {"status": "deleted", "strategy_id": strategy_id} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/{strategy_id}/start") +async def start_strategy(strategy_id: int): + """Start a strategy manually (bypasses Autopilot).""" + try: + db = get_database() + async with db.get_session() as session: + stmt = select(Strategy).where(Strategy.id == strategy_id) + result = await session.execute(stmt) + strategy = result.scalar_one_or_none() + if not strategy: + raise HTTPException(status_code=404, detail="Strategy not found") + + # Start strategy via scheduler + scheduler = get_strategy_scheduler() + scheduler.start_strategy(strategy_id) + + strategy.running = True # Only set running, not enabled + await session.commit() + + return {"status": "started", "strategy_id": strategy_id} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/{strategy_id}/stop") +async def stop_strategy(strategy_id: int): + """Stop a manually running strategy.""" + try: + db = get_database() + async with db.get_session() as session: + stmt = select(Strategy).where(Strategy.id == strategy_id) + result = await session.execute(stmt) + strategy = result.scalar_one_or_none() + if not strategy: + raise HTTPException(status_code=404, detail="Strategy not found") + + # Stop strategy via scheduler + scheduler = get_strategy_scheduler() + scheduler.stop_strategy(strategy_id) + + strategy.running = False # Only set running, not enabled + await session.commit() + + return {"status": "stopped", "strategy_id": strategy_id} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/{strategy_type}/optimize") +async def optimize_strategy( + strategy_type: str, + symbol: str = "BTC/USD", + method: str = "genetic", + population_size: int = 50, + generations: int = 100 +): + """Optimize strategy parameters using genetic algorithm. + + This endpoint queues an optimization task and returns immediately. + Use /api/tasks/{task_id} to monitor progress. + + Args: + strategy_type: Type of strategy to optimize (e.g., 'rsi', 'macd') + symbol: Trading symbol for backtesting + method: Optimization method ('genetic', 'grid') + population_size: Population size for genetic algorithm + generations: Number of generations + + Returns: + Task ID for monitoring + """ + try: + from src.worker.tasks import optimize_strategy_task + + # Get parameter ranges for the strategy type + registry = get_strategy_registry() + strategy_class = registry.get(strategy_type) + + if not strategy_class: + raise HTTPException(status_code=404, detail=f"Strategy type '{strategy_type}' not found") + + # Default parameter ranges based on strategy type + param_ranges = { + "rsi": {"period": (5, 50), "overbought": (60, 90), "oversold": (10, 40)}, + "macd": {"fast_period": (5, 20), "slow_period": (15, 40), "signal_period": (5, 15)}, + "moving_average": {"short_period": (5, 30), "long_period": (20, 100)}, + "bollinger_mean_reversion": {"period": (10, 50), "std_dev": (1.5, 3.0)}, + }.get(strategy_type.lower(), {"period": (10, 50)}) + + # Queue the optimization task + task = optimize_strategy_task.delay( + strategy_type=strategy_type, + symbol=symbol, + param_ranges=param_ranges, + method=method, + population_size=population_size, + generations=generations + ) + + return { + "task_id": task.id, + "status": "queued", + "strategy_type": strategy_type, + "symbol": symbol, + "method": method + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/{strategy_id}/status") +async def get_strategy_status(strategy_id: int): + """Get real-time status of a running strategy. + + Returns execution info including last tick time, last signal, and stats. + """ + try: + scheduler = get_strategy_scheduler() + status = scheduler.get_strategy_status(strategy_id) + + if not status: + # Check if strategy exists but isn't running + db = get_database() + async with db.get_session() as session: + stmt = select(Strategy).where(Strategy.id == strategy_id) + result = await session.execute(stmt) + strategy = result.scalar_one_or_none() + + if not strategy: + raise HTTPException(status_code=404, detail="Strategy not found") + + return { + "strategy_id": strategy_id, + "name": strategy.name, + "type": strategy.strategy_type, + "symbol": strategy.parameters.get('symbol') if strategy.parameters else None, + "running": False, + "enabled": strategy.enabled, + } + + return status + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/running/all") +async def get_all_running_strategies(): + """Get status of all currently running strategies.""" + try: + scheduler = get_strategy_scheduler() + active = scheduler.get_all_active_strategies() + return { + "total_running": len(active), + "strategies": active + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/api/trading.py b/backend/api/trading.py new file mode 100644 index 00000000..01e28f6b --- /dev/null +++ b/backend/api/trading.py @@ -0,0 +1,206 @@ +"""Trading API endpoints.""" + +from decimal import Decimal +from typing import List, Optional +from fastapi import APIRouter, HTTPException, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from ..core.dependencies import get_trading_engine, get_db_session +from ..core.schemas import OrderCreate, OrderResponse, PositionResponse +from src.core.database import Order, OrderSide, OrderType, OrderStatus +from src.core.repositories import OrderRepository, PositionRepository +from src.core.logger import get_logger +from src.trading.paper_trading import get_paper_trading + +router = APIRouter() +logger = get_logger(__name__) + + +@router.post("/orders", response_model=OrderResponse) +async def create_order( + order_data: OrderCreate, + trading_engine=Depends(get_trading_engine) +): + """Create and execute a trading order.""" + try: + # Convert string enums to actual enums + side = OrderSide(order_data.side.value) + order_type = OrderType(order_data.order_type.value) + + order = await trading_engine.execute_order( + exchange_id=order_data.exchange_id, + strategy_id=order_data.strategy_id, + symbol=order_data.symbol, + side=side, + order_type=order_type, + quantity=order_data.quantity, + price=order_data.price, + paper_trading=order_data.paper_trading + ) + + if not order: + raise HTTPException(status_code=400, detail="Order execution failed") + + return OrderResponse.model_validate(order) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/orders", response_model=List[OrderResponse]) +async def get_orders( + paper_trading: bool = True, + limit: int = 100, + db: AsyncSession = Depends(get_db_session) +): + """Get order history.""" + try: + repo = OrderRepository(db) + orders = await repo.get_all(limit=limit) + # Filter by paper_trading in memory or add to repo method (repo returns all by default currently sorted by date) + # Let's verify repo method. It has limit/offset but not filtering. + # We should filter here or improve repo. + # For this refactor, let's filter in python for simplicity or assume get_all needs an update. + # Ideally, update repo. But strictly following "get_all" contract. + + filtered = [o for o in orders if o.paper_trading == paper_trading] + return [OrderResponse.model_validate(order) for order in filtered] + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/orders/{order_id}", response_model=OrderResponse) +async def get_order( + order_id: int, + db: AsyncSession = Depends(get_db_session) +): + """Get order by ID.""" + try: + repo = OrderRepository(db) + order = await repo.get_by_id(order_id) + if not order: + raise HTTPException(status_code=404, detail="Order not found") + return OrderResponse.model_validate(order) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/orders/{order_id}/cancel") +async def cancel_order( + order_id: int, + trading_engine=Depends(get_trading_engine) +): + try: + # We can use trading_engine's cancel which handles DB and Exchange + success = await trading_engine.cancel_order(order_id) + + if not success: + raise HTTPException(status_code=400, detail="Failed to cancel order or order not found") + + return {"status": "cancelled", "order_id": order_id} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/orders/cancel-all") +async def cancel_all_orders( + paper_trading: bool = True, + trading_engine=Depends(get_trading_engine), + db: AsyncSession = Depends(get_db_session) +): + """Cancel all open orders.""" + try: + repo = OrderRepository(db) + open_orders = await repo.get_open_orders(paper_trading=paper_trading) + + if not open_orders: + return {"status": "no_orders", "cancelled_count": 0} + + cancelled_count = 0 + failed_count = 0 + + for order in open_orders: + try: + if await trading_engine.cancel_order(order.id): + cancelled_count += 1 + else: + failed_count += 1 + except Exception as e: + logger.error(f"Failed to cancel order {order.id}: {e}") + failed_count += 1 + + return { + "status": "completed", + "cancelled_count": cancelled_count, + "failed_count": failed_count, + "total_orders": len(open_orders) + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/positions", response_model=List[PositionResponse]) +async def get_positions( + paper_trading: bool = True, + db: AsyncSession = Depends(get_db_session) +): + """Get current positions.""" + try: + if paper_trading: + paper_trading_sim = get_paper_trading() + positions = paper_trading_sim.get_positions() + # positions is a List[Position], convert to PositionResponse list + position_list = [] + for pos in positions: + # pos is a Position database object + current_price = pos.current_price if pos.current_price else pos.entry_price + unrealized_pnl = pos.unrealized_pnl if pos.unrealized_pnl else Decimal(0) + realized_pnl = pos.realized_pnl if pos.realized_pnl else Decimal(0) + position_list.append( + PositionResponse( + symbol=pos.symbol, + quantity=pos.quantity, + entry_price=pos.entry_price, + current_price=current_price, + unrealized_pnl=unrealized_pnl, + realized_pnl=realized_pnl + ) + ) + return position_list + else: + # Live trading positions from database + repo = PositionRepository(db) + positions = await repo.get_all(paper_trading=False) + return [ + PositionResponse( + symbol=pos.symbol, + quantity=pos.quantity, + entry_price=pos.entry_price, + current_price=pos.current_price or pos.entry_price, + unrealized_pnl=pos.unrealized_pnl, + realized_pnl=pos.realized_pnl + ) + for pos in positions + ] + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/balance") +async def get_balance(paper_trading: bool = True): + """Get account balance.""" + try: + paper_trading_sim = get_paper_trading() + if paper_trading: + balance = paper_trading_sim.get_balance() + performance = paper_trading_sim.get_performance() + return { + "balance": float(balance), + "performance": performance + } + else: + # Live trading balance from exchange + return {"balance": 0.0, "performance": {}} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/api/websocket.py b/backend/api/websocket.py new file mode 100644 index 00000000..42486f37 --- /dev/null +++ b/backend/api/websocket.py @@ -0,0 +1,242 @@ +"""WebSocket endpoints for real-time updates.""" + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from typing import List, Dict, Set, Callable, Optional +import json +import asyncio +from datetime import datetime +from decimal import Decimal +from collections import deque + +from ..core.schemas import PriceUpdate, OrderUpdate +from src.data.pricing_service import get_pricing_service + +router = APIRouter() + + +class ConnectionManager: + """Manages WebSocket connections.""" + + + def __init__(self): + self.active_connections: List[WebSocket] = [] + self.subscribed_symbols: Set[str] = set() + self._pricing_service = None + self._price_callbacks: Dict[str, List[Callable]] = {} + # Queue for price updates (thread-safe for async processing) + self._price_update_queue: deque = deque() + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._processing_task = None + + def set_event_loop(self, loop: asyncio.AbstractEventLoop): + """Set the event loop for async operations.""" + self._loop = loop + + async def start_background_tasks(self): + """Start background processing tasks.""" + if self._processing_task is None or self._processing_task.done(): + self._processing_task = asyncio.create_task(self._process_queue()) + print("WebSocket manager background tasks started") + + async def _process_queue(self): + """Periodically process price updates from queue.""" + while True: + try: + if self._price_update_queue: + # Process up to 10 updates at a time to prevent blocking + for _ in range(10): + if not self._price_update_queue: + break + update = self._price_update_queue.popleft() + await self.broadcast_price_update( + exchange=update["exchange"], + symbol=update["symbol"], + price=update["price"] + ) + await asyncio.sleep(0.01) # Check queue frequently but yield + except Exception as e: + print(f"Error processing price update queue: {e}") + await asyncio.sleep(1) + + def _initialize_pricing_service(self): + """Initialize pricing service and subscribe to price updates.""" + if self._pricing_service is None: + self._pricing_service = get_pricing_service() + + def subscribe_to_symbol(self, symbol: str): + """Subscribe to price updates for a symbol.""" + self._initialize_pricing_service() + + if symbol not in self.subscribed_symbols: + self.subscribed_symbols.add(symbol) + + def price_callback(data): + """Callback for price updates from pricing service.""" + # Store update in queue for async processing + update = { + "exchange": "pricing", + "symbol": data.get('symbol', symbol), + "price": Decimal(str(data.get('price', 0))) + } + self._price_update_queue.append(update) + # Note: We rely on the background task to process this queue now + + self._pricing_service.subscribe_ticker(symbol, price_callback) + + async def _process_price_update(self, update: Dict): + """Process a price update asynchronously. + DEPRECATED: Use _process_queue instead. + """ + await self.broadcast_price_update( + exchange=update["exchange"], + symbol=update["symbol"], + price=update["price"] + ) + + def unsubscribe_from_symbol(self, symbol: str): + """Unsubscribe from price updates for a symbol.""" + if symbol in self.subscribed_symbols: + self.subscribed_symbols.remove(symbol) + if self._pricing_service: + self._pricing_service.unsubscribe_ticker(symbol) + + async def connect(self, websocket: WebSocket): + """Add WebSocket connection to active connections. + + Note: websocket.accept() must be called before this method. + """ + self.active_connections.append(websocket) + # Ensure background task is running + await self.start_background_tasks() + + def disconnect(self, websocket: WebSocket): + """Remove a WebSocket connection.""" + if websocket in self.active_connections: + self.active_connections.remove(websocket) + + async def broadcast_price_update(self, exchange: str, symbol: str, price: Decimal): + """Broadcast price update to all connected clients.""" + message = PriceUpdate( + exchange=exchange, + symbol=symbol, + price=price, + timestamp=datetime.utcnow() + ) + await self.broadcast(message.dict()) + + async def broadcast_order_update(self, order_id: int, status: str, filled_quantity: Decimal = None): + """Broadcast order update to all connected clients.""" + message = OrderUpdate( + order_id=order_id, + status=status, + filled_quantity=filled_quantity, + timestamp=datetime.utcnow() + ) + await self.broadcast(message.dict()) + + async def broadcast_training_progress(self, step: str, progress: int, total: int, message: str, details: dict = None): + """Broadcast training progress update to all connected clients.""" + update = { + "type": "training_progress", + "step": step, + "progress": progress, + "total": total, + "percent": int((progress / total) * 100) if total > 0 else 0, + "message": message, + "details": details or {}, + "timestamp": datetime.utcnow().isoformat() + } + await self.broadcast(update) + + async def broadcast(self, message: dict): + """Broadcast message to all connected clients.""" + disconnected = [] + for connection in self.active_connections: + try: + await connection.send_json(message) + except Exception: + disconnected.append(connection) + + # Remove disconnected clients + for conn in disconnected: + self.disconnect(conn) + + +manager = ConnectionManager() + + +@router.websocket("/") +async def websocket_endpoint(websocket: WebSocket): + """WebSocket endpoint for real-time updates.""" + # Check origin for CORS before accepting + origin = websocket.headers.get("origin") + allowed_origins = ["http://localhost:3000", "http://localhost:5173", "http://127.0.0.1:3000", "http://127.0.0.1:5173"] + + # Allow connections from allowed origins or if no origin header (direct connections, testing) + # Relaxed check: Log warning but allow if origin doesn't match, to prevent disconnection issues in some environments + if origin and origin not in allowed_origins: + print(f"Warning: WebSocket connection from unknown origin: {origin}") + # We allow it for now to fix disconnection issues, but normally we might block + # await websocket.close(code=1008, reason="Origin not allowed") + # return + + # Accept the connection + await websocket.accept() + + try: + # Connect to manager (starts background tasks if needed) + await manager.connect(websocket) + + subscribed_symbols = set() + + while True: + # Receive messages from client (for subscriptions, etc.) + data = await websocket.receive_text() + try: + message = json.loads(data) + + # Handle subscription messages + if message.get("type") == "subscribe": + symbol = message.get("symbol") + if symbol: + subscribed_symbols.add(symbol) + manager.subscribe_to_symbol(symbol) + await websocket.send_json({ + "type": "subscription_confirmed", + "symbol": symbol + }) + + elif message.get("type") == "unsubscribe": + symbol = message.get("symbol") + if symbol and symbol in subscribed_symbols: + subscribed_symbols.remove(symbol) + manager.unsubscribe_from_symbol(symbol) + await websocket.send_json({ + "type": "unsubscription_confirmed", + "symbol": symbol + }) + + else: + # Default acknowledgment + await websocket.send_json({"type": "ack", "message": "received"}) + + except json.JSONDecodeError: + await websocket.send_json({"type": "error", "message": "Invalid JSON"}) + except Exception as e: + # Don't send internal errors to client in production, but okay for debugging + await websocket.send_json({"type": "error", "message": str(e)}) + + except WebSocketDisconnect: + # Clean up subscriptions + for symbol in subscribed_symbols: + manager.unsubscribe_from_symbol(symbol) + manager.disconnect(websocket) + except Exception as e: + manager.disconnect(websocket) + print(f"WebSocket error: {e}") + # Only close if not already closed + try: + await websocket.close(code=1011) + except Exception: + pass + diff --git a/backend/core/__init__.py b/backend/core/__init__.py new file mode 100644 index 00000000..614d0026 --- /dev/null +++ b/backend/core/__init__.py @@ -0,0 +1 @@ +"""Core backend utilities.""" diff --git a/backend/core/dependencies.py b/backend/core/dependencies.py new file mode 100644 index 00000000..696ebd05 --- /dev/null +++ b/backend/core/dependencies.py @@ -0,0 +1,70 @@ +"""FastAPI dependencies for service injection.""" + +from functools import lru_cache +import sys +from pathlib import Path + +# Add src to path - must be done before any imports +src_path = Path(__file__).parent.parent.parent / "src" +if str(src_path) not in sys.path: + sys.path.insert(0, str(src_path)) + +# Import database and redis immediately +from core.database import get_database as _get_database +from src.core.redis import get_redis_client as _get_redis_client + +# Lazy imports for other services (only import when needed to avoid import errors) +# These will be imported on-demand in their respective getter functions + + +@lru_cache() +def get_database(): + """Get database instance.""" + return _get_database() + + +async def get_db_session(): + """Get database session.""" + db = get_database() + async with db.get_session() as session: + yield session + + +@lru_cache() +def get_trading_engine(): + """Get trading engine instance.""" + from trading.engine import get_trading_engine as _get_trading_engine + return _get_trading_engine() + + +@lru_cache() +def get_portfolio_tracker(): + """Get portfolio tracker instance.""" + from portfolio.tracker import get_portfolio_tracker as _get_portfolio_tracker + return _get_portfolio_tracker() + + +@lru_cache() +def get_strategy_registry(): + """Get strategy registry instance.""" + from strategies.base import get_strategy_registry as _get_strategy_registry + return _get_strategy_registry() + + +@lru_cache() +def get_backtesting_engine(): + """Get backtesting engine instance.""" + from backtesting.engine import get_backtest_engine as _get_backtesting_engine + return _get_backtesting_engine() + + +def get_exchange_factory(): + """Get exchange factory.""" + from exchanges.factory import ExchangeFactory + return ExchangeFactory + + +@lru_cache() +def get_redis_client(): + """Get Redis client instance.""" + return _get_redis_client() diff --git a/backend/core/schemas.py b/backend/core/schemas.py new file mode 100644 index 00000000..708ad4e3 --- /dev/null +++ b/backend/core/schemas.py @@ -0,0 +1,213 @@ +"""Pydantic schemas for request/response validation.""" + +from datetime import datetime, timezone +from decimal import Decimal +from typing import Optional, List, Dict, Any +from pydantic import BaseModel, Field, ConfigDict, field_validator +from enum import Enum + + +class OrderSide(str, Enum): + """Order side.""" + BUY = "buy" + SELL = "sell" + + +class OrderType(str, Enum): + """Order type.""" + MARKET = "market" + LIMIT = "limit" + STOP_LOSS = "stop_loss" + TAKE_PROFIT = "take_profit" + TRAILING_STOP = "trailing_stop" + OCO = "oco" + ICEBERG = "iceberg" + + +class OrderStatus(str, Enum): + """Order status.""" + PENDING = "pending" + OPEN = "open" + PARTIALLY_FILLED = "partially_filled" + FILLED = "filled" + CANCELLED = "cancelled" + REJECTED = "rejected" + EXPIRED = "expired" + + +# Trading Schemas +class OrderCreate(BaseModel): + """Create order request.""" + exchange_id: int + symbol: str + side: OrderSide + order_type: OrderType + quantity: Decimal + price: Optional[Decimal] = None + strategy_id: Optional[int] = None + paper_trading: bool = True + + +class OrderResponse(BaseModel): + """Order response.""" + model_config = ConfigDict(from_attributes=True, populate_by_name=True) + + id: int + exchange_id: int + strategy_id: Optional[int] + symbol: str + order_type: OrderType + side: OrderSide + status: OrderStatus + quantity: Decimal + price: Optional[Decimal] + filled_quantity: Decimal + average_fill_price: Optional[Decimal] + fee: Decimal + paper_trading: bool + created_at: datetime + updated_at: datetime + filled_at: Optional[datetime] + + @field_validator('created_at', 'updated_at', 'filled_at', mode='after') + @classmethod + def ensure_utc(cls, v: Optional[datetime]) -> Optional[datetime]: + if v and v.tzinfo is None: + return v.replace(tzinfo=timezone.utc) + return v + + +class PositionResponse(BaseModel): + """Position response.""" + model_config = ConfigDict(from_attributes=True, populate_by_name=True) + + symbol: str + quantity: Decimal + entry_price: Decimal + current_price: Decimal + unrealized_pnl: Decimal + realized_pnl: Decimal + + +# Portfolio Schemas +class PortfolioResponse(BaseModel): + """Portfolio response.""" + positions: List[Dict[str, Any]] + performance: Dict[str, float] + timestamp: str + + +class PortfolioHistoryResponse(BaseModel): + """Portfolio history response.""" + dates: List[str] + values: List[float] + pnl: List[float] + + +# Strategy Schemas +class StrategyCreate(BaseModel): + """Create strategy request.""" + name: str + description: Optional[str] = None + strategy_type: str + class_name: str + parameters: Dict[str, Any] = Field(default_factory=dict) + timeframes: List[str] = Field(default_factory=lambda: ["1h"]) + paper_trading: bool = True + schedule: Optional[Dict[str, Any]] = None + + +class StrategyUpdate(BaseModel): + """Update strategy request.""" + name: Optional[str] = None + description: Optional[str] = None + parameters: Optional[Dict[str, Any]] = None + timeframes: Optional[List[str]] = None + enabled: Optional[bool] = None + schedule: Optional[Dict[str, Any]] = None + + +class StrategyResponse(BaseModel): + """Strategy response.""" + model_config = ConfigDict(from_attributes=True, populate_by_name=True) + + id: int + name: str + description: Optional[str] + strategy_type: str + class_name: str + parameters: Dict[str, Any] + timeframes: List[str] + enabled: bool + running: bool = False + paper_trading: bool + version: str + schedule: Optional[Dict[str, Any]] + created_at: datetime + updated_at: datetime + + @field_validator('created_at', 'updated_at', mode='after') + @classmethod + def ensure_utc(cls, v: Optional[datetime]) -> Optional[datetime]: + if v and v.tzinfo is None: + return v.replace(tzinfo=timezone.utc) + return v + + +# Backtesting Schemas +class BacktestRequest(BaseModel): + """Backtest request.""" + strategy_id: int + symbol: str + exchange: str + timeframe: str + start_date: datetime + end_date: datetime + initial_capital: Decimal = Decimal("100.0") + slippage: float = 0.001 + fee_rate: float = 0.001 + + +class BacktestResponse(BaseModel): + """Backtest response.""" + backtest_id: Optional[int] = None + results: Dict[str, Any] + status: str = "completed" + + +# Exchange Schemas +class ExchangeResponse(BaseModel): + """Exchange response.""" + model_config = ConfigDict(from_attributes=True, populate_by_name=True) + + id: int + name: str + sandbox: bool + read_only: bool + enabled: bool + created_at: datetime + updated_at: datetime + + @field_validator('created_at', 'updated_at', mode='after') + @classmethod + def ensure_utc(cls, v: Optional[datetime]) -> Optional[datetime]: + if v and v.tzinfo is None: + return v.replace(tzinfo=timezone.utc) + return v + + +# WebSocket Messages +class PriceUpdate(BaseModel): + """Price update message.""" + exchange: str + symbol: str + price: Decimal + timestamp: datetime + + +class OrderUpdate(BaseModel): + """Order update message.""" + order_id: int + status: OrderStatus + filled_quantity: Optional[Decimal] = None + timestamp: datetime diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 00000000..edb37779 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,185 @@ +"""FastAPI main application - Simplified Crypto Trader API.""" + +import sys +from pathlib import Path + +# Set up import path correctly for relative imports to work +project_root = Path(__file__).parent.parent +src_path = project_root / "src" + +if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) + +if str(src_path) not in sys.path: + sys.path.insert(0, str(src_path)) + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse +from starlette.exceptions import HTTPException as StarletteHTTPException +import uvicorn + +from .api import autopilot, market_data +from .core.dependencies import get_database +# Initialize Celery app configuration +import src.worker.app + +app = FastAPI( + title="Crypto Trader API", + description="Simplified Cryptocurrency Trading Platform", + version="2.0.0" +) + +# CORS middleware for frontend +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:3000", "http://localhost:5173", "http://127.0.0.1:3000", "http://127.0.0.1:5173"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Core routers (always required) +app.include_router(autopilot.router, prefix="/api/autopilot", tags=["autopilot"]) +app.include_router(market_data.router, prefix="/api/market-data", tags=["market-data"]) + +# Trading and Portfolio +try: + from .api import trading + app.include_router(trading.router, prefix="/api/trading", tags=["trading"]) +except ImportError as e: + print(f"Warning: Could not import trading router: {e}") + +try: + from .api import portfolio + app.include_router(portfolio.router, prefix="/api/portfolio", tags=["portfolio"]) +except ImportError as e: + print(f"Warning: Could not import portfolio router: {e}") + +# Strategies and Backtesting +try: + from .api import strategies + app.include_router(strategies.router, prefix="/api/strategies", tags=["strategies"]) +except ImportError as e: + print(f"Warning: Could not import strategies router: {e}") + +try: + from .api import backtesting + app.include_router(backtesting.router, prefix="/api/backtesting", tags=["backtesting"]) +except ImportError as e: + print(f"Warning: Could not import backtesting router: {e}") + +# Settings (includes exchanges and alerts) +try: + from .api import exchanges + app.include_router(exchanges.router, prefix="/api/exchanges", tags=["exchanges"]) +except ImportError as e: + print(f"Warning: Could not import exchanges router: {e}") + +try: + from .api import settings + app.include_router(settings.router, prefix="/api/settings", tags=["settings"]) +except ImportError as e: + print(f"Warning: Could not import settings router: {e}") + +try: + from .api import alerts + app.include_router(alerts.router, prefix="/api/alerts", tags=["alerts"]) +except ImportError as e: + print(f"Warning: Could not import alerts router: {e}") + +# Reporting (merged into Portfolio UI but still needs API) +try: + from .api import reporting + app.include_router(reporting.router, prefix="/api/reporting", tags=["reporting"]) +except ImportError as e: + print(f"Warning: Could not import reporting router: {e}") + +# Reports (background generation) +try: + from .api import reports + app.include_router(reports.router, prefix="/api/reports", tags=["reports"]) +except ImportError as e: + print(f"Warning: Could not import reports router: {e}") + +# WebSocket endpoint +try: + from .api import websocket + app.include_router(websocket.router, prefix="/ws", tags=["websocket"]) +except ImportError as e: + print(f"Warning: Could not import websocket router: {e}") + +# Serve frontend static files (in production) +frontend_path = Path(__file__).parent.parent / "frontend" / "dist" +if frontend_path.exists(): + static_path = frontend_path / "assets" + if static_path.exists(): + app.mount("/assets", StaticFiles(directory=str(static_path)), name="assets") + + @app.get("/{full_path:path}") + async def serve_frontend(full_path: str): + """Serve frontend SPA.""" + if full_path.startswith(("api", "docs", "redoc", "openapi.json")): + from fastapi import HTTPException + raise HTTPException(status_code=404) + + file_path = frontend_path / full_path + if file_path.exists() and file_path.is_file(): + return FileResponse(str(file_path)) + + return FileResponse(str(frontend_path / "index.html")) + + +@app.on_event("startup") +async def startup_event(): + """Initialize services on startup.""" + try: + from src.trading.paper_trading import get_paper_trading + db = get_database() + await db.create_tables() + + # Verify connection + async with db.get_session() as session: + # Just checking connection + pass + + # Initialize paper trading (seeds portfolio if needed) + await get_paper_trading().initialize() + + print("✓ Database initialized") + print("✓ Crypto Trader API ready") + except Exception as e: + print(f"✗ Startup error: {e}") + # In production we might want to exit here, but for now just log + + + +@app.on_event("shutdown") +async def shutdown_event(): + """Cleanup on shutdown.""" + print("Shutting down Crypto Trader API...") + + +@app.exception_handler(StarletteHTTPException) +async def http_exception_handler(request: Request, exc: StarletteHTTPException): + from fastapi.responses import JSONResponse + return JSONResponse( + status_code=exc.status_code, + content={"detail": exc.detail} + ) + +@app.get("/api/health") +async def health_check(): + """Health check endpoint.""" + return {"status": "ok", "service": "crypto_trader_api", "version": "2.0.0"} + + +if __name__ == "__main__": + uvicorn.run( + "backend.main:app", + host="0.0.0.0", + port=8000, + reload=True, + log_level="info" + ) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 00000000..663746ee --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.104.0 +uvicorn[standard]>=0.24.0 +python-multipart>=0.0.6 +pydantic>=2.5.0 diff --git a/check_db.py b/check_db.py new file mode 100644 index 00000000..043737ea --- /dev/null +++ b/check_db.py @@ -0,0 +1,21 @@ +import os +import asyncio +from src.core.database import get_database +from sqlalchemy import text + +async def check_db(): + try: + db = get_database() + session = db.get_session() + async with session: + result = await session.execute(text("SELECT id, symbol, side, order_type, quantity, status, created_at FROM orders ORDER BY id DESC LIMIT 20;")) + rows = result.fetchall() + for row in rows: + print(row) + except Exception as e: + print(f"Error: {e}") + finally: + await db.close() + +if __name__ == "__main__": + asyncio.run(check_db()) diff --git a/config/config.yaml b/config/config.yaml new file mode 100644 index 00000000..3602b6ec --- /dev/null +++ b/config/config.yaml @@ -0,0 +1,94 @@ +app: + name: Crypto Trader + version: 0.1.0 +database: + type: postgresql + url: postgresql+asyncpg://trader:trader_password@localhost/crypto_trader +logging: + level: INFO + dir: ~/.local/share/crypto_trader/logs + retention_days: 30 + rotation: daily +paper_trading: + enabled: true + default_capital: 10000.0 + fee_exchange: coinbase +updates: + check_on_startup: true + repository_url: '' +exchanges: null +strategies: + default_timeframe: 1h +risk: + max_drawdown_percent: 20.0 + daily_loss_limit_percent: 5.0 + position_size_percent: 2.0 +trading: + default_fees: + maker: 0.001 + taker: 0.001 + minimum: 0.0 + exchanges: + coinbase: + fees: + maker: 0.004 + taker: 0.006 + minimum: 0.0 + kraken: + fees: + maker: 0.0016 + taker: 0.0026 + minimum: 0.0 + binance: + fees: + maker: 0.001 + taker: 0.001 + minimum: 0.0 +data_providers: + primary: + - name: kraken + enabled: true + priority: 1 + - name: coinbase + enabled: true + priority: 2 + - name: binance + enabled: true + priority: 3 + fallback: + name: coingecko + enabled: true + api_key: '' + caching: + ticker_ttl: 2 + ohlcv_ttl: 60 + max_cache_size: 1000 + websocket: + enabled: true + reconnect_interval: 5 + ping_interval: 30 +redis: + host: 127.0.0.1 + port: 6379 + db: 0 + password: null + socket_connect_timeout: 5 +celery: + broker_url: redis://127.0.0.1:6379/0 + result_backend: redis://127.0.0.1:6379/0 +autopilot: + intelligent: + min_confidence_threshold: 0.75 + max_trades_per_day: 10 + min_profit_target: 0.02 + enable_auto_execution: true + bootstrap: + days: 5 + timeframe: 1m + min_samples_per_strategy: 30 + symbols: + - ADA/USD +general: + timezone: America/New_York + theme: dark + currency: USD diff --git a/config/logging.yaml b/config/logging.yaml new file mode 100644 index 00000000..875623cd --- /dev/null +++ b/config/logging.yaml @@ -0,0 +1,54 @@ +# Logging Configuration + +version: 1 +disable_existing_loggers: false + +formatters: + standard: + format: '%(asctime)s [%(levelname)s] %(name)s: %(message)s' + datefmt: '%Y-%m-%d %H:%M:%S' + detailed: + format: '%(asctime)s [%(levelname)s] %(name)s:%(lineno)d: %(message)s' + datefmt: '%Y-%m-%d %H:%M:%S' + +handlers: + console: + class: logging.StreamHandler + level: INFO + formatter: standard + stream: ext://sys.stdout + + file: + class: logging.handlers.RotatingFileHandler + level: DEBUG + formatter: detailed + filename: ~/.local/share/crypto_trader/logs/crypto_trader.log + maxBytes: 10485760 # 10MB + backupCount: 5 + encoding: utf-8 + +loggers: + trading: + level: INFO + handlers: [console, file] + propagate: false + + exchange: + level: INFO + handlers: [console, file] + propagate: false + + strategy: + level: DEBUG + handlers: [console, file] + propagate: false + + backtesting: + level: INFO + handlers: [console, file] + propagate: false + +root: + level: INFO + handlers: [console, file] + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..bd5e34a5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +version: '3.8' + +services: + crypto-trader: + build: + context: . + dockerfile: Dockerfile + container_name: crypto-trader + ports: + - "8000:8000" + volumes: + - ./data:/app/data + - ./config:/app/config + environment: + - PYTHONPATH=/app + - PYTHONUNBUFFERED=1 + restart: unless-stopped + healthcheck: + test: ["CMD", "python", "-c", "import requests; requests.get('http://localhost:8000/api/health')"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s diff --git a/docs/api/Makefile b/docs/api/Makefile new file mode 100644 index 00000000..d0c3cbf1 --- /dev/null +++ b/docs/api/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/api/make.bat b/docs/api/make.bat new file mode 100644 index 00000000..747ffb7b --- /dev/null +++ b/docs/api/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/api/source/alerts.rst b/docs/api/source/alerts.rst new file mode 100644 index 00000000..1d6669bb --- /dev/null +++ b/docs/api/source/alerts.rst @@ -0,0 +1,29 @@ +Alert System +============ + +Alert system for price, indicator, risk, and system notifications. + +Alert Engine +------------ + +.. automodule:: src.alerts.engine + :members: + :undoc-members: + :show-inheritance: + +Alert Manager +------------- + +.. automodule:: src.alerts.manager + :members: + :undoc-members: + :show-inheritance: + +Alert Channels +-------------- + +.. automodule:: src.alerts.channels + :members: + :undoc-members: + :show-inheritance: + diff --git a/docs/api/source/backtesting.rst b/docs/api/source/backtesting.rst new file mode 100644 index 00000000..7a6af884 --- /dev/null +++ b/docs/api/source/backtesting.rst @@ -0,0 +1,37 @@ +Backtesting Engine +================== + +The backtesting engine enables strategy evaluation on historical data. + +Backtesting Engine +------------------ + +.. automodule:: src.backtesting.engine + :members: + :undoc-members: + :show-inheritance: + +Performance Metrics +------------------- + +.. automodule:: src.backtesting.metrics + :members: + :undoc-members: + :show-inheritance: + +Slippage and Fees +----------------- + +.. automodule:: src.backtesting.slippage + :members: + :undoc-members: + :show-inheritance: + +Data Provider +------------- + +.. automodule:: src.backtesting.data_provider + :members: + :undoc-members: + :show-inheritance: + diff --git a/docs/api/source/conf.py b/docs/api/source/conf.py new file mode 100644 index 00000000..655ad5fb --- /dev/null +++ b/docs/api/source/conf.py @@ -0,0 +1,80 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +import sys +import os + +# Add the project root to the path +sys.path.insert(0, os.path.abspath('../../..')) + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = 'Crypto Trader' +copyright = '2025, kfox' +author = 'kfox' +release = '0.1.0' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.intersphinx', + 'sphinx.ext.todo', + 'sphinx.ext.viewcode', + 'sphinx.ext.napoleon', # For Google-style docstrings + 'sphinx_autodoc_typehints', # Type hints in docs +] + +templates_path = ['_templates'] +exclude_patterns = [] + +# Napoleon settings for Google-style docstrings +napoleon_google_docstring = True +napoleon_numpy_docstring = False +napoleon_include_init_with_doc = False +napoleon_include_private_with_doc = False +napoleon_include_special_with_doc = True +napoleon_use_admonition_for_examples = False +napoleon_use_admonition_for_notes = False +napoleon_use_admonition_for_references = False +napoleon_use_ivar = False +napoleon_use_param = True +napoleon_use_rtype = True + +# Autodoc settings +autodoc_default_options = { + 'members': True, + 'undoc-members': True, + 'show-inheritance': True, + 'special-members': '__init__', +} + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = 'sphinx_rtd_theme' +html_static_path = ['_static'] +html_theme_options = { + 'collapse_navigation': False, + 'display_version': True, + 'logo_only': False, +} + +# -- Options for intersphinx extension --------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html#configuration + +intersphinx_mapping = { + 'python': ('https://docs.python.org/3', None), + 'pandas': ('https://pandas.pydata.org/docs/', None), + 'sqlalchemy': ('https://docs.sqlalchemy.org/en/latest/', None), + 'ccxt': ('https://docs.ccxt.com/en/latest/', None), +} + +# -- Options for todo extension ---------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/extensions/todo.html#configuration + +todo_include_todos = True diff --git a/docs/api/source/data.rst b/docs/api/source/data.rst new file mode 100644 index 00000000..c05b7759 --- /dev/null +++ b/docs/api/source/data.rst @@ -0,0 +1,94 @@ +Data Collection and Indicators +================================ + +Data collection, storage, and technical indicator calculations. + +Technical Indicators +-------------------- + +.. automodule:: src.data.indicators + :members: + :undoc-members: + :show-inheritance: + +Data Collector +-------------- + +.. automodule:: src.data.collector + :members: + :undoc-members: + :show-inheritance: + +Data Storage +------------ + +.. automodule:: src.data.storage + :members: + :undoc-members: + :show-inheritance: + +Data Quality +------------ + +.. automodule:: src.data.quality + :members: + :undoc-members: + :show-inheritance: + +Pricing Service +--------------- + +The unified pricing service manages multiple data providers with automatic failover. + +.. automodule:: src.data.pricing_service + :members: + :undoc-members: + :show-inheritance: + +Pricing Providers +----------------- + +Base Provider Interface +~~~~~~~~~~~~~~~~~~~~~~~ + +.. automodule:: src.data.providers.base_provider + :members: + :undoc-members: + :show-inheritance: + +CCXT Provider +~~~~~~~~~~~~~ + +.. automodule:: src.data.providers.ccxt_provider + :members: + :undoc-members: + :show-inheritance: + +CoinGecko Provider +~~~~~~~~~~~~~~~~~~ + +.. automodule:: src.data.providers.coingecko_provider + :members: + :undoc-members: + :show-inheritance: + +Cache Manager +------------- + +Intelligent caching system for pricing data. + +.. automodule:: src.data.cache_manager + :members: + :undoc-members: + :show-inheritance: + +Health Monitor +-------------- + +Provider health monitoring and failover management. + +.. automodule:: src.data.health_monitor + :members: + :undoc-members: + :show-inheritance: + diff --git a/docs/api/source/exchanges.rst b/docs/api/source/exchanges.rst new file mode 100644 index 00000000..0eddd9ce --- /dev/null +++ b/docs/api/source/exchanges.rst @@ -0,0 +1,40 @@ +Exchange Adapters +================= + +The exchange adapter system provides a unified interface for trading on multiple cryptocurrency exchanges. + +Base Exchange Adapter +--------------------- + +.. automodule:: src.exchanges.base + :members: + :undoc-members: + :show-inheritance: + +Exchange Factory +---------------- + +.. automodule:: src.exchanges.factory + :members: + :undoc-members: + :show-inheritance: + +Coinbase Exchange +----------------- + +.. automodule:: src.exchanges.coinbase + :members: + :undoc-members: + :show-inheritance: + +WebSocket Support +----------------- + +The Coinbase adapter includes WebSocket subscription methods: + +- ``subscribe_ticker()``: Subscribe to real-time price updates +- ``subscribe_orderbook()``: Subscribe to order book changes +- ``subscribe_trades()``: Subscribe to trade executions + +These methods support real-time data streaming for UI updates. + diff --git a/docs/api/source/index.rst b/docs/api/source/index.rst new file mode 100644 index 00000000..f03d37ba --- /dev/null +++ b/docs/api/source/index.rst @@ -0,0 +1,27 @@ +Crypto Trader API Documentation +================================ + +Welcome to the Crypto Trader API documentation. This documentation provides comprehensive reference for all public APIs, classes, and functions in the Crypto Trader application. + +.. toctree:: + :maxdepth: 2 + :caption: API Reference: + + modules + exchanges + strategies + trading + backtesting + portfolio + risk + data + market_data + alerts + security + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/api/source/market_data.rst b/docs/api/source/market_data.rst new file mode 100644 index 00000000..3be0002a --- /dev/null +++ b/docs/api/source/market_data.rst @@ -0,0 +1,312 @@ +Market Data API +=============== + +REST API endpoints for market data and pricing information. + +Market Data Endpoints +--------------------- + +OHLCV Data +~~~~~~~~~~ + +**GET** ``/api/market-data/ohlcv/{symbol}`` + +Get OHLCV (Open, High, Low, Close, Volume) candlestick data for a symbol. + +**Parameters:** + +- ``symbol`` (path, required): Trading pair symbol (e.g., "BTC/USD") +- ``timeframe`` (query, optional): Timeframe for candles (default: "1h") + - Valid values: "1m", "5m", "15m", "30m", "1h", "4h", "1d", "1w" +- ``limit`` (query, optional): Number of candles to return (default: 100, max: 1000) +- ``exchange`` (query, optional): Exchange name (deprecated, uses pricing service) + +**Response:** + +.. code-block:: json + + [ + { + "time": 1609459200, + "open": 50000.0, + "high": 51000.0, + "low": 49000.0, + "close": 50000.0, + "volume": 1000.0 + } + ] + +**Example:** + +.. code-block:: bash + + curl http://localhost:8000/api/market-data/ohlcv/BTC%2FUSD?timeframe=1h&limit=100 + +Ticker Data +~~~~~~~~~~~ + +**GET** ``/api/market-data/ticker/{symbol}`` + +Get current ticker data for a symbol, including price, volume, and provider information. + +**Parameters:** + +- ``symbol`` (path, required): Trading pair symbol (e.g., "BTC/USD") + +**Response:** + +.. code-block:: json + + { + "symbol": "BTC/USD", + "bid": 50000.0, + "ask": 50001.0, + "last": 50000.5, + "high": 51000.0, + "low": 49000.0, + "volume": 1000000.0, + "timestamp": 1609459200000, + "provider": "CCXT-Kraken" + } + +**Example:** + +.. code-block:: bash + + curl http://localhost:8000/api/market-data/ticker/BTC%2FUSD + +Provider Health +~~~~~~~~~~~~~~~ + +**GET** ``/api/market-data/providers/health`` + +Get health status for pricing providers. + +**Parameters:** + +- ``provider`` (query, optional): Specific provider name to get health for + +**Response:** + +.. code-block:: json + + { + "active_provider": "CCXT-Kraken", + "health": { + "CCXT-Kraken": { + "status": "healthy", + "last_check": "2025-01-01T12:00:00Z", + "last_success": "2025-01-01T12:00:00Z", + "success_count": 100, + "failure_count": 2, + "avg_response_time": 0.123, + "consecutive_failures": 0, + "circuit_breaker_open": false + } + } + } + +**Example:** + +.. code-block:: bash + + curl http://localhost:8000/api/market-data/providers/health?provider=CCXT-Kraken + +Provider Status +~~~~~~~~~~~~~~~ + +**GET** ``/api/market-data/providers/status`` + +Get detailed status for all pricing providers, including cache statistics. + +**Response:** + +.. code-block:: json + + { + "active_provider": "CCXT-Kraken", + "providers": { + "CCXT-Kraken": { + "status": "healthy", + "last_check": "2025-01-01T12:00:00Z", + "success_count": 100, + "failure_count": 2, + "avg_response_time": 0.123 + }, + "CoinGecko": { + "status": "unknown", + "success_count": 0, + "failure_count": 0 + } + }, + "cache": { + "size": 50, + "max_size": 1000, + "hits": 1000, + "misses": 200, + "hit_rate": 83.33, + "evictions": 0, + "avg_age_seconds": 30.5 + } + } + +**Example:** + +.. code-block:: bash + + curl http://localhost:8000/api/market-data/providers/status + +Provider Configuration +~~~~~~~~~~~~~~~~~~~~~~ + +**GET** ``/api/market-data/providers/config`` + +Get current provider configuration. + +**Response:** + +.. code-block:: json + + { + "primary": [ + { + "name": "kraken", + "enabled": true, + "priority": 1 + }, + { + "name": "coinbase", + "enabled": true, + "priority": 2 + } + ], + "fallback": { + "name": "coingecko", + "enabled": true, + "api_key": "" + }, + "caching": { + "ticker_ttl": 2, + "ohlcv_ttl": 60, + "max_cache_size": 1000 + }, + "websocket": { + "enabled": true, + "reconnect_interval": 5, + "ping_interval": 30 + } + } + +**Example:** + +.. code-block:: bash + + curl http://localhost:8000/api/market-data/providers/config + +Update Provider Configuration +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +**PUT** ``/api/market-data/providers/config`` + +Update provider configuration. + +**Request Body:** + +.. code-block:: json + + { + "primary": [ + { + "name": "kraken", + "enabled": true, + "priority": 1 + } + ], + "caching": { + "ticker_ttl": 5, + "ohlcv_ttl": 120 + } + } + +**Response:** + +.. code-block:: json + + { + "message": "Configuration updated successfully", + "config": { + "primary": [...], + "fallback": {...}, + "caching": {...}, + "websocket": {...} + } + } + +**Example:** + +.. code-block:: bash + + curl -X PUT http://localhost:8000/api/market-data/providers/config \ + -H "Content-Type: application/json" \ + -d '{"caching": {"ticker_ttl": 5}}' + +WebSocket Updates +----------------- + +The WebSocket endpoint at ``/ws`` broadcasts real-time price updates. + +**Connection:** + +.. code-block:: javascript + + const ws = new WebSocket('ws://localhost:8000/ws'); + ws.onopen = () => { + // Subscribe to symbol updates + ws.send(JSON.stringify({ + type: 'subscribe', + symbol: 'BTC/USD' + })); + }; + + ws.onmessage = (event) => { + const message = JSON.parse(event.data); + if (message.type === 'price_update') { + console.log('Price update:', message); + } + }; + +**Message Types:** + +- ``subscribe``: Subscribe to price updates for a symbol +- ``unsubscribe``: Unsubscribe from price updates +- ``price_update``: Price update broadcast (contains exchange, symbol, price, timestamp) +- ``provider_status_update``: Provider status change notification + +**Price Update Message:** + +.. code-block:: json + + { + "type": "price_update", + "exchange": "pricing", + "symbol": "BTC/USD", + "price": "50000.50", + "timestamp": "2025-01-01T12:00:00Z" + } + +Error Responses +--------------- + +All endpoints may return standard HTTP error codes: + +- ``400 Bad Request``: Invalid request parameters +- ``404 Not Found``: Symbol or provider not found +- ``500 Internal Server Error``: Server error + +Error response format: + +.. code-block:: json + + { + "detail": "Error message describing what went wrong" + } diff --git a/docs/api/source/modules.rst b/docs/api/source/modules.rst new file mode 100644 index 00000000..3080d609 --- /dev/null +++ b/docs/api/source/modules.rst @@ -0,0 +1,18 @@ +Modules +===== + +.. automodule:: src.core + :members: + :undoc-members: + :show-inheritance: + +Core Modules +------------ + +.. toctree:: + :maxdepth: 2 + + core/config + core/database + core/logger + diff --git a/docs/api/source/portfolio.rst b/docs/api/source/portfolio.rst new file mode 100644 index 00000000..62bc6afc --- /dev/null +++ b/docs/api/source/portfolio.rst @@ -0,0 +1,21 @@ +Portfolio Management +==================== + +Portfolio tracking and analytics for monitoring trading performance. + +Portfolio Tracker +----------------- + +.. automodule:: src.portfolio.tracker + :members: + :undoc-members: + :show-inheritance: + +Portfolio Analytics +------------------- + +.. automodule:: src.portfolio.analytics + :members: + :undoc-members: + :show-inheritance: + diff --git a/docs/api/source/risk.rst b/docs/api/source/risk.rst new file mode 100644 index 00000000..1ceaa641 --- /dev/null +++ b/docs/api/source/risk.rst @@ -0,0 +1,37 @@ +Risk Management +=============== + +Risk management system for controlling trading exposure and losses. + +Risk Manager +------------ + +.. automodule:: src.risk.manager + :members: + :undoc-members: + :show-inheritance: + +Stop Loss +--------- + +.. automodule:: src.risk.stop_loss + :members: + :undoc-members: + :show-inheritance: + +Position Sizing +--------------- + +.. automodule:: src.risk.position_sizing + :members: + :undoc-members: + :show-inheritance: + +Risk Limits +----------- + +.. automodule:: src.risk.limits + :members: + :undoc-members: + :show-inheritance: + diff --git a/docs/api/source/security.rst b/docs/api/source/security.rst new file mode 100644 index 00000000..56c2a8e7 --- /dev/null +++ b/docs/api/source/security.rst @@ -0,0 +1,29 @@ +Security +======== + +Security features for API key management and encryption. + +Encryption +---------- + +.. automodule:: src.security.encryption + :members: + :undoc-members: + :show-inheritance: + +Key Manager +----------- + +.. automodule:: src.security.key_manager + :members: + :undoc-members: + :show-inheritance: + +Audit Logging +------------- + +.. automodule:: src.security.audit + :members: + :undoc-members: + :show-inheritance: + diff --git a/docs/api/source/strategies.rst b/docs/api/source/strategies.rst new file mode 100644 index 00000000..01f8d870 --- /dev/null +++ b/docs/api/source/strategies.rst @@ -0,0 +1,120 @@ +Strategy Framework +================== + +The strategy framework enables creation and management of trading strategies. + +Base Strategy +------------- + +.. automodule:: src.strategies.base + :members: + :undoc-members: + :show-inheritance: + +Strategy Registry +----------------- + +.. automodule:: src.strategies.base + :members: StrategyRegistry + :undoc-members: + :show-inheritance: + +Technical Strategies +-------------------- + +RSI Strategy +~~~~~~~~~~~ + +.. automodule:: src.strategies.technical.rsi_strategy + :members: + :undoc-members: + :show-inheritance: + +MACD Strategy +~~~~~~~~~~~~~ + +.. automodule:: src.strategies.technical.macd_strategy + :members: + :undoc-members: + :show-inheritance: + +Moving Average Strategy +~~~~~~~~~~~~~~~~~~~~~~~~ + +.. automodule:: src.strategies.technical.moving_avg_strategy + :members: + :undoc-members: + :show-inheritance: + +DCA Strategy +~~~~~~~~~~~~ + +.. automodule:: src.strategies.dca.dca_strategy + :members: + :undoc-members: + :show-inheritance: + +Grid Strategy +~~~~~~~~~~~~~ + +.. automodule:: src.strategies.grid.grid_strategy + :members: + :undoc-members: + :show-inheritance: + +Momentum Strategy +~~~~~~~~~~~~~~~~~ + +.. automodule:: src.strategies.momentum.momentum_strategy + :members: + :undoc-members: + :show-inheritance: + +Confirmed Strategy +~~~~~~~~~~~~~~~~~~ + +.. automodule:: src.strategies.technical.confirmed_strategy + :members: + :undoc-members: + :show-inheritance: + +Divergence Strategy +~~~~~~~~~~~~~~~~~~~ + +.. automodule:: src.strategies.technical.divergence_strategy + :members: + :undoc-members: + :show-inheritance: + +Bollinger Mean Reversion Strategy +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. automodule:: src.strategies.technical.bollinger_mean_reversion + :members: + :undoc-members: + :show-inheritance: + +Consensus Strategy (Ensemble) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. automodule:: src.strategies.ensemble.consensus_strategy + :members: + :undoc-members: + :show-inheritance: + +Timeframe Manager +----------------- + +.. automodule:: src.strategies.timeframe_manager + :members: + :undoc-members: + :show-inheritance: + +Strategy Scheduler +---------------- + +.. automodule:: src.strategies.scheduler + :members: + :undoc-members: + :show-inheritance: + diff --git a/docs/api/source/trading.rst b/docs/api/source/trading.rst new file mode 100644 index 00000000..503e748d --- /dev/null +++ b/docs/api/source/trading.rst @@ -0,0 +1,45 @@ +Trading Engine +============== + +The trading engine handles order execution, position management, and exchange interactions. + +Trading Engine +-------------- + +.. automodule:: src.trading.engine + :members: + :undoc-members: + :show-inheritance: + +Order Manager +------------- + +.. automodule:: src.trading.order_manager + :members: + :undoc-members: + :show-inheritance: + +Paper Trading +------------- + +.. automodule:: src.trading.paper_trading + :members: + :undoc-members: + :show-inheritance: + +Advanced Orders +--------------- + +.. automodule:: src.trading.advanced_orders + :members: + :undoc-members: + :show-inheritance: + +Futures Trading +--------------- + +.. automodule:: src.trading.futures + :members: + :undoc-members: + :show-inheritance: + diff --git a/docs/architecture/autopilot.md b/docs/architecture/autopilot.md new file mode 100644 index 00000000..2d9178ae --- /dev/null +++ b/docs/architecture/autopilot.md @@ -0,0 +1,378 @@ +# Autopilot Architecture + +This document describes the architecture of the Autopilot system, including both Pattern-Based and ML-Based modes. + +## Overview + +The Autopilot system provides autonomous trading signal generation using an **Intelligent Autopilot** powered by machine learning-based strategy selection. + +## Architecture Diagram + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Unified Autopilot API │ +│ (/api/autopilot/start-unified) │ +└───────────────────────┬─────────────────────────────────────┘ + │ + ┌───────────────┴───────────────┐ + │ │ + ▼ ▼ +┌───────────────────┐ ┌───────────────────┐ +│ ML-Based │ │ Signal Display │ +│ Autopilot │ │ (Dashboard) │ +├───────────────────┤ └───────────────────┘ +│ MarketAnalyzer │ +│ StrategySelector │ +│ PerformanceTracker│ +└───────────────────┘ + │ + ▼ +┌───────────────────────┐ +│ Signal Generation │ +│ (StrategySignal) │ +└───────────────────────┘ + │ + ▼ +┌───────────────────┐ +│ Auto-Execution │ +│ (Optional) │ +└───────────────────┘ +``` + +## ML-Based Autopilot Flow + +> [!NOTE] +> **Global Model Architecture**: The system uses a single "Global Model" trained on data from *all* configured symbols. This allows for shared learning (patterns from BTC help predict ETH) and efficient resource usage. + +> [!IMPORTANT] +> **Single-Asset Focus**: The current ML model is designed for **single-asset strategies** (e.g., Momentum, RSI, Grid). It analyzes each coin in isolation. It does **NOT** currently support multi-asset strategies like **Arbitrage**, which require analyzing correlations between multiple coins simultaneously. + +``` +Market Data (OHLCV) + │ + ▼ +┌───────────────────┐ +│ MarketAnalyzer │ +│ - Analyze │ +│ conditions │ +│ - Determine │ +│ regime │ +└─────────┬─────────┘ + │ + ▼ +┌───────────────────┐ +│ StrategySelector │ +│ - Global ML Model │ +│ - Select best │ +│ strategy │ +│ - Calculate │ +│ confidence │ +└─────────┬─────────┘ + │ + ▼ +┌───────────────────┐ +│ Selected Strategy │ +│ - Generate signal│ +│ - Calculate size │ +└─────────┬─────────┘ + │ + ▼ + StrategySignal +``` + +## API Endpoints + +### Unified Endpoints + +- `POST /api/autopilot/start-unified` - Start autopilot +- `POST /api/autopilot/stop-unified` - Stop autopilot +- `GET /api/autopilot/status-unified/{symbol}` - Get autopilot status + +### Legacy Endpoints (Removed) + +- `POST /api/autopilot/start` - (Removed) +- `POST /api/autopilot/intelligent/start` - (Removed) + +## Mode Selection Logic + +The unified endpoints now exclusively support the "intelligent" mode. Code handling other modes has been removed. + +```python +def start_unified_autopilot(config: UnifiedAutopilotConfig): + # Always starts intelligent autopilot + autopilot = get_intelligent_autopilot( + symbol=config.symbol, + exchange_id=config.exchange_id, + timeframe=config.timeframe, + interval=config.interval, + paper_trading=config.paper_trading + ) + + if config.auto_execute: + autopilot.enable_auto_execution = True + + autopilot.start() +``` + +## Auto-Execution Integration + +Auto-execution is integrated directly into the ML-based workflow: + +```python +# Auto-Execution +if auto_execute and signal and confidence > threshold: + trading_engine.execute_order( + symbol=signal.symbol, + side=signal.signal_type, + quantity=strategy.calculate_position_size(signal, balance), + ... + ) +``` + +## Pre-Flight Order Validation + +Before submitting orders, the autopilot validates that the order will succeed: + +```python +async def _can_execute_order(side, quantity, price) -> Tuple[bool, str]: + # 1. Check minimum order value ($1 USD) + if order_value < 1.0: + return False, "Order value below minimum" + + # 2. For BUY: check sufficient balance (with fee buffer) + if side == BUY: + if balance < (order_value + fee_estimate): + return False, "Insufficient funds" + + # 3. For SELL: check position exists + if side == SELL: + if no_position: + return False, "No position to sell" + + return True, "OK" +``` + +**Benefits:** +- Prevents creation of PENDING orders that would be REJECTED +- Cleaner order history (no garbage orders) +- Faster execution (skip validation at execution layer) + +## Smart Order Type Selection + +The autopilot intelligently chooses between LIMIT and MARKET orders: + +| Scenario | Order Type | Rationale | +|----------|-----------|-----------| +| Strong signal (>80%) | MARKET | High confidence, execute now | +| Normal BUY signal | LIMIT (-0.1%) | Get better entry, pay maker fees | +| Take-profit SELL | LIMIT (+0.1%) | Get better exit price | +| Stop-loss SELL | MARKET | Urgent exit, protect capital | + +```python +def _determine_order_type_and_price(side, signal_strength, price, is_stop_loss): + # Strong signals or stop-losses use MARKET + if signal_strength > 0.8 or is_stop_loss: + return MARKET, None + + # Normal signals use LIMIT with 0.1% better price + if side == BUY: + limit_price = price * 0.999 # 0.1% below market + else: + limit_price = price * 1.001 # 0.1% above market + + return LIMIT, limit_price +``` + +**Stop-Loss vs Take-Profit Detection:** +- If `current_price < entry_price`: Stop-loss (use MARKET) +- If `current_price > entry_price`: Take-profit (use LIMIT) + +## Data Flow + +1. **Market Data Collection**: OHLCV data from database +2. **Market Analysis**: MarketAnalyzer determines conditions +3. **Strategy Selection**: ML model selects best strategy +4. **Signal Generation**: Selected strategy generates signal +5. **Opportunity Evaluation**: Check confidence and criteria +6. **Execution** (if enabled): Execute trade +7. **Learning**: Record trade for model improvement + +## Component Responsibilities + +- **MarketAnalyzer**: Analyzes market conditions and determines regime +- **StrategySelector**: ML model for selecting optimal strategy +- **PerformanceTracker**: Tracks strategy performance for learning +- **IntelligentAutopilot**: Orchestrates ML-based selection and execution + +## Configuration + +```python +{ + "symbol": "BTC/USD", + "exchange_id": 1, + "timeframe": "1h", + "interval": 60.0, # Analysis cycle in seconds + "paper_trading": True, + "min_confidence_threshold": 0.75, + "max_trades_per_day": 10, + "auto_execute": False +} +``` + +## State Management + +- `_running`: Boolean flag +- `_last_analysis`: Last analysis result +- `_selected_strategy`: Currently selected strategy +- `_trades_today`: Trade count for daily limit +- `_ohlcv_data`: Current market data +- `_strategy_instances`: Cached strategy instances +- `_intelligent_autopilots`: Global registry of active instances + +## Error Handling + +Both modes implement error handling: + +- **Market Data Errors**: Fallback to cached data or skip cycle +- **Pattern Detection Errors**: Return no pattern, continue monitoring +- **Sentiment Analysis Errors**: Use neutral sentiment, continue +- **ML Model Errors**: Fallback to rule-based selection +- **Execution Errors**: Log error, continue monitoring + +## Performance Considerations + +- **Moderate**: ML model adds overhead but is optimized for speed +- **Adaptive**: Performance improves with more training data +- **Scalable**: Model can handle multiple symbols with shared learning + +## ML Model Training + +The ML model training system supports background training via Celery with configurable parameters. + +### Training Configuration + +Training is configured via the Bootstrap Config API and persisted in the application config: + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `days` | Historical data (days) to fetch | 90 | +| `timeframe` | OHLCV candle timeframe | `1h` | +| `min_samples_per_strategy` | Minimum samples needed per strategy | 10 | +| `symbols` | Cryptocurrencies to train on | `["BTC/USD", "ETH/USD"]` | + +### API Endpoints + +``` +GET /api/autopilot/bootstrap-config # Get current config +PUT /api/autopilot/bootstrap-config # Update config +POST /api/autopilot/intelligent/retrain # Trigger retraining +GET /api/autopilot/tasks/{task_id} # Poll training status +GET /api/autopilot/intelligent/model-info # Get model info +POST /api/autopilot/intelligent/reset # Reset model +``` + +### Background Training Architecture + +``` +┌─────────────────┐ ┌──────────────┐ ┌─────────────────┐ +│ Settings Page │────▶│ FastAPI │────▶│ Celery Worker │ +│ (React) │ │ Backend │ │ (train_task) │ +└─────────────────┘ └──────────────┘ └─────────────────┘ + │ │ │ + │ Poll status │ get_config() │ Reset singletons + │◀─────────────────────│ │ Bootstrap data + │ │ │ Train model + │ │ │ Save to disk + │ │◀─────────────────────│ + │ SUCCESS + metrics │ │ + │◀─────────────────────│ │ + │ │ │ + ▼ ▼ ▼ + UI shows results Model auto-loads Model file saved +``` + +### Training Task Flow + +1. **Singleton Reset**: Reset `StrategySelector`, `PerformanceTracker`, and `Database` singletons to prevent async conflicts +2. **Check Existing Data**: Query performance tracker for existing training samples +3. **Bootstrap (if needed)**: Fetch OHLCV data for each symbol with progress updates +4. **Train Model**: Train ML model with cross-validation and walk-forward validation +5. **Save Model**: Persist trained model to `~/.local/share/crypto_trader/models/` +6. **Return Metrics**: Return training accuracy and model metadata + +### Model Auto-Reload + +The `StrategySelector.get_model_info()` method automatically checks for new model files: + +```python +def get_model_info(self): + # Always check if a newer model is available on disk + self._try_load_saved_model() # Compare timestamps, reload if newer + return {...} +``` + +This ensures the API process picks up newly trained models without restart. + +### Error Handling + +- **Celery Task Errors**: Serialized to JSON with traceback for frontend display +- **Polling Errors**: Frontend stops polling after 3 consecutive failures +- **Connection Errors**: Singletons reset to prevent `asyncpg.InterfaceError` + +## Strategy Grouping + +To improve ML accuracy, individual strategies are grouped into 5 logical categories. The model predicts the best **group**, then rule-based logic selects the optimal strategy within that group. + +### Strategy Groups + +| Group | Strategies | Market Conditions | +|-------|-----------|-------------------| +| **Trend Following** | moving_average, macd, confirmed | Strong trends (ADX > 25) | +| **Mean Reversion** | rsi, bollinger_mean_reversion, grid, divergence | Ranging markets, oversold/overbought | +| **Momentum** | momentum, volatility_breakout | High volume spikes, breakouts | +| **Market Making** | market_making, dca | Low volatility, stable markets | +| **Sentiment Based** | sentiment, pairs_trading, consensus | External signals available | + +### Benefits + +- **Reduced Classes**: 5 groups vs 14 strategies (~20% random baseline vs ~7%) +- **Better Generalization**: Model learns group characteristics rather than memorizing individual strategies +- **Combined Confidence**: `group_confidence × strategy_confidence` for final score + +## Improved Bootstrap Sampling + +Training data is collected using intelligent sampling rather than fixed intervals: + +### Sampling Strategies + +1. **Regime-Change Detection**: Sample when market regime changes (e.g., trending → ranging) +2. **Minimum Time Spacing**: 24-hour gaps between samples for 1h timeframe +3. **Periodic Sampling**: Every 48 hours regardless of regime changes + +### Timeframe Spacing + +| Timeframe | Min Spacing (candles) | Actual Time | +|-----------|----------------------|-------------| +| 1m | 1440 | 24 hours | +| 15m | 96 | 24 hours | +| 1h | 24 | 24 hours | +| 4h | 6 | 24 hours | +| 1d | 1 | 24 hours | + +## Security Considerations + +- **Auto-Execution**: Requires explicit user confirmation +- **Paper Trading**: Default mode for safety +- **Risk Limits**: Enforced regardless of mode +- **API Keys**: Encrypted storage +- **Audit Logging**: All autopilot actions logged + +## Future Enhancements + +- Hybrid mode combining both approaches +- Real-time mode switching +- Multi-symbol autopilot management +- Advanced risk management integration +- Performance analytics dashboard +- Persistent training configuration (file/database storage) + diff --git a/docs/architecture/backtesting.md b/docs/architecture/backtesting.md new file mode 100644 index 00000000..f148f012 --- /dev/null +++ b/docs/architecture/backtesting.md @@ -0,0 +1,135 @@ +# Backtesting Engine Architecture + +This document describes the backtesting engine design. + +## Backtesting Components + +``` +Backtesting Engine + ├──► Data Provider + │ │ + │ └──► Historical Data Loading + │ + ├──► Strategy Execution + │ │ + │ ├──► Data Replay + │ ├──► Signal Generation + │ └──► Order Simulation + │ + ├──► Realism Models + │ │ + │ ├──► Slippage Model + │ ├──► Fee Model + │ └──► Order Book Simulation + │ + └──► Metrics Calculation + │ + ├──► Performance Metrics + └──► Risk Metrics +``` + +## Data Replay + +Historical data is replayed chronologically: + +``` +Historical Data (Time Series) + │ + ▼ +Time-based Iteration + │ + ├──► For each timestamp: + │ │ + │ ├──► Update market data + │ ├──► Notify strategies + │ ├──► Process signals + │ └──► Execute orders + │ + └──► Continue until end date +``` + +## Order Simulation + +Simulated order execution: + +``` +Order Request + │ + ▼ +Order Type Check + │ + ├──► Market Order + │ │ + │ └──► Execute at current price + slippage + │ + └──► Limit Order + │ + └──► Wait for price to reach limit + │ + └──► Execute when filled +``` + +## Slippage Modeling + +Realistic slippage simulation: + +``` +Market Order + │ + ▼ +Current Price + │ + ├──► Buy Order: Price + Slippage + └──► Sell Order: Price - Slippage + │ + └──► Add Market Impact (for large orders) +``` + +## Fee Modeling + +Exchange fee calculation: + +``` +Order Execution + │ + ▼ +Order Type Check + │ + ├──► Maker Order (Limit) + │ │ + │ └──► Apply Maker Fee + │ + └──► Taker Order (Market) + │ + └──► Apply Taker Fee +``` + +## Performance Metrics + +Calculated metrics: + +- **Total Return**: (Final Capital - Initial Capital) / Initial Capital +- **Sharpe Ratio**: (Return - Risk-free Rate) / Volatility +- **Sortino Ratio**: (Return - Risk-free Rate) / Downside Deviation +- **Max Drawdown**: Largest peak-to-trough decline +- **Win Rate**: Winning Trades / Total Trades +- **Profit Factor**: Gross Profit / Gross Loss + +## Parameter Optimization + +Optimization methods: + +- **Grid Search**: Test all parameter combinations +- **Genetic Algorithm**: Evolutionary optimization +- **Bayesian Optimization**: Efficient parameter search + +## Backtest Results + +Stored results: + +- Performance metrics +- Trade history +- Equity curve +- Drawdown chart +- Parameter values + diff --git a/docs/architecture/data_flow.md b/docs/architecture/data_flow.md new file mode 100644 index 00000000..9ab76938 --- /dev/null +++ b/docs/architecture/data_flow.md @@ -0,0 +1,233 @@ +# Data Flow Architecture + +This document describes how data flows through the Crypto Trader system. + +## Real-Time Data Flow + +``` +Exchange WebSocket + │ + ▼ +Exchange Adapter + │ + ▼ +Data Collector + │ + ├──► Data Quality Validation + │ + ├──► Data Storage (Database) + │ + └──► Timeframe Manager + │ + ├──► Strategy 1 (1h timeframe) + ├──► Strategy 2 (15m timeframe) + └──► Strategy 3 (1d timeframe) + │ + ▼ + Signal Generation + │ + ▼ + Trading Engine +``` + +## Trading Signal Flow + +``` +Market Data Update + │ + ▼ +Strategy.on_data() + │ + ▼ +Indicator Calculation + │ + ▼ +Signal Generation + │ + ▼ +Trading Engine + │ + ├──► Risk Manager (Pre-trade Check) + │ │ + │ ├──► Position Sizing + │ ├──► Drawdown Check + │ └──► Daily Loss Check + │ + ▼ +Order Manager + │ + ├──► Paper Trading (if enabled) + │ │ + │ └──► Paper Trading Simulator + │ + └──► Live Trading + │ + ▼ + Exchange Adapter + │ + ▼ + Exchange API + │ + ▼ + Order Execution + │ + ▼ + Order Manager (Update Status) + │ + ▼ + Position Tracker + │ + ▼ + Portfolio Analytics +``` + +## Backtesting Data Flow + +``` +Historical Data Provider + │ + ▼ +Backtesting Engine + │ + ├──► Data Replay (Time-based) + │ + ├──► Strategy Execution + │ │ + │ ├──► Signal Generation + │ │ + │ └──► Order Simulation + │ │ + │ ├──► Slippage Model + │ ├──► Fee Model + │ └──► Order Book Simulation + │ + └──► Performance Calculation + │ + ├──► Metrics Calculation + │ │ + │ ├──► Returns + │ ├──► Sharpe Ratio + │ ├──► Sortino Ratio + │ └──► Drawdown + │ + └──► Results Storage +``` + +## Portfolio Update Flow + +``` +Trade Execution + │ + ▼ +Position Update + │ + ├──► Position Tracker + │ │ + │ ├──► Update Quantity + │ ├──► Update Entry Price + │ └──► Calculate P&L + │ + └──► Portfolio Analytics + │ + ├──► Recalculate Metrics + │ │ + │ ├──► Total Value + │ ├──► Unrealized P&L + │ ├──► Realized P&L + │ └──► Performance Metrics + │ + └──► Alert System + │ + └──► Risk Alerts (if triggered) +``` + +## Data Storage Flow + +``` +Data Collection + │ + ▼ +Data Quality Check + │ + ├──► Valid Data + │ │ + │ └──► Database Storage + │ │ + │ ├──► Market Data Table + │ ├──► Trades Table + │ └──► Positions Table + │ + └──► Invalid Data + │ + └──► Error Logging + │ + └──► Gap Detection + │ + └──► Gap Filling (if enabled) +``` + +## Multi-Timeframe Synchronization + +``` +1m Data Stream + │ + ├──► Strategy (1m) + │ + └──► Resample to 5m + │ + ├──► Strategy (5m) + │ + └──► Resample to 1h + │ + ├──► Strategy (1h) + │ + └──► Resample to 1d + │ + └──► Strategy (1d) +``` + +## Alert Flow + +``` +Data Update / Event + │ + ▼ +Alert Engine + │ + ├──► Check Alert Conditions + │ │ + │ ├──► Price Alerts + │ ├──► Indicator Alerts + │ ├──► Risk Alerts + │ └──► System Alerts + │ + └──► Trigger Alert (if condition met) + │ + ├──► Desktop Notification + ├──► Sound Alert + └──► Email Notification (if configured) +``` + +## Error Recovery Flow + +``` +Error Detected + │ + ▼ +Error Recovery System + │ + ├──► Log Error + │ + ├──► Attempt Recovery + │ │ + │ ├──► Retry Operation + │ ├──► Fallback Mechanism + │ └──► State Restoration + │ + └──► Health Monitor + │ + ├──► Check System Health + │ + └──► Auto-restart (if critical) +``` + diff --git a/docs/architecture/database_schema.md b/docs/architecture/database_schema.md new file mode 100644 index 00000000..67e51066 --- /dev/null +++ b/docs/architecture/database_schema.md @@ -0,0 +1,288 @@ +# Database Schema + +This document describes the database schema for Crypto Trader. + +## Database Options + +- **SQLite**: Default, bundled, zero configuration +- **PostgreSQL**: Optional, for advanced users with large datasets + +## Core Tables + +### Exchanges + +Stores exchange configuration and credentials. + +```sql +CREATE TABLE exchanges ( + id INTEGER PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + api_key TEXT, -- Encrypted + secret_key TEXT, -- Encrypted + password TEXT, -- Encrypted (for some exchanges) + api_permissions TEXT DEFAULT 'read_only', + is_enabled BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP +); +``` + +### Strategies + +Stores trading strategy configuration. + +```sql +CREATE TABLE strategies ( + id INTEGER PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + description TEXT, + strategy_type TEXT NOT NULL, + parameters TEXT, -- JSON + is_enabled BOOLEAN DEFAULT FALSE, + is_paper_trading BOOLEAN DEFAULT TRUE, + exchange_id INTEGER REFERENCES exchanges(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP +); +``` + +### Trades + +Stores executed trades. + +```sql +CREATE TABLE trades ( + id INTEGER PRIMARY KEY, + order_id TEXT UNIQUE NOT NULL, + symbol TEXT NOT NULL, + side TEXT NOT NULL, -- 'buy' or 'sell' + type TEXT NOT NULL, -- 'market', 'limit', etc. + price REAL, + amount REAL, + cost REAL, + fee REAL DEFAULT 0.0, + status TEXT DEFAULT 'open', + is_paper_trade BOOLEAN DEFAULT TRUE, + executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + exchange_id INTEGER REFERENCES exchanges(id), + strategy_id INTEGER REFERENCES strategies(id) +); +``` + +### Positions + +Stores open and closed positions. + +```sql +CREATE TABLE positions ( + id INTEGER PRIMARY KEY, + symbol TEXT NOT NULL, + exchange_id INTEGER REFERENCES exchanges(id), + quantity REAL NOT NULL, + entry_price REAL NOT NULL, + current_price REAL, + unrealized_pnl REAL, + realized_pnl REAL, + is_open BOOLEAN DEFAULT TRUE, + position_type TEXT DEFAULT 'spot', -- 'spot', 'futures', 'margin' + leverage REAL DEFAULT 1.0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP, + UNIQUE(symbol, exchange_id, position_type) +); +``` + +### Orders + +Stores order history and status. + +```sql +CREATE TABLE orders ( + id INTEGER PRIMARY KEY, + exchange_order_id TEXT UNIQUE NOT NULL, + client_order_id TEXT, + symbol TEXT NOT NULL, + side TEXT NOT NULL, + type TEXT NOT NULL, + price REAL, + amount REAL, + filled_amount REAL DEFAULT 0.0, + remaining_amount REAL DEFAULT 0.0, + cost REAL DEFAULT 0.0, + status TEXT DEFAULT 'pending', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP, + exchange_id INTEGER REFERENCES exchanges(id), + strategy_id INTEGER REFERENCES strategies(id), + is_paper_trade BOOLEAN DEFAULT TRUE +); +``` + +### Market Data + +Stores historical OHLCV data. + +```sql +CREATE TABLE market_data ( + id INTEGER PRIMARY KEY, + symbol TEXT NOT NULL, + timeframe TEXT NOT NULL, + timestamp TIMESTAMP NOT NULL, + open REAL NOT NULL, + high REAL NOT NULL, + low REAL NOT NULL, + close REAL NOT NULL, + volume REAL NOT NULL, + exchange_id INTEGER REFERENCES exchanges(id), + UNIQUE(symbol, timeframe, timestamp) +); +``` + +### Portfolio Snapshots + +Stores portfolio snapshots over time. + +```sql +CREATE TABLE portfolio_snapshots ( + id INTEGER PRIMARY KEY, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + total_value REAL NOT NULL, + cash_balance REAL NOT NULL, + asset_holdings TEXT, -- JSON + exchange_id INTEGER REFERENCES exchanges(id) +); +``` + +### Backtest Results + +Stores backtesting results. + +```sql +CREATE TABLE backtest_results ( + id INTEGER PRIMARY KEY, + strategy_id INTEGER REFERENCES strategies(id), + start_date TIMESTAMP NOT NULL, + end_date TIMESTAMP NOT NULL, + initial_capital REAL NOT NULL, + final_capital REAL NOT NULL, + total_return REAL NOT NULL, + sharpe_ratio REAL, + sortino_ratio REAL, + max_drawdown REAL, + win_rate REAL, + other_metrics TEXT, -- JSON + run_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +### Risk Limits + +Stores risk management limits. + +```sql +CREATE TABLE risk_limits ( + id INTEGER PRIMARY KEY, + strategy_id INTEGER REFERENCES strategies(id), + exchange_id INTEGER REFERENCES exchanges(id), + limit_type TEXT NOT NULL, + value REAL NOT NULL, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP +); +``` + +### Alerts + +Stores alert configurations. + +```sql +CREATE TABLE alerts ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + alert_type TEXT NOT NULL, + condition TEXT NOT NULL, + is_active BOOLEAN DEFAULT TRUE, + triggered_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +### Rebalancing Events + +Stores portfolio rebalancing history. + +```sql +CREATE TABLE rebalancing_events ( + id INTEGER PRIMARY KEY, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + exchange_id INTEGER REFERENCES exchanges(id), + old_allocations TEXT, -- JSON + new_allocations TEXT, -- JSON + executed_trades TEXT, -- JSON + status TEXT DEFAULT 'completed' +); +``` + +### App State + +Stores application state for recovery. + +```sql +CREATE TABLE app_state ( + id INTEGER PRIMARY KEY, + key TEXT UNIQUE NOT NULL, + value TEXT, -- JSON + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +### Audit Log + +Stores security audit events. + +```sql +CREATE TABLE audit_log ( + id INTEGER PRIMARY KEY, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + event_type TEXT NOT NULL, + user_id TEXT, + details TEXT -- JSON +); +``` + +## Indexes + +Key indexes for performance: + +- `market_data(symbol, timeframe, timestamp)` - Unique index +- `trades(symbol, executed_at)` - For trade queries +- `positions(symbol, exchange_id, is_open)` - For position lookups +- `orders(status, created_at)` - For order management +- `strategies(is_enabled)` - For active strategy queries + +## Relationships + +- **Exchanges** → **Strategies** (one-to-many) +- **Exchanges** → **Trades** (one-to-many) +- **Strategies** → **Trades** (one-to-many) +- **Strategies** → **Backtest Results** (one-to-many) +- **Exchanges** → **Positions** (one-to-many) +- **Exchanges** → **Market Data** (one-to-many) + +## Data Retention + +Configurable retention policies: + +- **Market Data**: Configurable (default: 1 year) +- **Trades**: Permanent +- **Orders**: Permanent +- **Portfolio Snapshots**: Configurable (default: 1 year) +- **Logs**: Configurable (default: 30 days) + +## Backup and Recovery + +- **Automatic Backups**: Before critical operations +- **Manual Backups**: Via export functionality +- **Recovery**: From backup files or database dumps + diff --git a/docs/architecture/exchange_integration.md b/docs/architecture/exchange_integration.md new file mode 100644 index 00000000..ec39f4cf --- /dev/null +++ b/docs/architecture/exchange_integration.md @@ -0,0 +1,176 @@ +# Exchange Integration Architecture + +This document describes how exchange adapters integrate with the trading system. + +## Adapter Pattern + +All exchanges use the adapter pattern to provide a unified interface: + +``` +Trading Engine + │ + ▼ +BaseExchange (Interface) + │ + ├──► CoinbaseExchange + ├──► BinanceExchange (future) + └──► KrakenExchange (future) +``` + +## Base Exchange Interface + +All exchanges implement `BaseExchange`: + +```python +class BaseExchange(ABC): + async def connect() + async def disconnect() + async def fetch_balance() + async def place_order() + async def cancel_order() + async def fetch_order_status() + async def fetch_ohlcv() + async def subscribe_ohlcv() + async def subscribe_trades() + async def subscribe_order_book() + async def fetch_open_orders() + async def fetch_positions() + async def fetch_markets() +``` + +## Exchange Factory + +The factory pattern creates exchange instances: + +``` +ExchangeFactory + │ + ├──► get_exchange(name) + │ │ + │ ├──► Lookup registered adapter + │ ├──► Get API keys from KeyManager + │ └──► Instantiate adapter + │ + └──► register_exchange(name, adapter_class) +``` + +## Exchange Registration + +Exchanges are registered at module import: + +```python +# In exchange module +from src.exchanges.factory import ExchangeFactory + +ExchangeFactory.register_exchange("coinbase", CoinbaseExchange) +``` + +## CCXT Integration + +Most exchanges use the CCXT library: + +```python +import ccxt.pro as ccxt + +class CoinbaseExchange(BaseExchange): + def __init__(self, ...): + self.exchange = ccxt.coinbaseadvanced({ + 'apiKey': api_key, + 'secret': secret_key, + 'enableRateLimit': True, + }) +``` + +## WebSocket Support + +Real-time data via WebSockets: + +```python +async def subscribe_ohlcv(self, symbol, timeframe, callback): + """Subscribe to OHLCV updates.""" + await self.exchange.watch_ohlcv(symbol, timeframe, callback) +``` + +## Rate Limiting + +All exchanges respect rate limits: + +- CCXT handles rate limiting automatically +- `enableRateLimit: True` in exchange config +- Custom rate limiting for non-CCXT exchanges + +## Error Handling + +Exchange-specific error handling: + +```python +try: + order = await self.exchange.create_order(...) +except ccxt.NetworkError as e: + # Handle network errors + logger.error(f"Network error: {e}") + raise +except ccxt.ExchangeError as e: + # Handle exchange errors + logger.error(f"Exchange error: {e}") + raise +``` + +## Connection Management + +- **Connection Pooling**: Reuse connections when possible +- **Auto-Reconnect**: Automatic reconnection on disconnect +- **Health Monitoring**: Monitor connection health +- **Graceful Shutdown**: Properly close connections + +## Adding New Exchanges + +See [Adding Exchanges](../developer/adding_exchanges.md) for detailed guide. + +## Exchange-Specific Features + +Some exchanges have unique features: + +- **Coinbase**: Requires passphrase for some operations +- **Binance**: Futures and margin trading +- **Kraken**: Different order types + +These are handled in exchange-specific adapters. + +## WebSocket Implementation + +### Coinbase WebSocket Support + +The Coinbase adapter includes WebSocket subscription methods: + +- **subscribe_ticker()**: Subscribe to real-time price updates +- **subscribe_orderbook()**: Subscribe to order book changes +- **subscribe_trades()**: Subscribe to trade executions + +### WebSocket Architecture + +``` +Exchange WebSocket API + ↓ +CoinbaseAdapter.subscribe_*() + ↓ +Callback Functions + ↓ +DataCollector (with signals) + ↓ +UI Widgets (via signals) +``` + +### Reconnection Strategy + +- Automatic reconnection on disconnect +- Message queuing during disconnection +- Heartbeat/ping-pong for connection health +- Fallback to polling if WebSocket unavailable + +### Implementation Notes + +- Uses `websockets` library for async WebSocket connections +- Callbacks are wrapped to emit Qt signals for UI updates +- Basic implementation provided; can be extended for full Coinbase Advanced Trade WebSocket API + diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md new file mode 100644 index 00000000..06937f7e --- /dev/null +++ b/docs/architecture/overview.md @@ -0,0 +1,185 @@ +# Architecture Overview + +This document provides a high-level overview of the Crypto Trader architecture. + +## System Architecture + +Crypto Trader follows a modular architecture with clear separation of concerns: + +``` +┌─────────────────────────────────────────────────────────┐ +│ Frontend (React + Vite) │ +│ Dashboard | Strategies | Portfolio | Backtest | Settings│ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Backend API (FastAPI) │ +│ Autopilot | Trading | Market Data | WebSocket │ +└─────────────────────────────────────────────────────────┘ + │ │ │ + ▼ ▼ ▼ +┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ +│ Redis │ │ Celery │ │ PostgreSQL │ +│ State/Cache │←───│ Workers │ │ Database │ +└──────────────┘ └──────────────┘ └──────────────────┘ + ▲ │ + │ ▼ +┌─────────────────────────────────────────────────────────┐ +│ Trading Engine │ +│ Order Management | Position Tracking | Risk Management │ +└─────────────────────────────────────────────────────────┘ + │ + ┌───────────────────┼───────────────────┐ + ▼ ▼ ▼ +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ Strategies │ │ Exchanges │ │ Portfolio │ +│ Framework │ │ Adapters │ │ Tracker │ +└──────────────┘ └──────────────┘ └──────────────┘ +``` + +## Core Components + +### 1. Frontend Layer + +- **Framework**: React with TypeScript +- **Build Tool**: Vite +- **Components**: Dashboard, Strategy Manager, Portfolio View, Backtest View, Settings +- **State Management**: React Query, Context API +- **Styling**: Material-UI (MUI) + +### 2. Backend API + +- **Framework**: FastAPI (async Python) +- **Features**: RESTful API, WebSocket support, auto-docs (Swagger/OpenAPI) +- **Authentication**: JWT tokens (planned) +- **Responsibilities**: Orchestrates all business logic + +### 3. Redis (State Management) + +- **Purpose**: Distributed state management and caching +- **Use Cases**: + - Autopilot registry (prevents multiple instances) + - Daily trade count persistence (survives restarts) + - Session caching (planned) + - Real-time data caching (planned) +- **Configuration**: `src/core/config.py` → `redis` section + +### 4. Celery (Background Tasks) + +- **Purpose**: Offload CPU-intensive tasks from the API +- **Use Cases**: + - ML model training (`train_model_task`) + - Data bootstrapping (`bootstrap_task`) + - Report generation (`generate_report_task`) +- **Broker**: Redis +- **Worker Command**: `celery -A src.worker.app worker --loglevel=info` + +### 5. Trading Engine + +- **Responsibilities**: Order execution, position management, risk checks +- **Components**: Trading engine, order manager, paper trading simulator +- **Integration**: Connects strategies to exchanges + +### 6. Strategy Framework + +- **Base Class**: `BaseStrategy` provides common interface +- **Registry**: Manages available strategies (RSI, MACD, Bollinger, etc.) +- **ML Selection**: `IntelligentAutopilot` uses ML to select optimal strategies +- **Features**: Multi-timeframe support, scheduling, parameter management + +### 7. Exchange Adapters + +- **Pattern**: Adapter pattern for unified interface +- **Factory**: Dynamic exchange instantiation +- **Current**: Coinbase Advanced Trade API, Binance Public +- **Data Providers**: CCXT-based providers with automatic failover + +### 8. Risk Management + +- **Components**: Risk manager, stop-loss, position sizing, limits +- **Integration**: Pre-trade checks, real-time monitoring +- **Features**: Drawdown limits, daily loss limits, position limits + +### 9. Backtesting Engine + +- **Features**: Historical data replay, realistic simulation +- **Components**: Engine, metrics, slippage model, fee model +- **Optimization**: Parameter optimization support + +### 10. Portfolio Management + +- **Tracking**: Real-time position tracking +- **Analytics**: Performance metrics, risk analysis +- **Rebalancing**: Automatic portfolio rebalancing (planned) + +## Data Flow + +### Trading Flow + +1. User starts autopilot → API receives request +2. Redis lock checked/set → Prevents duplicate instances +3. Strategy generates signal +4. Risk manager validates trade +5. Order manager creates order +6. Exchange adapter executes order +7. Position tracker updates positions +8. Redis updates trade count + +### ML Training Flow (Background) + +1. User triggers `/retrain` API +2. API queues `train_model_task` to Celery +3. API returns task ID immediately (non-blocking) +4. Celery worker picks up task +5. Worker bootstraps data if needed +6. Worker trains model +7. User polls `/tasks/{task_id}` for status + +## Technology Stack + +- **Language**: Python 3.11+ +- **Frontend**: React 18, TypeScript, Vite +- **Backend**: FastAPI, Uvicorn +- **Database**: PostgreSQL with SQLAlchemy +- **Cache/State**: Redis +- **Task Queue**: Celery +- **Exchange Library**: CCXT +- **Data Analysis**: Pandas, NumPy +- **Machine Learning**: LightGBM, scikit-learn +- **Technical Analysis**: pandas-ta, TA-Lib +- **Async**: asyncio +- **Testing**: pytest, React Testing Library + +## Design Patterns + +- **Adapter Pattern**: Exchange adapters +- **Factory Pattern**: Exchange and strategy creation +- **Strategy Pattern**: Trading strategies +- **Observer Pattern**: Data updates to strategies +- **Singleton Pattern**: Configuration, database connections, Redis client + +## Security Architecture + +- **API Key Encryption**: Fernet encryption +- **Secure Storage**: Keyring integration +- **Audit Logging**: All security events logged +- **Permission Management**: Read-only vs trading modes + +## Scalability Considerations + +- **Async Operations**: Non-blocking I/O throughout +- **Redis State**: Enables horizontal scaling of API workers +- **Celery Workers**: Can scale independently for heavy workloads +- **Database Optimization**: Indexed queries, connection pooling +- **Data Retention**: Configurable retention policies + +## Extension Points + +- **Exchange Adapters**: Add new exchanges via adapter interface +- **Strategies**: Create custom strategies via base class +- **Indicators**: Add custom indicators +- **Order Types**: Extend advanced order types +- **Risk Rules**: Add custom risk management rules +- **Celery Tasks**: Add new background tasks in `src/worker/tasks.py` + diff --git a/docs/architecture/risk_management.md b/docs/architecture/risk_management.md new file mode 100644 index 00000000..73a165d3 --- /dev/null +++ b/docs/architecture/risk_management.md @@ -0,0 +1,165 @@ +# Risk Management Architecture + +This document describes the risk management system. + +## Risk Management Components + +``` +Risk Manager + ├──► Pre-Trade Checks + │ │ + │ ├──► Position Sizing + │ ├──► Daily Loss Limit + │ └──► Portfolio Allocation + │ + ├──► Real-Time Monitoring + │ │ + │ ├──► Drawdown Monitoring + │ ├──► Position Monitoring + │ └──► Portfolio Monitoring + │ + └──► Stop Loss Management + │ + ├──► Stop Loss Orders + └──► Trailing Stops +``` + +## Pre-Trade Risk Checks + +Before executing any trade: + +1. **Position Sizing Check** + - Verify position size within limits + - Check portfolio allocation + - Validate against risk parameters + +2. **Daily Loss Limit Check** + - Calculate current daily P&L + - Compare against daily loss limit + - Block trades if limit exceeded + +3. **Drawdown Check** + - Calculate current drawdown + - Compare against max drawdown limit + - Block trades if limit exceeded + +4. **Portfolio Allocation Check** + - Verify total exposure within limits + - Check per-asset allocation + - Validate diversification requirements + +## Position Sizing Methods + +### Fixed Percentage + +```python +position_size = capital * percentage +``` + +### Kelly Criterion + +```python +f = (bp - q) / b +position_size = capital * f +``` + +### Volatility-Based + +```python +position_size = (capital * risk_percentage) / (stop_loss_distance * price) +``` + +## Risk Limits + +Configurable limits: + +- **Max Drawdown**: Maximum allowed drawdown percentage +- **Daily Loss Limit**: Maximum daily loss percentage +- **Position Size Limit**: Maximum position value +- **Portfolio Exposure**: Maximum portfolio exposure percentage + +## Stop Loss Management + +### Stop Loss Types + +- **Fixed Stop Loss**: Fixed price level +- **Trailing Stop**: Adjusts with price movement + - Percentage-based: Adjusts by fixed percentage + - ATR-based: Adjusts based on volatility (Average True Range) +- **Percentage Stop**: Percentage below entry +- **ATR-based Stop**: Based on Average True Range (volatility-adjusted) + - Automatically calculates stop distance using ATR multiplier + - Adapts to market volatility conditions + - Configurable ATR period (default: 14) and multiplier (default: 2.0) + - Works with both fixed and trailing stops + +### ATR-Based Dynamic Stops + +ATR-based stops provide better risk management in volatile markets: + +```python +stop_loss_manager.set_stop_loss( + position_id=1, + stop_price=entry_price, + use_atr=True, + atr_multiplier=Decimal('2.0'), + atr_period=14, + ohlcv_data=market_data, + trailing=True +) +``` + +**Benefits:** +- Adapts to market volatility +- Tighter stops in low volatility, wider in high volatility +- Reduces stop-outs during normal market noise +- Better risk-adjusted returns + +**Calculation:** +- Stop distance = ATR × multiplier +- For long positions: stop_price = entry_price - (ATR × multiplier) +- For short positions: stop_price = entry_price + (ATR × multiplier) + +### Stop Loss Execution + +``` +Price Update + │ + ▼ +Stop Loss Check + │ + ├──► Stop Loss Triggered? + │ │ + │ └──► Execute Market Sell + │ + └──► Update Trailing Stop (if applicable) +``` + +## Real-Time Monitoring + +Continuous monitoring of: + +- Portfolio value +- Unrealized P&L +- Drawdown levels +- Position sizes +- Risk metrics + +## Risk Alerts + +Automatic alerts for: + +- Drawdown threshold exceeded +- Daily loss limit reached +- Position size exceeded +- Portfolio exposure exceeded + +## Integration Points + +Risk management integrates with: + +- **Trading Engine**: Pre-trade validation +- **Order Manager**: Position tracking +- **Portfolio Tracker**: Real-time monitoring +- **Alert System**: Risk alerts + diff --git a/docs/architecture/security.md b/docs/architecture/security.md new file mode 100644 index 00000000..09c75d2e --- /dev/null +++ b/docs/architecture/security.md @@ -0,0 +1,135 @@ +# Security Architecture + +This document describes the security architecture of Crypto Trader. + +## Security Layers + +``` +Application Layer + ├──► API Key Encryption + ├──► Permission Management + └──► Audit Logging + │ + ▼ +Storage Layer + ├──► Encrypted Storage + └──► Secure Key Management +``` + +## API Key Encryption + +### Encryption Process + +``` +Plain API Key + │ + ▼ +Fernet Encryption + │ + ▼ +Encrypted Key (Stored in Database) +``` + +### Key Management + +- **Encryption Key**: Stored securely (environment variable or keyring) +- **Key Generation**: Automatic on first use +- **Key Rotation**: Manual rotation process + +## Permission Management + +### Permission Levels + +- **Read-Only**: Data collection, backtesting only +- **Trading Enabled**: Full trading capabilities + +### Permission Enforcement + +``` +API Request + │ + ▼ +Permission Check + │ + ├──► Read-Only Request + │ │ + │ └──► Allow (read operations) + │ + └──► Trading Request + │ + ├──► Trading Enabled? + │ │ + │ ├──► Yes: Allow + │ └──► No: Reject +``` + +## Secure Storage + +### Keyring Integration + +- **Linux**: Secret Service (GNOME Keyring) +- **macOS**: Keychain +- **Windows**: Windows Credential Manager + +### Fallback Storage + +If keyring unavailable: +- Environment variable (development only) +- Encrypted file with user permission + +## Audit Logging + +All security events are logged: + +- API key changes +- Permission changes +- Trading operations +- Configuration changes +- Error events + +### Audit Log Format + +```python +{ + "timestamp": "2025-12-13T19:00:00Z", + "event_type": "API_KEY_CHANGED", + "user_id": "system", + "details": { + "exchange": "coinbase", + "action": "updated" + } +} +``` + +## Data Privacy + +- **Local Storage**: All data stored locally +- **No Telemetry**: No data sent externally +- **Encryption**: Sensitive data encrypted at rest +- **Access Control**: File system permissions + +## Best Practices + +1. **Use Read-Only Keys**: When possible, use read-only API keys +2. **IP Whitelisting**: Enable IP whitelisting on exchange accounts +3. **Regular Rotation**: Rotate API keys periodically +4. **Secure Environment**: Keep encryption keys secure +5. **Audit Review**: Regularly review audit logs + +## Threat Model + +### Threats Addressed + +- **API Key Theft**: Encryption at rest +- **Unauthorized Trading**: Permission checks +- **Data Breach**: Local storage, encryption +- **Man-in-the-Middle**: HTTPS for API calls +- **Key Logging**: Secure keyring storage + +### Security Boundaries + +- **Application Boundary**: Application code +- **Storage Boundary**: Encrypted database +- **Network Boundary**: Secure API connections +- **System Boundary**: File system permissions + diff --git a/docs/architecture/strategy_framework.md b/docs/architecture/strategy_framework.md new file mode 100644 index 00000000..1ec120f9 --- /dev/null +++ b/docs/architecture/strategy_framework.md @@ -0,0 +1,208 @@ +# Strategy Framework Architecture + +This document describes the strategy framework design. + +## Strategy Hierarchy + +``` +BaseStrategy (Abstract) + │ + ├──► Technical Strategies + │ ├──► RSIStrategy + │ ├──► MACDStrategy + │ ├──► MovingAverageStrategy + │ ├──► ConfirmedStrategy (Multi-Indicator) + │ ├──► DivergenceStrategy + │ └──► BollingerMeanReversionStrategy + │ + ├──► Ensemble Strategies + │ └──► ConsensusStrategy + │ + ├──► Other Strategies + │ ├──► DCAStrategy + │ ├──► GridStrategy + │ └──► MomentumStrategy + │ + └──► CustomStrategy (user-defined) +``` + +## Base Strategy Interface + +All strategies implement: + +```python +class BaseStrategy(ABC): + async def on_data(new_data: pd.DataFrame) + async def generate_signal() -> Dict[str, Any] + async def calculate_position_size(capital, risk) -> float + async def start() + async def stop() +``` + +## Strategy Lifecycle + +``` +1. Initialization + └──► __init__(parameters) + +2. Activation + └──► start() + +3. Data Processing + └──► on_data(new_data) + └──► generate_signal() + └──► Trading Engine + +4. Deactivation + └──► stop() +``` + +## Strategy Registry + +Manages available strategies: + +```python +StrategyRegistry + ├──► register_strategy(name, class) + ├──► get_strategy_class(name) + └──► list_available() +``` + +## Multi-Timeframe Support + +Strategies can use multiple timeframes: + +``` +Primary Timeframe (1h) + │ + ├──► Signal Generation + │ + └──► Higher Timeframe (1d) - Trend Confirmation + │ + └──► Lower Timeframe (15m) - Entry Timing +``` + +## Strategy Scheduling + +Strategies can be scheduled: + +- **Continuous**: Run on every new candle +- **Time-based**: Run at specific times +- **Condition-based**: Run when conditions met + +## Signal Generation + +Signal flow: + +``` +Data Update + │ + ▼ +Indicator Calculation + │ + ▼ +Strategy Logic + │ + ▼ +Signal Generation + │ + ├──► "buy" - Generate buy signal + ├──► "sell" - Generate sell signal + └──► "hold" - No action +``` + +## Position Sizing + +Strategies calculate position sizes: + +- **Fixed Percentage**: Fixed % of capital +- **Kelly Criterion**: Optimal position sizing based on win rate +- **Volatility-Based**: Adjusts based on market volatility (ATR) + +## Advanced Features + +### Trend Filtering + +All strategies can optionally use ADX-based trend filtering: + +```python +signal = strategy.apply_trend_filter( + signal, + ohlcv_data, + adx_period=14, + min_adx=25.0 +) +``` + +This filters out signals when: +- ADX < threshold (weak trend/chop) +- Signal direction doesn't match trend direction + +### Multi-Indicator Confirmation + +The ConfirmedStrategy requires multiple indicators to agree before generating signals, reducing false signals by 20-30%. + +### Divergence Detection + +Divergence strategies detect price vs. indicator divergences: +- Bullish divergence: Price lower low, indicator higher low → BUY +- Bearish divergence: Price higher high, indicator lower high → SELL + +### Ensemble Methods + +ConsensusStrategy aggregates signals from multiple strategies: +- Weighted voting by strategy performance +- Minimum consensus threshold +- Dynamic weighting based on recent performance +- **Kelly Criterion**: Optimal position sizing +- **Volatility-based**: Based on ATR +- **Risk-based**: Based on stop-loss distance + +## Strategy Parameters + +Configurable parameters: + +- Strategy-specific parameters (e.g., RSI period) +- Risk parameters (position size, stop-loss) +- Timeframe settings +- Symbol selection + +## Strategy Execution + +Execution flow: + +``` +Strategy Signal + │ + ▼ +Trading Engine + │ + ├──► Risk Check + │ + ├──► Position Sizing + │ + └──► Order Execution + │ + ├──► Paper Trading + └──► Live Trading +``` + +## Strategy Performance + +Performance tracking: + +- Win rate +- Total return +- Sharpe ratio +- Max drawdown +- Number of trades + +## Extensibility + +Easy to add new strategies: + +1. Inherit from `BaseStrategy` +2. Implement required methods +3. Register with `StrategyRegistry` +4. Configure and use + diff --git a/docs/architecture/ui_architecture.md b/docs/architecture/ui_architecture.md new file mode 100644 index 00000000..d3168d31 --- /dev/null +++ b/docs/architecture/ui_architecture.md @@ -0,0 +1,270 @@ +# UI Architecture + +This document describes the user interface architecture and how the frontend integrates with the backend API. + +## Overview + +The UI is built with React and TypeScript, using Material-UI for components. The frontend communicates with the FastAPI backend through REST API endpoints and WebSocket connections for real-time updates. + +## Architecture + +``` +React Frontend → REST API / WebSocket → FastAPI Backend → Python Services → Database +``` + +## Frontend Structure + +``` +frontend/src/ +├── pages/ # Page components (Trading, Portfolio, Strategies, etc.) +├── components/ # Reusable UI components +├── api/ # API client functions +├── hooks/ # Custom React hooks +└── types/ # TypeScript type definitions +``` + +## Page Components + +### DashboardPage +- Overview of trading activity +- Key metrics and charts +- Quick actions + +### TradingPage +- Order placement form +- Positions table +- Order history +- Real-time price updates + +### PortfolioPage +- Portfolio summary +- Holdings table +- Risk metrics +- Performance charts + +### StrategiesPage +- Strategy list and management +- Strategy configuration +- Start/stop controls +- Performance metrics + +### BacktestPage +- Backtest configuration +- Results display +- Export functionality + +### SettingsPage +- Exchange management +- Configuration settings +- API key management + +## Real-Time Updates + +### WebSocket Connection + +The frontend connects to the backend WebSocket endpoint (`/ws/`) for real-time updates: + +- Price updates +- Order status changes +- Position updates +- Strategy status changes + +### Implementation + +```typescript +// Using WebSocket hook +import { useWebSocket } from '@/hooks/useWebSocket'; + +function TradingPage() { + const { data, connected } = useWebSocket('/ws/'); + + // Handle real-time price updates + useEffect(() => { + if (data?.type === 'price_update') { + // Update UI with new price + } + }, [data]); +} +``` + +## API Integration + +### API Client + +All API calls go through the API client which handles: +- Request/response serialization +- Error handling +- Authentication (if added) + +```typescript +// Example API call +import { tradingApi } from '@/api/trading'; + +const placeOrder = async () => { + const order = await tradingApi.placeOrder({ + exchangeId: 1, + symbol: 'BTC/USD', + side: 'buy', + type: 'market', + quantity: 0.1, + paperTrading: true + }); +}; +``` + +### React Query + +The frontend uses React Query for: +- Data fetching +- Caching +- Automatic refetching +- Optimistic updates + +```typescript +import { useQuery, useMutation } from '@tanstack/react-query'; +import { portfolioApi } from '@/api/portfolio'; + +function PortfolioPage() { + const { data, isLoading } = useQuery({ + queryKey: ['portfolio'], + queryFn: () => portfolioApi.getCurrent() + }); + + const updateMutation = useMutation({ + mutationFn: portfolioApi.update, + onSuccess: () => { + // Invalidate and refetch + queryClient.invalidateQueries({ queryKey: ['portfolio'] }); + } + }); +} +``` + +## State Management + +### Component State + +- Local component state with `useState` +- Form state management +- UI-only state (modals, tabs, etc.) + +### Server State + +- React Query for server state +- Automatic caching and synchronization +- Optimistic updates + +### Global State + +- Context API for theme, auth (if needed) +- Minimal global state - prefer server state + +## Component Patterns + +### Container/Presentational Pattern + +```typescript +// Container component (handles data fetching) +function TradingPageContainer() { + const { data } = useQuery({ queryKey: ['orders'], queryFn: fetchOrders }); + return ; +} + +// Presentational component (pure UI) +function TradingPage({ orders }) { + return ( + + + + ); +} +``` + +### Custom Hooks + +Extract reusable logic into custom hooks: + +```typescript +function useOrders() { + const { data, isLoading, error } = useQuery({ + queryKey: ['orders'], + queryFn: () => tradingApi.getOrders() + }); + + const placeOrder = useMutation({ + mutationFn: tradingApi.placeOrder + }); + + return { orders: data, isLoading, error, placeOrder }; +} +``` + +## Error Handling + +### API Errors + +```typescript +try { + await tradingApi.placeOrder(order); +} catch (error) { + if (error.response?.status === 400) { + // Handle validation error + } else if (error.response?.status === 500) { + // Handle server error + } +} +``` + +### Error Boundaries + +Use React Error Boundaries to catch and display errors gracefully: + +```typescript +}> + + +``` + +## Performance Optimization + +### Code Splitting + +Use React.lazy for code splitting: + +```typescript +const TradingPage = lazy(() => import('@/pages/TradingPage')); + +}> + + +``` + +### Memoization + +Use React.memo, useMemo, and useCallback to prevent unnecessary re-renders: + +```typescript +const MemoizedTable = React.memo(OrdersTable); + +const filteredData = useMemo(() => { + return data.filter(/* ... */); +}, [data, filter]); +``` + +## Testing + +See [Testing Guide](../developer/testing.md) for frontend testing strategies. + +## Styling + +- Material-UI components and theming +- Consistent design system +- Dark/light theme support +- Responsive design + +## Accessibility + +- Semantic HTML +- ARIA labels where needed +- Keyboard navigation +- Screen reader support diff --git a/docs/deployment/README.md b/docs/deployment/README.md new file mode 100644 index 00000000..f7368a29 --- /dev/null +++ b/docs/deployment/README.md @@ -0,0 +1,116 @@ +# Deployment Guide + +This guide covers deploying Crypto Trader in various environments. + +## Table of Contents + +1. [AppImage Deployment](appimage.md) - Building and distributing AppImage +2. [Bluefin Linux](bluefin.md) - Bluefin Linux specific instructions +3. [PostgreSQL Setup](postgresql.md) - PostgreSQL configuration +4. [Updates](updates.md) - Update mechanism and versioning + +## Deployment Options + +### AppImage (Recommended) + +- Single executable file +- No installation required +- Portable across Linux distributions +- Includes all dependencies + +### From Source + +- Full control over installation +- Customizable configuration +- Development and production use + +## System Requirements + +- **OS**: Linux (Bluefin recommended), macOS, Windows +- **Python**: 3.11+ (for source installation) +- **Node.js**: 18+ (for frontend) +- **Memory**: 4GB minimum, 8GB recommended +- **Storage**: 1GB+ for application and data +- **Network**: Internet connection required +- **Redis**: Version 5.0+ (for state management) +- **PostgreSQL**: Version 14+ (for database) + +## Quick Deployment + +### AppImage + +1. Download AppImage +2. Make executable: `chmod +x crypto_trader-*.AppImage` +3. Run: `./crypto_trader-*.AppImage` + +### From Source + +1. Clone repository +2. Install dependencies: `pip install -r requirements.txt` +3. Install frontend dependencies: `cd frontend && npm install` +4. Start Redis (see Redis section below for options) +5. Start Celery worker: `celery -A src.worker.app worker --loglevel=info &` +6. Start backend: `uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000 &` +7. Start frontend: `cd frontend && npm run dev` + +Or use the helper script: +```bash +./scripts/start_all.sh +``` + +## Required Services + +### Redis + +Redis is required for distributed state management and Celery background tasks (e.g., ML model training). + +```bash +# Install (Ubuntu/Debian) +sudo apt-get install redis-server +``` + +**Starting Redis**: + +```bash +# Option 1: Using system service (requires sudo) +sudo service redis-server start + +# Option 2: Direct daemon mode (for containers/restricted environments) +redis-server --daemonize yes + +# Verify +redis-cli ping # Should return PONG +``` + +> **Note**: In containerized environments (Toolbox, Distrobox, etc.) where `sudo` is not available, use the direct daemon mode option. + +### Celery Worker + +Celery handles background tasks like ML training: + +```bash +# Start worker +celery -A src.worker.app worker --loglevel=info + +# Start with specific queues +celery -A src.worker.app worker -Q ml_training,celery --loglevel=info +``` + +## Post-Deployment + +After deployment: + +1. Configure exchanges +2. Set up risk management +3. Verify Redis connection: `python scripts/verify_redis.py` +4. Test with paper trading +5. Review configuration +6. Start with small positions + +## Production Considerations + +- Use a process manager (systemd, supervisor) for services +- Configure Redis persistence (AOF or RDB) +- Set up monitoring and alerting +- Enable HTTPS for the API +- Configure proper firewall rules diff --git a/docs/deployment/postgresql.md b/docs/deployment/postgresql.md new file mode 100644 index 00000000..165ef742 --- /dev/null +++ b/docs/deployment/postgresql.md @@ -0,0 +1,171 @@ +# PostgreSQL Setup + +This guide covers optional PostgreSQL database configuration. + +## When to Use PostgreSQL + +PostgreSQL is recommended for: + +- Large datasets (millions of trades) +- Multiple users +- Advanced queries +- Production deployments +- High-performance requirements + +## Installation + +### Install PostgreSQL + +**Fedora/Bluefin**: +```bash +sudo dnf install postgresql postgresql-server +sudo postgresql-setup --initdb +sudo systemctl enable postgresql +sudo systemctl start postgresql +``` + +**Ubuntu/Debian**: +```bash +sudo apt-get install postgresql postgresql-contrib +sudo systemctl enable postgresql +sudo systemctl start postgresql +``` + +## Database Setup + +### Create Database + +```bash +sudo -u postgres psql +``` + +```sql +CREATE DATABASE crypto_trader; +CREATE USER crypto_trader_user WITH PASSWORD 'your_password'; +GRANT ALL PRIVILEGES ON DATABASE crypto_trader TO crypto_trader_user; +\q +``` + +### Configure Connection + +Update `config.yaml`: + +```yaml +database: + type: postgresql + host: localhost + port: 5432 + database: crypto_trader + user: crypto_trader_user + password: ${DB_PASSWORD} # Use environment variable +``` + +### Set Environment Variable + +```bash +export DB_PASSWORD='your_password' +``` + +Or add to `~/.bashrc`: +```bash +echo 'export DB_PASSWORD="your_password"' >> ~/.bashrc +``` + +## Migration from SQLite + +### Export from SQLite + +```bash +sqlite3 trading.db .dump > dump.sql +``` + +### Import to PostgreSQL + +```bash +psql -U crypto_trader_user -d crypto_trader -f dump.sql +``` + +## Performance Tuning + +### PostgreSQL Configuration + +Edit `/etc/postgresql/*/main/postgresql.conf`: + +```ini +shared_buffers = 256MB +effective_cache_size = 1GB +maintenance_work_mem = 64MB +checkpoint_completion_target = 0.9 +wal_buffers = 16MB +default_statistics_target = 100 +random_page_cost = 1.1 +effective_io_concurrency = 200 +work_mem = 4MB +min_wal_size = 1GB +max_wal_size = 4GB +``` + +### Indexes + +Key indexes are created automatically. For custom queries, add indexes: + +```sql +CREATE INDEX idx_trades_symbol_date ON trades(symbol, executed_at); +CREATE INDEX idx_market_data_symbol_timeframe ON market_data(symbol, timeframe, timestamp); +``` + +## Backup and Recovery + +### Backup + +```bash +pg_dump -U crypto_trader_user crypto_trader > backup.sql +``` + +### Restore + +```bash +psql -U crypto_trader_user crypto_trader < backup.sql +``` + +### Automated Backups + +Set up cron job: + +```bash +0 2 * * * pg_dump -U crypto_trader_user crypto_trader > /backup/crypto_trader_$(date +\%Y\%m\%d).sql +``` + +## Security + +### Connection Security + +- Use strong passwords +- Restrict network access +- Use SSL connections for remote access +- Regular security updates + +### User Permissions + +- Use dedicated database user +- Grant only necessary permissions +- Don't use superuser for application + +## Troubleshooting + +**Connection refused?** +- Check PostgreSQL is running: `sudo systemctl status postgresql` +- Verify connection settings +- Check firewall rules + +**Authentication failed?** +- Verify username and password +- Check `pg_hba.conf` configuration +- Review PostgreSQL logs + +**Performance issues?** +- Check PostgreSQL configuration +- Review query performance +- Add appropriate indexes +- Monitor resource usage + diff --git a/docs/deployment/updates.md b/docs/deployment/updates.md new file mode 100644 index 00000000..fe856edb --- /dev/null +++ b/docs/deployment/updates.md @@ -0,0 +1,131 @@ +# Update Mechanism + +This guide covers the built-in update mechanism for Crypto Trader. + +## Update System + +Crypto Trader includes a built-in update checker and installer for AppImage deployments. + +## Update Check + +### Automatic Check + +Updates are checked on application startup (if enabled): + +```yaml +updates: + check_on_startup: true + repository_url: "https://github.com/user/crypto_trader" +``` + +### Manual Check + +Check for updates from the application: + +1. Navigate to Help > Check for Updates +2. Application checks GitHub releases +3. Notifies if update available + +## Update Process + +### Automatic Update + +1. **Check for Updates** + - Compares current version with latest release + - Downloads update information + +2. **Download Update** + - Downloads new AppImage + - Shows progress + - Verifies download + +3. **Install Update** + - Creates backup of current version + - Replaces with new version + - Makes executable + +4. **Restart** + - Prompts to restart + - Launches new version + +### Manual Update + +1. Download new AppImage +2. Replace old file +3. Make executable: `chmod +x crypto_trader-*.AppImage` +4. Run new version + +## Version Management + +### Version Format + +Follows semantic versioning: `MAJOR.MINOR.PATCH` + +Example: `1.2.3` + +### Version Comparison + +- **Major**: Breaking changes +- **Minor**: New features (backward compatible) +- **Patch**: Bug fixes (backward compatible) + +## Update Configuration + +### Disable Auto-Check + +```yaml +updates: + check_on_startup: false +``` + +### Custom Repository + +```yaml +updates: + repository_url: "https://github.com/your-org/crypto_trader" +``` + +## Update Notifications + +Users are notified when: + +- Update is available on startup +- Manual check finds update +- Critical security update available + +## Rollback + +If update causes issues: + +1. Locate backup: `crypto_trader-*.AppImage.backup` +2. Restore backup: + ```bash + mv crypto_trader-*.AppImage.backup crypto_trader-*.AppImage + chmod +x crypto_trader-*.AppImage + ``` +3. Run previous version + +## Best Practices + +1. **Test Updates**: Test updates in development first +2. **Backup**: Always backup before updating +3. **Release Notes**: Review release notes before updating +4. **Staged Rollout**: Consider staged rollout for major updates + +## Troubleshooting + +**Update check fails?** +- Check internet connection +- Verify repository URL +- Review application logs + +**Download fails?** +- Check disk space +- Verify download URL +- Check network connection + +**Installation fails?** +- Check file permissions +- Verify AppImage integrity +- Review error logs + diff --git a/docs/deployment/web_architecture.md b/docs/deployment/web_architecture.md new file mode 100644 index 00000000..800a6cc9 --- /dev/null +++ b/docs/deployment/web_architecture.md @@ -0,0 +1,262 @@ +# Web Architecture Deployment Guide + +This guide covers deploying the new web-based architecture for Crypto Trader. + +## Overview + +The application has been migrated from PyQt6 desktop app to a modern web-based architecture: + +- **Frontend**: React + TypeScript + Material-UI +- **Backend**: FastAPI (Python) +- **Deployment**: Docker + +## Architecture + +``` +┌─────────────────────────────────────────┐ +│ Web Browser (Client) │ +│ React + Material-UI + TypeScript │ +└─────────────────┬───────────────────────┘ + │ HTTP/WebSocket +┌─────────────────▼───────────────────────┐ +│ Python Backend API (FastAPI) │ +│ - Trading Engine (existing code) │ +│ - Strategy Framework (existing code) │ +│ - Portfolio Management (existing code) │ +└─────────────────┬───────────────────────┘ + │ +┌─────────────────▼───────────────────────┐ +│ Database (SQLite/PostgreSQL) │ +└─────────────────────────────────────────┘ +``` + +## Quick Start + +### Development + +1. **Start Backend**: + ```bash + cd backend + python -m uvicorn main:app --reload --port 8000 + ``` + +2. **Start Frontend**: + ```bash + cd frontend + npm install + npm run dev + ``` + +3. **Access Application**: + - Frontend: http://localhost:3000 + - API Docs: http://localhost:8000/docs + - API: http://localhost:8000/api + +### Docker Deployment + +1. **Build and Run**: + ```bash + docker-compose up --build + ``` + +2. **Access Application**: + - Application: http://localhost:8000 + - API Docs: http://localhost:8000/docs + +## API Endpoints + +### Trading +- `POST /api/trading/orders` - Create order +- `GET /api/trading/orders` - List orders +- `GET /api/trading/orders/{id}` - Get order +- `POST /api/trading/orders/{id}/cancel` - Cancel order +- `GET /api/trading/positions` - Get positions +- `GET /api/trading/balance` - Get balance + +### Portfolio +- `GET /api/portfolio/current` - Get current portfolio +- `GET /api/portfolio/history` - Get portfolio history +- `POST /api/portfolio/positions/update-prices` - Update prices + +### Strategies +- `GET /api/strategies/` - List strategies +- `GET /api/strategies/available` - List available strategy types +- `POST /api/strategies/` - Create strategy +- `GET /api/strategies/{id}` - Get strategy +- `PUT /api/strategies/{id}` - Update strategy +- `DELETE /api/strategies/{id}` - Delete strategy +- `POST /api/strategies/{id}/start` - Start strategy +- `POST /api/strategies/{id}/stop` - Stop strategy + +### Backtesting +- `POST /api/backtesting/run` - Run backtest +- `GET /api/backtesting/results/{id}` - Get backtest results + +### Exchanges +- `GET /api/exchanges/` - List exchanges +- `GET /api/exchanges/{id}` - Get exchange + +### WebSocket +- `WS /ws/` - WebSocket connection for real-time updates + +## Configuration + +### Environment Variables + +Create `.env` file: + +```env +# API Configuration +VITE_API_URL=http://localhost:8000 +VITE_WS_URL=ws://localhost:8000/ws/ + +# Database (optional) +DATABASE_URL=sqlite:///./data/crypto_trader.db +``` + +### Docker Environment + +Environment variables can be set in `docker-compose.yml`: + +```yaml +environment: + - DATABASE_URL=sqlite:///./data/crypto_trader.db + - PYTHONPATH=/app +``` + +## Data Persistence + +Data is stored in the `./data` directory, which is mounted as a volume in Docker: + +```yaml +volumes: + - ./data:/app/data +``` + +## Development + +### Backend Development + +1. Install dependencies: + ```bash + pip install -r requirements.txt + pip install -r backend/requirements.txt + ``` + +2. Run with hot-reload: + ```bash + python -m uvicorn backend.main:app --reload + ``` + +### Frontend Development + +1. Install dependencies: + ```bash + cd frontend + npm install + ``` + +2. Run development server: + ```bash + npm run dev + ``` + +3. Build for production: + ```bash + npm run build + ``` + +## Production Deployment + +### Docker Production + +1. Build production image: + ```bash + docker build -t crypto-trader:latest . + ``` + +2. Run container: + ```bash + docker run -d \ + -p 8000:8000 \ + -v $(pwd)/data:/app/data \ + -v $(pwd)/config:/app/config \ + --name crypto-trader \ + crypto-trader:latest + ``` + +### Reverse Proxy (Nginx) + +Example Nginx configuration: + +```nginx +server { + listen 80; + server_name crypto-trader.example.com; + + location / { + proxy_pass http://localhost:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /ws/ { + proxy_pass http://localhost:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + } +} +``` + +## Migration from PyQt6 + +The existing Python code has been preserved: + +- Trading engine (`src/trading/`) +- Strategy framework (`src/strategies/`) +- Portfolio tracker (`src/portfolio/`) +- Backtesting engine (`src/backtesting/`) +- All other core modules + +Only the UI layer has been replaced with a web interface. + +## Troubleshooting + +### Backend Issues + +- **Port already in use**: Change port in `docker-compose.yml` or use `--port` flag +- **Database errors**: Check database file permissions in `./data` directory +- **Import errors**: Ensure `PYTHONPATH=/app` is set + +### Frontend Issues + +- **API connection errors**: Check `VITE_API_URL` in `.env` file +- **WebSocket connection fails**: Verify WebSocket URL and backend is running +- **Build errors**: Clear `node_modules` and reinstall: `rm -rf node_modules && npm install` + +### Docker Issues + +- **Build fails**: Check Dockerfile syntax and dependencies +- **Container won't start**: Check logs: `docker-compose logs` +- **Volume permissions**: Ensure `./data` directory is writable + +## Benefits of Web Architecture + +1. **Modern UI**: Access to entire web ecosystem (Material-UI, charts, etc.) +2. **Cross-platform**: Works on any device with a browser +3. **Easier deployment**: Docker is simpler than AppImage +4. **Better development**: Hot-reload, better tooling +5. **Maintainability**: Easier to update and deploy +6. **Accessibility**: Access from anywhere via browser + +## Next Steps + +1. Add authentication (JWT tokens) +2. Implement WebSocket price updates +3. Add more charting capabilities +4. Enhance strategy management UI +5. Add mobile-responsive design diff --git a/docs/developer/README.md b/docs/developer/README.md new file mode 100644 index 00000000..503255e5 --- /dev/null +++ b/docs/developer/README.md @@ -0,0 +1,40 @@ +# Developer Guide + +Welcome to the Crypto Trader developer guide. This guide will help you set up a development environment, understand the codebase, and contribute to the project. + +## Table of Contents + +1. [Development Setup](setup.md) - Setting up your development environment +2. [Architecture Overview](architecture.md) - System architecture and design +3. [Coding Standards](coding_standards.md) - Code style and conventions +4. [Adding Exchanges](adding_exchanges.md) - How to add new exchange adapters +5. [Creating Strategies](creating_strategies.md) - Strategy development guide +6. [Testing](testing.md) - Testing guidelines and practices +7. [Contributing](contributing.md) - Contribution guidelines +8. [Release Process](release_process.md) - Release and deployment process + +## Quick Start + +1. [Set up development environment](setup.md) +2. [Review architecture](architecture.md) +3. [Read coding standards](coding_standards.md) +4. [Run tests](testing.md#running-tests) +5. [Make your first contribution](contributing.md) + +## Development Workflow + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Write/update tests +5. Ensure all tests pass +6. Update documentation +7. Submit a pull request + +## Getting Help + +- Review the [API Documentation](../api/index.html) +- Check existing issues and pull requests +- Ask questions in discussions +- Review code examples in the codebase + diff --git a/docs/developer/adding_exchanges.md b/docs/developer/adding_exchanges.md new file mode 100644 index 00000000..57323819 --- /dev/null +++ b/docs/developer/adding_exchanges.md @@ -0,0 +1,216 @@ +# Adding New Exchange Adapters + +This guide explains how to add support for new cryptocurrency exchanges. + +## Exchange Adapter Architecture + +All exchange adapters inherit from `BaseExchange` and implement a standardized interface. This allows the trading engine to work with any exchange through a common API. + +## Implementation Steps + +### 1. Create Exchange Module + +Create a new file in `src/exchanges/`: + +```python +# src/exchanges/your_exchange.py +from src.exchanges.base import BaseExchange +from src.exchanges.factory import ExchangeFactory +import ccxt.pro as ccxt +import logging + +logger = logging.getLogger(__name__) + +class YourExchangeAdapter(BaseExchange): + """Adapter for Your Exchange.""" + + def __init__(self, name: str, api_key: str, secret_key: str, password: str = None): + super().__init__(name, api_key, secret_key, password) + self.exchange = ccxt.yourexchange({ + 'apiKey': self.api_key, + 'secret': self.secret_key, + 'enableRateLimit': True, + }) + + async def connect(self): + """Establish connection to exchange.""" + await self.exchange.load_markets() + self.is_connected = True + logger.info(f"Connected to {self.name}") + + # Implement all required methods from BaseExchange + # ... +``` + +### 2. Implement Required Methods + +Implement all abstract methods from `BaseExchange`: + +- `connect()` - Establish connection +- `disconnect()` - Close connection +- `fetch_balance()` - Get account balance +- `place_order()` - Place order +- `cancel_order()` - Cancel order +- `fetch_order_status()` - Get order status +- `fetch_ohlcv()` - Get historical data +- `subscribe_ohlcv()` - Real-time OHLCV +- `subscribe_trades()` - Real-time trades +- `subscribe_order_book()` - Real-time order book +- `fetch_open_orders()` - Get open orders +- `fetch_positions()` - Get positions (futures) +- `fetch_markets()` - Get available markets + +### 3. Register Exchange + +Register the exchange in `src/exchanges/__init__.py`: + +```python +from .your_exchange import YourExchangeAdapter +from .factory import ExchangeFactory + +ExchangeFactory.register_exchange("your_exchange", YourExchangeAdapter) +``` + +### 4. Handle Exchange-Specific Features + +Some exchanges have unique features: + +- **Authentication**: Some exchanges use different auth methods +- **Rate Limits**: Respect exchange rate limits +- **WebSocket**: Implement exchange-specific WebSocket protocol +- **Order Types**: Support exchange-specific order types + +### 5. Write Tests + +Create tests in `tests/unit/exchanges/test_your_exchange.py`: + +```python +import pytest +from unittest.mock import Mock, patch +from src.exchanges.your_exchange import YourExchangeAdapter + +class TestYourExchangeAdapter: + """Tests for Your Exchange adapter.""" + + @pytest.fixture + def adapter(self): + return YourExchangeAdapter( + name="test", + api_key="test_key", + secret_key="test_secret" + ) + + @pytest.mark.asyncio + async def test_connect(self, adapter): + """Test connection.""" + with patch.object(adapter.exchange, 'load_markets'): + await adapter.connect() + assert adapter.is_connected + + # Add more tests... +``` + +## Using CCXT + +Most exchanges can use the `ccxt` library: + +```python +import ccxt.pro as ccxt + +exchange = ccxt.pro.yourexchange({ + 'apiKey': api_key, + 'secret': secret_key, + 'enableRateLimit': True, +}) + +# Use ccxt methods +balance = await exchange.fetch_balance() +order = await exchange.create_order(...) +``` + +## Exchange-Specific Considerations + +### Authentication + +Some exchanges require additional authentication: + +```python +# Example: Exchange requiring passphrase +self.exchange = ccxt.coinbase({ + 'apiKey': api_key, + 'secret': secret_key, + 'password': passphrase, # Coinbase requires this +}) +``` + +### Rate Limits + +Always enable rate limiting: + +```python +self.exchange = ccxt.yourexchange({ + 'enableRateLimit': True, # Important! +}) +``` + +### WebSocket Support + +For real-time data, implement WebSocket connections: + +```python +async def subscribe_ohlcv(self, symbol: str, timeframe: str, callback): + """Subscribe to OHLCV updates.""" + # Exchange-specific WebSocket implementation + await self.exchange.watch_ohlcv(symbol, timeframe, callback) +``` + +## Testing Your Adapter + +### Unit Tests + +Test all methods with mocked exchange responses: + +```python +@pytest.mark.asyncio +async def test_fetch_balance(self, adapter): + """Test balance fetching.""" + mock_balance = {'BTC': {'free': 1.0, 'used': 0.0, 'total': 1.0}} + with patch.object(adapter.exchange, 'fetch_balance', return_value=mock_balance): + balance = await adapter.fetch_balance() + assert 'BTC' in balance +``` + +### Integration Tests + +Test with real exchange (use testnet/sandbox if available): + +```python +@pytest.mark.integration +async def test_real_connection(self): + """Test real connection (requires API keys).""" + adapter = YourExchangeAdapter(...) + await adapter.connect() + assert adapter.is_connected +``` + +## Documentation + +Document your exchange adapter: + +1. Add docstrings to all methods +2. Document exchange-specific features +3. Add usage examples +4. Update API documentation + +## Best Practices + +1. **Error Handling**: Handle exchange-specific errors +2. **Rate Limiting**: Always respect rate limits +3. **Retry Logic**: Implement retry for transient failures +4. **Logging**: Log important operations +5. **Testing**: Test thoroughly before submitting + +## Example: Complete Exchange Adapter + +See `src/exchanges/coinbase.py` for a complete example implementation. + diff --git a/docs/developer/coding_standards.md b/docs/developer/coding_standards.md new file mode 100644 index 00000000..bb599eb1 --- /dev/null +++ b/docs/developer/coding_standards.md @@ -0,0 +1,226 @@ +# Coding Standards + +This document outlines the coding standards and conventions for Crypto Trader. + +## Code Style + +### Python Style Guide + +Follow [PEP 8](https://www.python.org/dev/peps/pep-0008/) with the following additions: + +- **Line Length**: Maximum 100 characters +- **Indentation**: 4 spaces (no tabs) +- **Imports**: Grouped and sorted (stdlib, third-party, local) +- **Naming**: + - Classes: `PascalCase` + - Functions/Variables: `snake_case` + - Constants: `UPPER_SNAKE_CASE` + - Private: `_leading_underscore` + +### Code Formatting + +Use `black` for automatic formatting: + +```bash +black src/ tests/ +``` + +### Type Hints + +Always use type hints for function signatures: + +```python +def calculate_position_size( + capital: float, + risk_percentage: float +) -> float: + """Calculate position size.""" + return capital * risk_percentage +``` + +### Docstrings + +Use Google-style docstrings: + +```python +def fetch_balance(self) -> Dict[str, Any]: + """Fetches the account balance. + + Args: + None + + Returns: + Dictionary containing balance information with keys: + - 'free': Available balance + - 'used': Used balance + - 'total': Total balance + + Raises: + ConnectionError: If exchange connection fails + ValueError: If exchange credentials are invalid + """ + pass +``` + +## File Organization + +### Module Structure + +```python +"""Module docstring describing the module.""" + +# Standard library imports +import os +from typing import Dict, List + +# Third-party imports +import pandas as pd +from sqlalchemy import Column + +# Local imports +from ..core.logger import get_logger +from .base import BaseClass + +# Constants +DEFAULT_VALUE = 100 + +# Module-level variables +logger = get_logger(__name__) + +# Classes +class MyClass: + """Class docstring.""" + pass + +# Functions +def my_function(): + """Function docstring.""" + pass +``` + +## Error Handling + +### Exception Handling + +```python +try: + result = risky_operation() +except SpecificError as e: + logger.error(f"Operation failed: {e}") + raise +except Exception as e: + logger.exception("Unexpected error") + raise RuntimeError("Operation failed") from e +``` + +### Logging + +Use appropriate log levels: + +- **DEBUG**: Detailed information for debugging +- **INFO**: General informational messages +- **WARNING**: Warning messages +- **ERROR**: Error messages +- **CRITICAL**: Critical errors + +```python +logger.debug("Detailed debug information") +logger.info("Operation completed successfully") +logger.warning("Deprecated function used") +logger.error("Operation failed") +logger.critical("System failure") +``` + +## Testing Standards + +### Test Naming + +```python +def test_function_name_should_do_something(): + """Test description.""" + pass + +class TestClassName: + """Test class description.""" + + def test_method_should_handle_case(self): + """Test method description.""" + pass +``` + +### Test Organization + +- One test class per module +- Test methods should be independent +- Use fixtures for common setup +- Mock external dependencies + +## Documentation Standards + +### Inline Comments + +```python +# Calculate position size using Kelly Criterion +# Formula: f = (bp - q) / b +fraction = (payout_ratio * win_prob - loss_prob) / payout_ratio +``` + +### Function Documentation + +Always document: +- Purpose +- Parameters +- Return values +- Exceptions +- Examples (when helpful) + +## Git Commit Messages + +Follow conventional commits: + +``` +type(scope): subject + +body + +footer +``` + +Types: +- `feat`: New feature +- `fix`: Bug fix +- `docs`: Documentation +- `test`: Tests +- `refactor`: Code refactoring +- `chore`: Maintenance + +Example: +``` +feat(trading): add trailing stop order support + +Implemented trailing stop orders with configurable +trail percentage and activation price. + +Closes #123 +``` + +## Code Review Guidelines + +### What to Review + +- Code correctness +- Test coverage +- Documentation +- Performance +- Security +- Style compliance + +### Review Checklist + +- [ ] Code follows style guide +- [ ] Tests are included +- [ ] Documentation is updated +- [ ] No security issues +- [ ] Performance is acceptable +- [ ] Error handling is appropriate + diff --git a/docs/developer/contributing.md b/docs/developer/contributing.md new file mode 100644 index 00000000..439dde14 --- /dev/null +++ b/docs/developer/contributing.md @@ -0,0 +1,115 @@ +# Contributing Guidelines + +Thank you for your interest in contributing to Crypto Trader! + +## How to Contribute + +### Reporting Bugs + +1. Check if the bug has already been reported +2. Create a new issue with: + - Clear description + - Steps to reproduce + - Expected vs actual behavior + - System information + - Log files (if applicable) + +### Suggesting Features + +1. Check if the feature has been suggested +2. Create a feature request with: + - Use case description + - Proposed solution + - Benefits + - Implementation considerations + +### Code Contributions + +1. **Fork the repository** +2. **Create a feature branch**: + ```bash + git checkout -b feature/my-feature + ``` +3. **Make your changes**: + - Follow coding standards + - Write tests + - Update documentation +4. **Run tests**: + ```bash + pytest + ``` +5. **Commit changes**: + ```bash + git commit -m "feat(module): add new feature" + ``` +6. **Push to your fork**: + ```bash + git push origin feature/my-feature + ``` +7. **Create a pull request** + +## Pull Request Process + +### Before Submitting + +- [ ] Code follows style guide +- [ ] Tests are included and passing +- [ ] Documentation is updated +- [ ] No new warnings or errors +- [ ] Coverage is maintained + +### Pull Request Template + +```markdown +## Description +Brief description of changes + +## Type of Change +- [ ] Bug fix +- [ ] New feature +- [ ] Breaking change +- [ ] Documentation update + +## Testing +How was this tested? + +## Checklist +- [ ] Code follows style guide +- [ ] Tests added/updated +- [ ] Documentation updated +- [ ] No breaking changes (or documented) +``` + +## Code Review + +All pull requests require review: + +- At least one approval required +- Address review comments +- Keep PR focused and small when possible +- Respond to feedback promptly + +## Development Setup + +See [Development Setup](setup.md) for environment setup. + +## Coding Standards + +See [Coding Standards](coding_standards.md) for detailed guidelines. + +## Questions? + +- Open a discussion +- Check existing issues +- Review documentation +- Ask in comments + +## Recognition + +Contributors will be recognized in: +- CONTRIBUTORS.md +- Release notes +- Project documentation + +Thank you for contributing! + diff --git a/docs/developer/creating_strategies.md b/docs/developer/creating_strategies.md new file mode 100644 index 00000000..ecbf0eac --- /dev/null +++ b/docs/developer/creating_strategies.md @@ -0,0 +1,233 @@ +# Creating Custom Strategies + +This guide explains how to create custom trading strategies for Crypto Trader. + +## Strategy Framework + +All strategies inherit from `BaseStrategy` and implement a standardized interface. This allows the trading engine to execute any strategy uniformly. + +## Basic Strategy Structure + +```python +from src.strategies.base import BaseStrategy, StrategyRegistry +from src.data.indicators import get_indicators +import pandas as pd +from typing import Dict, Any + +class MyCustomStrategy(BaseStrategy): + """My custom trading strategy.""" + + def __init__(self, strategy_id: int, name: str, symbol: str, timeframe: str, parameters: Dict[str, Any]): + super().__init__(strategy_id, name, symbol, timeframe, parameters) + # Initialize strategy-specific parameters + self.my_param = parameters.get("my_param", 10) + + async def on_data(self, new_data: pd.DataFrame): + """Called when new data is available.""" + # Update internal data + self.current_data = pd.concat([self.current_data, new_data]) + + # Generate signal if enough data + if len(self.current_data) >= self.my_param: + signal = await self.generate_signal() + if signal["signal"] != "hold": + # Signal generated - will be handled by trading engine + pass + + async def generate_signal(self) -> Dict[str, Any]: + """Generate trading signal.""" + # Calculate indicators + indicators = get_indicators() + rsi = indicators.rsi(self.current_data['close'], period=14) + + # Generate signal based on strategy logic + current_rsi = rsi.iloc[-1] + current_price = self.current_data['close'].iloc[-1] + + if current_rsi < 30: + return {"signal": "buy", "price": current_price} + elif current_rsi > 70: + return {"signal": "sell", "price": current_price} + else: + return {"signal": "hold", "price": current_price} + + async def calculate_position_size(self, capital: float, risk_percentage: float) -> float: + """Calculate position size.""" + return capital * risk_percentage + +# Register strategy +StrategyRegistry.register_strategy("my_custom", MyCustomStrategy) +``` + +## Required Methods + +### `on_data(new_data: pd.DataFrame)` + +Called when new OHLCV data is available for the strategy's timeframe. + +**Responsibilities**: +- Update internal data storage +- Calculate indicators +- Generate signals when conditions are met + +### `generate_signal() -> Dict[str, Any]` + +Analyzes current data and generates a trading signal. + +**Returns**: +```python +{ + "signal": "buy" | "sell" | "hold", + "price": float, # Optional, current price + "confidence": float, # Optional, 0.0 to 1.0 +} +``` + +### `calculate_position_size(capital: float, risk_percentage: float) -> float` + +Calculates the appropriate position size based on capital and risk. + +**Parameters**: +- `capital`: Available trading capital +- `risk_percentage`: Percentage of capital to risk (0.0 to 1.0) + +**Returns**: Position size in base currency + +## Using Technical Indicators + +Access indicators through the indicators library: + +```python +from src.data.indicators import get_indicators + +indicators = get_indicators() + +# Calculate indicators +sma = indicators.sma(data['close'], period=20) +ema = indicators.ema(data['close'], period=20) +rsi = indicators.rsi(data['close'], period=14) +macd_result = indicators.macd(data['close'], fast=12, slow=26, signal=9) +bbands = indicators.bollinger_bands(data['close'], period=20, std_dev=2) +``` + +## Multi-Timeframe Strategies + +Strategies can use multiple timeframes: + +```python +from src.strategies.timeframe_manager import get_timeframe_manager + +timeframe_manager = get_timeframe_manager() + +# Get data from different timeframes +primary_data = timeframe_manager.get_data(self.symbol, self.timeframe) +higher_tf_data = timeframe_manager.get_data(self.symbol, "1d") # Daily for trend + +# Use higher timeframe for trend confirmation +if higher_tf_data is not None and len(higher_tf_data) > 0: + daily_trend = higher_tf_data['close'].iloc[-1] > higher_tf_data['close'].iloc[-20] + if daily_trend: + # Only trade in direction of higher timeframe trend + pass +``` + +## Strategy Parameters + +Define configurable parameters: + +```python +def __init__(self, strategy_id: int, name: str, symbol: str, timeframe: str, parameters: Dict[str, Any]): + super().__init__(strategy_id, name, symbol, timeframe, parameters) + + # Required parameters with defaults + self.rsi_period = parameters.get("rsi_period", 14) + self.overbought = parameters.get("overbought", 70) + self.oversold = parameters.get("oversold", 30) + + # Validate parameters + if not (0 < self.oversold < self.overbought < 100): + raise ValueError("Invalid RSI thresholds") +``` + +## Strategy Registration + +Register your strategy so it can be used: + +```python +from src.strategies.base import StrategyRegistry + +StrategyRegistry.register_strategy("my_strategy", MyCustomStrategy) +``` + +## Strategy Lifecycle + +1. **Initialization**: Strategy is created with parameters +2. **Start**: `start()` is called when strategy is activated +3. **Data Updates**: `on_data()` is called with new candles +4. **Signal Generation**: `generate_signal()` is called when conditions are met +5. **Order Execution**: Trading engine executes signals +6. **Stop**: `stop()` is called when strategy is deactivated + +## Best Practices + +1. **Parameter Validation**: Validate all parameters in `__init__` +2. **Error Handling**: Handle missing data and calculation errors +3. **Logging**: Use strategy logger for important events +4. **Testing**: Write unit tests for your strategy +5. **Documentation**: Document strategy logic and parameters +6. **Backtesting**: Always backtest before live trading + +## Example: Complete Strategy + +See `src/strategies/technical/rsi_strategy.py` for a complete example. + +## Available Strategy Examples + +The codebase includes several advanced strategy implementations that serve as examples: + +### Multi-Indicator Confirmation + +See `src/strategies/technical/confirmed_strategy.py` for an example of combining multiple indicators. + +### Divergence Detection + +See `src/strategies/technical/divergence_strategy.py` for an example of divergence detection using the indicators API. + +### Ensemble Methods + +See `src/strategies/ensemble/consensus_strategy.py` for an example of combining multiple strategies with weighted voting. + +### Trend Filtering + +All strategies can use the optional trend filter method from BaseStrategy: + +```python +signal = strategy.apply_trend_filter(signal, ohlcv_data, adx_period=14, min_adx=25.0) +``` + +## Testing Your Strategy + +```python +import pytest +from src.strategies.my_strategy import MyCustomStrategy + +def test_strategy_signal_generation(): + """Test strategy signal generation.""" + strategy = MyCustomStrategy( + strategy_id=1, + name="Test Strategy", + symbol="BTC/USD", + timeframe="1h", + parameters={"rsi_period": 14} + ) + + # Create test data + test_data = pd.DataFrame({ + 'close': [100, 101, 102, 103, 104] + }) + + # Test signal generation + signal = await strategy.generate_signal() + assert signal["signal"] in ["buy", "sell", "hold"] +``` + diff --git a/docs/developer/frontend_testing.md b/docs/developer/frontend_testing.md new file mode 100644 index 00000000..b27756e9 --- /dev/null +++ b/docs/developer/frontend_testing.md @@ -0,0 +1,270 @@ +# Frontend Testing Guide + +This guide explains how to test the React frontend components and pages. + +## Testing Setup + +The frontend uses Vitest (recommended) or Jest for testing React components. To set up testing: + +```bash +cd frontend +npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event vitest @vitest/ui jsdom +``` + +Add to `package.json`: +```json +{ + "scripts": { + "test": "vitest", + "test:ui": "vitest --ui", + "test:coverage": "vitest --coverage" + } +} +``` + +## Testing Strategy + +### Component Testing + +Test individual components in isolation: + +```typescript +// frontend/src/components/__tests__/StatusIndicator.test.tsx +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import StatusIndicator from '../StatusIndicator'; + +describe('StatusIndicator', () => { + it('renders connected status correctly', () => { + render(); + expect(screen.getByText('WebSocket')).toBeInTheDocument(); + }); + + it('shows correct color for error status', () => { + render(); + const chip = screen.getByText('Error'); + expect(chip).toHaveClass('MuiChip-colorError'); + }); +}); +``` + +### Page Testing + +Test complete page workflows: + +```typescript +// frontend/src/pages/__tests__/StrategiesPage.test.tsx +import { render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { describe, it, expect, vi } from 'vitest'; +import StrategiesPage from '../StrategiesPage'; +import * as strategiesApi from '../../api/strategies'; + +vi.mock('../../api/strategies'); + +describe('StrategiesPage', () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } } + }); + + it('displays list of strategies', async () => { + const mockStrategies = [ + { + id: 1, + name: 'Test Strategy', + strategy_type: 'rsi', + enabled: false, + paper_trading: true, + // ... other fields + } + ]; + + vi.mocked(strategiesApi.strategiesApi.listStrategies).mockResolvedValue(mockStrategies); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText('Test Strategy')).toBeInTheDocument(); + }); + }); + + it('shows create strategy button', () => { + render( + + + + ); + expect(screen.getByText('Create Strategy')).toBeInTheDocument(); + }); +}); +``` + +### API Integration Testing + +Test API client functions: + +```typescript +// frontend/src/api/__tests__/strategies.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { strategiesApi } from '../strategies'; +import { apiClient } from '../client'; + +vi.mock('../client'); + +describe('strategiesApi', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('lists strategies', async () => { + const mockStrategies = [{ id: 1, name: 'Test' }]; + vi.mocked(apiClient.get).mockResolvedValue({ data: mockStrategies }); + + const result = await strategiesApi.listStrategies(); + expect(result).toEqual(mockStrategies); + expect(apiClient.get).toHaveBeenCalledWith('/api/strategies/'); + }); + + it('creates strategy', async () => { + const newStrategy = { name: 'New', strategy_type: 'rsi' }; + const created = { id: 1, ...newStrategy }; + vi.mocked(apiClient.post).mockResolvedValue({ data: created }); + + const result = await strategiesApi.createStrategy(newStrategy); + expect(result).toEqual(created); + }); +}); +``` + +### Hook Testing + +Test custom React hooks: + +```typescript +// frontend/src/hooks/__tests__/useWebSocket.test.ts +import { renderHook, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { useWebSocket } from '../useWebSocket'; + +// Mock WebSocket +global.WebSocket = vi.fn(() => ({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + send: vi.fn(), + close: vi.fn(), + readyState: WebSocket.OPEN, +})) as any; + +describe('useWebSocket', () => { + it('connects to WebSocket', async () => { + const { result } = renderHook(() => useWebSocket('ws://localhost:8000/ws/')); + + await waitFor(() => { + expect(result.current.isConnected).toBe(true); + }); + }); + + it('handles messages', async () => { + const { result } = renderHook(() => useWebSocket('ws://localhost:8000/ws/')); + + // Simulate message + const ws = (global.WebSocket as any).mock.results[0].value; + const messageEvent = new MessageEvent('message', { + data: JSON.stringify({ type: 'order_update', order_id: 1 }) + }); + ws.onmessage(messageEvent); + + await waitFor(() => { + expect(result.current.lastMessage).toBeTruthy(); + }); + }); +}); +``` + +## Test Coverage + +### Components to Test + +1. **Pages**: + - `StrategiesPage` - CRUD operations, start/stop + - `TradingPage` - Order placement, position closing + - `DashboardPage` - AutoPilot controls, system health + - `PortfolioPage` - Position management, charts + - `BacktestPage` - Backtest execution, results + - `SettingsPage` - All settings tabs + +2. **Components**: + - `StrategyDialog` - Form validation, parameter configuration + - `OrderForm` - Order type handling, validation + - `PositionCard` - Position display, close functionality + - `StatusIndicator` - Status display + - `SystemHealth` - Health status aggregation + - `DataFreshness` - Timestamp calculations + - `OperationsPanel` - Operation display + +3. **Hooks**: + - `useWebSocket` - Connection, messages, subscriptions + - `useRealtimeData` - Query invalidation + +4. **Contexts**: + - `SnackbarContext` - Notification display + - `WebSocketProvider` - WebSocket management + +## Running Tests + +```bash +# Run all tests +npm test + +# Run tests in watch mode +npm test -- --watch + +# Run tests with UI +npm run test:ui + +# Generate coverage report +npm run test:coverage +``` + +## Best Practices + +1. **Mock External Dependencies**: Mock API calls, WebSocket connections +2. **Test User Interactions**: Use `@testing-library/user-event` for clicks, typing +3. **Test Error States**: Verify error handling and display +4. **Test Loading States**: Ensure loading indicators appear +5. **Test Accessibility**: Use `@testing-library/jest-dom` matchers +6. **Keep Tests Fast**: Mock expensive operations +7. **Test Edge Cases**: Empty states, error conditions, boundary values + +## Example Test Suite Structure + +``` +frontend/src/ +├── components/ +│ ├── __tests__/ +│ │ ├── StatusIndicator.test.tsx +│ │ ├── StrategyDialog.test.tsx +│ │ └── ... +│ └── ... +├── pages/ +│ ├── __tests__/ +│ │ ├── StrategiesPage.test.tsx +│ │ ├── TradingPage.test.tsx +│ │ └── ... +│ └── ... +├── hooks/ +│ ├── __tests__/ +│ │ ├── useWebSocket.test.ts +│ │ └── ... +│ └── ... +└── api/ + ├── __tests__/ + │ ├── strategies.test.ts + │ └── ... + └── ... +``` + diff --git a/docs/developer/pricing_providers.md b/docs/developer/pricing_providers.md new file mode 100644 index 00000000..e3836395 --- /dev/null +++ b/docs/developer/pricing_providers.md @@ -0,0 +1,243 @@ +# Pricing Provider Development Guide + +This guide explains how to add new pricing data providers to the Crypto Trader system. + +## Overview + +Pricing providers are responsible for fetching market data (prices, OHLCV candlestick data) without requiring API keys. They differ from exchange adapters, which handle trading operations and require authentication. + +The system uses a multi-tier provider strategy: +- **Primary Providers**: CCXT-based providers (Kraken, Coinbase, Binance) +- **Fallback Provider**: CoinGecko API + +## Provider Interface + +All pricing providers must implement the `BasePricingProvider` interface, located in `src/data/providers/base_provider.py`. + +### Required Methods + +1. **`name` (property)**: Return the provider's display name +2. **`supports_websocket` (property)**: Return True if the provider supports WebSocket connections +3. **`connect()`**: Establish connection to the provider, return True if successful +4. **`disconnect()`**: Close connection and clean up resources +5. **`get_ticker(symbol: str) -> Dict`**: Get current ticker data for a symbol +6. **`get_ohlcv(symbol, timeframe, since, limit) -> List[List]`**: Get historical OHLCV data +7. **`subscribe_ticker(symbol, callback) -> bool`**: Subscribe to real-time ticker updates + +### Ticker Data Format + +The `get_ticker()` method should return a dictionary with the following keys: + +```python +{ + 'symbol': str, # Trading pair (e.g., 'BTC/USD') + 'bid': Decimal, # Best bid price + 'ask': Decimal, # Best ask price + 'last': Decimal, # Last traded price + 'high': Decimal, # 24h high + 'low': Decimal, # 24h low + 'volume': Decimal, # 24h volume + 'timestamp': int, # Unix timestamp in milliseconds +} +``` + +### OHLCV Data Format + +The `get_ohlcv()` method should return a list of candles, where each candle is: + +```python +[timestamp_ms, open, high, low, close, volume] +``` + +All values should be numeric (float or Decimal). + +## Creating a New Provider + +### Step 1: Create Provider Class + +Create a new file in `src/data/providers/` (e.g., `my_provider.py`): + +```python +"""My custom pricing provider.""" + +from decimal import Decimal +from typing import Dict, List, Optional, Any, Callable +from datetime import datetime +from .base_provider import BasePricingProvider +from ...core.logger import get_logger + +logger = get_logger(__name__) + + +class MyProvider(BasePricingProvider): + """My custom pricing provider implementation.""" + + @property + def name(self) -> str: + return "MyProvider" + + @property + def supports_websocket(self) -> bool: + return False # Set to True if WebSocket supported + + def connect(self) -> bool: + """Connect to provider.""" + try: + # Initialize connection + self._connected = True + logger.info(f"Connected to {self.name}") + return True + except Exception as e: + logger.error(f"Failed to connect: {e}") + self._connected = False + return False + + def disconnect(self): + """Disconnect from provider.""" + self._connected = False + logger.info(f"Disconnected from {self.name}") + + def get_ticker(self, symbol: str) -> Dict[str, Any]: + """Get current ticker data.""" + # Implementation here + pass + + def get_ohlcv( + self, + symbol: str, + timeframe: str = '1h', + since: Optional[datetime] = None, + limit: int = 100 + ) -> List[List]: + """Get OHLCV data.""" + # Implementation here + pass + + def subscribe_ticker(self, symbol: str, callback: Callable) -> bool: + """Subscribe to ticker updates.""" + # Implementation here + pass +``` + +### Step 2: Register Provider + +Update `src/data/providers/__init__.py` to include your provider: + +```python +from .my_provider import MyProvider + +__all__ = [..., 'MyProvider'] +``` + +### Step 3: Add to Pricing Service + +Update `src/data/pricing_service.py` to include your provider in the initialization: + +```python +# Add to _initialize_providers method +try: + my_provider = MyProvider() + if my_provider.connect(): + self._providers[my_provider.name] = my_provider + self._provider_priority.append(my_provider.name) +except Exception as e: + logger.error(f"Error initializing MyProvider: {e}") +``` + +### Step 4: Add Configuration + +Update `src/core/config.py` to include configuration options for your provider: + +```python +"data_providers": { + "primary": [ + # ... existing providers ... + {"name": "my_provider", "enabled": True, "priority": 4}, + ], + # ... +} +``` + +## Best Practices + +### Error Handling + +- Always catch exceptions in provider methods +- Return empty data structures (`{}` or `[]`) on error rather than raising +- Log errors with appropriate detail level + +### Rate Limiting + +- Respect API rate limits +- Implement appropriate delays between requests +- Use exponential backoff for retries + +### Symbol Normalization + +- Override `normalize_symbol()` if your provider uses different symbol formats +- Handle common variations (BTC/USD vs BTC-USD vs BTCUSD) + +### Caching + +- The pricing service handles caching automatically +- Focus on providing fresh data from the API +- Don't implement your own caching layer + +### Testing + +Create unit tests in `tests/unit/data/providers/test_my_provider.py`: + +```python +"""Unit tests for MyProvider.""" + +import pytest +from unittest.mock import Mock, patch +from src.data.providers.my_provider import MyProvider + +class TestMyProvider: + def test_connect(self): + provider = MyProvider() + result = provider.connect() + assert result is True + + def test_get_ticker(self): + provider = MyProvider() + provider.connect() + ticker = provider.get_ticker("BTC/USD") + assert 'last' in ticker + assert ticker['last'] > 0 +``` + +## Example: CoinGecko Provider + +See `src/data/providers/coingecko_provider.py` for a complete example of a REST API-based provider. + +## Example: CCXT Provider + +See `src/data/providers/ccxt_provider.py` for an example of a provider that wraps an existing library (CCXT). + +## Health Monitoring + +The pricing service automatically monitors provider health: +- Tracks success/failure rates +- Measures response times +- Implements circuit breaker pattern +- Automatically fails over to next provider + +Your provider doesn't need to implement health monitoring - it's handled by the `HealthMonitor` class. + +## Subscriptions + +For real-time updates, implement `subscribe_ticker()`. The service expects: +- Subscriptions to be persistent until `unsubscribe_ticker()` is called +- Callbacks to be invoked with ticker data dictionaries +- Graceful handling of connection failures + +If WebSocket is not supported, use polling with appropriate intervals (typically 1-5 seconds for ticker data). + +## Questions? + +For more information, see: +- `src/data/providers/base_provider.py` - Base interface +- `src/data/pricing_service.py` - Service implementation +- `src/data/health_monitor.py` - Health monitoring diff --git a/docs/developer/release_process.md b/docs/developer/release_process.md new file mode 100644 index 00000000..80302fde --- /dev/null +++ b/docs/developer/release_process.md @@ -0,0 +1,180 @@ +# Release Process + +This guide outlines the process for releasing new versions of Crypto Trader. + +## Version Numbering + +Follow [Semantic Versioning](https://semver.org/): + +- **MAJOR**: Breaking changes +- **MINOR**: New features (backward compatible) +- **PATCH**: Bug fixes (backward compatible) + +Example: `1.2.3` + +## Release Checklist + +### Pre-Release + +- [ ] All tests passing +- [ ] Code coverage meets threshold (95%) +- [ ] Documentation is up to date +- [ ] Changelog is updated +- [ ] Version number updated +- [ ] All issues for milestone are closed + +### Release Steps + +1. **Update Version** + + Update version in: + - `setup.py` + - `docs/api/source/conf.py` + - `src/__init__.py` (if exists) + +2. **Update Changelog** + + Document all changes in `CHANGELOG.md`: + - Added features + - Changed features + - Deprecated features + - Removed features + - Fixed bugs + - Security updates + +3. **Create Release Branch** + + ```bash + git checkout -b release/v1.2.3 + git push origin release/v1.2.3 + ``` + +4. **Build and Test** + + ```bash + # Run all tests + pytest + + # Build AppImage + ./packaging/build_appimage.sh + + # Test AppImage + ./crypto_trader-*.AppImage --test + ``` + +5. **Create Release Tag** + + ```bash + git tag -a v1.2.3 -m "Release v1.2.3" + git push origin v1.2.3 + ``` + +6. **Create GitHub Release** + + - Create release on GitHub + - Upload AppImage + - Add release notes from changelog + - Mark as latest release + +7. **Merge to Main** + + ```bash + git checkout main + git merge release/v1.2.3 + git push origin main + ``` + +## AppImage Release + +### Building AppImage + +```bash +cd packaging +./build_appimage.sh +``` + +### AppImage Requirements + +- Must be executable +- Must include all dependencies +- Must be tested on target system +- Must include version in filename + +### AppImage Distribution + +- Upload to GitHub Releases +- Include checksums (SHA256) +- Provide installation instructions +- Test on clean system + +## Post-Release + +1. **Announce Release** + + - Update website (if applicable) + - Post release notes + - Notify users + +2. **Monitor** + + - Watch for issues + - Monitor error reports + - Track download statistics + +3. **Hotfixes** + + If critical bugs are found: + - Create hotfix branch + - Fix and test + - Release patch version + - Merge to main + +## Release Notes Template + +```markdown +# Release v1.2.3 + +## Added +- New feature X +- New feature Y + +## Changed +- Improved performance of Z +- Updated dependency versions + +## Fixed +- Fixed bug in A +- Fixed issue with B + +## Security +- Security update for C + +## Installation + +Download the AppImage: +- [crypto_trader-1.2.3-x86_64.AppImage](link) + +SHA256: [checksum] + +## Upgrade Notes + +[Any upgrade instructions] +``` + +## Emergency Releases + +For critical security or stability issues: + +1. Create hotfix branch from latest release +2. Apply fix +3. Test thoroughly +4. Release immediately +5. Merge to main and develop + +## Version History + +Maintain version history in: +- `CHANGELOG.md` +- GitHub Releases +- Documentation + diff --git a/docs/developer/setup.md b/docs/developer/setup.md new file mode 100644 index 00000000..39716524 --- /dev/null +++ b/docs/developer/setup.md @@ -0,0 +1,315 @@ +# Development Environment Setup + +This guide will help you set up a development environment for Crypto Trader. + +## Prerequisites + +- Python 3.11 or higher +- Node.js 18 or higher +- Git +- Virtual environment tool (venv or virtualenv) +- Code editor (VS Code, PyCharm, etc.) +- PostgreSQL 14 or higher +- Redis 5.0 or higher + +## Initial Setup + +### 1. Clone the Repository + +```bash +git clone +cd crypto_trader +``` + +### 2. Create Virtual Environment + +```bash +python -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate +``` + +### 3. Install Python Dependencies + +```bash +# Install main dependencies +pip install -r requirements.txt + +# Install development dependencies +pip install -r tests/requirements.txt + +# Install documentation dependencies +pip install -r docs/requirements.txt +``` + +### 4. Install Frontend Dependencies + +```bash +cd frontend +npm install +cd .. +``` + +### 5. Install Pre-commit Hooks (Optional) + +```bash +pip install pre-commit +pre-commit install +``` + +## Development Tools + +### Recommended IDE Setup + +**VS Code**: +- Python extension +- Pylance for type checking +- Python Test Explorer +- ESLint and Prettier for frontend +- YAML extension + +**PyCharm**: +- Configure Python interpreter +- Set up test runner +- Configure code style +- Enable JavaScript/TypeScript support + +### Code Quality Tools + +```bash +# Install linting and formatting tools +pip install black flake8 mypy pylint + +# Format code +black src/ tests/ + +# Lint code +flake8 src/ tests/ + +# Type checking +mypy src/ +``` + +## Database Setup + +### PostgreSQL (Required) + +You must have a PostgreSQL instance running for development. + +```bash +# Install PostgreSQL +# Create development database +createdb crypto_trader_dev + +# Update config.yaml or set env var +# DATABASE_URL=postgresql+asyncpg://user:password@localhost/crypto_trader_dev +``` + +### SQLite (Internal) + +Used internally for unit tests (in-memory) only. No setup required. + +## Redis Setup + +Redis is required for distributed state management and Celery background tasks (e.g., ML model retraining). + +```bash +# Install Redis (Ubuntu/Debian) +sudo apt-get install redis-server +``` + +**Starting Redis**: + +```bash +# Option 1: Using system service (requires sudo) +sudo service redis-server start + +# Option 2: Direct daemon mode (for containers/restricted environments) +redis-server --daemonize yes + +# Verify +redis-cli ping # Should return PONG +``` + +> **Note**: In containerized environments (Toolbox, Distrobox, Docker, etc.) where `sudo` is not available, use the direct daemon mode option. If you see "Connection refused" errors when using features like "Retrain Model", Redis is likely not running. + +### Default Configuration + +Redis defaults to `localhost:6379`. Override in `config.yaml`: + +```yaml +redis: + host: "127.0.0.1" + port: 6379 + db: 0 +``` + +## Running the Application + +### Start All Services (Recommended) + +Use the helper script to start all services: + +```bash +./scripts/start_all.sh +``` + +### Start Services Manually + +```bash +# 1. Start Redis +# Use sudo if available, otherwise direct daemon mode +sudo service redis-server start # OR: redis-server --daemonize yes + +# 2. Start Celery Worker (background tasks) +celery -A src.worker.app worker --loglevel=info & + +# 3. Start Backend API +uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000 & + +# 4. Start Frontend +cd frontend && npm run dev +``` + +### Access Points + +- **Frontend**: http://localhost:3000 +- **Backend API**: http://localhost:8000 +- **API Docs**: http://localhost:8000/docs + +### Verify Redis/Celery Integration + +```bash +python scripts/verify_redis.py +``` + +## Running Tests + +```bash +# Run all tests +pytest + +# Run with coverage +pytest --cov=src --cov-report=html + +# Run specific test file +pytest tests/unit/core/test_redis.py + +# Run Redis/Celery tests +pytest tests/unit/core/test_redis.py tests/unit/worker/test_tasks.py -v + +# Run with verbose output +pytest -v +``` + +## Project Structure + +``` +crypto_trader/ +├── src/ # Backend source code +│ ├── autopilot/ # Intelligent autopilot +│ ├── core/ # Core utilities (config, redis, logging) +│ ├── strategies/ # Trading strategies +│ ├── trading/ # Trading engine +│ └── worker/ # Celery tasks +├── backend/ # FastAPI application +│ ├── api/ # API endpoints +│ └── main.py # Application entry point +├── frontend/ # React frontend +│ ├── src/ # React source +│ └── package.json # Frontend dependencies +├── tests/ # Test suite +├── scripts/ # Utility scripts +│ ├── start_all.sh # Start all services +│ └── verify_redis.py # Verify Redis/Celery +├── docs/ # Documentation +└── requirements.txt # Python dependencies +``` + +## Common Development Tasks + +### Adding a New Celery Task + +1. Add task function in `src/worker/tasks.py` +2. Configure task routing in `src/worker/app.py` (optional) +3. Create API endpoint in `backend/api/` +4. Write unit tests in `tests/unit/worker/` + +### Adding a New API Endpoint + +1. Create/update router in `backend/api/` +2. Register router in `backend/main.py` +3. Add frontend API client in `frontend/src/api/` +4. Write tests + +### Running Documentation + +```bash +cd docs/api +make html +# Open build/html/index.html +``` + +### Building AppImage + +```bash +./packaging/build_appimage.sh +``` + +## Debugging + +### Enable Debug Logging + +Set in `config.yaml`: +```yaml +logging: + level: DEBUG +``` + +### Using Debugger + +```bash +# VS Code: Set breakpoints and use debugger +# PyCharm: Configure debug configuration +# Command line: Use pdb +python -m pdb -c continue backend/main.py +``` + +### Checking Celery Tasks + +```bash +# Monitor tasks +celery -A src.worker.app events + +# Inspect active tasks +celery -A src.worker.app inspect active +``` + +## Troubleshooting + +**Import errors?** +- Verify virtual environment is activated +- Check PYTHONPATH +- Reinstall dependencies + +**Redis connection failed / "Connection refused" error?** +- Ensure Redis is running: `redis-cli ping` (should return `PONG`) +- Start Redis if not running: + - With sudo: `sudo service redis-server start` + - Without sudo: `redis-server --daemonize yes` +- Check host/port configuration in `config.yaml` +- Verify firewall rules + +**Celery tasks not executing?** +- Ensure worker is running: `ps aux | grep celery` +- Check worker logs: `tail -f celery.log` +- Verify Redis is accessible + +**Tests failing?** +- Check test database setup +- Verify test fixtures +- Review test logs + +**Frontend not connecting?** +- Check API is running on port 8000 +- Verify CORS settings in backend +- Check browser console for errors diff --git a/docs/developer/testing.md b/docs/developer/testing.md new file mode 100644 index 00000000..edf5efe0 --- /dev/null +++ b/docs/developer/testing.md @@ -0,0 +1,421 @@ +# Testing Guide + +This guide covers testing practices for Crypto Trader, including backend API testing, integration testing, and end-to-end testing. + +## Test Structure + +Tests are organized to mirror the source code structure: + +``` +tests/ +├── unit/ # Unit tests +│ ├── backend/ # Backend API tests +│ ├── core/ # Core module tests +│ └── ... +├── integration/ # Integration tests +├── e2e/ # End-to-end tests +├── fixtures/ # Test fixtures +├── utils/ # Test utilities +└── performance/ # Performance benchmarks +``` + +## Running Tests + +### All Tests + +```bash +pytest +``` + +### With Coverage + +```bash +pytest --cov=src --cov-report=html +``` + +### Specific Test File + +```bash +pytest tests/unit/core/test_config.py +``` + +### Specific Test + +```bash +pytest tests/unit/core/test_config.py::test_config_loading +``` + +### Verbose Output + +```bash +pytest -v +``` + +### Test Categories + +```bash +# Unit tests only +pytest -m unit + +# Integration tests only +pytest -m integration + +# End-to-end tests only +pytest -m e2e +``` + +## Writing Tests + +### Unit Tests + +Test individual functions and classes in isolation: + +```python +import pytest +from unittest.mock import Mock, patch +from src.core.config import get_config + +class TestConfig: + """Tests for configuration system.""" + + def test_config_loading(self): + """Test configuration loading.""" + config = get_config() + assert config is not None + assert config.config_dir is not None +``` + +### Backend API Tests + +Test FastAPI endpoints using TestClient: + +```python +from fastapi.testclient import TestClient +from backend.main import app + +client = TestClient(app) + +def test_get_orders(): + """Test getting orders endpoint.""" + response = client.get("/api/trading/orders") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + +def test_place_order(): + """Test placing an order.""" + order_data = { + "exchange_id": 1, + "symbol": "BTC/USD", + "side": "buy", + "type": "market", + "quantity": 0.1, + "paper_trading": True + } + response = client.post("/api/trading/orders", json=order_data) + assert response.status_code == 201 + data = response.json() + assert data["symbol"] == "BTC/USD" +``` + +### Integration Tests + +Test component interactions: + +```python +import pytest +from fastapi.testclient import TestClient +from backend.main import app + +@pytest.mark.integration +def test_trading_workflow(client: TestClient): + """Test complete trading workflow.""" + # Place order + order_response = client.post("/api/trading/orders", json={...}) + assert order_response.status_code == 201 + order_id = order_response.json()["id"] + + # Check order status + status_response = client.get(f"/api/trading/orders/{order_id}") + assert status_response.status_code == 200 + + # Check portfolio update + portfolio_response = client.get("/api/portfolio/current") + assert portfolio_response.status_code == 200 +``` + +### End-to-End Tests + +Test complete user workflows: + +```python +@pytest.mark.e2e +async def test_paper_trading_scenario(): + """Test complete paper trading scenario.""" + # Test full application flow through API + pass +``` + +## Test Fixtures + +Use fixtures for common setup: + +```python +import pytest +from fastapi.testclient import TestClient +from backend.main import app +from src.core.database import get_database + +@pytest.fixture +def client(): + """Test client fixture.""" + return TestClient(app) + +@pytest.fixture +def mock_exchange(): + """Mock exchange adapter.""" + exchange = Mock() + exchange.fetch_balance.return_value = {'USD': {'free': 1000}} + return exchange + +@pytest.fixture +def test_db(): + """Test database fixture.""" + # Use in-memory SQLite for unit tests (fast, isolated) + # Note: Requires aiosqlite installed as test dependency + db = get_database() + # Setup test data + yield db + # Cleanup +``` + +## Mocking + +### Mocking External APIs + +```python +from unittest.mock import patch, AsyncMock + +@patch('src.exchanges.coinbase.ccxt') +async def test_coinbase_connection(mock_ccxt): + """Test Coinbase connection.""" + mock_exchange = AsyncMock() + mock_ccxt.coinbaseadvanced.return_value = mock_exchange + mock_exchange.load_markets = AsyncMock() + + adapter = CoinbaseExchange(...) + await adapter.connect() + + assert adapter.is_connected +``` + +### Mocking Database + +```python +@pytest.fixture +def test_db(): + """Create test database.""" + # Use in-memory SQLite for unit tests (internal use only) + from src.core.database import Database + db = Database("sqlite:///:memory:") + db.create_tables() + return db +``` + +### Mocking Services + +```python +@pytest.fixture +def mock_trading_engine(): + """Mock trading engine.""" + engine = Mock() + engine.execute_order.return_value = MockOrder(id=1, status="filled") + return engine + +def test_place_order_endpoint(mock_trading_engine): + """Test order placement with mocked engine.""" + with patch('backend.api.trading.get_trading_engine', return_value=mock_trading_engine): + response = client.post("/api/trading/orders", json={...}) + assert response.status_code == 201 +``` + +## Async Testing + +Use `pytest-asyncio` for async tests: + +```python +import pytest + +@pytest.mark.asyncio +async def test_async_function(): + """Test async function.""" + result = await my_async_function() + assert result is not None +``` + +## WebSocket Testing + +Test WebSocket endpoints: + +```python +from fastapi.testclient import TestClient + +def test_websocket_connection(client: TestClient): + """Test WebSocket connection.""" + with client.websocket_connect("/ws/") as websocket: + # Send message + websocket.send_json({"type": "subscribe", "channel": "prices"}) + + # Receive message + data = websocket.receive_json() + assert data["type"] == "price_update" +``` + +## Test Coverage + +Target 95% code coverage: + +```bash +# Generate coverage report +pytest --cov=src --cov-report=html + +# View in browser +open htmlcov/index.html +``` + +### Coverage Configuration + +Configure in `pytest.ini`: + +```ini +[pytest] +cov = src +cov-report = term-missing +cov-report = html +cov-fail-under = 95 +``` + +## Frontend Testing + +The frontend uses React Testing Library and Vitest for testing. See [Frontend Testing Guide](./frontend_testing.md) for detailed information. + +### Quick Start + +```bash +cd frontend +npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event vitest jsdom +npm test +``` + +### Component Testing + +```typescript +import { render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { describe, it, expect, vi } from 'vitest'; +import StrategiesPage from '../pages/StrategiesPage'; +import * as strategiesApi from '../api/strategies'; + +vi.mock('../api/strategies'); + +describe('StrategiesPage', () => { + it('renders and displays strategies', async () => { + const mockStrategies = [{ id: 1, name: 'Test Strategy', ... }]; + vi.mocked(strategiesApi.strategiesApi.listStrategies).mockResolvedValue(mockStrategies); + + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText('Test Strategy')).toBeInTheDocument(); + }); + }); +}); +``` + +### Testing New Components + +All new components should have corresponding test files: +- `StrategiesPage` → `StrategiesPage.test.tsx` +- `TradingPage` → `TradingPage.test.tsx` +- `StrategyDialog` → `StrategyDialog.test.tsx` +- `OrderForm` → `OrderForm.test.tsx` +- And all other new components + +See [Frontend Testing Guide](./frontend_testing.md) for comprehensive testing patterns. + +## Best Practices + +1. **Test Independence**: Tests should not depend on each other +2. **Fast Tests**: Unit tests should run quickly (< 1 second each) +3. **Clear Names**: Test names should describe what they test +4. **One Assertion**: Prefer one assertion per test when possible +5. **Mock External**: Mock external dependencies (APIs, databases) +6. **Test Edge Cases**: Test boundary conditions and errors +7. **Documentation**: Document complex test scenarios +8. **Arrange-Act-Assert**: Structure tests clearly +9. **Use Fixtures**: Reuse common setup code +10. **Isolation**: Each test should be able to run independently + +## Test Organization + +### Unit Tests + +- Test single function/class +- Mock external dependencies +- Fast execution +- High coverage + +### Integration Tests + +- Test component interactions +- Use test database +- Test real workflows +- Moderate speed + +### E2E Tests + +- Test complete user flows +- Use test environment +- Slow execution +- Critical paths only + +## Continuous Integration + +Tests run automatically in CI/CD: + +- All tests must pass +- Coverage must meet threshold (95%) +- Code style must pass +- Type checking must pass (if using mypy) + +## Debugging Tests + +### Verbose Output + +```bash +pytest -vv # Very verbose +pytest -s # Show print statements +``` + +### Debugging Failed Tests + +```bash +# Drop into debugger on failure +pytest --pdb + +# Drop into debugger on first failure +pytest -x --pdb +``` + +### Running Last Failed Tests + +```bash +pytest --lf # Last failed +pytest --ff # Failed first +``` diff --git a/docs/developer/ui_development.md b/docs/developer/ui_development.md new file mode 100644 index 00000000..5fd9cc52 --- /dev/null +++ b/docs/developer/ui_development.md @@ -0,0 +1,346 @@ +# Frontend Development Guide + +This guide explains how to develop and extend frontend components in Crypto Trader. + +## Tech Stack + +- **React 18** - UI framework +- **TypeScript** - Type safety +- **Material-UI (MUI)** - Component library +- **React Query** - Data fetching and caching +- **React Router** - Routing +- **Vite** - Build tool + +## Project Structure + +``` +frontend/ +├── src/ +│ ├── pages/ # Page components +│ ├── components/ # Reusable components +│ ├── api/ # API client functions +│ ├── hooks/ # Custom React hooks +│ ├── types/ # TypeScript types +│ └── App.tsx # Main app component +├── public/ # Static assets +└── package.json # Dependencies +``` + +## Development Setup + +```bash +cd frontend +npm install +npm run dev +``` + +Access at: http://localhost:3000 + +## Creating a New Page + +1. Create page component in `src/pages/`: + +```typescript +// src/pages/MyPage.tsx +import { Box, Typography } from '@mui/material'; + +export function MyPage() { + return ( + + My Page + + ); +} +``` + +2. Add route in `src/App.tsx`: + +```typescript +import { MyPage } from './pages/MyPage'; + +} /> +``` + +## API Integration + +### Creating API Functions + +```typescript +// src/api/myService.ts +import { apiClient } from './client'; + +export const myServiceApi = { + async getData() { + const response = await apiClient.get('/api/my-service/data'); + return response.data; + }, + + async createItem(data: CreateItemDto) { + const response = await apiClient.post('/api/my-service/items', data); + return response.data; + } +}; +``` + +### Using React Query + +```typescript +import { useQuery, useMutation } from '@tanstack/react-query'; +import { myServiceApi } from '@/api/myService'; + +function MyComponent() { + // Fetch data + const { data, isLoading, error } = useQuery({ + queryKey: ['myData'], + queryFn: myServiceApi.getData + }); + + // Mutate data + const mutation = useMutation({ + mutationFn: myServiceApi.createItem, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['myData'] }); + } + }); + + return ( +
+ {isLoading && } + {data && } + +
+ ); +} +``` + +## Creating Reusable Components + +```typescript +// src/components/DataTable.tsx +import { Table, TableBody, TableCell, TableHead, TableRow } from '@mui/material'; + +interface DataTableProps { + data: T[]; + columns: Column[]; +} + +export function DataTable({ data, columns }: DataTableProps) { + return ( + + + + {columns.map(col => ( + {col.header} + ))} + + + + {data.map((row, idx) => ( + + {columns.map(col => ( + {col.render(row)} + ))} + + ))} + +
+ ); +} +``` + +## Custom Hooks + +```typescript +// src/hooks/useOrders.ts +import { useQuery, useMutation } from '@tanstack/react-query'; +import { tradingApi } from '@/api/trading'; + +export function useOrders() { + const { data: orders, isLoading, error } = useQuery({ + queryKey: ['orders'], + queryFn: () => tradingApi.getOrders() + }); + + const placeOrder = useMutation({ + mutationFn: tradingApi.placeOrder, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['orders'] }); + } + }); + + return { + orders: orders ?? [], + isLoading, + error, + placeOrder: placeOrder.mutate + }; +} +``` + +## WebSocket Integration + +The application uses an enhanced WebSocket system with message type handling: + +```typescript +// Using the WebSocket context +import { useWebSocketContext } from '@/components/WebSocketProvider'; +import { useRealtimeData } from '@/hooks/useRealtimeData'; + +function MyComponent() { + const { isConnected, lastMessage, subscribe } = useWebSocketContext(); + + // Subscribe to specific message types + useEffect(() => { + const unsubscribe = subscribe('order_update', (message) => { + // Handle order update + console.log('Order updated:', message); + }); + + return unsubscribe; + }, [subscribe]); + + // Or use the real-time data hook for automatic query invalidation + useRealtimeData(); // Automatically handles common message types +} +``` + +### Message Types + +The WebSocket supports these message types: +- `order_update`: Order status changes +- `position_update`: Position changes +- `price_update`: Price updates +- `alert_triggered`: Alert notifications +- `strategy_signal`: Strategy signal notifications +- `system_event`: System events and errors + +## Form Handling + +```typescript +import { useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; + +const schema = yup.object({ + symbol: yup.string().required(), + quantity: yup.number().positive().required() +}); + +function OrderForm() { + const { register, handleSubmit, formState: { errors } } = useForm({ + resolver: yupResolver(schema) + }); + + const onSubmit = async (data) => { + await tradingApi.placeOrder(data); + }; + + return ( +
+ + + + ); +} +``` + +## Error Handling + +The application uses a centralized Snackbar context for error handling: + +```typescript +import { useSnackbar } from '@/contexts/SnackbarContext'; +import ErrorDisplay from '@/components/ErrorDisplay'; + +function MyComponent() { + const { showError, showSuccess, showWarning, showInfo } = useSnackbar(); + + const handleAction = async () => { + try { + await apiCall(); + showSuccess('Action completed successfully'); + } catch (err) { + showError(err instanceof Error ? err.message : 'An error occurred'); + } + }; + + // For complex error display with retry + return ( + <> + {error && ( + + )} + + ); +} +``` + +### Available Components + +- `ErrorDisplay`: Enhanced error display with retry functionality +- `LoadingSkeleton`: Loading placeholders for tables, cards, lists +- `ProgressOverlay`: Overlay with progress indicator +- `StatusIndicator`: Connection status indicators +- `DataFreshness`: Data freshness timestamps +- `HelpTooltip`: Contextual help tooltips +- `InfoCard`: Collapsible information cards +- `OperationsPanel`: Panel showing running operations + +## Styling + +### Material-UI Theming + +```typescript +// src/theme.ts +import { createTheme } from '@mui/material/styles'; + +export const theme = createTheme({ + palette: { + mode: 'dark', + primary: { + main: '#1976d2' + } + } +}); +``` + +### Component Styling + +Use Material-UI's `sx` prop for inline styles: + +```typescript + + Content + +``` + +## Testing + +See [Testing Guide](./testing.md) for frontend testing strategies. + +## Best Practices + +1. **Type Safety**: Always use TypeScript types +2. **Component Composition**: Prefer composition over inheritance +3. **Custom Hooks**: Extract reusable logic into hooks +4. **Error Handling**: Handle errors gracefully with user feedback +5. **Loading States**: Always show loading indicators +6. **Accessibility**: Use semantic HTML and ARIA labels +7. **Performance**: Use React.memo, useMemo, useCallback appropriately +8. **Code Splitting**: Use lazy loading for large components +9. **Form Validation**: Validate on both client and server +10. **Consistent Patterns**: Follow established patterns in the codebase diff --git a/docs/frontend_changes_summary.md b/docs/frontend_changes_summary.md new file mode 100644 index 00000000..502e2559 --- /dev/null +++ b/docs/frontend_changes_summary.md @@ -0,0 +1,156 @@ +# Frontend UI Enhancement Summary + +This document summarizes the comprehensive frontend enhancements completed to ensure all documented features are accessible in the UI and improve UX transparency. + +## New Pages Added + +### 1. Strategy Management Page (`/strategies`) +- Full CRUD operations for strategies +- Create, edit, delete strategies +- Start/stop strategy controls +- Parameter configuration UI for all strategy types (RSI, MACD, Moving Average, Confirmed, Divergence, Bollinger, Consensus, DCA, Grid, Momentum) +- Strategy status indicators +- Performance metrics display + +### 2. Manual Trading Page (`/trading`) +- Order placement form with all order types (Market, Limit, Stop Loss, Take Profit, Trailing Stop, OCO, Iceberg) +- Active orders table with cancel functionality +- Order history with filters +- Position management with close position functionality +- Real-time order and position updates + +## Enhanced Existing Pages + +### Dashboard +- System health indicators (WebSocket, Database, Exchange connections) +- Operations panel showing running operations +- Data freshness indicators +- Enhanced error handling with retry options +- Loading states for all async operations +- Real-time status updates + +### Portfolio +- Position closing interface +- Portfolio allocation pie charts +- Data freshness indicators +- Enhanced export functionality with success/error feedback +- Card-based position view with detailed P&L + +### Backtesting +- Progress overlay for long-running backtests +- Operations panel integration +- Enhanced error messages +- Info card about parameter optimization (requires backend API) + +### Settings +- Alert history view showing triggered alerts +- Enhanced exchange management with status indicators +- Better connection testing with detailed feedback +- Improved error handling throughout + +## New Components Created + +### UX Components +- `LoadingSkeleton` - Loading placeholders for tables, cards, lists +- `ProgressOverlay` - Overlay with progress indicator for long operations +- `ErrorDisplay` - Enhanced error display with retry functionality +- `StatusIndicator` - Connection status indicators +- `SystemHealth` - System health dashboard widget +- `DataFreshness` - Timestamp indicators showing data age +- `HelpTooltip` - Contextual help tooltips +- `InfoCard` - Collapsible information cards +- `OperationsPanel` - Panel showing running operations and progress + +### Feature Components +- `StrategyDialog` - Create/edit strategy dialog with tabs +- `StrategyParameterForm` - Dynamic parameter forms for each strategy type +- `OrderForm` - Order placement form with all order types +- `PositionCard` - Position display card with close functionality +- `AlertHistory` - Alert history table component + +## Infrastructure Improvements + +### Error Handling +- Replaced all `alert()` calls with Snackbar notifications +- Created `SnackbarContext` for global error/success messaging +- Added error boundaries with recovery options +- Inline validation errors in forms + +### Real-time Updates +- Enhanced WebSocket hook with message type handling +- Created `useRealtimeData` hook for automatic query invalidation +- Message subscription system for different event types +- Real-time updates for orders, positions, prices, alerts, strategy signals + +### State Management +- Better loading states throughout +- Progress indicators for long operations +- Data freshness tracking +- Operation visibility + +## Navigation Updates + +- Added "Strategies" menu item +- Added "Trading" menu item +- Improved navigation organization + +## Testing Notes + +The following areas should be tested: + +1. **Strategy Management** + - Create/edit/delete strategies + - Start/stop strategies + - Parameter validation + - Strategy type switching + +2. **Trading** + - Order placement (all order types) + - Order cancellation + - Position closing + - Real-time updates + +3. **Real-time Features** + - WebSocket connection/reconnection + - Message handling + - Query invalidation on updates + +4. **Error Handling** + - Network errors + - Validation errors + - Backend errors + - Retry functionality + +5. **Loading States** + - Skeleton loaders + - Progress overlays + - Button disabled states + +## Documentation Updates Needed + +1. **User Manual Updates** + - Add Strategy Management section + - Add Manual Trading section + - Update Dashboard documentation + - Update Portfolio documentation with new features + - Update Settings documentation with alert history + +2. **API Documentation** + - Document WebSocket message types + - Document new endpoints if any + - Update response schemas + +3. **Developer Documentation** + - Component architecture + - State management patterns + - WebSocket integration guide + - Error handling patterns + +## Known Limitations + +1. **Parameter Optimization**: UI structure is in place, but requires backend API endpoints for optimization methods (Grid Search, Genetic Algorithm, Bayesian Optimization) + +2. **Advanced Order Types**: OCO and Iceberg orders have UI support but may need backend implementation verification + +3. **Exchange Status**: Exchange connection status is displayed but may need backend health check endpoints for accurate status + diff --git a/docs/guides/pairs_trading_setup.md b/docs/guides/pairs_trading_setup.md new file mode 100644 index 00000000..c0bd3a90 --- /dev/null +++ b/docs/guides/pairs_trading_setup.md @@ -0,0 +1,192 @@ +# Pairs Trading Strategy - Configuration Guide + +This guide walks you through configuring and enabling the Statistical Arbitrage (Pairs Trading) strategy in your crypto trading application. + +## Overview + +Pairs Trading is a market-neutral strategy that profits from the relative price movements between two correlated assets. When the spread between two assets diverges beyond a statistical threshold (Z-Score), the strategy generates signals to trade the reversion. + +--- + +## Step 1: Navigate to Strategy Management + +1. Open your application in the browser (typically `http://localhost:5173`) +2. Click on **"Strategies"** in the navigation menu +3. You'll see the Strategy Management page + +--- + +## Step 2: Create a New Strategy + +1. Click the **"Create Strategy"** button in the top-right corner +2. The Strategy Dialog will open + +--- + +## Step 3: Configure Basic Settings + +Fill in the following fields: + +| Field | Description | Example | +|-------|-------------|---------| +| **Strategy Name** | A descriptive name for your strategy | `SOL-AVAX Pairs Trade` | +| **Strategy Type** | Select from dropdown | `Statistical Arbitrage (Pairs)` | +| **Primary Symbol** | The main asset to trade | `SOL/USD` | +| **Exchange** | Your configured exchange | `Coinbase` or `Binance` | +| **Timeframe** | Candlestick interval | `1h` (recommended) | + +--- + +## Step 4: Configure Pairs Trading Parameters + +After selecting "Statistical Arbitrage (Pairs)" as the strategy type, you'll see the parameters section: + +### Required Parameters: + +| Parameter | Description | Default | Recommended Range | +|-----------|-------------|---------|-------------------| +| **Second Symbol** | The correlated asset to pair with | - | `AVAX/USD`, `ETH/USD`, etc. | +| **Lookback Period** | Rolling window for statistics | `20` | 15-50 | +| **Z-Score Threshold** | Trigger level for signals | `2.0` | 1.5-3.0 | + +### Parameter Explanations: + +- **Second Symbol**: Choose an asset that moves similarly to your primary symbol. Common pairs include: + - `BTC/USD` ↔ `ETH/USD` (highly correlated) + - `SOL/USD` ↔ `AVAX/USD` (Layer 1s) + - `DOGE/USD` ↔ `SHIB/USD` (meme coins) + +- **Lookback Period**: Number of candles used to calculate rolling mean and standard deviation. Higher values = smoother but slower to react. + +- **Z-Score Threshold**: How many standard deviations from the mean before triggering: + - `1.5` = More frequent signals, smaller moves + - `2.0` = Balanced (default) + - `2.5-3.0` = Fewer signals, larger moves + +--- + +## Step 5: Additional Settings + +| Setting | Description | Recommendation | +|---------|-------------|----------------| +| **Paper Trading** | Enable for testing | ✅ Start with Paper Trading ON | +| **Auto Execute** | Automatically place trades | ❌ Keep OFF initially to observe signals | + +--- + +## Step 6: Save and Enable + +1. Click **"Create"** to save the strategy +2. The strategy will appear in your strategy list with status "Disabled" +3. Click the **▶️ Play** button to enable the strategy + +--- + +## Step 7: Monitor the Spread + +Once enabled, scroll down on the Strategies page to see the **Pairs Trading Analysis** section: + +- **Current Spread**: The ratio of Primary Symbol / Secondary Symbol prices +- **Z-Score**: How many standard deviations the current spread is from its mean +- **Signal State**: Shows if a signal is active (Long Spread, Short Spread, or Neutral) + +### Understanding the Charts: + +1. **Spread History Chart**: Shows the ratio over time +2. **Z-Score Chart**: Shows statistical deviation with threshold lines + - Green dashed line: Buy threshold (-2.0) + - Red dashed line: Sell threshold (+2.0) + +--- + +## Signal Logic + +| Condition | Signal | Action | +|-----------|--------|--------| +| Z-Score > +Threshold | **SELL** | Sell Primary, Buy Secondary | +| Z-Score < -Threshold | **BUY** | Buy Primary, Sell Secondary | +| Z-Score between thresholds | **HOLD** | No action (neutral) | + +### Example: +If you're trading `SOL/USD` vs `AVAX/USD`: +- **Z-Score = +2.5**: SOL is overvalued relative to AVAX → Sell SOL, Buy AVAX +- **Z-Score = -2.5**: SOL is undervalued relative to AVAX → Buy SOL, Sell AVAX + +--- + +## Tips for Success + +1. **Choose Correlated Pairs**: The strategy works best with assets that historically move together. Check correlation before pairing. + +2. **Start with Paper Trading**: Always test with paper trading first to understand signal frequency and behavior. + +3. **Consider Timeframe**: + - `1h` is good for daily monitoring + - `4h` for longer-term positions + - `15m` for more active trading (higher risk) + +4. **Monitor Volatility**: The strategy performs best in ranging/mean-reverting markets. Trending markets can cause losses. + +5. **Adjust Threshold**: If you get too many signals, increase the threshold. Too few? Lower it. + +--- + +## Troubleshooting + +| Issue | Solution | +|-------|----------| +| No data appearing | Ensure both symbols are available on your selected exchange | +| Z-Score always near 0 | Try increasing lookback period or verify price data is flowing | +| Too many signals | Increase Z-Score threshold (e.g., 2.5 or 3.0) | +| Strategy not executing | Check if Auto Execute is enabled in Settings | + +--- + +## Example Configuration + +``` +Name: ETH-SOL Mean Reversion +Type: Statistical Arbitrage (Pairs) +Primary Symbol: ETH/USD +Second Symbol: SOL/USD +Lookback Period: 20 +Z-Score Threshold: 2.0 +Timeframe: 1h +Paper Trading: ON +``` + +This configuration would: +1. Monitor the ETH/SOL price ratio +2. Generate a BUY signal when ETH is historically cheap vs SOL (Z-Score < -2) +3. Generate a SELL signal when ETH is historically expensive vs SOL (Z-Score > +2) + +--- + +## How Execution Works + +When you click **Start** on a Pairs Trading strategy: + +1. **Strategy Scheduler starts** - Runs the strategy on a 60-second interval (configurable) +2. **Each tick**: + - Fetches current prices for both symbols + - Calculates spread ratio and Z-Score + - If Z-Score exceeds threshold → generates signal +3. **Signal execution** (if enabled): + - Executes both legs simultaneously + - Primary symbol: BUY or SELL based on signal + - Secondary symbol: Opposite action +4. **Status updates** visible on Strategies page + +--- + +## Real-Time Status Panel + +When strategies are running, you'll see a **green status panel** on the Strategies page showing: + +- Strategy name and symbol +- Start time +- Number of signals generated +- Last signal type and price +- Last execution tick time + +This updates every 5 seconds automatically. diff --git a/docs/migration_guide.md b/docs/migration_guide.md new file mode 100644 index 00000000..eb3ea2d8 --- /dev/null +++ b/docs/migration_guide.md @@ -0,0 +1,222 @@ +# Migration Guide: PyQt6 to Web Architecture + +This guide explains the migration from PyQt6 desktop application to web-based architecture. + +## Overview + +The application has been migrated from a PyQt6 desktop app to a modern web-based architecture while preserving 90% of the existing Python backend code. + +## What Changed + +### Architecture + +**Before (PyQt6)**: +``` +PyQt6 UI → Direct Python calls → Services → Database +``` + +**After (Web)**: +``` +React UI → HTTP/WebSocket → FastAPI → Services → Database +``` + +### Code Structure + +**Before**: +``` +crypto_trader/ +├── src/ +│ ├── ui/ # PyQt6 widgets +│ ├── trading/ # Trading engine +│ ├── strategies/ # Strategy framework +│ └── ... +└── packaging/ # AppImage build +``` + +**After**: +``` +crypto_trader/ +├── backend/ # FastAPI application +│ ├── api/ # API endpoints +│ └── core/ # Dependencies, schemas +├── frontend/ # React application +│ └── src/ +│ ├── pages/ # Page components +│ ├── api/ # API client +│ └── ... +├── src/ # Existing Python code (unchanged) +└── docker-compose.yml +``` + +## What Was Preserved + +### 100% Preserved (No Changes) + +- `src/trading/engine.py` - Trading engine +- `src/strategies/` - Strategy framework +- `src/portfolio/` - Portfolio tracker +- `src/backtesting/` - Backtesting engine +- `src/risk/` - Risk management +- `src/data/` - Data collection +- `src/exchanges/` - Exchange integrations +- `src/core/database.py` - Database models +- All business logic + +### What Was Replaced + +- **UI Layer**: PyQt6 widgets → React components +- **Communication**: Direct function calls → HTTP API +- **Real-time Updates**: Signal/slot → WebSocket +- **Deployment**: AppImage → Docker + +## Migration Steps + +### 1. API Layer Creation + +Created FastAPI application that wraps existing services: + +```python +# backend/api/trading.py +@router.post("/orders") +async def create_order(order_data: OrderCreate): + # Uses existing trading_engine.execute_order() + order = trading_engine.execute_order(...) + return OrderResponse.from_orm(order) +``` + +### 2. Frontend Development + +Created React frontend with: +- Material-UI for modern components +- React Query for data fetching +- TypeScript for type safety +- WebSocket for real-time updates + +### 3. Docker Deployment + +Created Docker setup for easy deployment: +- Multi-stage build (frontend + backend) +- Single container deployment +- Volume mounts for data persistence + +## Running the New Architecture + +### Development + +1. **Backend**: + ```bash + cd backend + python -m uvicorn main:app --reload + ``` + +2. **Frontend**: + ```bash + cd frontend + npm install + npm run dev + ``` + +### Production + +```bash +docker-compose up --build +``` + +Access at: http://localhost:8000 + +## API Endpoints + +All functionality is now available via REST API: + +- **Trading**: `/api/trading/*` +- **Portfolio**: `/api/portfolio/*` +- **Strategies**: `/api/strategies/*` +- **Backtesting**: `/api/backtesting/*` +- **WebSocket**: `/ws/` for real-time updates + +See API documentation at: http://localhost:8000/docs + +## Key Differences + +### UI Development + +**Before (PyQt6)**: +```python +# Complex QSS styling +self.setStyleSheet(""" + QPushButton { + background-color: #1E1E1E; + ... + } +""") +``` + +**After (React)**: +```tsx +// Modern CSS-in-JS + +``` + +### Data Fetching + +**Before (PyQt6)**: +```python +# Direct function calls +order = trading_engine.execute_order(...) +``` + +**After (React)**: +```tsx +// API calls with React Query +const { data } = useQuery({ + queryKey: ['orders'], + queryFn: () => tradingApi.getOrders() +}) +``` + +### Real-time Updates + +**Before (PyQt6)**: +```python +# Signal/slot connections +collector.signals.price_updated.connect(self._on_price_update) +``` + +**After (React)**: +```tsx +// WebSocket hook +const { lastMessage } = useWebSocket('ws://localhost:8000/ws/') +``` + +## Benefits + +1. **Modern UI**: Access to entire web ecosystem +2. **Cross-platform**: Works on any device +3. **Easier deployment**: Docker vs AppImage +4. **Better development**: Hot-reload, better tooling +5. **Maintainability**: Easier to update +6. **Accessibility**: Access from anywhere + +## Backward Compatibility + +The existing Python code remains unchanged. You can still: +- Import and use services directly +- Run existing tests +- Use the code in other projects + +## Next Steps + +1. Add authentication (JWT) +2. Enhance WebSocket integration +3. Add more charting features +4. Improve mobile responsiveness +5. Add more strategy management features + +## Support + +For issues or questions: +- Check API docs: http://localhost:8000/docs +- Review deployment guide: `docs/deployment/web_architecture.md` +- Check backend logs: `docker-compose logs` diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 00000000..6ec34670 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,5 @@ +sphinx>=7.0.0 +sphinx-rtd-theme>=1.3.0 +sphinx-autodoc-typehints>=1.24.0 +sphinxcontrib-napoleon>=0.7 + diff --git a/docs/user_manual/ALGORITHM_IMPROVEMENTS.md b/docs/user_manual/ALGORITHM_IMPROVEMENTS.md new file mode 100644 index 00000000..081294a7 --- /dev/null +++ b/docs/user_manual/ALGORITHM_IMPROVEMENTS.md @@ -0,0 +1,182 @@ +# Algorithm Improvements and New Features + +This document describes the recent algorithm improvements implemented to improve trading success rates. + +## Overview + +Several advanced algorithms and strategies have been added to improve trade success rates and reduce false signals. These improvements leverage multi-indicator confirmation, divergence detection, ensemble methods, and advanced risk management. + +## New Strategies + +### 1. Confirmed Strategy (Multi-Indicator Confirmation) + +**Purpose**: Reduce false signals by requiring multiple indicators to agree before generating a trade signal. + +**How It Works**: +- Combines signals from RSI, MACD, and Moving Average indicators +- Only generates signals when a configurable number of indicators agree (default: 2) +- Calculates signal strength based on the level of agreement + +**Benefits**: +- 20-30% reduction in false signals +- Higher confidence trades +- Better win rate through confirmation + +**When to Use**: +- When you want to reduce false signals +- For more conservative trading approach +- In markets where single indicators are unreliable + +### 2. Divergence Strategy + +**Purpose**: Identify potential trend reversals by detecting divergences between price and indicators. + +**How It Works**: +- Detects bullish divergence: Price makes lower low, indicator makes higher low → BUY signal +- Detects bearish divergence: Price makes higher high, indicator makes lower high → SELL signal +- Works with RSI or MACD indicators + +**Benefits**: +- 15-25% improvement in entry timing +- Excellent for ranging markets +- Identifies reversal points before they happen + +**When to Use**: +- In ranging/consolidating markets +- For identifying trend reversals +- When looking for contrarian signals + +### 3. Bollinger Bands Mean Reversion + +**Purpose**: Trade mean reversion in ranging markets using Bollinger Bands. + +**How It Works**: +- Buys when price touches lower band in uptrend +- Sells at middle band for profit-taking +- Includes trend filter to avoid counter-trend trades + +**Benefits**: +- Works well in ranging markets +- Clear entry and exit signals +- Risk-controlled through trend filter + +**When to Use**: +- In ranging/consolidating markets +- For mean reversion trading +- When volatility is moderate + +### 4. Consensus Strategy (Ensemble) + +**Purpose**: Combine signals from multiple strategies using weighted voting to improve overall performance. + +**How It Works**: +- Aggregates signals from multiple registered strategies +- Uses performance-based weighting (better performing strategies have more weight) +- Only executes when minimum number of strategies agree + +**Benefits**: +- 15-20% overall improvement through consensus +- Dynamic weighting based on recent performance +- Reduces reliance on single strategy + +**When to Use**: +- When you want to combine multiple strategies +- For more robust signal generation +- When trading with multiple indicators/approaches + +## Enhanced Risk Management + +### ATR-Based Dynamic Stop Loss + +**Purpose**: Improve stop loss placement by adapting to market volatility. + +**How It Works**: +- Calculates stop distance based on Average True Range (ATR) +- Stops automatically adjust to market volatility +- Tighter stops in low volatility, wider in high volatility +- Works with both fixed and trailing stops + +**Benefits**: +- 10-15% better risk-adjusted returns +- Fewer stop-outs during normal market noise +- Better adaptation to market conditions + +**Usage**: +```python +# Set ATR-based stop loss +risk_manager.update_stop_loss( + position_id=1, + stop_price=entry_price, + use_atr=True, + atr_multiplier=Decimal('2.0'), # Stop distance = 2 × ATR + atr_period=14, + ohlcv_data=market_data, + trailing=True # Enable trailing stop +) +``` + +## Advanced Features + +### Trend Filtering + +All strategies can now use optional ADX-based trend filtering: + +- Filters out signals when ADX < threshold (weak trend/chop) +- Only allows BUY signals in uptrends, SELL in downtrends +- Reduces trades in choppy/ranging markets + +**Enable in Strategy Parameters**: +- Set `use_trend_filter: true` in strategy parameters +- Configure `min_adx` threshold (default: 25.0) + +## Expected Improvements + +When all improvements are implemented and properly configured: + +- **Overall Win Rate**: 30-40% improvement +- **False Signals**: 20-30% reduction +- **Risk-Adjusted Returns**: 10-15% improvement +- **Entry Timing**: 15-25% improvement + +## Best Practices + +1. **Start with Paper Trading**: Always test new strategies in paper trading mode first + +2. **Combine Strategies**: Use Consensus Strategy to combine multiple approaches + +3. **Use ATR Stops**: Enable ATR-based stops for better risk management + +4. **Enable Trend Filters**: Use trend filtering in choppy markets + +5. **Backtest Thoroughly**: Backtest all strategies before live trading + +6. **Monitor Performance**: Regularly review strategy performance and adjust parameters + +7. **Gradual Implementation**: Add new strategies gradually and monitor their impact + +## Migration Guide + +### Updating Existing Strategies + +Existing strategies can benefit from new features: + +1. **Add Trend Filtering**: + - Add `use_trend_filter: true` to strategy parameters + - Signals will be automatically filtered + +2. **Upgrade to ATR Stops**: + - Update stop loss settings to use `use_atr: true` + - Provide OHLCV data for ATR calculation + +3. **Combine with Consensus**: + - Create a Consensus Strategy + - Include your existing strategies + - Benefit from ensemble methods + +## Technical Details + +For technical implementation details, see: +- [Strategy Framework Architecture](../architecture/strategy_framework.md) +- [Risk Management Architecture](../architecture/risk_management.md) +- [Creating Custom Strategies](../developer/creating_strategies.md) + diff --git a/docs/user_manual/README.md b/docs/user_manual/README.md new file mode 100644 index 00000000..719855f7 --- /dev/null +++ b/docs/user_manual/README.md @@ -0,0 +1,23 @@ +# Crypto Trader User Manual + +Welcome to the Crypto Trader user manual. This guide will help you get started with the application and use all its features effectively. + +## Table of Contents + +1. [Getting Started](getting_started.md) - Installation and first steps +2. [Configuration](configuration.md) - Setting up the application +3. [Trading](trading.md) - How to trade cryptocurrencies +4. [Strategies](strategies.md) - Creating and managing trading strategies +5. [Backtesting](backtesting.md) - Testing strategies on historical data +6. [Portfolio](portfolio.md) - Managing your portfolio +7. [Alerts](alerts.md) - Setting up price and indicator alerts +8. [Reporting](reporting.md) - Exporting data and generating reports +9. [Troubleshooting](troubleshooting.md) - Common issues and solutions +10. [FAQ](faq.md) - Frequently asked questions + +## Quick Links + +- [Installation Guide](getting_started.md#installation) +- [First Trade](trading.md#placing-your-first-trade) +- [Creating a Strategy](strategies.md#creating-a-strategy) +- [Running a Backtest](backtesting.md#running-a-backtest) diff --git a/docs/user_manual/alerts.md b/docs/user_manual/alerts.md new file mode 100644 index 00000000..cf487673 --- /dev/null +++ b/docs/user_manual/alerts.md @@ -0,0 +1,141 @@ +# Alerts Guide + +Set up alerts for price movements, indicators, and system events. + +## Alert Types + +### Price Alerts + +Get notified when prices reach specific levels: + +- **Price Above**: Alert when price exceeds threshold +- **Price Below**: Alert when price drops below threshold +- **Price Change**: Alert on percentage change + +### Indicator Alerts + +Alert on technical indicator conditions: + +- **RSI**: Overbought/oversold conditions +- **MACD**: Crossover signals +- **Moving Average**: Price crosses moving average +- **Bollinger Bands**: Price touches bands + +### Risk Alerts + +Get notified about risk conditions: + +- **Drawdown**: Portfolio drawdown exceeds limit +- **Daily Loss**: Daily loss exceeds threshold +- **Position Size**: Position exceeds limit +- **Margin**: Margin level warnings + +### System Alerts + +System and application alerts: + +- **Connection Lost**: Exchange connection issues +- **Order Filled**: Trade execution notifications +- **Error**: Application errors +- **Update Available**: New version available + +## Creating Alerts + +1. Navigate to Alerts view +2. Click "Create Alert" +3. Select alert type +4. Configure conditions: + - **Symbol**: Trading pair + - **Condition**: Alert trigger condition + - **Value**: Threshold value +5. Choose notification method: + - **Desktop**: System notification + - **Sound**: Audio alert + - **Email**: Email notification (if configured) +6. Set alert name +7. Click "Save" + +## Alert Examples + +### Price Alert Example + +**Alert**: BTC Price Above $50,000 +- **Type**: Price Alert +- **Symbol**: BTC/USD +- **Condition**: Price Above +- **Value**: 50000 +- **Notification**: Desktop + Sound + +### RSI Alert Example + +**Alert**: BTC RSI Oversold +- **Type**: Indicator Alert +- **Symbol**: BTC/USD +- **Indicator**: RSI +- **Condition**: RSI Below +- **Value**: 30 +- **Timeframe**: 1h + +### Risk Alert Example + +**Alert**: Portfolio Drawdown Warning +- **Type**: Risk Alert +- **Condition**: Drawdown Exceeds +- **Value**: 10% +- **Notification**: Desktop + Email + +## Managing Alerts + +### Enabling/Disabling + +- Toggle alerts on/off without deleting +- Disabled alerts don't trigger +- Useful for temporary disabling + +### Editing Alerts + +1. Select the alert +2. Click "Edit" +3. Modify conditions +4. Save changes + +### Deleting Alerts + +1. Select the alert +2. Click "Delete" +3. Confirm deletion + +## Alert History + +View all triggered alerts in the Alert History tab: + +1. Navigate to **Settings** page +2. Click on **Alert History** tab +3. View the table showing: + - **Alert Name**: Name of the alert + - **Type**: Alert type (price, indicator, risk, system) + - **Condition**: The condition that triggered (e.g., "BTC/USD @ $50000") + - **Triggered At**: Timestamp when the alert fired + - **Status**: Whether the alert is currently enabled or disabled + +The alert history automatically refreshes every 5 seconds to show newly triggered alerts. Only alerts that have been triggered at least once appear in this view. + +## Notification Settings + +Configure notification preferences: + +1. Navigate to Settings > Notifications +2. Configure: + - **Desktop Notifications**: Enable/disable + - **Sound Alerts**: Enable/disable + - **Email Notifications**: Configure email settings +3. Test notifications + +## Best Practices + +1. **Set Meaningful Thresholds**: Avoid too many alerts +2. **Use Multiple Channels**: Desktop + sound for important alerts +3. **Review Regularly**: Clean up unused alerts +4. **Test Alerts**: Verify alerts work correctly +5. **Monitor Alert History**: Track what triggered + diff --git a/docs/user_manual/backtesting.md b/docs/user_manual/backtesting.md new file mode 100644 index 00000000..3708b178 --- /dev/null +++ b/docs/user_manual/backtesting.md @@ -0,0 +1,217 @@ +# Backtesting Guide + +This guide explains how to use the backtesting feature to evaluate trading strategies on historical data. + +## Running a Backtest + +1. Navigate to the **Backtesting** page +2. Configure your backtest in the form: + - **Strategy**: Select a strategy from the dropdown (required) + - **Symbol**: Trading pair to test (e.g., BTC/USD) + - **Exchange**: Data source exchange (e.g., coinbase) + - **Timeframe**: Data timeframe (1m, 5m, 15m, 1h, 4h, 1d) - 1h recommended for most strategies + - **Start Date**: Beginning of test period (required) + - **End Date**: End of test period (required) + - **Initial Capital**: Starting capital in USD (default: $100) + - **Slippage (%)**: Expected slippage percentage (default: 0.1%) + - **Fee Rate (%)**: Trading fee percentage (default: 0.1%) +3. Click **Run Backtest** +4. A progress overlay will appear showing the backtest is running +5. An operations panel will show the running backtest with status +6. Wait for completion (you'll receive a success notification) +7. Review results in the **Backtest Results** section below + +## Understanding Results + +The backtest results include: + +- **Total Return**: Overall percentage return +- **Sharpe Ratio**: Risk-adjusted return metric (higher is better) +- **Sortino Ratio**: Downside risk-adjusted return (higher is better) +- **Max Drawdown**: Largest peak-to-trough decline +- **Win Rate**: Percentage of profitable trades +- **Total Trades**: Number of trades executed +- **Final Value**: Portfolio value at end of backtest + +## Exporting Results + +After a backtest completes, you can export the results: + +1. In the backtest results section, find the export buttons +2. **Export CSV**: + - Click **Export CSV** button + - Downloads a CSV file with all trades from the backtest + - File includes: timestamp, side, price, quantity, value +3. **Export PDF**: + - Click **Export PDF** button + - Generates a comprehensive PDF report + - Includes charts, metrics, and trade analysis + +Both exports are automatically named with the current date for easy organization. + +## Parameter Optimization + +Parameter optimization allows you to automatically find the best strategy parameters. This feature requires backend API support and will be available once the optimization endpoints are implemented. + +The UI includes an information card explaining this feature. When available, you'll be able to: +- Select parameters to optimize +- Set parameter ranges +- Choose optimization method (Grid Search, Genetic Algorithm, Bayesian Optimization) +- View optimization progress +- Compare optimization results + +## Interpreting Metrics + +- **Sharpe Ratio > 1**: Good risk-adjusted returns +- **Max Drawdown < 20%**: Acceptable risk level +- **Win Rate > 50%**: More winning than losing trades + +# Backtesting Guide + +Learn how to test your trading strategies on historical data. + +## What is Backtesting? + +Backtesting is the process of testing a trading strategy on historical data to evaluate its performance before risking real money. + +## Running a Backtest + +1. Navigate to Backtest view +2. Select a strategy +3. Configure backtest parameters: + - **Start Date**: Beginning of test period + - **End Date**: End of test period + - **Initial Capital**: Starting capital + - **Symbol**: Trading pair to test + - **Timeframe**: Data timeframe +4. Click "Run Backtest" +5. Wait for completion +6. Review results + +## Backtest Parameters + +### Time Period + +- **Start Date**: When to begin the backtest +- **End Date**: When to end the backtest +- **Duration**: Length of test period +- Longer periods provide more reliable results + +### Capital Settings + +- **Initial Capital**: Starting amount (e.g., $10,000) +- **Currency**: Base currency (USD, EUR, etc.) + +### Market Settings + +- **Symbol**: Trading pair (BTC/USD, ETH/USD, etc.) +- **Timeframe**: Data granularity (1m, 5m, 1h, 1d) +- **Exchange**: Historical data source + +## Understanding Results + +### Performance Metrics + +- **Total Return**: Overall profit/loss percentage +- **Final Capital**: Ending portfolio value +- **Sharpe Ratio**: Risk-adjusted return measure +- **Sortino Ratio**: Downside risk-adjusted return +- **Max Drawdown**: Largest peak-to-trough decline +- **Win Rate**: Percentage of profitable trades + +### Trade Analysis + +- **Total Trades**: Number of trades executed +- **Winning Trades**: Number of profitable trades +- **Losing Trades**: Number of unprofitable trades +- **Average Win**: Average profit per winning trade +- **Average Loss**: Average loss per losing trade +- **Profit Factor**: Ratio of gross profit to gross loss + +### Charts + +- **Equity Curve**: Portfolio value over time +- **Drawdown Chart**: Drawdown periods +- **Trade Distribution**: Win/loss distribution +- **Monthly Returns**: Performance by month + +## Realistic Backtesting + +Crypto Trader includes realistic backtesting features: + +### Slippage + +Slippage simulates the difference between expected and actual execution prices. + +- **Default**: 0.1% for market orders +- **Configurable**: Adjust based on market conditions +- **Market Impact**: Larger orders have more slippage + +### Fees + +Trading fees are automatically included: + +- **Maker Fees**: For limit orders (typically 0.1%) +- **Taker Fees**: For market orders (typically 0.2%) +- **Exchange-Specific**: Fees vary by exchange + +### Order Execution + +- **Market Orders**: Execute at current price + slippage +- **Limit Orders**: Execute only if price reaches limit +- **Partial Fills**: Large orders may fill partially + +## Parameter Optimization + +Optimize strategy parameters for better performance: + +1. Select strategy +2. Choose parameters to optimize +3. Set parameter ranges +4. Select optimization method: + - **Grid Search**: Test all combinations + - **Genetic Algorithm**: Evolutionary optimization + - **Bayesian Optimization**: Efficient parameter search +5. Run optimization +6. Review results and select best parameters + +## Best Practices + +1. **Use Sufficient Data**: Test on at least 6-12 months of data +2. **Avoid Overfitting**: Don't optimize too aggressively +3. **Test Multiple Periods**: Verify performance across different market conditions +4. **Consider Fees**: Always include realistic fees +5. **Check Slippage**: Account for execution costs +6. **Validate Results**: Compare with paper trading + +## Limitations + +Backtesting has limitations: + +- **Past Performance**: Doesn't guarantee future results +- **Market Conditions**: Markets change over time +- **Data Quality**: Results depend on data accuracy +- **Execution**: Real trading may differ from simulation + +## Exporting Results + +Export backtest results for analysis: + +1. Click "Export Results" +2. Choose format: + - **CSV**: For spreadsheet analysis + - **PDF**: For reports +3. Save file + +## Troubleshooting + +**No results?** +- Check date range has data +- Verify symbol is correct +- Check strategy parameters + +**Unrealistic results?** +- Verify fees are enabled +- Check slippage settings +- Review data quality + diff --git a/docs/user_manual/configuration.md b/docs/user_manual/configuration.md new file mode 100644 index 00000000..dfc5f795 --- /dev/null +++ b/docs/user_manual/configuration.md @@ -0,0 +1,359 @@ +# Configuration Guide + +This guide explains how to configure Crypto Trader to suit your needs. + +## Configuration Files + +Configuration files are stored in `~/.config/crypto_trader/` following the XDG Base Directory Specification. + +### Main Configuration + +The main configuration file is `config.yaml`. It contains: + +- Database settings +- Logging configuration +- Paper trading settings +- Update preferences +- Notification settings +- Backtesting defaults + +### Logging Configuration + +Logging settings are in `logging.yaml`. You can configure: + +- Log levels (DEBUG, INFO, WARNING, ERROR) +- Log file location +- Log rotation settings +- Retention policies + +## Exchange Configuration + +### Adding Exchange API Keys via UI + +1. Open the application +2. Navigate to the **Settings** tab +3. In the **Exchanges** section, click **Add Exchange** +4. Enter exchange details: + - **Exchange Name**: Name of the exchange (e.g., Coinbase) + - **API Key**: Your exchange API key + - **API Secret**: Your exchange API secret + - **Use Sandbox/Testnet**: Enable for testing + - **Read-Only Mode**: Recommended for safety (prevents trading) + - **Enabled**: Enable the exchange connection +5. Click **Save** +6. Test the connection using **Test Connection** button + +### Editing Exchange Settings + +1. Select an exchange from the exchanges table +2. Click **Edit Exchange** +3. Update API keys or settings as needed +4. Click **Save** + +### Managing Exchanges + +The Settings page provides comprehensive exchange management: + +- **Status Indicators**: Each exchange shows a color-coded status indicator: + - Green: Enabled and connected + - Gray: Disabled + - Red: Error state +- **Test Connection**: Click the checkmark icon to test the connection + - You'll receive a notification with detailed connection status + - Success: Green notification + - Warning: Yellow notification with details + - Error: Red notification with error message +- **Edit Exchange**: Click the pencil icon to edit exchange settings +- **Delete Exchange**: Click the trash icon to delete (removes API keys) + - Confirmation dialog will appear + - You'll receive a success notification when deleted + +### API Key Security + +- API keys are encrypted before storage +- Use read-only keys when possible +- Enable IP whitelisting on your exchange account +- Never share your API keys + +## Paper Trading Configuration + +Paper trading settings can be configured in the Settings page: + +1. Navigate to **Settings** page +2. Click on **Paper Trading** tab +3. Configure: + - **Initial Capital ($)**: Starting capital in USD (default: $100) + - **Fee Model (Exchange)**: Select which exchange's fee structure to simulate +4. Click **Save Settings** +5. You'll receive a success notification when settings are saved + +### Fee Exchange Models + +Choose from available exchange fee models: + +| Exchange | Maker Fee | Taker Fee | Best For | +|----------|-----------|-----------|----------| +| **Default** | 0.10% | 0.10% | General testing | +| **Coinbase** | 0.40% | 0.60% | Conservative estimates | +| **Kraken** | 0.16% | 0.26% | Moderate fee simulation | +| **Binance** | 0.10% | 0.10% | Low-fee simulation | + +The fee rates display shows your current maker fee, taker fee, and estimated round-trip cost. + +### Resetting Paper Account + +To reset your paper trading account (closes all positions and resets balance): + +1. Navigate to **Settings** > **Paper Trading** tab +2. Click **Reset Paper Account** button +3. Confirm the reset in the dialog +4. All positions will be closed and balance reset to initial capital +5. You'll receive a success notification when complete + +**Warning**: This action cannot be undone. All paper trading history will be preserved, but positions and balance will be reset. + +## Data Provider Configuration + +Data providers are used to fetch real-time pricing data for paper trading, backtesting, and ML training. They work independently of exchange integrations and don't require API keys. + +### Configuring Providers + +1. Navigate to **Settings** page +2. Click on **Data Providers** tab +3. Configure provider settings: + +#### Primary Providers (CCXT) + +Primary providers use CCXT to connect to cryptocurrency exchanges: +- **Kraken**: Default priority 1 (tried first) +- **Coinbase**: Default priority 2 +- **Binance**: Default priority 3 + +Each provider can be: +- **Enabled/Disabled**: Toggle using the switch +- **Reordered**: Use up/down arrows to change priority order +- **Monitored**: View health status, response times, and success rates + +#### Fallback Provider + +**CoinGecko** is used as a fallback when primary providers are unavailable: +- Free tier API (no authentication required) +- Lower rate limits than primary providers +- Provides basic price data + +#### Cache Settings + +Configure caching behavior: +- **Ticker TTL (seconds)**: How long to cache ticker data (default: 2 seconds) +- **OHLCV TTL (seconds)**: How long to cache candlestick data (default: 60 seconds) + +### Provider Status + +The Data Providers tab shows: +- **Active Provider**: Currently used provider +- **Health Status**: Color-coded status for each provider: + - Green: Healthy and working + - Yellow: Degraded performance + - Red: Unhealthy or unavailable +- **Response Times**: Average response time for each provider +- **Success/Failure Counts**: Historical performance metrics + +### Automatic Failover + +The system automatically: +- Monitors provider health +- Switches to the next available provider if current one fails +- Opens circuit breakers for repeatedly failing providers +- Retries failed providers after a timeout period + +### Troubleshooting Providers + +If all providers fail: +1. Check your internet connection +2. Verify firewall isn't blocking connections +3. Check provider status in the Settings tab +4. Try enabling/disabling specific providers +5. Reset provider metrics if needed + +## Risk Management Settings + +Configure risk limits in the Settings page: + +1. Navigate to **Settings** page +2. Click on **Risk Management** tab +3. Configure the following settings: + - **Max Drawdown Limit (%)**: Maximum portfolio drawdown before trading stops + - **Daily Loss Limit (%)**: Maximum daily loss percentage + - **Default Position Size (%)**: Default percentage of capital to use per trade +4. Click **Save Risk Settings** +5. You'll receive a success notification when settings are saved + +Settings are validated before saving and you'll see error messages if values are invalid. + +## Data Storage + +Data is stored in `~/.local/share/crypto_trader/`: + +- `trading.db` - Legacy file (removed) +- `historical/` - Historical market data +- `logs/` - Application logs + +### Database Options + +### Database Options + + **PostgreSQL (Required)**: + - All production data is stored in PostgreSQL. + - Requires a running PostgreSQL instance. + - Configure connection in `config.yaml`. + + **SQLite (Internal)**: + - Used internally for unit testing only. + - No configuration required for users. + +## Redis Configuration + +Redis is used for distributed state management, ensuring autopilot state persists across restarts and preventing duplicate instances. + +### Default Settings + +Redis settings are configured in `config.yaml`: + +```yaml +redis: + host: "127.0.0.1" + port: 6379 + db: 0 + password: null # Set if Redis requires authentication + socket_connect_timeout: 5 +``` + +### Environment Variables + +You can also configure Redis via environment variables: +- `REDIS_HOST` - Redis server hostname +- `REDIS_PORT` - Redis server port +- `REDIS_PASSWORD` - Redis password (if required) + +### Verifying Redis Connection + +Run the verification script: +```bash +python scripts/verify_redis.py +``` + +## Celery Configuration (Background Tasks) + +Celery handles long-running tasks like ML model training in the background. + +### Starting the Worker + +```bash +# From project root (with virtualenv activated) +celery -A src.worker.app worker --loglevel=info +``` + +Or use the helper script: +```bash +./scripts/start_all.sh +``` + +### Task Queues + +Tasks are routed to specific queues: +- `ml_training` - Model training and data bootstrapping +- `reporting` - Report generation +- `celery` - Default queue + +## ML Model Training Configuration + +Configure how the autopilot ML model is trained via the Settings page. + +### Training Data Configuration + +Navigate to **Settings** > **Autopilot** tab to access these settings: + +| Setting | Description | Recommended | +|---------|-------------|-------------| +| **Historical Data (days)** | Amount of historical data to fetch for training | 90-365 days | +| **Timeframe** | OHLCV candle timeframe for training data | 1h (balanced) | +| **Min Samples per Strategy** | Minimum samples required per strategy | 30+ | +| **Training Symbols** | Cryptocurrencies to include in training | 5-10 major coins | + +### Setting Training Symbols + +1. Navigate to **Settings** > **Autopilot** tab +2. In the **Training Data Configuration** section, find **Training Symbols** +3. Click to add/remove symbols from the multi-select dropdown +4. Common symbols: BTC/USD, ETH/USD, SOL/USD, XRP/USD, ADA/USD +5. Click **Save Bootstrap Config** to persist your changes + +### Triggering Model Training + +1. Click **Retrain Model** button in the Model Management section +2. A progress bar will appear showing: + - Data fetching progress (20-60%) + - Training progress (70-100%) +3. Training typically takes 30-60 seconds depending on data volume +4. Upon completion, the model card updates to show: + - "Global Model Trained" badge + - Number of strategies and features + - Training accuracy + - Which symbols the model was trained on + +### Training Progress Persistence + +Training progress persists across page navigation: +- If you navigate away during training, progress resumes on return +- The training task ID is stored in browser localStorage +- On task completion or failure, the task ID is cleared + +### Model Management + +- **Retrain Model**: Triggers retraining with current configuration +- **Reset Model**: Deletes all saved model files (confirmation required) + +### Troubleshooting Training + +If training fails: +1. Check that Redis and Celery are running +2. Review Celery worker logs: `tail -f celery.log` +3. Ensure sufficient historical data is available +4. Try reducing the number of training symbols +5. Check backend logs for error details + +### Monitoring Tasks + +The API provides endpoints to monitor background tasks: +- `POST /api/autopilot/intelligent/retrain` - Starts training, returns task ID +- `GET /api/autopilot/tasks/{task_id}` - Check task status + +## Environment Variables + +You can override configuration using environment variables: + +- `CRYPTO_TRADER_CONFIG_DIR` - Custom config directory +- `CRYPTO_TRADER_DATA_DIR` - Custom data directory +- `CRYPTO_TRADER_LOG_DIR` - Custom log directory +- `DB_PASSWORD` - Database password (for PostgreSQL) +- `REDIS_HOST` - Redis server hostname +- `REDIS_PORT` - Redis server port +- `REDIS_PASSWORD` - Redis password + +## Backup and Restore + +### Backup + +1. Stop the application and Celery workers +2. Backup PostgreSQL database +3. Copy the entire `~/.local/share/crypto_trader/` directory +4. Copy `~/.config/crypto_trader/` directory +5. (Optional) Export Redis data with `redis-cli SAVE` + +### Restore + +1. Stop the application +2. Restore PostgreSQL database +3. Replace the directories with your backup +4. Restart Redis, Celery worker, and application + diff --git a/docs/user_manual/faq.md b/docs/user_manual/faq.md new file mode 100644 index 00000000..ff5e6834 --- /dev/null +++ b/docs/user_manual/faq.md @@ -0,0 +1,235 @@ +# Frequently Asked Questions + +Common questions about Crypto Trader. + +## General Questions + +### What is Crypto Trader? + +Crypto Trader is a comprehensive web-based cryptocurrency trading platform for trading, backtesting, and portfolio management. It features a modern React frontend with a FastAPI backend, supporting multiple exchanges, real-time trading, paper trading, and advanced analytics. + +### Is Crypto Trader free? + +[Answer depends on your licensing - update as needed] + +### What operating systems are supported? + +The web interface works on any operating system with a modern browser: +- **Backend**: Linux, macOS, Windows (Python 3.11+) +- **Frontend**: Any OS with Chrome, Firefox, Safari, or Edge +- **Recommended**: Linux (Bluefin Linux) or macOS for development + +### What Python version is required? + +Python 3.11 or higher is required. + +## Installation Questions + +### How do I install Crypto Trader? + +See the [Getting Started](getting_started.md) guide for installation instructions. + +### Can I run from source? + +Yes, see the [Getting Started](getting_started.md) guide. You'll need to run both the backend (FastAPI) and frontend (React) servers. + +### Do I need to install TA-Lib separately? + +TA-Lib is bundled in the AppImage. For source installation, you may need to install the TA-Lib C library separately. + +## Trading Questions + +### Is paper trading safe? + +Yes, paper trading uses virtual funds and doesn't risk real money. It's perfect for testing strategies. + +### How do I switch from paper trading to live trading? + +1. Configure exchange API keys with trading permissions +2. Disable paper trading mode in settings +3. Start with small position sizes +4. Monitor closely + +### What exchanges are supported? + +Currently Coinbase is supported. The framework supports adding additional exchanges. + +### Can I trade futures? + +Yes, futures and leverage trading are supported. See the trading guide for details. + +## Strategy Questions + +### How do I create a strategy? + +See the [Strategy Guide](strategies.md) for detailed instructions. + +### Can I use multiple strategies at once? + +Yes, you can run multiple strategies simultaneously on different symbols or timeframes. + +### How do I optimize strategy parameters? + +Use the backtesting optimization features. See the [Backtesting Guide](backtesting.md#parameter-optimization). + +### Can I create custom strategies? + +Yes, see the [Developer Guide](../developer/creating_strategies.md) for instructions. + +## Backtesting Questions + +### How accurate is backtesting? + +Backtesting includes realistic features like slippage and fees, but past performance doesn't guarantee future results. + +### How much historical data do I need? + +At least 6-12 months of data is recommended for reliable backtesting. + +### Can I backtest multiple strategies? + +Yes, you can backtest multiple strategies and compare results. + +## Autopilot Questions + +### What's the difference between Pattern-Based and ML-Based Autopilot? + +**Pattern-Based Autopilot** uses technical chart pattern recognition combined with sentiment analysis. It's transparent, explainable, and works immediately without training data. **ML-Based Autopilot** uses machine learning to select the best strategy based on market conditions. It's adaptive and learns from historical performance but requires training data. + +See the [Trading Guide](trading.md#autopilot-modes) for detailed comparison. + +### Which autopilot mode should I use? + +- **Use Pattern-Based** if you want transparency, understand technical analysis, prefer explainable decisions, or want immediate setup. +- **Use ML-Based** if you want adaptive decisions, have historical trading data, don't need to understand every decision, or want maximum optimization. + +See the [decision guide](trading.md#choosing-the-right-autopilot-mode) for more details. + +### Can I switch between modes? + +Yes, you can switch between modes at any time. Simply stop the current autopilot, select the desired mode, and start it again. You cannot run both modes simultaneously for the same symbol. + +### What is auto-execution and should I enable it? + +Auto-execution automatically executes trades based on autopilot signals. It's available for both modes and can be enabled/disabled independently. + +**Enable auto-execution if**: +- You've tested the autopilot in paper trading mode +- You trust the autopilot's signals +- You've set appropriate risk limits +- You can monitor trades regularly + +**Don't enable auto-execution if**: +- You're new to the autopilot +- You haven't tested it thoroughly +- You want to review signals before executing +- You're trading with real money and want manual control + +### How do I know which mode is running? + +The Dashboard shows the active mode in the status chip (e.g., "AutoPilot Active (pattern)"). You can also check the Autopilot Configuration section to see the selected mode. + +### Can I use both modes simultaneously? + +No, you can only run one autopilot mode at a time per symbol. However, you can run different modes on different symbols. + +### What happens if I switch modes while autopilot is running? + +You must stop the current autopilot before switching modes. The system will prevent starting a new mode while another is running for the same symbol. + +### Does Pattern-Based Autopilot require training data? + +No, Pattern-Based Autopilot works immediately without any training data. It uses predefined pattern recognition rules and sentiment analysis. + +### Does ML-Based Autopilot always need training data? + +Yes, ML-Based Autopilot requires training data to build its model. However, the system can bootstrap training data from historical backtests if available. You can also manually trigger model training from the Dashboard. + +### How accurate are autopilot signals? + +Signal accuracy depends on market conditions and the selected mode: +- **Pattern-Based**: Accuracy depends on pattern clarity and sentiment alignment +- **ML-Based**: Accuracy depends on model training quality and market regime match + +Past performance doesn't guarantee future results. Always use risk management and test thoroughly in paper trading mode. + +## Data Questions + +### Where is data stored? + +Data is stored in `~/.local/share/crypto_trader/`: +- Database: `trading.db` +- Historical data: `historical/` +- Logs: `logs/` + +### How much storage do I need? + +Storage depends on: +- Historical data retention +- Number of symbols tracked +- Log retention settings + +Typically 1-5GB is sufficient. + +### Can I use PostgreSQL instead of SQLite? + +Yes, PostgreSQL is supported for advanced users. See the [Configuration Guide](configuration.md#database-options). + +## Security Questions + +### Are my API keys secure? + +Yes, API keys are encrypted before storage using industry-standard encryption. + +### Can I use read-only API keys? + +Yes, read-only keys are recommended for backtesting and data collection. + +### Where are API keys stored? + +API keys are stored encrypted in the SQLite database. + +## Performance Questions + +### Why is the application slow? + +Possible causes: +- Large historical datasets +- Many active strategies +- Insufficient system resources +- Database optimization needed + +See [Troubleshooting](troubleshooting.md#performance-issues) for solutions. + +### How can I improve performance? + +- Reduce data retention +- Limit active strategies +- Optimize database +- Increase system resources + +## Technical Questions + +### Can I contribute to the project? + +Yes! See the [Developer Guide](../developer/contributing.md) for contribution guidelines. + +### How do I report bugs? + +Create an issue with: +- Error description +- Steps to reproduce +- System information +- Log files + +### Is there an API? + +Yes, see the [API Documentation](../api/index.html) for details. + +## Still Have Questions? + +- Check the [User Manual](README.md) +- Review [Troubleshooting](troubleshooting.md) +- Consult [API Documentation](../api/index.html) +- See [Developer Guide](../developer/README.md) + diff --git a/docs/user_manual/getting_started.md b/docs/user_manual/getting_started.md new file mode 100644 index 00000000..19d0d895 --- /dev/null +++ b/docs/user_manual/getting_started.md @@ -0,0 +1,157 @@ +# Getting Started + +This guide will help you install and set up Crypto Trader for the first time. + +## Installation + +### From AppImage (Recommended for Bluefin Linux) + +1. Download the latest AppImage release from the releases page +2. Make it executable: + ```bash + chmod +x crypto_trader-*.AppImage + ``` +3. Run the application: + ```bash + ./crypto_trader-*.AppImage + ``` + +### From Source + +1. Clone the repository: + ```bash + git clone + cd crypto_trader + ``` + +2. Create a virtual environment: + ```bash + python -m venv venv + source venv/bin/activate # On Windows: venv\Scripts\activate + ``` + +3. Install dependencies: + ```bash + pip install -r requirements.txt + ``` + +4. Install backend dependencies: + ```bash + pip install -r requirements.txt + pip install -r backend/requirements.txt + ``` + +5. Install frontend dependencies: + ```bash + cd frontend + npm install + ``` + +6. Run the application: + + **Backend** (in one terminal): + ```bash + python -m uvicorn backend.main:app --reload --port 8000 + ``` + + **Frontend** (in another terminal): + ```bash + cd frontend + npm run dev + ``` + + Access the application at: http://localhost:3000 + API documentation at: http://localhost:8000/docs + +## First Launch + +When you first launch Crypto Trader: + +1. **Configuration Setup**: The application will create configuration directories: + - `~/.config/crypto_trader/` - Configuration files + - `~/.local/share/crypto_trader/` - Data and database + - `~/.local/share/crypto_trader/logs/` - Application logs + + + 2. **Database Initialization**: + - Ensure PostgreSQL is running and credentials are configured in `config.yaml` or via environment variables. + - Tables will be created automatically on first run. + +3. **Paper Trading**: By default, the application starts in paper trading mode with $100 virtual capital. + +4. **UI Overview**: The web interface contains six main pages accessible via the navigation menu: + - **Dashboard**: Overview with AutoPilot controls, system health, and real-time market data + - **Strategies**: Create, edit, delete, and manage trading strategies with full parameter configuration + - **Trading**: Manual order placement, order management, and position closing + - **Portfolio**: View portfolio performance, holdings, allocation charts, and risk metrics + - **Backtesting**: Configure and run backtests on historical data with progress tracking + - **Settings**: Manage exchanges, risk settings, alerts, alert history, and application configuration + +## Adding Your First Exchange + +1. Click on the **Settings** tab +2. In the **Exchanges** section, click **Add Exchange** +3. Enter your exchange name (e.g., "Coinbase") +4. Enter your API key and secret +5. Choose **Read-Only Mode** for safety (recommended for first-time setup) +6. Click **Save** +7. Test the connection using the **Test Connection** button + +## Placing Your First Trade + +1. Go to the **Trading** tab +2. Select an exchange and symbol from the dropdowns +3. Choose order type (Market, Limit, or Stop) +4. Select Buy or Sell +5. Enter quantity +6. For limit orders, enter your desired price +7. Click **Place Order** + +The order will execute in paper trading mode by default. You can view your positions in the **Open Positions** table and order history in the **Order History** section. + +## Using Autopilot + +The Autopilot feature provides autonomous trading signal generation with two modes: + +1. **Pattern-Based Autopilot**: Uses technical pattern recognition and sentiment analysis + - Transparent and explainable + - Works immediately without training data + - Best for users who want to understand every decision + +2. **ML-Based Autopilot**: Uses machine learning to select optimal strategies + - Adaptive and learns from performance + - Requires training data + - Best for users who want data-driven optimization + +**Quick Start**: +1. Go to the **Dashboard** page +2. In the **Autopilot Configuration** section, select your preferred mode +3. Choose a symbol (e.g., BTC/USD) +4. Optionally enable **Auto-Execute** (start with paper trading first!) +5. Click **Start AutoPilot** + +See the [Trading Guide](trading.md#autopilot-modes) for detailed information about both modes and when to use each. + +## Next Steps + +1. [Configure your exchanges](configuration.md#exchanges) +2. [Set up your first strategy](strategies.md#creating-a-strategy) +3. [Try paper trading](trading.md#paper-trading) +4. [Try autopilot](trading.md#autopilot-modes) +5. [Run a backtest](backtesting.md#running-a-backtest) + +## System Requirements + +- **Python**: 3.11 or higher +- **Operating System**: Linux (Bluefin Linux recommended), macOS, Windows +- **Memory**: 2GB RAM minimum, 4GB recommended +- **Storage**: 500MB for application, additional space for historical data +- **Network**: Internet connection for real-time data and trading +- **Database**: PostgreSQL 12 or higher (required) + +## Getting Help + +- Check the [FAQ](faq.md) for common questions +- Review the [Troubleshooting](troubleshooting.md) guide +- Consult the [API Documentation](../api/index.html) for advanced usage + diff --git a/docs/user_manual/portfolio.md b/docs/user_manual/portfolio.md new file mode 100644 index 00000000..47cace4d --- /dev/null +++ b/docs/user_manual/portfolio.md @@ -0,0 +1,134 @@ +# Portfolio Management + +This guide explains the Portfolio view and how to interpret portfolio metrics. + +## Portfolio View Features + +The Portfolio page provides a comprehensive view of your trading portfolio with three main tabs: + +### Overview Tab + +#### Summary Cards + +At the top of the page, you'll see four summary cards: +- **Current Value**: Current portfolio value (cash + positions) +- **Unrealized P&L**: Profit/loss on open positions (color-coded: green for profit, red for loss) +- **Realized P&L**: Profit/loss from closed trades +- **Daily Change**: Percentage change in portfolio value today + +#### Portfolio Value History Chart + +An equity curve chart showing: +- Portfolio value over time (last 30 days by default) +- P&L line showing profit/loss progression +- Interactive tooltips showing exact values on hover + +#### Risk Metrics + +A grid of risk metrics cards showing: +- **Sharpe Ratio**: Risk-adjusted return measure +- **Sortino Ratio**: Downside risk-adjusted return +- **Max Drawdown**: Largest historical decline (percentage) +- **Win Rate**: Percentage of profitable trades +- **Total Return**: Overall return percentage + +#### Holdings + +All open positions displayed as cards showing: +- **Symbol**: Trading pair +- **Quantity**: Amount held (8 decimal precision) +- **Entry Price**: Average entry price +- **Current Price**: Latest market price +- **Value**: Current position value +- **Unrealized P&L**: Profit/loss with percentage change +- **Realized P&L**: Profit/loss from closed portions +- **Close Position Button**: Close the position directly from the card + +Each position card is color-coded with a chip showing the P&L percentage. + +### Allocations Tab + +A pie chart visualization showing: +- Portfolio allocation by asset +- Percentage breakdown for each holding +- Interactive tooltips with exact dollar values +- Color-coded segments for easy identification + +This helps you visualize your portfolio distribution and identify over-concentration in specific assets. + +### Reports & Export Tab + +Export functionality for: +- **Export Trades**: Download all trading history as CSV +- **Export Portfolio**: Download current portfolio holdings as CSV +- **Tax Reporting**: Generate tax reports using FIFO or LIFO methods + +### Risk Metrics + +- **Current Drawdown**: Current decline from peak value +- **Max Drawdown**: Largest historical decline +- **Sharpe Ratio**: Risk-adjusted return measure +- **Sortino Ratio**: Downside risk-adjusted return +- **Win Rate**: Percentage of profitable trades + +### Performance Chart + +The equity curve chart shows portfolio value over time, helping you visualize performance trends. + +## Understanding Metrics + +### Sharpe Ratio +Measures risk-adjusted returns. Higher values indicate better risk-adjusted performance: +- **> 1**: Good +- **> 2**: Very good +- **> 3**: Excellent + +### Sortino Ratio +Similar to Sharpe but only considers downside volatility. Better for asymmetric return distributions. + +### Drawdown +Maximum peak-to-trough decline. Lower is better: +- **< 10%**: Low risk +- **10-20%**: Moderate risk +- **> 20%**: High risk + +## Closing Positions from Portfolio + +You can close positions directly from the Portfolio page: + +1. Navigate to the **Portfolio** page +2. In the **Overview** tab, find the position card you want to close +3. Click **Close Position** button on the card +4. In the dialog: + - Choose order type (Market or Limit) + - For limit orders, enter the limit price +5. Click **Close Position** to confirm + +The position will be closed and removed from your holdings. You'll receive a notification when the order is filled. + +## Data Freshness + +The Portfolio page shows a data freshness indicator in the header showing when the data was last updated. The page automatically refreshes every 5 seconds to show the latest data. + +## Exporting Data + +### Export Trades + +1. Navigate to **Portfolio** > **Reports & Export** tab +2. Click **Export Trades CSV** button +3. The file will download with all your trading history + +### Export Portfolio + +1. Navigate to **Portfolio** > **Reports & Export** tab +2. Click **Export Portfolio CSV** button +3. The file will download with current holdings + +### Tax Reports + +1. Navigate to **Portfolio** > **Reports & Export** tab +2. Select tax method (FIFO or LIFO) +3. Enter tax year +4. Optionally filter by symbol +5. Click **Generate Tax Report** +6. The CSV file will download with tax reporting information diff --git a/docs/user_manual/reporting.md b/docs/user_manual/reporting.md new file mode 100644 index 00000000..279e9f66 --- /dev/null +++ b/docs/user_manual/reporting.md @@ -0,0 +1,120 @@ +# Reporting Guide + +Export your trading data and generate reports. + +## Export Formats + +### CSV Export + +Export data to CSV for spreadsheet analysis: + +- **Trades**: All executed trades +- **Orders**: Order history +- **Positions**: Current and historical positions +- **Portfolio**: Portfolio snapshots +- **Backtest Results**: Backtesting data + +### PDF Reports + +Generate comprehensive PDF reports: + +- **Trading Report**: Summary of trading activity +- **Performance Report**: Portfolio performance analysis +- **Tax Report**: Tax reporting information + +## Exporting Trades + +1. Navigate to Reporting view +2. Select "Export Trades" +3. Choose date range: + - **Start Date**: Beginning of period + - **End Date**: End of period +4. Select filters: + - **Symbol**: Specific trading pair + - **Paper Trading**: Include/exclude paper trades + - **Exchange**: Specific exchange +5. Choose format: CSV or PDF +6. Click "Export" +7. Save file + +## Exporting Portfolio + +1. Select "Export Portfolio" +2. Choose data: + - **Current Positions**: Open positions + - **Position History**: Historical positions + - **Performance Metrics**: Analytics data +3. Select format +4. Click "Export" +5. Save file + +## Tax Reporting + +Generate tax reports for accounting: + +1. Navigate to Reporting > Tax Report +2. Select tax year +3. Choose method: + - **FIFO**: First In, First Out + - **LIFO**: Last In, First Out + - **Specific Identification**: Choose specific lots +4. Configure settings: + - **Currency**: Reporting currency + - **Include Fees**: Include trading fees + - **Include Paper Trades**: Optional +5. Generate report +6. Review and export + +### Tax Report Contents + +- **Realized Gains/Losses**: Profit/loss from closed trades +- **Trade Summary**: All trades in period +- **Cost Basis**: Purchase costs +- **Proceeds**: Sale proceeds +- **Gain/Loss**: Net gain or loss + +## Scheduled Reports + +Set up automatic report generation: + +1. Navigate to Settings > Scheduled Reports +2. Click "Add Schedule" +3. Configure: + - **Report Type**: Trades, Portfolio, Tax + - **Frequency**: Daily, Weekly, Monthly + - **Format**: CSV, PDF + - **Email**: Optional email delivery +4. Save schedule + +## Performance Reports + +Generate performance analysis reports: + +- **Period Performance**: Returns for time period +- **Risk Metrics**: Sharpe, Sortino, drawdown +- **Trade Analysis**: Win rate, average win/loss +- **Asset Performance**: Performance by asset +- **Monthly Breakdown**: Performance by month + +## Custom Reports + +Create custom reports: + +1. Select "Custom Report" +2. Choose data sources: + - Trades + - Positions + - Portfolio snapshots + - Backtest results +3. Select metrics to include +4. Configure filters +5. Generate report + +## Best Practices + +1. **Regular Exports**: Export data regularly for backup +2. **Tax Records**: Keep detailed tax reports +3. **Performance Tracking**: Generate monthly performance reports +4. **Data Backup**: Export before major updates +5. **Record Keeping**: Maintain organized export files + diff --git a/docs/user_manual/strategies.md b/docs/user_manual/strategies.md new file mode 100644 index 00000000..001ab691 --- /dev/null +++ b/docs/user_manual/strategies.md @@ -0,0 +1,286 @@ +# Strategy Guide + +Learn how to create, configure, and manage trading strategies in Crypto Trader. + +## What is a Strategy? + +A trading strategy is a set of rules that determine when to buy and sell cryptocurrencies. Strategies analyze market data and generate trading signals. + +## Pre-built Strategies + +Crypto Trader includes several pre-built strategies: + +### RSI Strategy + +Uses the Relative Strength Index to identify overbought and oversold conditions. + +- **Buy Signal**: RSI below 30 (oversold) +- **Sell Signal**: RSI above 70 (overbought) +- **Parameters**: RSI period (default: 14) + +### MACD Strategy + +Uses Moving Average Convergence Divergence for trend following. + +- **Buy Signal**: MACD crosses above signal line +- **Sell Signal**: MACD crosses below signal line +- **Parameters**: Fast period, slow period, signal period + +### Moving Average Crossover + +Uses two moving averages to identify trend changes. + +- **Buy Signal**: Short MA crosses above long MA +- **Sell Signal**: Short MA crosses below long MA +- **Parameters**: Short MA period, long MA period + +### Confirmed Strategy (Multi-Indicator) + +Requires multiple indicators (RSI, MACD, Moving Average) to align before generating signals. This significantly reduces false signals by requiring confirmation from 2-3 indicators. + +- **Buy Signal**: When majority of indicators agree on buy signal +- **Sell Signal**: When majority of indicators agree on sell signal +- **Parameters**: + - RSI period, oversold, overbought thresholds + - MACD fast, slow, signal periods + - MA fast, slow periods, MA type (SMA/EMA) + - Minimum confirmations required (default: 2) + - Which indicators to require (RSI, MACD, MA) + +### Divergence Strategy + +Detects price vs. indicator divergences which are powerful reversal signals. Works exceptionally well in ranging markets. + +- **Buy Signal**: Bullish divergence (price makes lower low, indicator makes higher low) +- **Sell Signal**: Bearish divergence (price makes higher high, indicator makes lower high) +- **Parameters**: + - Indicator type: RSI or MACD (default: RSI) + - Lookback period for swing detection (default: 20) + - Minimum swings required (default: 2) + - Minimum confidence threshold (default: 0.5) + +### Bollinger Bands Mean Reversion + +Trades mean reversion using Bollinger Bands. Buys at lower band in uptrends, exits at middle band. Works well in ranging markets. + +- **Buy Signal**: Price touches lower Bollinger Band in uptrend +- **Sell Signal**: Price reaches middle band (profit target) or touches upper band (stop loss) +- **Parameters**: + - Bollinger Bands period (default: 20) + - Standard deviation multiplier (default: 2.0) + - Trend filter enabled (default: True) + - Trend MA period (default: 50) + - Entry threshold (how close to band, default: 0.95) + - Exit threshold (when to take profit, default: 0.5) + +### Consensus Strategy (Ensemble) + +Combines signals from multiple strategies with voting mechanism. Only executes when multiple strategies agree, improving signal quality through ensemble methods. + +- **Buy Signal**: When minimum number of strategies agree on buy +- **Sell Signal**: When minimum number of strategies agree on sell +- **Parameters**: + - Strategy names to include (None = all available) + - Minimum consensus count (default: 2) + - Use performance-based weights (default: True) + - Minimum weight threshold (default: 0.3) + - Strategies to exclude + +## Strategy Management Page + +The Strategy Management page provides a comprehensive interface for creating, editing, and managing all your trading strategies. + +### Accessing Strategy Management + +1. Click on **Strategies** in the navigation menu +2. You'll see a table listing all your strategies with: + - Strategy name and description + - Strategy type + - Trading symbol + - Timeframes + - Status (Enabled/Disabled) + - Paper Trading mode + - Action buttons (Start, Stop, Edit, Delete) + +## Creating a Strategy + +1. Navigate to the **Strategies** page +2. Click **Create Strategy** button +3. The strategy dialog has three tabs: + + **Basic Settings Tab**: + - **Name**: Give your strategy a descriptive name (required) + - **Description**: Optional description + - **Strategy Type**: Select from RSI, MACD, Moving Average, Confirmed, Divergence, Bollinger Mean Reversion, Consensus, DCA, Grid, or Momentum + - **Symbol**: Trading pair (e.g., BTC/USD) + - **Exchange**: Select the exchange (required) + - **Timeframes**: Select one or more timeframes (1m, 5m, 15m, 30m, 1h, 4h, 1d) + - **Paper Trading Mode**: Toggle for paper trading (recommended for testing) + + **Parameters Tab**: + - Configure strategy-specific parameters + - Parameters vary by strategy type + - Default values are provided for all parameters + - See strategy-specific sections below for parameter details + + **Risk Settings Tab**: + - **Position Size (%)**: Percentage of capital to use per trade + - **Stop Loss (%)**: Maximum loss percentage before exit + - **Take Profit (%)**: Profit target percentage + - **Max Position Size**: Optional maximum position size limit + +4. Click **Create** to save the strategy + +The strategy will appear in the strategies table. New strategies start as **Disabled** - you must enable and start them manually. + +## Managing Strategies + +### Starting a Strategy + +1. Find the strategy in the table +2. Click the **Start** button (green play icon) +3. The strategy status will change to **Enabled** and it will begin generating signals + +### Stopping a Strategy + +1. Find the running strategy in the table +2. Click the **Stop** button (red stop icon) +3. The strategy will stop generating new signals + +### Editing a Strategy + +1. Click the **Edit** button (pencil icon) for the strategy +2. Modify any settings in the dialog +3. Click **Update** to save changes + +**Note**: Strategy type cannot be changed after creation. You must create a new strategy with a different type. + +### Deleting a Strategy + +1. Click the **Delete** button (red trash icon) +2. Confirm the deletion in the dialog +3. The strategy and all its configuration will be permanently deleted + +## Strategy Types + +### DCA (Dollar Cost Averaging) +Invests a fixed amount at regular intervals (daily, weekly, or monthly). Ideal for long-term accumulation. + +**Parameters:** +- Amount: Fixed investment amount per interval +- Interval: Daily, weekly, or monthly +- Target Allocation: Target portfolio allocation percentage + +### Grid Trading +Places buy orders at lower price levels and sell orders at higher levels. Profits from price oscillations. + +**Parameters:** +- Grid Spacing: Percentage between grid levels +- Number of Levels: Grid levels above and below center price +- Profit Target: Profit percentage to take + +### Momentum +Trades based on price momentum with volume confirmation. Enters on strong upward momentum, exits on reversal. + +**Parameters:** +- Lookback Period: Period for momentum calculation +- Momentum Threshold: Minimum momentum to enter +- Volume Threshold: Volume increase multiplier for confirmation +- Exit Threshold: Momentum reversal threshold + +## Strategy Configuration + +### Basic Settings + +- **Name**: Descriptive name for your strategy +- **Symbol**: Trading pair (e.g., BTC/USD) +- **Timeframe**: Data timeframe (1m, 5m, 15m, 1h, 4h, 1d) +- **Enabled**: Turn strategy on/off + +### Parameters + +Each strategy has specific parameters: + +- **RSI Strategy**: RSI period, overbought threshold, oversold threshold +- **MACD Strategy**: Fast period, slow period, signal period +- **Moving Average**: Short period, long period, MA type (SMA/EMA) +- **Confirmed Strategy**: RSI, MACD, and MA parameters, minimum confirmations +- **Divergence Strategy**: Indicator type, lookback period, confidence threshold +- **Bollinger Mean Reversion**: Period, std dev, trend filter settings +- **Consensus Strategy**: Strategy selection, consensus threshold, weighting options + +### Risk Settings + +- **Position Size**: Amount to trade per signal +- **Max Position**: Maximum position size +- **Stop Loss**: Percentage, price level, or ATR-based dynamic stop + - **ATR-based stops**: Automatically adjust stop distance based on market volatility + - **ATR Multiplier**: Multiplier for ATR calculation (default: 2.0) + - **ATR Period**: Period for ATR calculation (default: 14) +- **Take Profit**: Profit target +- **Trend Filter**: Optional ADX-based trend filter to avoid trading in choppy markets + +## Multi-Timeframe Strategies + +Strategies can use multiple timeframes: + +1. Primary timeframe for signal generation +2. Higher timeframes for trend confirmation +3. Lower timeframes for entry timing + +## Strategy Scheduling + +Strategies can be scheduled to run: + +- **Continuous**: Runs on every new candle +- **Time-based**: Runs at specific times +- **Condition-based**: Runs when conditions are met + +## Managing Strategies + +### Enabling/Disabling + +- Toggle strategy on/off without deleting +- Disabled strategies don't generate signals +- Useful for testing multiple strategies + +### Editing Strategies + +1. Select the strategy +2. Click "Edit" +3. Modify parameters +4. Save changes + +### Deleting Strategies + +1. Select the strategy +2. Click "Delete" +3. Confirm deletion + +## Strategy Performance + +Monitor strategy performance: + +- **Win Rate**: Percentage of profitable trades +- **Total Return**: Overall return +- **Sharpe Ratio**: Risk-adjusted return +- **Max Drawdown**: Largest peak-to-trough decline +- **Number of Trades**: Total trades executed + +## Best Practices + +1. **Backtest First**: Always backtest strategies before live trading +2. **Start Small**: Use small position sizes initially +3. **Monitor Performance**: Regularly review strategy results +4. **Adjust Parameters**: Optimize based on performance +5. **Use Paper Trading**: Test changes in paper trading first + +## Creating Custom Strategies + +For advanced users, you can create custom strategies: + +1. See [Developer Guide](../developer/creating_strategies.md) +2. Extend the `BaseStrategy` class +3. Implement signal generation logic +4. Register your strategy + diff --git a/docs/user_manual/trading.md b/docs/user_manual/trading.md new file mode 100644 index 00000000..4b515828 --- /dev/null +++ b/docs/user_manual/trading.md @@ -0,0 +1,332 @@ +# Trading Guide + +This guide explains how to trade cryptocurrencies using Crypto Trader. + +## Trading Modes + +### Paper Trading + +Paper trading allows you to practice trading with virtual funds without risking real money. + +- Default starting capital: $100 USD +- All trades are simulated +- Perfect for testing strategies +- No real money at risk + +### Live Trading + +Live trading executes real orders on connected exchanges. + +- Requires exchange API keys with trading permissions +- Real money is at risk +- Always test strategies in paper trading first +- Use risk management features + +## Autopilot Modes + +The Autopilot feature provides autonomous trading signal generation with two distinct modes, each optimized for different use cases and user preferences. + +### Pattern-Based Autopilot + +**What it does**: Detects technical chart patterns (Head & Shoulders, triangles, wedges, etc.) and combines them with sentiment analysis to generate trading signals. + +**How it works**: +- Uses geometric pattern recognition to identify 40+ chart patterns +- Analyzes news headlines using FinBERT for market sentiment +- Generates signals when patterns align with sentiment +- Rule-based logic: Pattern + Sentiment Alignment = Signal + +**Best for**: +- Users who want transparency and explainable decisions +- Users who understand technical analysis +- Users who prefer immediate setup without training data +- Users who want lightweight, fast execution + +**Key Features**: +- ✅ Transparent and explainable - you can see exactly why each signal was generated +- ✅ No training data required - works immediately +- ✅ Fast and lightweight - minimal resource usage +- ✅ Pattern recognition for 40+ chart patterns +- ✅ Real-time sentiment analysis + +**Tradeoffs**: +- ❌ Less adaptive to market changes (fixed rules) +- ❌ Requires both pattern and sentiment alignment for signals + +### ML-Based Autopilot + +**What it does**: Uses machine learning to analyze market conditions and automatically select the best trading strategy from available strategies. + +**How it works**: +- Analyzes current market conditions (volatility, trend, volume, etc.) +- Uses ML model trained on historical performance data +- Selects optimal strategy based on market regime +- Can auto-execute trades when confidence is high + +**Best for**: +- Users who want adaptive, data-driven decisions +- Users who don't need to understand every decision +- Advanced users seeking performance optimization +- Users with sufficient historical data for training + +**Key Features**: +- ✅ Adapts to market conditions automatically +- ✅ Learns from historical performance +- ✅ Optimizes strategy selection +- ✅ Market condition analysis +- ✅ Performance tracking and learning + +**Tradeoffs**: +- ❌ Requires training data (needs historical trades) +- ❌ Less transparent (ML model decisions are less explainable) +- ❌ More complex setup (model training required) + +### Choosing the Right Autopilot Mode + +Use this decision guide to select the appropriate mode: + +**Choose Pattern-Based if**: +- You want to understand every trading decision +- You prefer transparent, explainable logic +- You want immediate setup without waiting for training data +- You understand technical analysis patterns +- You want lightweight, fast execution + +**Choose ML-Based if**: +- You want adaptive, data-driven decisions +- You have sufficient historical trading data +- You want the system to learn and optimize automatically +- You don't need to understand every decision +- You want maximum performance optimization + +### Mode Comparison + +| Feature | Pattern-Based | ML-Based | +|---------|--------------|----------| +| **Transparency** | High - All decisions explainable | Low - ML model decisions less transparent | +| **Adaptability** | Low - Fixed rules | High - Learns and adapts | +| **Setup Time** | Immediate - No setup required | Requires training data collection | +| **Resource Usage** | Low - Lightweight | Medium - ML model overhead | +| **Training Data** | Not required | Required | +| **Pattern Recognition** | Yes (40+ patterns) | No (uses strategies) | +| **Sentiment Analysis** | Yes (FinBERT) | No | +| **ML Strategy Selection** | No | Yes | +| **Auto-Execution** | Configurable | Configurable | + +### Auto-Execution + +Both autopilot modes support auto-execution, which can be enabled independently of mode selection. + +**What is Auto-Execution?** +- When enabled, the autopilot will automatically execute trades based on generated signals +- Trades are executed according to risk management rules +- You can monitor all auto-executed trades in the Trading page + +**Safety Considerations**: +- ⚠️ **Warning**: Auto-execution will automatically execute trades with real money (if not in paper trading mode) +- Always test in paper trading mode first +- Set appropriate risk limits before enabling +- Monitor auto-executed trades regularly +- Start with small position sizes + +**How to Enable/Disable**: +1. Go to the Dashboard page +2. Find the Autopilot Configuration section +3. Toggle the "Auto-Execute" switch +4. Confirm when prompted (first time only) + +**Monitoring Auto-Executed Trades**: +- All auto-executed trades appear in the Trading page +- Check the Order History tab to review executed trades +- Monitor positions in the Positions tab +- Review autopilot status in the Dashboard + +### Switching Between Modes + +You can switch between autopilot modes at any time: + +1. Stop the current autopilot (if running) +2. Select the desired mode in the Autopilot Configuration section +3. Configure mode-specific settings +4. Start the autopilot in the new mode + +**Note**: You cannot run both modes simultaneously for the same symbol. Stopping one mode before starting another is required. + +## Manual Trading Interface + +The Trading page provides a comprehensive interface for manual order placement and management. + +### Accessing the Trading Page + +1. Click on **Trading** in the navigation menu +2. The page displays: + - Account balance and summary + - Open positions (card view) + - Active orders table + - Order history table + +### Placing Orders + +#### Market Orders + +Market orders execute immediately at the current market price. + +1. Click **Place Order** button +2. In the order dialog: + - Select **Exchange** from dropdown + - Select **Symbol** (e.g., BTC/USD) + - Choose **Side** (Buy or Sell) + - Select **Order Type**: Market + - Enter **Quantity** +3. Click **Place Order** + +The order will execute immediately. You'll see a success notification and the order will appear in the Active Orders table. + +#### Limit Orders + +Limit orders execute only when the price reaches your specified limit. + +1. Click **Place Order** button +2. Select **Order Type**: Limit +3. Enter **Price** (required for limit orders) +4. Enter **Quantity** +5. Click **Place Order** + +The order will appear in Active Orders and execute when the price reaches your limit. + +#### Advanced Order Types + +**Stop Loss**: Automatically sells if price drops below threshold +- Select "Stop Loss" order type +- Enter stop price +- Enter quantity + +**Take Profit**: Automatically sells when profit target is reached +- Select "Take Profit" order type +- Enter target price +- Enter quantity + +**Trailing Stop**: Adjusts stop price as price moves favorably +- Select "Trailing Stop" order type +- Enter initial stop price +- Enter quantity + +**OCO (One-Cancels-Other)**: Places two orders, one cancels the other when filled +- Select "OCO" order type +- Configure both orders in advanced options + +**Iceberg**: Large order split into smaller visible orders +- Select "Iceberg" order type +- Configure in advanced options + +## Managing Positions + +### Viewing Positions + +Positions can be viewed in two places: + +1. **Trading Page - Positions Tab**: + - Card-based view showing all open positions + - Each card displays: + - Symbol + - Quantity + - Entry price + - Current price + - Unrealized P&L (with percentage) + - Realized P&L + - Position value + +2. **Portfolio Page**: + - Detailed portfolio view with allocation charts + - Same position cards with close functionality + +### Closing Positions + +1. Navigate to **Trading** page and select **Positions** tab, or go to **Portfolio** page +2. Find the position card you want to close +3. Click **Close Position** button on the card +4. In the close dialog: + - Choose order type (Market or Limit) + - For limit orders, enter the limit price +5. Click **Close Position** to confirm + +The position will be closed and removed from your open positions. You'll receive a notification when the order is filled. + +## Order Management + +### Viewing Orders + +The Trading page has three tabs for order management: + +1. **Positions Tab**: Shows all open positions with detailed P&L information +2. **Active Orders Tab**: Displays all pending, open, and partially filled orders + - Shows order details: time, symbol, side, type, quantity, price, fill status + - Real-time updates as orders are filled or canceled +3. **Order History Tab**: Shows completed, canceled, rejected, and expired orders + - Includes filled quantity, average fill price, and fees + - Limited to most recent 50 orders + +### Canceling Orders + +1. Navigate to **Trading** page +2. Select **Active Orders** tab +3. Find the order you want to cancel +4. Click the **Cancel** button (red X icon) in the Actions column + +The order will be canceled immediately and moved to Order History. You'll receive a notification confirming the cancellation. + +### Order Status Indicators + +Orders are color-coded by status: +- **Green (Filled)**: Successfully executed +- **Yellow (Pending/Open/Partially Filled)**: Waiting to be filled +- **Red (Cancelled/Rejected/Expired)**: Failed or canceled orders + +## Best Practices + +1. **Start with Paper Trading**: Always test strategies first +2. **Use Risk Management**: Set stop-losses and position limits +3. **Monitor Positions**: Regularly check your open positions +4. **Start Small**: Begin with small position sizes +5. **Keep Records**: Review your trading history regularly + +## Fees + +Trading fees are automatically calculated and deducted: + +- **Maker fees**: Lower fees for limit orders that add liquidity +- **Taker fees**: Higher fees for market orders +- Fees vary by exchange + +### Paper Trading Fee Simulation + +For paper trading, you can simulate different exchange fee structures: + +1. Go to **Settings → Paper Trading** +2. Select **Fee Model (Exchange)** from the dropdown: + - **Default**: 0.1% maker / 0.1% taker + - **Coinbase**: 0.4% maker / 0.6% taker + - **Kraken**: 0.16% maker / 0.26% taker + - **Binance**: 0.1% maker / 0.1% taker +3. Click **Save Settings** + +The current fee rates display shows: +- Maker fee rate +- Taker fee rate +- Estimated round-trip cost (buy + sell) + +This helps you understand how fees impact your trading strategy profitability before going live. + +## Troubleshooting + +**Order not executing?** +- Check exchange connection +- Verify API permissions +- Check account balance +- Review order parameters + +**Position not showing?** +- Refresh the portfolio view +- Check database connection +- Review application logs + diff --git a/docs/user_manual/troubleshooting.md b/docs/user_manual/troubleshooting.md new file mode 100644 index 00000000..fd15fb52 --- /dev/null +++ b/docs/user_manual/troubleshooting.md @@ -0,0 +1,254 @@ +# Troubleshooting Guide + +Common issues and solutions for Crypto Trader. + +## Application Won't Start + +### Issue: Backend server won't start + +**Solutions:** +1. Check Python version (requires 3.11+) +2. Verify all dependencies are installed: `pip install -r requirements.txt && pip install -r backend/requirements.txt` +3. Check if port 8000 is already in use: `lsof -i :8000` (Linux/macOS) or `netstat -ano | findstr :8000` (Windows) +4. Try running manually: `python -m uvicorn backend.main:app --reload --port 8000` +5. Check log files in `~/.local/share/crypto_trader/logs/` +6. Verify database permissions + +### Issue: Frontend won't start + +**Solutions:** +1. Verify Node.js is installed (v18+): `node --version` +2. Install dependencies: `cd frontend && npm install` +3. Check if port 3000 is already in use +4. Try running manually: `cd frontend && npm run dev` +5. Clear node_modules and reinstall: `rm -rf node_modules package-lock.json && npm install` +6. Check browser console for errors + +### Issue: Cannot connect to backend from frontend + +**Solutions:** +1. Verify backend is running on http://localhost:8000 +2. Check API endpoint in browser: http://localhost:8000/docs +3. Check CORS settings in backend +4. Verify API client URL in frontend code +5. Check browser console for CORS errors +6. Verify firewall isn't blocking connections + +### Issue: Import errors + +**Solutions:** +1. Reinstall dependencies: `pip install -r requirements.txt` +2. Verify virtual environment is activated +3. Check Python path configuration +4. Reinstall problematic packages + +## Exchange Connection Issues + +### Issue: Cannot connect to exchange + +**Solutions:** +1. Verify API keys are correct +2. Check API key permissions +3. Verify internet connection +4. Check exchange status (may be down) +5. Review exchange rate limits +6. Check firewall settings + +### Issue: API key errors + +**Solutions:** +1. Verify API keys are valid +2. Check key permissions (read-only vs trading) +3. Regenerate API keys if needed +4. Verify IP whitelist settings +5. Check key expiration dates + +## Trading Issues + +### Issue: Orders not executing + +**Solutions:** +1. Check account balance +2. Verify API permissions include trading +3. Check order parameters (price, quantity) +4. Review exchange order limits +5. Check if market is open +6. Verify order type is supported + +### Issue: Positions not updating + +**Solutions:** +1. Refresh portfolio view +2. Check database connection +3. Verify exchange connection +4. Review application logs +5. Restart application + +## Data Issues + +### Issue: Missing historical data + +**Solutions:** +1. Check data collection is enabled +2. Verify exchange connection +3. Check data storage permissions +4. Review data retention settings +5. Manually trigger data collection + +### Issue: Incorrect price data + +**Solutions:** +1. Verify exchange connection +2. Check data source +3. Review data quality settings +4. Clear and reload data +5. Check for data gaps + +## Performance Issues + +### Issue: Application is slow + +**Solutions:** +1. Check system resources (CPU, memory) +2. Reduce historical data retention +3. Optimize database (VACUUM ANALYZE) +4. Close unnecessary strategies +5. Reduce chart data points +6. Check for memory leaks in logs + +### Issue: High memory usage + +**Solutions:** +1. Reduce data retention period +2. Limit number of active strategies +3. Reduce chart history +4. Clear old logs +5. Restart application periodically + +## Database Issues + +### Issue: Database errors + +**Solutions:** +1. Check database connection to PostgreSQL +2. Verify disk space available +3. Check database user permissions +4. Backup and restore database if corrupted +5. Review database logs + +### Issue: Database connection failed + +**Solutions:** +1. Check if PostgreSQL service is running +2. Verify connection URL in `config.yaml` +3. Check network connectivity to database host +4. Verify database user credentials and permissions +5. Check PostgreSQL logs + +## Configuration Issues + +### Issue: Settings not saving + +**Solutions:** +1. Check config directory permissions +2. Verify config file is writable +3. Check disk space +4. Review application logs +5. Manually edit config file if needed + +### Issue: Configuration errors + +**Solutions:** +1. Validate YAML syntax +2. Check for invalid values +3. Review default configuration +4. Reset to defaults if needed +5. Check configuration file encoding + +## Web UI Issues + +### Issue: Page not loading or blank screen + +**Solutions:** +1. Check browser console for JavaScript errors (F12) +2. Clear browser cache and reload +3. Verify frontend server is running +4. Check network tab for failed API requests +5. Try a different browser +6. Check if backend is accessible + +### Issue: WebSocket connection not working + +**Solutions:** +1. Check WebSocket status indicator in Dashboard +2. Verify backend WebSocket endpoint is accessible +3. Check browser console for WebSocket errors +4. Verify firewall/proxy settings allow WebSocket connections +5. Try refreshing the page +6. Check backend logs for WebSocket errors + +### Issue: Real-time updates not appearing + +**Solutions:** +1. Check WebSocket connection status +2. Verify WebSocket is connected (green indicator) +3. Refresh the page +4. Check browser console for errors +5. Verify backend is sending WebSocket messages +6. Check network tab for WebSocket connection + +### Issue: Forms not submitting + +**Solutions:** +1. Check browser console for validation errors +2. Verify all required fields are filled +3. Check for error messages in the form +4. Verify API endpoint is accessible +5. Check network tab for failed requests +6. Look for Snackbar error notifications + +## Strategy Issues + +### Issue: Strategy not generating signals + +**Solutions:** +1. Verify strategy is enabled (check Strategies page) +2. Check strategy parameters +3. Verify data is available +4. Check strategy logs +5. Review signal generation logic +6. Check Operations Panel for running strategies + +### Issue: Strategy errors + +**Solutions:** +1. Check strategy parameters in Strategy Management page +2. Verify data availability +3. Review strategy code +4. Check application logs +5. Test with different parameters +6. Check error notifications in UI + +## Getting Help + +If you continue to experience issues: + +1. **Check Logs**: Review application logs in `~/.local/share/crypto_trader/logs/` +2. **Review Documentation**: Check user manual and API docs +3. **Check FAQ**: See [FAQ](faq.md) for common questions +4. **Report Issues**: Create an issue with: + - Error messages + - Steps to reproduce + - System information + - Log files + +## Log Files + +Log files are located in `~/.local/share/crypto_trader/logs/`: + +- `app.log`: Main application log +- `trading.log`: Trading operations +- `errors.log`: Error messages +- `debug.log`: Debug information (if enabled) + +Review logs for detailed error information. diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 00000000..c4789365 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,53 @@ +# Crypto Trader Frontend + +Modern React frontend for the Crypto Trader application. + +## Setup + +```bash +npm install +``` + +## Development + +```bash +npm run dev +``` + +Access at: http://localhost:3000 + +## Build + +```bash +npm run build +``` + +## Environment Variables + +Create `.env` file: + +```env +VITE_API_URL=http://localhost:8000 +VITE_WS_URL=ws://localhost:8000/ws/ +``` + +## Tech Stack + +- **React 18** - UI framework +- **TypeScript** - Type safety +- **Material-UI** - Component library +- **React Query** - Data fetching +- **React Router** - Routing +- **Recharts** - Charts +- **Vite** - Build tool + +## Project Structure + +``` +src/ +├── api/ # API client functions +├── components/ # Reusable components +├── hooks/ # Custom React hooks +├── pages/ # Page components +└── types/ # TypeScript types +``` diff --git a/frontend/e2e/dashboard.spec.ts b/frontend/e2e/dashboard.spec.ts new file mode 100644 index 00000000..b285dc1a --- /dev/null +++ b/frontend/e2e/dashboard.spec.ts @@ -0,0 +1,89 @@ +import { test, expect } from '@playwright/test' + +test.describe('Dashboard Page', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + }) + + test('loads dashboard page', async ({ page }) => { + await expect(page).toHaveTitle(/FXQ One|Crypto Trader/i) + }) + + test('displays main sections', async ({ page }) => { + // Check for main dashboard elements + await expect(page.getByRole('heading', { level: 4 })).toBeVisible() + + // Wait for content to load + await page.waitForLoadState('networkidle') + + // Check for navigation elements + await expect(page.getByRole('navigation')).toBeVisible() + }) + + test('displays autopilot configuration section', async ({ page }) => { + await page.waitForLoadState('networkidle') + + // Look for autopilot related elements + await expect(page.getByText(/autopilot/i).first()).toBeVisible() + }) + + test('navigation works correctly', async ({ page }) => { + // Navigate to different pages + await page.click('text=Trading') + await expect(page).toHaveURL(/.*trading/i) + + await page.click('text=Portfolio') + await expect(page).toHaveURL(/.*portfolio/i) + + await page.click('text=Strategies') + await expect(page).toHaveURL(/.*strateg/i) + + await page.click('text=Settings') + await expect(page).toHaveURL(/.*settings/i) + + // Go back to dashboard + await page.click('text=Dashboard') + await expect(page).toHaveURL(/.*\/$/) + }) + + test('displays real-time status indicators', async ({ page }) => { + await page.waitForLoadState('networkidle') + + // Look for status indicators (chips, badges, etc.) + const statusChips = page.locator('.MuiChip-root') + await expect(statusChips.first()).toBeVisible({ timeout: 10000 }) + }) +}) + +test.describe('Dashboard - Autopilot Controls', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + }) + + test('shows autopilot start button', async ({ page }) => { + const startButton = page.getByRole('button', { name: /start.*autopilot/i }) + // Button should be visible + await expect(startButton).toBeVisible({ timeout: 10000 }) + }) + + test('symbol selection is available', async ({ page }) => { + // Look for symbol selector (autocomplete or select) + const symbolInput = page.locator('[data-testid="autopilot-symbols"], .MuiAutocomplete-root, input[placeholder*="symbol" i]').first() + await expect(symbolInput).toBeVisible({ timeout: 10000 }) + }) +}) + +test.describe('Dashboard - Charts', () => { + test('chart grid displays', async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + + // Wait for charts to potentially load + await page.waitForTimeout(2000) + + // Look for chart container + const chartArea = page.locator('[class*="chart"], canvas, svg').first() + await expect(chartArea).toBeVisible({ timeout: 15000 }) + }) +}) diff --git a/frontend/e2e/settings.spec.ts b/frontend/e2e/settings.spec.ts new file mode 100644 index 00000000..f71cadc0 --- /dev/null +++ b/frontend/e2e/settings.spec.ts @@ -0,0 +1,73 @@ +import { test, expect } from '@playwright/test' + +test.describe('Settings Page', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/settings') + await page.waitForLoadState('networkidle') + }) + + test('displays settings page title', async ({ page }) => { + await expect(page.getByRole('heading', { name: /settings/i })).toBeVisible() + }) + + test('shows exchange configuration section', async ({ page }) => { + await expect(page.getByText(/exchange/i).first()).toBeVisible() + }) + + test('shows alert configuration section', async ({ page }) => { + await expect(page.getByText(/alert/i).first()).toBeVisible() + }) + + test('has tabs for different settings categories', async ({ page }) => { + // Look for tab navigation + const tabs = page.getByRole('tab') + await expect(tabs.first()).toBeVisible() + }) +}) + +test.describe('Settings - Exchange Configuration', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/settings') + await page.waitForLoadState('networkidle') + }) + + test('shows add exchange option', async ({ page }) => { + // Look for add exchange button or option + const addExchange = page.getByRole('button', { name: /add.*exchange|new.*exchange|configure/i }) + await expect(addExchange.first()).toBeVisible() + }) +}) + +test.describe('Settings - Performance', () => { + test('page loads within reasonable time', async ({ page }) => { + const startTime = Date.now() + + await page.goto('/settings') + await page.waitForLoadState('domcontentloaded') + + const loadTime = Date.now() - startTime + + // Page should load within 5 seconds + expect(loadTime).toBeLessThan(5000) + }) + + test('no console errors on load', async ({ page }) => { + const errors: string[] = [] + + page.on('console', (msg) => { + if (msg.type() === 'error') { + errors.push(msg.text()) + } + }) + + await page.goto('/settings') + await page.waitForLoadState('networkidle') + + // Filter out known non-critical errors + const criticalErrors = errors.filter( + (e) => !e.includes('WebSocket') && !e.includes('Failed to load resource') + ) + + expect(criticalErrors).toHaveLength(0) + }) +}) diff --git a/frontend/e2e/strategies.spec.ts b/frontend/e2e/strategies.spec.ts new file mode 100644 index 00000000..56b78325 --- /dev/null +++ b/frontend/e2e/strategies.spec.ts @@ -0,0 +1,79 @@ +import { test, expect } from '@playwright/test' + +test.describe('Strategies Page', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/strategies') + await page.waitForLoadState('networkidle') + }) + + test('displays strategies page title', async ({ page }) => { + await expect(page.getByRole('heading', { name: /strateg/i })).toBeVisible() + }) + + test('shows create strategy button', async ({ page }) => { + const createButton = page.getByRole('button', { name: /create|new|add/i }).first() + await expect(createButton).toBeVisible() + }) + + test('displays strategy list or empty state', async ({ page }) => { + // Either show strategies or empty state message + const content = page.getByText(/no strategies|create your first|strategy/i).first() + await expect(content).toBeVisible() + }) +}) + +test.describe('Strategies - Create Strategy Flow', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/strategies') + await page.waitForLoadState('networkidle') + }) + + test('opens strategy creation dialog', async ({ page }) => { + const createButton = page.getByRole('button', { name: /create|new|add/i }).first() + await createButton.click() + + // Dialog should open + await expect(page.getByRole('dialog')).toBeVisible() + }) + + test('strategy dialog has required fields', async ({ page }) => { + const createButton = page.getByRole('button', { name: /create|new|add/i }).first() + await createButton.click() + + await expect(page.getByRole('dialog')).toBeVisible() + + // Check for strategy name field + await expect(page.getByLabel(/name/i)).toBeVisible() + + // Check for strategy type selector + await expect(page.getByLabel(/type|strategy type/i).or(page.getByText(/select.*strategy/i).first())).toBeVisible() + }) + + test('shows available strategy types', async ({ page }) => { + const createButton = page.getByRole('button', { name: /create|new|add/i }).first() + await createButton.click() + + await expect(page.getByRole('dialog')).toBeVisible() + + // Open strategy type dropdown + const typeSelector = page.getByLabel(/type|strategy type/i).or( + page.locator('[data-testid="strategy-type-select"]') + ) + await typeSelector.click() + + // Should see strategy options like RSI, MACD, etc. + await expect(page.getByRole('option').first()).toBeVisible({ timeout: 5000 }) + }) + + test('can cancel strategy creation', async ({ page }) => { + const createButton = page.getByRole('button', { name: /create|new|add/i }).first() + await createButton.click() + + await expect(page.getByRole('dialog')).toBeVisible() + + // Cancel + await page.getByRole('button', { name: /cancel/i }).click() + + await expect(page.getByRole('dialog')).not.toBeVisible() + }) +}) diff --git a/frontend/e2e/trading.spec.ts b/frontend/e2e/trading.spec.ts new file mode 100644 index 00000000..21b79c94 --- /dev/null +++ b/frontend/e2e/trading.spec.ts @@ -0,0 +1,96 @@ +import { test, expect } from '@playwright/test' + +test.describe('Trading Page', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/trading') + await page.waitForLoadState('networkidle') + }) + + test('displays trading page title', async ({ page }) => { + await expect(page.getByRole('heading', { name: /trading/i })).toBeVisible() + }) + + test('shows order form button', async ({ page }) => { + // Look for new order button or order form + const orderButton = page.getByRole('button', { name: /new order|place order|create order/i }) + await expect(orderButton).toBeVisible() + }) + + test('displays positions section', async ({ page }) => { + // Look for positions area + await expect(page.getByText(/positions/i).first()).toBeVisible() + }) + + test('displays orders section', async ({ page }) => { + // Look for orders area + await expect(page.getByText(/orders/i).first()).toBeVisible() + }) + + test('paper trading toggle is visible', async ({ page }) => { + // Look for paper trading switch + const paperToggle = page.getByRole('switch', { name: /paper/i }).or( + page.getByText(/paper trading/i) + ) + await expect(paperToggle.first()).toBeVisible() + }) +}) + +test.describe('Trading - Order Form', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/trading') + await page.waitForLoadState('networkidle') + }) + + test('opens order form dialog', async ({ page }) => { + // Click new order button + const orderButton = page.getByRole('button', { name: /new order|place order|create order/i }) + await orderButton.click() + + // Check dialog opens + await expect(page.getByRole('dialog')).toBeVisible() + }) + + test('order form has required fields', async ({ page }) => { + // Open order form + const orderButton = page.getByRole('button', { name: /new order|place order|create order/i }) + await orderButton.click() + + await expect(page.getByRole('dialog')).toBeVisible() + + // Check for symbol field + await expect(page.getByLabel(/symbol/i)).toBeVisible() + + // Check for quantity field + await expect(page.getByLabel(/quantity|amount/i)).toBeVisible() + + // Check for side selection (buy/sell) + await expect(page.getByText(/buy|sell/i).first()).toBeVisible() + + // Check for order type + await expect(page.getByLabel(/order type|type/i)).toBeVisible() + }) + + test('can close order form', async ({ page }) => { + // Open order form + const orderButton = page.getByRole('button', { name: /new order|place order|create order/i }) + await orderButton.click() + + await expect(page.getByRole('dialog')).toBeVisible() + + // Close dialog + await page.getByRole('button', { name: /cancel|close/i }).click() + + await expect(page.getByRole('dialog')).not.toBeVisible() + }) +}) + +test.describe('Trading - Balance Display', () => { + test('shows balance information', async ({ page }) => { + await page.goto('/trading') + await page.waitForLoadState('networkidle') + + // Look for balance display + const balanceText = page.getByText(/balance|total|available/i) + await expect(balanceText.first()).toBeVisible() + }) +}) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 00000000..e1bb536b --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + FXQ One + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 00000000..369c23f3 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,6715 @@ +{ + "name": "fxq-one-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fxq-one-frontend", + "version": "1.0.0", + "dependencies": { + "@emotion/react": "^11.11.1", + "@emotion/styled": "^11.11.0", + "@mui/icons-material": "^5.15.0", + "@mui/material": "^5.15.0", + "@tanstack/react-query": "^5.12.0", + "axios": "^1.6.2", + "date-fns": "^3.0.0", + "lightweight-charts": "^4.1.3", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0", + "recharts": "^2.10.3", + "zustand": "^4.4.7" + }, + "devDependencies": { + "@playwright/test": "^1.57.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.1", + "@testing-library/user-event": "^14.6.1", + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@typescript-eslint/eslint-plugin": "^6.14.0", + "@typescript-eslint/parser": "^6.14.0", + "@vitejs/plugin-react": "^4.2.1", + "@vitest/coverage-v8": "^4.0.16", + "eslint": "^8.55.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.5", + "jsdom": "^27.3.0", + "typescript": "^5.2.2", + "vite": "^5.0.8", + "vitest": "^4.0.16" + } + }, + "node_modules/@acemir/cssom": { + "version": "0.9.30", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.30.tgz", + "integrity": "sha512-9CnlMCI0LmCIq0olalQqdWrJHPzm0/tw3gzOA9zJSgvFX7Xau3D24mAGa4BtwxwY69nsuJW6kQqqCzf/mEcQgg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", + "integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.7.6", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz", + "integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@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/core/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/@babel/core/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/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@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.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@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-compilation-targets/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/@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==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "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==", + "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==", + "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.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "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.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.22", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.22.tgz", + "integrity": "sha512-qBcx6zYlhleiFfdtzkRgwNC7VVoAwfK76Vmsw5t+PbvtdknO9StgRk7ROvq9so1iqbdW4uLIDAsXRsTfUrIoOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/styled": { + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, + "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-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "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-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "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/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "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/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "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==", + "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==", + "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==", + "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==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mui/core-downloads-tracker": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.18.0.tgz", + "integrity": "sha512-jbhwoQ1AY200PSSOrNXmrFCaSDSJWP7qk6urkTmIirvRXDROkqe+QwcLlUiw/PrREwsIF/vm3/dAXvjlMHF0RA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.18.0.tgz", + "integrity": "sha512-1s0vEZj5XFXDMmz3Arl/R7IncFqJ+WQ95LDp1roHWGDE2oCO3IS4/hmiOv1/8SD9r6B7tv9GLiqVZYHo+6PkTg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^5.0.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.18.0.tgz", + "integrity": "sha512-bbH/HaJZpFtXGvWg3TsBWG4eyt3gah3E7nCNU8GLyRjVoWcA91Vm/T+sjHfUcwgJSw9iLtucfHBoq+qW/T30aA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/core-downloads-tracker": "^5.18.0", + "@mui/system": "^5.18.0", + "@mui/types": "~7.2.15", + "@mui/utils": "^5.17.1", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.10", + "clsx": "^2.1.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^19.0.0", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/private-theming": { + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.17.1.tgz", + "integrity": "sha512-XMxU0NTYcKqdsG8LRmSoxERPXwMbp16sIXPcLVgLGII/bVNagX0xaheWAwFv8+zDK7tI3ajllkuD3GZZE++ICQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/utils": "^5.17.1", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.18.0.tgz", + "integrity": "sha512-BN/vKV/O6uaQh2z5rXV+MBlVrEkwoS/TK75rFQ2mjxA7+NBo8qtTAOA4UaM0XeJfn7kh2wZ+xQw2HAx0u+TiBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.18.0.tgz", + "integrity": "sha512-ojZGVcRWqWhu557cdO3pWHloIGJdzVtxs3rk0F9L+x55LsUjcMUVkEhiF7E4TMxZoF9MmIHGGs0ZX3FDLAf0Xw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/private-theming": "^5.17.1", + "@mui/styled-engine": "^5.18.0", + "@mui/types": "~7.2.15", + "@mui/utils": "^5.17.1", + "clsx": "^2.1.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.2.24", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.24.tgz", + "integrity": "sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.17.1.tgz", + "integrity": "sha512-jEZ8FTqInt2WzxDV8bhImWBqeQRD99c/id/fq83H0ER9tFl+sfZlaAoCdznGvbSQQ9ividMxqSV2c7cC1vBcQg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/types": "~7.2.15", + "@types/prop-types": "^15.7.12", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.0.0" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "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/@playwright/test": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.1", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.1.tgz", + "integrity": "sha512-vDbaOzF7yT2Qs4vO6XV1MHcJv+3dgR1sT+l3B8xxOVhUC336prMvqrvsLL/9Dnw2xr6Qhz4J0dmS0llNAbnUmQ==", + "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.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", + "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz", + "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz", + "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz", + "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz", + "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz", + "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz", + "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz", + "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz", + "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz", + "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz", + "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz", + "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz", + "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz", + "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz", + "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", + "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", + "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz", + "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz", + "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz", + "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz", + "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz", + "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tanstack/query-core": { + "version": "5.90.12", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz", + "integrity": "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.12", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz", + "integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.1.tgz", + "integrity": "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "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/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "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.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "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/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "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/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "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==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.27", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", + "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "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/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", + "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.5.1", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/type-utils": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "graphemer": "^1.4.0", + "ignore": "^5.2.4", + "natural-compare": "^1.4.0", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", + "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", + "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", + "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", + "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", + "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "semver": "^7.5.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", + "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "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/@vitest/coverage-v8": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.16.tgz", + "integrity": "sha512-2rNdjEIsPRzsdu6/9Eq0AYAzYdpP6Bx9cje9tL3FE5XzXRQF1fNU9pe/1yE8fCrS0HD+fBtt6gLPh6LI57tX7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.0.16", + "ast-v8-to-istanbul": "^0.3.8", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.1", + "obug": "^2.1.1", + "std-env": "^3.10.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.0.16", + "vitest": "4.0.16" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz", + "integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.16.tgz", + "integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.16", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.16.tgz", + "integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.16.tgz", + "integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.16", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.16.tgz", + "integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.16", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.16.tgz", + "integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.16.tgz", + "integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.16", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.9.tgz", + "integrity": "sha512-dSC6tJeOJxbZrPzPbv5mMd6CMiQ1ugaVXXPRad2fXUSsy1kstFn9XQWemV9VW7Y7kpxgQ/4WMoZfwdH8XSU48w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^9.0.1" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "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/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.11", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", + "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "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/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001761", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz", + "integrity": "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==", + "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/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "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/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "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/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "5.3.5", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.5.tgz", + "integrity": "sha512-GlsEptulso7Jg0VaOZ8BXQi3AkYM5BOJKEO/rjMidSCq70FkIC5y0eawrCXeYzxgt3OCf4Ls+eoxN+/05vN0Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "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.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "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/data-urls": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", + "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "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/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "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/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "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.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "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-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "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/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", + "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "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/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "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-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "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/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "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/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/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "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/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "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/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "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": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/glob/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/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/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "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-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "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==", + "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/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "27.3.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.3.0.tgz", + "integrity": "sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "cssstyle": "^5.3.4", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "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/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightweight-charts": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-4.2.3.tgz", + "integrity": "sha512-5kS/2hY3wNYNzhnS8Gb+GAS07DX8GPF2YVDnd2NMC85gJVQ6RLU6YrXNgNJ6eg0AnWPwCnvaGtYmGky3HiLQEw==", + "license": "Apache-2.0", + "dependencies": { + "fancy-canvas": "2.1.0" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "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/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz", + "integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, + "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/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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==", + "license": "MIT" + }, + "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/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "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/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/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "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==", + "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/playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "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/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "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/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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": "19.2.3", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz", + "integrity": "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==", + "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.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.2.tgz", + "integrity": "sha512-H2Bm38Zu1bm8KUE5NVWRMzuIyAV8p/JrOaBJAwVmp37AXG72+CZJlEBw6pdn9i5TBgLMhNDgijS4ZlblpHyWTA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.2.tgz", + "integrity": "sha512-l2OwHn3UUnEVUqc6/1VMmR1cvZryZ3j3NzapC2eUXO1dB0sYp5mvwdjiXhpUbRb21eFow3qSxpP8Yv6oAU824Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.1", + "react-router": "6.30.2" + }, + "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/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/recharts/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/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "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/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "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/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", + "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", + "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.54.0", + "@rollup/rollup-android-arm64": "4.54.0", + "@rollup/rollup-darwin-arm64": "4.54.0", + "@rollup/rollup-darwin-x64": "4.54.0", + "@rollup/rollup-freebsd-arm64": "4.54.0", + "@rollup/rollup-freebsd-x64": "4.54.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", + "@rollup/rollup-linux-arm-musleabihf": "4.54.0", + "@rollup/rollup-linux-arm64-gnu": "4.54.0", + "@rollup/rollup-linux-arm64-musl": "4.54.0", + "@rollup/rollup-linux-loong64-gnu": "4.54.0", + "@rollup/rollup-linux-ppc64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-musl": "4.54.0", + "@rollup/rollup-linux-s390x-gnu": "4.54.0", + "@rollup/rollup-linux-x64-gnu": "4.54.0", + "@rollup/rollup-linux-x64-musl": "4.54.0", + "@rollup/rollup-openharmony-arm64": "4.54.0", + "@rollup/rollup-win32-arm64-msvc": "4.54.0", + "@rollup/rollup-win32-ia32-msvc": "4.54.0", + "@rollup/rollup-win32-x64-gnu": "4.54.0", + "@rollup/rollup-win32-x64-msvc": "4.54.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/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "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": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "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/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "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/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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/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/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", + "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.19" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", + "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", + "dev": true, + "license": "MIT" + }, + "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/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "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/vite/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/vitest": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.16.tgz", + "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.16", + "@vitest/mocker": "4.0.16", + "@vitest/pretty-format": "4.0.16", + "@vitest/runner": "4.0.16", + "@vitest/snapshot": "4.0.16", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.16", + "@vitest/browser-preview": "4.0.16", + "@vitest/browser-webdriverio": "4.0.16", + "@vitest/ui": "4.0.16", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/vitest/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/vitest/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/vitest/node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.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 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", + "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "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" + }, + "node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 00000000..3a6069bc --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,51 @@ +{ + "name": "fxq-one-frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui" + }, + "dependencies": { + "@emotion/react": "^11.11.1", + "@emotion/styled": "^11.11.0", + "@mui/icons-material": "^5.15.0", + "@mui/material": "^5.15.0", + "@tanstack/react-query": "^5.12.0", + "axios": "^1.6.2", + "date-fns": "^3.0.0", + "lightweight-charts": "^4.1.3", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0", + "recharts": "^2.10.3", + "zustand": "^4.4.7" + }, + "devDependencies": { + "@playwright/test": "^1.57.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.1", + "@testing-library/user-event": "^14.6.1", + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@typescript-eslint/eslint-plugin": "^6.14.0", + "@typescript-eslint/parser": "^6.14.0", + "@vitejs/plugin-react": "^4.2.1", + "@vitest/coverage-v8": "^4.0.16", + "eslint": "^8.55.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.5", + "jsdom": "^27.3.0", + "typescript": "^5.2.2", + "vite": "^5.0.8", + "vitest": "^4.0.16" + } +} \ No newline at end of file diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 00000000..b625073b --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,56 @@ +import { defineConfig, devices } from '@playwright/test' + +/** + * Playwright E2E Test Configuration + * See https://playwright.dev/docs/test-configuration + */ +export default defineConfig({ + testDir: './e2e', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use */ + reporter: 'html', + /* Shared settings for all the projects below */ + use: { + /* Base URL to use in actions like `await page.goto('/')` */ + baseURL: 'http://localhost:3000', + /* Collect trace when retrying the failed test */ + trace: 'on-first-retry', + /* Take screenshot on failure */ + screenshot: 'only-on-failure', + /* Record video on failure */ + video: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + /* Uncomment for WebKit testing + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + */ + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'npm run dev', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + }, +}) diff --git a/frontend/public/logo.png b/frontend/public/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..ef8c0e17cb87b9da0db32954d5a7ffaab05b64e9 GIT binary patch literal 422172 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
+              {this.state.error?.stack}
+            
+ + + ) + } + + return this.props.children + } +} + +function App() { + // Enable real-time data updates + useRealtimeData() + + return ( + + + + } /> + } /> + } /> + } /> + } /> + } /> + + + + ) +} + +export default App diff --git a/frontend/src/api/__init__.ts b/frontend/src/api/__init__.ts new file mode 100644 index 00000000..513d3eef --- /dev/null +++ b/frontend/src/api/__init__.ts @@ -0,0 +1,5 @@ +export { apiClient } from './client' +export * as tradingApi from './trading' +export * as portfolioApi from './portfolio' +export * as strategiesApi from './strategies' +export * as backtestingApi from './backtesting' diff --git a/frontend/src/api/__tests__/autopilot.test.ts b/frontend/src/api/__tests__/autopilot.test.ts new file mode 100644 index 00000000..fe419985 --- /dev/null +++ b/frontend/src/api/__tests__/autopilot.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { autopilotApi } from '../autopilot' +import { apiClient } from '../client' + +vi.mock('../client') + +describe('autopilotApi', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('getModes', () => { + it('calls correct endpoint', async () => { + const mockResponse = { + data: { + modes: { + pattern: { name: 'Pattern-Based' }, + intelligent: { name: 'ML-Based' }, + }, + }, + } + + vi.mocked(apiClient.get).mockResolvedValue(mockResponse) + + const result = await autopilotApi.getModes() + + expect(apiClient.get).toHaveBeenCalledWith('/api/autopilot/modes') + expect(result).toEqual(mockResponse.data) + }) + }) + + describe('startUnified', () => { + it('calls correct endpoint with config', async () => { + const config = { + symbol: 'BTC/USD', + mode: 'pattern' as const, + auto_execute: false, + } + + const mockResponse = { data: { status: 'started' } } + vi.mocked(apiClient.post).mockResolvedValue(mockResponse) + + const result = await autopilotApi.startUnified(config) + + expect(apiClient.post).toHaveBeenCalledWith('/api/autopilot/start-unified', config) + expect(result).toEqual(mockResponse.data) + }) + }) + + describe('stopUnified', () => { + it('calls correct endpoint with parameters', async () => { + const mockResponse = { data: { status: 'stopped' } } + vi.mocked(apiClient.post).mockResolvedValue(mockResponse) + + const result = await autopilotApi.stopUnified('BTC/USD', 'pattern', '1h') + + expect(apiClient.post).toHaveBeenCalledWith( + '/api/autopilot/stop-unified?symbol=BTC/USD&mode=pattern&timeframe=1h' + ) + expect(result).toEqual(mockResponse.data) + }) + }) + + describe('getUnifiedStatus', () => { + it('calls correct endpoint with parameters', async () => { + const mockResponse = { + data: { + running: true, + mode: 'pattern', + }, + } + vi.mocked(apiClient.get).mockResolvedValue(mockResponse) + + const result = await autopilotApi.getUnifiedStatus('BTC/USD', 'pattern', '1h') + + expect(apiClient.get).toHaveBeenCalledWith( + '/api/autopilot/status-unified/BTC/USD?mode=pattern&timeframe=1h' + ) + expect(result).toEqual(mockResponse.data) + }) + }) + + describe('backward compatibility', () => { + it('start method still exists', () => { + expect(autopilotApi.start).toBeDefined() + }) + + it('stop method still exists', () => { + expect(autopilotApi.stop).toBeDefined() + }) + + it('getStatus method still exists', () => { + expect(autopilotApi.getStatus).toBeDefined() + }) + + it('startIntelligent method still exists', () => { + expect(autopilotApi.startIntelligent).toBeDefined() + }) + + it('stopIntelligent method still exists', () => { + expect(autopilotApi.stopIntelligent).toBeDefined() + }) + }) +}) + diff --git a/frontend/src/api/__tests__/marketData.test.ts b/frontend/src/api/__tests__/marketData.test.ts new file mode 100644 index 00000000..7db64a2f --- /dev/null +++ b/frontend/src/api/__tests__/marketData.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { marketDataApi } from '../marketData' +import { apiClient } from '../client' + +vi.mock('../client') + +describe('marketDataApi', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('getOHLCV', () => { + it('calls correct endpoint with symbol', async () => { + const mockData = [{ time: 1234567890, open: 100, high: 105, low: 95, close: 102, volume: 1000 }] + vi.mocked(apiClient.get).mockResolvedValue({ data: mockData }) + + const result = await marketDataApi.getOHLCV('BTC/USD') + + expect(apiClient.get).toHaveBeenCalledWith('/api/market-data/ohlcv/BTC/USD', { + params: { timeframe: '1h' }, + }) + expect(result).toEqual(mockData) + }) + + it('passes custom timeframe', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ data: [] }) + + await marketDataApi.getOHLCV('ETH/USD', '4h') + + expect(apiClient.get).toHaveBeenCalledWith('/api/market-data/ohlcv/ETH/USD', { + params: { timeframe: '4h' }, + }) + }) + }) + + describe('getTicker', () => { + it('calls correct endpoint', async () => { + const mockTicker = { + symbol: 'BTC/USD', + bid: 41000, + ask: 41050, + last: 41025, + high: 42000, + low: 40000, + volume: 5000, + timestamp: 1234567890, + provider: 'CoinGecko', + } + vi.mocked(apiClient.get).mockResolvedValue({ data: mockTicker }) + + const result = await marketDataApi.getTicker('BTC/USD') + + expect(apiClient.get).toHaveBeenCalledWith('/api/market-data/ticker/BTC/USD') + expect(result).toEqual(mockTicker) + }) + }) + + describe('getProviderHealth', () => { + it('calls correct endpoint without provider param', async () => { + const mockHealth = { active_provider: 'CoinGecko', health: {} } + vi.mocked(apiClient.get).mockResolvedValue({ data: mockHealth }) + + const result = await marketDataApi.getProviderHealth() + + expect(apiClient.get).toHaveBeenCalledWith('/api/market-data/providers/health', { params: {} }) + expect(result).toEqual(mockHealth) + }) + + it('passes provider param when specified', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ data: {} }) + + await marketDataApi.getProviderHealth('CCXT') + + expect(apiClient.get).toHaveBeenCalledWith('/api/market-data/providers/health', { + params: { provider: 'CCXT' }, + }) + }) + }) + + describe('getProviderStatus', () => { + it('calls correct endpoint', async () => { + const mockStatus = { + active_provider: 'CoinGecko', + providers: {}, + cache: { size: 10, max_size: 100 }, + } + vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus }) + + const result = await marketDataApi.getProviderStatus() + + expect(apiClient.get).toHaveBeenCalledWith('/api/market-data/providers/status') + expect(result).toEqual(mockStatus) + }) + }) + + describe('getProviderConfig', () => { + it('calls correct endpoint', async () => { + const mockConfig = { + primary: [{ name: 'CoinGecko', enabled: true, priority: 1 }], + fallback: { name: 'CCXT', enabled: true }, + caching: { ticker_ttl: 60, ohlcv_ttl: 300, max_cache_size: 1000 }, + websocket: { enabled: true, reconnect_interval: 5000, ping_interval: 30000 }, + } + vi.mocked(apiClient.get).mockResolvedValue({ data: mockConfig }) + + const result = await marketDataApi.getProviderConfig() + + expect(apiClient.get).toHaveBeenCalledWith('/api/market-data/providers/config') + expect(result).toEqual(mockConfig) + }) + }) + + describe('updateProviderConfig', () => { + it('calls correct endpoint with config updates', async () => { + const updates = { caching: { ticker_ttl: 120 } } + const mockResponse = { message: 'Updated', config: {} } + vi.mocked(apiClient.put).mockResolvedValue({ data: mockResponse }) + + const result = await marketDataApi.updateProviderConfig(updates as any) + + expect(apiClient.put).toHaveBeenCalledWith('/api/market-data/providers/config', updates) + expect(result).toEqual(mockResponse) + }) + }) + + describe('getSpreadData', () => { + it('calls correct endpoint with all parameters', async () => { + const mockResponse = { + primarySymbol: 'BTC/USD', + secondarySymbol: 'ETH/USD', + timeframe: '1h', + lookbackWindow: 50, + data: [], + currentSpread: 0.5, + currentZScore: 1.2, + } + vi.mocked(apiClient.get).mockResolvedValue({ data: mockResponse }) + + const result = await marketDataApi.getSpreadData('BTC/USD', 'ETH/USD', '4h', 100) + + expect(apiClient.get).toHaveBeenCalledWith('/api/market-data/spread', { + params: { + primary_symbol: 'BTC/USD', + secondary_symbol: 'ETH/USD', + timeframe: '4h', + lookback: 100, + }, + }) + expect(result).toEqual(mockResponse) + }) + + it('uses default values for optional parameters', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ data: {} }) + + await marketDataApi.getSpreadData('BTC/USD', 'ETH/USD') + + expect(apiClient.get).toHaveBeenCalledWith('/api/market-data/spread', { + params: { + primary_symbol: 'BTC/USD', + secondary_symbol: 'ETH/USD', + timeframe: '1h', + lookback: 50, + }, + }) + }) + }) +}) diff --git a/frontend/src/api/__tests__/strategies.test.ts b/frontend/src/api/__tests__/strategies.test.ts new file mode 100644 index 00000000..70484ecb --- /dev/null +++ b/frontend/src/api/__tests__/strategies.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { strategiesApi } from '../strategies' +import { apiClient } from '../client' + +vi.mock('../client') + +describe('strategiesApi', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('listStrategies', () => { + it('calls correct endpoint', async () => { + const mockStrategies = [{ id: 1, name: 'RSI Strategy' }] + vi.mocked(apiClient.get).mockResolvedValue({ data: mockStrategies }) + + const result = await strategiesApi.listStrategies() + + expect(apiClient.get).toHaveBeenCalledWith('/api/strategies/') + expect(result).toEqual(mockStrategies) + }) + }) + + describe('getAvailableStrategies', () => { + it('calls correct endpoint', async () => { + const mockResponse = { strategies: ['RSI', 'MACD', 'Moving Average'] } + vi.mocked(apiClient.get).mockResolvedValue({ data: mockResponse }) + + const result = await strategiesApi.getAvailableStrategies() + + expect(apiClient.get).toHaveBeenCalledWith('/api/strategies/available') + expect(result).toEqual(mockResponse) + }) + }) + + describe('createStrategy', () => { + it('calls correct endpoint with strategy data', async () => { + const strategy = { + name: 'My Strategy', + type: 'RSI', + symbol: 'BTC/USD', + parameters: { period: 14 }, + enabled: true, + } + const mockResponse = { id: 1, ...strategy } + vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse }) + + const result = await strategiesApi.createStrategy(strategy) + + expect(apiClient.post).toHaveBeenCalledWith('/api/strategies/', strategy) + expect(result).toEqual(mockResponse) + }) + }) + + describe('getStrategy', () => { + it('calls correct endpoint with ID', async () => { + const mockStrategy = { id: 5, name: 'Test Strategy' } + vi.mocked(apiClient.get).mockResolvedValue({ data: mockStrategy }) + + const result = await strategiesApi.getStrategy(5) + + expect(apiClient.get).toHaveBeenCalledWith('/api/strategies/5') + expect(result).toEqual(mockStrategy) + }) + }) + + describe('updateStrategy', () => { + it('calls correct endpoint with updates', async () => { + const updates = { name: 'Updated Name', enabled: false } + const mockResponse = { id: 5, ...updates } + vi.mocked(apiClient.put).mockResolvedValue({ data: mockResponse }) + + const result = await strategiesApi.updateStrategy(5, updates) + + expect(apiClient.put).toHaveBeenCalledWith('/api/strategies/5', updates) + expect(result).toEqual(mockResponse) + }) + }) + + describe('deleteStrategy', () => { + it('calls correct endpoint', async () => { + vi.mocked(apiClient.delete).mockResolvedValue({ data: {} }) + + await strategiesApi.deleteStrategy(5) + + expect(apiClient.delete).toHaveBeenCalledWith('/api/strategies/5') + }) + }) + + describe('startStrategy', () => { + it('calls correct endpoint', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ data: {} }) + + await strategiesApi.startStrategy(5) + + expect(apiClient.post).toHaveBeenCalledWith('/api/strategies/5/start') + }) + }) + + describe('stopStrategy', () => { + it('calls correct endpoint', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ data: {} }) + + await strategiesApi.stopStrategy(5) + + expect(apiClient.post).toHaveBeenCalledWith('/api/strategies/5/stop') + }) + }) + + describe('getStrategyStatus', () => { + it('calls correct endpoint', async () => { + const mockStatus = { strategy_id: 5, running: true, name: 'Test' } + vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus }) + + const result = await strategiesApi.getStrategyStatus(5) + + expect(apiClient.get).toHaveBeenCalledWith('/api/strategies/5/status') + expect(result).toEqual(mockStatus) + }) + }) + + describe('getRunningStrategies', () => { + it('calls correct endpoint', async () => { + const mockResponse = { + total_running: 2, + strategies: [{ strategy_id: 1 }, { strategy_id: 2 }], + } + vi.mocked(apiClient.get).mockResolvedValue({ data: mockResponse }) + + const result = await strategiesApi.getRunningStrategies() + + expect(apiClient.get).toHaveBeenCalledWith('/api/strategies/running/all') + expect(result).toEqual(mockResponse) + }) + }) +}) diff --git a/frontend/src/api/__tests__/trading.test.ts b/frontend/src/api/__tests__/trading.test.ts new file mode 100644 index 00000000..2449f29b --- /dev/null +++ b/frontend/src/api/__tests__/trading.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { tradingApi } from '../trading' +import { apiClient } from '../client' +import { OrderSide, OrderType } from '../../types' + +vi.mock('../client') + +describe('tradingApi', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('createOrder', () => { + it('calls correct endpoint with order data', async () => { + const order = { + exchange_id: 1, + symbol: 'BTC/USD', + side: OrderSide.BUY, + order_type: OrderType.MARKET, + quantity: 0.1, + paper_trading: true, + } + const mockResponse = { data: { id: 1, ...order, status: 'pending' } } + vi.mocked(apiClient.post).mockResolvedValue(mockResponse) + + const result = await tradingApi.createOrder(order) + + expect(apiClient.post).toHaveBeenCalledWith('/api/trading/orders', order) + expect(result).toEqual(mockResponse.data) + }) + }) + + describe('getOrders', () => { + it('calls correct endpoint with default parameters', async () => { + const mockOrders = [{ id: 1, symbol: 'BTC/USD' }] + vi.mocked(apiClient.get).mockResolvedValue({ data: mockOrders }) + + const result = await tradingApi.getOrders() + + expect(apiClient.get).toHaveBeenCalledWith('/api/trading/orders', { + params: { paper_trading: true, limit: 100 }, + }) + expect(result).toEqual(mockOrders) + }) + + it('passes custom parameters', async () => { + const mockOrders = [{ id: 1 }] + vi.mocked(apiClient.get).mockResolvedValue({ data: mockOrders }) + + await tradingApi.getOrders(false, 50) + + expect(apiClient.get).toHaveBeenCalledWith('/api/trading/orders', { + params: { paper_trading: false, limit: 50 }, + }) + }) + }) + + describe('getOrder', () => { + it('calls correct endpoint with order ID', async () => { + const mockOrder = { id: 123, symbol: 'ETH/USD' } + vi.mocked(apiClient.get).mockResolvedValue({ data: mockOrder }) + + const result = await tradingApi.getOrder(123) + + expect(apiClient.get).toHaveBeenCalledWith('/api/trading/orders/123') + expect(result).toEqual(mockOrder) + }) + }) + + describe('cancelOrder', () => { + it('calls correct endpoint to cancel order', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ data: {} }) + + await tradingApi.cancelOrder(123) + + expect(apiClient.post).toHaveBeenCalledWith('/api/trading/orders/123/cancel') + }) + }) + + describe('cancelAllOrders', () => { + it('calls correct endpoint with paper trading param', async () => { + const mockResponse = { + status: 'success', + cancelled_count: 5, + failed_count: 0, + total_orders: 5, + } + vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse }) + + const result = await tradingApi.cancelAllOrders(true) + + expect(apiClient.post).toHaveBeenCalledWith('/api/trading/orders/cancel-all', null, { + params: { paper_trading: true }, + }) + expect(result).toEqual(mockResponse) + }) + }) + + describe('getPositions', () => { + it('calls correct endpoint', async () => { + const mockPositions = [{ id: 1, symbol: 'BTC/USD', quantity: 0.5 }] + vi.mocked(apiClient.get).mockResolvedValue({ data: mockPositions }) + + const result = await tradingApi.getPositions() + + expect(apiClient.get).toHaveBeenCalledWith('/api/trading/positions', { + params: { paper_trading: true }, + }) + expect(result).toEqual(mockPositions) + }) + }) + + describe('getBalance', () => { + it('calls correct endpoint', async () => { + const mockBalance = { total: 10000, available: 8000 } + vi.mocked(apiClient.get).mockResolvedValue({ data: mockBalance }) + + const result = await tradingApi.getBalance() + + expect(apiClient.get).toHaveBeenCalledWith('/api/trading/balance', { + params: { paper_trading: true }, + }) + expect(result).toEqual(mockBalance) + }) + }) +}) diff --git a/frontend/src/api/alerts.ts b/frontend/src/api/alerts.ts new file mode 100644 index 00000000..a98c377a --- /dev/null +++ b/frontend/src/api/alerts.ts @@ -0,0 +1,53 @@ +import { apiClient } from './client' + +export interface AlertCreate { + name: string + alert_type: string + condition: Record +} + +export interface AlertUpdate { + name?: string + condition?: Record + enabled?: boolean +} + +export interface AlertResponse { + id: number + name: string + alert_type: string + condition: Record + enabled: boolean + triggered: boolean + triggered_at: string | null + created_at: string + updated_at: string +} + +export const alertsApi = { + listAlerts: async (enabledOnly: boolean = false): Promise => { + const response = await apiClient.get('/api/alerts/', { + params: { enabled_only: enabledOnly }, + }) + return response.data + }, + + getAlert: async (alertId: number): Promise => { + const response = await apiClient.get(`/api/alerts/${alertId}`) + return response.data + }, + + createAlert: async (alert: AlertCreate): Promise => { + const response = await apiClient.post('/api/alerts/', alert) + return response.data + }, + + updateAlert: async (alertId: number, updates: AlertUpdate): Promise => { + const response = await apiClient.put(`/api/alerts/${alertId}`, updates) + return response.data + }, + + deleteAlert: async (alertId: number): Promise => { + await apiClient.delete(`/api/alerts/${alertId}`) + }, +} diff --git a/frontend/src/api/autopilot.ts b/frontend/src/api/autopilot.ts new file mode 100644 index 00000000..48db98c1 --- /dev/null +++ b/frontend/src/api/autopilot.ts @@ -0,0 +1,200 @@ +import { apiClient } from './client' + +// Autopilot configuration (intelligent mode only) +export interface UnifiedAutopilotConfig { + symbol: string + mode?: 'intelligent' // Only intelligent mode supported + auto_execute?: boolean + interval?: number + exchange_id?: number + timeframe?: string + paper_trading?: boolean +} + + + + + +export const autopilotApi = { + + // Intelligent Autopilot APIs + startIntelligent: async (config: { + symbol: string + exchange_id?: number + timeframe?: string + interval?: number + paper_trading?: boolean + }) => { + const response = await apiClient.post('/api/autopilot/intelligent/start', config) + return response.data + }, + + stopIntelligent: async (symbol: string, timeframe: string = '1h') => { + const response = await apiClient.post( + `/api/autopilot/intelligent/stop?symbol=${symbol}&timeframe=${timeframe}` + ) + return response.data + }, + + getIntelligentStatus: async (symbol: string, timeframe: string = '1h'): Promise<{ + symbol: string + timeframe: string + running: boolean + selected_strategy: string | null + trades_today: number + max_trades_per_day: number + min_confidence_threshold: number + enable_auto_execution: boolean + last_analysis: any + model_info: any + }> => { + const response = await apiClient.get( + `/api/autopilot/intelligent/status/${symbol}?timeframe=${timeframe}` + ) + return response.data + }, + + getIntelligentPerformance: async (strategy_name?: string, days: number = 30) => { + const params: any = { days } + if (strategy_name) params.strategy_name = strategy_name + const response = await apiClient.get('/api/autopilot/intelligent/performance', { params }) + return response.data + }, + + retrainModel: async (force: boolean = false) => { + const response = await apiClient.post('/api/autopilot/intelligent/retrain', null, { + params: { force }, + }) + return response.data + }, + + getTrainingStats: async (days: number = 365): Promise => { + const response = await apiClient.get('/api/autopilot/intelligent/training-stats', { + params: { days } + }) + return response.data + }, + + getModelInfo: async () => { + const response = await apiClient.get('/api/autopilot/intelligent/model-info') + return response.data + }, + + resetModel: async (): Promise<{ status: string; message: string; deleted_count: number }> => { + const response = await apiClient.post('/api/autopilot/intelligent/reset') + return response.data + }, + + // Unified Autopilot APIs + getModes: async (): Promise => { + const response = await apiClient.get('/api/autopilot/modes') + return response.data + }, + + startUnified: async (config: UnifiedAutopilotConfig) => { + const response = await apiClient.post('/api/autopilot/start-unified', config) + return response.data + }, + + stopUnified: async (symbol: string, mode: 'intelligent' = 'intelligent', timeframe: string = '1h') => { + const response = await apiClient.post( + `/api/autopilot/stop-unified?symbol=${symbol}&mode=${mode}&timeframe=${timeframe}` + ) + return response.data + }, + + getUnifiedStatus: async ( + symbol: string, + mode: 'intelligent' = 'intelligent', + timeframe: string = '1h' + ) => { + const response = await apiClient.get( + `/api/autopilot/status-unified/${symbol}?mode=${mode}&timeframe=${timeframe}` + ) + return response.data + }, + + // Bootstrap Configuration APIs + getBootstrapConfig: async (): Promise => { + const response = await apiClient.get('/api/autopilot/bootstrap-config') + return response.data + }, + + updateBootstrapConfig: async (config: BootstrapConfig) => { + const response = await apiClient.put('/api/autopilot/bootstrap-config', config) + return response.data + }, + + // Multi-Symbol Autopilot APIs + startMultiSymbol: async (config: MultiSymbolAutopilotConfig): Promise => { + const response = await apiClient.post('/api/autopilot/multi-symbol/start', config) + return response.data + }, + + stopMultiSymbol: async (symbols: string[], mode: string = 'intelligent', timeframe: string = '1h'): Promise => { + const response = await apiClient.post('/api/autopilot/multi-symbol/stop', symbols, { + params: { mode, timeframe } + }) + return response.data + }, + + getTaskStatus: async (taskId: string): Promise => { + const response = await apiClient.get(`/api/autopilot/tasks/${taskId}`) + return response.data + }, + + getMultiSymbolStatus: async (symbols?: string[], mode: string = 'intelligent', timeframe: string = '1h'): Promise => { + const response = await apiClient.get('/api/autopilot/multi-symbol/status', { + params: { + symbols: symbols?.join(',') || '', + mode, + timeframe + } + }) + return response.data + }, +} + +export interface TrainingStats { + total_samples: number + strategy_counts: Record +} + +export interface BootstrapConfig { + days: number + timeframe: string + min_samples_per_strategy: number + symbols: string[] +} + +export interface MultiSymbolAutopilotConfig { + symbols: string[] + mode?: 'intelligent' // Only intelligent mode supported + auto_execute?: boolean + timeframe?: string + exchange_id?: number + paper_trading?: boolean + interval?: number +} + +export interface MultiSymbolResponse { + status: string + mode?: string + symbols: Array<{ + symbol: string + status: string + }> +} + +export interface MultiSymbolStatusResponse { + mode: string + symbols: Array<{ + symbol: string + mode: string + running: boolean + selected_strategy?: string + trades_today?: number + last_analysis?: any + }> + total_running: number +} diff --git a/frontend/src/api/backtesting.ts b/frontend/src/api/backtesting.ts new file mode 100644 index 00000000..f1c8c2e3 --- /dev/null +++ b/frontend/src/api/backtesting.ts @@ -0,0 +1,14 @@ +import { apiClient } from './client' +import { BacktestRequest, BacktestResponse } from '../types' + +export const backtestingApi = { + runBacktest: async (backtest: BacktestRequest): Promise => { + const response = await apiClient.post('/api/backtesting/run', backtest) + return response.data + }, + + getBacktestResults: async (backtestId: string): Promise => { + const response = await apiClient.get(`/api/backtesting/results/${backtestId}`) + return response.data + }, +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 00000000..c0ac7007 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,37 @@ +import axios from 'axios' + +const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000' + +export const apiClient = axios.create({ + baseURL: API_BASE_URL, + headers: { + 'Content-Type': 'application/json', + }, +}) + +// Request interceptor +apiClient.interceptors.request.use( + (config) => { + // Add auth token if available + const token = localStorage.getItem('token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config + }, + (error) => { + return Promise.reject(error) + } +) + +// Response interceptor +apiClient.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + // Handle unauthorized + localStorage.removeItem('token') + } + return Promise.reject(error) + } +) diff --git a/frontend/src/api/exchanges.ts b/frontend/src/api/exchanges.ts new file mode 100644 index 00000000..89d75c6d --- /dev/null +++ b/frontend/src/api/exchanges.ts @@ -0,0 +1,19 @@ +import { apiClient } from './client' + +export interface ExchangeResponse { + id: number + name: string + enabled: boolean +} + +export const exchangesApi = { + listExchanges: async (): Promise => { + const response = await apiClient.get('/api/exchanges/') + return response.data + }, + + getExchange: async (exchangeId: number): Promise => { + const response = await apiClient.get(`/api/exchanges/${exchangeId}`) + return response.data + }, +} diff --git a/frontend/src/api/marketData.ts b/frontend/src/api/marketData.ts new file mode 100644 index 00000000..f47fe2f6 --- /dev/null +++ b/frontend/src/api/marketData.ts @@ -0,0 +1,142 @@ +import { apiClient } from './client' + +export interface OHLCVData { + time: number + open: number + high: number + low: number + close: number + volume: number +} + +export interface TickerData { + symbol: string + bid: number + ask: number + last: number + high: number + low: number + volume: number + timestamp: number + provider: string +} + +export interface ProviderHealth { + status: 'healthy' | 'degraded' | 'unhealthy' | 'unknown' + last_check?: string + last_success?: string + last_failure?: string + success_count: number + failure_count: number + avg_response_time: number + consecutive_failures: number + circuit_breaker_open: boolean + circuit_breaker_opened_at?: string +} + +export interface ProviderStatus { + active_provider: string | null + providers: Record + cache: { + size: number + max_size: number + hits: number + misses: number + hit_rate: number + evictions: number + avg_age_seconds: number + } +} + +export interface ProviderConfig { + primary: Array<{ + name: string + enabled: boolean + priority: number + }> + fallback: { + name: string + enabled: boolean + api_key?: string + } + caching: { + ticker_ttl: number + ohlcv_ttl: number + max_cache_size: number + } + websocket: { + enabled: boolean + reconnect_interval: number + ping_interval: number + } +} + +export const marketDataApi = { + getOHLCV: async (symbol: string, timeframe: string = '1h'): Promise => { + const response = await apiClient.get(`/api/market-data/ohlcv/${symbol}`, { + params: { timeframe } + }) + return response.data + }, + + getTicker: async (symbol: string): Promise => { + const response = await apiClient.get(`/api/market-data/ticker/${symbol}`) + return response.data + }, + + getProviderHealth: async (provider?: string): Promise<{ active_provider: string | null; health: Record | ProviderHealth }> => { + const params = provider ? { provider } : {} + const response = await apiClient.get('/api/market-data/providers/health', { params }) + return response.data + }, + + getProviderStatus: async (): Promise => { + const response = await apiClient.get('/api/market-data/providers/status') + return response.data + }, + + getProviderConfig: async (): Promise => { + const response = await apiClient.get('/api/market-data/providers/config') + return response.data + }, + + updateProviderConfig: async (config: Partial): Promise<{ message: string; config: ProviderConfig }> => { + const response = await apiClient.put('/api/market-data/providers/config', config) + return response.data + }, + + getSpreadData: async ( + primarySymbol: string, + secondarySymbol: string, + timeframe: string = '1h', + lookback: number = 50 + ): Promise => { + const response = await apiClient.get('/api/market-data/spread', { + params: { + primary_symbol: primarySymbol, + secondary_symbol: secondarySymbol, + timeframe, + lookback, + } + }) + return response.data + } +} + +export interface SpreadDataPoint { + timestamp: number + spread: number + zScore: number + priceA: number + priceB: number +} + +export interface SpreadDataResponse { + primarySymbol: string + secondarySymbol: string + timeframe: string + lookbackWindow: number + data: SpreadDataPoint[] + currentSpread: number | null + currentZScore: number | null +} diff --git a/frontend/src/api/portfolio.ts b/frontend/src/api/portfolio.ts new file mode 100644 index 00000000..c65e568b --- /dev/null +++ b/frontend/src/api/portfolio.ts @@ -0,0 +1,41 @@ +import { apiClient } from './client' +import { PortfolioResponse, PortfolioHistoryResponse } from '../types' + +export const portfolioApi = { + getCurrentPortfolio: async (paperTrading: boolean = true): Promise => { + const response = await apiClient.get('/api/portfolio/current', { + params: { paper_trading: paperTrading }, + }) + return response.data + }, + + getPortfolioHistory: async (days: number = 30, paperTrading: boolean = true): Promise => { + const response = await apiClient.get('/api/portfolio/history', { + params: { days, paper_trading: paperTrading }, + }) + return response.data + }, + + updatePositionsPrices: async (prices: Record, paperTrading: boolean = true): Promise => { + await apiClient.post('/api/portfolio/positions/update-prices', prices, { + params: { paper_trading: paperTrading }, + }) + }, + + getRiskMetrics: async (days: number = 30, paperTrading: boolean = true): Promise<{ + total_return: number + total_return_percent: number + sharpe_ratio: number + sortino_ratio: number + max_drawdown: number + current_drawdown: number + win_rate: number + initial_value: number + final_value: number + }> => { + const response = await apiClient.get('/api/portfolio/risk-metrics', { + params: { days, paper_trading: paperTrading }, + }) + return response.data + }, +} diff --git a/frontend/src/api/reporting.ts b/frontend/src/api/reporting.ts new file mode 100644 index 00000000..5032c1ab --- /dev/null +++ b/frontend/src/api/reporting.ts @@ -0,0 +1,33 @@ +import { apiClient } from './client' + +export const reportingApi = { + exportBacktestCSV: async (results: any): Promise => { + const response = await apiClient.post('/api/reporting/backtest/csv', results, { + responseType: 'blob', + }) + return response.data + }, + + exportBacktestPDF: async (results: any): Promise => { + const response = await apiClient.post('/api/reporting/backtest/pdf', results, { + responseType: 'blob', + }) + return response.data + }, + + exportTradesCSV: async (startDate?: string, endDate?: string, paperTrading: boolean = true): Promise => { + const response = await apiClient.get('/api/reporting/trades/csv', { + params: { start_date: startDate, end_date: endDate, paper_trading: paperTrading }, + responseType: 'blob', + }) + return response.data + }, + + exportPortfolioCSV: async (paperTrading: boolean = true): Promise => { + const response = await apiClient.get('/api/reporting/portfolio/csv', { + params: { paper_trading: paperTrading }, + responseType: 'blob', + }) + return response.data + }, +} diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts new file mode 100644 index 00000000..a0e3efc9 --- /dev/null +++ b/frontend/src/api/settings.ts @@ -0,0 +1,137 @@ +import { apiClient } from './client' + +export interface RiskSettings { + max_drawdown_percent: number + daily_loss_limit_percent: number + position_size_percent: number +} + +export interface PaperTradingSettings { + initial_capital: number + fee_exchange: string + fee_rates?: { + maker: number + taker: number + minimum?: number + } +} + +export interface FeeExchange { + name: string + fees: { + maker: number + taker: number + minimum?: number + } +} + +export interface FeeExchangesResponse { + exchanges: FeeExchange[] + current: string +} + +export interface LoggingSettings { + level: string + dir: string + retention_days: number +} + +export interface GeneralSettings { + timezone: string + theme: string + currency: string +} + +export interface ExchangeCreate { + name: string + api_key?: string + api_secret?: string + sandbox?: boolean + read_only?: boolean + enabled?: boolean +} + +export interface ExchangeUpdate { + api_key?: string + api_secret?: string + sandbox?: boolean + read_only?: boolean + enabled?: boolean +} + +export const settingsApi = { + // Risk Management + getRiskSettings: async (): Promise => { + const response = await apiClient.get('/api/settings/risk') + return response.data + }, + + updateRiskSettings: async (settings: RiskSettings) => { + const response = await apiClient.put('/api/settings/risk', settings) + return response.data + }, + + // Paper Trading + getPaperTradingSettings: async (): Promise => { + const response = await apiClient.get('/api/settings/paper-trading') + return response.data + }, + + updatePaperTradingSettings: async (settings: PaperTradingSettings) => { + const response = await apiClient.put('/api/settings/paper-trading', settings) + return response.data + }, + + resetPaperAccount: async () => { + const response = await apiClient.post('/api/settings/paper-trading/reset') + return response.data + }, + + getFeeExchanges: async (): Promise => { + const response = await apiClient.get('/api/settings/paper-trading/fee-exchanges') + return response.data + }, + + // Logging + getLoggingSettings: async (): Promise => { + const response = await apiClient.get('/api/settings/logging') + return response.data + }, + + updateLoggingSettings: async (settings: LoggingSettings) => { + const response = await apiClient.put('/api/settings/logging', settings) + return response.data + }, + + // General Settings + getGeneralSettings: async (): Promise => { + const response = await apiClient.get('/api/settings/general') + return response.data + }, + + updateGeneralSettings: async (settings: GeneralSettings) => { + const response = await apiClient.put('/api/settings/general', settings) + return response.data + }, + + // Exchanges + createExchange: async (exchange: ExchangeCreate) => { + const response = await apiClient.post('/api/settings/exchanges', exchange) + return response.data + }, + + updateExchange: async (exchangeId: number, exchange: ExchangeUpdate) => { + const response = await apiClient.put(`/api/settings/exchanges/${exchangeId}`, exchange) + return response.data + }, + + deleteExchange: async (exchangeId: number) => { + const response = await apiClient.delete(`/api/settings/exchanges/${exchangeId}`) + return response.data + }, + + testExchangeConnection: async (exchangeId: number) => { + const response = await apiClient.post(`/api/settings/exchanges/${exchangeId}/test`) + return response.data + }, +} diff --git a/frontend/src/api/strategies.ts b/frontend/src/api/strategies.ts new file mode 100644 index 00000000..0675e7e0 --- /dev/null +++ b/frontend/src/api/strategies.ts @@ -0,0 +1,76 @@ +import { apiClient } from './client' +import { StrategyCreate, StrategyUpdate, StrategyResponse } from '../types' + +export const strategiesApi = { + listStrategies: async (): Promise => { + const response = await apiClient.get('/api/strategies/') + return response.data + }, + + getAvailableStrategies: async (): Promise<{ strategies: string[] }> => { + const response = await apiClient.get('/api/strategies/available') + return response.data + }, + + createStrategy: async (strategy: StrategyCreate): Promise => { + const response = await apiClient.post('/api/strategies/', strategy) + return response.data + }, + + getStrategy: async (strategyId: number): Promise => { + const response = await apiClient.get(`/api/strategies/${strategyId}`) + return response.data + }, + + updateStrategy: async (strategyId: number, updates: StrategyUpdate): Promise => { + const response = await apiClient.put(`/api/strategies/${strategyId}`, updates) + return response.data + }, + + deleteStrategy: async (strategyId: number): Promise => { + await apiClient.delete(`/api/strategies/${strategyId}`) + }, + + startStrategy: async (strategyId: number): Promise => { + await apiClient.post(`/api/strategies/${strategyId}/start`) + }, + + stopStrategy: async (strategyId: number): Promise => { + await apiClient.post(`/api/strategies/${strategyId}/stop`) + }, + + getStrategyStatus: async (strategyId: number): Promise => { + const response = await apiClient.get(`/api/strategies/${strategyId}/status`) + return response.data + }, + + getRunningStrategies: async (): Promise => { + const response = await apiClient.get('/api/strategies/running/all') + return response.data + }, +} + +export interface StrategyStatusResponse { + strategy_id: number + name: string + type: string + symbol: string | null + running: boolean + enabled?: boolean + started_at?: string + last_tick?: string + last_signal?: { + type: string + strength: number + price: number + timestamp: string + metadata?: Record + } + signal_count?: number + error_count?: number +} + +export interface RunningStrategiesResponse { + total_running: number + strategies: StrategyStatusResponse[] +} diff --git a/frontend/src/api/trading.ts b/frontend/src/api/trading.ts new file mode 100644 index 00000000..b31d686c --- /dev/null +++ b/frontend/src/api/trading.ts @@ -0,0 +1,51 @@ +import { apiClient } from './client' +import { OrderCreate, OrderResponse, PositionResponse } from '../types' + +export const tradingApi = { + createOrder: async (order: OrderCreate): Promise => { + const response = await apiClient.post('/api/trading/orders', order) + return response.data + }, + + getOrders: async (paperTrading: boolean = true, limit: number = 100): Promise => { + const response = await apiClient.get('/api/trading/orders', { + params: { paper_trading: paperTrading, limit }, + }) + return response.data + }, + + getOrder: async (orderId: number): Promise => { + const response = await apiClient.get(`/api/trading/orders/${orderId}`) + return response.data + }, + + cancelOrder: async (orderId: number): Promise => { + await apiClient.post(`/api/trading/orders/${orderId}/cancel`) + }, + + cancelAllOrders: async (paperTrading: boolean = true): Promise<{ + status: string + cancelled_count: number + failed_count: number + total_orders: number + }> => { + const response = await apiClient.post('/api/trading/orders/cancel-all', null, { + params: { paper_trading: paperTrading }, + }) + return response.data + }, + + getPositions: async (paperTrading: boolean = true): Promise => { + const response = await apiClient.get('/api/trading/positions', { + params: { paper_trading: paperTrading }, + }) + return response.data + }, + + getBalance: async (paperTrading: boolean = true) => { + const response = await apiClient.get('/api/trading/balance', { + params: { paper_trading: paperTrading }, + }) + return response.data + }, +} diff --git a/frontend/src/assets/logo.png b/frontend/src/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..ef8c0e17cb87b9da0db32954d5a7ffaab05b64e9 GIT binary patch literal 422172 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
+ Technical Details +
+              {error.stack}
+            
+
+ + )} + + ) +} + diff --git a/frontend/src/components/HelpTooltip.tsx b/frontend/src/components/HelpTooltip.tsx new file mode 100644 index 00000000..06e21510 --- /dev/null +++ b/frontend/src/components/HelpTooltip.tsx @@ -0,0 +1,18 @@ +import { Tooltip, IconButton } from '@mui/material' +import { HelpOutline } from '@mui/icons-material' + +interface HelpTooltipProps { + title: string + placement?: 'top' | 'bottom' | 'left' | 'right' +} + +export default function HelpTooltip({ title, placement = 'top' }: HelpTooltipProps) { + return ( + + + + + + ) +} + diff --git a/frontend/src/components/InfoCard.tsx b/frontend/src/components/InfoCard.tsx new file mode 100644 index 00000000..46e656fb --- /dev/null +++ b/frontend/src/components/InfoCard.tsx @@ -0,0 +1,39 @@ +import { Card, CardContent, Typography, Box, IconButton, Collapse } from '@mui/material' +import { Info, ExpandMore, ExpandLess } from '@mui/icons-material' +import { useState } from 'react' + +interface InfoCardProps { + title: string + children: React.ReactNode + collapsible?: boolean +} + +export default function InfoCard({ title, children, collapsible = false }: InfoCardProps) { + const [expanded, setExpanded] = useState(!collapsible) + + return ( + + + + + + + {title} + + + {collapsible && ( + setExpanded(!expanded)}> + {expanded ? : } + + )} + + + + {children} + + + + + ) +} + diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 00000000..fda9989d --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,132 @@ +import { ReactNode } from 'react' +import { useNavigate, useLocation } from 'react-router-dom' +import { + Box, + Drawer, + List, + ListItem, + ListItemButton, + ListItemIcon, + ListItemText, + AppBar, + Toolbar, + Typography, + Container, +} from '@mui/material' +import { + Dashboard, + AccountBalance, + Assessment, + Settings, + Psychology, + ShoppingCart, +} from '@mui/icons-material' +import logo from '../assets/logo.png' + +const Logo = () => ( + + + +) + +const drawerWidth = 200 + +interface LayoutProps { + children: ReactNode +} + +const menuItems = [ + { text: 'Dashboard', icon: , path: '/' }, + { text: 'Strategies', icon: , path: '/strategies' }, + { text: 'Trading', icon: , path: '/trading' }, + { text: 'Portfolio', icon: , path: '/portfolio' }, + { text: 'Backtesting', icon: , path: '/backtesting' }, + { text: 'Settings', icon: , path: '/settings' }, +] + +export default function Layout({ children }: LayoutProps) { + const navigate = useNavigate() + const location = useLocation() + + return ( + + theme.zIndex.drawer + 1 }} + > + + + FXQ One + + + + + + + + {menuItems.map((item) => ( + + navigate(item.path)} + > + {item.icon} + + + + ))} + + + + + + + + {children} + + + + ) +} diff --git a/frontend/src/components/LoadingSkeleton.tsx b/frontend/src/components/LoadingSkeleton.tsx new file mode 100644 index 00000000..f3ae705e --- /dev/null +++ b/frontend/src/components/LoadingSkeleton.tsx @@ -0,0 +1,64 @@ +import { Skeleton, Box } from '@mui/material' + +interface LoadingSkeletonProps { + variant?: 'table' | 'card' | 'list' | 'text' + rows?: number +} + +export default function LoadingSkeleton({ variant = 'text', rows = 3 }: LoadingSkeletonProps) { + if (variant === 'table') { + return ( + + {Array.from({ length: rows }).map((_, i) => ( + + + + + + + + + ))} + + ) + } + + if (variant === 'card') { + return ( + + {Array.from({ length: rows }).map((_, i) => ( + + + + + + + + ))} + + ) + } + + if (variant === 'list') { + return ( + + {Array.from({ length: rows }).map((_, i) => ( + + + + + + ))} + + ) + } + + return ( + + {Array.from({ length: rows }).map((_, i) => ( + + ))} + + ) +} + diff --git a/frontend/src/components/OperationsPanel.tsx b/frontend/src/components/OperationsPanel.tsx new file mode 100644 index 00000000..5e401377 --- /dev/null +++ b/frontend/src/components/OperationsPanel.tsx @@ -0,0 +1,97 @@ +import { useState } from 'react' +import { + Box, + Paper, + Typography, + List, + ListItem, + ListItemText, + Chip, + IconButton, + Collapse, + LinearProgress, +} from '@mui/material' +import { ExpandMore, ExpandLess, PlayArrow, Stop } from '@mui/icons-material' + +interface Operation { + id: string + type: 'backtest' | 'optimization' | 'strategy' | 'order' + name: string + status: 'running' | 'queued' | 'completed' | 'failed' + progress?: number + startTime?: Date + estimatedTimeRemaining?: number +} + +interface OperationsPanelProps { + operations?: Operation[] + onCancel?: (id: string) => void +} + +export default function OperationsPanel({ operations = [], onCancel }: OperationsPanelProps) { + const [expanded, setExpanded] = useState(true) + + const runningOperations = operations.filter((op) => op.status === 'running' || op.status === 'queued') + const completedOperations = operations.filter((op) => op.status === 'completed' || op.status === 'failed') + + if (operations.length === 0) { + return null + } + + return ( + + + + Operations ({runningOperations.length} running) + + setExpanded(!expanded)}> + {expanded ? : } + + + + + + {runningOperations.map((op) => ( + + + + {op.progress !== undefined && ( + + + + {op.progress}% + + + )} + + } + /> + {onCancel && op.status === 'running' && ( + onCancel(op.id)}> + + + )} + + ))} + {runningOperations.length === 0 && ( + + + + )} + + + + ) +} + diff --git a/frontend/src/components/OrderConfirmationDialog.tsx b/frontend/src/components/OrderConfirmationDialog.tsx new file mode 100644 index 00000000..5d3aa1fd --- /dev/null +++ b/frontend/src/components/OrderConfirmationDialog.tsx @@ -0,0 +1,120 @@ +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Typography, + Box, + Alert, +} from '@mui/material' +import { OrderSide, OrderType } from '../types' + +interface OrderConfirmationDialogProps { + open: boolean + onClose: () => void + onConfirm: () => void + symbol: string + side: OrderSide + orderType: OrderType + quantity: number + price?: number + stopPrice?: number + takeProfitPrice?: number + trailingPercent?: number +} + +export default function OrderConfirmationDialog({ + open, + onClose, + onConfirm, + symbol, + side, + orderType, + quantity, + price, + stopPrice, + takeProfitPrice, + trailingPercent, +}: OrderConfirmationDialogProps) { + const estimatedTotal = price ? quantity * price : null + + return ( + + + Confirm {side.toUpperCase()} Order + + + + + Please verify all details carefully before confirming. + + + + Symbol: + {symbol} + + + Side: + + {side.toUpperCase()} + + + + Type: + {orderType.replace('_', ' ')} + + + Quantity: + {quantity.toFixed(8)} + + {price && ( + + Price: + ${price.toFixed(2)} + + )} + {stopPrice && ( + + Stop Price: + ${stopPrice.toFixed(2)} + + )} + {takeProfitPrice && ( + + Take Profit Price: + ${takeProfitPrice.toFixed(2)} + + )} + {trailingPercent && ( + + Trailing Percent: + {(trailingPercent * 100).toFixed(2)}% + + )} + {estimatedTotal && ( + + Est. Total: + ${estimatedTotal.toFixed(2)} + + )} + + + + + + + + + ) +} diff --git a/frontend/src/components/OrderForm.tsx b/frontend/src/components/OrderForm.tsx new file mode 100644 index 00000000..82496833 --- /dev/null +++ b/frontend/src/components/OrderForm.tsx @@ -0,0 +1,316 @@ +import { useState, useEffect } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + TextField, + FormControl, + InputLabel, + Select, + MenuItem, + Grid, + Alert, + Box, + Typography, + Divider, + Collapse, +} from '@mui/material' +import { ExpandMore, ExpandLess } from '@mui/icons-material' +import { tradingApi } from '../api/trading' +import { OrderCreate, OrderSide, OrderType } from '../types' +import { useSnackbar } from '../contexts/SnackbarContext' +import RealtimePrice from './RealtimePrice' +import ProviderStatusDisplay from './ProviderStatus' + +interface OrderFormProps { + open: boolean + onClose: () => void + exchanges: Array<{ id: number; name: string }> + paperTrading: boolean + onSuccess: () => void +} + +const CRYPTO_PAIRS = [ + 'BTC/USD', + 'ETH/USD', + 'BTC/USDT', + 'ETH/USDT', + 'SOL/USD', + 'ADA/USD', + 'XRP/USD', + 'DOGE/USD', + 'DOT/USD', + 'MATIC/USD', + 'AVAX/USD', + 'LINK/USD', +] + +const ORDER_TYPES = [ + { value: OrderType.MARKET, label: 'Market' }, + { value: OrderType.LIMIT, label: 'Limit' }, + { value: OrderType.STOP_LOSS, label: 'Stop Loss' }, + { value: OrderType.TAKE_PROFIT, label: 'Take Profit' }, + { value: OrderType.TRAILING_STOP, label: 'Trailing Stop' }, + { value: OrderType.OCO, label: 'OCO (One-Cancels-Other)' }, + { value: OrderType.ICEBERG, label: 'Iceberg' }, +] + +export default function OrderForm({ + open, + onClose, + exchanges, + paperTrading, + onSuccess, +}: OrderFormProps) { + const queryClient = useQueryClient() + const { showError } = useSnackbar() + const [exchangeId, setExchangeId] = useState('') + const [symbol, setSymbol] = useState('BTC/USD') + const [side, setSide] = useState(OrderSide.BUY) + const [orderType, setOrderType] = useState(OrderType.MARKET) + const [quantity, setQuantity] = useState('') + const [price, setPrice] = useState('') + const [stopPrice, setStopPrice] = useState('') + const [showAdvanced, setShowAdvanced] = useState(false) + + useEffect(() => { + if (exchanges.length > 0 && !exchangeId) { + setExchangeId(exchanges[0].id) + } + }, [exchanges, exchangeId]) + + useEffect(() => { + if (!open) { + // Reset form when dialog closes + setExchangeId(exchanges.length > 0 ? exchanges[0].id : '') + setSymbol('BTC/USD') + setSide(OrderSide.BUY) + setOrderType(OrderType.MARKET) + setQuantity('') + setPrice('') + setStopPrice('') + setShowAdvanced(false) + } + }, [open, exchanges]) + + const createOrderMutation = useMutation({ + mutationFn: (order: OrderCreate) => tradingApi.createOrder(order), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['orders'] }) + queryClient.invalidateQueries({ queryKey: ['positions'] }) + queryClient.invalidateQueries({ queryKey: ['balance'] }) + onSuccess() + }, + }) + + const handleSubmit = () => { + if (!exchangeId) { + showError('Please select an exchange') + return + } + + if (!quantity || parseFloat(quantity) <= 0) { + showError('Please enter a valid quantity') + return + } + + const requiresPrice = [ + OrderType.LIMIT, + OrderType.STOP_LOSS, + OrderType.TAKE_PROFIT, + ].includes(orderType) + + if (requiresPrice && (!price || parseFloat(price) <= 0)) { + showError('Please enter a valid price for this order type') + return + } + + const order: OrderCreate = { + exchange_id: exchangeId as number, + symbol, + side, + order_type: orderType, + quantity: parseFloat(quantity), + price: requiresPrice ? parseFloat(price) : undefined, + paper_trading: paperTrading, + } + + createOrderMutation.mutate(order) + } + + const requiresPrice = [ + OrderType.LIMIT, + OrderType.STOP_LOSS, + OrderType.TAKE_PROFIT, + ].includes(orderType) + + const requiresStopPrice = [ + OrderType.STOP_LOSS, + OrderType.TRAILING_STOP, + ].includes(orderType) + + return ( + + Place Order + + + + + Exchange + + + + + + Symbol + + + + + + + + + + + + Side + + + + + + Order Type + + + + + setQuantity(e.target.value)} + required + inputProps={{ min: 0, step: 0.00000001 }} + helperText="Amount to buy or sell" + /> + + {requiresPrice && ( + + setPrice(e.target.value)} + required + inputProps={{ min: 0, step: 0.01 }} + helperText="Limit price for the order" + /> + + )} + {requiresStopPrice && ( + + setStopPrice(e.target.value)} + inputProps={{ min: 0, step: 0.01 }} + helperText="Stop price for stop-loss or trailing stop orders" + /> + + )} + + + + + + + + Advanced order types (OCO, Iceberg) require additional configuration. + These features are available in the API but may need custom implementation. + + + + + {paperTrading && ( + + + This order will be executed in paper trading mode with virtual funds. + + + )} + {createOrderMutation.isError && ( + + + {createOrderMutation.error instanceof Error + ? createOrderMutation.error.message + : 'Failed to place order'} + + + )} + + + + + + + + ) +} + diff --git a/frontend/src/components/PositionCard.tsx b/frontend/src/components/PositionCard.tsx new file mode 100644 index 00000000..da79ea80 --- /dev/null +++ b/frontend/src/components/PositionCard.tsx @@ -0,0 +1,240 @@ +import { useState } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { + Card, + CardContent, + Typography, + Button, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + FormControl, + InputLabel, + Select, + MenuItem, + Box, + Grid, + Chip, + Alert, + Divider, +} from '@mui/material' +import { Close } from '@mui/icons-material' +import { tradingApi } from '../api/trading' +import { PositionResponse, OrderCreate, OrderSide, OrderType } from '../types' +import { useSnackbar } from '../contexts/SnackbarContext' + +interface PositionCardProps { + position: PositionResponse + paperTrading: boolean + onClose: () => void +} + +export default function PositionCard({ + position, + paperTrading, + onClose, +}: PositionCardProps) { + const queryClient = useQueryClient() + const { showError } = useSnackbar() + const [closeDialogOpen, setCloseDialogOpen] = useState(false) + const [closeType, setCloseType] = useState(OrderType.MARKET) + const [closePrice, setClosePrice] = useState('') + + const closePositionMutation = useMutation({ + mutationFn: (order: OrderCreate) => tradingApi.createOrder(order), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['positions'] }) + queryClient.invalidateQueries({ queryKey: ['orders'] }) + queryClient.invalidateQueries({ queryKey: ['balance'] }) + setCloseDialogOpen(false) + onClose() + }, + }) + + const handleClosePosition = () => { + setCloseDialogOpen(true) + } + + const handleConfirmClose = () => { + // Extract exchange_id from position or use default + // In a real implementation, you'd get this from the position or context + const exchangeId = 1 // This should come from position data or context + + const requiresPrice = closeType === OrderType.LIMIT + + if (requiresPrice && (!closePrice || parseFloat(closePrice) <= 0)) { + showError('Please enter a valid price for limit orders') + return + } + + const order: OrderCreate = { + exchange_id: exchangeId, + symbol: position.symbol, + side: OrderSide.SELL, + order_type: closeType, + quantity: position.quantity, + price: requiresPrice ? parseFloat(closePrice) : undefined, + paper_trading: paperTrading, + } + + closePositionMutation.mutate(order) + } + + const pnlPercent = + position.entry_price > 0 + ? ((position.current_price - position.entry_price) / position.entry_price) * 100 + : 0 + + return ( + <> + + + + {position.symbol} + = 0 ? `+${pnlPercent.toFixed(2)}%` : `${pnlPercent.toFixed(2)}%`} + color={pnlPercent >= 0 ? 'success' : 'error'} + size="small" + /> + + + + + Quantity + + + {Number(position.quantity).toFixed(8)} + + + + + Entry Price + + + ${Number(position.entry_price).toFixed(2)} + + + + + Current Price + + + ${Number(position.current_price).toFixed(2)} + + + + + Value + + + ${(Number(position.quantity) * Number(position.current_price)).toFixed(2)} + + + + + + + + Unrealized P&L + + = 0 ? 'success.main' : 'error.main', + }} + > + {position.unrealized_pnl >= 0 ? '+' : ''} + ${Number(position.unrealized_pnl).toFixed(2)} + + + + + Realized P&L + + = 0 ? 'success.main' : 'error.main', + }} + > + {position.realized_pnl >= 0 ? '+' : ''} + ${Number(position.realized_pnl).toFixed(2)} + + + + + + + + + + setCloseDialogOpen(false)} maxWidth="sm" fullWidth> + Close Position + + + + Closing {Number(position.quantity).toFixed(8)} {position.symbol} at current price of ${Number(position.current_price).toFixed(2)} + + + Order Type + + + {closeType === OrderType.LIMIT && ( + setClosePrice(e.target.value)} + inputProps={{ min: 0, step: 0.01 }} + helperText="Price at which to close the position" + /> + )} + {closePositionMutation.isError && ( + + {closePositionMutation.error instanceof Error + ? closePositionMutation.error.message + : 'Failed to close position'} + + )} + + + + + + + + + ) +} + diff --git a/frontend/src/components/ProgressOverlay.tsx b/frontend/src/components/ProgressOverlay.tsx new file mode 100644 index 00000000..919ee9de --- /dev/null +++ b/frontend/src/components/ProgressOverlay.tsx @@ -0,0 +1,45 @@ +import { Box, CircularProgress, Typography, LinearProgress } from '@mui/material' + +interface ProgressOverlayProps { + message?: string + progress?: number + variant?: 'indeterminate' | 'determinate' +} + +export default function ProgressOverlay({ + message = 'Loading...', + progress, + variant = 'indeterminate', +}: ProgressOverlayProps) { + return ( + + + {variant === 'determinate' && progress !== undefined && ( + + + + {progress.toFixed(0)}% + + + )} + + {message} + + + ) +} + diff --git a/frontend/src/components/ProviderStatus.tsx b/frontend/src/components/ProviderStatus.tsx new file mode 100644 index 00000000..12b1cc64 --- /dev/null +++ b/frontend/src/components/ProviderStatus.tsx @@ -0,0 +1,132 @@ +import { Box, Chip, Tooltip, Typography, CircularProgress } from '@mui/material' +import { CheckCircle, Error, Warning, CloudOff, Info } from '@mui/icons-material' +import { useProviderStatus } from '../hooks/useProviderStatus' +import StatusIndicator from './StatusIndicator' + +interface ProviderStatusProps { + compact?: boolean + showDetails?: boolean +} + +export default function ProviderStatusDisplay({ compact = false, showDetails = false }: ProviderStatusProps) { + const { status, isLoading, error } = useProviderStatus() + + if (isLoading) { + return compact ? ( + + ) : ( + + + Loading provider status... + + ) + } + + if (error) { + return ( + } + label="Provider status unavailable" + color="error" + size="small" + variant="outlined" + /> + ) + } + + if (!status) { + return null + } + + const activeProvider = status.active_provider + const providerHealth = activeProvider ? status.providers[activeProvider] : null + + if (compact) { + return ( + + + {providerHealth ? ( + + ) : ( + + )} + + + ) + } + + return ( + + + Data Providers + {activeProvider && ( + + )} + + + {showDetails && ( + + {Object.entries(status.providers).map(([name, health]) => ( + + + + {name === activeProvider && ( + + )} + + + {health.avg_response_time > 0 && ( + + {health.avg_response_time.toFixed(3)}s avg + + )} + + {health.success_count} success, {health.failure_count} failures + + + + ))} + + )} + + ) +} diff --git a/frontend/src/components/RealtimePrice.tsx b/frontend/src/components/RealtimePrice.tsx new file mode 100644 index 00000000..4917d798 --- /dev/null +++ b/frontend/src/components/RealtimePrice.tsx @@ -0,0 +1,185 @@ +import { useState, useEffect } from 'react' +import { Box, Typography, Chip, CircularProgress, Tooltip, Fade } from '@mui/material' +import { TrendingUp, TrendingDown } from '@mui/icons-material' +import { useQuery } from '@tanstack/react-query' +import { marketDataApi, TickerData } from '../api/marketData' +import { useWebSocketContext } from './WebSocketProvider' + +interface RealtimePriceProps { + symbol: string + showProvider?: boolean + showChange?: boolean + size?: 'small' | 'medium' | 'large' + onPriceUpdate?: (price: number) => void +} + +export default function RealtimePrice({ + symbol, + showProvider = true, + showChange = true, + size = 'medium', + onPriceUpdate, +}: RealtimePriceProps) { + const { isConnected, lastMessage } = useWebSocketContext() + const [currentPrice, setCurrentPrice] = useState(null) + const [previousPrice, setPreviousPrice] = useState(null) + const [priceChange, setPriceChange] = useState(0) + const [flash, setFlash] = useState(false) + + const { data: ticker, isLoading, error, refetch } = useQuery({ + queryKey: ['ticker', symbol], + queryFn: () => marketDataApi.getTicker(symbol), + refetchInterval: 5000, // Refetch every 5 seconds as fallback + enabled: !!symbol, + }) + + // Update price when ticker data changes + useEffect(() => { + // Only update if we have a valid price (including 0, but not null/undefined) + if (ticker?.last !== undefined && ticker?.last !== null) { + const newPrice = ticker.last + + setCurrentPrice((prevPrice) => { + // Check if price has changed + if (prevPrice !== null && newPrice !== prevPrice) { + setPreviousPrice(prevPrice) + setPriceChange(newPrice - prevPrice) + + // Flash animation + setFlash(true) + setTimeout(() => setFlash(false), 2000) + + // Call callback if provided + if (onPriceUpdate) { + onPriceUpdate(newPrice) + } + } + + // Always update currentPrice when we have valid ticker data + // This ensures the price persists even if ticker becomes undefined later + return newPrice + }) + } + }, [ticker?.last, onPriceUpdate]) + + // Listen for WebSocket price updates + useEffect(() => { + if (!isConnected || !lastMessage) return + + try { + const message = typeof lastMessage === 'string' ? JSON.parse(lastMessage) : lastMessage + + if (message.type === 'price_update' && message.symbol === symbol && message.price !== undefined && message.price !== null) { + const newPrice = parseFloat(message.price) + + // Validate the parsed price + if (isNaN(newPrice)) return + + setCurrentPrice((prevPrice) => { + if (prevPrice !== null && newPrice !== prevPrice) { + setPreviousPrice(prevPrice) + setPriceChange(newPrice - prevPrice) + + // Flash animation + setFlash(true) + setTimeout(() => setFlash(false), 2000) + + if (onPriceUpdate) { + onPriceUpdate(newPrice) + } + } + + return newPrice + }) + } + } catch (e) { + // Ignore parsing errors + } + }, [isConnected, lastMessage, symbol, onPriceUpdate]) + + if (isLoading && currentPrice === null) { + return ( + + + + Loading... + + + ) + } + + if (error) { + return ( + + Error loading price + + ) + } + + // Use currentPrice if available, otherwise fall back to ticker?.last + // Only show 0 if both are explicitly 0 (not null/undefined) + const price = currentPrice !== null ? currentPrice : (ticker?.last !== undefined && ticker?.last !== null ? ticker.last : null) + const isPositive = priceChange >= 0 + const changePercent = currentPrice !== null && previousPrice !== null ? ((priceChange / previousPrice) * 100).toFixed(2) : null + + const priceVariant = size === 'small' ? 'body2' : size === 'large' ? 'h5' : 'h6' + const priceColor = flash ? (isPositive ? 'success.main' : 'error.main') : 'text.primary' + + // Don't render price if we don't have a valid price value + if (price === null) { + return ( + + + + Loading price... + + + ) + } + + return ( + + + + ${price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 8 })} + + + {showChange && changePercent && ( + : } + label={`${isPositive ? '+' : ''}${changePercent}%`} + color={isPositive ? 'success' : 'error'} + size="small" + variant="outlined" + /> + )} + + {showProvider && ticker?.provider && ( + + + + )} + + + {ticker && ( + + + 24h: ${ticker.high.toFixed(2)} / ${ticker.low.toFixed(2)} + + {ticker.volume > 0 && ( + + Vol: ${ticker.volume.toLocaleString('en-US', { maximumFractionDigits: 0 })} + + )} + + )} + + ) +} diff --git a/frontend/src/components/SpreadChart.tsx b/frontend/src/components/SpreadChart.tsx new file mode 100644 index 00000000..6f7ca615 --- /dev/null +++ b/frontend/src/components/SpreadChart.tsx @@ -0,0 +1,253 @@ +import { useQuery } from '@tanstack/react-query' +import { + Box, + Paper, + Typography, + Grid, + Chip, + Tooltip, + CircularProgress, +} from '@mui/material' +import { + TrendingUp, + TrendingDown, + SwapHoriz, +} from '@mui/icons-material' +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip as RechartsTooltip, + ResponsiveContainer, + ReferenceLine, +} from 'recharts' +import { marketDataApi } from '../api/marketData' + +interface SpreadChartProps { + primarySymbol: string + secondarySymbol: string + lookbackPeriod?: number + zScoreThreshold?: number +} + +export default function SpreadChart({ + primarySymbol, + secondarySymbol, + lookbackPeriod = 20, + zScoreThreshold = 2.0, +}: SpreadChartProps) { + + // Fetch spread data from backend + const { data: spreadResponse, isLoading, error } = useQuery({ + queryKey: ['spread-data', primarySymbol, secondarySymbol, lookbackPeriod], + queryFn: () => marketDataApi.getSpreadData( + primarySymbol, + secondarySymbol, + '1h', + lookbackPeriod + 30 + ), + refetchInterval: 60000, // Refresh every minute + staleTime: 30000, + }) + + const spreadData = spreadResponse?.data ?? [] + const currentZScore = spreadResponse?.currentZScore ?? 0 + const currentSpread = spreadResponse?.currentSpread ?? 0 + + // Determine signal state + const getSignalState = () => { + if (currentZScore > zScoreThreshold) { + return { label: `Short Spread (Sell ${primarySymbol})`, color: 'error' as const, icon: } + } else if (currentZScore < -zScoreThreshold) { + return { label: `Long Spread (Buy ${primarySymbol})`, color: 'success' as const, icon: } + } + return { label: 'Neutral (No Signal)', color: 'default' as const, icon: } + } + + const signalState = getSignalState() + + if (isLoading) { + return ( + + + Loading spread data... + + ) + } + + if (error) { + return ( + + Failed to load spread data: {(error as Error).message} + + ) + } + + return ( + + + + + Pairs Trading: {primarySymbol} / {secondarySymbol} + + + Statistical Arbitrage - Spread Analysis + + + + + + {/* Key Metrics */} + + + + Current Spread + {currentSpread?.toFixed(4) ?? 'N/A'} + + + + zScoreThreshold + ? (currentZScore > 0 ? 'error.dark' : 'success.dark') + : 'background.default', + textAlign: 'center', + transition: 'background-color 0.3s', + }} + > + Z-Score + zScoreThreshold ? 'white' : 'inherit' + }} + > + {currentZScore?.toFixed(2) ?? 'N/A'} + + + + + + Threshold + ±{zScoreThreshold} + + + + + {/* Z-Score Visual Gauge */} + + + -{zScoreThreshold} (Buy) + 0 (Neutral) + +{zScoreThreshold} (Sell) + + + + {/* Threshold zones */} + + + {/* Current position indicator */} + zScoreThreshold + ? (currentZScore > 0 ? 'error.main' : 'success.main') + : 'primary.main', + borderRadius: 1, + }} /> + + + + + {/* Spread Chart */} + + Spread History (Ratio: {primarySymbol} / {secondarySymbol}) + + + + + + new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + stroke="rgba(255,255,255,0.5)" + /> + + new Date(t).toLocaleString()} + /> + + + + + + {/* Z-Score Chart */} + + Z-Score History + + + + + + new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + stroke="rgba(255,255,255,0.5)" + /> + + new Date(t).toLocaleString()} + /> + {/* Threshold lines */} + + + + + + + + + ) +} diff --git a/frontend/src/components/StatusIndicator.tsx b/frontend/src/components/StatusIndicator.tsx new file mode 100644 index 00000000..23243eb7 --- /dev/null +++ b/frontend/src/components/StatusIndicator.tsx @@ -0,0 +1,63 @@ +import { Chip, Tooltip, Box } from '@mui/material' +import { CheckCircle, Error, Warning, CloudOff } from '@mui/icons-material' + +export type StatusType = 'connected' | 'disconnected' | 'error' | 'warning' | 'unknown' + +interface StatusIndicatorProps { + status: StatusType + label: string + tooltip?: string +} + +export default function StatusIndicator({ status, label, tooltip }: StatusIndicatorProps) { + const getColor = () => { + switch (status) { + case 'connected': + return 'success' + case 'disconnected': + return 'default' + case 'error': + return 'error' + case 'warning': + return 'warning' + default: + return 'default' + } + } + + const getIcon = () => { + switch (status) { + case 'connected': + return + case 'disconnected': + return + case 'error': + return + case 'warning': + return + default: + return null + } + } + + const chip = ( + + ) + + if (tooltip) { + return ( + + {chip} + + ) + } + + return chip +} + diff --git a/frontend/src/components/StrategyDialog.tsx b/frontend/src/components/StrategyDialog.tsx new file mode 100644 index 00000000..a9fed593 --- /dev/null +++ b/frontend/src/components/StrategyDialog.tsx @@ -0,0 +1,404 @@ +import { useState, useEffect } from 'react' +import { useMutation } from '@tanstack/react-query' +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + TextField, + FormControl, + InputLabel, + Select, + MenuItem, + Grid, + Alert, + Switch, + FormControlLabel, + Box, + Typography, + Tabs, + Tab, +} from '@mui/material' +import { strategiesApi } from '../api/strategies' +import { StrategyResponse, StrategyCreate, StrategyUpdate } from '../types' +import StrategyParameterForm from './StrategyParameterForm' +import { useSnackbar } from '../contexts/SnackbarContext' + +interface StrategyDialogProps { + open: boolean + onClose: () => void + strategy: StrategyResponse | null + exchanges: Array<{ id: number; name: string }> + onSave: () => void +} + +const STRATEGY_TYPES = [ + { value: 'rsi', label: 'RSI Strategy' }, + { value: 'macd', label: 'MACD Strategy' }, + { value: 'moving_average', label: 'Moving Average Crossover' }, + { value: 'confirmed', label: 'Confirmed Strategy (Multi-Indicator)' }, + { value: 'divergence', label: 'Divergence Strategy' }, + { value: 'bollinger_mean_reversion', label: 'Bollinger Bands Mean Reversion' }, + { value: 'consensus', label: 'Consensus Strategy (Ensemble)' }, + { value: 'dca', label: 'Dollar Cost Averaging' }, + { value: 'grid', label: 'Grid Trading' }, + { value: 'momentum', label: 'Momentum Strategy' }, + { value: 'pairs_trading', label: 'Statistical Arbitrage (Pairs)' }, + { value: 'volatility_breakout', label: 'Volatility Breakout' }, + { value: 'sentiment', label: 'Sentiment / News Trading' }, + { value: 'market_making', label: 'Market Making' }, +] + +const TIMEFRAMES = ['1m', '5m', '15m', '30m', '1h', '4h', '1d'] + +const CRYPTO_PAIRS = [ + 'BTC/USD', + 'ETH/USD', + 'BTC/USDT', + 'ETH/USDT', + 'SOL/USD', + 'ADA/USD', + 'XRP/USD', + 'DOGE/USD', + 'DOT/USD', + 'MATIC/USD', + 'AVAX/USD', + 'LINK/USD', +] + +export default function StrategyDialog({ + open, + onClose, + strategy, + exchanges, + onSave, +}: StrategyDialogProps) { + const { showError } = useSnackbar() + const [tabValue, setTabValue] = useState(0) + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [strategyType, setStrategyType] = useState('rsi') + const [symbol, setSymbol] = useState('BTC/USD') + const [exchangeId, setExchangeId] = useState('') + const [timeframes, setTimeframes] = useState(['1h']) + const [paperTrading, setPaperTrading] = useState(true) + const [enabled, setEnabled] = useState(false) + const [parameters, setParameters] = useState>({}) + + useEffect(() => { + if (strategy) { + setName(strategy.name) + setDescription(strategy.description || '') + setStrategyType(strategy.strategy_type) + setSymbol(strategy.parameters?.symbol || 'BTC/USD') + setExchangeId(strategy.parameters?.exchange_id || '') + setTimeframes(strategy.timeframes || ['1h']) + setPaperTrading(strategy.paper_trading) + setEnabled(strategy.enabled) + // Extract strategy-specific parameters (exclude symbol, exchange_id) + const { symbol: _, exchange_id: __, ...strategyParams } = strategy.parameters || {} + setParameters(strategyParams) + } else { + // Reset form + setName('') + setDescription('') + setStrategyType('rsi') + setSymbol('BTC/USD') + setExchangeId('') + setTimeframes(['1h']) + setPaperTrading(true) + setEnabled(false) + setParameters({}) + } + }, [strategy, open]) + + const createMutation = useMutation({ + mutationFn: (data: StrategyCreate) => strategiesApi.createStrategy(data), + onSuccess: onSave, + }) + + const updateMutation = useMutation({ + mutationFn: (data: StrategyUpdate) => + strategiesApi.updateStrategy(strategy!.id, data), + onSuccess: onSave, + }) + + const handleSave = () => { + if (!name.trim()) { + showError('Strategy name is required') + return + } + + if (!exchangeId) { + showError('Please select an exchange') + return + } + + const strategyData: StrategyCreate | StrategyUpdate = { + name: name.trim(), + description: description.trim() || undefined, + strategy_type: strategyType, + class_name: strategyType, + parameters: { + ...parameters, + symbol, + exchange_id: exchangeId, + }, + timeframes, + paper_trading: paperTrading, + enabled: strategy ? enabled : false, // New strategies start disabled + } + + if (strategy) { + updateMutation.mutate(strategyData as StrategyUpdate) + } else { + createMutation.mutate(strategyData as StrategyCreate) + } + } + + return ( + + + {strategy ? 'Edit Strategy' : 'Create Strategy'} + + + setTabValue(v)} sx={{ mb: 3 }}> + + + + + + {tabValue === 0 && ( + + + setName(e.target.value)} + required + helperText="A descriptive name for this strategy" + /> + + + setDescription(e.target.value)} + multiline + rows={2} + helperText="Optional description of the strategy" + /> + + + + Strategy Type + + + + + + Symbol + + + + + + Exchange + + + + + + Timeframes + + + + + setPaperTrading(e.target.checked)} + /> + } + label="Paper Trading Mode" + /> + + Paper trading uses virtual funds for testing + + + {strategy && ( + + setEnabled(e.target.checked)} + /> + } + label="Enable for Autopilot" + /> + + Make this strategy available for ML-based selection + + + )} + + )} + + {tabValue === 1 && ( + + + + )} + + {tabValue === 2 && ( + + + + setParameters({ + ...parameters, + position_size_percent: parseFloat(e.target.value) || 10, + }) + } + inputProps={{ min: 0.1, max: 100, step: 0.1 }} + helperText="Percentage of capital to use per trade" + /> + + + + setParameters({ + ...parameters, + stop_loss_percent: parseFloat(e.target.value) || 5, + }) + } + inputProps={{ min: 0.1, max: 50, step: 0.1 }} + helperText="Maximum loss percentage before exit" + /> + + + + setParameters({ + ...parameters, + take_profit_percent: parseFloat(e.target.value) || 10, + }) + } + inputProps={{ min: 0.1, max: 100, step: 0.1 }} + helperText="Profit target percentage" + /> + + + + setParameters({ + ...parameters, + max_position_size: e.target.value ? parseFloat(e.target.value) : undefined, + }) + } + inputProps={{ min: 0, step: 0.01 }} + helperText="Maximum position size (optional)" + /> + + + )} + + {(createMutation.isError || updateMutation.isError) && ( + + {createMutation.error instanceof Error + ? createMutation.error.message + : updateMutation.error instanceof Error + ? updateMutation.error.message + : 'Failed to save strategy'} + + )} + + + + + + + ) +} + diff --git a/frontend/src/components/StrategyParameterForm.tsx b/frontend/src/components/StrategyParameterForm.tsx new file mode 100644 index 00000000..1d80ae64 --- /dev/null +++ b/frontend/src/components/StrategyParameterForm.tsx @@ -0,0 +1,792 @@ +import { Grid, TextField, FormControl, InputLabel, Select, MenuItem, Typography, Box } from '@mui/material' +import { Info } from '@mui/icons-material' + +interface StrategyParameterFormProps { + strategyType: string + parameters: Record + onChange: (parameters: Record) => void +} + +export default function StrategyParameterForm({ + strategyType, + parameters, + onChange, +}: StrategyParameterFormProps) { + const updateParameter = (key: string, value: any) => { + onChange({ ...parameters, [key]: value }) + } + + const renderRSIParameters = () => ( + + + updateParameter('rsi_period', parseInt(e.target.value) || 14)} + inputProps={{ min: 2, max: 100 }} + helperText="Period for RSI calculation" + /> + + + updateParameter('oversold', parseFloat(e.target.value) || 30)} + inputProps={{ min: 0, max: 50 }} + helperText="RSI level considered oversold" + /> + + + updateParameter('overbought', parseFloat(e.target.value) || 70)} + inputProps={{ min: 50, max: 100 }} + helperText="RSI level considered overbought" + /> + + + ) + + const renderMACDParameters = () => ( + + + updateParameter('fast_period', parseInt(e.target.value) || 12)} + inputProps={{ min: 1, max: 50 }} + /> + + + updateParameter('slow_period', parseInt(e.target.value) || 26)} + inputProps={{ min: 1, max: 100 }} + /> + + + updateParameter('signal_period', parseInt(e.target.value) || 9)} + inputProps={{ min: 1, max: 50 }} + /> + + + ) + + const renderMovingAverageParameters = () => ( + + + updateParameter('short_period', parseInt(e.target.value) || 20)} + inputProps={{ min: 1, max: 200 }} + /> + + + updateParameter('long_period', parseInt(e.target.value) || 50)} + inputProps={{ min: 1, max: 200 }} + /> + + + + MA Type + + + + + ) + + const renderDCAParameters = () => ( + + + updateParameter('amount', parseFloat(e.target.value) || 10)} + inputProps={{ min: 0.01, step: 0.01 }} + helperText="Fixed amount to invest per interval" + /> + + + + Interval + + + + + updateParameter('target_allocation', parseFloat(e.target.value) || 10)} + inputProps={{ min: 0.1, max: 100, step: 0.1 }} + helperText="Target portfolio allocation percentage" + /> + + + ) + + const renderGridParameters = () => ( + + + updateParameter('grid_spacing', parseFloat(e.target.value) || 1)} + inputProps={{ min: 0.1, max: 10, step: 0.1 }} + helperText="Percentage spacing between grid levels" + /> + + + updateParameter('num_levels', parseInt(e.target.value) || 10)} + inputProps={{ min: 1, max: 50 }} + helperText="Grid levels above and below center" + /> + + + updateParameter('profit_target', parseFloat(e.target.value) || 2)} + inputProps={{ min: 0.1, max: 50, step: 0.1 }} + helperText="Profit percentage to take" + /> + + + ) + + const renderMomentumParameters = () => ( + + + updateParameter('lookback_period', parseInt(e.target.value) || 20)} + inputProps={{ min: 1, max: 100 }} + helperText="Period for momentum calculation" + /> + + + updateParameter('momentum_threshold', parseFloat(e.target.value) || 0.05)} + inputProps={{ min: 0, max: 1, step: 0.01 }} + helperText="Minimum momentum to enter (0.05 = 5%)" + /> + + + updateParameter('volume_threshold', parseFloat(e.target.value) || 1.5)} + inputProps={{ min: 1, max: 10, step: 0.1 }} + helperText="Volume increase multiplier for confirmation" + /> + + + updateParameter('exit_threshold', parseFloat(e.target.value) || -0.02)} + inputProps={{ min: -1, max: 0, step: 0.01 }} + helperText="Momentum reversal threshold for exit" + /> + + + ) + + const renderConfirmedParameters = () => ( + + + + RSI Parameters + + + + updateParameter('rsi_period', parseInt(e.target.value) || 14)} + /> + + + updateParameter('rsi_oversold', parseFloat(e.target.value) || 30)} + /> + + + updateParameter('rsi_overbought', parseFloat(e.target.value) || 70)} + /> + + + + MACD Parameters + + + + updateParameter('macd_fast', parseInt(e.target.value) || 12)} + /> + + + updateParameter('macd_slow', parseInt(e.target.value) || 26)} + /> + + + updateParameter('macd_signal', parseInt(e.target.value) || 9)} + /> + + + + Moving Average Parameters + + + + updateParameter('ma_fast', parseInt(e.target.value) || 10)} + /> + + + updateParameter('ma_slow', parseInt(e.target.value) || 30)} + /> + + + + MA Type + + + + + + Confirmation Settings + + + + updateParameter('min_confirmations', parseInt(e.target.value) || 2)} + inputProps={{ min: 1, max: 3 }} + helperText="Minimum indicators that must agree" + /> + + + ) + + const renderDivergenceParameters = () => ( + + + + Indicator Type + + + + + updateParameter('lookback_period', parseInt(e.target.value) || 20)} + inputProps={{ min: 5, max: 100 }} + helperText="Period for swing detection" + /> + + + updateParameter('min_swings', parseInt(e.target.value) || 2)} + inputProps={{ min: 1, max: 10 }} + /> + + + updateParameter('confidence_threshold', parseFloat(e.target.value) || 0.5)} + inputProps={{ min: 0, max: 1, step: 0.1 }} + /> + + + ) + + const renderBollingerParameters = () => ( + + + updateParameter('period', parseInt(e.target.value) || 20)} + inputProps={{ min: 5, max: 100 }} + /> + + + updateParameter('std_dev_multiplier', parseFloat(e.target.value) || 2.0)} + inputProps={{ min: 1, max: 5, step: 0.1 }} + /> + + + updateParameter('trend_ma_period', parseInt(e.target.value) || 50)} + inputProps={{ min: 10, max: 200 }} + /> + + + updateParameter('entry_threshold', parseFloat(e.target.value) || 0.95)} + inputProps={{ min: 0, max: 1, step: 0.01 }} + helperText="How close to band (0.95 = 95%)" + /> + + + ) + + const renderConsensusParameters = () => ( + + + updateParameter('min_consensus_count', parseInt(e.target.value) || 2)} + inputProps={{ min: 1, max: 10 }} + helperText="Minimum strategies that must agree" + /> + + + updateParameter('min_weight_threshold', parseFloat(e.target.value) || 0.3)} + inputProps={{ min: 0, max: 1, step: 0.1 }} + /> + + + ) + + const renderPairsTradingParameters = () => ( + + + + Statistical Arbitrage trades the spread between the main symbol and a second symbol. + + + + updateParameter('second_symbol', e.target.value)} + helperText="The correlated asset to pair with" + /> + + + updateParameter('lookback_period', parseInt(e.target.value) || 20)} + inputProps={{ min: 5, max: 100 }} + helperText="Rolling window for Z-Score calc" + /> + + + updateParameter('z_score_threshold', parseFloat(e.target.value) || 2.0)} + inputProps={{ min: 1.0, max: 5.0, step: 0.1 }} + helperText="Entry trigger (Standard Deviations)" + /> + + + ) + + const renderVolatilityBreakoutParameters = () => ( + + + + Captures explosive moves after periods of low volatility (squeeze). + + + + updateParameter('bb_period', parseInt(e.target.value) || 20)} + inputProps={{ min: 5, max: 50 }} + helperText="Bollinger Bands period" + /> + + + updateParameter('bb_std_dev', parseFloat(e.target.value) || 2.0)} + inputProps={{ min: 1.0, max: 4.0, step: 0.1 }} + helperText="Standard deviation multiplier" + /> + + + updateParameter('squeeze_threshold', parseFloat(e.target.value) || 0.1)} + inputProps={{ min: 0.01, max: 0.5, step: 0.01 }} + helperText="BB Width for squeeze detection" + /> + + + updateParameter('volume_multiplier', parseFloat(e.target.value) || 1.5)} + inputProps={{ min: 1.0, max: 5.0, step: 0.1 }} + helperText="Min volume vs 20-day avg" + /> + + + updateParameter('min_adx', parseFloat(e.target.value) || 25)} + inputProps={{ min: 10, max: 50 }} + helperText="Trend strength filter" + /> + + + ) + + const renderSentimentParameters = () => ( + + + + Trades based on news sentiment and Fear & Greed Index. + + + + + Mode + + + + + updateParameter('min_sentiment_score', parseFloat(e.target.value) || 0.5)} + inputProps={{ min: 0.1, max: 1.0, step: 0.1 }} + helperText="Threshold for momentum signals" + /> + + + updateParameter('fear_threshold', parseInt(e.target.value) || 25)} + inputProps={{ min: 0, max: 50 }} + helperText="F&G value for 'extreme fear'" + /> + + + updateParameter('greed_threshold', parseInt(e.target.value) || 75)} + inputProps={{ min: 50, max: 100 }} + helperText="F&G value for 'extreme greed'" + /> + + + updateParameter('news_lookback_hours', parseInt(e.target.value) || 24)} + inputProps={{ min: 1, max: 72 }} + helperText="How far back to analyze news" + /> + + + ) + + const renderMarketMakingParameters = () => ( + + + + Places limit orders on both sides of the spread. Best for ranging markets. + + + + updateParameter('spread_percent', parseFloat(e.target.value) || 0.2)} + inputProps={{ min: 0.05, max: 2.0, step: 0.05 }} + helperText="Distance from mid price" + /> + + + updateParameter('requote_threshold', parseFloat(e.target.value) || 0.5)} + inputProps={{ min: 0.1, max: 2.0, step: 0.1 }} + helperText="Price move to trigger requote" + /> + + + updateParameter('max_inventory', parseFloat(e.target.value) || 1.0)} + inputProps={{ min: 0.1, max: 10, step: 0.1 }} + helperText="Max position before skewing" + /> + + + updateParameter('inventory_skew_factor', parseFloat(e.target.value) || 0.5)} + inputProps={{ min: 0, max: 1, step: 0.1 }} + helperText="How much to skew quotes" + /> + + + updateParameter('min_adx', parseInt(e.target.value) || 20)} + inputProps={{ min: 10, max: 40 }} + helperText="Skip if ADX above this" + /> + + + ) + + const renderParameters = () => { + switch (strategyType) { + case 'rsi': + return renderRSIParameters() + case 'macd': + return renderMACDParameters() + case 'moving_average': + return renderMovingAverageParameters() + case 'confirmed': + return renderConfirmedParameters() + case 'divergence': + return renderDivergenceParameters() + case 'bollinger_mean_reversion': + return renderBollingerParameters() + case 'consensus': + return renderConsensusParameters() + case 'dca': + return renderDCAParameters() + case 'grid': + return renderGridParameters() + case 'momentum': + return renderMomentumParameters() + case 'pairs_trading': + return renderPairsTradingParameters() + case 'volatility_breakout': + return renderVolatilityBreakoutParameters() + case 'sentiment': + return renderSentimentParameters() + case 'market_making': + return renderMarketMakingParameters() + default: + return ( + + + No specific parameters for this strategy type. + + + ) + } + } + + return ( + + + + + Configure strategy-specific parameters. Default values are provided. + + + {renderParameters()} + + ) +} + diff --git a/frontend/src/components/SystemHealth.tsx b/frontend/src/components/SystemHealth.tsx new file mode 100644 index 00000000..994df752 --- /dev/null +++ b/frontend/src/components/SystemHealth.tsx @@ -0,0 +1,83 @@ +import { Card, CardContent, Typography, Box, Grid, Chip } from '@mui/material' +import { CheckCircle, Error, Warning } from '@mui/icons-material' +import StatusIndicator from './StatusIndicator' + +interface SystemHealthProps { + websocketStatus: 'connected' | 'disconnected' | 'error' + exchangeStatuses?: Array<{ name: string; status: 'connected' | 'disconnected' | 'error' }> + databaseStatus?: 'connected' | 'disconnected' | 'error' +} + +export default function SystemHealth({ + websocketStatus, + exchangeStatuses = [], + databaseStatus = 'connected', +}: SystemHealthProps) { + const getOverallHealth = () => { + const statuses = [ + websocketStatus, + databaseStatus, + ...exchangeStatuses.map((e) => e.status), + ] + + if (statuses.some((s) => s === 'error')) return 'error' + if (statuses.some((s) => s === 'disconnected')) return 'warning' + return 'connected' + } + + const overallHealth = getOverallHealth() + + return ( + + + + System Health + + + + + + Overall Status: + + + + + + + + + + + {exchangeStatuses.length > 0 && ( + + + Exchanges: + + + {exchangeStatuses.map((exchange) => ( + + ))} + + + )} + + + + ) +} + diff --git a/frontend/src/components/WebSocketProvider.tsx b/frontend/src/components/WebSocketProvider.tsx new file mode 100644 index 00000000..f60446e8 --- /dev/null +++ b/frontend/src/components/WebSocketProvider.tsx @@ -0,0 +1,31 @@ +import { createContext, useContext, ReactNode } from 'react' +import { useWebSocket, WebSocketMessage } from '../hooks/useWebSocket' + +interface WebSocketContextType { + isConnected: boolean + lastMessage: WebSocketMessage | null + messageHistory: WebSocketMessage[] + sendMessage: (message: any) => void + subscribe: (messageType: string, handler: (message: WebSocketMessage) => void) => () => void +} + +const WebSocketContext = createContext(undefined) + +export function WebSocketProvider({ children }: { children: ReactNode }) { + const wsUrl = import.meta.env.VITE_WS_URL || 'ws://localhost:8000/ws/' + const { isConnected, lastMessage, messageHistory, sendMessage, subscribe } = useWebSocket(wsUrl) + + return ( + + {children} + + ) +} + +export function useWebSocketContext() { + const context = useContext(WebSocketContext) + if (!context) { + throw new Error('useWebSocketContext must be used within WebSocketProvider') + } + return context +} diff --git a/frontend/src/components/__init__.ts b/frontend/src/components/__init__.ts new file mode 100644 index 00000000..cac901fc --- /dev/null +++ b/frontend/src/components/__init__.ts @@ -0,0 +1,2 @@ +export { default as Layout } from './Layout' +export { WebSocketProvider, useWebSocketContext } from './WebSocketProvider' diff --git a/frontend/src/components/__tests__/ErrorDisplay.test.tsx b/frontend/src/components/__tests__/ErrorDisplay.test.tsx new file mode 100644 index 00000000..bb769b7f --- /dev/null +++ b/frontend/src/components/__tests__/ErrorDisplay.test.tsx @@ -0,0 +1,65 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import ErrorDisplay from '../ErrorDisplay' + +describe('ErrorDisplay', () => { + describe('error message rendering', () => { + it('renders error string message', () => { + render() + expect(screen.getByText('Test error message')).toBeInTheDocument() + }) + + it('renders Error object message', () => { + const error = new Error('Error object message') + render() + expect(screen.getByText('Error object message')).toBeInTheDocument() + }) + + it('renders default title', () => { + render() + expect(screen.getByText('Error')).toBeInTheDocument() + }) + + it('renders custom title', () => { + render() + expect(screen.getByText('Custom Error Title')).toBeInTheDocument() + }) + }) + + describe('retry functionality', () => { + it('does not show retry button when onRetry is not provided', () => { + render() + expect(screen.queryByText('Retry')).not.toBeInTheDocument() + }) + + it('shows retry button when onRetry is provided', () => { + const onRetry = vi.fn() + render() + expect(screen.getByText('Retry')).toBeInTheDocument() + }) + + it('calls onRetry when retry button is clicked', () => { + const onRetry = vi.fn() + render() + + fireEvent.click(screen.getByText('Retry')) + + expect(onRetry).toHaveBeenCalledTimes(1) + }) + }) + + describe('technical details', () => { + it('shows technical details section for Error object with stack', () => { + const error = new Error('Error with stack') + render() + + expect(screen.getByText('Technical Details')).toBeInTheDocument() + }) + + it('does not show technical details for string errors', () => { + render() + + expect(screen.queryByText('Technical Details')).not.toBeInTheDocument() + }) + }) +}) diff --git a/frontend/src/components/__tests__/PositionCard.test.tsx b/frontend/src/components/__tests__/PositionCard.test.tsx new file mode 100644 index 00000000..3d8c371b --- /dev/null +++ b/frontend/src/components/__tests__/PositionCard.test.tsx @@ -0,0 +1,166 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import PositionCard from '../PositionCard' +import { PositionResponse, OrderType } from '../../types' +import * as tradingApi from '../../api/trading' + +vi.mock('../../api/trading') +vi.mock('../../contexts/SnackbarContext', () => ({ + useSnackbar: () => ({ + showError: vi.fn(), + showSuccess: vi.fn(), + showWarning: vi.fn(), + showInfo: vi.fn(), + }), +})) + +const mockPosition: PositionResponse = { + id: 1, + symbol: 'BTC/USD', + quantity: 0.5, + entry_price: 40000, + current_price: 42000, + unrealized_pnl: 1000, + realized_pnl: 500, + side: 'long', + opened_at: '2024-01-01T00:00:00Z', +} + +describe('PositionCard', () => { + let queryClient: QueryClient + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + vi.clearAllMocks() + }) + + const renderComponent = (position = mockPosition, paperTrading = false) => { + const onClose = vi.fn() + return { + onClose, + ...render( + + + + ), + } + } + + describe('position data display', () => { + it('renders position symbol', () => { + renderComponent() + expect(screen.getByText('BTC/USD')).toBeInTheDocument() + }) + + it('renders position quantity', () => { + renderComponent() + expect(screen.getByText('0.50000000')).toBeInTheDocument() + }) + + it('renders entry price', () => { + renderComponent() + expect(screen.getByText('$40000.00')).toBeInTheDocument() + }) + + it('renders current price', () => { + renderComponent() + expect(screen.getByText('$42000.00')).toBeInTheDocument() + }) + }) + + describe('PnL display', () => { + it('displays positive unrealized PnL with plus sign', () => { + renderComponent() + expect(screen.getByText('+$1000.00')).toBeInTheDocument() + }) + + it('displays positive realized PnL with plus sign', () => { + renderComponent() + expect(screen.getByText('+$500.00')).toBeInTheDocument() + }) + + it('displays negative unrealized PnL correctly', () => { + const negativePosition = { ...mockPosition, unrealized_pnl: -500 } + renderComponent(negativePosition) + expect(screen.getByText('-$500.00')).toBeInTheDocument() + }) + + it('shows positive percent chip for profitable position', () => { + renderComponent() + expect(screen.getByText('+5.00%')).toBeInTheDocument() + }) + + it('shows negative percent chip for losing position', () => { + const losingPosition = { ...mockPosition, current_price: 38000 } + renderComponent(losingPosition) + expect(screen.getByText('-5.00%')).toBeInTheDocument() + }) + }) + + describe('close position functionality', () => { + it('shows close position button', () => { + renderComponent() + expect(screen.getByText('Close Position')).toBeInTheDocument() + }) + + it('opens close dialog when button is clicked', async () => { + renderComponent() + + fireEvent.click(screen.getByText('Close Position')) + + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + }) + + it('shows order type selector in dialog', async () => { + renderComponent() + + fireEvent.click(screen.getByText('Close Position')) + + await waitFor(() => { + expect(screen.getByLabelText('Order Type')).toBeInTheDocument() + }) + }) + + it('closes dialog when cancel is clicked', async () => { + renderComponent() + + fireEvent.click(screen.getByText('Close Position')) + + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByText('Cancel')) + + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + }) + + it('submits market order when confirmed', async () => { + const mockCreateOrder = vi.fn().mockResolvedValue({}) + vi.mocked(tradingApi.tradingApi.createOrder).mockImplementation(mockCreateOrder) + + renderComponent() + + fireEvent.click(screen.getByText('Close Position')) + + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + // Click the confirm button (second "Close Position" button in dialog) + const buttons = screen.getAllByText('Close Position') + fireEvent.click(buttons[1]) + + await waitFor(() => { + expect(mockCreateOrder).toHaveBeenCalled() + }) + }) + }) +}) diff --git a/frontend/src/components/__tests__/StatusIndicator.test.tsx b/frontend/src/components/__tests__/StatusIndicator.test.tsx new file mode 100644 index 00000000..bf98df19 --- /dev/null +++ b/frontend/src/components/__tests__/StatusIndicator.test.tsx @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import StatusIndicator from '../StatusIndicator' + +describe('StatusIndicator', () => { + describe('rendering', () => { + it('renders with label', () => { + render() + expect(screen.getByText('Test Label')).toBeInTheDocument() + }) + + it('renders tooltip when provided', () => { + render() + expect(screen.getByText('Test')).toBeInTheDocument() + }) + }) + + describe('status colors', () => { + it('shows success color for connected status', () => { + render() + const chip = screen.getByText('Connected').closest('.MuiChip-root') + expect(chip).toHaveClass('MuiChip-colorSuccess') + }) + + it('shows error color for error status', () => { + render() + const chip = screen.getByText('Error').closest('.MuiChip-root') + expect(chip).toHaveClass('MuiChip-colorError') + }) + + it('shows warning color for warning status', () => { + render() + const chip = screen.getByText('Warning').closest('.MuiChip-root') + expect(chip).toHaveClass('MuiChip-colorWarning') + }) + + it('shows default color for disconnected status', () => { + render() + const chip = screen.getByText('Disconnected').closest('.MuiChip-root') + expect(chip).toHaveClass('MuiChip-colorDefault') + }) + + it('shows default color for unknown status', () => { + render() + const chip = screen.getByText('Unknown').closest('.MuiChip-root') + expect(chip).toHaveClass('MuiChip-colorDefault') + }) + }) + + describe('icons', () => { + it('renders CheckCircle icon for connected status', () => { + render() + expect(document.querySelector('[data-testid="CheckCircleIcon"]')).toBeInTheDocument() + }) + + it('renders CloudOff icon for disconnected status', () => { + render() + expect(document.querySelector('[data-testid="CloudOffIcon"]')).toBeInTheDocument() + }) + + it('renders Error icon for error status', () => { + render() + expect(document.querySelector('[data-testid="ErrorIcon"]')).toBeInTheDocument() + }) + + it('renders Warning icon for warning status', () => { + render() + expect(document.querySelector('[data-testid="WarningIcon"]')).toBeInTheDocument() + }) + }) +}) diff --git a/frontend/src/contexts/AutopilotSettingsContext.tsx b/frontend/src/contexts/AutopilotSettingsContext.tsx new file mode 100644 index 00000000..96b3451d --- /dev/null +++ b/frontend/src/contexts/AutopilotSettingsContext.tsx @@ -0,0 +1,96 @@ +import { createContext, useContext, useState, useEffect, ReactNode } from 'react' + +export interface AutopilotSettings { + autoExecute: boolean + // Intelligent mode settings + interval: number + timeframe: string + exchange_id: number +} + +const DEFAULT_SETTINGS: AutopilotSettings = { + autoExecute: false, + interval: 60.0, + timeframe: '1h', + exchange_id: 1, +} + + +const STORAGE_KEY = 'autopilot_settings' + +interface AutopilotSettingsContextType { + settings: AutopilotSettings + updateSettings: (updates: Partial) => void + resetSettings: () => void +} + +const AutopilotSettingsContext = createContext(undefined) + +export function AutopilotSettingsProvider({ children }: { children: ReactNode }) { + const [settings, setSettings] = useState(() => { + // Load from localStorage on initialization + try { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored) { + const parsed = JSON.parse(stored) + return { ...DEFAULT_SETTINGS, ...parsed } + } + } catch (error) { + console.error('Failed to load autopilot settings from localStorage:', error) + } + return DEFAULT_SETTINGS + }) + + // Save to localStorage whenever settings change + useEffect(() => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)) + } catch (error) { + console.error('Failed to save autopilot settings to localStorage:', error) + } + }, [settings]) + + const updateSettings = (updates: Partial) => { + setSettings((prev) => ({ ...prev, ...updates })) + } + + const resetSettings = () => { + setSettings(DEFAULT_SETTINGS) + try { + localStorage.removeItem(STORAGE_KEY) + } catch (error) { + console.error('Failed to remove autopilot settings from localStorage:', error) + } + } + + return ( + + {children} + + ) +} + +export function useAutopilotSettings() { + const context = useContext(AutopilotSettingsContext) + if (context === undefined) { + throw new Error('useAutopilotSettings must be used within an AutopilotSettingsProvider') + } + return context.settings +} + +export function useUpdateAutopilotSettings() { + const context = useContext(AutopilotSettingsContext) + if (context === undefined) { + throw new Error('useUpdateAutopilotSettings must be used within an AutopilotSettingsProvider') + } + return context.updateSettings +} + +export function useAutopilotSettingsContext() { + const context = useContext(AutopilotSettingsContext) + if (context === undefined) { + throw new Error('useAutopilotSettingsContext must be used within an AutopilotSettingsProvider') + } + return context +} + diff --git a/frontend/src/contexts/SnackbarContext.tsx b/frontend/src/contexts/SnackbarContext.tsx new file mode 100644 index 00000000..e4ab537a --- /dev/null +++ b/frontend/src/contexts/SnackbarContext.tsx @@ -0,0 +1,54 @@ +import { createContext, useContext, useState, ReactNode } from 'react' +import { Snackbar, Alert, AlertColor } from '@mui/material' + +interface SnackbarContextType { + showSnackbar: (message: string, severity?: AlertColor) => void + showError: (message: string) => void + showSuccess: (message: string) => void + showWarning: (message: string) => void + showInfo: (message: string) => void +} + +const SnackbarContext = createContext(undefined) + +export function SnackbarProvider({ children }: { children: ReactNode }) { + const [open, setOpen] = useState(false) + const [message, setMessage] = useState('') + const [severity, setSeverity] = useState('info') + + const showSnackbar = (msg: string, sev: AlertColor = 'info') => { + setMessage(msg) + setSeverity(sev) + setOpen(true) + } + + const showError = (msg: string) => showSnackbar(msg, 'error') + const showSuccess = (msg: string) => showSnackbar(msg, 'success') + const showWarning = (msg: string) => showSnackbar(msg, 'warning') + const showInfo = (msg: string) => showSnackbar(msg, 'info') + + return ( + + {children} + setOpen(false)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} + > + setOpen(false)} severity={severity} sx={{ width: '100%' }}> + {message} + + + + ) +} + +export function useSnackbar() { + const context = useContext(SnackbarContext) + if (!context) { + throw new Error('useSnackbar must be used within SnackbarProvider') + } + return context +} + diff --git a/frontend/src/hooks/__tests__/useProviderStatus.test.ts b/frontend/src/hooks/__tests__/useProviderStatus.test.ts new file mode 100644 index 00000000..cdd1b26d --- /dev/null +++ b/frontend/src/hooks/__tests__/useProviderStatus.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, act, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { ReactNode } from 'react' +import { useProviderStatus } from '../useProviderStatus' +import * as marketDataApi from '../../api/marketData' + +vi.mock('../../api/marketData') +vi.mock('../../components/WebSocketProvider', () => ({ + useWebSocketContext: () => ({ + isConnected: true, + lastMessage: null, + subscribe: vi.fn(), + }), +})) + +describe('useProviderStatus', () => { + let queryClient: QueryClient + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }) + vi.clearAllMocks() + }) + + const wrapper = ({ children }: { children: ReactNode }) => ( + { children } + ) + +it('returns loading state initially', () => { + vi.mocked(marketDataApi.marketDataApi.getProviderStatus).mockImplementation( + () => new Promise(() => { }) // Never resolves + ) + + const { result } = renderHook(() => useProviderStatus(), { wrapper }) + + expect(result.current.isLoading).toBe(true) +}) + +it('returns provider status after loading', async () => { + const mockStatus = { + primary_provider: 'CoinGecko', + primary_healthy: true, + fallback_provider: 'CCXT', + fallback_healthy: true, + last_check: '2024-01-01T00:00:00Z', + } + + vi.mocked(marketDataApi.marketDataApi.getProviderStatus).mockResolvedValue(mockStatus) + + const { result } = renderHook(() => useProviderStatus(), { wrapper }) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + + expect(result.current.status).toEqual(mockStatus) +}) + +it('returns error when API call fails', async () => { + const mockError = new Error('API Error') + vi.mocked(marketDataApi.marketDataApi.getProviderStatus).mockRejectedValue(mockError) + + const { result } = renderHook(() => useProviderStatus(), { wrapper }) + + await waitFor(() => { + expect(result.current.error).not.toBeNull() + }) + + expect(result.current.error?.message).toBe('API Error') +}) + +it('provides refetch function', async () => { + const mockStatus = { + primary_provider: 'CoinGecko', + primary_healthy: true, + fallback_provider: 'CCXT', + fallback_healthy: true, + last_check: '2024-01-01T00:00:00Z', + } + + vi.mocked(marketDataApi.marketDataApi.getProviderStatus).mockResolvedValue(mockStatus) + + const { result } = renderHook(() => useProviderStatus(), { wrapper }) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + + expect(result.current.refetch).toBeDefined() + expect(typeof result.current.refetch).toBe('function') +}) +}) diff --git a/frontend/src/hooks/__tests__/useRealtimeData.test.ts b/frontend/src/hooks/__tests__/useRealtimeData.test.ts new file mode 100644 index 00000000..0e33ad5f --- /dev/null +++ b/frontend/src/hooks/__tests__/useRealtimeData.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { ReactNode } from 'react' +import { useRealtimeData } from '../useRealtimeData' + +const mockSubscribe = vi.fn(() => vi.fn()) +const mockShowInfo = vi.fn() +const mockShowWarning = vi.fn() + +vi.mock('../../components/WebSocketProvider', () => ({ + useWebSocketContext: () => ({ + isConnected: true, + lastMessage: null, + subscribe: mockSubscribe, + }), +})) + +vi.mock('../../contexts/SnackbarContext', () => ({ + useSnackbar: () => ({ + showInfo: mockShowInfo, + showWarning: mockShowWarning, + showError: vi.fn(), + showSuccess: vi.fn(), + }), +})) + +describe('useRealtimeData', () => { + let queryClient: QueryClient + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }) + vi.clearAllMocks() + }) + + const wrapper = ({ children }: { children: ReactNode }) => ( + { children } + ) + +it('subscribes to all message types on mount', () => { + renderHook(() => useRealtimeData(), { wrapper }) + + expect(mockSubscribe).toHaveBeenCalledWith('order_update', expect.any(Function)) + expect(mockSubscribe).toHaveBeenCalledWith('position_update', expect.any(Function)) + expect(mockSubscribe).toHaveBeenCalledWith('price_update', expect.any(Function)) + expect(mockSubscribe).toHaveBeenCalledWith('alert_triggered', expect.any(Function)) + expect(mockSubscribe).toHaveBeenCalledWith('strategy_signal', expect.any(Function)) + expect(mockSubscribe).toHaveBeenCalledWith('system_event', expect.any(Function)) +}) + +it('handles order update messages', async () => { + const unsubscribeMock = vi.fn() + let orderHandler: ((message: any) => void) | undefined + + mockSubscribe.mockImplementation((type: string, handler: any) => { + if (type === 'order_update') { + orderHandler = handler + } + return unsubscribeMock + }) + + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + renderHook(() => useRealtimeData(), { wrapper }) + + // Simulate order filled message + if (orderHandler) { + orderHandler({ type: 'order_update', order_id: '123', status: 'filled' }) + } + + await waitFor(() => { + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['orders'] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['balance'] }) + }) + + expect(mockShowInfo).toHaveBeenCalledWith('Order 123 filled') +}) + +it('handles alert triggered messages', async () => { + let alertHandler: ((message: any) => void) | undefined + + mockSubscribe.mockImplementation((type: string, handler: any) => { + if (type === 'alert_triggered') { + alertHandler = handler + } + return vi.fn() + }) + + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + renderHook(() => useRealtimeData(), { wrapper }) + + // Simulate alert triggered + if (alertHandler) { + alertHandler({ type: 'alert_triggered', alert_name: 'BTC Price Alert' }) + } + + await waitFor(() => { + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['alerts'] }) + }) + + expect(mockShowWarning).toHaveBeenCalledWith('Alert triggered: BTC Price Alert') +}) + +it('unsubscribes from all message types on unmount', () => { + const unsubscribeMocks = { + order_update: vi.fn(), + position_update: vi.fn(), + price_update: vi.fn(), + alert_triggered: vi.fn(), + strategy_signal: vi.fn(), + system_event: vi.fn(), + } + + mockSubscribe.mockImplementation((type: string) => { + return unsubscribeMocks[type as keyof typeof unsubscribeMocks] || vi.fn() + }) + + const { unmount } = renderHook(() => useRealtimeData(), { wrapper }) + + unmount() + + expect(unsubscribeMocks.order_update).toHaveBeenCalled() + expect(unsubscribeMocks.position_update).toHaveBeenCalled() + expect(unsubscribeMocks.price_update).toHaveBeenCalled() + expect(unsubscribeMocks.alert_triggered).toHaveBeenCalled() + expect(unsubscribeMocks.strategy_signal).toHaveBeenCalled() + expect(unsubscribeMocks.system_event).toHaveBeenCalled() +}) +}) diff --git a/frontend/src/hooks/__tests__/useWebSocket.test.ts b/frontend/src/hooks/__tests__/useWebSocket.test.ts new file mode 100644 index 00000000..f2ccc4cb --- /dev/null +++ b/frontend/src/hooks/__tests__/useWebSocket.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, act, waitFor } from '@testing-library/react' +import { useWebSocket } from '../useWebSocket' + +// Mock WebSocket class +class MockWebSocket { + static CONNECTING = 0 + static OPEN = 1 + static CLOSING = 2 + static CLOSED = 3 + + readyState = MockWebSocket.CONNECTING + url: string + onopen: (() => void) | null = null + onmessage: ((event: { data: string }) => void) | null = null + onerror: ((error: Event) => void) | null = null + onclose: (() => void) | null = null + + constructor(url: string) { + this.url = url + mockWebSocketInstances.push(this) + } + + send = vi.fn() + close = vi.fn(() => { + this.readyState = MockWebSocket.CLOSED + this.onclose?.() + }) + + // Helper to simulate connection + simulateOpen() { + this.readyState = MockWebSocket.OPEN + this.onopen?.() + } + + // Helper to simulate message + simulateMessage(data: object) { + this.onmessage?.({ data: JSON.stringify(data) }) + } + + // Helper to simulate close + simulateClose() { + this.readyState = MockWebSocket.CLOSED + this.onclose?.() + } + + // Helper to simulate error + simulateError() { + this.onerror?.(new Event('error')) + } +} + +let mockWebSocketInstances: MockWebSocket[] = [] + +describe('useWebSocket', () => { + beforeEach(() => { + vi.useFakeTimers() + mockWebSocketInstances = [] + ; (globalThis as any).WebSocket = MockWebSocket + }) + + afterEach(() => { + vi.useRealTimers() + vi.clearAllMocks() + }) + + it('initializes as disconnected', () => { + const { result } = renderHook(() => useWebSocket('ws://localhost:8000/ws')) + + expect(result.current.isConnected).toBe(false) + }) + + it('connects and sets isConnected to true', async () => { + const { result } = renderHook(() => useWebSocket('ws://localhost:8000/ws')) + + // Simulate WebSocket open + act(() => { + mockWebSocketInstances[0].simulateOpen() + }) + + await waitFor(() => { + expect(result.current.isConnected).toBe(true) + }) + }) + + it('receives and stores messages', async () => { + const { result } = renderHook(() => useWebSocket('ws://localhost:8000/ws')) + + act(() => { + mockWebSocketInstances[0].simulateOpen() + }) + + const testMessage = { type: 'order_update', order_id: '123' } + act(() => { + mockWebSocketInstances[0].simulateMessage(testMessage) + }) + + await waitFor(() => { + expect(result.current.lastMessage).toEqual(expect.objectContaining(testMessage)) + }) + }) + + it('adds messages to message history', async () => { + const { result } = renderHook(() => useWebSocket('ws://localhost:8000/ws')) + + act(() => { + mockWebSocketInstances[0].simulateOpen() + }) + + const message1 = { type: 'order_update' as const, order_id: '1' } + const message2 = { type: 'position_update' as const, position_id: '2' } + + act(() => { + mockWebSocketInstances[0].simulateMessage(message1) + mockWebSocketInstances[0].simulateMessage(message2) + }) + + await waitFor(() => { + expect(result.current.messageHistory).toHaveLength(2) + }) + }) + + it('sends messages when connected', async () => { + const { result } = renderHook(() => useWebSocket('ws://localhost:8000/ws')) + + act(() => { + mockWebSocketInstances[0].simulateOpen() + }) + + const testMessage = { action: 'subscribe', channel: 'prices' } + act(() => { + result.current.sendMessage(testMessage) + }) + + expect(mockWebSocketInstances[0].send).toHaveBeenCalledWith(JSON.stringify(testMessage)) + }) + + it('allows subscribing to specific message types', async () => { + const { result } = renderHook(() => useWebSocket('ws://localhost:8000/ws')) + const handler = vi.fn() + + act(() => { + mockWebSocketInstances[0].simulateOpen() + result.current.subscribe('order_update', handler) + }) + + const testMessage = { type: 'order_update', order_id: '123' } + act(() => { + mockWebSocketInstances[0].simulateMessage(testMessage) + }) + + await waitFor(() => { + expect(handler).toHaveBeenCalledWith(expect.objectContaining(testMessage)) + }) + }) + + it('cleans up subscription on unsubscribe', async () => { + const { result } = renderHook(() => useWebSocket('ws://localhost:8000/ws')) + const handler = vi.fn() + + act(() => { + mockWebSocketInstances[0].simulateOpen() + }) + + let unsubscribe: () => void + act(() => { + unsubscribe = result.current.subscribe('order_update', handler) + }) + + act(() => { + unsubscribe() + }) + + const testMessage = { type: 'order_update', order_id: '123' } + act(() => { + mockWebSocketInstances[0].simulateMessage(testMessage) + }) + + // Handler should not be called after unsubscribe + expect(handler).not.toHaveBeenCalled() + }) + + it('sets isConnected to false on disconnect', async () => { + const { result } = renderHook(() => useWebSocket('ws://localhost:8000/ws')) + + act(() => { + mockWebSocketInstances[0].simulateOpen() + }) + + await waitFor(() => { + expect(result.current.isConnected).toBe(true) + }) + + act(() => { + mockWebSocketInstances[0].simulateClose() + }) + + await waitFor(() => { + expect(result.current.isConnected).toBe(false) + }) + }) + + it('closes WebSocket on unmount', () => { + const { unmount } = renderHook(() => useWebSocket('ws://localhost:8000/ws')) + + act(() => { + mockWebSocketInstances[0].simulateOpen() + }) + + unmount() + + expect(mockWebSocketInstances[0].close).toHaveBeenCalled() + }) +}) diff --git a/frontend/src/hooks/useProviderStatus.ts b/frontend/src/hooks/useProviderStatus.ts new file mode 100644 index 00000000..04e86526 --- /dev/null +++ b/frontend/src/hooks/useProviderStatus.ts @@ -0,0 +1,60 @@ +import { useQuery } from '@tanstack/react-query' +import { marketDataApi, ProviderStatus } from '../api/marketData' +import { useWebSocketContext } from '../components/WebSocketProvider' +import { useEffect, useState } from 'react' + +export interface ProviderStatusData { + status: ProviderStatus | null + isLoading: boolean + error: Error | null + refetch: () => void +} + +export function useProviderStatus(): ProviderStatusData { + const { isConnected, lastMessage } = useWebSocketContext() + const [status, setStatus] = useState(null) + + const { + data, + isLoading, + error, + refetch, + } = useQuery({ + queryKey: ['provider-status'], + queryFn: () => marketDataApi.getProviderStatus(), + refetchInterval: 10000, // Refetch every 10 seconds + }) + + // Update local state when query data changes + useEffect(() => { + if (data) { + setStatus(data) + } + }, [data]) + + // Listen for provider status updates via WebSocket + useEffect(() => { + if (!isConnected || !lastMessage) return + + try { + const message = typeof lastMessage === 'string' ? JSON.parse(lastMessage) : lastMessage + + if (message.type === 'provider_status_update') { + // Update status from WebSocket message + setStatus((prev) => ({ + ...prev!, + ...message.data, + })) + } + } catch (e) { + // Ignore parsing errors + } + }, [isConnected, lastMessage]) + + return { + status, + isLoading, + error: error as Error | null, + refetch, + } +} diff --git a/frontend/src/hooks/useRealtimeData.ts b/frontend/src/hooks/useRealtimeData.ts new file mode 100644 index 00000000..a9ea52f6 --- /dev/null +++ b/frontend/src/hooks/useRealtimeData.ts @@ -0,0 +1,69 @@ +import { useEffect } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import { useWebSocketContext } from '../components/WebSocketProvider' +import { useSnackbar } from '../contexts/SnackbarContext' + +export function useRealtimeData() { + const queryClient = useQueryClient() + const { isConnected, lastMessage, subscribe } = useWebSocketContext() + const { showInfo, showWarning } = useSnackbar() + + useEffect(() => { + if (!isConnected) return + + // Subscribe to order updates + const unsubscribeOrder = subscribe('order_update', (message) => { + queryClient.invalidateQueries({ queryKey: ['orders'] }) + queryClient.invalidateQueries({ queryKey: ['balance'] }) + + if (message.status === 'filled') { + showInfo(`Order ${message.order_id} filled`) + } + }) + + // Subscribe to position updates + const unsubscribePosition = subscribe('position_update', (message) => { + queryClient.invalidateQueries({ queryKey: ['positions'] }) + queryClient.invalidateQueries({ queryKey: ['portfolio'] }) + }) + + // Subscribe to price updates + const unsubscribePrice = subscribe('price_update', (message) => { + // Invalidate market data queries for the specific symbol + if (message.symbol) { + queryClient.invalidateQueries({ queryKey: ['market-data', message.symbol] }) + } + }) + + // Subscribe to alert triggers + const unsubscribeAlert = subscribe('alert_triggered', (message) => { + queryClient.invalidateQueries({ queryKey: ['alerts'] }) + showWarning(`Alert triggered: ${message.alert_name || 'Unknown alert'}`) + }) + + // Subscribe to strategy signals + const unsubscribeSignal = subscribe('strategy_signal', (message) => { + queryClient.invalidateQueries({ queryKey: ['autopilot-status'] }) + if (message.signal_type) { + showInfo(`Strategy signal: ${message.signal_type.toUpperCase()} for ${message.symbol || 'N/A'}`) + } + }) + + // Subscribe to system events + const unsubscribeSystem = subscribe('system_event', (message) => { + if (message.event_type === 'error') { + showWarning(`System event: ${message.message || 'Unknown error'}`) + } + }) + + return () => { + unsubscribeOrder() + unsubscribePosition() + unsubscribePrice() + unsubscribeAlert() + unsubscribeSignal() + unsubscribeSystem() + } + }, [isConnected, subscribe, queryClient, showInfo, showWarning]) +} + diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts new file mode 100644 index 00000000..b87e3608 --- /dev/null +++ b/frontend/src/hooks/useWebSocket.ts @@ -0,0 +1,116 @@ +import { useEffect, useRef, useState, useCallback } from 'react' + +export interface WebSocketMessage { + type: 'order_update' | 'position_update' | 'price_update' | 'alert_triggered' | 'strategy_signal' | 'system_event' + [key: string]: any +} + +export function useWebSocket(url: string) { + const [isConnected, setIsConnected] = useState(false) + const [lastMessage, setLastMessage] = useState(null) + const [messageHistory, setMessageHistory] = useState([]) + const wsRef = useRef(null) + const reconnectTimeoutRef = useRef() + const messageHandlersRef = useRef void>>(new Map()) + const isConnectingRef = useRef(false) + + useEffect(() => { + let isMounted = true + + const connect = () => { + // Prevent duplicate connections + if (isConnectingRef.current || wsRef.current?.readyState === WebSocket.OPEN || wsRef.current?.readyState === WebSocket.CONNECTING) { + return + } + + try { + isConnectingRef.current = true + const ws = new WebSocket(url) + wsRef.current = ws + + ws.onopen = () => { + if (isMounted) { + isConnectingRef.current = false + setIsConnected(true) + console.log('WebSocket connected') + } + } + + ws.onmessage = (event) => { + if (!isMounted) return + try { + const message = JSON.parse(event.data) as WebSocketMessage + setLastMessage(message) + setMessageHistory((prev) => [...prev.slice(-99), message]) // Keep last 100 messages + + // Call registered handlers for this message type + const handlers = messageHandlersRef.current.get(message.type) + if (handlers) { + handlers(message) + } + } catch (error) { + console.error('Failed to parse WebSocket message:', error) + } + } + + ws.onerror = (error) => { + isConnectingRef.current = false + // Only log error if we're still mounted (avoid noise from StrictMode cleanup) + if (isMounted) { + console.error('WebSocket error:', error) + } + } + + ws.onclose = () => { + isConnectingRef.current = false + if (isMounted) { + setIsConnected(false) + console.log('WebSocket disconnected') + // Reconnect after 3 seconds + reconnectTimeoutRef.current = setTimeout(() => { + if (isMounted) connect() + }, 3000) + } + } + } catch (error) { + isConnectingRef.current = false + console.error('Failed to create WebSocket:', error) + } + } + + connect() + + return () => { + isMounted = false + isConnectingRef.current = false + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + } + if (wsRef.current) { + wsRef.current.close() + wsRef.current = null + } + } + }, [url]) + + const sendMessage = useCallback((message: any) => { + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify(message)) + } + }, []) + + const subscribe = useCallback((messageType: string, handler: (message: WebSocketMessage) => void) => { + messageHandlersRef.current.set(messageType, handler) + return () => { + messageHandlersRef.current.delete(messageType) + } + }, []) + + return { + isConnected, + lastMessage, + messageHistory, + sendMessage, + subscribe, + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 00000000..016fc53f --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,62 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { BrowserRouter } from 'react-router-dom' +import { ThemeProvider, createTheme, CssBaseline } from '@mui/material' +import { WebSocketProvider } from './components/WebSocketProvider' +import { SnackbarProvider } from './contexts/SnackbarContext' +import { AutopilotSettingsProvider } from './contexts/AutopilotSettingsContext' +import App from './App' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: 1, + }, + }, +}) + +const darkTheme = createTheme({ + palette: { + mode: 'dark', + primary: { + main: '#FF9800', // Amber + }, + secondary: { + main: '#2196F3', // Blue + }, + success: { + main: '#00E676', // Neon Green + }, + error: { + main: '#FF1744', // Neon Red + }, + background: { + default: '#121212', + paper: '#1E1E1E', + }, + }, + typography: { + fontFamily: "'Segoe UI', 'Roboto', 'Arial', sans-serif", + }, +}) + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + + + + + + + + + + , +) diff --git a/frontend/src/pages/BacktestPage.tsx b/frontend/src/pages/BacktestPage.tsx new file mode 100644 index 00000000..0e26c6c7 --- /dev/null +++ b/frontend/src/pages/BacktestPage.tsx @@ -0,0 +1,488 @@ +import { useState } from 'react' +import { useMutation, useQuery } from '@tanstack/react-query' +import { + Box, + Paper, + Typography, + Button, + TextField, + Grid, + FormControl, + InputLabel, + Select, + MenuItem, + Alert, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Card, + CardContent, +} from '@mui/material' +import { backtestingApi } from '../api/backtesting' +import { strategiesApi } from '../api/strategies' +import { reportingApi } from '../api/reporting' +import { BacktestRequest, StrategyResponse } from '../types' +import { useSnackbar } from '../contexts/SnackbarContext' +import HelpTooltip from '../components/HelpTooltip' +import InfoCard from '../components/InfoCard' +import OperationsPanel from '../components/OperationsPanel' +import ProgressOverlay from '../components/ProgressOverlay' +import { settingsApi } from '../api/settings' +import { formatDate } from '../utils/formatters' + +export default function BacktestPage() { + const { showError, showSuccess } = useSnackbar() + const [strategyId, setStrategyId] = useState('') + const [symbol, setSymbol] = useState('BTC/USD') + const [exchange, setExchange] = useState('coinbase') + const [timeframe, setTimeframe] = useState('1h') + const [startDate, setStartDate] = useState('') + const [endDate, setEndDate] = useState('') + const [initialCapital, setInitialCapital] = useState('100') + const [slippage, setSlippage] = useState('0.1') + const [feeRate, setFeeRate] = useState('0.1') + const [backtestResults, setBacktestResults] = useState(null) + + const { data: strategies, isLoading: strategiesLoading } = useQuery({ + queryKey: ['strategies'], + queryFn: () => strategiesApi.listStrategies(), + }) + + const { data: generalSettings } = useQuery({ + queryKey: ['general-settings'], + queryFn: () => settingsApi.getGeneralSettings(), + }) + + const backtestMutation = useMutation({ + mutationFn: backtestingApi.runBacktest, + onSuccess: (data) => { + setBacktestResults(data.results) + showSuccess('Backtest completed successfully') + }, + onError: (error: any) => { + showError(error?.response?.data?.detail || error?.message || 'Backtest failed') + }, + }) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!strategyId) { + showError('Please select a strategy') + return + } + const request: BacktestRequest = { + strategy_id: parseInt(strategyId), + symbol, + exchange, + timeframe, + start_date: new Date(startDate).toISOString(), + end_date: new Date(endDate).toISOString(), + initial_capital: parseFloat(initialCapital), + slippage: parseFloat(slippage) / 100, // Convert percentage to decimal + fee_rate: parseFloat(feeRate) / 100, // Convert percentage to decimal + } + backtestMutation.mutate(request) + } + + return ( + + + Backtesting + + + + + Parameter optimization allows you to find the best strategy parameters automatically. + This feature requires backend API support for optimization methods (Grid Search, Genetic Algorithm, Bayesian Optimization). + The optimization UI can be added once the backend endpoints are available. + + + + {backtestMutation.isPending && ( + + )} +
+ + + + Strategy + + + + + setSymbol(e.target.value)} + required + /> + + + setExchange(e.target.value)} + required + /> + + + + Timeframe + + + + + setStartDate(e.target.value)} + required + InputLabelProps={{ shrink: true }} + /> + + + setEndDate(e.target.value)} + required + InputLabelProps={{ shrink: true }} + /> + + + setInitialCapital(e.target.value)} + required + inputProps={{ step: '0.01', min: 0 }} + /> + + + setSlippage(e.target.value)} + required + inputProps={{ step: '0.01', min: 0, max: 10 }} + helperText="Percentage slippage per trade (e.g., 0.1 for 0.1%)" + /> + + + setFeeRate(e.target.value)} + required + inputProps={{ step: '0.01', min: 0, max: 10 }} + helperText="Trading fee percentage (e.g., 0.1 for 0.1%)" + /> + + + + + +
+ + {backtestMutation.isError && ( + + {backtestMutation.error instanceof Error + ? backtestMutation.error.message + : 'Backtest failed'} + + )} + + {/* Operations Panel */} + {backtestMutation.isPending && ( + + + + )} + + {backtestMutation.isSuccess && backtestResults && ( + + + Backtest completed successfully! + + + {/* Results Metrics */} + + + Performance Metrics + + + + + + + Total Return + + = 0 ? 'success.main' : 'error.main', + }} + > + {((backtestResults.total_return || 0) * 100).toFixed(2)}% + + + + + + + + + Sharpe Ratio + + + {(backtestResults.sharpe_ratio || 0).toFixed(2)} + + + + + + + + + Sortino Ratio + + + {(backtestResults.sortino_ratio || 0).toFixed(2)} + + + + + + + + + Max Drawdown + + + {((backtestResults.max_drawdown || 0) * 100).toFixed(2)}% + + + + + + + + + Win Rate + + + {((backtestResults.win_rate || 0) * 100).toFixed(1)}% + + + + + + + + + Total Trades + + + {backtestResults.total_trades || 0} + + + + + + + + + Final Value + + + ${(backtestResults.final_value || 0).toFixed(2)} + + + + + + + + + Initial Capital + + + ${(backtestResults.initial_capital || parseFloat(initialCapital)).toFixed(2)} + + + + + + + + {/* Export Buttons */} + + + + + + + + {/* Trades Table */} + {backtestResults.trades && backtestResults.trades.length > 0 && ( + + + Trades ({backtestResults.trades.length}) + + + + + + Time + Side + Price + Quantity + Value + + + + {backtestResults.trades.slice(0, 100).map((trade: any, index: number) => ( + + + {formatDate(trade.timestamp || '', generalSettings)} + + + + {trade.side?.toUpperCase() || 'N/A'} + + + ${(trade.price || 0).toFixed(2)} + {(trade.quantity || 0).toFixed(8)} + + ${((trade.price || 0) * (trade.quantity || 0)).toFixed(2)} + + + ))} + {backtestResults.trades.length > 100 && ( + + + + Showing first 100 trades of {backtestResults.trades.length} total + + + + )} + +
+
+
+ )} +
+ )} +
+
+ ) +} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx new file mode 100644 index 00000000..f25e7b89 --- /dev/null +++ b/frontend/src/pages/DashboardPage.tsx @@ -0,0 +1,733 @@ +import { useState, useEffect } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { + Box, + Paper, + Typography, + Grid, + Button, + TextField, + Chip, + Card, + CardContent, + Divider, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Switch, + FormControlLabel, + Autocomplete, +} from '@mui/material' +import { + PlayArrow, + Stop, + Refresh, + Psychology, + AutoMode, + TrendingUp, + TrendingDown, +} from '@mui/icons-material' +import { autopilotApi } from '../api/autopilot' +import { tradingApi } from '../api/trading' +import { strategiesApi } from '../api/strategies' +import { marketDataApi } from '../api/marketData' +import Chart from '../components/Chart' +import { OrderSide, OrderStatus, PositionResponse, OrderResponse } from '../types' +import { useSnackbar } from '../contexts/SnackbarContext' +import { useAutopilotSettings } from '../contexts/AutopilotSettingsContext' +import { useWebSocketContext } from '../components/WebSocketProvider' +import SystemHealth from '../components/SystemHealth' +import StatusIndicator from '../components/StatusIndicator' +import DataFreshness from '../components/DataFreshness' +import LoadingSkeleton from '../components/LoadingSkeleton' +import ErrorDisplay from '../components/ErrorDisplay' +import OperationsPanel from '../components/OperationsPanel' +import RealtimePrice from '../components/RealtimePrice' +import ProviderStatusDisplay from '../components/ProviderStatus' +import ChartGrid from '../components/ChartGrid' +import { formatTime } from '../utils/formatters' +import { settingsApi } from '../api/settings' + +const CRYPTO_PAIRS = [ + 'BTC/USD', + 'ETH/USD', + 'BTC/USDT', + 'ETH/USDT', + 'SOL/USD', + 'ADA/USD', + 'XRP/USD', + 'DOGE/USD', + 'DOT/USD', + 'MATIC/USD', + 'AVAX/USD', + 'LINK/USD', + 'AVAX/USD', + 'SOL/USD', +] + +export default function DashboardPage() { + const queryClient = useQueryClient() + const { showSuccess, showError } = useSnackbar() + const { isConnected: wsConnected } = useWebSocketContext() + + // Core state + const [selectedSymbols, setSelectedSymbols] = useState(['BTC/USD']) + const [symbol, setSymbol] = useState('BTC/USD') // Primary symbol for chart display + const [paperTrading, setPaperTrading] = useState(true) + const [optimisticRunning, setOptimisticRunning] = useState(null) // For immediate UI feedback + + // Autopilot settings from context + const autopilotSettings = useAutopilotSettings() + const autoExecute = autopilotSettings.autoExecute + const timeframe = autopilotSettings.timeframe + const autopilotMode = 'intelligent' as const // Only intelligent mode is supported + + // Unified Autopilot Status Query (intelligent mode only) + const { data: unifiedStatus, error: unifiedStatusError } = useQuery({ + queryKey: ['unified-autopilot-status', symbol, timeframe], + queryFn: () => autopilotApi.getUnifiedStatus(symbol, autopilotMode, timeframe), + refetchInterval: 5000, + enabled: true, + }) + + // Use unified status for intelligent mode + const status = unifiedStatus + const statusError = unifiedStatusError + + // Market Data Query + const { data: ohlcv, isLoading: isLoadingOHLCV } = useQuery({ + queryKey: ['market-data', symbol], + queryFn: () => marketDataApi.getOHLCV(symbol), + refetchInterval: 60000, + retry: false, + }) + + // Positions Query + const { data: positions } = useQuery({ + queryKey: ['positions', paperTrading], + queryFn: () => tradingApi.getPositions(paperTrading), + refetchInterval: 5000, + }) + + // Orders Query + const { data: orders } = useQuery({ + queryKey: ['orders', paperTrading], + queryFn: () => tradingApi.getOrders(paperTrading, 10), + refetchInterval: 5000, + }) + + // General Settings for timezone and display preferences + const { data: generalSettings } = useQuery({ + queryKey: ['general-settings'], + queryFn: () => settingsApi.getGeneralSettings(), + }) + + // Balance Query + const { data: balance } = useQuery({ + queryKey: ['balance', paperTrading], + queryFn: () => tradingApi.getBalance(paperTrading), + refetchInterval: 5000, + }) + + + + // Multi-Symbol Status Query + const { data: multiSymbolStatus } = useQuery({ + queryKey: ['multi-symbol-status', autopilotMode, timeframe], + queryFn: () => autopilotApi.getMultiSymbolStatus(undefined, autopilotMode, timeframe), + refetchInterval: 5000, + }) + + // Running Manual Strategies Query + const { data: runningStrategies } = useQuery({ + queryKey: ['running-strategies'], + queryFn: () => strategiesApi.getRunningStrategies(), + refetchInterval: 5000, + }) + + // Sync selected symbols with running autopilots AND manual strategies + const [hasInitialized, setHasInitialized] = useState(false) + useEffect(() => { + if (!hasInitialized && multiSymbolStatus?.symbols && runningStrategies?.strategies) { + // Get autopilot symbols + const autopilotSymbols = multiSymbolStatus.symbols + .filter(s => s.running) + .map(s => s.symbol) + + // Get manual strategy symbols + const manualSymbols = runningStrategies.strategies + .filter(s => s.running && s.symbol) + .map(s => s.symbol as string) + + // Combine and deduplicate + const allActiveSymbols = Array.from(new Set([...autopilotSymbols, ...manualSymbols])) + + if (allActiveSymbols.length > 0) { + // If current selections are just the default or empty, override them + // Or if we haven't initialized yet + if (!hasInitialized || (selectedSymbols.length === 1 && selectedSymbols[0] === 'BTC/USD')) { + setSelectedSymbols(allActiveSymbols) + setSymbol(allActiveSymbols[0]) // Set primary symbol for chart + } + } + setHasInitialized(true) + } + }, [multiSymbolStatus, runningStrategies, hasInitialized]) + + // Unified Autopilot Mutations + const startUnifiedMutation = useMutation({ + mutationFn: autopilotApi.startUnified, + onMutate: () => { + setOptimisticRunning(true) // Immediately show as running + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['unified-autopilot-status'] }) + queryClient.invalidateQueries({ queryKey: ['autopilot-status'] }) + queryClient.invalidateQueries({ queryKey: ['intelligent-autopilot-status'] }) + showSuccess(`Autopilot started in ${autopilotMode} mode`) + }, + onError: (error: any) => { + setOptimisticRunning(null) // Reset on error + const errorMessage = error?.response?.data?.detail || error?.message || 'Unknown error' + showError(`Failed to start autopilot: ${errorMessage}`) + }, + onSettled: () => { + // Reset optimistic state after query refetches + setTimeout(() => setOptimisticRunning(null), 1000) + }, + }) + + const stopUnifiedMutation = useMutation({ + mutationFn: ({ symbol, mode, timeframe }: { symbol: string; mode: 'intelligent'; timeframe: string }) => + autopilotApi.stopUnified(symbol, mode, timeframe), + onMutate: () => { + setOptimisticRunning(false) // Immediately show as stopped + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['unified-autopilot-status'] }) + queryClient.invalidateQueries({ queryKey: ['autopilot-status'] }) + queryClient.invalidateQueries({ queryKey: ['intelligent-autopilot-status'] }) + showSuccess('Autopilot stopped') + }, + onError: (error: any) => { + setOptimisticRunning(null) // Reset on error + const errorMessage = error?.response?.data?.detail || error?.message || 'Unknown error' + showError(`Failed to stop autopilot: ${errorMessage}`) + }, + onSettled: () => { + setTimeout(() => setOptimisticRunning(null), 1000) + }, + }) + + + + // Multi-symbol mutations + const startMultiSymbolMutation = useMutation({ + mutationFn: autopilotApi.startMultiSymbol, + onMutate: () => { + setOptimisticRunning(true) // Immediately show as running + }, + onSuccess: (data) => { + const started = data.symbols.filter(s => s.status === 'started').length + showSuccess(`Started autopilot for ${started} symbol(s)`) + queryClient.invalidateQueries({ queryKey: ['multi-symbol-status'] }) + }, + onError: (error: any) => { + setOptimisticRunning(null) // Reset on error + showError(`Failed to start: ${error.message}`) + }, + onSettled: () => { + setTimeout(() => setOptimisticRunning(null), 1000) + }, + }) + + const stopMultiSymbolMutation = useMutation({ + mutationFn: ({ symbols, mode, timeframe }: { symbols: string[]; mode: string; timeframe: string }) => + autopilotApi.stopMultiSymbol(symbols, mode, timeframe), + onMutate: () => { + setOptimisticRunning(false) // Immediately show as stopped + }, + onSuccess: (data) => { + showSuccess(`Stopped ${data.symbols.length} symbol(s)`) + queryClient.invalidateQueries({ queryKey: ['multi-symbol-status'] }) + }, + onError: (error: any) => { + setOptimisticRunning(null) // Reset on error + showError(`Failed to stop: ${error.message}`) + }, + onSettled: () => { + setTimeout(() => setOptimisticRunning(null), 1000) + }, + }) + + const handleStartUnifiedAutopilot = () => { + // Use multi-symbol start if multiple symbols selected + if (selectedSymbols.length > 0) { + startMultiSymbolMutation.mutate({ + symbols: selectedSymbols, + mode: 'intelligent', // Enforce intelligent mode + auto_execute: autoExecute, + timeframe, + exchange_id: autopilotSettings.exchange_id, + paper_trading: paperTrading, + interval: autopilotSettings.interval, + }) + } else { + startUnifiedMutation.mutate({ + symbol, + mode: autopilotMode, + auto_execute: autoExecute, + interval: autopilotSettings.interval, + exchange_id: autopilotSettings.exchange_id, + timeframe, + paper_trading: paperTrading, + }) + } + } + + const handleStopUnifiedAutopilot = () => { + // Stop all running symbols + if (multiSymbolStatus && multiSymbolStatus.total_running > 0) { + const runningSymbols = multiSymbolStatus.symbols + .filter(s => s.running) + .map(s => s.symbol) + stopMultiSymbolMutation.mutate({ symbols: runningSymbols, mode: 'intelligent', timeframe }) + } else { + stopUnifiedMutation.mutate({ symbol, mode: 'intelligent', timeframe }) + } + } + + const getStatusColor = (orderStatus: OrderStatus) => { + switch (orderStatus) { + case OrderStatus.FILLED: + return 'success' + case OrderStatus.PENDING: + case OrderStatus.OPEN: + return 'warning' + case OrderStatus.CANCELLED: + case OrderStatus.REJECTED: + return 'error' + default: + return 'default' + } + } + + // Use optimistic state if set, otherwise fall back to server state + const serverRunning = unifiedStatus?.running ?? status?.running ?? false + const isRunning = optimisticRunning !== null ? optimisticRunning : serverRunning + + return ( + + {/* Header with controls */} + + + + Dashboard + + {isRunning && autoExecute && ( + + )} + + + + {/* Multi-Symbol Selector */} + { + setSelectedSymbols(newValue) + if (newValue.length > 0) { + setSymbol(newValue[0]) // Set primary symbol for chart + } + }} + renderTags={(value, getTagProps) => + value.map((option, index) => ( + + )) + } + renderInput={(params) => ( + + )} + sx={{ minWidth: 280 }} + /> + + {/* Paper/Real Toggle */} + setPaperTrading(!e.target.checked)} + color="warning" + /> + } + label={ + + {paperTrading ? 'Paper' : 'Real'} + + } + /> + + {/* AutoPilot Toggle Button */} + + + + + + {/* System Health */} + + + + + + + + System Status + + + + {ohlcv && ohlcv.length > 0 && ( + + )} + + + + + + + + s.running) + .map((s, i) => ({ + id: `autopilot-${s.symbol}-${i}`, + type: 'strategy' as const, + name: `AutoPilot: ${s.symbol} (${s.mode})`, + status: 'running' as const, + startTime: new Date(), + details: s.selected_strategy ? `Strategy: ${s.selected_strategy}` : undefined, + })), + // Show manually running strategies + ...(runningStrategies?.strategies || []) + .filter(s => s.running) + .map((s) => ({ + id: `manual-${s.strategy_id}`, + type: 'strategy' as const, + name: `Manual: ${s.name}`, + status: 'running' as const, + startTime: s.started_at ? new Date(s.started_at) : new Date(), + details: `Symbol: ${s.symbol} | Type: ${s.type}`, + })), + // Fallback for unified single symbol (if not covered above) + ...(isRunning && (!multiSymbolStatus?.total_running) + ? [{ + id: 'autopilot-running', + type: 'strategy' as const, + name: `AutoPilot: ${symbol} (${autopilotMode})`, + status: 'running' as const, + startTime: new Date(), + }] + : []), + ]} + /> + + + + {/* Error Alert */} + {(statusError || unifiedStatusError) && ( + { + queryClient.invalidateQueries({ queryKey: ['autopilot-status'] }) + queryClient.invalidateQueries({ queryKey: ['unified-autopilot-status'] }) + }} + /> + )} + + + {/* Left Panel - ML Autopilot Info */} + + + + + ML Autopilot + + + {/* Active Strategy Display */} + + + Active Strategy + + + + + {status?.selected_strategy + ? status.selected_strategy.replace(/_/g, ' ').replace(/\b\w/g, (l: string) => l.toUpperCase()) + : 'Waiting for analysis...'} + + + + + {/* How it works */} + + The ML model analyzes market conditions and automatically selects the best strategy from 14 available: + + + RSI • MACD • Moving Average • Bollinger • Momentum • DCA • Grid • Pairs Trading • Volatility Breakout • Sentiment • Market Making • Consensus • Confirmed • Divergence + + + Tip: Configure strategy parameters and availability in the Strategy Configurations page. + + + + + {/* Balance Display */} + + Account Balance + + + ${balance?.balance?.toFixed(2) || '0.00'} + + + {/* Positions Summary */} + + Open Positions + + {positions && positions.length > 0 ? ( + + {positions.slice(0, 3).map((pos: PositionResponse) => ( + + {pos.symbol} + = 0 ? 'success.main' : 'error.main', + fontWeight: 500 + }} + > + {pos.unrealized_pnl >= 0 ? '+' : ''}{pos.unrealized_pnl.toFixed(2)} + + + ))} + + ) : ( + No positions + )} + + + + {/* Charts Section */} + 1 ? 9 : 6}> + {selectedSymbols.length > 1 ? ( + // Multi-chart grid for multiple symbols + { }} + isAnalyzing={false} + /> + ) : ( + // Single chart for one symbol + + + + {symbol} + + + + + {isLoadingOHLCV ? ( + + ) : ohlcv && Array.isArray(ohlcv) && ohlcv.length > 0 ? ( + + ) : ( + + No market data available + + )} + + )} + + + {/* Right Panel - Status */} + + + {/* Signal Card */} + + + + Last Signal + {status?.last_signal ? ( + + + {status.last_signal.type === 'buy' ? ( + + ) : ( + + )} + + {status.last_signal.type?.toUpperCase()} + + + + Strength: {(status.last_signal.strength * 100).toFixed(0)}% + + + ) : ( + + No signals yet + + )} + + + + + + + + + {/* Bottom - Recent Orders */} + + + Recent Activity + + + + + Time + Symbol + Side + Type + Quantity + Price + Status + + + + {orders && orders.length > 0 ? ( + orders.slice(0, 5).map((order: OrderResponse) => ( + + {formatTime(order.created_at, generalSettings)} + {order.symbol} + + + + {order.order_type.replace('_', ' ')} + {Number(order.quantity).toFixed(6)} + + {order.price ? `$${Number(order.price).toFixed(2)}` : 'Market'} + + + + + + )) + ) : ( + + + + No recent activity + + + + )} + +
+
+
+
+
+
+ ) +} diff --git a/frontend/src/pages/PortfolioPage.tsx b/frontend/src/pages/PortfolioPage.tsx new file mode 100644 index 00000000..010ca08d --- /dev/null +++ b/frontend/src/pages/PortfolioPage.tsx @@ -0,0 +1,497 @@ +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { + Box, + Grid, + Paper, + Typography, + Card, + CardContent, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tabs, + Tab, + Button, + FormControl, + InputLabel, + Select, + MenuItem, + TextField, + Alert, +} from '@mui/material' +import { Download, Description } from '@mui/icons-material' +import { portfolioApi } from '../api/portfolio' +import { reportingApi } from '../api/reporting' +import { useSnackbar } from '../contexts/SnackbarContext' +import LoadingSkeleton from '../components/LoadingSkeleton' +import DataFreshness from '../components/DataFreshness' +import PositionCard from '../components/PositionCard' +import HelpTooltip from '../components/HelpTooltip' +import { + PieChart, + Pie, + Cell, + ResponsiveContainer, + Legend, + Tooltip, + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, +} from 'recharts' + +interface TabPanelProps { + children?: React.ReactNode + index: number + value: number +} + +function TabPanel(props: TabPanelProps) { + const { children, value, index, ...other } = props + return ( + + ) +} + +export default function PortfolioPage() { + const [tabValue, setTabValue] = useState(0) + const { showError, showSuccess } = useSnackbar() + + const { data: portfolio, isLoading } = useQuery({ + queryKey: ['portfolio'], + queryFn: () => portfolioApi.getCurrentPortfolio(), + refetchInterval: 5000, + }) + + const { data: history } = useQuery({ + queryKey: ['portfolio-history'], + queryFn: () => portfolioApi.getPortfolioHistory(30), + }) + + const { data: riskMetrics } = useQuery({ + queryKey: ['portfolio-risk-metrics'], + queryFn: () => portfolioApi.getRiskMetrics(30), + refetchInterval: 30000, + }) + + const chartData = history + ? history.dates.map((date, i) => ({ + date: new Date(date).toLocaleDateString(), + value: history.values[i], + pnl: history.pnl[i], + })) + : [] + + if (isLoading) { + return + } + + // Calculate allocation data for pie chart + const allocationData = portfolio?.positions.map((pos) => ({ + name: pos.symbol, + value: (pos.quantity ?? 0) * (pos.current_price ?? 0), + })) || [] + + const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884d8', '#82ca9d'] + + return ( + + + Portfolio + {portfolio && } + + + + setTabValue(v)}> + + + + + + + + + {/* Summary Cards */} + {portfolio && ( + <> + + + + + Current Value + + + ${(portfolio.performance?.current_value ?? 0).toFixed(2)} + + + + + + + + + Unrealized P&L + + = 0 ? 'success.main' : 'error.main', + }} + > + ${(portfolio.performance?.unrealized_pnl ?? 0).toFixed(2)} + + + + + + + + + Realized P&L + + = 0 ? 'success.main' : 'error.main', + }} + > + ${(portfolio.performance?.realized_pnl ?? 0).toFixed(2)} + + + + + {history && history.values.length >= 2 && ( + + + + + Daily Change + + = 0 + ? 'success.main' + : 'error.main', + }} + > + {(() => { + const change = history.values[history.values.length - 1] - history.values[history.values.length - 2] + const changePercent = history.values[history.values.length - 2] > 0 + ? (change / history.values[history.values.length - 2]) * 100 + : 0 + return `${changePercent >= 0 ? '+' : ''}${changePercent.toFixed(2)}%` + })()} + + + + + )} + + )} + + {/* Portfolio Chart */} + + + + Portfolio Value History + + + + + + + + + + + + + + + + {/* Risk Metrics */} + {riskMetrics && ( + + + + Risk Metrics (30 days) + + + + + + Sharpe Ratio + {(riskMetrics.sharpe_ratio ?? 0).toFixed(2)} + + + + + + + Sortino Ratio + {(riskMetrics.sortino_ratio ?? 0).toFixed(2)} + + + + + + + Max Drawdown + + {((riskMetrics.max_drawdown ?? 0) * 100).toFixed(2)}% + + + + + + + + Win Rate + {((riskMetrics.win_rate ?? 0) * 100).toFixed(1)}% + + + + + + + Total Return + = 0 ? 'success.main' : 'error.main' }}> + {(riskMetrics.total_return_percent ?? 0).toFixed(2)}% + + + + + + + + )} + + {/* Holdings - Card View */} + + + + + Holdings + + + + {portfolio && portfolio.positions.length > 0 ? ( + + {portfolio.positions.map((pos) => ( + + { + // Position will be closed via PositionCard + }} + /> + + ))} + + ) : ( + + No holdings + + )} + + + + + + + + + Portfolio Allocation + + + {allocationData.length > 0 ? ( + + + `${name} ${(percent * 100).toFixed(0)}%`} + outerRadius={120} + fill="#8884d8" + dataKey="value" + > + {allocationData.map((entry, index) => ( + + ))} + + `$${value.toFixed(2)}`} /> + + + + ) : ( + + No allocation data available + + )} + + + + + + + + ) +} + +// Reports Section (merged from ReportingPage) +function ReportsSection({ showError, showSuccess }: { showError: (msg: string) => void; showSuccess: (msg: string) => void }) { + const [taxMethod, setTaxMethod] = useState('fifo') + const [taxYear, setTaxYear] = useState(new Date().getFullYear()) + const [taxSymbol, setTaxSymbol] = useState('') + + const handleExportTrades = async () => { + try { + const blob = await reportingApi.exportTradesCSV() + const url = window.URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `trades_${new Date().toISOString().split('T')[0]}.csv` + document.body.appendChild(a) + a.click() + window.URL.revokeObjectURL(url) + document.body.removeChild(a) + showSuccess('Trades exported successfully') + } catch (error) { + showError('Failed to export trades: ' + (error instanceof Error ? error.message : 'Unknown error')) + } + } + + const handleExportPortfolio = async () => { + try { + const blob = await reportingApi.exportPortfolioCSV() + const url = window.URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `portfolio_${new Date().toISOString().split('T')[0]}.csv` + document.body.appendChild(a) + a.click() + window.URL.revokeObjectURL(url) + document.body.removeChild(a) + showSuccess('Portfolio exported successfully') + } catch (error) { + showError('Failed to export portfolio: ' + (error instanceof Error ? error.message : 'Unknown error')) + } + } + + const handleExportTaxReport = async () => { + try { + const url = `/api/reporting/tax/${taxMethod}?year=${taxYear}${taxSymbol ? `&symbol=${taxSymbol}` : ''}&paper_trading=true` + const response = await fetch(url) + if (!response.ok) throw new Error('Failed to generate tax report') + const blob = await response.blob() + const downloadUrl = window.URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = downloadUrl + a.download = `tax_report_${taxMethod}_${taxYear}_${new Date().toISOString().split('T')[0]}.csv` + document.body.appendChild(a) + a.click() + window.URL.revokeObjectURL(downloadUrl) + document.body.removeChild(a) + showSuccess('Tax report generated successfully') + } catch (error) { + showError('Failed to export tax report: ' + (error instanceof Error ? error.message : 'Unknown error')) + } + } + + return ( + + {/* Export Buttons */} + + + + + + Export Trades + + + Export all trading history to CSV format. + + + + + + + + + + + + Export Portfolio + + + Export current portfolio holdings to CSV format. + + + + + + + {/* Tax Reporting */} + + + Tax Reporting + + Generate tax reports using FIFO or LIFO cost basis methods. Consult a tax professional for accurate reporting. + + + + + + Tax Method + + + + + setTaxYear(parseInt(e.target.value) || new Date().getFullYear())} + inputProps={{ min: 2020, max: new Date().getFullYear() }} + /> + + + setTaxSymbol(e.target.value)} + placeholder="BTC/USD or leave empty for all" + /> + + + + + + + + + ) +} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx new file mode 100644 index 00000000..4a303d64 --- /dev/null +++ b/frontend/src/pages/SettingsPage.tsx @@ -0,0 +1,1744 @@ +import { useState, useEffect } from 'react' +import { useWebSocketContext } from '../components/WebSocketProvider' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { + Box, + Paper, + Typography, + Tabs, + Tab, + TextField, + Button, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Chip, + Alert as MuiAlert, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + FormControl, + InputLabel, + Select, + MenuItem, + Checkbox, + FormControlLabel, + Grid, + IconButton, + Switch, + CircularProgress, + LinearProgress, + Divider, + Card, + CardContent, + Autocomplete, +} from '@mui/material' +import { Add, Edit, Delete, CheckCircle, NotificationsActive, NotificationsOff, ExpandMore, ExpandLess, Psychology, ArrowUpward, ArrowDownward } from '@mui/icons-material' + +import { settingsApi, ExchangeCreate, GeneralSettings } from '../api/settings' +import { exchangesApi } from '../api/exchanges' +import { alertsApi, AlertResponse, AlertCreate, AlertUpdate } from '../api/alerts' +import { useSnackbar } from '../contexts/SnackbarContext' +import { useAutopilotSettings, useUpdateAutopilotSettings } from '../contexts/AutopilotSettingsContext' +import { autopilotApi } from '../api/autopilot' +import AlertHistory from '../components/AlertHistory' +import StatusIndicator from '../components/StatusIndicator' + +import ProviderStatusDisplay from '../components/ProviderStatus' +import { marketDataApi } from '../api/marketData' + +interface TabPanelProps { + children?: React.ReactNode + index: number + value: number +} + +function TabPanel(props: TabPanelProps) { + const { children, value, index, ...other } = props + return ( + + ) +} + +export default function SettingsPage() { + const [tabValue, setTabValue] = useState(0) + const [exchangeDialogOpen, setExchangeDialogOpen] = useState(false) + const [editingExchange, setEditingExchange] = useState(null) + const queryClient = useQueryClient() + const { showSuccess, showError, showWarning } = useSnackbar() + + // Risk Settings + const { data: riskSettings } = useQuery({ + queryKey: ['risk-settings'], + queryFn: settingsApi.getRiskSettings, + }) + + const riskMutation = useMutation({ + mutationFn: settingsApi.updateRiskSettings, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['risk-settings'] }) + showSuccess('Risk settings saved successfully') + }, + }) + + // Paper Trading Settings + const { data: paperSettings } = useQuery({ + queryKey: ['paper-settings'], + queryFn: settingsApi.getPaperTradingSettings, + }) + + const paperMutation = useMutation({ + mutationFn: settingsApi.updatePaperTradingSettings, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['paper-settings'] }) + showSuccess('Paper trading settings saved successfully') + }, + }) + + const resetPaperMutation = useMutation({ + mutationFn: settingsApi.resetPaperAccount, + onSuccess: () => { + showSuccess('Paper account reset successfully') + }, + }) + + // Logging Settings + const { data: loggingSettings } = useQuery({ + queryKey: ['logging-settings'], + queryFn: settingsApi.getLoggingSettings, + }) + + const loggingMutation = useMutation({ + mutationFn: settingsApi.updateLoggingSettings, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['logging-settings'] }) + showSuccess('Logging settings saved successfully') + }, + }) + + // Exchanges + const { data: exchanges } = useQuery({ + queryKey: ['exchanges'], + queryFn: exchangesApi.listExchanges, + }) + + const testConnectionMutation = useMutation({ + mutationFn: settingsApi.testExchangeConnection, + }) + + const deleteExchangeMutation = useMutation({ + mutationFn: settingsApi.deleteExchange, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['exchanges'] }) + }, + }) + + const handleSaveRisk = () => { + if (!riskSettings) return + riskMutation.mutate(riskSettings) + } + + const handleSavePaper = () => { + if (!paperSettings) return + paperMutation.mutate(paperSettings) + } + + const handleSaveLogging = () => { + if (!loggingSettings) return + loggingMutation.mutate(loggingSettings) + } + + // General Settings + const { data: generalSettings } = useQuery({ + queryKey: ['general-settings'], + queryFn: settingsApi.getGeneralSettings, + }) + + const generalMutation = useMutation({ + mutationFn: settingsApi.updateGeneralSettings, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['general-settings'] }) + showSuccess('General settings saved successfully') + }, + }) + + const handleSaveGeneral = () => { + if (!generalSettings) return + generalMutation.mutate(generalSettings) + } + + const handleAddExchange = () => { + setEditingExchange(null) + setExchangeDialogOpen(true) + } + + const handleEditExchange = (exchange: any) => { + setEditingExchange(exchange) + setExchangeDialogOpen(true) + } + + const handleDeleteExchange = (exchangeId: number) => { + if (window.confirm('Are you sure you want to delete this exchange?')) { + deleteExchangeMutation.mutate(exchangeId) + showSuccess('Exchange deleted successfully') + } + } + + const handleTestConnection = (exchangeId: number) => { + testConnectionMutation.mutate(exchangeId, { + onSuccess: (data) => { + if (data.message?.includes('success') || data.message?.includes('connected')) { + showSuccess(data.message || 'Connection test completed successfully') + } else { + showWarning(data.message || 'Connection test completed with warnings') + } + }, + onError: (error: any) => { + showError(error?.response?.data?.detail || error?.message || 'Connection test failed') + }, + }) + } + + return ( + + + Settings + + + + setTabValue(v)} variant="scrollable" scrollButtons="auto"> + + + + + + + + + + + + {/* General Tab */} + + queryClient.setQueryData(['general-settings'], newSettings)} + /> + + + {/* Exchanges Tab */} + + + Exchange Management + + + + + + + Name + Status + Mode + Read-Only + Actions + + + + {exchanges && exchanges.length > 0 ? ( + exchanges.map((exchange: any) => ( + + {exchange.name} + + + + {exchange.sandbox ? 'Sandbox' : 'Live'} + {exchange.read_only ? 'Yes' : 'No'} + + handleEditExchange(exchange)}> + + + handleTestConnection(exchange.id)}> + + + handleDeleteExchange(exchange.id)}> + + + + + )) + ) : ( + + + No exchanges configured + + + )} + +
+
+
+ + {/* Data Providers Tab */} + + + + + {/* Risk Management Tab */} + + + Risk Management Settings + + {riskSettings && ( + + + { + const newSettings = { ...riskSettings, max_drawdown_percent: parseFloat(e.target.value) } + queryClient.setQueryData(['risk-settings'], newSettings) + }} + inputProps={{ step: 0.1, min: 0.1, max: 100 }} + /> + + + { + const newSettings = { ...riskSettings, daily_loss_limit_percent: parseFloat(e.target.value) } + queryClient.setQueryData(['risk-settings'], newSettings) + }} + inputProps={{ step: 0.1, min: 0.1, max: 100 }} + /> + + + { + const newSettings = { ...riskSettings, position_size_percent: parseFloat(e.target.value) } + queryClient.setQueryData(['risk-settings'], newSettings) + }} + inputProps={{ step: 0.01, min: 0.01, max: 100 }} + /> + + + + + + )} + + + {/* Autopilot Tab */} + + + + + {/* Paper Trading Tab */} + + queryClient.setQueryData(['paper-settings'], newSettings)} + onSave={handleSavePaper} + onReset={() => { + if (confirm('Are you sure you want to reset the paper trading account? All positions will be closed.')) { + resetPaperMutation.mutate() + } + }} + isPending={paperMutation.isPending} + isResetting={resetPaperMutation.isPending} + /> + + + {/* Data & Logging Tab */} + + + Data & Logging Settings + + {loggingSettings && ( + + + + Log Level + + + + + { + const newSettings = { ...loggingSettings, dir: e.target.value } + queryClient.setQueryData(['logging-settings'], newSettings) + }} + /> + + + { + const newSettings = { ...loggingSettings, retention_days: parseInt(e.target.value) } + queryClient.setQueryData(['logging-settings'], newSettings) + }} + inputProps={{ step: 1, min: 1, max: 365 }} + /> + + + + + + )} + + + {/* Alerts Tab */} + + + + + {/* Alert History Tab */} + + + +
+ + {/* Exchange Dialog */} + setExchangeDialogOpen(false)} + exchange={editingExchange} + onSave={() => { + setExchangeDialogOpen(false) + queryClient.invalidateQueries({ queryKey: ['exchanges'] }) + }} + /> +
+ ) +} + +// Paper Trading Settings Section +function PaperTradingSettingsSection({ + paperSettings, + onSettingsChange, + onSave, + onReset, + isPending, + isResetting, +}: { + paperSettings: any + onSettingsChange: (settings: any) => void + onSave: () => void + onReset: () => void + isPending: boolean + isResetting: boolean +}) { + // Get available fee exchanges + const { data: feeExchanges } = useQuery({ + queryKey: ['fee-exchanges'], + queryFn: settingsApi.getFeeExchanges, + }) + + if (!paperSettings) { + return + } + + return ( + + + Paper Trading Settings + + + + {/* Initial Capital */} + + { + onSettingsChange({ ...paperSettings, initial_capital: parseFloat(e.target.value) }) + }} + inputProps={{ step: 100, min: 1, max: 1000000 }} + /> + + + {/* Fee Exchange Selector */} + + + Fee Model (Exchange) + + + + + {/* Fee Rates Display */} + + + + + Current Fee Rates + + + + Maker Fee + + {paperSettings.fee_rates ? (paperSettings.fee_rates.maker * 100).toFixed(2) : '0.10'}% + + + + Taker Fee + + {paperSettings.fee_rates ? (paperSettings.fee_rates.taker * 100).toFixed(2) : '0.10'}% + + + + Est. Round-Trip + + {paperSettings.fee_rates ? ((paperSettings.fee_rates.taker * 2) * 100).toFixed(2) : '0.20'}% + + + + + + + + {/* Actions */} + + + + + + + + + ) +} + +// Data Providers Settings Section +function DataProvidersSection() { + const queryClient = useQueryClient() + const { showSuccess, showError } = useSnackbar() + const [showDetails, setShowDetails] = useState(false) + + const { data: config, isLoading: configLoading } = useQuery({ + queryKey: ['provider-config'], + queryFn: marketDataApi.getProviderConfig, + }) + + const { data: status } = useQuery({ + queryKey: ['provider-status'], + queryFn: marketDataApi.getProviderStatus, + refetchInterval: 5000, + }) + + const updateConfigMutation = useMutation({ + mutationFn: marketDataApi.updateProviderConfig, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['provider-config'] }) + queryClient.invalidateQueries({ queryKey: ['provider-status'] }) + showSuccess('Provider configuration updated successfully') + }, + onError: (error: any) => { + showError(`Failed to update configuration: ${error.message}`) + }, + }) + + const handleToggleProvider = (providerName: string, enabled: boolean) => { + if (!config) return + + const updatedConfig = { ...config } + const providerIndex = updatedConfig.primary.findIndex((p: any) => p.name === providerName) + + if (providerIndex >= 0) { + updatedConfig.primary[providerIndex] = { + ...updatedConfig.primary[providerIndex], + enabled, + } + updateConfigMutation.mutate(updatedConfig) + } + } + + const handleMoveProvider = (providerName: string, direction: 'up' | 'down') => { + if (!config) return + + const updatedConfig = { ...config } + const providerIndex = updatedConfig.primary.findIndex((p: any) => p.name === providerName) + + if (providerIndex < 0) return + + const newIndex = direction === 'up' ? providerIndex - 1 : providerIndex + 1 + + if (newIndex >= 0 && newIndex < updatedConfig.primary.length) { + const [moved] = updatedConfig.primary.splice(providerIndex, 1) + updatedConfig.primary.splice(newIndex, 0, moved) + + // Update priorities + updatedConfig.primary = updatedConfig.primary.map((p: any, idx: number) => ({ + ...p, + priority: idx + 1, + })) + + updateConfigMutation.mutate(updatedConfig) + } + } + + if (configLoading) { + return + } + + if (!config) { + return Failed to load provider configuration + } + + return ( + + + Data Provider Configuration + + + + + Data providers are used to fetch real-time pricing data for paper trading, backtesting, and ML training. + They work independently of exchange integrations and don't require API keys. + + + {/* Provider Status Overview */} + {status && ( + + + + )} + + {/* Primary Providers */} + + Primary Providers (CCXT) + + + + + + Priority + Provider + Status + Enabled + Actions + + + + {config.primary.map((provider: any, index: number) => { + const providerStatus = status?.providers[provider.name] || status?.providers[`CCXT-${provider.name.charAt(0).toUpperCase() + provider.name.slice(1)}`] + const healthStatus = providerStatus?.status || 'unknown' + + return ( + + + + + + + {provider.name.charAt(0).toUpperCase() + provider.name.slice(1)} + + + + + {showDetails && providerStatus && ( + + Avg: {providerStatus.avg_response_time?.toFixed(3)}s + + )} + + + handleToggleProvider(provider.name, e.target.checked)} + size="small" + /> + + + handleMoveProvider(provider.name, 'up')} + disabled={index === 0} + > + + + handleMoveProvider(provider.name, 'down')} + disabled={index === config.primary.length - 1} + > + + + + + ) + })} + +
+
+ + {/* Fallback Provider */} + + Fallback Provider + + + + + + + {config.fallback.name.charAt(0).toUpperCase() + config.fallback.name.slice(1)} + + + Used when primary providers are unavailable + + + { + const updatedConfig = { + ...config, + fallback: { ...config.fallback, enabled: e.target.checked }, + } + updateConfigMutation.mutate(updatedConfig) + }} + /> + + + + + {/* Cache Configuration */} + + Cache Configuration + + + + { + const updatedConfig = { + ...config, + caching: { + ...config.caching, + ticker_ttl: parseInt(e.target.value) || 2, + }, + } + updateConfigMutation.mutate(updatedConfig) + }} + inputProps={{ min: 1, max: 60 }} + /> + + + { + const updatedConfig = { + ...config, + caching: { + ...config.caching, + ohlcv_ttl: parseInt(e.target.value) || 60, + }, + } + updateConfigMutation.mutate(updatedConfig) + }} + inputProps={{ min: 1, max: 3600 }} + /> + + +
+ ) +} + +// Autopilot Settings Section +function AutopilotSettingsSection() { + const queryClient = useQueryClient() + const { showSuccess, showError, showWarning, showInfo } = useSnackbar() + const settings = useAutopilotSettings() + const updateSettings = useUpdateAutopilotSettings() + + const [trainingProgress, setTrainingProgress] = useState<{ + step: string + progress: number + total: number + percent: number + message: string + } | null>(null) + + // Listen for training progress via WebSocket (using global context) + const { subscribe } = useWebSocketContext() + + useEffect(() => { + const unsubscribe = subscribe('training_progress', (data: any) => { + setTrainingProgress({ + step: data.step, + progress: data.progress, + total: data.total, + percent: data.percent, + message: data.message, + }) + // Clear progress after completion or error + if (data.step === 'complete' || data.step === 'error') { + setTimeout(() => setTrainingProgress(null), 5000) + } + }) + return unsubscribe + }, [subscribe]) + + + + + // Model Info Query (for intelligent mode) + const { data: modelInfo } = useQuery({ + queryKey: ['model-info'], + queryFn: () => autopilotApi.getModelInfo(), + refetchInterval: 30000, + enabled: true, + }) + + // Bootstrap Config Query + const { data: serverBootstrapConfig } = useQuery({ + queryKey: ['bootstrap-config'], + queryFn: () => autopilotApi.getBootstrapConfig(), + enabled: true, + }) + + // Local state for editing bootstrap config + const [localBootstrapConfig, setLocalBootstrapConfig] = useState<{ + days: number + timeframe: string + min_samples_per_strategy: number + symbols: string[] + } | null>(null) + + // Sync local state when server data loads + useEffect(() => { + if (serverBootstrapConfig && !localBootstrapConfig) { + setLocalBootstrapConfig(serverBootstrapConfig) + } + }, [serverBootstrapConfig, localBootstrapConfig]) + + // Use local state if available, otherwise fall back to server data + const bootstrapConfig = localBootstrapConfig || serverBootstrapConfig || { + days: 90, + timeframe: '1h', + min_samples_per_strategy: 10, + symbols: ['BTC/USD', 'ETH/USD'] + } + + const updateBootstrapMutation = useMutation({ + mutationFn: autopilotApi.updateBootstrapConfig, + onSuccess: () => { + setLocalBootstrapConfig(null) // Reset local state to allow re-sync with server + queryClient.invalidateQueries({ queryKey: ['bootstrap-config'] }) + showSuccess('Bootstrap configuration saved') + }, + onError: (error: any) => { + showError(`Failed to save: ${error.message}`) + }, + }) + + // Track current training task (persist across navigation) + const [currentTaskId, setCurrentTaskId] = useState(() => { + return localStorage.getItem('active_training_task_id') + }) + const [pollingErrorCount, setPollingErrorCount] = useState(0) + + // Persist task ID changes + useEffect(() => { + if (currentTaskId) { + localStorage.setItem('active_training_task_id', currentTaskId) + } else { + localStorage.removeItem('active_training_task_id') + setPollingErrorCount(0) + } + }, [currentTaskId]) + + // Poll for task status + const { data: taskStatusData, error: taskStatusError } = useQuery({ + queryKey: ['task-status', currentTaskId], + queryFn: () => autopilotApi.getTaskStatus(currentTaskId!), + enabled: !!currentTaskId, + refetchInterval: 1000, // Poll every second + refetchIntervalInBackground: true, + }) + + // Effect to handle polling updates - react to taskStatusData changes + useEffect(() => { + if (!currentTaskId) return + + // Handle errors + if (taskStatusError) { + console.error("Polling error:", taskStatusError) + setPollingErrorCount(prev => { + const newCount = prev + 1 + if (newCount >= 3) { + setCurrentTaskId(null) + setTrainingProgress(null) + showError("Lost connection to training task. Please check logs.") + } + return newCount + }) + return + } + + const data = taskStatusData + if (!data) return + + // Reset error count on successful fetch + setPollingErrorCount(0) + + if (!data) return + + // Reset error count on successful fetch + setPollingErrorCount(0) + + // Update progress if available + if (data.status === 'PROGRESS' && data.meta) { + setTrainingProgress({ + step: data.meta.step, + progress: data.meta.progress, + total: 100, // Normalized to 100% + percent: data.meta.progress, + message: data.meta.message, + }) + } else if (data.status === 'PENDING' || data.status === 'STARTED') { + setTrainingProgress({ + step: 'pending', + progress: 0, + total: 100, + percent: 0, + message: 'Initializing training task...', + }) + } + + // Handle completion + if (data.status === 'SUCCESS') { + setCurrentTaskId(null) // This triggers the useEffect to clear localStorage + queryClient.invalidateQueries({ queryKey: ['model-info'] }) + + const result = data.result || {} + + // Clear progress after short delay + setTrainingProgress({ + step: 'complete', + progress: 100, + total: 100, + percent: 100, + message: 'Training completed successfully!' + }) + setTimeout(() => setTrainingProgress(null), 5000) + + if (result.training_results) { + const metrics = result.training_results.metrics || {} + const accuracy = metrics.test_accuracy || metrics.test_rmse || 0 + const accuracyText = metrics.test_accuracy + ? `Accuracy: ${(accuracy * 100).toFixed(1)}%` + : metrics.test_rmse + ? `RMSE: ${accuracy.toFixed(4)}` + : '' + showSuccess(`Model retrained successfully! ${accuracyText}`) + } else { + showSuccess('Model retrained successfully!') + } + } else if (data.status === 'FAILURE') { + setCurrentTaskId(null) + setTrainingProgress({ + step: 'error', + progress: 100, + total: 100, + percent: 100, + message: 'Training failed' + }) + setTimeout(() => setTrainingProgress(null), 5000) // Keep error visible for a bit + const errorMessage = data.result?.error || data.result?.detail || data.meta?.error || 'Unknown error' + showError(`Training failed: ${errorMessage}`) + } + }, [currentTaskId, taskStatusData, taskStatusError, queryClient, showSuccess, showError]) + + const retrainModelMutation = useMutation({ + mutationFn: autopilotApi.retrainModel, + onSuccess: (data) => { + // Just set the task ID to start polling + if (data.task_id) { + setCurrentTaskId(data.task_id) + showInfo('Retraining started in background...') + } else { + // Fallback for immediate response (legacy) + if (data.status === 'success') { + showSuccess('Retraining complete') + } + } + }, + onError: (error: any) => { + const errorMessage = error?.response?.data?.detail || error?.message || 'Unknown error' + showError(`Failed to start retraining: ${errorMessage}`) + }, + }) + + + const resetModelMutation = useMutation({ + mutationFn: autopilotApi.resetModel, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['model-info'] }) + showSuccess(data.message || 'Model reset successfully') + }, + onError: (error: any) => { + const errorMessage = error?.response?.data?.detail || error?.message || 'Unknown error' + showError(`Failed to reset model: ${errorMessage}`) + }, + }) + + const handleAutoExecuteToggle = (checked: boolean) => { + updateSettings({ autoExecute: checked }) + if (checked) { + showWarning('Auto-execution enabled: Trades will be executed automatically!') + } + } + + const handleRetrainModel = () => { + retrainModelMutation.mutate(true) + } + + const handleResetModel = () => { + if (window.confirm('Are you sure you want to reset the model? This will delete all saved model files.')) { + resetModelMutation.mutate() + } + } + + return ( + + + Autopilot Configuration + + + + + Autopilot running in Intelligent (ML-Driven) Mode. + + + + + + {/* Auto-Execution Toggle */} + + + Auto-Execution + + handleAutoExecuteToggle(e.target.checked)} + color="warning" + /> + } + label={ + + + Enable Auto-Execution {settings.autoExecute && '⚠️'} + + + Automatically execute trades based on autopilot signals + + + } + /> + + + + + {/* Pattern Mode Settings */} + + + {/* Intelligent Mode Settings */} + + + + Intelligent Mode Settings + + + + + Timeframe + + + + + updateSettings({ exchange_id: parseInt(e.target.value) || 1 })} + inputProps={{ step: 1, min: 1 }} + helperText="ID of the exchange to use" + /> + + + + + + {/* Bootstrap Configuration */} + + + Training Data Configuration + + + Configure how much historical data to fetch when training the ML model. + + {bootstrapConfig && ( + + + { + setLocalBootstrapConfig({ ...bootstrapConfig, days: parseInt(e.target.value) || 90 }) + }} + inputProps={{ step: 1, min: 7, max: 365 }} + helperText="Days of data to fetch (more = better training)" + /> + + + + Timeframe + + + + + { + setLocalBootstrapConfig({ ...bootstrapConfig, min_samples_per_strategy: parseInt(e.target.value) || 10 }) + }} + inputProps={{ step: 1, min: 5, max: 100 }} + helperText="Minimum training samples required per strategy (30+ recommended)" + /> + + + { + setLocalBootstrapConfig({ ...bootstrapConfig, symbols: newValue as string[] }) + }} + renderTags={(value, getTagProps) => + value.map((option, index) => ( + + )) + } + renderInput={(params) => ( + + )} + /> + + + + + + ) + } + + + + + {/* Model Management */} + + + Model Management + + + + {modelInfo?.is_trained ? ( + + + + Strategies: {modelInfo?.available_strategies?.length || 0} + + + Features: {modelInfo?.feature_count || 0} + + {modelInfo?.training_metadata && ( + <> + + Accuracy: {(modelInfo.training_metadata.metrics?.test_accuracy * 100 || 0).toFixed(1)}% + + + Trained on: { + modelInfo.training_metadata.training_symbols && modelInfo.training_metadata.training_symbols.length > 0 + ? modelInfo.training_metadata.training_symbols.join(', ') + : 'Shared Data' + } + + + )} + + ) : ( + + Model not trained. Using fallback selection. + + )} + + + + {/* Training Progress Display */} + {trainingProgress && ( + + + + {trainingProgress.message} + + + {trainingProgress.progress}/{trainingProgress.total} + + + + + )} + + + + + + Retrain the ML model with latest data, or reset to start fresh + + + + + + + + + + ) +} + + + +function GeneralSettingsSection({ settings, onSave, isPending, onSettingsChange }: { + settings?: GeneralSettings, + onSave: () => void, + isPending: boolean, + onSettingsChange: (settings: GeneralSettings) => void +}) { + const timezones = (Intl as any).supportedValuesOf ? (Intl as any).supportedValuesOf('timeZone') : ['UTC', 'America/New_York', 'Europe/London', 'Asia/Tokyo']; + + return ( + + + General Settings + + + Configure your display preferences, including timezone and theme. + + + {settings && ( + + + + Display Timezone + + + + Used to format all timestamps in the application. + + + + + + Theme Preference + + + + + + + Display Currency + + + + + + + + + )} + + ) +} + +function AlertsSection() { + const queryClient = useQueryClient() + const { showError } = useSnackbar() + const [dialogOpen, setDialogOpen] = useState(false) + const [editingAlert, setEditingAlert] = useState(null) + const [formData, setFormData] = useState({ + name: '', + alert_type: 'price', + condition: {}, + }) + + const { data: alerts, isLoading } = useQuery({ + queryKey: ['alerts'], + queryFn: () => alertsApi.listAlerts(), + }) + + const createMutation = useMutation({ + mutationFn: alertsApi.createAlert, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['alerts'] }) + setDialogOpen(false) + resetForm() + }, + }) + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: number; data: AlertUpdate }) => + alertsApi.updateAlert(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['alerts'] }) + setDialogOpen(false) + resetForm() + }, + }) + + const deleteMutation = useMutation({ + mutationFn: alertsApi.deleteAlert, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['alerts'] }) + }, + }) + + const toggleMutation = useMutation({ + mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) => + alertsApi.updateAlert(id, { enabled }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['alerts'] }) + }, + }) + + const resetForm = () => { + setFormData({ name: '', alert_type: 'price', condition: {} }) + setEditingAlert(null) + } + + const handleOpenDialog = (alertItem?: AlertResponse) => { + if (alertItem) { + setEditingAlert(alertItem) + setFormData({ + name: alertItem.name, + alert_type: alertItem.alert_type, + condition: alertItem.condition || {}, + }) + } else { + resetForm() + } + setDialogOpen(true) + } + + const handleSave = () => { + if (!formData.name) { + showError('Name is required') + return + } + if (editingAlert) { + updateMutation.mutate({ id: editingAlert.id, data: { name: formData.name, condition: formData.condition } }) + } else { + createMutation.mutate(formData) + } + } + + const handleDelete = (alertId: number) => { + if (window.confirm('Are you sure you want to delete this alert?')) { + deleteMutation.mutate(alertId) + } + } + + const handleToggle = (alertItem: AlertResponse) => { + toggleMutation.mutate({ id: alertItem.id, enabled: !alertItem.enabled }) + } + + const getConditionDescription = (condition: Record) => { + if (!condition || Object.keys(condition).length === 0) return 'No condition set' + const parts: string[] = [] + if (condition.symbol) parts.push(`Symbol: ${condition.symbol}`) + if (condition.price_threshold) parts.push(`Price: $${condition.price_threshold}`) + if (condition.percentage_change) parts.push(`Change: ${condition.percentage_change}%`) + return parts.join(', ') || JSON.stringify(condition) + } + + const getConditionFields = () => { + const condition = formData.condition || {} + if (formData.alert_type === 'price') { + return ( + <> + + setFormData((prev) => ({ ...prev, condition: { ...prev.condition, symbol: e.target.value } }))} + placeholder="BTC/USD" + /> + + + + Condition + + + + + setFormData((prev) => ({ ...prev, condition: { ...prev.condition, price_threshold: parseFloat(e.target.value) || 0 } }))} + /> + + + ) + } + return null + } + + return ( + + + Price & System Alerts + + + + + + + + Name + Type + Condition + Status + Actions + + + + {isLoading ? ( + Loading... + ) : alerts && alerts.length > 0 ? ( + alerts.map((alertItem: AlertResponse) => ( + + {alertItem.name} + + {getConditionDescription(alertItem.condition)} + + + + + handleToggle(alertItem)}> + {alertItem.enabled ? : } + + handleOpenDialog(alertItem)}> + handleDelete(alertItem.id)}> + + + )) + ) : ( + No alerts configured + )} + +
+
+ + setDialogOpen(false)} maxWidth="md" fullWidth> + {editingAlert ? 'Edit Alert' : 'Create Alert'} + + + + setFormData((prev) => ({ ...prev, name: e.target.value }))} required /> + + + + Alert Type + + + + {getConditionFields()} + {(createMutation.isError || updateMutation.isError) && ( + + Failed to save alert + + )} + + + + + + + +
+ ) +} + +function ExchangeDialog({ open, onClose, exchange, onSave }: any) { + const [formData, setFormData] = useState({ + name: exchange?.name || '', + api_key: '', + api_secret: '', + sandbox: false, + read_only: true, + enabled: true, + }) + + const createMutation = useMutation({ + mutationFn: settingsApi.createExchange, + onSuccess: onSave, + }) + + const updateMutation = useMutation({ + mutationFn: (data: ExchangeCreate) => settingsApi.updateExchange(exchange.id, data), + onSuccess: onSave, + }) + + const handleSubmit = () => { + if (exchange) { + updateMutation.mutate(formData) + } else { + createMutation.mutate(formData) + } + } + + return ( + + {exchange ? 'Edit Exchange' : 'Add Exchange'} + + + + setFormData({ ...formData, name: e.target.value })} disabled={!!exchange} required /> + + + setFormData({ ...formData, api_key: e.target.value })} /> + + + setFormData({ ...formData, api_secret: e.target.value })} /> + + + setFormData({ ...formData, sandbox: e.target.checked })} />} label="Use Sandbox/Testnet" /> + + + setFormData({ ...formData, read_only: e.target.checked })} />} label="Read-Only Mode" /> + + + setFormData({ ...formData, enabled: e.target.checked })} />} label="Enabled" /> + + + + + + + + + ) +} diff --git a/frontend/src/pages/StrategiesPage.tsx b/frontend/src/pages/StrategiesPage.tsx new file mode 100644 index 00000000..5d2e2187 --- /dev/null +++ b/frontend/src/pages/StrategiesPage.tsx @@ -0,0 +1,402 @@ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { + Box, + Paper, + Typography, + Button, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Chip, + IconButton, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Alert, + CircularProgress, + Tooltip, + Card, + CardContent, + Switch, +} from '@mui/material' +import { + Add, + Edit, + Delete, + Info, + PlayArrow, +} from '@mui/icons-material' +import { strategiesApi } from '../api/strategies' +import { exchangesApi } from '../api/exchanges' +import { StrategyResponse } from '../types' +import StrategyDialog from '../components/StrategyDialog' +import SpreadChart from '../components/SpreadChart' + +export default function StrategiesPage() { + const queryClient = useQueryClient() + const [dialogOpen, setDialogOpen] = useState(false) + const [editingStrategy, setEditingStrategy] = useState(null) + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false) + const [strategyToDelete, setStrategyToDelete] = useState(null) + + const { data: strategies, isLoading } = useQuery({ + queryKey: ['strategies'], + queryFn: () => strategiesApi.listStrategies(), + }) + + const { data: exchanges } = useQuery({ + queryKey: ['exchanges'], + queryFn: () => exchangesApi.listExchanges(), + }) + + // Query for real-time status of running strategies + const { data: runningStatus } = useQuery({ + queryKey: ['running-strategies'], + queryFn: () => strategiesApi.getRunningStrategies(), + refetchInterval: 5000, // Refresh every 5 seconds + }) + + const startMutation = useMutation({ + mutationFn: (id: number) => strategiesApi.startStrategy(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['strategies'] }) + }, + }) + + const stopMutation = useMutation({ + mutationFn: (id: number) => strategiesApi.stopStrategy(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['strategies'] }) + }, + }) + + const deleteMutation = useMutation({ + mutationFn: (id: number) => strategiesApi.deleteStrategy(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['strategies'] }) + setDeleteConfirmOpen(false) + setStrategyToDelete(null) + }, + }) + + const updateMutation = useMutation({ + mutationFn: ({ id, updates }: { id: number; updates: any }) => + strategiesApi.updateStrategy(id, updates), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['strategies'] }) + } + }) + + const handleCreate = () => { + setEditingStrategy(null) + setDialogOpen(true) + } + + const handleEdit = (strategy: StrategyResponse) => { + setEditingStrategy(strategy) + setDialogOpen(true) + } + + const handleDelete = (id: number) => { + setStrategyToDelete(id) + setDeleteConfirmOpen(true) + } + + const handleConfirmDelete = () => { + if (strategyToDelete) { + deleteMutation.mutate(strategyToDelete) + } + } + + const handleStart = (id: number) => { + startMutation.mutate(id) + } + + const handleStop = (id: number) => { + stopMutation.mutate(id) + } + + + + const getStrategyTypeLabel = (type: string) => { + return type + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') + } + + if (isLoading) { + return ( + + + + ) + } + + return ( + + + + Strategy Configurations + + Customize strategies for Autopilot or run them manually + + + + + + } sx={{ mb: 3 }}> + Autopilot = Available for ML selection. Manual Run = Execute immediately (bypasses Autopilot). + + + {strategies && strategies.length === 0 && ( + + + + No strategies created yet. Click "Create Strategy" to get started. + + + + )} + + {strategies && strategies.length > 0 && ( + + + + + + Name + Type + Symbol + Timeframe + Autopilot + Manual Run + Trading Type + Config + + + + {strategies.map((strategy: StrategyResponse) => ( + + + + {strategy.name} + + {strategy.description && ( + + {strategy.description} + + )} + + + + + + {strategy.parameters?.symbol || 'N/A'} + + + {strategy.timeframes?.join(', ') || '1h'} + + + + { + const isEnabling = e.target.checked + // Update enabled state + updateMutation.mutate({ + id: strategy.id, + updates: { enabled: isEnabling } + }) + // Safeguard: If enabling Autopilot and it's manually running, stop it + if (isEnabling && strategy.running) { + handleStop(strategy.id) + } + }} + size="small" + color="success" + /> + + + + + { + const isStarting = e.target.checked + if (isStarting) { + handleStart(strategy.id) + // Safeguard: If starting manually and Autopilot is enabled, disable it + if (strategy.enabled) { + updateMutation.mutate({ + id: strategy.id, + updates: { enabled: false } + }) + } + } else { + handleStop(strategy.id) + } + }} + size="small" + color="primary" + disabled={startMutation.isPending || stopMutation.isPending} + /> + + + + + + + + handleEdit(strategy)} + > + + + + + handleDelete(strategy.id)} + disabled={deleteMutation.isPending} + > + + + + + + ))} + +
+
+
+ )} + + {/* Running Strategies Status */} + {runningStatus && runningStatus.total_running > 0 && ( + + + + + {runningStatus.total_running} Strategy Running + + + {runningStatus.strategies.map((status) => ( + + + + + {status.name} ({status.symbol}) + + + Type: {status.type} | Started: {status.started_at ? new Date(status.started_at).toLocaleTimeString() : 'Unknown'} + + + + 0 ? 'success' : 'default'} + /> + {status.last_signal && ( + + )} + {status.last_tick && ( + + Last tick: {new Date(status.last_tick).toLocaleTimeString()} + + )} + + + + ))} + + )} + + {/* Pairs Trading Visualization */} + {strategies && strategies.some((s: StrategyResponse) => s.strategy_type === 'pairs_trading' && (s.enabled || s.running)) && ( + + + Pairs Trading Analysis + + {strategies + .filter((s: StrategyResponse) => s.strategy_type === 'pairs_trading' && (s.enabled || s.running)) + .map((strategy: StrategyResponse) => ( + + + + ))} + + )} + + { + setDialogOpen(false) + setEditingStrategy(null) + }} + strategy={editingStrategy} + exchanges={exchanges || []} + onSave={() => { + setDialogOpen(false) + setEditingStrategy(null) + queryClient.invalidateQueries({ queryKey: ['strategies'] }) + }} + /> + + setDeleteConfirmOpen(false)}> + Delete Strategy + + + Are you sure you want to delete this strategy? This action cannot be undone. + + + This will permanently delete the strategy and all its configuration. + + + + + + + +
+ ) +} + diff --git a/frontend/src/pages/TradingPage.tsx b/frontend/src/pages/TradingPage.tsx new file mode 100644 index 00000000..d84576dc --- /dev/null +++ b/frontend/src/pages/TradingPage.tsx @@ -0,0 +1,554 @@ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { + Box, + Paper, + Typography, + Button, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Chip, + IconButton, + CircularProgress, + Tooltip, + Tabs, + Tab, + Card, + CardContent, + Grid, + Switch, + FormControlLabel, +} from '@mui/material' +import { + Add, + Cancel, + TrendingUp, + TrendingDown, +} from '@mui/icons-material' +import { tradingApi } from '../api/trading' +import { exchangesApi } from '../api/exchanges' +import { OrderResponse, PositionResponse, OrderSide, OrderStatus } from '../types' +import OrderForm from '../components/OrderForm' +import PositionCard from '../components/PositionCard' +import { settingsApi } from '../api/settings' +import { formatDate } from '../utils/formatters' +import { + Checkbox, + TextField, + InputAdornment, + ButtonGroup, + Divider, +} from '@mui/material' +import { + Search, + DeleteSweep, + ClearAll, +} from '@mui/icons-material' +import { useSnackbar } from '../contexts/SnackbarContext' + +interface TabPanelProps { + children?: React.ReactNode + index: number + value: number +} + +function TabPanel(props: TabPanelProps) { + const { children, value, index, ...other } = props + return ( + + ) +} + +export default function TradingPage() { + const { showSuccess, showError } = useSnackbar() + const queryClient = useQueryClient() + const [tabValue, setTabValue] = useState(0) + const [orderDialogOpen, setOrderDialogOpen] = useState(false) + const [paperTrading, setPaperTrading] = useState(true) + + const { data: exchanges } = useQuery({ + queryKey: ['exchanges'], + queryFn: () => exchangesApi.listExchanges(), + }) + + const [selectedOrders, setSelectedOrders] = useState([]) + const [symbolFilter, setSymbolFilter] = useState('') + + const { data: orders, isLoading: ordersLoading } = useQuery({ + queryKey: ['orders', paperTrading], + queryFn: () => tradingApi.getOrders(paperTrading, 100), + refetchInterval: 5000, + }) + + const { data: positions, isLoading: positionsLoading } = useQuery({ + queryKey: ['positions', paperTrading], + queryFn: () => tradingApi.getPositions(paperTrading), + refetchInterval: 5000, + }) + + const { data: generalSettings } = useQuery({ + queryKey: ['general-settings'], + queryFn: () => settingsApi.getGeneralSettings(), + }) + + const { data: balance } = useQuery({ + queryKey: ['balance', paperTrading], + queryFn: () => tradingApi.getBalance(paperTrading), + refetchInterval: 5000, + }) + + // Create order mutation moved to OrderForm or removed if redundant + + const cancelOrderMutation = useMutation({ + mutationFn: (orderId: number) => tradingApi.cancelOrder(orderId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['orders'] }) + showSuccess?.('Order cancelled successfully') + }, + onError: (error: any) => { + showError?.(`Failed to cancel order: ${error.message}`) + } + }) + + const cancelAllOrdersMutation = useMutation({ + mutationFn: (paperTrading: boolean) => tradingApi.cancelAllOrders(paperTrading), + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['orders'] }) + showSuccess?.(`Cancelled ${data.cancelled_count} orders`) + }, + onError: (error: any) => { + showError?.(`Failed to cancel all orders: ${error.message}`) + } + }) + + const cancelMultipleOrdersMutation = useMutation({ + mutationFn: async (orderIds: number[]) => { + const results = await Promise.all(orderIds.map(id => tradingApi.cancelOrder(id))) + return results + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['orders'] }) + setSelectedOrders([]) + showSuccess?.('Selected orders cancelled') + }, + onError: (error: any) => { + showError?.(`Failed to cancel selected orders: ${error.message}`) + } + }) + + const handleCreateOrder = () => { + setOrderDialogOpen(true) + } + + const handleCancelOrder = (orderId: number) => { + if (window.confirm('Are you sure you want to cancel this order?')) { + cancelOrderMutation.mutate(orderId) + } + } + + const handleCancelAll = () => { + if (window.confirm(`Are you sure you want to cancel ALL open ${paperTrading ? 'paper' : 'live'} orders?`)) { + cancelAllOrdersMutation.mutate(paperTrading) + } + } + + const handleCancelSelected = () => { + if (selectedOrders.length === 0) return + if (window.confirm(`Are you sure you want to cancel ${selectedOrders.length} selected orders?`)) { + cancelMultipleOrdersMutation.mutate(selectedOrders) + } + } + + const handleSelectOrder = (orderId: number) => { + setSelectedOrders(prev => + prev.includes(orderId) ? prev.filter(id => id !== orderId) : [...prev, orderId] + ) + } + + const handleSelectAll = (orderIds: number[]) => { + if (selectedOrders.length === orderIds.length && orderIds.length > 0) { + setSelectedOrders([]) + } else { + setSelectedOrders(orderIds) + } + } + + const getStatusColor = (status: OrderStatus) => { + switch (status) { + case OrderStatus.FILLED: + return 'success' + case OrderStatus.PENDING: + case OrderStatus.OPEN: + case OrderStatus.PARTIALLY_FILLED: + return 'warning' + case OrderStatus.CANCELLED: + case OrderStatus.REJECTED: + case OrderStatus.EXPIRED: + return 'error' + default: + return 'default' + } + } + + const filteredOrders = orders?.filter(o => + !symbolFilter || o.symbol.toLowerCase().includes(symbolFilter.toLowerCase()) + ) || [] + + const activeOrders = filteredOrders.filter( + (o) => o.status === OrderStatus.OPEN || o.status === OrderStatus.PENDING || o.status === OrderStatus.PARTIALLY_FILLED + ) + + const orderHistory = filteredOrders.filter( + (o) => o.status === OrderStatus.FILLED || o.status === OrderStatus.CANCELLED || o.status === OrderStatus.REJECTED || o.status === OrderStatus.EXPIRED + ) + + return ( + + + Trading + + ) => setPaperTrading(!e.target.checked)} + color="warning" + size="small" + /> + } + label={ + + {paperTrading ? 'Paper' : 'Live'} + + } + /> + + + + + {/* Balance Card */} + + + + + + Available Balance + + + ${balance?.balance?.toFixed(2) || '0.00'} + + + + + Open Positions + + + {positions?.length || 0} + + + + + Active Orders + + + {activeOrders.length} + + + + + + + + setTabValue(v)}> + + + + + + + {positionsLoading ? ( + + + + ) : positions && positions.length > 0 ? ( + + {positions.map((position: PositionResponse) => ( + + { + // Position closing will be handled in PositionCard + queryClient.invalidateQueries({ queryKey: ['positions'] }) + }} + /> + + ))} + + ) : ( + + + No open positions + + + )} + + + + + + setSymbolFilter(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + {activeOrders.length > 0 && ( + + + + + )} + + + + {ordersLoading ? ( + + + + ) : activeOrders.length > 0 ? ( + + + + + + 0 && selectedOrders.length < activeOrders.length} + checked={activeOrders.length > 0 && selectedOrders.length === activeOrders.length} + onChange={() => handleSelectAll(activeOrders.map(o => o.id))} + /> + + Time + Symbol + Side + Type + Quantity + Price + Filled + Status + Actions + + + + {activeOrders.map((order: OrderResponse) => ( + handleSelectOrder(order.id)} + sx={{ cursor: 'pointer' }} + > + + handleSelectOrder(order.id)} + onClick={(e) => e.stopPropagation()} + /> + + + {formatDate(order.created_at, generalSettings)} + + {order.symbol} + + : } + /> + + + {order.order_type.replace('_', ' ')} + + + {Number(order.quantity).toFixed(8)} + + + {order.price ? `$${Number(order.price).toFixed(2)}` : 'Market'} + + + {Number(order.filled_quantity).toFixed(8)} / {Number(order.quantity).toFixed(8)} + + + + + + + { + e.stopPropagation() + handleCancelOrder(order.id) + }} + disabled={cancelOrderMutation.isPending} + > + + + + + + ))} + +
+
+ ) : ( + + + No active orders + + + )} +
+ + + + setSymbolFilter(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + {ordersLoading ? ( + + + + ) : orderHistory.length > 0 ? ( + + + + + Time + Symbol + Side + Type + Quantity + Price + Filled + Fee + Status + + + + {orderHistory.slice(0, 50).map((order: OrderResponse) => ( + + + {formatDate(order.created_at, generalSettings)} + + {order.symbol} + + + + + {order.order_type.replace('_', ' ')} + + + {Number(order.quantity).toFixed(8)} + + + {order.average_fill_price + ? `$${Number(order.average_fill_price).toFixed(2)}` + : order.price + ? `$${Number(order.price).toFixed(2)}` + : 'Market'} + + + {Number(order.filled_quantity).toFixed(8)} + + + ${Number(order.fee).toFixed(2)} + + + + + + ))} + +
+
+ ) : ( + + + No order history + + + )} +
+
+ + setOrderDialogOpen(false)} + exchanges={exchanges || []} + paperTrading={paperTrading} + onSuccess={() => { + setOrderDialogOpen(false) + }} + /> +
+ ) +} + diff --git a/frontend/src/pages/__init__.ts b/frontend/src/pages/__init__.ts new file mode 100644 index 00000000..4375acea --- /dev/null +++ b/frontend/src/pages/__init__.ts @@ -0,0 +1,4 @@ +export { default as DashboardPage } from './DashboardPage' +export { default as PortfolioPage } from './PortfolioPage' +export { default as BacktestPage } from './BacktestPage' +export { default as SettingsPage } from './SettingsPage' diff --git a/frontend/src/pages/__tests__/DashboardPage.test.tsx b/frontend/src/pages/__tests__/DashboardPage.test.tsx new file mode 100644 index 00000000..59c6a6e9 --- /dev/null +++ b/frontend/src/pages/__tests__/DashboardPage.test.tsx @@ -0,0 +1,196 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor, fireEvent } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import DashboardPage from '../DashboardPage' +import * as autopilotApi from '../../api/autopilot' +import { AutopilotSettingsProvider } from '../../contexts/AutopilotSettingsContext' +import { BrowserRouter } from 'react-router-dom' + +vi.mock('../../api/autopilot') +vi.mock('../../api/trading') +vi.mock('../../api/marketData') +vi.mock('../../api/strategies') +vi.mock('../../components/WebSocketProvider', () => ({ + useWebSocketContext: () => ({ isConnected: true, lastMessage: null, subscribe: vi.fn(() => vi.fn()) }), +})) +vi.mock('../../contexts/SnackbarContext', () => ({ + useSnackbar: () => ({ + showError: vi.fn(), + showSuccess: vi.fn(), + showWarning: vi.fn(), + showInfo: vi.fn(), + }), +})) + +describe('DashboardPage - Autopilot Section', () => { + let queryClient: QueryClient + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + vi.clearAllMocks() + }) + + // Helper to wrap component with all required providers + const renderDashboard = () => { + return render( + + + + + + + + ) + } + + it('renders autopilot configuration section', async () => { + const mockModeInfo = { + modes: { + pattern: { name: 'Pattern-Based', description: 'Test' }, + intelligent: { name: 'ML-Based', description: 'Test' }, + }, + comparison: {}, + } + + vi.mocked(autopilotApi.autopilotApi.getModes).mockResolvedValue(mockModeInfo as any) + + renderDashboard() + + await waitFor(() => { + expect(screen.getByText('Autopilot Configuration')).toBeInTheDocument() + }) + }) + + it('displays mode selector', async () => { + const mockModeInfo = { + modes: { + pattern: { name: 'Pattern-Based', description: 'Test' }, + intelligent: { name: 'ML-Based', description: 'Test' }, + }, + comparison: {}, + } + + vi.mocked(autopilotApi.autopilotApi.getModes).mockResolvedValue(mockModeInfo as any) + + renderDashboard() + + await waitFor(() => { + expect(screen.getByText('Select Autopilot Mode')).toBeInTheDocument() + }) + }) + + it('shows auto-execute toggle', async () => { + const mockModeInfo = { + modes: { + pattern: { name: 'Pattern-Based', description: 'Test' }, + intelligent: { name: 'ML-Based', description: 'Test' }, + }, + comparison: {}, + } + + vi.mocked(autopilotApi.autopilotApi.getModes).mockResolvedValue(mockModeInfo as any) + + renderDashboard() + + await waitFor(() => { + expect(screen.getByText(/Auto-Execute/)).toBeInTheDocument() + }) + }) + + it('shows start button when autopilot is not running', async () => { + const mockModeInfo = { + modes: { + pattern: { name: 'Pattern-Based', description: 'Test' }, + intelligent: { name: 'ML-Based', description: 'Test' }, + }, + comparison: {}, + } + + vi.mocked(autopilotApi.autopilotApi.getModes).mockResolvedValue(mockModeInfo as any) + vi.mocked(autopilotApi.autopilotApi.getUnifiedStatus).mockResolvedValue({ + running: false, + mode: 'pattern', + } as any) + + renderDashboard() + + await waitFor(() => { + expect(screen.getByText('Start AutoPilot')).toBeInTheDocument() + }) + }) + + it('shows stop button when autopilot is running', async () => { + const mockModeInfo = { + modes: { + pattern: { name: 'Pattern-Based', description: 'Test' }, + intelligent: { name: 'ML-Based', description: 'Test' }, + }, + comparison: {}, + } + + vi.mocked(autopilotApi.autopilotApi.getModes).mockResolvedValue(mockModeInfo as any) + vi.mocked(autopilotApi.autopilotApi.getUnifiedStatus).mockResolvedValue({ + running: true, + mode: 'pattern', + } as any) + + renderDashboard() + + await waitFor(() => { + expect(screen.getByText('Stop AutoPilot')).toBeInTheDocument() + }) + }) + + it('calls startUnified when start button is clicked', async () => { + const mockModeInfo = { + modes: { + pattern: { name: 'Pattern-Based', description: 'Test' }, + intelligent: { name: 'ML-Based', description: 'Test' }, + }, + comparison: {}, + } + + const startUnifiedMock = vi.fn().mockResolvedValue({ status: 'started' }) + vi.mocked(autopilotApi.autopilotApi.getModes).mockResolvedValue(mockModeInfo as any) + vi.mocked(autopilotApi.autopilotApi.getUnifiedStatus).mockResolvedValue({ + running: false, + mode: 'pattern', + } as any) + vi.mocked(autopilotApi.autopilotApi.startUnified).mockImplementation(startUnifiedMock) + + renderDashboard() + + await waitFor(() => { + const startButton = screen.getByText('Start AutoPilot') + fireEvent.click(startButton) + }) + + await waitFor(() => { + expect(startUnifiedMock).toHaveBeenCalled() + }) + }) + + it('displays current mode in status chip', async () => { + const mockModeInfo = { + modes: { + pattern: { name: 'Pattern-Based', description: 'Test' }, + intelligent: { name: 'ML-Based', description: 'Test' }, + }, + comparison: {}, + } + + vi.mocked(autopilotApi.autopilotApi.getModes).mockResolvedValue(mockModeInfo as any) + vi.mocked(autopilotApi.autopilotApi.getUnifiedStatus).mockResolvedValue({ + running: true, + mode: 'intelligent', + } as any) + + renderDashboard() + + await waitFor(() => { + expect(screen.getByText(/AutoPilot Active \(intelligent\)/)).toBeInTheDocument() + }) + }) +}) diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts new file mode 100644 index 00000000..76175700 --- /dev/null +++ b/frontend/src/test/setup.ts @@ -0,0 +1,65 @@ +import '@testing-library/jest-dom' +import { afterEach, vi } from 'vitest' +import { cleanup } from '@testing-library/react' + +// Cleanup after each test +afterEach(() => { + cleanup() +}) + +// Mock window.matchMedia +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}) + +// Mock ResizeObserver +class ResizeObserverMock { + observe = vi.fn() + unobserve = vi.fn() + disconnect = vi.fn() +} +window.ResizeObserver = ResizeObserverMock + +// Mock WebSocket +class WebSocketMock { + static CONNECTING = 0 + static OPEN = 1 + static CLOSING = 2 + static CLOSED = 3 + + readyState = WebSocketMock.OPEN + url: string + onopen: (() => void) | null = null + onmessage: ((event: { data: string }) => void) | null = null + onerror: ((error: Event) => void) | null = null + onclose: (() => void) | null = null + + constructor(url: string) { + this.url = url + // Simulate connection + setTimeout(() => this.onopen?.(), 0) + } + + send = vi.fn() + close = vi.fn(() => { + this.readyState = WebSocketMock.CLOSED + this.onclose?.() + }) +} +; (globalThis as unknown as { WebSocket: typeof WebSocketMock }).WebSocket = WebSocketMock + +// Mock fetch +globalThis.fetch = vi.fn() as unknown as typeof fetch + +// Suppress console errors in tests (optional, enable if needed) +// vi.spyOn(console, 'error').mockImplementation(() => {}) diff --git a/frontend/src/test/utils.tsx b/frontend/src/test/utils.tsx new file mode 100644 index 00000000..db0effa2 --- /dev/null +++ b/frontend/src/test/utils.tsx @@ -0,0 +1,80 @@ +import { ReactElement } from 'react' +import { render, RenderOptions } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { BrowserRouter } from 'react-router-dom' +import { vi } from 'vitest' + +/** + * Creates a fresh QueryClient for testing with retry disabled + */ +export function createTestQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + mutations: { + retry: false, + }, + }, + }) +} + +interface WrapperProps { + children: React.ReactNode +} + +/** + * Custom render function that wraps components with necessary providers + */ +export function renderWithProviders( + ui: ReactElement, + options?: Omit & { + queryClient?: QueryClient + route?: string + } +) { + const { queryClient = createTestQueryClient(), route = '/', ...renderOptions } = options || {} + + // Set the route + window.history.pushState({}, 'Test page', route) + + function Wrapper({ children }: WrapperProps) { + return ( + + {children} + + ) + } + + return { + ...render(ui, { wrapper: Wrapper, ...renderOptions }), + queryClient, + } +} + +/** + * Mock snackbar context for testing + */ +export const mockSnackbar = { + showError: vi.fn(), + showSuccess: vi.fn(), + showWarning: vi.fn(), + showInfo: vi.fn(), +} + +/** + * Mock WebSocket context for testing + */ +export const mockWebSocketContext = { + isConnected: true, + lastMessage: null, + messageHistory: [], + sendMessage: vi.fn(), + subscribe: vi.fn(), +} + +// Re-export everything from testing-library +export * from '@testing-library/react' +export { default as userEvent } from '@testing-library/user-event' diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 00000000..f4113387 --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,150 @@ +export enum OrderSide { + BUY = 'buy', + SELL = 'sell', +} + +export enum OrderType { + MARKET = 'market', + LIMIT = 'limit', + STOP_LOSS = 'stop_loss', + TAKE_PROFIT = 'take_profit', + TRAILING_STOP = 'trailing_stop', + OCO = 'oco', + ICEBERG = 'iceberg', +} + +export enum OrderStatus { + PENDING = 'pending', + OPEN = 'open', + PARTIALLY_FILLED = 'partially_filled', + FILLED = 'filled', + CANCELLED = 'cancelled', + REJECTED = 'rejected', + EXPIRED = 'expired', +} + +export interface OrderCreate { + exchange_id: number + symbol: string + side: OrderSide + order_type: OrderType + quantity: number + price?: number + strategy_id?: number + paper_trading?: boolean +} + +export interface OrderResponse { + id: number + exchange_id: number + strategy_id?: number + symbol: string + order_type: OrderType + side: OrderSide + status: OrderStatus + quantity: number + price?: number + filled_quantity: number + average_fill_price?: number + fee: number + paper_trading: boolean + created_at: string + updated_at: string + filled_at?: string +} + +export interface PositionResponse { + symbol: string + quantity: number + entry_price: number + current_price: number + unrealized_pnl: number + realized_pnl: number +} + +export interface PortfolioResponse { + positions: Array<{ + symbol: string + quantity: number + entry_price: number + current_price: number + unrealized_pnl: number + }> + performance: { + current_value: number + unrealized_pnl: number + realized_pnl: number + } + timestamp: string +} + +export interface PortfolioHistoryResponse { + dates: string[] + values: number[] + pnl: number[] +} + +export interface StrategyCreate { + name: string + description?: string + strategy_type: string + class_name: string + parameters?: Record + timeframes?: string[] + paper_trading?: boolean + schedule?: Record +} + +export interface StrategyUpdate { + name?: string + description?: string + parameters?: Record + timeframes?: string[] + enabled?: boolean + schedule?: Record +} + +export interface StrategyResponse { + id: number + name: string + description?: string + strategy_type: string + class_name: string + parameters: Record + timeframes: string[] + enabled: boolean + running: boolean + paper_trading: boolean + version: string + schedule?: Record + created_at: string + updated_at: string +} + +export interface BacktestRequest { + strategy_id: number + symbol: string + exchange: string + timeframe: string + start_date: string + end_date: string + initial_capital?: number + slippage?: number + fee_rate?: number +} + +export interface BacktestResponse { + backtest_id?: number + results: Record + status: string +} + +export interface ExchangeResponse { + id: number + name: string + sandbox: boolean + read_only: boolean + enabled: boolean + created_at: string + updated_at: string +} diff --git a/frontend/src/utils/errorHandler.ts b/frontend/src/utils/errorHandler.ts new file mode 100644 index 00000000..1898e43e --- /dev/null +++ b/frontend/src/utils/errorHandler.ts @@ -0,0 +1,48 @@ +export interface AppError { + message: string + code?: string + details?: any + retryable?: boolean +} + +export function formatError(error: unknown): AppError { + if (error instanceof Error) { + return { + message: error.message, + details: error.stack, + retryable: false, + } + } + + if (typeof error === 'string') { + return { + message: error, + retryable: false, + } + } + + if (error && typeof error === 'object' && 'message' in error) { + return { + message: String(error.message), + code: 'code' in error ? String(error.code) : undefined, + details: error, + retryable: 'retryable' in error ? Boolean(error.retryable) : false, + } + } + + return { + message: 'An unknown error occurred', + retryable: false, + } +} + +export function getErrorMessage(error: unknown): string { + const formatted = formatError(error) + return formatted.message +} + +export function isRetryable(error: unknown): boolean { + const formatted = formatError(error) + return formatted.retryable || false +} + diff --git a/frontend/src/utils/formatters.ts b/frontend/src/utils/formatters.ts new file mode 100644 index 00000000..c6d223a8 --- /dev/null +++ b/frontend/src/utils/formatters.ts @@ -0,0 +1,58 @@ +import { GeneralSettings } from '../api/settings' + +/** + * Formats a date string or object based on user settings + */ +export const formatDate = (date: string | Date | number, settings?: GeneralSettings): string => { + if (!date) return 'N/A' + + const d = typeof date === 'string' && !date.includes('Z') && !date.includes('+') + ? new Date(date + 'Z') // Ensure UTC if no timezone info + : new Date(date) + + if (isNaN(d.getTime())) return 'Invalid Date' + + const timezone = settings?.timezone || 'local' + + const options: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + } + + if (timezone !== 'local') { + options.timeZone = timezone + } + + return d.toLocaleString(undefined, options) +} + +/** + * Formats only the time part + */ +export const formatTime = (date: string | Date | number, settings?: GeneralSettings): string => { + if (!date) return 'N/A' + + const d = typeof date === 'string' && !date.includes('Z') && !date.includes('+') + ? new Date(date + 'Z') + : new Date(date) + + if (isNaN(d.getTime())) return 'N/A' + + const timezone = settings?.timezone || 'local' + + const options: Intl.DateTimeFormatOptions = { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + } + + if (timezone !== 'local') { + options.timeZone = timezone + } + + return d.toLocaleTimeString(undefined, options) +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 00000000..7c8ed3c3 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_URL: string + readonly VITE_WS_URL: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 00000000..04eb5481 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 00000000..42872c59 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 00000000..3103891e --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import path from 'path' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + server: { + port: 3000, + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + '/ws': { + target: 'ws://localhost:8000', + ws: true, + }, + }, + }, +}) diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 00000000..ae1134d3 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' +import { fileURLToPath, URL } from 'node:url' + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + include: ['src/**/*.{test,spec}.{ts,tsx}'], + exclude: ['node_modules', 'e2e'], + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'lcov'], + exclude: [ + 'node_modules/', + 'src/test/', + '**/*.d.ts', + '**/*.config.*', + '**/types/', + ], + }, + }, +}) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..017175f8 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,23 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = + -v + --strict-markers + --tb=short + --cov=src + --cov-report=term-missing + --cov-report=html + --cov-report=xml + --cov-fail-under=95 +asyncio_mode = auto +markers = + unit: Unit tests + integration: Integration tests + e2e: End-to-end tests + slow: Slow running tests + requires_api: Tests requiring API access + requires_db: Tests requiring database + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..adf388f8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,62 @@ +# Core +python-dotenv>=1.0.0 +pyyaml>=6.0 +sqlalchemy>=2.0.0 +alembic>=1.12.0 +# Note: psycopg2-binary optional for PostgreSQL support +asyncpg>=0.29.0 +psycopg2-binary>=2.9.0 + +# Testing +pytest-asyncio>=0.23.0 +aiosqlite>=0.19.0 # Testing only + +# Exchange Integration +ccxt>=4.0.0 +websockets>=12.0 + +# Technical Analysis +pandas>=2.0.0 +numpy>=1.24.0 +pandas-ta>=0.3.14b +# Note: numba may need to be installed separately if pandas-ta fails +TA-Lib>=0.4.28 + +# Data & Async +aiohttp>=3.9.0 +httpx>=0.25.0 + +# Notifications +plyer>=2.1.0 + +# Utilities +python-dateutil>=2.8.0 +pytz>=2023.3 +cryptography>=41.0.0 +keyring>=24.0.0 + +# Optimization +scipy>=1.11.0 +scikit-optimize>=0.9.0 + +# Machine Learning +scikit-learn>=1.3.0 +joblib>=1.3.0 + +# PDF Generation +reportlab>=4.0.0 +matplotlib>=3.8.0 + +# Scheduling +APScheduler>=3.10.0 + +# NLP / Sentiment Analysis (AutoPilot Engine) +transformers>=4.35.0 +torch>=2.0.0 + +# News Collection +feedparser>=6.0.0 + +# Redis & Celery +redis>=5.0.0 +celery>=5.3.0 diff --git a/scripts/add_running_column.sql b/scripts/add_running_column.sql new file mode 100644 index 00000000..22a89bfd --- /dev/null +++ b/scripts/add_running_column.sql @@ -0,0 +1,8 @@ +-- Migration: Add running column to strategies table +-- This separates "running manually" state from "enabled for Autopilot" state + +ALTER TABLE strategies ADD COLUMN IF NOT EXISTS running BOOLEAN DEFAULT FALSE; + +-- Comment explaining the difference +COMMENT ON COLUMN strategies.enabled IS 'Available to Autopilot with custom params'; +COMMENT ON COLUMN strategies.running IS 'Currently running manually (bypasses Autopilot)'; diff --git a/scripts/fetch_historical_data.py b/scripts/fetch_historical_data.py new file mode 100644 index 00000000..3547d946 --- /dev/null +++ b/scripts/fetch_historical_data.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Utility script to fetch and store historical OHLCV data from Binance public API. + +This script uses the PublicDataAdapter to fetch historical market data without +requiring API keys. Perfect for populating your database with historical data +for backtesting and analysis. + +Usage: + python scripts/fetch_historical_data.py --symbol BTC/USDT --timeframe 1h --days 30 + python scripts/fetch_historical_data.py --symbol ETH/USDT --timeframe 1d --days 365 +""" + +import sys +import argparse +from datetime import datetime, timedelta +from pathlib import Path + +# Add src to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from src.exchanges.public_data import PublicDataAdapter +from src.data.collector import get_data_collector +from src.core.logger import setup_logging, get_logger + +setup_logging() +logger = get_logger(__name__) + + +def fetch_historical_data( + symbol: str, + timeframe: str, + days: int, + exchange_name: str = "Binance Public" +) -> int: + """Fetch and store historical OHLCV data. + + Args: + symbol: Trading symbol (e.g., 'BTC/USDT') + timeframe: Timeframe (e.g., '1h', '1d', '4h') + days: Number of days of historical data to fetch + exchange_name: Exchange name for storage + + Returns: + Number of candles stored + """ + logger.info(f"Fetching {days} days of {timeframe} data for {symbol}") + + # Create public data adapter + adapter = PublicDataAdapter() + if not adapter.connect(): + logger.error("Failed to connect to Binance public API") + return 0 + + # Calculate start date + end_date = datetime.utcnow() + start_date = end_date - timedelta(days=days) + + # Fetch data in chunks (Binance limit is 1000 candles per request) + collector = get_data_collector() + total_candles = 0 + + current_date = start_date + chunk_days = 30 # Fetch 30 days at a time to stay under 1000 candle limit + + while current_date < end_date: + chunk_end = min(current_date + timedelta(days=chunk_days), end_date) + + logger.info(f"Fetching data from {current_date.date()} to {chunk_end.date()}") + + # Fetch OHLCV data + ohlcv = adapter.get_ohlcv( + symbol=symbol, + timeframe=timeframe, + since=current_date, + limit=1000 + ) + + if ohlcv: + # Store in database + collector.store_ohlcv(exchange_name, symbol, timeframe, ohlcv) + total_candles += len(ohlcv) + logger.info(f"Stored {len(ohlcv)} candles (total: {total_candles})") + else: + logger.warning(f"No data returned for period {current_date} to {chunk_end}") + + # Move to next chunk + current_date = chunk_end + + # Small delay to respect rate limits + import time + time.sleep(1) + + adapter.disconnect() + logger.info(f"Completed! Total candles stored: {total_candles}") + return total_candles + + +def main(): + """Main entry point.""" + parser = argparse.ArgumentParser( + description="Fetch historical OHLCV data from Binance public API" + ) + parser.add_argument( + '--symbol', + type=str, + default='BTC/USDT', + help='Trading symbol (e.g., BTC/USDT, ETH/USDT)' + ) + parser.add_argument( + '--timeframe', + type=str, + default='1h', + choices=['1m', '5m', '15m', '30m', '1h', '4h', '1d', '1w'], + help='Timeframe for candles' + ) + parser.add_argument( + '--days', + type=int, + default=30, + help='Number of days of historical data to fetch' + ) + parser.add_argument( + '--exchange', + type=str, + default='Binance Public', + help='Exchange name for storage (default: Binance Public)' + ) + + args = parser.parse_args() + + try: + count = fetch_historical_data( + symbol=args.symbol, + timeframe=args.timeframe, + days=args.days, + exchange_name=args.exchange + ) + print(f"\n✓ Successfully fetched and stored {count} candles") + print(f" Symbol: {args.symbol}") + print(f" Timeframe: {args.timeframe}") + print(f" Period: {args.days} days") + return 0 + except KeyboardInterrupt: + print("\n\nInterrupted by user") + return 1 + except Exception as e: + logger.error(f"Error: {e}", exc_info=True) + print(f"\n✗ Error: {e}") + return 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/reset_database.py b/scripts/reset_database.py new file mode 100644 index 00000000..d38cc78b --- /dev/null +++ b/scripts/reset_database.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Reset database to a fresh state by dropping all tables and recreating them.""" + +import asyncio +import sys +from pathlib import Path + +# Add project root to path +project_root = Path(__file__).parent.parent +if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) + +from sqlalchemy import text +from src.core.database import Base, Database +from src.core.config import get_config + + +async def reset_database(): + """Reset database by dropping all tables and recreating them.""" + config = get_config() + db_type = config.get("database.type", "postgresql") + + print(f"Resetting {db_type} database...") + + # Create database instance to get engine + db = Database() + + try: + # Drop all tables + # For PostgreSQL, we need to handle foreign key constraints + print("Dropping all tables...") + async with db.engine.begin() as conn: + # Disable multiple statements in one call caution, but reset logic usually needs strict control. + # However, asyncpg doesn't support "SET session_replication_role" easily within a transaction block + # if it's not a superuser or specific config. + # Instead, we will use CASCADE drop which is cleaner for full reset. + + # Use reflection to find tables is harder in async. + # We will just drop all tables using CASCADE via raw SQL or metadata. + + # Since we are using async engine, we need to utilize run_sync for metadata operations + # But drop_all doesn't support CASCADE automatically for all dialects in the way we might want + # if using pure SQLAlchemy metadata.drop_all. + + # Let's try the standard metadata.drop_all first + await conn.run_sync(Base.metadata.drop_all) + + print("Dropped all tables.") + + # Recreate all tables + print("Recreating all tables...") + await db.create_tables() + print("Database reset complete!") + + except Exception as e: + print(f"Error during reset: {e}") + raise + finally: + # Close database connection + await db.close() + + +if __name__ == "__main__": + try: + asyncio.run(reset_database()) + except Exception as e: + print(f"Error resetting database: {e}", file=sys.stderr) + sys.exit(1) diff --git a/scripts/start_all.sh b/scripts/start_all.sh new file mode 100644 index 00000000..3dc91573 --- /dev/null +++ b/scripts/start_all.sh @@ -0,0 +1,52 @@ +#!/bin/bash + +# Start Redis +echo "Starting Redis..." +if sudo service redis-server start; then + echo "✓ Redis started" +else + echo "x Failed to start Redis" + exit 1 +fi + +# Activate virtual environment if it exists +if [ -d "venv" ]; then + source venv/bin/activate +fi + +# Start Celery Worker +echo "Starting Celery Worker..." +# Check for existing celery process +if pgrep -f "celery worker" > /dev/null; then + echo "! Celery is already running" +else + nohup celery -A src.worker.app worker --loglevel=info > celery.log 2>&1 & + echo "✓ Celery worker started" +fi + +# Start Backend +echo "Starting Backend API..." +if pgrep -f "uvicorn backend.main:app" > /dev/null; then + echo "! Backend is already running" +else + nohup uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000 > backend.log 2>&1 & + echo "✓ Backend API started" +fi + +# Start Frontend +echo "Starting Frontend..." +if pgrep -f "vite" > /dev/null; then + echo "! Frontend is already running" +else + cd frontend + nohup npm run dev > ../frontend.log 2>&1 & + cd .. + echo "✓ Frontend started" +fi + +echo "-----------------------------------" +echo "All services are running!" +echo "Logs:" +echo " - Celery: tail -f celery.log" +echo " - Backend: tail -f backend.log" +echo " - Frontend: tail -f frontend.log" diff --git a/scripts/verify_redis.py b/scripts/verify_redis.py new file mode 100644 index 00000000..74ff99c2 --- /dev/null +++ b/scripts/verify_redis.py @@ -0,0 +1,53 @@ +"""Verify Redis and Celery integration.""" + +import asyncio +import os +import sys + +# Add project root to path +sys.path.insert(0, os.getcwd()) + +from src.core.redis import get_redis_client +from src.worker.app import app +from src.worker.tasks import train_model_task + +async def verify_redis(): + """Verify Redis connection.""" + print("Verifying Redis connection...") + try: + redis = get_redis_client() + client = redis.get_client() + await client.set("test_key", "hello_redis") + value = await client.get("test_key") + print(f"Redis write/read success: {value}") + await client.delete("test_key") + await redis.close() + return True + except Exception as e: + print(f"Redis verification failed: {e}") + return False + +def verify_celery_task_queuing(): + """Verify Celery task queuing.""" + print("\nVerifying Celery task queuing...") + try: + # Submit task (won't run unless worker is active, but we check queuing) + task = train_model_task.delay(force_retrain=False, bootstrap=False) + print(f"Task submitted. ID: {task.id}") + print(f"Task status: {task.status}") + return True + except Exception as e: + print(f"Celery task submission failed: {e}") + return False + +async def main(): + redis_ok = await verify_redis() + celery_ok = verify_celery_task_queuing() + + if redis_ok and celery_ok: + print("\nSUCCESS: Redis and Celery integration verified.") + else: + print("\nFAILURE: One or more components failed verification.") + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..d2235cc2 --- /dev/null +++ b/setup.py @@ -0,0 +1,43 @@ +"""Setup script for crypto_trader application.""" + +from setuptools import setup, find_packages + +setup( + name="crypto_trader", + version="0.1.0", + description="Cryptocurrency Trading Platform - Web-based trading application with FastAPI backend", + author="Your Name", + author_email="your.email@example.com", + packages=find_packages(where="src"), + package_dir={"": "src"}, + python_requires=">=3.11", + install_requires=[ + "python-dotenv>=1.0.0", + "pyyaml>=6.0", + "sqlalchemy>=2.0.0", + "alembic>=1.12.0", + "ccxt>=4.0.0", + "websockets>=12.0", + "pandas>=2.0.0", + "numpy>=1.24.0", + "pandas-ta>=0.3.14b", + "TA-Lib>=0.4.28", + "aiohttp>=3.9.0", + "plyer>=2.1.0", + "python-dateutil>=2.8.0", + "pytz>=2023.3", + "cryptography>=41.0.0", + "keyring>=24.0.0", + "scipy>=1.11.0", + "scikit-optimize>=0.9.0", + "reportlab>=4.0.0", + "matplotlib>=3.8.0", + "APScheduler>=3.10.0", + "scikit-learn>=1.3.0", + "joblib>=1.3.0", + "transformers>=4.35.0", + "torch>=2.0.0", + "feedparser>=6.0.0", + ], +) + diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/alerts/__init__.py b/src/alerts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/alerts/channels.py b/src/alerts/channels.py new file mode 100644 index 00000000..aade2f27 --- /dev/null +++ b/src/alerts/channels.py @@ -0,0 +1,26 @@ +"""Alert delivery channels.""" + +from typing import Optional +from src.ui.utils.notifications import get_notification_manager +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class AlertChannel: + """Manages alert delivery channels.""" + + def __init__(self): + """Initialize alert channel.""" + self.notifications = get_notification_manager() + self.logger = get_logger(__name__) + + def send(self, alert_type: str, message: str): + """Send alert through all channels. + + Args: + alert_type: Alert type + message: Alert message + """ + self.notifications.notify_alert(alert_type, message) + diff --git a/src/alerts/engine.py b/src/alerts/engine.py new file mode 100644 index 00000000..77314a2f --- /dev/null +++ b/src/alerts/engine.py @@ -0,0 +1,139 @@ +"""Alert rule engine.""" + +from decimal import Decimal +from typing import Dict, Optional, Callable, List +from datetime import datetime +from sqlalchemy.orm import Session +from src.core.database import get_database, Alert +from src.core.logger import get_logger +from .channels import AlertChannel + +logger = get_logger(__name__) + + +class AlertEngine: + """Alert rule engine.""" + + def __init__(self): + """Initialize alert engine.""" + self.db = get_database() + self.logger = get_logger(__name__) + self.channel = AlertChannel() + self._active_alerts: Dict[int, Dict] = {} + + def evaluate_price_alert( + self, + alert_id: int, + symbol: str, + current_price: Decimal + ) -> bool: + """Evaluate price alert. + + Args: + alert_id: Alert ID + symbol: Trading symbol + current_price: Current price + + Returns: + True if alert should trigger + """ + session = self.db.get_session() + try: + alert = session.query(Alert).filter_by(id=alert_id).first() + if not alert or not alert.enabled: + return False + + condition = alert.condition + alert_type = condition.get('type') + threshold = Decimal(str(condition.get('threshold', 0))) + operator = condition.get('operator', '>') # >, <, >=, <= + + if alert_type == 'price_above': + return current_price > threshold + elif alert_type == 'price_below': + return current_price < threshold + elif alert_type == 'price_change': + # Would need previous price + return False + + return False + finally: + session.close() + + def evaluate_indicator_alert( + self, + alert_id: int, + indicator_value: float + ) -> bool: + """Evaluate indicator alert. + + Args: + alert_id: Alert ID + indicator_value: Current indicator value + + Returns: + True if alert should trigger + """ + session = self.db.get_session() + try: + alert = session.query(Alert).filter_by(id=alert_id).first() + if not alert or not alert.enabled: + return False + + condition = alert.condition + threshold = float(condition.get('threshold', 0)) + operator = condition.get('operator', '>') + + if operator == '>': + return indicator_value > threshold + elif operator == '<': + return indicator_value < threshold + elif operator == 'crosses_above': + # Would need previous value + return False + elif operator == 'crosses_below': + # Would need previous value + return False + + return False + finally: + session.close() + + def trigger_alert(self, alert_id: int, message: str): + """Trigger an alert. + + Args: + alert_id: Alert ID + message: Alert message + """ + session = self.db.get_session() + try: + alert = session.query(Alert).filter_by(id=alert_id).first() + if not alert: + return + + alert.triggered = True + alert.triggered_at = datetime.utcnow() + session.commit() + + # Send notification + self.channel.send(alert.alert_type, message) + logger.info(f"Alert {alert_id} triggered: {message}") + except Exception as e: + session.rollback() + logger.error(f"Failed to trigger alert {alert_id}: {e}") + finally: + session.close() + + +# Global alert engine +_alert_engine: Optional[AlertEngine] = None + + +def get_alert_engine() -> AlertEngine: + """Get global alert engine instance.""" + global _alert_engine + if _alert_engine is None: + _alert_engine = AlertEngine() + return _alert_engine + diff --git a/src/alerts/manager.py b/src/alerts/manager.py new file mode 100644 index 00000000..afbb0d9e --- /dev/null +++ b/src/alerts/manager.py @@ -0,0 +1,78 @@ +"""Alert management.""" + +from typing import Dict, Optional, List +from sqlalchemy import select +from src.core.database import get_database, Alert +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class AlertManager: + """Manages alerts.""" + + def __init__(self): + """Initialize alert manager.""" + self.db = get_database() + self.logger = get_logger(__name__) + + async def create_alert( + self, + name: str, + alert_type: str, + condition: Dict + ) -> Alert: + """Create a new alert. + + Args: + name: Alert name + alert_type: Alert type (price, indicator, risk, system) + condition: Alert condition configuration + + Returns: + Alert object + """ + async with self.db.get_session() as session: + try: + alert = Alert( + name=name, + alert_type=alert_type, + condition=condition, + enabled=True + ) + session.add(alert) + await session.commit() + await session.refresh(alert) + return alert + except Exception as e: + await session.rollback() + logger.error(f"Failed to create alert: {e}") + raise + + async def list_alerts(self, enabled_only: bool = False) -> List[Alert]: + """List all alerts. + + Args: + enabled_only: Only return enabled alerts + + Returns: + List of alerts + """ + async with self.db.get_session() as session: + stmt = select(Alert) + if enabled_only: + stmt = stmt.where(Alert.enabled == True) + result = await session.execute(stmt) + return result.scalars().all() + + +# Global alert manager +_alert_manager: Optional[AlertManager] = None + + +def get_alert_manager() -> AlertManager: + """Get global alert manager instance.""" + global _alert_manager + if _alert_manager is None: + _alert_manager = AlertManager() + return _alert_manager diff --git a/src/autopilot/__init__.py b/src/autopilot/__init__.py new file mode 100644 index 00000000..0cc51339 --- /dev/null +++ b/src/autopilot/__init__.py @@ -0,0 +1,150 @@ +"""AutoPilot autonomous trading engine module. + +This module provides an autonomous background service that combines +geometric pattern recognition with NLP-based sentiment analysis +to generate trade signals. + +Supported Patterns: +- Head and Shoulders (bullish/bearish) +- Double Top/Bottom +- Triple Top/Bottom +- Triangle patterns (Ascending, Descending, Symmetrical) +- Wedge patterns (Rising, Falling) +- Flag and Pennant patterns +- Candlestick patterns (Engulfing, Hammer, Doji, Stars, etc.) +- MA Crossovers (Golden Cross, Death Cross) +- Support/Resistance levels +- Harmonic patterns (ABCD) +- Gap patterns +""" + +from .intelligent_autopilot import ( + IntelligentAutopilot, + get_intelligent_autopilot, + stop_all_autopilots, +) +from .market_analyzer import ( + MarketAnalyzer, + MarketConditions, + MarketRegime, + get_market_analyzer, +) +from .strategy_selector import ( + StrategySelector, + get_strategy_selector, +) +from .performance_tracker import ( + PerformanceTracker, + get_performance_tracker, +) +from typing import Dict, Any + +def get_autopilot_mode_info() -> Dict[str, Any]: + """Get information about available autopilot modes. + + Returns: + Dictionary with mode information including descriptions, capabilities, and tradeoffs + """ + return { + "modes": { + "pattern": { + "name": "Pattern-Based Autopilot", + "description": "Detects technical chart patterns (Head & Shoulders, triangles, etc.) and combines with sentiment analysis", + "how_it_works": "Rule-based logic - pattern + sentiment alignment = signal", + "best_for": [ + "Users who want transparency", + "Users who understand technical analysis", + "Users who prefer explainable decisions" + ], + "tradeoffs": { + "pros": [ + "Transparent and explainable", + "No training data required", + "Fast and lightweight" + ], + "cons": [ + "Less adaptive to market changes", + "Fixed decision rules" + ] + }, + "features": [ + "Pattern recognition (40+ patterns)", + "Sentiment analysis (FinBERT)", + "Real-time signal generation", + "Transparent decision logic" + ], + "requirements": { + "training_data": False, + "setup_complexity": "Low", + "resource_usage": "Low" + } + }, + "intelligent": { + "name": "ML-Based Autopilot", + "description": "Uses machine learning to select the best strategy based on current market conditions", + "how_it_works": "ML model analyzes market conditions and selects optimal strategy from available strategies", + "best_for": [ + "Users who want adaptive, data-driven decisions", + "Users who don't need to understand every decision", + "Advanced users seeking optimization" + ], + "tradeoffs": { + "pros": [ + "Adapts to market conditions", + "Learns from historical performance", + "Can optimize strategy selection" + ], + "cons": [ + "Requires training data", + "Less transparent (black box)", + "More complex setup" + ] + }, + "features": [ + "ML-based strategy selection", + "Market condition analysis", + "Performance tracking", + "Auto-execution support" + ], + "requirements": { + "training_data": True, + "setup_complexity": "Medium", + "resource_usage": "Medium" + } + } + }, + "comparison": { + "transparency": { + "pattern": "High - All decisions are explainable", + "intelligent": "Low - ML model decisions are less transparent" + }, + "adaptability": { + "pattern": "Low - Fixed rules", + "intelligent": "High - Learns and adapts" + }, + "setup_time": { + "pattern": "Immediate - No setup required", + "intelligent": "Requires training data collection" + }, + "resource_usage": { + "pattern": "Low - Lightweight", + "intelligent": "Medium - ML model overhead" + } + } + } + + +__all__ = [ + "stop_all_autopilots", + "IntelligentAutopilot", + "get_intelligent_autopilot", + "MarketAnalyzer", + "MarketConditions", + "MarketRegime", + "get_market_analyzer", + "StrategySelector", + "get_strategy_selector", + "PerformanceTracker", + "get_performance_tracker", + "get_autopilot_mode_info", +] diff --git a/src/autopilot/intelligent_autopilot.py b/src/autopilot/intelligent_autopilot.py new file mode 100644 index 00000000..ee196f87 --- /dev/null +++ b/src/autopilot/intelligent_autopilot.py @@ -0,0 +1,717 @@ +"""Intelligent autopilot orchestrator with ML-based strategy selection.""" + +import asyncio +import json +from decimal import Decimal +from typing import Dict, Any, Optional, List, Tuple +from datetime import datetime, timedelta +import pandas as pd + +from src.core.logger import get_logger +from src.core.database import get_database, OrderSide, OrderType +from src.core.config import get_config +from src.core.redis import get_redis_client +from .market_analyzer import MarketConditions, get_market_analyzer +from .strategy_selector import get_strategy_selector +from .performance_tracker import get_performance_tracker +from src.strategies.base import get_strategy_registry, BaseStrategy, StrategySignal, SignalType +from src.trading.engine import get_trading_engine +from src.risk.manager import get_risk_manager +from src.data.collector import get_data_collector + +logger = get_logger(__name__) + +# Strategies that require user configuration (excluded from default autopilot) +# These are only loaded if the user has configured them in the Strategies page +STRATEGIES_REQUIRING_CONFIG = { + 'pairs_trading', # Requires second_symbol + 'grid', # Requires grid_spacing, num_levels + 'dca', # Requires amount, interval + 'market_making', # Requires spread_percent, max_inventory +} + + +class IntelligentAutopilot: + """Intelligent autopilot with ML-based strategy selection and auto-execution.""" + + def __init__( + self, + symbol: str, + exchange_id: int = 1, + timeframe: str = "1h", + interval: float = 60.0, + paper_trading: bool = True + ): + """Initialize intelligent autopilot. + + Args: + symbol: Trading symbol (e.g., "BTC/USD") + exchange_id: Exchange ID + timeframe: Analysis timeframe + interval: Analysis cycle interval in seconds + paper_trading: Paper trading mode + """ + self.symbol = symbol + self.exchange_id = exchange_id + self.timeframe = timeframe + self.interval = interval + self.paper_trading = paper_trading + + self.logger = get_logger(__name__) + self.config = get_config() + self.db = get_database() + self.redis = get_redis_client() + + # Core components + self.market_analyzer = get_market_analyzer() + self.strategy_selector = get_strategy_selector() + self.performance_tracker = get_performance_tracker() + self.strategy_registry = get_strategy_registry() + self.trading_engine = get_trading_engine() + self.risk_manager = get_risk_manager() + self.data_collector = get_data_collector() + + # Configuration + self.min_confidence_threshold = self.config.get( + "autopilot.intelligent.min_confidence_threshold", + 0.75 + ) + self.max_trades_per_day = self.config.get( + "autopilot.intelligent.max_trades_per_day", + 10 + ) + self.min_profit_target = self.config.get( + "autopilot.intelligent.min_profit_target", + 0.02 # 2% minimum expected profit + ) + self.enable_auto_execution = self.config.get( + "autopilot.intelligent.enable_auto_execution", + True + ) + + # State + self._local_running = False # Local flag for the loop + self._last_analysis: Optional[Dict[str, Any]] = None + self._selected_strategy: Optional[str] = None + self._strategy_instances: Dict[str, BaseStrategy] = {} + self._ohlcv_data: Optional[pd.DataFrame] = None + + # Redis Keys + safe_symbol = symbol.replace("/", "-") + self.key_running = f"autopilot:running:{safe_symbol}:{timeframe}" + self.key_trades = f"autopilot:trades:{safe_symbol}" + + # Initialize strategy instances + self._initialize_strategies() + + @property + def _trades_today_key(self) -> str: + """Get Redis key for today's trade count.""" + today = datetime.utcnow().strftime("%Y-%m-%d") + return f"{self.key_trades}:{today}" + + async def _fetch_market_data(self): + """Fetch OHLCV data from src.database.""" + from sqlalchemy import select + try: + async with self.db.get_session() as session: + from src.core.database import MarketData, Exchange + + # Get exchange name + stmt = select(Exchange).filter_by(id=self.exchange_id).limit(1) + result = await session.execute(stmt) + exchange = result.scalar_one_or_none() + exchange_name = exchange.name if exchange else "coinbase" + + # Fetch recent OHLCV data (last 200 candles for analysis) + ohlcv_stmt = select(MarketData).filter_by( + exchange=exchange_name, + symbol=self.symbol, + timeframe=self.timeframe + ).order_by(MarketData.timestamp.desc()).limit(200) + + ohlcv_result = await session.execute(ohlcv_stmt) + market_data = ohlcv_result.scalars().all() + + if market_data: + # Convert to DataFrame + data = { + 'timestamp': [md.timestamp for md in reversed(market_data)], + 'open': [float(md.open) for md in reversed(market_data)], + 'high': [float(md.high) for md in reversed(market_data)], + 'low': [float(md.low) for md in reversed(market_data)], + 'close': [float(md.close) for md in reversed(market_data)], + 'volume': [float(md.volume) for md in reversed(market_data)], + } + self._ohlcv_data = pd.DataFrame(data) + self.logger.info(f"Loaded {len(self._ohlcv_data)} candles from src.database") + else: + self.logger.warning("No market data found in database") + + except Exception as e: + self.logger.error(f"Error fetching market data: {e}") + + def _initialize_strategies(self): + """Initialize strategy instances for strategies that work without configuration.""" + available_strategies = self.strategy_registry.list_available() + + for strategy_name in available_strategies: + # Skip strategies that require user configuration + if strategy_name.lower() in STRATEGIES_REQUIRING_CONFIG: + self.logger.debug(f"Skipping {strategy_name} (requires configuration)") + continue + + try: + # Create instance with default parameters + strategy_class = self.strategy_registry._strategies.get(strategy_name.lower()) + if strategy_class: + instance = strategy_class( + name=strategy_name, + parameters={}, + timeframes=[self.timeframe] + ) + instance.enabled = True + self._strategy_instances[strategy_name] = instance + self.logger.debug(f"Initialized strategy: {strategy_name}") + except Exception as e: + self.logger.warning(f"Failed to initialize strategy {strategy_name}: {e}") + + async def _load_user_configured_strategies(self): + """Load user-configured strategies from database. + + This enables strategies that require configuration (like pairs_trading) + when the user has set them up in the Strategies page. + Also overrides default parameters if user has configured them. + """ + from sqlalchemy import select + from src.core.database import Strategy + + try: + async with self.db.get_session() as session: + # Get all enabled strategies from database + stmt = select(Strategy).where(Strategy.enabled == True) + result = await session.execute(stmt) + user_strategies = result.scalars().all() + + for db_strategy in user_strategies: + strategy_type = db_strategy.strategy_type + if not strategy_type: + continue + + strategy_type_lower = strategy_type.lower() + + # Check if this strategy type is registered + strategy_class = self.strategy_registry._strategies.get(strategy_type_lower) + if not strategy_class: + self.logger.debug(f"Strategy type {strategy_type} not registered") + continue + + try: + # Create instance with user's parameters + instance = strategy_class( + name=db_strategy.name, + parameters=db_strategy.parameters or {}, + timeframes=db_strategy.timeframes or [self.timeframe] + ) + instance.enabled = True + + # Use strategy_type as key (overwrites default if exists) + self._strategy_instances[strategy_type_lower] = instance + + self.logger.info( + f"Loaded user-configured strategy: {db_strategy.name} " + f"(type: {strategy_type}, params: {db_strategy.parameters})" + ) + except Exception as e: + self.logger.warning( + f"Failed to load user strategy {db_strategy.name}: {e}" + ) + + except Exception as e: + self.logger.error(f"Error loading user-configured strategies: {e}") + + def update_market_data(self, ohlcv_data: pd.DataFrame): + """Update OHLCV market data. + + Args: + ohlcv_data: DataFrame with columns: open, high, low, close, volume + """ + self._ohlcv_data = ohlcv_data.copy() + self.logger.debug(f"Updated market data: {len(ohlcv_data)} candles") + + async def start(self): + """Start the intelligent autopilot loop.""" + redis_client = self.redis.get_client() + + # Check if already running (distributed lock style) + is_running = await redis_client.get(self.key_running) + if is_running: + self.logger.warning(f"Autopilot for {self.symbol} is already running elsewhere") + return + + # Set running state in Redis + await redis_client.set(self.key_running, "1") + self._local_running = True + + self.logger.info( + f"Intelligent autopilot started for {self.symbol} " + f"(timeframe: {self.timeframe}, interval: {self.interval}s)" + ) + + # Initial data fetch + await self._fetch_market_data() + + # Try to load or train model + try: + # Check if this task should be offloaded to Celery in future + await self.strategy_selector.train_model(force_retrain=False) + except Exception as e: + self.logger.warning(f"Model training failed, will use fallback: {e}") + + # Load user-configured strategies from database + await self._load_user_configured_strategies() + + self.logger.info( + f"Autopilot has {len(self._strategy_instances)} strategies available: " + f"{list(self._strategy_instances.keys())}" + ) + + try: + while self._local_running: + # Distributed check: verify key still exists + if not await redis_client.exists(self.key_running): + self.logger.info("Remote stop signal received") + break + + try: + await self._analysis_cycle() + except Exception as e: + self.logger.error(f"Analysis cycle error: {e}") + + await asyncio.sleep(self.interval) + finally: + self._local_running = False + # Ensure key is deleted (cleanup) + await redis_client.delete(self.key_running) + self.logger.info("Intelligent autopilot stopped") + + async def _analysis_cycle(self): + """Perform one analysis and execution cycle.""" + # Check daily trade limit from Redis + trades_today = await self._get_trades_today() + + if trades_today >= self.max_trades_per_day: + self.logger.debug(f"Daily trade limit reached: {trades_today}/{self.max_trades_per_day}") + return + + # Get market data (refresh if needed) + if self._ohlcv_data is None or len(self._ohlcv_data) < 50: + await self._fetch_market_data() + if self._ohlcv_data is None or len(self._ohlcv_data) < 50: + self.logger.warning("Insufficient market data for analysis") + return + + # Analyze market conditions + market_conditions = self.market_analyzer.analyze_current_conditions( + symbol=self.symbol, + timeframe=self.timeframe, + ohlcv_data=self._ohlcv_data + ) + + # Select best strategy using ML + selection_result = self.strategy_selector.select_best_strategy( + market_conditions, + min_confidence=self.min_confidence_threshold + ) + + if selection_result is None: + self.logger.debug("No strategy selected (confidence too low)") + self._last_analysis = { + 'market_conditions': market_conditions.to_dict(), + 'selected_strategy': None, + 'confidence': 0.0 + } + return + + selected_strategy_name, confidence, all_predictions = selection_result + self._selected_strategy = selected_strategy_name + + # Get strategy instance + strategy_instance = self._strategy_instances.get(selected_strategy_name) + if not strategy_instance: + self.logger.error(f"Strategy instance not found: {selected_strategy_name}") + return + + # Generate signal from selected strategy + current_price = Decimal(str(self._ohlcv_data['close'].iloc[-1])) + signal = await self._generate_strategy_signal( + strategy_instance, + current_price + ) + + if signal is None or signal.signal_type == SignalType.HOLD: + self.logger.debug(f"No actionable signal from {selected_strategy_name}") + self._last_analysis = { + 'market_conditions': market_conditions.to_dict(), + 'selected_strategy': selected_strategy_name, + 'confidence': confidence, + 'signal': None + } + return + + # Evaluate opportunity + if not self._evaluate_opportunity(signal, confidence, market_conditions): + self.logger.debug("Opportunity does not meet execution criteria") + return + + # Execute trade + if self.enable_auto_execution: + await self._execute_opportunity(strategy_instance, signal, market_conditions) + else: + self.logger.info(f"Auto-execution disabled. Signal: {signal.signal_type.value}") + + # Store analysis result + self._last_analysis = { + 'market_conditions': market_conditions.to_dict(), + 'selected_strategy': selected_strategy_name, + 'confidence': confidence, + 'signal': { + 'type': signal.signal_type.value, + 'strength': signal.strength, + 'price': float(signal.price) if signal.price else None + } + } + + async def _generate_strategy_signal( + self, + strategy: BaseStrategy, + current_price: Decimal + ) -> Optional[StrategySignal]: + """Generate signal from strategy.""" + try: + # Prepare market data for strategy + latest_candle = self._ohlcv_data.iloc[-1] + data = { + 'open': latest_candle['open'], + 'high': latest_candle['high'], + 'low': latest_candle['low'], + 'volume': latest_candle['volume'] + } + + # Generate signal + signal = await strategy.on_tick( + symbol=self.symbol, + price=current_price, + timeframe=self.timeframe, + data=data + ) + + if signal: + # Process signal + signal = strategy.on_signal(signal) + + return signal + + except Exception as e: + self.logger.error(f"Error generating strategy signal: {e}") + return None + + def _evaluate_opportunity( + self, + signal: StrategySignal, + confidence: float, + market_conditions: MarketConditions + ) -> bool: + """Evaluate if opportunity meets execution criteria.""" + # Check confidence threshold + if confidence < self.min_confidence_threshold: + return False + + # Check signal strength + if signal.strength < 0.5: + return False + + return True + + async def _can_execute_order( + self, + side: OrderSide, + quantity: Decimal, + price: Decimal + ) -> Tuple[bool, str]: + """Pre-flight check if order can be executed.""" + # 1. Check minimum order value ($1 USD) + order_value = quantity * price + if order_value < Decimal('1.0'): + return False, f"Order value ${order_value:.2f} below minimum $1.00" + + # 2. For BUY: check sufficient balance (include fee buffer) + if side == OrderSide.BUY: + if self.paper_trading: + balance = self.trading_engine.paper_trading.get_balance() + else: + balance = Decimal('0') # Live trading balance check handled elsewhere + + # Add 1% fee buffer + fee_estimate = order_value * Decimal('0.01') + total_required = order_value + fee_estimate + + if balance < total_required: + return False, f"Insufficient funds: ${balance:.2f} < ${total_required:.2f}" + + # 3. For SELL: check position exists with sufficient quantity + if side == OrderSide.SELL: + if self.paper_trading: + positions = self.trading_engine.paper_trading.get_positions() + position = next((p for p in positions if p.symbol == self.symbol), None) + + if not position: + return False, f"No position to sell for {self.symbol}" + + if position.quantity < quantity: + return False, f"Insufficient position: {position.quantity} < {quantity}" + + return True, "OK" + + def _determine_order_type_and_price( + self, + side: OrderSide, + signal_strength: float, + current_price: Decimal, + is_stop_loss: bool = False + ) -> Tuple[OrderType, Optional[Decimal]]: + """Determine optimal order type and price for execution.""" + # Strong signals (>80% strength) or stop-loss use MARKET for speed + if signal_strength > 0.8 or is_stop_loss: + reason = "stop-loss" if is_stop_loss else f"high strength ({signal_strength:.2f})" + self.logger.debug(f"Using MARKET order ({reason})") + return OrderType.MARKET, None + + # Normal signals use LIMIT for better price + # BUY: bid slightly below market (0.1% discount) + # SELL (take-profit): ask slightly above market (0.1% premium) + if side == OrderSide.BUY: + limit_price = current_price * Decimal('0.999') # 0.1% below + else: + limit_price = current_price * Decimal('1.001') # 0.1% above + + # Round to reasonable precision (2 decimal places for USD pairs) + limit_price = limit_price.quantize(Decimal('0.01')) + + return OrderType.LIMIT, limit_price + + async def _execute_opportunity( + self, + strategy: BaseStrategy, + signal: StrategySignal, + market_conditions: MarketConditions + ): + """Execute trade opportunity.""" + try: + current_price = signal.price or Decimal(str(self._ohlcv_data['close'].iloc[-1])) + + # Calculate position size + balance = self.trading_engine.paper_trading.get_balance() if self.paper_trading else Decimal(0) + quantity = strategy.calculate_position_size(signal, balance, current_price) + + if quantity <= 0: + self.logger.warning("Invalid position size calculated") + return + + # Determine order side + if signal.signal_type == SignalType.BUY: + side = OrderSide.BUY + elif signal.signal_type == SignalType.SELL: + side = OrderSide.SELL + else: + self.logger.warning(f"Unsupported signal type: {signal.signal_type}") + return + + # Pre-flight validation - skip order if it would fail + can_execute, reason = await self._can_execute_order(side, quantity, current_price) + if not can_execute: + self.logger.info(f"Skipping order ({strategy.name}): {reason}") + return + + # Determine optimal order type + is_stop_loss = False + if side == OrderSide.SELL and self.paper_trading: + positions = self.trading_engine.paper_trading.get_positions() + position = next((p for p in positions if p.symbol == self.symbol), None) + if position: + is_stop_loss = current_price < position.entry_price + + order_type, limit_price = self._determine_order_type_and_price( + side=side, + signal_strength=signal.strength, + current_price=current_price, + is_stop_loss=is_stop_loss + ) + + # Execute order with smart order type + order = await self.trading_engine.execute_order( + exchange_id=self.exchange_id, + strategy_id=None, # Intelligent autopilot doesn't use strategy_id + symbol=self.symbol, + side=side, + order_type=order_type, + quantity=quantity, + price=limit_price, # None for MARKET, price for LIMIT + paper_trading=self.paper_trading + ) + + order_type_str = "LIMIT" if order_type == OrderType.LIMIT else "MARKET" + if order: + self.logger.info( + f"Executed {side.value} {order_type_str} order: {quantity} {self.symbol} " + f"at {limit_price or current_price} (strategy: {strategy.name})" + ) + + # Increment Redis trade counter + await self._increment_trades_today() + + # Record trade for ML training (async, don't wait) + asyncio.create_task(self._record_trade_for_learning( + strategy.name, + market_conditions, + order + )) + else: + self.logger.warning("Order execution failed") + + except Exception as e: + self.logger.error(f"Error executing opportunity: {e}") + + async def _record_trade_for_learning( + self, + strategy_name: str, + market_conditions: MarketConditions, + order: Any + ): + """Record trade for ML learning (called after trade completes).""" + try: + # Wait a bit for order to complete + await asyncio.sleep(5) + + trade_result = { + 'return_pct': 0.0, + 'sharpe_ratio': 0.0, + 'win_rate': 0.0, + 'max_drawdown': 0.0, + 'trade_count': 1 + } + + await self.performance_tracker.record_trade( + strategy_name=strategy_name, + market_conditions=market_conditions, + trade_result=trade_result + ) + + except Exception as e: + self.logger.error(f"Error recording trade for learning: {e}") + + async def _get_trades_today(self) -> int: + """Get number of trades executed today from Redis.""" + try: + redis_client = self.redis.get_client() + count = await redis_client.get(self._trades_today_key) + return int(count) if count else 0 + except Exception as e: + self.logger.error(f"Error getting trade count from Redis: {e}") + return 0 + + async def _increment_trades_today(self): + """Increment daily trade counter in Redis.""" + try: + redis_client = self.redis.get_client() + key = self._trades_today_key + + # Increment + await redis_client.incr(key) + + # Set expiry to 24 hours (if new key) + if await redis_client.ttl(key) == -1: + await redis_client.expire(key, 86400) + + except Exception as e: + self.logger.error(f"Error incrementing trade count in Redis: {e}") + + def stop(self): + """Stop the intelligent autopilot (signals distributed stop).""" + # Synchronous method to trigger stop + # Since we need to delete Redis key async, we create a task + asyncio.create_task(self._stop_async()) + + async def _stop_async(self): + """Async stop implementation.""" + self._local_running = False + redis_client = self.redis.get_client() + await redis_client.delete(self.key_running) + self.logger.info("Intelligent autopilot stopping... (signal sent)") + + @property + def is_running(self) -> bool: + """Check if autopilot is running (Local check).""" + return self._local_running + + def get_status(self) -> Dict[str, Any]: + """Get current autopilot status (Synchronous wrapper).""" + return { + 'symbol': self.symbol, + 'timeframe': self.timeframe, + 'running': self._local_running, + 'selected_strategy': self._selected_strategy, + 'trades_today': 0, # Fetch async if needed via get_distributed_status + 'max_trades_per_day': self.max_trades_per_day, + 'min_confidence_threshold': self.min_confidence_threshold, + 'enable_auto_execution': self.enable_auto_execution, + 'last_analysis': self._last_analysis, + 'model_info': self.strategy_selector.get_model_info() + } + + async def get_distributed_status(self) -> Dict[str, Any]: + """Get full distributed status (Async).""" + try: + redis_client = self.redis.get_client() + is_running_distributed = await redis_client.exists(self.key_running) + trades_today = await self._get_trades_today() + + status = self.get_status() + status['running'] = bool(is_running_distributed) + status['trades_today'] = trades_today + return status + except Exception: + # Fallback to local status if Redis fails + return self.get_status() + + +# Global instances (factory cache) +_intelligent_autopilots: Dict[str, IntelligentAutopilot] = {} + + +def get_intelligent_autopilot( + symbol: str, + exchange_id: int = 1, + timeframe: str = "1h", + **kwargs +) -> IntelligentAutopilot: + """Get or create intelligent autopilot instance.""" + key = f"{symbol}:{exchange_id}:{timeframe}" + if key not in _intelligent_autopilots: + _intelligent_autopilots[key] = IntelligentAutopilot( + symbol=symbol, + exchange_id=exchange_id, + timeframe=timeframe, + **kwargs + ) + logger.info(f"Created new IntelligentAutopilot instance for {key}") + + return _intelligent_autopilots[key] + + +def stop_all_autopilots(): + """Stop all running IntelligentAutoPilot instances.""" + for key, autopilot in _intelligent_autopilots.items(): + if autopilot.is_running: + autopilot.stop() + logger.info(f"Stopped IntelligentAutopilot for {key}") diff --git a/src/autopilot/market_analyzer.py b/src/autopilot/market_analyzer.py new file mode 100644 index 00000000..0924fdf4 --- /dev/null +++ b/src/autopilot/market_analyzer.py @@ -0,0 +1,485 @@ +"""Market condition analyzer for intelligent autopilot. + +Analyzes real-time market conditions and extracts features for ML model. +""" + +from decimal import Decimal +from typing import Dict, Any, Optional +from enum import Enum +from dataclasses import dataclass +import pandas as pd +import numpy as np + +from src.core.logger import get_logger +from src.data.indicators import get_indicators + +logger = get_logger(__name__) + + +class MarketRegime(str, Enum): + """Market regime classification.""" + TRENDING_UP = "trending_up" + TRENDING_DOWN = "trending_down" + RANGING = "ranging" + HIGH_VOLATILITY = "high_volatility" + LOW_VOLATILITY = "low_volatility" + BREAKOUT = "breakout" + REVERSAL = "reversal" + UNKNOWN = "unknown" + + +@dataclass +class MarketConditions: + """Market conditions snapshot.""" + symbol: str + timeframe: str + regime: MarketRegime + features: Dict[str, float] + timestamp: pd.Timestamp + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for storage.""" + return { + 'symbol': self.symbol, + 'timeframe': self.timeframe, + 'regime': self.regime.value, + 'features': self.features, + 'timestamp': self.timestamp.isoformat() + } + + +class MarketAnalyzer: + """Analyzes market conditions and extracts features for ML.""" + + def __init__(self): + """Initialize market analyzer.""" + self.logger = get_logger(__name__) + self.indicators = get_indicators() + + def analyze_current_conditions( + self, + symbol: str, + timeframe: str, + ohlcv_data: pd.DataFrame + ) -> MarketConditions: + """Analyze current market conditions. + + Args: + symbol: Trading symbol + timeframe: Timeframe + ohlcv_data: OHLCV DataFrame with columns: open, high, low, close, volume + + Returns: + MarketConditions object + """ + if len(ohlcv_data) < 50: + self.logger.warning(f"Insufficient data for analysis: {len(ohlcv_data)} candles") + return MarketConditions( + symbol=symbol, + timeframe=timeframe, + regime=MarketRegime.UNKNOWN, + features={}, + timestamp=pd.Timestamp.now() + ) + + # Extract features + features = self.extract_features(ohlcv_data) + + # Classify market regime + regime = self.classify_market_regime(features, ohlcv_data) + + return MarketConditions( + symbol=symbol, + timeframe=timeframe, + regime=regime, + features=features, + timestamp=pd.Timestamp.now() + ) + + def extract_features(self, df: pd.DataFrame) -> Dict[str, float]: + """Extract comprehensive market features. + + Args: + df: OHLCV DataFrame + + Returns: + Dictionary of feature names to values + """ + features = {} + + try: + close = df['close'] + high = df['high'] + low = df['low'] + open_price = df['open'] + volume = df['volume'] + + # Trend Features + features['sma_20'] = float(close.rolling(20).mean().iloc[-1]) + features['sma_50'] = float(close.rolling(50).mean().iloc[-1]) if len(close) >= 50 else features['sma_20'] + features['ema_12'] = float(self.indicators.ema(close, period=12).iloc[-1]) + features['ema_26'] = float(self.indicators.ema(close, period=26).iloc[-1]) if len(close) >= 26 else features['ema_12'] + + # Price position relative to MAs + current_price = float(close.iloc[-1]) + features['price_vs_sma20'] = (current_price - features['sma_20']) / features['sma_20'] if features['sma_20'] > 0 else 0.0 + features['price_vs_sma50'] = (current_price - features['sma_50']) / features['sma_50'] if features['sma_50'] > 0 else 0.0 + features['sma20_vs_sma50'] = (features['sma_20'] - features['sma_50']) / features['sma_50'] if features['sma_50'] > 0 else 0.0 + + # Trend strength (ADX) + if len(df) >= 14: + adx = self.indicators.adx(high, low, close, period=14) + features['adx'] = float(adx.iloc[-1]) if not pd.isna(adx.iloc[-1]) else 0.0 + else: + features['adx'] = 0.0 + + # Momentum Features + if len(close) >= 14: + rsi = self.indicators.rsi(close, period=14) + features['rsi'] = float(rsi.iloc[-1]) if not pd.isna(rsi.iloc[-1]) else 50.0 + else: + features['rsi'] = 50.0 + + # MACD + if len(close) >= 26: + macd_result = self.indicators.macd(close, fast=12, slow=26, signal=9) + features['macd'] = float(macd_result['macd'].iloc[-1]) if not pd.isna(macd_result['macd'].iloc[-1]) else 0.0 + features['macd_signal'] = float(macd_result['signal'].iloc[-1]) if not pd.isna(macd_result['signal'].iloc[-1]) else 0.0 + features['macd_histogram'] = float(macd_result['histogram'].iloc[-1]) if not pd.isna(macd_result['histogram'].iloc[-1]) else 0.0 + else: + features['macd'] = 0.0 + features['macd_signal'] = 0.0 + features['macd_histogram'] = 0.0 + + # Volatility Features + if len(df) >= 14: + atr = self.indicators.atr(high, low, close, period=14) + features['atr'] = float(atr.iloc[-1]) if not pd.isna(atr.iloc[-1]) else 0.0 + features['atr_percent'] = (features['atr'] / current_price) * 100 if current_price > 0 else 0.0 + else: + features['atr'] = 0.0 + features['atr_percent'] = 0.0 + + # Bollinger Bands + if len(close) >= 20: + bb = self.indicators.bollinger_bands(close, period=20, std_dev=2) + features['bb_upper'] = float(bb['upper'].iloc[-1]) if not pd.isna(bb['upper'].iloc[-1]) else current_price + features['bb_lower'] = float(bb['lower'].iloc[-1]) if not pd.isna(bb['lower'].iloc[-1]) else current_price + features['bb_middle'] = float(bb['middle'].iloc[-1]) if not pd.isna(bb['middle'].iloc[-1]) else current_price + features['bb_width'] = (features['bb_upper'] - features['bb_lower']) / features['bb_middle'] if features['bb_middle'] > 0 else 0.0 + features['bb_position'] = (current_price - features['bb_lower']) / (features['bb_upper'] - features['bb_lower']) if (features['bb_upper'] - features['bb_lower']) > 0 else 0.5 + else: + features['bb_upper'] = current_price + features['bb_lower'] = current_price + features['bb_middle'] = current_price + features['bb_width'] = 0.0 + features['bb_position'] = 0.5 + + # Volume Features + if len(volume) >= 20: + volume_ma = volume.rolling(20).mean() + features['volume_ratio'] = float(volume.iloc[-1] / volume_ma.iloc[-1]) if volume_ma.iloc[-1] > 0 else 1.0 + features['volume_trend'] = float((volume.iloc[-5:].mean() - volume.iloc[-20:-5].mean()) / volume.iloc[-20:-5].mean()) if volume.iloc[-20:-5].mean() > 0 else 0.0 + else: + features['volume_ratio'] = 1.0 + features['volume_trend'] = 0.0 + + # OBV (On-Balance Volume) + if len(close) >= 10: + obv = self.indicators.obv(close, volume) + features['obv_trend'] = float((obv.iloc[-1] - obv.iloc[-10]) / abs(obv.iloc[-10])) if obv.iloc[-10] != 0 else 0.0 + else: + features['obv_trend'] = 0.0 + + # Price Action Features + features['price_change_1'] = float((close.iloc[-1] - close.iloc[-2]) / close.iloc[-2]) if len(close) >= 2 and close.iloc[-2] > 0 else 0.0 + features['price_change_5'] = float((close.iloc[-1] - close.iloc[-6]) / close.iloc[-6]) if len(close) >= 6 and close.iloc[-6] > 0 else 0.0 + features['price_change_20'] = float((close.iloc[-1] - close.iloc[-21]) / close.iloc[-21]) if len(close) >= 21 and close.iloc[-21] > 0 else 0.0 + + # High-Low Range + features['high_low_range'] = float((high.iloc[-1] - low.iloc[-1]) / current_price) if current_price > 0 else 0.0 + features['high_low_range_5'] = float((high.iloc[-5:].max() - low.iloc[-5:].min()) / current_price) if current_price > 0 else 0.0 + + # Candlestick Patterns (simplified) + if len(df) >= 3: + features['is_bullish_candle'] = 1.0 if close.iloc[-1] > open_price.iloc[-1] else 0.0 + features['candle_body_size'] = float(abs(close.iloc[-1] - open_price.iloc[-1]) / current_price) if current_price > 0 else 0.0 + features['candle_wick_upper'] = float((high.iloc[-1] - max(close.iloc[-1], open_price.iloc[-1])) / current_price) if current_price > 0 else 0.0 + features['candle_wick_lower'] = float((min(close.iloc[-1], open_price.iloc[-1]) - low.iloc[-1]) / current_price) if current_price > 0 else 0.0 + else: + features['is_bullish_candle'] = 0.5 + features['candle_body_size'] = 0.0 + features['candle_wick_upper'] = 0.0 + features['candle_wick_lower'] = 0.0 + + # Support/Resistance Proximity (simplified - using recent highs/lows) + if len(df) >= 20: + recent_high = float(high.iloc[-20:].max()) + recent_low = float(low.iloc[-20:].min()) + features['distance_to_resistance'] = (recent_high - current_price) / current_price if current_price > 0 else 0.0 + features['distance_to_support'] = (current_price - recent_low) / current_price if current_price > 0 else 0.0 + else: + features['distance_to_resistance'] = 0.0 + features['distance_to_support'] = 0.0 + + # ========================================== + # NEW FEATURES FOR IMPROVED ML ACCURACY + # ========================================== + + # Volatility Percentile - current ATR vs 30-day rolling ATR + if len(df) >= 30: + atr_series = self.indicators.atr(high, low, close, period=14) + atr_30_mean = float(atr_series.iloc[-30:].mean()) if len(atr_series) >= 30 else features.get('atr', 0) + features['volatility_percentile'] = features.get('atr', 0) / atr_30_mean if atr_30_mean > 0 else 1.0 + else: + features['volatility_percentile'] = 1.0 + + # Momentum Delta - 3-period vs 10-period price change (acceleration) + if len(close) >= 11: + momentum_3 = float((close.iloc[-1] - close.iloc[-4]) / close.iloc[-4]) if close.iloc[-4] > 0 else 0.0 + momentum_10 = float((close.iloc[-1] - close.iloc[-11]) / close.iloc[-11]) if close.iloc[-11] > 0 else 0.0 + features['momentum_3'] = momentum_3 + features['momentum_10'] = momentum_10 + features['momentum_delta'] = momentum_3 - momentum_10 # Positive = accelerating + else: + features['momentum_3'] = 0.0 + features['momentum_10'] = 0.0 + features['momentum_delta'] = 0.0 + + # Trend Strength Change - ADX rate of change + if len(df) >= 20: + adx_series = self.indicators.adx(high, low, close, period=14) + if len(adx_series) >= 5: + adx_current = float(adx_series.iloc[-1]) if not pd.isna(adx_series.iloc[-1]) else 0.0 + adx_prev = float(adx_series.iloc[-5]) if not pd.isna(adx_series.iloc[-5]) else adx_current + features['adx_change'] = adx_current - adx_prev # Positive = strengthening trend + else: + features['adx_change'] = 0.0 + else: + features['adx_change'] = 0.0 + + # Volume Climax Detection - extreme volume with reversal candle + if len(df) >= 20: + volume_std = float(volume.iloc[-20:].std()) + volume_mean = float(volume.iloc[-20:].mean()) + current_volume = float(volume.iloc[-1]) + z_score = (current_volume - volume_mean) / volume_std if volume_std > 0 else 0.0 + is_reversal = (close.iloc[-1] < open_price.iloc[-1]) != (close.iloc[-2] < open_price.iloc[-2]) + features['volume_z_score'] = z_score + features['volume_climax'] = 1.0 if (z_score > 2.0 and is_reversal) else 0.0 + else: + features['volume_z_score'] = 0.0 + features['volume_climax'] = 0.0 + + # RSI Extremes - overbought/oversold signals + rsi_value = features.get('rsi', 50.0) + features['rsi_oversold'] = 1.0 if rsi_value < 30 else 0.0 + features['rsi_overbought'] = 1.0 if rsi_value > 70 else 0.0 + features['rsi_extreme'] = 1.0 if (rsi_value < 25 or rsi_value > 75) else 0.0 + + # Price Position in Range - where is price in N-day range + if len(df) >= 20: + range_high = float(high.iloc[-20:].max()) + range_low = float(low.iloc[-20:].min()) + range_size = range_high - range_low + features['price_in_range'] = (current_price - range_low) / range_size if range_size > 0 else 0.5 + else: + features['price_in_range'] = 0.5 + + # Trend Alignment - short term vs medium term + sma_10 = float(close.rolling(10).mean().iloc[-1]) if len(close) >= 10 else current_price + features['sma_10'] = sma_10 + features['trend_aligned'] = 1.0 if (sma_10 > features.get('sma_20', sma_10) > features.get('sma_50', sma_10)) or \ + (sma_10 < features.get('sma_20', sma_10) < features.get('sma_50', sma_10)) else 0.0 + + # Consecutive Candles - streak of bullish/bearish candles + if len(df) >= 5: + bullish_streak = 0 + bearish_streak = 0 + for i in range(1, 6): + if close.iloc[-i] > open_price.iloc[-i]: + if bearish_streak == 0: + bullish_streak += 1 + else: + break + else: + if bullish_streak == 0: + bearish_streak += 1 + else: + break + features['bullish_streak'] = float(bullish_streak) + features['bearish_streak'] = float(bearish_streak) + else: + features['bullish_streak'] = 0.0 + features['bearish_streak'] = 0.0 + + # MACD Crossover proximity + macd_val = features.get('macd', 0.0) + macd_sig = features.get('macd_signal', 0.0) + if macd_sig != 0: + features['macd_signal_ratio'] = macd_val / abs(macd_sig) if macd_sig != 0 else 0.0 + else: + features['macd_signal_ratio'] = 0.0 + + # Bollinger Band squeeze detection + bb_width = features.get('bb_width', 0.0) + if len(df) >= 30: + bb_history = [] + for i in range(30): + if len(close) > i + 20: + bb_temp = self.indicators.bollinger_bands(close.iloc[:-i-1] if i > 0 else close, period=20, std_dev=2) + if 'upper' in bb_temp and 'lower' in bb_temp: + width = (float(bb_temp['upper'].iloc[-1]) - float(bb_temp['lower'].iloc[-1])) / float(bb_temp['middle'].iloc[-1]) if not pd.isna(bb_temp['middle'].iloc[-1]) and bb_temp['middle'].iloc[-1] > 0 else 0 + bb_history.append(width) + if bb_history: + avg_width = np.mean(bb_history) + features['bb_squeeze'] = 1.0 if bb_width < avg_width * 0.7 else 0.0 + else: + features['bb_squeeze'] = 0.0 + else: + features['bb_squeeze'] = 0.0 + + # ========================================== + # SENTIMENT FEATURES + # ========================================== + + # Fear & Greed Index (0-100, 0=extreme fear, 100=extreme greed) + # Fetch from API or use cached value + fear_greed = self._get_fear_greed_index() + features['fear_greed_index'] = fear_greed + # Normalize to -1 to 1 range (fear negative, greed positive) + features['fear_greed_normalized'] = (fear_greed - 50) / 50 + # Binary indicators + features['is_extreme_fear'] = 1.0 if fear_greed < 25 else 0.0 + features['is_extreme_greed'] = 1.0 if fear_greed > 75 else 0.0 + + except Exception as e: + self.logger.error(f"Error extracting features: {e}") + # Return empty features on error + return {} + + return features + + def _get_fear_greed_index(self) -> float: + """Fetch the current Fear & Greed Index. + + Uses alternative.me API for Bitcoin Fear & Greed Index. + Falls back to neutral (50) if unavailable. + + Returns: + Fear & Greed value 0-100 + """ + try: + import requests + + # Check cache + cache_key = '_fear_greed_cache' + cache_time_key = '_fear_greed_cache_time' + + if hasattr(self, cache_key) and hasattr(self, cache_time_key): + # Cache for 1 hour + cache_age = (pd.Timestamp.now() - getattr(self, cache_time_key)).total_seconds() + if cache_age < 3600: + return getattr(self, cache_key) + + # Fetch from API + response = requests.get( + "https://api.alternative.me/fng/?limit=1", + timeout=5 + ) + if response.status_code == 200: + data = response.json() + if data.get('data') and len(data['data']) > 0: + value = float(data['data'][0].get('value', 50)) + # Cache the value + setattr(self, cache_key, value) + setattr(self, cache_time_key, pd.Timestamp.now()) + return value + except Exception as e: + self.logger.debug(f"Could not fetch Fear & Greed Index: {e}") + + # Return neutral if unavailable + return 50.0 + + def classify_market_regime( + self, + features: Dict[str, float], + df: pd.DataFrame + ) -> MarketRegime: + """Classify market regime based on features. + + Args: + features: Extracted features + df: OHLCV DataFrame + + Returns: + MarketRegime classification + """ + if not features: + return MarketRegime.UNKNOWN + + try: + close = df['close'] + current_price = float(close.iloc[-1]) + + # Check for trending conditions + adx = features.get('adx', 0.0) + price_vs_sma20 = features.get('price_vs_sma20', 0.0) + sma20_vs_sma50 = features.get('sma20_vs_sma50', 0.0) + + # Strong uptrend + if adx > 25 and price_vs_sma20 > 0.01 and sma20_vs_sma50 > 0.01: + return MarketRegime.TRENDING_UP + + # Strong downtrend + if adx > 25 and price_vs_sma20 < -0.01 and sma20_vs_sma50 < -0.01: + return MarketRegime.TRENDING_DOWN + + # Check volatility + atr_percent = features.get('atr_percent', 0.0) + bb_width = features.get('bb_width', 0.0) + + if atr_percent > 3.0 or bb_width > 0.05: + return MarketRegime.HIGH_VOLATILITY + + if atr_percent < 1.0 and bb_width < 0.02: + return MarketRegime.LOW_VOLATILITY + + # Check for ranging (low ADX, price oscillating) + if adx < 20: + # Check if price is oscillating around MAs + price_change_20 = features.get('price_change_20', 0.0) + if abs(price_change_20) < 0.05: # Less than 5% change over 20 periods + return MarketRegime.RANGING + + # Check for breakout + bb_position = features.get('bb_position', 0.5) + volume_ratio = features.get('volume_ratio', 1.0) + if (bb_position > 0.95 or bb_position < 0.05) and volume_ratio > 1.5: + return MarketRegime.BREAKOUT + + # Check for reversal signals + rsi = features.get('rsi', 50.0) + macd_histogram = features.get('macd_histogram', 0.0) + if (rsi > 70 and macd_histogram < 0) or (rsi < 30 and macd_histogram > 0): + return MarketRegime.REVERSAL + + # Default to ranging if unclear + return MarketRegime.RANGING + + except Exception as e: + self.logger.error(f"Error classifying market regime: {e}") + return MarketRegime.UNKNOWN + + +# Global instance +_market_analyzer: Optional[MarketAnalyzer] = None + + +def get_market_analyzer() -> MarketAnalyzer: + """Get global market analyzer instance.""" + global _market_analyzer + if _market_analyzer is None: + _market_analyzer = MarketAnalyzer() + return _market_analyzer + diff --git a/src/autopilot/models.py b/src/autopilot/models.py new file mode 100644 index 00000000..439e7d14 --- /dev/null +++ b/src/autopilot/models.py @@ -0,0 +1,519 @@ +"""ML model definitions and persistence for strategy selection. + +Implements automated model selection with: +- XGBoost +- LightGBM +- RandomForest +- MLP Neural Network + +Uses TimeSeriesSplit cross-validation and walk-forward validation. +""" + +import os +from pathlib import Path +from typing import Dict, Any, Optional, List, Tuple +from datetime import datetime +import numpy as np +import pandas as pd +from sklearn.ensemble import RandomForestClassifier, VotingClassifier +from sklearn.neural_network import MLPClassifier +from sklearn.model_selection import TimeSeriesSplit, cross_val_score +from sklearn.preprocessing import StandardScaler, LabelEncoder +from sklearn.metrics import accuracy_score, top_k_accuracy_score +import joblib +import warnings + +from src.core.logger import get_logger +from src.core.config import get_config + +logger = get_logger(__name__) + +# Try importing optional dependencies +try: + from xgboost import XGBClassifier + HAS_XGBOOST = True +except ImportError: + HAS_XGBOOST = False + logger.warning("XGBoost not installed. Run: pip install xgboost") + +try: + from lightgbm import LGBMClassifier + HAS_LIGHTGBM = True +except ImportError: + HAS_LIGHTGBM = False + logger.warning("LightGBM not installed. Run: pip install lightgbm") + + +class ModelSelector: + """Automated model selection for strategy prediction. + + Trains multiple models, compares via cross-validation, picks the best. + """ + + def __init__(self): + """Initialize model selector.""" + self.logger = get_logger(__name__) + self.config = get_config() + + self.scaler = StandardScaler() + self.label_encoder = LabelEncoder() + self.feature_names: List[str] = [] + self.strategy_names: List[str] = [] + self.is_trained = False + self.training_metadata: Dict[str, Any] = {} + + # Best model after training + self.best_model = None + self.best_model_name: str = "" + + # Model storage path + self.model_dir = Path.home() / ".local" / "share" / "crypto_trader" / "models" + self.model_dir.mkdir(parents=True, exist_ok=True) + + def _get_candidate_models(self) -> Dict[str, Any]: + """Get dictionary of candidate models to compare. + + Returns: + Dictionary of model name -> model instance + """ + models = {} + + # RandomForest - stable, interpretable baseline + models['random_forest'] = RandomForestClassifier( + n_estimators=200, + max_depth=15, + min_samples_split=10, + min_samples_leaf=4, + class_weight='balanced', + random_state=42, + n_jobs=-1 + ) + + # XGBoost - typically best for tabular data + if HAS_XGBOOST: + models['xgboost'] = XGBClassifier( + n_estimators=200, + max_depth=8, + learning_rate=0.1, + subsample=0.8, + colsample_bytree=0.8, + random_state=42, + n_jobs=-1, + use_label_encoder=False, + eval_metric='mlogloss' + ) + + # LightGBM - fast, handles noisy data well + if HAS_LIGHTGBM: + models['lightgbm'] = LGBMClassifier( + n_estimators=200, + max_depth=10, + learning_rate=0.1, + subsample=0.8, + colsample_bytree=0.8, + class_weight='balanced', + random_state=42, + n_jobs=-1, + verbose=-1 + ) + + # MLP Neural Network - high ceiling with enough data + models['mlp'] = MLPClassifier( + hidden_layer_sizes=(128, 64, 32), + activation='relu', + solver='adam', + alpha=0.01, # L2 regularization + batch_size='auto', + learning_rate='adaptive', + learning_rate_init=0.001, + max_iter=500, + early_stopping=True, + validation_fraction=0.1, + n_iter_no_change=20, + random_state=42 + ) + + return models + + def train( + self, + X: pd.DataFrame, + y: np.ndarray, + strategy_names: List[str], + use_ensemble: bool = False, + n_splits: int = 5, + training_symbols: List[str] = None + ) -> Dict[str, Any]: + """Train and select best model via cross-validation. + + Args: + X: Feature matrix (market conditions) + y: Target values (strategy names) + strategy_names: List of strategy names + use_ensemble: If True, use voting ensemble of top models instead of single best + n_splits: Number of cross-validation splits + training_symbols: List of symbols used for training + + Returns: + Training metrics dictionary + """ + self.strategy_names = strategy_names + self.feature_names = list(X.columns) + self.training_symbols = training_symbols or [] + + # Encode labels + y_encoded = self.label_encoder.fit_transform(y) + + # Scale features + X_scaled = self.scaler.fit_transform(X) + + # Time series cross-validation + tscv = TimeSeriesSplit(n_splits=n_splits) + + # Get candidate models + models = self._get_candidate_models() + + self.logger.info(f"Training {len(models)} candidate models with {len(X)} samples...") + + # Evaluate each model + model_scores = {} + model_cv_results = {} + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + + for name, model in models.items(): + try: + self.logger.info(f"Evaluating {name}...") + scores = cross_val_score( + model, X_scaled, y_encoded, + cv=tscv, scoring='accuracy', n_jobs=-1 + ) + # Filter out nan scores before calculating mean + valid_scores = [s for s in scores if not np.isnan(s)] + if valid_scores: + mean_score = np.mean(valid_scores) + std_score = np.std(valid_scores) if len(valid_scores) > 1 else 0.0 + model_scores[name] = mean_score + model_cv_results[name] = { + 'mean': float(mean_score), + 'std': float(std_score), + 'scores': [float(s) if not np.isnan(s) else 0.0 for s in scores] + } + self.logger.info(f" {name}: {mean_score:.3f} (+/- {std_score:.3f})") + else: + self.logger.warning(f" {name}: All scores were nan, skipping") + model_scores[name] = 0.0 + except Exception as e: + self.logger.warning(f" {name} failed: {e}") + model_scores[name] = 0.0 + + # Filter out models with zero scores before selecting best + valid_model_scores = {k: v for k, v in model_scores.items() if v > 0} + + # Select best model or create ensemble + if not valid_model_scores: + self.logger.warning("No models trained successfully, using RandomForest as fallback") + self.best_model_name = "random_forest" + self.best_model = models["random_forest"] + elif use_ensemble and len(valid_model_scores) >= 2: + # Use top 3 models in voting ensemble, but only if they have at least 10% accuracy + MIN_ENSEMBLE_ACCURACY = 0.10 + good_models = {k: v for k, v in valid_model_scores.items() if v >= MIN_ENSEMBLE_ACCURACY} + + if len(good_models) >= 2: + top_models = sorted(good_models.items(), key=lambda x: x[1], reverse=True)[:3] + estimators = [(name, models[name]) for name, _ in top_models] + + self.best_model = VotingClassifier(estimators=estimators, voting='soft') + self.best_model_name = "ensemble" + self.logger.info(f"Using ensemble of: {[n for n, _ in top_models]} (excluding models below {MIN_ENSEMBLE_ACCURACY:.0%} accuracy)") + else: + # Not enough good models for ensemble, use single best + self.best_model_name = max(valid_model_scores, key=valid_model_scores.get) + self.best_model = models[self.best_model_name] + self.logger.info(f"Not enough models for ensemble (min {MIN_ENSEMBLE_ACCURACY:.0%}), using best: {self.best_model_name}") + else: + # Use single best model + self.best_model_name = max(valid_model_scores, key=valid_model_scores.get) + self.best_model = models[self.best_model_name] + self.logger.info(f"Best model: {self.best_model_name} ({valid_model_scores[self.best_model_name]:.3f})") + + # Train best model on full dataset + self.best_model.fit(X_scaled, y_encoded) + + # Calculate final metrics with walk-forward validation + train_pred = self.best_model.predict(X_scaled) + train_acc = accuracy_score(y_encoded, train_pred) + + # Top-3 accuracy if we have probabilities + top3_acc = None + try: + if hasattr(self.best_model, 'predict_proba'): + proba = self.best_model.predict_proba(X_scaled) + k = min(3, len(self.strategy_names)) + top3_acc = float(top_k_accuracy_score(y_encoded, proba, k=k)) + except Exception: + pass + + # Run walk-forward validation + wf_scores = self._walk_forward_validation(X_scaled, y_encoded) + + # Calculate estimated CV accuracy for ensemble (average of component models) + if self.best_model_name == "ensemble" and len(valid_model_scores) > 0: + # Use average of models in the ensemble for CV estimate + ensemble_cv_acc = np.mean([v for v in valid_model_scores.values() if v >= 0.10]) + else: + ensemble_cv_acc = model_scores.get(self.best_model_name, train_acc) + + metrics = { + 'train_accuracy': float(train_acc), + 'test_accuracy': float(ensemble_cv_acc), + 'cv_mean_accuracy': float(ensemble_cv_acc), + 'walk_forward_accuracy': float(np.mean(wf_scores)) if wf_scores else None, + 'top3_accuracy': top3_acc, + 'n_samples': len(X), + 'n_features': len(self.feature_names), + 'n_strategies': len(strategy_names), + 'best_model': self.best_model_name, + 'all_model_scores': model_cv_results + } + + self.is_trained = True + self.training_metadata = { + 'trained_at': datetime.utcnow().isoformat(), + 'metrics': metrics, + 'model_type': 'classifier', + 'best_model_name': self.best_model_name, + 'training_symbols': self.training_symbols if hasattr(self, 'training_symbols') else [] + } + + self.logger.info(f"Training complete. Best: {self.best_model_name}, " + f"CV accuracy: {metrics['cv_mean_accuracy']:.1%}") + + return metrics + + def _walk_forward_validation( + self, + X: np.ndarray, + y: np.ndarray, + train_ratio: float = 0.7, + step_size: int = None + ) -> List[float]: + """Perform walk-forward validation. + + Train on months 1-6, test on 7. Train on 1-7, test on 8. Etc. + + Args: + X: Scaled feature matrix + y: Encoded labels + train_ratio: Initial training set ratio + step_size: Step size for rolling window + + Returns: + List of accuracy scores for each step + """ + n_samples = len(X) + if n_samples < 100: + self.logger.warning("Insufficient data for walk-forward validation") + return [] + + initial_train_size = int(n_samples * train_ratio) + if step_size is None: + step_size = max(10, n_samples // 20) + + scores = [] + models = self._get_candidate_models() + model = models.get(self.best_model_name, list(models.values())[0]) + + for i in range(initial_train_size, n_samples - step_size, step_size): + X_train = X[:i] + y_train = y[:i] + X_test = X[i:i + step_size] + y_test = y[i:i + step_size] + + try: + model_clone = type(model)(**model.get_params()) + model_clone.fit(X_train, y_train) + pred = model_clone.predict(X_test) + scores.append(accuracy_score(y_test, pred)) + except Exception as e: + self.logger.warning(f"Walk-forward step failed: {e}") + + if scores: + self.logger.info(f"Walk-forward validation: {np.mean(scores):.3f} " + f"(+/- {np.std(scores):.3f}) over {len(scores)} steps") + + return scores + + def predict( + self, + features: Dict[str, float] + ) -> Tuple[str, float, Dict[str, float]]: + """Predict best strategy for given market conditions. + + Args: + features: Market condition features + + Returns: + Tuple of (best_strategy_name, confidence_score, all_predictions) + """ + if not self.is_trained: + raise ValueError("Model not trained. Call train() first.") + + # Convert features to DataFrame + feature_df = pd.DataFrame([features]) + + # Ensure all required features are present + for feat in self.feature_names: + if feat not in feature_df.columns: + feature_df[feat] = 0.0 + + # Select only required features in correct order + X = feature_df[self.feature_names] + + # Scale + X_scaled = self.scaler.transform(X) + + # Get probabilities for each strategy + if hasattr(self.best_model, 'predict_proba'): + probabilities = self.best_model.predict_proba(X_scaled)[0] + else: + # Fallback for models without predict_proba + pred = self.best_model.predict(X_scaled)[0] + probabilities = np.zeros(len(self.strategy_names)) + probabilities[pred] = 1.0 + + # Map probabilities to strategy names + encoded_classes = self.label_encoder.classes_ + strategy_probs = {} + for idx, prob in enumerate(probabilities): + if idx < len(encoded_classes): + strategy_name = encoded_classes[idx] + strategy_probs[strategy_name] = float(prob) + + # Get best strategy + best_idx = np.argmax(probabilities) + best_strategy = encoded_classes[best_idx] + confidence = float(probabilities[best_idx]) + + return best_strategy, confidence, strategy_probs + + def get_feature_importance(self) -> Dict[str, float]: + """Get feature importance scores. + + Returns: + Dictionary of feature names to importance scores (JSON serializable) + """ + if not self.is_trained or self.best_model is None: + return {} + + try: + if hasattr(self.best_model, 'feature_importances_'): + importances = self.best_model.feature_importances_ + # Convert numpy types to native Python floats for JSON serialization + return {name: float(val) for name, val in zip(self.feature_names, importances)} + elif hasattr(self.best_model, 'coefs_'): + # For MLP, use absolute mean of first layer weights + importances = np.abs(self.best_model.coefs_[0]).mean(axis=1) + return {name: float(val) for name, val in zip(self.feature_names, importances)} + except Exception as e: + self.logger.warning(f"Could not get feature importance: {e}") + + return {} + + def save(self, filename: Optional[str] = None) -> str: + """Save model to disk. + + Args: + filename: Optional custom filename + + Returns: + Path to saved model + """ + if filename is None: + timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S") + filename = f"strategy_selector_{self.best_model_name}_{timestamp}.joblib" + + filepath = self.model_dir / filename + + model_data = { + 'model': self.best_model, + 'scaler': self.scaler, + 'label_encoder': self.label_encoder, + 'feature_names': self.feature_names, + 'strategy_names': self.strategy_names, + 'is_trained': self.is_trained, + 'training_metadata': self.training_metadata, + 'model_type': 'classifier', + 'best_model_name': self.best_model_name + } + + joblib.dump(model_data, filepath) + self.logger.info(f"Model saved to {filepath}") + return str(filepath) + + def load(self, filepath: str) -> bool: + """Load model from disk. + + Args: + filepath: Path to model file + + Returns: + True if loaded successfully + """ + try: + if not os.path.exists(filepath): + self.logger.error(f"Model file not found: {filepath}") + return False + + model_data = joblib.load(filepath) + + self.best_model = model_data['model'] + self.scaler = model_data['scaler'] + self.label_encoder = model_data.get('label_encoder', LabelEncoder()) + self.feature_names = model_data['feature_names'] + self.strategy_names = model_data['strategy_names'] + self.is_trained = model_data['is_trained'] + self.training_metadata = model_data.get('training_metadata', {}) + self.best_model_name = model_data.get('best_model_name', 'unknown') + + self.logger.info(f"Model loaded from {filepath}") + return True + + except Exception as e: + self.logger.error(f"Failed to load model: {e}") + return False + + def get_latest_model_path(self) -> Optional[str]: + """Get path to latest saved model. + + Returns: + Path to latest model or None + """ + model_files = list(self.model_dir.glob("strategy_selector_*.joblib")) + if not model_files: + return None + + # Sort by modification time + latest = max(model_files, key=lambda p: p.stat().st_mtime) + return str(latest) + + +# Backwards-compatible alias +class StrategySelectorModel(ModelSelector): + """Backwards-compatible alias for ModelSelector. + + Now uses automated model selection instead of just RandomForest. + """ + + def __init__(self, model_type: str = "classifier"): + """Initialize model. + + Args: + model_type: Model type (only 'classifier' supported now) + """ + super().__init__() + self.model_type = model_type + if model_type != "classifier": + self.logger.warning("Only 'classifier' model type is supported. Using classifier.") diff --git a/src/autopilot/performance_tracker.py b/src/autopilot/performance_tracker.py new file mode 100644 index 00000000..9f1e6fb4 --- /dev/null +++ b/src/autopilot/performance_tracker.py @@ -0,0 +1,351 @@ +"""Strategy performance tracker for ML training data collection.""" + +from decimal import Decimal +from typing import Dict, Any, Optional, List +from datetime import datetime, timedelta +from sqlalchemy.orm import Session +import pandas as pd +import numpy as np + +from src.core.database import get_database, Trade, Strategy +from src.core.logger import get_logger +from .market_analyzer import MarketConditions + +logger = get_logger(__name__) + + +class PerformanceTracker: + """Tracks strategy performance for ML training.""" + + def __init__(self): + """Initialize performance tracker.""" + self.db = get_database() + self.logger = get_logger(__name__) + + async def record_trade( + self, + strategy_name: str, + market_conditions: MarketConditions, + trade_result: Dict[str, Any] + ) -> bool: + """Record a trade for ML training. + + Args: + strategy_name: Name of strategy used + market_conditions: Market conditions at trade time + trade_result: Trade result with performance metrics + + Returns: + True if recorded successfully + """ + try: + async with self.db.get_session() as session: + try: + # Store market conditions snapshot + from src.core.database import MarketConditionsSnapshot + + snapshot = MarketConditionsSnapshot( + symbol=market_conditions.symbol, + timeframe=market_conditions.timeframe, + regime=market_conditions.regime.value, + features=market_conditions.features, + strategy_name=strategy_name, + timestamp=market_conditions.timestamp + ) + session.add(snapshot) + + # Store performance record + from src.core.database import StrategyPerformance + + performance = StrategyPerformance( + strategy_name=strategy_name, + symbol=market_conditions.symbol, + timeframe=market_conditions.timeframe, + market_regime=market_conditions.regime.value, + return_pct=float(trade_result.get('return_pct', 0.0)), + sharpe_ratio=float(trade_result.get('sharpe_ratio', 0.0)), + win_rate=float(trade_result.get('win_rate', 0.0)), + max_drawdown=float(trade_result.get('max_drawdown', 0.0)), + trade_count=int(trade_result.get('trade_count', 1)), + timestamp=datetime.utcnow() + ) + session.add(performance) + + await session.commit() + return True + + except Exception as e: + await session.rollback() + self.logger.error(f"Failed to record trade: {e}") + return False + + except Exception as e: + self.logger.error(f"Error recording trade: {e}") + return False + + async def get_performance_history( + self, + strategy_name: Optional[str] = None, + market_regime: Optional[str] = None, + days: int = 30 + ) -> pd.DataFrame: + """Get performance history for training. + + Args: + strategy_name: Filter by strategy name + market_regime: Filter by market regime + days: Number of days to look back + + Returns: + DataFrame with performance history + """ + from sqlalchemy import select + try: + async with self.db.get_session() as session: + from src.core.database import StrategyPerformance, MarketConditionsSnapshot + + # Query performance records + stmt = select(StrategyPerformance) + + if strategy_name: + stmt = stmt.where(StrategyPerformance.strategy_name == strategy_name) + if market_regime: + stmt = stmt.where(StrategyPerformance.market_regime == market_regime) + + cutoff_date = datetime.utcnow() - timedelta(days=days) + stmt = stmt.where(StrategyPerformance.timestamp >= cutoff_date) + # Limit to prevent excessive queries - if we have lots of data, sample it + stmt = stmt.order_by(StrategyPerformance.timestamp.desc()).limit(10000) + + result = await session.execute(stmt) + records = result.scalars().all() + + if not records: + return pd.DataFrame() + + self.logger.info(f"Processing {len(records)} performance records for training data") + + # Convert to DataFrame - optimize by batching snapshot queries + data = [] + # Batch snapshot lookups to reduce N+1 query problem + snapshot_cache = {} + for record in records: + cache_key = f"{record.strategy_name}:{record.symbol}:{record.timeframe}:{record.timestamp.date()}" + + if cache_key not in snapshot_cache: + # Get corresponding market conditions (only once per day per strategy) + snapshot_stmt = select(MarketConditionsSnapshot).filter_by( + strategy_name=record.strategy_name, + symbol=record.symbol, + timeframe=record.timeframe + ).where( + MarketConditionsSnapshot.timestamp <= record.timestamp + ).order_by( + MarketConditionsSnapshot.timestamp.desc() + ).limit(1) + + snapshot_result = await session.execute(snapshot_stmt) + snapshot = snapshot_result.scalar_one_or_none() + snapshot_cache[cache_key] = snapshot + else: + snapshot = snapshot_cache[cache_key] + + row = { + 'strategy_name': record.strategy_name, + 'symbol': record.symbol, + 'timeframe': record.timeframe, + 'market_regime': record.market_regime, + 'return_pct': float(record.return_pct), + 'sharpe_ratio': float(record.sharpe_ratio), + 'win_rate': float(record.win_rate), + 'max_drawdown': float(record.max_drawdown), + 'trade_count': int(record.trade_count), + 'timestamp': record.timestamp + } + + # Add market features if available + if snapshot and snapshot.features: + row.update(snapshot.features) + + data.append(row) + + return pd.DataFrame(data) + + except Exception as e: + self.logger.error(f"Error getting performance history: {e}") + return pd.DataFrame() + + async def calculate_metrics( + self, + strategy_name: str, + period_days: int = 30 + ) -> Dict[str, Any]: + """Calculate performance metrics for a strategy. + + Args: + strategy_name: Strategy name + period_days: Period to calculate metrics over + + Returns: + Dictionary of performance metrics + """ + from sqlalchemy import select + try: + async with self.db.get_session() as session: + from src.core.database import StrategyPerformance + + cutoff_date = datetime.utcnow() - timedelta(days=period_days) + + stmt = select(StrategyPerformance).where( + StrategyPerformance.strategy_name == strategy_name, + StrategyPerformance.timestamp >= cutoff_date + ) + + result = await session.execute(stmt) + records = result.scalars().all() + + if not records: + return { + 'total_trades': 0, + 'win_rate': 0.0, + 'avg_return': 0.0, + 'total_return': 0.0, + 'sharpe_ratio': 0.0, + 'max_drawdown': 0.0 + } + + returns = [float(r.return_pct) for r in records] + wins = [r for r in returns if r > 0] + + total_return = sum(returns) + avg_return = np.mean(returns) if returns else 0.0 + win_rate = len(wins) / len(returns) if returns else 0.0 + + # Calculate Sharpe ratio (simplified) + if len(returns) > 1: + sharpe = (avg_return / np.std(returns)) * np.sqrt(252) if np.std(returns) > 0 else 0.0 + else: + sharpe = 0.0 + + # Max drawdown + max_dd = max([float(r.max_drawdown) for r in records]) if records else 0.0 + + return { + 'total_trades': len(records), + 'win_rate': float(win_rate), + 'avg_return': float(avg_return), + 'total_return': float(total_return), + 'sharpe_ratio': float(sharpe), + 'max_drawdown': float(max_dd) + } + + except Exception as e: + self.logger.error(f"Error calculating metrics: {e}") + return {} + + async def prepare_training_data( + self, + min_samples_per_strategy: int = 10 + ) -> Optional[Dict[str, Any]]: + """Prepare training data for ML model. + + Args: + min_samples_per_strategy: Minimum samples required per strategy + + Returns: + Dictionary with 'X' (features), 'y' (targets), and 'strategy_names' + """ + try: + # Get all performance history - extended to 365 days for better training + df = await self.get_performance_history(days=365) + + if df.empty: + self.logger.warning("No performance history available for training") + return None + + # Filter strategies with enough samples + strategy_counts = df['strategy_name'].value_counts() + valid_strategies = strategy_counts[strategy_counts >= min_samples_per_strategy].index.tolist() + + if not valid_strategies: + self.logger.warning(f"No strategies with at least {min_samples_per_strategy} samples") + return None + + df = df[df['strategy_name'].isin(valid_strategies)] + + # Extract features (exclude metadata columns) + feature_cols = [col for col in df.columns if col not in [ + 'strategy_name', 'symbol', 'timeframe', 'market_regime', + 'return_pct', 'sharpe_ratio', 'win_rate', 'max_drawdown', + 'trade_count', 'timestamp' + ]] + + # Fill missing features with 0 + for col in feature_cols: + if col not in df.columns: + df[col] = 0.0 + df[col] = pd.to_numeric(df[col], errors='coerce').fillna(0.0) + + X = df[feature_cols] + y = df['strategy_name'].values + + return { + 'X': X, + 'y': y, + 'strategy_names': valid_strategies, + 'feature_names': feature_cols, + 'training_symbols': sorted(df['symbol'].unique().tolist()) + } + + except Exception as e: + self.logger.error(f"Error preparing training data: {e}") + return None + + + async def get_strategy_sample_counts(self, days: int = 365) -> Dict[str, int]: + """Get sample counts per strategy. + + Args: + days: Number of days to look back + + Returns: + Dictionary mapping strategy names to sample counts + """ + from sqlalchemy import select, func + try: + async with self.db.get_session() as session: + from src.core.database import StrategyPerformance + + cutoff_date = datetime.utcnow() - timedelta(days=days) + + # Group by strategy name and count + stmt = select( + StrategyPerformance.strategy_name, + func.count(StrategyPerformance.id) + ).where( + StrategyPerformance.timestamp >= cutoff_date + ).group_by( + StrategyPerformance.strategy_name + ) + + result = await session.execute(stmt) + counts = dict(result.all()) + + return counts + + except Exception as e: + self.logger.error(f"Error getting strategy sample counts: {e}") + return {} + + +# Global instance +_performance_tracker: Optional[PerformanceTracker] = None + + +def get_performance_tracker() -> PerformanceTracker: + """Get global performance tracker instance.""" + global _performance_tracker + if _performance_tracker is None: + _performance_tracker = PerformanceTracker() + return _performance_tracker + diff --git a/src/autopilot/strategy_groups.py b/src/autopilot/strategy_groups.py new file mode 100644 index 00000000..239c5c59 --- /dev/null +++ b/src/autopilot/strategy_groups.py @@ -0,0 +1,190 @@ +"""Strategy grouping for improved ML accuracy. + +Groups 14 individual strategies into 5 logical groups to reduce classification +complexity and improve model accuracy. +""" + +from typing import Dict, List, Optional, Tuple +from enum import Enum + +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class StrategyGroup(str, Enum): + """Strategy group classifications.""" + TREND_FOLLOWING = "trend_following" + MEAN_REVERSION = "mean_reversion" + MOMENTUM = "momentum" + MARKET_MAKING = "market_making" + SENTIMENT_BASED = "sentiment_based" + + +# Map each strategy to its group +STRATEGY_TO_GROUP: Dict[str, StrategyGroup] = { + # Trend Following: Follow established trends + "moving_average": StrategyGroup.TREND_FOLLOWING, + "macd": StrategyGroup.TREND_FOLLOWING, + "confirmed": StrategyGroup.TREND_FOLLOWING, + + # Mean Reversion: Bet on price returning to mean + "rsi": StrategyGroup.MEAN_REVERSION, + "bollinger_mean_reversion": StrategyGroup.MEAN_REVERSION, + "grid": StrategyGroup.MEAN_REVERSION, + "divergence": StrategyGroup.MEAN_REVERSION, + + # Momentum: Capture fast price moves + "momentum": StrategyGroup.MOMENTUM, + "volatility_breakout": StrategyGroup.MOMENTUM, + + # Market Making: Profit from bid-ask spread + "market_making": StrategyGroup.MARKET_MAKING, + "dca": StrategyGroup.MARKET_MAKING, + + # Sentiment Based: Use external signals + "sentiment": StrategyGroup.SENTIMENT_BASED, + "pairs_trading": StrategyGroup.SENTIMENT_BASED, + "consensus": StrategyGroup.SENTIMENT_BASED, +} + +# Reverse mapping: group -> list of strategies +GROUP_TO_STRATEGIES: Dict[StrategyGroup, List[str]] = {} +for strategy, group in STRATEGY_TO_GROUP.items(): + if group not in GROUP_TO_STRATEGIES: + GROUP_TO_STRATEGIES[group] = [] + GROUP_TO_STRATEGIES[group].append(strategy) + + +def get_strategy_group(strategy_name: str) -> Optional[StrategyGroup]: + """Get the group a strategy belongs to. + + Args: + strategy_name: Name of the strategy (case-insensitive) + + Returns: + StrategyGroup or None if strategy not found + """ + return STRATEGY_TO_GROUP.get(strategy_name.lower()) + + +def get_strategies_in_group(group: StrategyGroup) -> List[str]: + """Get all strategies belonging to a group. + + Args: + group: Strategy group + + Returns: + List of strategy names in the group + """ + return GROUP_TO_STRATEGIES.get(group, []) + + +def get_all_groups() -> List[StrategyGroup]: + """Get all available strategy groups. + + Returns: + List of all strategy groups + """ + return list(StrategyGroup) + + +def get_best_strategy_in_group( + group: StrategyGroup, + market_features: Dict[str, float], + available_strategies: Optional[List[str]] = None +) -> Tuple[str, float]: + """Select the best individual strategy within a group based on market conditions. + + Uses rule-based heuristics to pick the optimal strategy when the ML model + has predicted a group. + + Args: + group: The predicted strategy group + market_features: Current market condition features + available_strategies: Optional list of available strategies (filters choices) + + Returns: + Tuple of (best_strategy_name, confidence_score) + """ + strategies = get_strategies_in_group(group) + + # Filter by availability if provided + if available_strategies: + strategies = [s for s in strategies if s in available_strategies] + + if not strategies: + # Fallback if no strategies available in group + logger.warning(f"No strategies available in group {group}") + return ("rsi", 0.5) # Safe default + + # If only one option, return it + if len(strategies) == 1: + return (strategies[0], 0.7) + + # Rule-based selection within group based on market features + rsi = market_features.get("rsi", 50.0) + adx = market_features.get("adx", 25.0) + volatility = market_features.get("atr_percent", 2.0) + volume_ratio = market_features.get("volume_ratio", 1.0) + + if group == StrategyGroup.TREND_FOLLOWING: + # Strong trends: use confirmed, moderate: moving_average, diverging: macd + if adx > 30: + return ("confirmed", 0.75) if "confirmed" in strategies else ("moving_average", 0.7) + elif adx > 20: + return ("moving_average", 0.7) if "moving_average" in strategies else ("macd", 0.65) + else: + return ("macd", 0.6) if "macd" in strategies else (strategies[0], 0.55) + + elif group == StrategyGroup.MEAN_REVERSION: + # Extreme RSI: rsi strategy, tight range: bollinger, low vol: grid + if rsi < 30 or rsi > 70: + return ("rsi", 0.75) if "rsi" in strategies else ("bollinger_mean_reversion", 0.7) + elif volatility < 1.5: + return ("grid", 0.7) if "grid" in strategies else ("bollinger_mean_reversion", 0.65) + else: + return ("bollinger_mean_reversion", 0.65) if "bollinger_mean_reversion" in strategies else (strategies[0], 0.6) + + elif group == StrategyGroup.MOMENTUM: + # High volume spike: volatility_breakout, otherwise momentum + if volume_ratio > 1.5: + return ("volatility_breakout", 0.75) if "volatility_breakout" in strategies else ("momentum", 0.7) + else: + return ("momentum", 0.7) if "momentum" in strategies else (strategies[0], 0.65) + + elif group == StrategyGroup.MARKET_MAKING: + # Low volatility: market_making, otherwise dca + if volatility < 2.0: + return ("market_making", 0.7) if "market_making" in strategies else ("dca", 0.65) + else: + return ("dca", 0.7) if "dca" in strategies else (strategies[0], 0.6) + + elif group == StrategyGroup.SENTIMENT_BASED: + # Default to sentiment, fall back to consensus + if "sentiment" in strategies: + return ("sentiment", 0.65) + elif "consensus" in strategies: + return ("consensus", 0.6) + else: + return (strategies[0], 0.55) + + # Fallback + return (strategies[0], 0.5) + + +def convert_strategy_to_group_label(strategy_name: str) -> str: + """Convert a strategy name to its group label for ML training. + + Args: + strategy_name: Individual strategy name + + Returns: + Group label string (e.g., "trend_following") + """ + group = get_strategy_group(strategy_name) + if group: + return group.value + else: + logger.warning(f"Strategy '{strategy_name}' not in any group, using as-is") + return strategy_name diff --git a/src/autopilot/strategy_selector.py b/src/autopilot/strategy_selector.py new file mode 100644 index 00000000..d9097b39 --- /dev/null +++ b/src/autopilot/strategy_selector.py @@ -0,0 +1,581 @@ +"""ML-based strategy selector for intelligent autopilot.""" + +import asyncio +from typing import Dict, Any, Optional, List, Tuple + +from src.core.logger import get_logger +from src.core.config import get_config +from .market_analyzer import MarketConditions, MarketRegime, get_market_analyzer +from .models import StrategySelectorModel +from .performance_tracker import get_performance_tracker +from .strategy_groups import ( + StrategyGroup, get_strategy_group, get_strategies_in_group, + get_best_strategy_in_group, convert_strategy_to_group_label, get_all_groups +) +from src.strategies import get_strategy_registry + +logger = get_logger(__name__) + + +class StrategySelector: + """ML-based strategy selector.""" + + def __init__(self): + """Initialize strategy selector.""" + self.logger = get_logger(__name__) + self.config = get_config() + self.market_analyzer = get_market_analyzer() + self.performance_tracker = get_performance_tracker() + self.model = StrategySelectorModel(model_type="classifier") + self.strategy_registry = get_strategy_registry() + self._available_strategies: List[str] = [] + + # Bootstrap configuration - improved defaults for better ML accuracy + self.bootstrap_days = self.config.get("autopilot.intelligent.bootstrap.days", 365) + self.bootstrap_timeframe = self.config.get("autopilot.intelligent.bootstrap.timeframe", "1h") + self.min_samples_per_strategy = self.config.get("autopilot.intelligent.bootstrap.min_samples_per_strategy", 10) + self.bootstrap_symbols = self.config.get("autopilot.intelligent.bootstrap.symbols", ["BTC/USD", "ETH/USD"]) + self.bootstrap_timeframes = ["15m", "1h", "4h"] # Multi-timeframe for more data + + self._available_strategies: List[str] = [] + self._load_available_strategies() + + self._last_model_ts = 0.0 + + # Auto-load persisted model if available + self._try_load_saved_model() + + def _try_load_saved_model(self): + """Try to load a previously saved model.""" + import os + try: + latest_model = self.model.get_latest_model_path() + if latest_model: + mtime = os.path.getmtime(latest_model) + # Only load if it's new or we haven't loaded anything + if mtime > self._last_model_ts: + if self.model.load(latest_model): + self._last_model_ts = mtime + self.logger.info(f"Loaded persisted model: {latest_model}") + else: + self.logger.warning(f"Failed to load model {latest_model}") + else: + if self._last_model_ts == 0: + self.logger.info("No saved model found, model needs training") + except Exception as e: + self.logger.warning(f"Failed to load saved model: {e}") + + def _load_available_strategies(self): + """Load list of available strategies.""" + self._available_strategies = self.strategy_registry.list_available() + self.logger.info(f"Available strategies: {self._available_strategies}") + + async def train_model( + self, + min_samples_per_strategy: int = 10, + force_retrain: bool = False + ) -> Dict[str, Any]: + """Train the ML model on historical performance data. + + Args: + min_samples_per_strategy: Minimum samples required per strategy + force_retrain: Force retraining even if model exists + + Returns: + Training metrics dictionary + """ + # Try to load existing model first + if not force_retrain: + latest_model = self.model.get_latest_model_path() + if latest_model and self.model.load(latest_model): + self.logger.info("Loaded existing trained model") + return self.model.training_metadata.get('metrics', {}) + + # Prepare training data + training_data = await self.performance_tracker.prepare_training_data( + min_samples_per_strategy=min_samples_per_strategy + ) + + if training_data is None: + self.logger.warning("Insufficient training data. Using fallback rule-based selection.") + return {} + + X = training_data['X'] + y = training_data['y'] + strategy_names = training_data['strategy_names'] + + # Convert individual strategy names to group labels for better ML accuracy + # This reduces the number of classes from 14 to 5 + y_groups = [convert_strategy_to_group_label(name) for name in y] + group_names = [g.value for g in get_all_groups()] + + self.logger.info(f"Training with {len(set(y_groups))} strategy groups (from {len(strategy_names)} strategies)") + + if len(X) < min_samples_per_strategy * len(group_names): + self.logger.warning("Insufficient training data. Using fallback rule-based selection.") + return {} + + # Train model with ensemble mode for better accuracy + # Ensemble combines XGBoost + LightGBM + RandomForest via voting + # Now predicts GROUPS instead of individual strategies + metrics = self.model.train( + X, + y_groups, # Use group labels + group_names, # Use group names + use_ensemble=True, + training_symbols=training_data.get('training_symbols', []) + ) + + # Save model + self.model.save() + + return metrics + + def select_best_strategy( + self, + market_conditions: MarketConditions, + min_confidence: float = 0.5 + ) -> Optional[Tuple[str, float, Dict[str, float]]]: + """Select best strategy for current market conditions. + + The ML model predicts a strategy GROUP, then we use rule-based logic + to select the best individual strategy within that group. + + Args: + market_conditions: Current market conditions + min_confidence: Minimum confidence threshold + + Returns: + Tuple of (strategy_name, confidence, all_predictions) or None + """ + # If model not trained, use fallback + if not self.model.is_trained: + self.logger.debug("Model not trained, using rule-based fallback") + return self._fallback_strategy_selection(market_conditions) + + try: + # Predict strategy GROUP using ML model + predicted_group, group_confidence, all_group_predictions = self.model.predict( + market_conditions.features + ) + + if group_confidence < min_confidence: + self.logger.debug( + f"ML group confidence {group_confidence:.2f} below threshold {min_confidence}, " + "using fallback" + ) + return self._fallback_strategy_selection(market_conditions) + + # Convert group string to enum + try: + group_enum = StrategyGroup(predicted_group) + except ValueError: + self.logger.warning(f"Unknown group '{predicted_group}', using fallback") + return self._fallback_strategy_selection(market_conditions) + + # Select best individual strategy within the predicted group + best_strategy, strategy_confidence = get_best_strategy_in_group( + group_enum, + market_conditions.features, + available_strategies=self._available_strategies + ) + + # Combined confidence: group confidence * strategy confidence + combined_confidence = group_confidence * strategy_confidence + + # Build all_predictions dict with individual strategies + all_predictions = {} + for group_name, group_score in all_group_predictions.items(): + try: + group_e = StrategyGroup(group_name) + strategies_in_group = get_strategies_in_group(group_e) + for strat in strategies_in_group: + if strat in self._available_strategies: + all_predictions[strat] = group_score + except ValueError: + pass + + self.logger.info( + f"ML selected group: {predicted_group} (conf: {group_confidence:.2f}) " + f"-> strategy: {best_strategy} (combined conf: {combined_confidence:.2f})" + ) + + return best_strategy, combined_confidence, all_predictions + + except Exception as e: + self.logger.error(f"Error in ML prediction: {e}") + return self._fallback_strategy_selection(market_conditions) + + def _fallback_strategy_selection( + self, + market_conditions: MarketConditions + ) -> Optional[Tuple[str, float, Dict[str, float]]]: + """Fallback rule-based strategy selection. + + Args: + market_conditions: Current market conditions + + Returns: + Tuple of (strategy_name, confidence, all_predictions) + """ + features = market_conditions.features + regime = market_conditions.regime + + # Rule-based selection based on market regime + strategy_rules = { + MarketRegime.TRENDING_UP: "moving_average", + MarketRegime.TRENDING_DOWN: "moving_average", + MarketRegime.RANGING: "rsi", + MarketRegime.HIGH_VOLATILITY: "momentum", + MarketRegime.LOW_VOLATILITY: "grid", + MarketRegime.BREAKOUT: "momentum", + MarketRegime.REVERSAL: "rsi" + } + + # Map regime enum to string + regime_str = regime.value if hasattr(regime, 'value') else str(regime) + + # Select strategy based on regime + selected_strategy = strategy_rules.get(regime, "rsi") + + # Check if strategy is available + if selected_strategy not in self._available_strategies: + # Fallback to first available + if self._available_strategies: + selected_strategy = self._available_strategies[0] + else: + return None + + # Calculate confidence based on regime clarity + rsi = features.get('rsi', 50.0) + adx = features.get('adx', 0.0) + + # Higher confidence for clear signals + if regime in [MarketRegime.TRENDING_UP, MarketRegime.TRENDING_DOWN] and adx > 25: + confidence = 0.7 + elif regime == MarketRegime.RANGING and (rsi < 30 or rsi > 70): + confidence = 0.65 + else: + confidence = 0.5 + + all_predictions = {selected_strategy: confidence} + + self.logger.info( + f"Fallback selected strategy: {selected_strategy} " + f"(confidence: {confidence:.2f}, regime: {regime_str})" + ) + + return selected_strategy, confidence, all_predictions + + def get_strategy_rankings( + self, + market_conditions: MarketConditions + ) -> List[Tuple[str, float]]: + """Get all strategies ranked by expected performance. + + Args: + market_conditions: Current market conditions + + Returns: + List of (strategy_name, score) tuples, sorted by score descending + """ + result = self.select_best_strategy(market_conditions, min_confidence=0.0) + + if result is None: + return [] + + _, _, all_predictions = result + + # Sort by score descending + rankings = sorted( + all_predictions.items(), + key=lambda x: x[1], + reverse=True + ) + + return rankings + + def update_model(self, trade_result: Dict[str, Any]) -> bool: + """Update model with new trade result (incremental learning). + + Args: + trade_result: Trade result with performance metrics + + Returns: + True if update successful + """ + # For now, we'll retrain periodically rather than incremental updates + # This can be enhanced later with online learning algorithms + self.logger.debug("Model update requested (will retrain on next cycle)") + return True + + async def bootstrap_training_data( + self, + symbol: str = "BTC/USD", + timeframe: Optional[str] = None, + days: Optional[int] = None, + exchange_name: str = "Binance Public" + ) -> Dict[str, Any]: + """Bootstrap training data by running backtests on historical data. + + Args: + symbol: Trading symbol + timeframe: Timeframe (defaults to config value) + days: Number of days of historical data (defaults to config value) + exchange_name: Exchange name + + Returns: + Dictionary with bootstrap results + """ + # Use config values as defaults + if timeframe is None: + timeframe = self.bootstrap_timeframe + if days is None: + days = self.bootstrap_days + from src.backtesting.engine import BacktestingEngine + from src.exchanges.public_data import PublicDataAdapter + from src.data.collector import get_data_collector + from src.core.database import get_database, MarketData + from datetime import datetime, timedelta + from decimal import Decimal + from sqlalchemy import select, func + import time + + self.logger.info(f"Bootstrapping training data for {symbol} ({timeframe})") + + db = get_database() + + # Step 1: Check and fetch historical data + async with db.get_session() as session: + try: + end_date = datetime.utcnow() + start_date = end_date - timedelta(days=days) + + # Check if we have data + stmt = select(func.count()).select_from(MarketData).where( + MarketData.exchange == exchange_name, + MarketData.symbol == symbol, + MarketData.timeframe == timeframe, + MarketData.timestamp >= start_date + ) + result = await session.execute(stmt) + existing_data = result.scalar() + + if existing_data < 100: # Need at least 100 candles + self.logger.info(f"Fetching historical data ({days} days)...") + adapter = PublicDataAdapter() + if await adapter.connect(): + collector = get_data_collector() + current_date = start_date + chunk_days = 30 + + while current_date < end_date: + chunk_end = min(current_date + timedelta(days=chunk_days), end_date) + ohlcv = await adapter.get_ohlcv( + symbol=symbol, + timeframe=timeframe, + since=current_date, + limit=1000 + ) + if ohlcv: + await collector.store_ohlcv(exchange_name, symbol, timeframe, ohlcv) + current_date = chunk_end + time.sleep(1) + + await adapter.disconnect() + self.logger.info("Historical data fetched") + else: + self.logger.error("Failed to connect to exchange") + return {"error": "Failed to fetch historical data"} + except Exception as e: + self.logger.error(f"Error checking/fetching historical data: {e}") + return {"error": f"Database error: {e}"} + + # Step 2: Run backtests for each strategy + backtest_engine = BacktestingEngine() + bootstrap_results = [] + + self.logger.info(f"Available strategies for bootstrap: {self._available_strategies}") + + total_strategies = len(self._available_strategies) + for strategy_idx, strategy_name in enumerate(self._available_strategies): + try: + strategy_class = self.strategy_registry._strategies.get(strategy_name.lower()) + if not strategy_class: + continue + + strategy = strategy_class( + name=strategy_name, + parameters={}, + timeframes=[timeframe] + ) + strategy.enabled = True + + self.logger.info(f"Running backtest for {strategy_name} ({strategy_idx + 1}/{total_strategies})...") + + # Run backtest + results = await backtest_engine.run_backtest( + strategy=strategy, + symbol=symbol, + exchange=exchange_name, + timeframe=timeframe, + start_date=start_date, + end_date=end_date, + initial_capital=Decimal("10000.0"), + slippage=0.001 + ) + + if "error" in results: + self.logger.warning(f"Backtest failed for {strategy_name}: {results['error']}") + continue + + self.logger.info(f"Backtest successful for {strategy_name}, return: {results.get('total_return', 0.0)}%") + + # Get market conditions for the period + market_data = await backtest_engine._get_historical_data( + exchange_name, symbol, timeframe, start_date, end_date + ) + + if len(market_data) > 0: + # ===================================================== + # IMPROVED SAMPLING: Regime-change detection + time spacing + # ===================================================== + # This creates more diverse, independent training samples + # by sampling at meaningful market transitions rather than + # every N candles (which creates nearly-identical samples) + + # Minimum time spacing between samples (in candles) + # For 1h: 24 candles = 24 hours + # For 15m: 96 candles = 24 hours + # For 4h: 6 candles = 24 hours + timeframe_to_spacing = { + "1m": 1440, # 24 hours + "5m": 288, + "15m": 96, + "30m": 48, + "1h": 24, + "4h": 6, + "1d": 1 + } + min_spacing = timeframe_to_spacing.get(timeframe, 24) + + samples_recorded = 0 + last_regime = None + last_sample_idx = -min_spacing # Allow first sample immediately + + # Limit processing to prevent excessive computation + # For small datasets (5 days), process all points but yield periodically + data_points = len(market_data) - 50 + self.logger.info(f"Processing {data_points} data points for {strategy_name}...") + + # Need at least 50 candles for feature calculation + for i in range(50, len(market_data)): + # Yield control periodically to prevent blocking (every 10 iterations) + if i % 10 == 0: + await asyncio.sleep(0) # Yield to event loop + + sample_data = market_data.iloc[i-50:i] + conditions = self.market_analyzer.analyze_current_conditions( + symbol, timeframe, sample_data + ) + current_regime = conditions.regime + + # Determine if we should sample at this point + # 1. Sample on regime change (market transition = valuable data) + # 2. Sample after minimum time spacing (ensure independence) + regime_changed = (last_regime is not None and current_regime != last_regime) + time_elapsed = (i - last_sample_idx) >= min_spacing + + should_sample = regime_changed or (time_elapsed and last_regime is None) or (time_elapsed and i == 50) + + # Also sample periodically even without regime change (every 2x min_spacing) + periodic_sample = (i - last_sample_idx) >= (min_spacing * 2) + + if should_sample or periodic_sample: + # Record as training data + trade_result = { + 'return_pct': results.get('total_return', 0.0) / 100.0, + 'sharpe_ratio': results.get('sharpe_ratio', 0.0), + 'win_rate': results.get('win_rate', 0.5), + 'max_drawdown': abs(results.get('max_drawdown', 0.0)) / 100.0, + 'trade_count': results.get('total_trades', 0) + } + + await self.performance_tracker.record_trade( + strategy_name=strategy_name, + market_conditions=conditions, + trade_result=trade_result + ) + samples_recorded += 1 + last_sample_idx = i + + if regime_changed: + self.logger.debug( + f"Sampled at regime change: {last_regime} -> {current_regime}" + ) + + last_regime = current_regime + + self.logger.info(f"Recorded {samples_recorded} samples for {strategy_name}") + + bootstrap_results.append({ + 'strategy': strategy_name, + 'trades_recorded': samples_recorded, + 'backtest_return': results.get('total_return', 0.0) + }) + + except Exception as e: + self.logger.error(f"Error bootstrapping {strategy_name}: {e}", exc_info=True) + continue + + total_samples = sum(r['trades_recorded'] for r in bootstrap_results) + self.logger.info(f"Bootstrap complete: {total_samples} training samples created") + + return { + 'status': 'success', + 'strategies': bootstrap_results, + 'total_samples': total_samples + } + + def get_model_info(self) -> Dict[str, Any]: + """Get information about the current model. + + Returns: + Dictionary with model information + """ + # Always check if a newer model is available on disk + self._try_load_saved_model() + + info = { + 'is_trained': self.model.is_trained, + 'model_type': self.model.model_type, + 'available_strategies': self._available_strategies, + 'feature_count': len(self.model.feature_names) if self.model.is_trained else 0 + } + + if self.model.is_trained: + info.update({ + 'training_metadata': self.model.training_metadata, + 'feature_importance': dict( + sorted( + self.model.get_feature_importance().items(), + key=lambda x: x[1], + reverse=True + )[:10] # Top 10 features + ) + }) + + return info + + +# Global instance +_strategy_selector: Optional[StrategySelector] = None + + +def get_strategy_selector() -> StrategySelector: + """Get global strategy selector instance.""" + global _strategy_selector + if _strategy_selector is None: + _strategy_selector = StrategySelector() + return _strategy_selector + diff --git a/src/backtesting/__init__.py b/src/backtesting/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backtesting/data_provider.py b/src/backtesting/data_provider.py new file mode 100644 index 00000000..bc2608e0 --- /dev/null +++ b/src/backtesting/data_provider.py @@ -0,0 +1,51 @@ +"""Historical data management for backtesting.""" + +from datetime import datetime +from typing import List, Optional +from sqlalchemy.orm import Session +from src.core.database import get_database, MarketData +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class DataProvider: + """Provides historical data for backtesting.""" + + def __init__(self): + """Initialize data provider.""" + self.db = get_database() + self.logger = get_logger(__name__) + + def get_data( + self, + exchange: str, + symbol: str, + timeframe: str, + start_date: datetime, + end_date: datetime + ) -> List[MarketData]: + """Get historical data. + + Args: + exchange: Exchange name + symbol: Trading symbol + timeframe: Timeframe + start_date: Start date + end_date: End date + + Returns: + List of MarketData objects + """ + session = self.db.get_session() + try: + return session.query(MarketData).filter( + MarketData.exchange == exchange, + MarketData.symbol == symbol, + MarketData.timeframe == timeframe, + MarketData.timestamp >= start_date, + MarketData.timestamp <= end_date + ).order_by(MarketData.timestamp).all() + finally: + session.close() + diff --git a/src/backtesting/engine.py b/src/backtesting/engine.py new file mode 100644 index 00000000..00977b58 --- /dev/null +++ b/src/backtesting/engine.py @@ -0,0 +1,207 @@ +"""Backtesting engine with historical data replay and performance metrics.""" + +import pandas as pd +from decimal import Decimal +from datetime import datetime +from typing import Dict, List, Optional, Any +from sqlalchemy import select +from src.core.database import get_database, MarketData, OrderType +from src.core.logger import get_logger +from src.strategies.base import BaseStrategy +from src.trading.paper_trading import PaperTradingSimulator +from src.exchanges.factory import get_exchange +from .metrics import BacktestMetrics +from .slippage import FeeModel + +logger = get_logger(__name__) + + +class BacktestingEngine: + """Backtesting engine for strategy evaluation.""" + + def __init__(self): + """Initialize backtesting engine.""" + self.db = get_database() + self.logger = get_logger(__name__) + + async def run_backtest( + self, + strategy: BaseStrategy, + symbol: str, + exchange: str, + timeframe: str, + start_date: datetime, + end_date: datetime, + initial_capital: Decimal = Decimal("100.0"), + slippage: float = 0.001, # 0.1% slippage + fee_model: Optional[FeeModel] = None, + exchange_id: Optional[int] = None, + ) -> Dict[str, Any]: + """Run backtest on strategy. + + Args: + strategy: Strategy instance + symbol: Trading symbol + exchange: Exchange name + timeframe: Timeframe + start_date: Start date + end_date: End date + initial_capital: Initial capital + slippage: Slippage percentage + fee_model: FeeModel instance (if None, creates default) + exchange_id: Exchange ID for fee structure retrieval + + Returns: + Backtest results dictionary + """ + # Get historical data + data = await self._get_historical_data(exchange, symbol, timeframe, start_date, end_date) + + if len(data) == 0: + return {"error": "No historical data available"} + + # Initialize fee model + if fee_model is None: + # Try to get exchange adapter for fee structure + exchange_adapter = None + if exchange_id: + try: + exchange_adapter = await get_exchange(exchange_id) + except Exception as e: + self.logger.warning(f"Could not get exchange adapter for fees: {e}") + + fee_model = FeeModel(exchange_adapter=exchange_adapter) + + # Initialize paper trading simulator + simulator = PaperTradingSimulator(initial_capital) + await simulator.initialize() + strategy.enabled = True + + # Run backtest + trades = [] + total_fees = Decimal(0) + + for i, row in data.iterrows(): + price = Decimal(str(row['close'])) + + # Generate signal + signal = await strategy.on_tick( + symbol, + price, + timeframe, + {'open': row['open'], 'high': row['high'], 'low': row['low'], 'volume': row['volume']} + ) + + if signal and strategy.should_execute(signal): + # Determine order type (default to MARKET for backtesting) + # In real trading, strategies could specify limit orders + order_type = OrderType.MARKET + is_maker = (order_type == OrderType.LIMIT) + + # Execute trade with slippage and fees + slippage_multiplier = (Decimal("1") + Decimal(str(slippage))) if signal.signal_type.value == "buy" else (Decimal("1") - Decimal(str(slippage))) + fill_price = price * slippage_multiplier + quantity = signal.quantity or strategy.calculate_position_size(signal, simulator.get_balance(), fill_price) + + # Calculate fee using FeeModel with maker/taker distinction + fee = fee_model.calculate_fee(quantity, fill_price, is_maker=is_maker) + total_fees += fee + + # Create and execute order + from src.core.database import Order as DBOrder, OrderSide + from src.trading.order_manager import get_order_manager + + order_manager = get_order_manager() + order = await order_manager.create_order( + exchange_id=exchange_id or 1, # Use provided exchange_id or placeholder + strategy_id=None, + symbol=symbol, + order_type=order_type, + side=OrderSide.BUY if signal.signal_type.value == "buy" else OrderSide.SELL, + quantity=quantity, + paper_trading=True + ) + + if order and await simulator.execute_order(order, fill_price, fee): + trades.append({ + 'timestamp': i, + 'price': float(fill_price), + 'quantity': float(quantity), + 'side': signal.signal_type.value, + 'fee': float(fee), + }) + + # Calculate metrics + metrics = BacktestMetrics() + results = metrics.calculate_metrics(simulator, trades, initial_capital, total_fees) + + # Add fee information to results + results['total_fees'] = float(total_fees) + results['fee_percentage'] = float((total_fees / initial_capital) * 100) if initial_capital > 0 else 0.0 + + return results + + async def _get_historical_data( + self, + exchange: str, + symbol: str, + timeframe: str, + start_date: datetime, + end_date: datetime + ) -> pd.DataFrame: + """Get historical OHLCV data. + + Args: + exchange: Exchange name + symbol: Trading symbol + timeframe: Timeframe + start_date: Start date + end_date: End date + + Returns: + DataFrame with OHLCV data + """ + async with self.db.get_session() as session: + try: + stmt = select(MarketData).filter( + MarketData.exchange == exchange, + MarketData.symbol == symbol, + MarketData.timeframe == timeframe, + MarketData.timestamp >= start_date, + MarketData.timestamp <= end_date + ).order_by(MarketData.timestamp) + + result = await session.execute(stmt) + market_data = result.scalars().all() + + if len(market_data) == 0: + return pd.DataFrame() + + data = { + 'timestamp': [md.timestamp for md in market_data], + 'open': [float(md.open) for md in market_data], + 'high': [float(md.high) for md in market_data], + 'low': [float(md.low) for md in market_data], + 'close': [float(md.close) for md in market_data], + 'volume': [float(md.volume) for md in market_data], + } + + df = pd.DataFrame(data) + df.set_index('timestamp', inplace=True) + return df + except Exception as e: + logger.error(f"Failed to get historical data: {e}") + return pd.DataFrame() + + +# Global backtesting engine +_backtesting_engine: Optional[BacktestingEngine] = None + + +def get_backtest_engine() -> BacktestingEngine: + """Get global backtesting engine instance.""" + global _backtesting_engine + if _backtesting_engine is None: + _backtesting_engine = BacktestingEngine() + return _backtesting_engine + diff --git a/src/backtesting/metrics.py b/src/backtesting/metrics.py new file mode 100644 index 00000000..690c475d --- /dev/null +++ b/src/backtesting/metrics.py @@ -0,0 +1,85 @@ +"""Performance metrics for backtesting.""" + +from decimal import Decimal +from typing import Dict, List, Any, Optional +from src.trading.paper_trading import PaperTradingSimulator + +class BacktestMetrics: + """Calculates backtest performance metrics.""" + + def calculate_metrics( + self, + simulator: PaperTradingSimulator, + trades: List[Dict[str, Any]], + initial_capital: Decimal, + total_fees: Optional[Decimal] = None + ) -> Dict[str, Any]: + """Calculate backtest metrics, including fee-adjusted metrics. + + Args: + simulator: Paper trading simulator + trades: List of executed trades (may include 'fee' field) + initial_capital: Initial capital + total_fees: Total fees paid (if None, calculated from trades) + + Returns: + Dictionary of metrics + """ + final_value = simulator.get_portfolio_value() + + # Calculate total fees if not provided + if total_fees is None: + total_fees = sum( + Decimal(str(trade.get('fee', 0))) for trade in trades + ) + + # Gross return (before fees) + gross_return = ((final_value - initial_capital) / initial_capital) * 100 if initial_capital > 0 else 0 + + # Net return (after fees) - fees are already deducted in simulator + # But we calculate what return would be without fees for comparison + value_without_fees = final_value + total_fees + net_return = ((final_value - initial_capital) / initial_capital) * 100 if initial_capital > 0 else 0 + gross_return_without_fees = ((value_without_fees - initial_capital) / initial_capital) * 100 if initial_capital > 0 else 0 + + # Fee impact + fee_impact = gross_return_without_fees - net_return + + # Calculate win rate from trades + win_rate = self._calculate_win_rate(trades) + + return { + "initial_capital": float(initial_capital), + "final_capital": float(final_value), + "total_return": float(net_return), # Net return (after fees) + "gross_return": float(gross_return_without_fees), # Gross return (before fees) + "total_fees": float(total_fees), + "fee_impact_percent": float(fee_impact), + "fee_percentage": float((total_fees / initial_capital) * 100) if initial_capital > 0 else 0.0, + "total_trades": len(trades), + "win_rate": win_rate, + "sharpe_ratio": 0.0, # Placeholder - would need returns series + "max_drawdown": 0.0, # Placeholder - would need equity curve + } + + def _calculate_win_rate(self, trades: List[Dict[str, Any]]) -> float: + """Calculate win rate from trades. + + Args: + trades: List of trades + + Returns: + Win rate (0.0 to 1.0) + """ + if len(trades) < 2: + return 0.0 + + # Simple approach: count buy-sell pairs + # This is a simplified calculation - full implementation would track positions + wins = 0 + total_pairs = 0 + + # Group trades by symbol and calculate pairs + # For now, return placeholder + return 0.5 + diff --git a/src/backtesting/slippage.py b/src/backtesting/slippage.py new file mode 100644 index 00000000..38a25b44 --- /dev/null +++ b/src/backtesting/slippage.py @@ -0,0 +1,175 @@ +"""Slippage modeling for realistic backtesting.""" + +from decimal import Decimal +from typing import Dict, Optional, Any +from src.core.logger import get_logger +from src.exchanges.base import BaseExchangeAdapter + +logger = get_logger(__name__) + + +class SlippageModel: + """Models slippage for realistic backtesting.""" + + def __init__(self, slippage_rate: float = 0.001): + """Initialize slippage model. + + Args: + slippage_rate: Slippage rate (0.001 = 0.1%) + """ + self.slippage_rate = slippage_rate + self.logger = get_logger(__name__) + + def calculate_fill_price( + self, + order_price: Decimal, + side: str, # "buy" or "sell" + order_type: str, # "market" or "limit" + market_price: Decimal, + volume: Decimal = Decimal(0) + ) -> Decimal: + """Calculate fill price with slippage. + + Args: + order_price: Order price + side: Buy or sell + order_type: Market or limit + market_price: Current market price + volume: Order volume (for market impact) + + Returns: + Fill price with slippage + """ + if order_type == "limit": + # Limit orders fill at order price (if market reaches it) + return order_price + + # Market orders have slippage + if side == "buy": + # Buy orders pay more (slippage up) + slippage = market_price * Decimal(str(self.slippage_rate)) + # Add market impact based on volume + impact = market_price * Decimal(str(volume)) * Decimal("0.0001") # Simplified + return market_price + slippage + impact + else: + # Sell orders receive less (slippage down) + slippage = market_price * Decimal(str(self.slippage_rate)) + impact = market_price * Decimal(str(volume)) * Decimal("0.0001") + return market_price - slippage - impact + + +class FeeModel: + """Models exchange fees with support for dynamic fee retrieval.""" + + def __init__( + self, + maker_fee: Optional[float] = None, + taker_fee: Optional[float] = None, + exchange_adapter: Optional[BaseExchangeAdapter] = None, + minimum_fee: float = 0.0 + ): + """Initialize fee model. + + Args: + maker_fee: Maker fee rate (if None, retrieved from exchange or default) + taker_fee: Taker fee rate (if None, retrieved from exchange or default) + exchange_adapter: Exchange adapter for dynamic fee retrieval + minimum_fee: Minimum fee amount + """ + self.exchange_adapter = exchange_adapter + self.minimum_fee = Decimal(str(minimum_fee)) + self.logger = get_logger(__name__) + + # Set fees from parameters or retrieve from exchange + if maker_fee is not None and taker_fee is not None: + self.maker_fee = maker_fee + self.taker_fee = taker_fee + else: + fee_structure = self._get_fee_structure() + self.maker_fee = maker_fee if maker_fee is not None else fee_structure.get('maker', 0.001) + self.taker_fee = taker_fee if taker_fee is not None else fee_structure.get('taker', 0.001) + + def _get_fee_structure(self) -> Dict[str, Any]: + """Get fee structure from exchange adapter or defaults. + + Returns: + Fee structure dictionary + """ + if self.exchange_adapter: + try: + return self.exchange_adapter.get_fee_structure() + except Exception as e: + self.logger.warning(f"Failed to get fee structure from exchange: {e}") + + return { + 'maker': 0.001, # 0.1% + 'taker': 0.001, # 0.1% + 'minimum': 0.0 + } + + def calculate_fee( + self, + quantity: Decimal, + price: Decimal, + is_maker: bool = False + ) -> Decimal: + """Calculate trading fee. + + Args: + quantity: Trade quantity + price: Trade price + is_maker: True if maker order + + Returns: + Trading fee + """ + if quantity <= 0 or price <= 0: + return Decimal(0) + + trade_value = quantity * price + fee_rate = self.maker_fee if is_maker else self.taker_fee + fee = trade_value * Decimal(str(fee_rate)) + + # Apply minimum fee + if self.minimum_fee > 0 and fee < self.minimum_fee: + fee = self.minimum_fee + + return fee + + def estimate_round_trip_fee( + self, + quantity: Decimal, + price: Decimal + ) -> Decimal: + """Estimate total fees for a round-trip trade (buy + sell). + + Args: + quantity: Trade quantity + price: Trade price + + Returns: + Total estimated round-trip fee + """ + buy_fee = self.calculate_fee(quantity, price, is_maker=False) + sell_fee = self.calculate_fee(quantity, price, is_maker=False) + return buy_fee + sell_fee + + def get_minimum_profit_threshold( + self, + quantity: Decimal, + price: Decimal, + multiplier: float = 2.0 + ) -> Decimal: + """Calculate minimum profit threshold needed to break even after fees. + + Args: + quantity: Trade quantity + price: Trade price + multiplier: Multiplier for minimum profit (default 2.0 = 2x fees) + + Returns: + Minimum profit threshold + """ + round_trip_fee = self.estimate_round_trip_fee(quantity, price) + return round_trip_fee * Decimal(str(multiplier)) + diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/core/config.py b/src/core/config.py new file mode 100644 index 00000000..1ecb50e5 --- /dev/null +++ b/src/core/config.py @@ -0,0 +1,256 @@ +"""Configuration management system with YAML and environment variables.""" + +import os +import yaml +from pathlib import Path +from typing import Any, Dict, Optional +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + + +class Config: + """Configuration manager with XDG directory support.""" + + def __init__(self, config_file: Optional[str] = None): + """Initialize configuration manager. + + Args: + config_file: Optional path to config file. If None, uses XDG default. + """ + self._setup_xdg_directories() + + # Determine config file priority: + # 1. Explicit argument + # 2. Local project config (dev mode) + # 3. XDG config (user mode) + + local_config = Path(__file__).parent.parent.parent / "config" / "config.yaml" + + if config_file: + self.config_file = Path(config_file) + elif local_config.exists(): + self.config_file = local_config + else: + self.config_file = self.config_dir / "config.yaml" + + self._config: Dict[str, Any] = {} + self._load_config() + + def _setup_xdg_directories(self): + """Set up XDG Base Directory Specification directories.""" + home = Path.home() + + # XDG_CONFIG_HOME or default + xdg_config = os.getenv("XDG_CONFIG_HOME", home / ".config") + self.config_dir = Path(xdg_config) / "crypto_trader" + self.config_dir.mkdir(parents=True, exist_ok=True) + + # XDG_DATA_HOME or default + xdg_data = os.getenv("XDG_DATA_HOME", home / ".local" / "share") + self.data_dir = Path(xdg_data) / "crypto_trader" + self.data_dir.mkdir(parents=True, exist_ok=True) + + # Create subdirectories + (self.data_dir / "historical").mkdir(exist_ok=True) + (self.data_dir / "backups").mkdir(exist_ok=True) + (self.data_dir / "logs").mkdir(exist_ok=True) + + # XDG_CACHE_HOME or default + xdg_cache = os.getenv("XDG_CACHE_HOME", home / ".cache") + self.cache_dir = Path(xdg_cache) / "crypto_trader" + self.cache_dir.mkdir(parents=True, exist_ok=True) + + def _load_config(self): + """Load configuration from YAML file and environment variables.""" + # Load defaults + default_config = self._get_default_config() + self._config = default_config.copy() + + # Load from file if it exists + if self.config_file.exists(): + with open(self.config_file, 'r') as f: + file_config = yaml.safe_load(f) or {} + self._config.update(file_config) + + # Override with environment variables + self._load_from_env() + + def _get_default_config(self) -> Dict[str, Any]: + """Get default configuration.""" + return { + "app": { + "name": "Crypto Trader", + "version": "0.1.0", + }, + "database": { + "type": "postgresql", + "url": None, # For PostgreSQL + }, + "logging": { + "level": os.getenv("LOG_LEVEL", "INFO"), + "dir": str(self.data_dir / "logs"), + "retention_days": 30, + "rotation": "daily", + }, + "paper_trading": { + "enabled": True, + "default_capital": float(os.getenv("PAPER_TRADING_CAPITAL", "100.0")), + }, + "updates": { + "check_on_startup": os.getenv("UPDATE_CHECK_ON_STARTUP", "true").lower() == "true", + "repository_url": os.getenv("UPDATE_REPOSITORY_URL", ""), + }, + "exchanges": {}, + "strategies": { + "default_timeframe": "1h", + }, + "risk": { + "max_drawdown_percent": 20.0, + "daily_loss_limit_percent": 5.0, + "position_size_percent": 2.0, + }, + "trading": { + "default_fees": { + "maker": 0.001, # 0.1% + "taker": 0.001, # 0.1% + "minimum": 0.0, + }, + "exchanges": {}, + }, + "data_providers": { + "primary": [ + {"name": "kraken", "enabled": True, "priority": 1}, + {"name": "coinbase", "enabled": True, "priority": 2}, + {"name": "binance", "enabled": True, "priority": 3}, + ], + "fallback": { + "name": "coingecko", + "enabled": True, + "api_key": "", + }, + "caching": { + "ticker_ttl": 2, # seconds + "ohlcv_ttl": 60, # seconds + "max_cache_size": 1000, + }, + "websocket": { + "enabled": True, + "reconnect_interval": 5, # seconds + "ping_interval": 30, # seconds + }, + }, + "redis": { + "host": os.getenv("REDIS_HOST", "127.0.0.1"), + "port": int(os.getenv("REDIS_PORT", 6379)), + "db": int(os.getenv("REDIS_DB", 0)), + "password": os.getenv("REDIS_PASSWORD", None), + "socket_connect_timeout": 5, + }, + "celery": { + "broker_url": os.getenv("CELERY_BROKER_URL", "redis://127.0.0.1:6379/0"), + "result_backend": os.getenv("CELERY_RESULT_BACKEND", "redis://127.0.0.1:6379/0"), + }, + } + + def _load_from_env(self): + """Load configuration from environment variables.""" + # Database + if db_url := os.getenv("DATABASE_URL"): + self._config["database"]["url"] = db_url + self._config["database"]["type"] = "postgresql" + + # Logging + if log_level := os.getenv("LOG_LEVEL"): + self._config["logging"]["level"] = log_level + if log_dir := os.getenv("LOG_DIR"): + self._config["logging"]["dir"] = log_dir + + # Paper trading + if capital := os.getenv("PAPER_TRADING_CAPITAL"): + self._config["paper_trading"]["default_capital"] = float(capital) + + def get(self, key: str, default: Any = None) -> Any: + """Get configuration value using dot notation. + + Args: + key: Configuration key (e.g., "database.path") + default: Default value if key not found + + Returns: + Configuration value or default + """ + keys = key.split(".") + value = self._config + for k in keys: + if isinstance(value, dict): + value = value.get(k) + if value is None: + return default + else: + return default + return value + + def set(self, key: str, value: Any): + """Set configuration value using dot notation. + + Args: + key: Configuration key (e.g., "database.path") + value: Value to set + """ + keys = key.split(".") + config = self._config + for k in keys[:-1]: + if k not in config: + config[k] = {} + config = config[k] + config[keys[-1]] = value + + def save(self): + """Save configuration to file.""" + with open(self.config_file, 'w') as f: + yaml.dump(self._config, f, default_flow_style=False, sort_keys=False) + + @property + def config_dir(self) -> Path: + """Get config directory path.""" + return self._config_dir + + @config_dir.setter + def config_dir(self, value: Path): + """Set config directory.""" + self._config_dir = value + + @property + def data_dir(self) -> Path: + """Get data directory path.""" + return self._data_dir + + @data_dir.setter + def data_dir(self, value: Path): + """Set data directory.""" + self._data_dir = value + + @property + def cache_dir(self) -> Path: + """Get cache directory path.""" + return self._cache_dir + + @cache_dir.setter + def cache_dir(self, value: Path): + """Set cache directory.""" + self._cache_dir = value + + +# Global config instance +_config_instance: Optional[Config] = None + + +def get_config() -> Config: + """Get global configuration instance.""" + global _config_instance + if _config_instance is None: + _config_instance = Config() + return _config_instance + diff --git a/src/core/database.py b/src/core/database.py new file mode 100644 index 00000000..3a1fb8ff --- /dev/null +++ b/src/core/database.py @@ -0,0 +1,416 @@ +"""Database connection and models using SQLAlchemy.""" + +from datetime import datetime +from decimal import Decimal +from enum import Enum +from pathlib import Path +from typing import Optional +from sqlalchemy import ( + create_engine, Column, Integer, String, Float, Boolean, DateTime, + Text, ForeignKey, JSON, Enum as SQLEnum, Numeric +) +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker, relationship, Session +from .config import get_config + +Base = declarative_base() + + +class OrderType(str, Enum): + """Order type enumeration.""" + MARKET = "market" + LIMIT = "limit" + STOP_LOSS = "stop_loss" + TAKE_PROFIT = "take_profit" + TRAILING_STOP = "trailing_stop" + OCO = "oco" + ICEBERG = "iceberg" + + +class OrderSide(str, Enum): + """Order side enumeration.""" + BUY = "buy" + SELL = "sell" + + +class OrderStatus(str, Enum): + """Order status enumeration.""" + PENDING = "pending" + OPEN = "open" + PARTIALLY_FILLED = "partially_filled" + FILLED = "filled" + CANCELLED = "cancelled" + REJECTED = "rejected" + EXPIRED = "expired" + + +class TradeType(str, Enum): + """Trade type enumeration.""" + SPOT = "spot" + FUTURES = "futures" + MARGIN = "margin" + + +class Exchange(Base): + """Exchange configuration and credentials.""" + __tablename__ = "exchanges" + + id = Column(Integer, primary_key=True) + name = Column(String(50), nullable=False, unique=True) + api_key_encrypted = Column(Text) # Encrypted API key + api_secret_encrypted = Column(Text) # Encrypted API secret + sandbox = Column(Boolean, default=False) + read_only = Column(Boolean, default=True) # Read-only mode + enabled = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + trades = relationship("Trade", back_populates="exchange") + orders = relationship("Order", back_populates="exchange") + positions = relationship("Position", back_populates="exchange") + + +class Strategy(Base): + """Strategy definitions and parameters.""" + __tablename__ = "strategies" + + id = Column(Integer, primary_key=True) + name = Column(String(100), nullable=False) + description = Column(Text) + strategy_type = Column(String(50)) # technical, momentum, grid, dca, etc. + class_name = Column(String(100)) # Python class name + parameters = Column(JSON) # Strategy parameters + timeframes = Column(JSON) # Multi-timeframe configuration + enabled = Column(Boolean, default=False) # Available to Autopilot + running = Column(Boolean, default=False) # Currently running manually + paper_trading = Column(Boolean, default=True) + version = Column(String(20), default="1.0.0") + schedule = Column(JSON) # Scheduling configuration + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + trades = relationship("Trade", back_populates="strategy") + backtest_results = relationship("BacktestResult", back_populates="strategy") + + +class Order(Base): + """Order history with state tracking.""" + __tablename__ = "orders" + + id = Column(Integer, primary_key=True) + exchange_id = Column(Integer, ForeignKey("exchanges.id"), nullable=False) + strategy_id = Column(Integer, ForeignKey("strategies.id"), nullable=True) + exchange_order_id = Column(String(100)) # Exchange's order ID + symbol = Column(String(20), nullable=False) + order_type = Column(SQLEnum(OrderType), nullable=False) + side = Column(SQLEnum(OrderSide), nullable=False) + status = Column(SQLEnum(OrderStatus), default=OrderStatus.PENDING) + quantity = Column(Numeric(20, 8), nullable=False) + price = Column(Numeric(20, 8)) # For limit orders + filled_quantity = Column(Numeric(20, 8), default=0) + average_fill_price = Column(Numeric(20, 8)) + fee = Column(Numeric(20, 8), default=0) + trade_type = Column(SQLEnum(TradeType), default=TradeType.SPOT) + leverage = Column(Integer, default=1) # For futures/margin + paper_trading = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + filled_at = Column(DateTime) + + # Relationships + exchange = relationship("Exchange", back_populates="orders") + strategy = relationship("Strategy") + trades = relationship("Trade", back_populates="order") + + +class Trade(Base): + """All executed trades (paper and live).""" + __tablename__ = "trades" + + id = Column(Integer, primary_key=True) + exchange_id = Column(Integer, ForeignKey("exchanges.id"), nullable=False) + strategy_id = Column(Integer, ForeignKey("strategies.id"), nullable=True) + order_id = Column(Integer, ForeignKey("orders.id"), nullable=True) + symbol = Column(String(20), nullable=False) + side = Column(SQLEnum(OrderSide), nullable=False) + quantity = Column(Numeric(20, 8), nullable=False) + price = Column(Numeric(20, 8), nullable=False) + fee = Column(Numeric(20, 8), default=0) + total = Column(Numeric(20, 8), nullable=False) # quantity * price + fee + trade_type = Column(SQLEnum(TradeType), default=TradeType.SPOT) + paper_trading = Column(Boolean, default=True) + timestamp = Column(DateTime, default=datetime.utcnow, nullable=False) + + # Relationships + exchange = relationship("Exchange", back_populates="trades") + strategy = relationship("Strategy", back_populates="trades") + order = relationship("Order", back_populates="trades") + + +class Position(Base): + """Current open positions (spot and futures).""" + __tablename__ = "positions" + + id = Column(Integer, primary_key=True) + exchange_id = Column(Integer, ForeignKey("exchanges.id"), nullable=False) + symbol = Column(String(20), nullable=False) + side = Column(String(10)) # long, short + quantity = Column(Numeric(20, 8), nullable=False) + entry_price = Column(Numeric(20, 8), nullable=False) + current_price = Column(Numeric(20, 8)) + unrealized_pnl = Column(Numeric(20, 8), default=0) + realized_pnl = Column(Numeric(20, 8), default=0) + trade_type = Column(SQLEnum(TradeType), default=TradeType.SPOT) + leverage = Column(Integer, default=1) + margin = Column(Numeric(20, 8)) + paper_trading = Column(Boolean, default=True) + opened_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + exchange = relationship("Exchange", back_populates="positions") + + +class PortfolioSnapshot(Base): + """Historical portfolio values.""" + __tablename__ = "portfolio_snapshots" + + id = Column(Integer, primary_key=True) + total_value = Column(Numeric(20, 8), nullable=False) + cash = Column(Numeric(20, 8), nullable=False) + positions_value = Column(Numeric(20, 8), nullable=False) + unrealized_pnl = Column(Numeric(20, 8), default=0) + realized_pnl = Column(Numeric(20, 8), default=0) + paper_trading = Column(Boolean, default=True) + timestamp = Column(DateTime, default=datetime.utcnow, nullable=False, index=True) + + +class MarketData(Base): + """OHLCV historical data (multiple timeframes).""" + __tablename__ = "market_data" + + id = Column(Integer, primary_key=True) + exchange = Column(String(50), nullable=False) + symbol = Column(String(20), nullable=False) + timeframe = Column(String(10), nullable=False) # 1m, 5m, 15m, 1h, 1d, etc. + timestamp = Column(DateTime, nullable=False, index=True) + open = Column(Numeric(20, 8), nullable=False) + high = Column(Numeric(20, 8), nullable=False) + low = Column(Numeric(20, 8), nullable=False) + close = Column(Numeric(20, 8), nullable=False) + volume = Column(Numeric(20, 8), nullable=False) + + + + +class BacktestResult(Base): + """Backtesting results and metrics.""" + __tablename__ = "backtest_results" + + id = Column(Integer, primary_key=True) + strategy_id = Column(Integer, ForeignKey("strategies.id"), nullable=False) + start_date = Column(DateTime, nullable=False) + end_date = Column(DateTime, nullable=False) + initial_capital = Column(Numeric(20, 8), nullable=False) + final_capital = Column(Numeric(20, 8), nullable=False) + total_return = Column(Numeric(10, 4)) # Percentage + sharpe_ratio = Column(Numeric(10, 4)) + sortino_ratio = Column(Numeric(10, 4)) + max_drawdown = Column(Numeric(10, 4)) + win_rate = Column(Numeric(10, 4)) + total_trades = Column(Integer, default=0) + parameters = Column(JSON) # Parameters used in backtest + metrics = Column(JSON) # Additional metrics + created_at = Column(DateTime, default=datetime.utcnow) + + # Relationships + strategy = relationship("Strategy", back_populates="backtest_results") + + +class RiskLimit(Base): + """Risk management configuration.""" + __tablename__ = "risk_limits" + + id = Column(Integer, primary_key=True) + name = Column(String(100), nullable=False) + limit_type = Column(String(50)) # max_drawdown, daily_loss, position_size, etc. + value = Column(Numeric(10, 4), nullable=False) + enabled = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class Alert(Base): + """Alert definitions and history.""" + __tablename__ = "alerts" + + id = Column(Integer, primary_key=True) + name = Column(String(100), nullable=False) + alert_type = Column(String(50)) # price, indicator, risk, system + condition = Column(JSON) # Alert condition configuration + enabled = Column(Boolean, default=True) + triggered = Column(Boolean, default=False) + triggered_at = Column(DateTime) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class RebalancingEvent(Base): + """Portfolio rebalancing history.""" + __tablename__ = "rebalancing_events" + + id = Column(Integer, primary_key=True) + trigger_type = Column(String(50)) # time, threshold, manual + target_allocations = Column(JSON) # Target portfolio allocations + before_allocations = Column(JSON) # Allocations before rebalancing + after_allocations = Column(JSON) # Allocations after rebalancing + orders_placed = Column(JSON) # Orders placed for rebalancing + timestamp = Column(DateTime, default=datetime.utcnow, nullable=False) + + +class AppState(Base): + """Application state for recovery.""" + __tablename__ = "app_state" + + id = Column(Integer, primary_key=True) + key = Column(String(100), unique=True, nullable=False) + value = Column(JSON) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class AuditLog(Base): + """Security and action audit trail.""" + __tablename__ = "audit_log" + + id = Column(Integer, primary_key=True) + action = Column(String(100), nullable=False) + entity_type = Column(String(50)) # exchange, strategy, order, etc. + entity_id = Column(Integer) + details = Column(JSON) + timestamp = Column(DateTime, default=datetime.utcnow, nullable=False, index=True) + + +class MarketConditionsSnapshot(Base): + """Market conditions snapshot for ML training.""" + __tablename__ = "market_conditions_snapshot" + + id = Column(Integer, primary_key=True) + symbol = Column(String(20), nullable=False) + timeframe = Column(String(10), nullable=False) + regime = Column(String(50)) # Market regime classification + features = Column(JSON) # Market condition features + strategy_name = Column(String(100)) # Strategy used at this time + timestamp = Column(DateTime, default=datetime.utcnow, nullable=False, index=True) + + + + +class StrategyPerformance(Base): + """Strategy performance records for ML training.""" + __tablename__ = "strategy_performance" + + id = Column(Integer, primary_key=True) + strategy_name = Column(String(100), nullable=False, index=True) + symbol = Column(String(20), nullable=False) + timeframe = Column(String(10), nullable=False) + market_regime = Column(String(50), index=True) # Market regime when trade executed + return_pct = Column(Numeric(10, 4)) # Return percentage + sharpe_ratio = Column(Numeric(10, 4)) # Sharpe ratio + win_rate = Column(Numeric(5, 2)) # Win rate (0-100) + max_drawdown = Column(Numeric(10, 4)) # Maximum drawdown + trade_count = Column(Integer, default=1) # Number of trades in this period + timestamp = Column(DateTime, default=datetime.utcnow, nullable=False, index=True) + + + + +class MLModelMetadata(Base): + """ML model metadata and versions.""" + __tablename__ = "ml_model_metadata" + + id = Column(Integer, primary_key=True) + model_name = Column(String(100), nullable=False) + model_type = Column(String(50)) # classifier, regressor + version = Column(String(20)) + file_path = Column(String(500)) # Path to saved model file + training_metrics = Column(JSON) # Training metrics (accuracy, MSE, etc.) + feature_names = Column(JSON) # List of feature names + strategy_names = Column(JSON) # List of strategy names + training_samples = Column(Integer) # Number of training samples + trained_at = Column(DateTime, nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + + + + +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker + +class Database: + """Database connection manager.""" + + def __init__(self): + """Initialize database connection.""" + self.config = get_config() + self.engine = self._create_engine() + self.SessionLocal = async_sessionmaker( + bind=self.engine, + class_=AsyncSession, + expire_on_commit=False + ) + # self._create_tables() # Tables should be created via alembic or separate init script in async + + def _create_engine(self): + """Create database engine.""" + db_type = self.config.get("database.type", "postgresql") + + if db_type == "postgresql": + db_url = self.config.get("database.url") + if not db_url: + raise ValueError("PostgreSQL URL not configured") + # Ensure URL uses async driver (e.g. postgresql+asyncpg) + if "postgresql://" in db_url and "postgresql+asyncpg://" not in db_url: + # This is a naive replacement, in production we should handle this better + db_url = db_url.replace("postgresql://", "postgresql+asyncpg://") + # Add connection timeout to prevent hanging + # asyncpg connect timeout is set via connect_timeout in connect_args + return create_async_engine( + db_url, + echo=False, + connect_args={ + "server_settings": {"application_name": "crypto_trader"}, + "timeout": 5, # 5 second connection timeout + }, + pool_pre_ping=True, # Verify connections before using + pool_recycle=3600, # Recycle connections after 1 hour + pool_timeout=5, # Timeout when getting connection from pool + ) + else: + raise ValueError(f"Unsupported database type: {db_type}. Only 'postgresql' is supported.") + + async def create_tables(self): + """Create all database tables.""" + async with self.engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + def get_session(self) -> AsyncSession: + """Get a database session.""" + return self.SessionLocal() + + async def close(self): + """Close database connection.""" + await self.engine.dispose() + + +# Global database instance +_db_instance: Optional[Database] = None + + +def get_database() -> Database: + """Get global database instance.""" + global _db_instance + if _db_instance is None: + _db_instance = Database() + return _db_instance + diff --git a/src/core/logger.py b/src/core/logger.py new file mode 100644 index 00000000..5d9eec56 --- /dev/null +++ b/src/core/logger.py @@ -0,0 +1,128 @@ +"""Configurable logging system with XDG directory support.""" + +import logging +import logging.handlers +import yaml +from pathlib import Path +from typing import Optional +from .config import get_config + + +class LoggingConfig: + """Logging configuration manager.""" + + def __init__(self): + """Initialize logging configuration.""" + self.config = get_config() + self.log_dir = Path(self.config.get("logging.dir", "~/.local/share/crypto_trader/logs")).expanduser() + self.log_dir.mkdir(parents=True, exist_ok=True) + self.retention_days = self.config.get("logging.retention_days", 30) + self._setup_logging() + + def _setup_logging(self): + """Set up logging configuration.""" + log_level = self.config.get("logging.level", "INFO") + level = getattr(logging, log_level.upper(), logging.INFO) + + # Root logger + root_logger = logging.getLogger() + root_logger.setLevel(level) + + # Clear existing handlers + root_logger.handlers.clear() + + # Console handler + console_handler = logging.StreamHandler() + console_handler.setLevel(level) + console_formatter = logging.Formatter( + '%(asctime)s [%(levelname)s] %(name)s: %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + ) + console_handler.setFormatter(console_formatter) + root_logger.addHandler(console_handler) + + # File handler with rotation + log_file = self.log_dir / "crypto_trader.log" + file_handler = logging.handlers.TimedRotatingFileHandler( + log_file, + when='midnight', + interval=1, + backupCount=self.retention_days, + encoding='utf-8' + ) + file_handler.setLevel(logging.DEBUG) + file_formatter = logging.Formatter( + '%(asctime)s [%(levelname)s] %(name)s:%(lineno)d: %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + ) + file_handler.setFormatter(file_formatter) + root_logger.addHandler(file_handler) + + # Compress old logs + self._setup_log_compression() + + def _setup_log_compression(self): + """Set up log compression for old log files.""" + import gzip + import glob + + # Compress logs older than retention period + log_files = list(self.log_dir.glob("crypto_trader.log.*")) + for log_file in log_files: + if not log_file.name.endswith('.gz'): + try: + with open(log_file, 'rb') as f_in: + with gzip.open(f"{log_file}.gz", 'wb') as f_out: + f_out.writelines(f_in) + log_file.unlink() + except Exception: + pass # Skip if compression fails + + def get_logger(self, name: str) -> logging.Logger: + """Get a logger with the specified name. + + Args: + name: Logger name (typically module name) + + Returns: + Logger instance + """ + logger = logging.getLogger(name) + return logger + + def set_level(self, level: str): + """Set logging level. + + Args: + level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) + """ + log_level = getattr(logging, level.upper(), logging.INFO) + logging.getLogger().setLevel(log_level) + for handler in logging.getLogger().handlers: + handler.setLevel(log_level) + + +# Global logging config instance +_logging_config: Optional[LoggingConfig] = None + + +def get_logger(name: str) -> logging.Logger: + """Get a logger instance. + + Args: + name: Logger name (typically __name__) + + Returns: + Logger instance + """ + global _logging_config + if _logging_config is None: + _logging_config = LoggingConfig() + return _logging_config.get_logger(name) + + +def setup_logging(): + """Set up logging system.""" + global _logging_config + _logging_config = LoggingConfig() + diff --git a/src/core/pubsub.py b/src/core/pubsub.py new file mode 100644 index 00000000..58d9cb1c --- /dev/null +++ b/src/core/pubsub.py @@ -0,0 +1,261 @@ +"""Redis Pub/Sub for real-time event broadcasting across workers.""" + +import asyncio +import json +from typing import Callable, Dict, Any, Optional, List +from src.core.redis import get_redis_client +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +# Channel names +CHANNEL_MARKET_EVENTS = "crypto_trader:market_events" +CHANNEL_TRADE_EVENTS = "crypto_trader:trade_events" +CHANNEL_SYSTEM_EVENTS = "crypto_trader:system_events" +CHANNEL_AUTOPILOT_EVENTS = "crypto_trader:autopilot_events" + + +class RedisPubSub: + """Redis Pub/Sub handler for real-time event broadcasting.""" + + def __init__(self): + """Initialize Redis Pub/Sub.""" + self.redis = get_redis_client() + self._subscribers: Dict[str, List[Callable]] = {} + self._pubsub = None + self._running = False + self._listen_task: Optional[asyncio.Task] = None + + async def publish(self, channel: str, event_type: str, data: Dict[str, Any]) -> int: + """Publish an event to a channel. + + Args: + channel: Channel name + event_type: Type of event (e.g., 'price_update', 'trade_executed') + data: Event data + + Returns: + Number of subscribers that received the message + """ + message = { + "type": event_type, + "data": data, + "timestamp": asyncio.get_event_loop().time() + } + + try: + client = self.redis.get_client() + count = await client.publish(channel, json.dumps(message)) + logger.debug(f"Published {event_type} to {channel} ({count} subscribers)") + return count + except Exception as e: + logger.error(f"Failed to publish to {channel}: {e}") + return 0 + + async def subscribe(self, channel: str, callback: Callable[[Dict[str, Any]], None]) -> None: + """Subscribe to a channel. + + Args: + channel: Channel name + callback: Async function to call when message received + """ + if channel not in self._subscribers: + self._subscribers[channel] = [] + self._subscribers[channel].append(callback) + + logger.info(f"Subscribed to channel: {channel}") + + # Start listener if not running + if not self._running: + await self._start_listener() + + async def unsubscribe(self, channel: str, callback: Callable = None) -> None: + """Unsubscribe from a channel. + + Args: + channel: Channel name + callback: Specific callback to remove, or None to remove all + """ + if channel in self._subscribers: + if callback: + self._subscribers[channel] = [c for c in self._subscribers[channel] if c != callback] + else: + del self._subscribers[channel] + + logger.info(f"Unsubscribed from channel: {channel}") + + async def _start_listener(self) -> None: + """Start the Pub/Sub listener.""" + if self._running: + return + + self._running = True + self._listen_task = asyncio.create_task(self._listen()) + logger.info("Started Redis Pub/Sub listener") + + async def _listen(self) -> None: + """Listen for messages on subscribed channels.""" + try: + client = self.redis.get_client() + self._pubsub = client.pubsub() + + # Subscribe to all registered channels + channels = list(self._subscribers.keys()) + if channels: + await self._pubsub.subscribe(*channels) + + while self._running: + try: + message = await self._pubsub.get_message(ignore_subscribe_messages=True, timeout=1.0) + + if message and message['type'] == 'message': + channel = message['channel'] + if isinstance(channel, bytes): + channel = channel.decode('utf-8') + + data = message['data'] + if isinstance(data, bytes): + data = data.decode('utf-8') + + try: + parsed = json.loads(data) + except json.JSONDecodeError: + parsed = {"raw": data} + + # Call all subscribers for this channel + callbacks = self._subscribers.get(channel, []) + for callback in callbacks: + try: + if asyncio.iscoroutinefunction(callback): + await callback(parsed) + else: + callback(parsed) + except Exception as e: + logger.error(f"Subscriber callback error: {e}") + + await asyncio.sleep(0.01) # Prevent busy loop + + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"Pub/Sub listener error: {e}") + await asyncio.sleep(1) + + finally: + if self._pubsub: + await self._pubsub.close() + self._running = False + + async def stop(self) -> None: + """Stop the Pub/Sub listener.""" + self._running = False + if self._listen_task: + self._listen_task.cancel() + try: + await self._listen_task + except asyncio.CancelledError: + pass + logger.info("Stopped Redis Pub/Sub listener") + + # Convenience methods for common event types + + async def publish_price_update(self, symbol: str, price: float, bid: float = None, ask: float = None) -> int: + """Publish a price update event. + + Args: + symbol: Trading symbol + price: Current price + bid: Bid price + ask: Ask price + + Returns: + Number of subscribers notified + """ + return await self.publish(CHANNEL_MARKET_EVENTS, "price_update", { + "symbol": symbol, + "price": price, + "bid": bid, + "ask": ask + }) + + async def publish_trade_executed( + self, + symbol: str, + side: str, + quantity: float, + price: float, + order_id: str = None + ) -> int: + """Publish a trade execution event. + + Args: + symbol: Trading symbol + side: 'buy' or 'sell' + quantity: Trade quantity + price: Execution price + order_id: Order ID + + Returns: + Number of subscribers notified + """ + return await self.publish(CHANNEL_TRADE_EVENTS, "trade_executed", { + "symbol": symbol, + "side": side, + "quantity": quantity, + "price": price, + "order_id": order_id + }) + + async def publish_autopilot_status( + self, + symbol: str, + status: str, + action: str = None, + details: Dict[str, Any] = None + ) -> int: + """Publish an autopilot status event. + + Args: + symbol: Trading symbol + status: 'started', 'stopped', 'error', 'signal' + action: Optional action taken + details: Additional details + + Returns: + Number of subscribers notified + """ + return await self.publish(CHANNEL_AUTOPILOT_EVENTS, "autopilot_status", { + "symbol": symbol, + "status": status, + "action": action, + "details": details or {} + }) + + async def publish_system_event(self, event_type: str, message: str, severity: str = "info") -> int: + """Publish a system event. + + Args: + event_type: Event type (e.g., 'startup', 'shutdown', 'error') + message: Event message + severity: 'info', 'warning', 'error' + + Returns: + Number of subscribers notified + """ + return await self.publish(CHANNEL_SYSTEM_EVENTS, event_type, { + "message": message, + "severity": severity + }) + + +# Global Pub/Sub instance +_redis_pubsub: Optional[RedisPubSub] = None + + +def get_redis_pubsub() -> RedisPubSub: + """Get global Redis Pub/Sub instance.""" + global _redis_pubsub + if _redis_pubsub is None: + _redis_pubsub = RedisPubSub() + return _redis_pubsub diff --git a/src/core/redis.py b/src/core/redis.py new file mode 100644 index 00000000..8e254f34 --- /dev/null +++ b/src/core/redis.py @@ -0,0 +1,70 @@ +"""Redis client wrapper.""" + +import redis.asyncio as redis +from typing import Optional +from src.core.config import get_config +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class RedisClient: + """Redis client wrapper with automatic connection handling.""" + + def __init__(self): + """Initialize Redis client.""" + self.config = get_config() + self._client: Optional[redis.Redis] = None + self._pool: Optional[redis.ConnectionPool] = None + + def get_client(self) -> redis.Redis: + """Get or create Redis client. + + Returns: + Async Redis client + """ + if self._client is None: + self._connect() + return self._client + + def _connect(self): + """Connect to Redis.""" + redis_config = self.config.get("redis", {}) + host = redis_config.get("host", "localhost") + port = redis_config.get("port", 6379) + db = redis_config.get("db", 0) + password = redis_config.get("password") + + logger.info(f"Connecting to Redis at {host}:{port}/{db}") + + try: + self._pool = redis.ConnectionPool( + host=host, + port=port, + db=db, + password=password, + decode_responses=True, + socket_connect_timeout=redis_config.get("socket_connect_timeout", 5) + ) + self._client = redis.Redis(connection_pool=self._pool) + except Exception as e: + logger.error(f"Failed to create Redis client: {e}") + raise + + async def close(self): + """Close Redis connection.""" + if self._client: + await self._client.aclose() + logger.info("Redis connection closed") + + +# Global instance +_redis_client: Optional[RedisClient] = None + + +def get_redis_client() -> RedisClient: + """Get global Redis client instance.""" + global _redis_client + if _redis_client is None: + _redis_client = RedisClient() + return _redis_client diff --git a/src/core/repositories.py b/src/core/repositories.py new file mode 100644 index 00000000..1406fe6a --- /dev/null +++ b/src/core/repositories.py @@ -0,0 +1,99 @@ +"""Database repositories for data access.""" + +from typing import Optional, List, Sequence +from datetime import datetime +from decimal import Decimal +from sqlalchemy import select, update, delete +from sqlalchemy.ext.asyncio import AsyncSession +from .database import Order, Position, OrderStatus, OrderSide, OrderType, MarketData + +class BaseRepository: + """Base repository.""" + + def __init__(self, session: AsyncSession): + """Initialize repository.""" + self.session = session + +class OrderRepository(BaseRepository): + """Order repository.""" + + async def create(self, order: Order) -> Order: + """Create new order.""" + self.session.add(order) + await self.session.commit() + await self.session.refresh(order) + return order + + async def get_by_id(self, order_id: int) -> Optional[Order]: + """Get order by ID.""" + result = await self.session.execute( + select(Order).where(Order.id == order_id) + ) + return result.scalar_one_or_none() + + async def get_all(self, limit: int = 100, offset: int = 0) -> Sequence[Order]: + """Get all orders.""" + result = await self.session.execute( + select(Order).limit(limit).offset(offset).order_by(Order.created_at.desc()) + ) + return result.scalars().all() + + async def update_status( + self, + order_id: int, + status: OrderStatus, + exchange_order_id: Optional[str] = None, + fee: Optional[Decimal] = None + ) -> Optional[Order]: + """Update order status.""" + values = {"status": status, "updated_at": datetime.utcnow()} + if exchange_order_id: + values["exchange_order_id"] = exchange_order_id + if fee is not None: + values["fee"] = fee + + await self.session.execute( + update(Order) + .where(Order.id == order_id) + .values(**values) + ) + await self.session.commit() + return await self.get_by_id(order_id) + + async def get_open_orders(self, paper_trading: bool = True) -> Sequence[Order]: + """Get open orders.""" + result = await self.session.execute( + select(Order).where( + Order.paper_trading == paper_trading, + Order.status.in_([OrderStatus.PENDING, OrderStatus.OPEN, OrderStatus.PARTIALLY_FILLED]) + ) + ) + return result.scalars().all() + + async def delete(self, order_id: int) -> bool: + """Delete order.""" + result = await self.session.execute( + delete(Order).where(Order.id == order_id) + ) + await self.session.commit() + return result.rowcount > 0 + +class PositionRepository(BaseRepository): + """Position repository.""" + + async def get_all(self, paper_trading: bool = True) -> Sequence[Position]: + """Get all positions.""" + result = await self.session.execute( + select(Position).where(Position.paper_trading == paper_trading) + ) + return result.scalars().all() + + async def get_by_symbol(self, symbol: str, paper_trading: bool = True) -> Optional[Position]: + """Get position by symbol.""" + result = await self.session.execute( + select(Position).where( + Position.symbol == symbol, + Position.paper_trading == paper_trading + ) + ) + return result.scalar_one_or_none() diff --git a/src/data/__init__.py b/src/data/__init__.py new file mode 100644 index 00000000..bebffad8 --- /dev/null +++ b/src/data/__init__.py @@ -0,0 +1,27 @@ +"""Data collection and storage module. + +Provides: +- DataCollector: Real-time market data collection +- NewsCollector: Crypto news headline aggregation for sentiment analysis +- TechnicalIndicators: Technical analysis indicators +- DataStorage: Data persistence utilities +- DataQualityManager: Data quality checks +""" + +from .collector import DataCollector +from .news_collector import NewsCollector, NewsItem, NewsSource, get_news_collector +from .indicators import TechnicalIndicators, get_indicators +from .storage import DataStorage +from .quality import DataQualityManager + +__all__ = [ + "DataCollector", + "NewsCollector", + "NewsItem", + "NewsSource", + "get_news_collector", + "TechnicalIndicators", + "get_indicators", + "DataStorage", + "DataQualityManager", +] diff --git a/src/data/cache_manager.py b/src/data/cache_manager.py new file mode 100644 index 00000000..625eb3a0 --- /dev/null +++ b/src/data/cache_manager.py @@ -0,0 +1,221 @@ +"""Intelligent caching system for pricing data.""" + +import time +from typing import Dict, Any, Optional, Tuple +from datetime import datetime, timedelta +from collections import OrderedDict +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class CacheEntry: + """Cache entry with TTL support.""" + + def __init__(self, data: Any, ttl: float): + """Initialize cache entry. + + Args: + data: Cached data + ttl: Time to live in seconds + """ + self.data = data + self.expires_at = time.time() + ttl + self.created_at = time.time() + self.access_count = 0 + self.last_accessed = time.time() + + def is_expired(self) -> bool: + """Check if entry is expired. + + Returns: + True if expired + """ + return time.time() > self.expires_at + + def touch(self): + """Update access statistics.""" + self.access_count += 1 + self.last_accessed = time.time() + + def age(self) -> float: + """Get age of entry in seconds. + + Returns: + Age in seconds + """ + return time.time() - self.created_at + + +class CacheManager: + """Intelligent cache manager with TTL and size limits. + + Implements LRU (Least Recently Used) eviction when size limit is reached. + """ + + def __init__( + self, + default_ttl: float = 60.0, + max_size: int = 1000, + ticker_ttl: float = 2.0, + ohlcv_ttl: float = 60.0 + ): + """Initialize cache manager. + + Args: + default_ttl: Default TTL in seconds + max_size: Maximum number of cache entries + ticker_ttl: TTL for ticker data in seconds + ohlcv_ttl: TTL for OHLCV data in seconds + """ + self.default_ttl = default_ttl + self.max_size = max_size + self.ticker_ttl = ticker_ttl + self.ohlcv_ttl = ohlcv_ttl + + # Use OrderedDict for LRU eviction + self._cache: OrderedDict[str, CacheEntry] = OrderedDict() + self._hits = 0 + self._misses = 0 + self._evictions = 0 + + self.logger = get_logger(__name__) + + def get(self, key: str) -> Optional[Any]: + """Get value from cache. + + Args: + key: Cache key + + Returns: + Cached value or None if not found/expired + """ + # Clean expired entries + self._cleanup_expired() + + if key not in self._cache: + self._misses += 1 + return None + + entry = self._cache[key] + + if entry.is_expired(): + # Remove expired entry + del self._cache[key] + self._misses += 1 + return None + + # Update access (move to end for LRU) + entry.touch() + self._cache.move_to_end(key) + self._hits += 1 + return entry.data + + def set( + self, + key: str, + value: Any, + ttl: Optional[float] = None, + cache_type: Optional[str] = None + ): + """Set value in cache. + + Args: + key: Cache key + value: Value to cache + ttl: Time to live in seconds (uses type-specific or default if None) + cache_type: Type of cache ('ticker' or 'ohlcv') for type-specific TTL + """ + # Determine TTL + if ttl is None: + if cache_type == 'ticker': + ttl = self.ticker_ttl + elif cache_type == 'ohlcv': + ttl = self.ohlcv_ttl + else: + ttl = self.default_ttl + + # Check if we need to evict + if len(self._cache) >= self.max_size and key not in self._cache: + self._evict_lru() + + # Create entry + entry = CacheEntry(value, ttl) + + # Add or update + if key in self._cache: + self._cache.move_to_end(key) + self._cache[key] = entry + + def delete(self, key: str) -> bool: + """Delete entry from cache. + + Args: + key: Cache key + + Returns: + True if entry was deleted, False if not found + """ + if key in self._cache: + del self._cache[key] + return True + return False + + def clear(self): + """Clear all cache entries.""" + self._cache.clear() + self.logger.info("Cache cleared") + + def _cleanup_expired(self): + """Remove expired entries from cache.""" + expired_keys = [ + key for key, entry in self._cache.items() + if entry.is_expired() + ] + for key in expired_keys: + del self._cache[key] + + def _evict_lru(self): + """Evict least recently used entry.""" + if self._cache: + # Remove oldest (first) entry + self._cache.popitem(last=False) + self._evictions += 1 + + def get_stats(self) -> Dict[str, Any]: + """Get cache statistics. + + Returns: + Dictionary with cache statistics + """ + total_requests = self._hits + self._misses + hit_rate = (self._hits / total_requests * 100) if total_requests > 0 else 0.0 + + # Calculate average age + if self._cache: + avg_age = sum(entry.age() for entry in self._cache.values()) / len(self._cache) + else: + avg_age = 0.0 + + return { + 'size': len(self._cache), + 'max_size': self.max_size, + 'hits': self._hits, + 'misses': self._misses, + 'hit_rate': round(hit_rate, 2), + 'evictions': self._evictions, + 'avg_age_seconds': round(avg_age, 2), + } + + def invalidate_pattern(self, pattern: str): + """Invalidate entries matching a pattern. + + Args: + pattern: String pattern to match (simple substring match) + """ + keys_to_delete = [key for key in self._cache.keys() if pattern in key] + for key in keys_to_delete: + del self._cache[key] + + if keys_to_delete: + self.logger.info(f"Invalidated {len(keys_to_delete)} cache entries matching '{pattern}'") diff --git a/src/data/collector.py b/src/data/collector.py new file mode 100644 index 00000000..4f3d9971 --- /dev/null +++ b/src/data/collector.py @@ -0,0 +1,139 @@ +"""Real-time data collection system with WebSocket support.""" + +import asyncio +from decimal import Decimal +from typing import Dict, Optional, Callable, List +from datetime import datetime +from sqlalchemy import select +from src.core.database import get_database, MarketData +from src.core.logger import get_logger +from .pricing_service import get_pricing_service + +logger = get_logger(__name__) + + +class DataCollector: + """Collects real-time market data using the unified pricing service.""" + + def __init__(self): + """Initialize data collector.""" + self.db = get_database() + self.logger = get_logger(__name__) + self._callbacks: Dict[str, List[Callable]] = {} + self._running = False + self._pricing_service = get_pricing_service() + + def subscribe( + self, + exchange_id: Optional[int] = None, + symbol: str = "", + callback: Optional[Callable] = None + ): + """Subscribe to real-time data. + + Args: + exchange_id: Exchange ID (deprecated, kept for backward compatibility) + symbol: Trading symbol + callback: Callback function(data) + """ + if not symbol or not callback: + logger.warning("subscribe called without symbol or callback") + return + + key = f"pricing:{symbol}" + if key not in self._callbacks: + self._callbacks[key] = [] + self._callbacks[key].append(callback) + + # Subscribe via pricing service + def wrapped_callback(data): + """Wrap callback to maintain backward compatibility.""" + for cb in self._callbacks.get(key, []): + try: + cb(data) + except Exception as e: + logger.error(f"Callback error for {symbol}: {e}") + + self._pricing_service.subscribe_ticker(symbol, wrapped_callback) + + async def store_ohlcv( + self, + exchange: str, + symbol: str, + timeframe: str, + ohlcv_data: List[List] + ): + """Store OHLCV data in database. + + Args: + exchange: Exchange name (can be provider name like 'CCXT-Kraken' or 'CoinGecko') + symbol: Trading symbol + timeframe: Timeframe + ohlcv_data: List of [timestamp, open, high, low, close, volume] + """ + async with self.db.get_session() as session: + try: + for candle in ohlcv_data: + timestamp = datetime.fromtimestamp(candle[0] / 1000) + + # Check if already exists + stmt = select(MarketData).filter_by( + exchange=exchange, + symbol=symbol, + timeframe=timeframe, + timestamp=timestamp + ) + result = await session.execute(stmt) + existing = result.scalar_one_or_none() + + if not existing: + market_data = MarketData( + exchange=exchange, + symbol=symbol, + timeframe=timeframe, + timestamp=timestamp, + open=Decimal(str(candle[1])), + high=Decimal(str(candle[2])), + low=Decimal(str(candle[3])), + close=Decimal(str(candle[4])), + volume=Decimal(str(candle[5])) + ) + session.add(market_data) + + await session.commit() + except Exception as e: + await session.rollback() + logger.error(f"Failed to store OHLCV data: {e}") + + def get_ohlcv_from_pricing_service( + self, + symbol: str, + timeframe: str = '1h', + since: Optional[datetime] = None, + limit: int = 100 + ) -> List[List]: + """Get OHLCV data from pricing service. + + Args: + symbol: Trading symbol + timeframe: Timeframe + since: Start datetime + limit: Number of candles + + Returns: + List of OHLCV candles + """ + return self._pricing_service.get_ohlcv(symbol, timeframe, since, limit) + + +# Global data collector +_data_collector: Optional[DataCollector] = None + + +def get_data_collector() -> DataCollector: + """Get global data collector instance.""" + global _data_collector + if _data_collector is None: + _data_collector = DataCollector() + return _data_collector + diff --git a/src/data/health_monitor.py b/src/data/health_monitor.py new file mode 100644 index 00000000..71ba3324 --- /dev/null +++ b/src/data/health_monitor.py @@ -0,0 +1,317 @@ +"""Health monitoring and failover management for pricing providers.""" + +import time +from typing import Dict, List, Optional, Any +from enum import Enum +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from collections import deque +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class HealthStatus(Enum): + """Provider health status.""" + HEALTHY = "healthy" + DEGRADED = "degraded" + UNHEALTHY = "unhealthy" + UNKNOWN = "unknown" + + +@dataclass +class HealthMetrics: + """Health metrics for a provider.""" + status: HealthStatus = HealthStatus.UNKNOWN + last_check: Optional[datetime] = None + last_success: Optional[datetime] = None + last_failure: Optional[datetime] = None + success_count: int = 0 + failure_count: int = 0 + response_times: deque = field(default_factory=lambda: deque(maxlen=100)) + consecutive_failures: int = 0 + circuit_breaker_open: bool = False + circuit_breaker_opened_at: Optional[datetime] = None + + def record_success(self, response_time: float): + """Record a successful operation. + + Args: + response_time: Response time in seconds + """ + self.status = HealthStatus.HEALTHY + self.last_check = datetime.utcnow() + self.last_success = datetime.utcnow() + self.success_count += 1 + self.response_times.append(response_time) + self.consecutive_failures = 0 + self.circuit_breaker_open = False + self.circuit_breaker_opened_at = None + + def record_failure(self): + """Record a failed operation.""" + self.last_check = datetime.utcnow() + self.last_failure = datetime.utcnow() + self.failure_count += 1 + self.consecutive_failures += 1 + + # Open circuit breaker after 5 consecutive failures + if self.consecutive_failures >= 5: + if not self.circuit_breaker_open: + self.circuit_breaker_open = True + self.circuit_breaker_opened_at = datetime.utcnow() + logger.warning(f"Circuit breaker opened after {self.consecutive_failures} failures") + + # Update status based on failure rate + total = self.success_count + self.failure_count + if total > 0: + failure_rate = self.failure_count / total + if failure_rate > 0.5: + self.status = HealthStatus.UNHEALTHY + elif failure_rate > 0.2: + self.status = HealthStatus.DEGRADED + + def get_avg_response_time(self) -> float: + """Get average response time. + + Returns: + Average response time in seconds, or 0.0 if no data + """ + if not self.response_times: + return 0.0 + return sum(self.response_times) / len(self.response_times) + + def should_attempt(self, circuit_breaker_timeout: int = 60) -> bool: + """Check if we should attempt to use this provider. + + Args: + circuit_breaker_timeout: Seconds to wait before retrying after circuit breaker opens + + Returns: + True if we should attempt, False otherwise + """ + if not self.circuit_breaker_open: + return True + + # Check if timeout has passed + if self.circuit_breaker_opened_at: + elapsed = (datetime.utcnow() - self.circuit_breaker_opened_at).total_seconds() + if elapsed >= circuit_breaker_timeout: + # Half-open state: allow one attempt + logger.info("Circuit breaker half-open, allowing attempt") + return True + + return False + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for API responses.""" + return { + 'status': self.status.value, + 'last_check': self.last_check.isoformat() if self.last_check else None, + 'last_success': self.last_success.isoformat() if self.last_success else None, + 'last_failure': self.last_failure.isoformat() if self.last_failure else None, + 'success_count': self.success_count, + 'failure_count': self.failure_count, + 'avg_response_time': round(self.get_avg_response_time(), 3), + 'consecutive_failures': self.consecutive_failures, + 'circuit_breaker_open': self.circuit_breaker_open, + 'circuit_breaker_opened_at': ( + self.circuit_breaker_opened_at.isoformat() + if self.circuit_breaker_opened_at else None + ), + } + + +class HealthMonitor: + """Monitors health of pricing providers and manages failover.""" + + def __init__( + self, + circuit_breaker_timeout: int = 60, + min_success_rate: float = 0.8, + max_avg_response_time: float = 5.0 + ): + """Initialize health monitor. + + Args: + circuit_breaker_timeout: Seconds to wait before retrying after circuit breaker opens + min_success_rate: Minimum success rate to be considered healthy (0.0-1.0) + max_avg_response_time: Maximum average response time in seconds to be considered healthy + """ + self.circuit_breaker_timeout = circuit_breaker_timeout + self.min_success_rate = min_success_rate + self.max_avg_response_time = max_avg_response_time + + self._metrics: Dict[str, HealthMetrics] = {} + self.logger = get_logger(__name__) + + def record_success(self, provider_name: str, response_time: float): + """Record a successful operation for a provider. + + Args: + provider_name: Name of the provider + response_time: Response time in seconds + """ + if provider_name not in self._metrics: + self._metrics[provider_name] = HealthMetrics() + + self._metrics[provider_name].record_success(response_time) + self.logger.debug(f"Recorded success for {provider_name} ({response_time:.3f}s)") + + def record_failure(self, provider_name: str): + """Record a failed operation for a provider. + + Args: + provider_name: Name of the provider + """ + if provider_name not in self._metrics: + self._metrics[provider_name] = HealthMetrics() + + self._metrics[provider_name].record_failure() + self.logger.warning(f"Recorded failure for {provider_name} " + f"(consecutive: {self._metrics[provider_name].consecutive_failures})") + + def is_healthy(self, provider_name: str) -> bool: + """Check if a provider is healthy. + + Args: + provider_name: Name of the provider + + Returns: + True if provider is healthy + """ + if provider_name not in self._metrics: + return True # Assume healthy if no metrics yet + + metrics = self._metrics[provider_name] + + # Check circuit breaker + if not metrics.should_attempt(self.circuit_breaker_timeout): + return False + + # Check status + if metrics.status == HealthStatus.UNHEALTHY: + return False + + # Check success rate + total = metrics.success_count + metrics.failure_count + if total > 10: # Need minimum data points + success_rate = metrics.success_count / total + if success_rate < self.min_success_rate: + return False + + # Check response time + if metrics.response_times: + avg_response_time = metrics.get_avg_response_time() + if avg_response_time > self.max_avg_response_time: + return False + + return True + + def get_health_status(self, provider_name: str) -> HealthStatus: + """Get health status for a provider. + + Args: + provider_name: Name of the provider + + Returns: + Health status + """ + if provider_name not in self._metrics: + return HealthStatus.UNKNOWN + + return self._metrics[provider_name].status + + def get_metrics(self, provider_name: str) -> Optional[HealthMetrics]: + """Get health metrics for a provider. + + Args: + provider_name: Name of the provider + + Returns: + Health metrics or None if not found + """ + return self._metrics.get(provider_name) + + def get_all_metrics(self) -> Dict[str, Dict[str, Any]]: + """Get all provider metrics. + + Returns: + Dictionary mapping provider names to their metrics + """ + return { + name: metrics.to_dict() + for name, metrics in self._metrics.items() + } + + def select_healthiest(self, provider_names: List[str]) -> Optional[str]: + """Select the healthiest provider from a list. + + Args: + provider_names: List of provider names to choose from + + Returns: + Name of healthiest provider, or None if none are healthy + """ + healthy_providers = [ + name for name in provider_names + if self.is_healthy(name) + ] + + if not healthy_providers: + return None + + # Sort by health metrics (better providers first) + def health_score(name: str) -> tuple: + metrics = self._metrics.get(name) + if not metrics: + return (1, 0, 0) # Unknown providers get lowest priority + + # Score: (status_weight, -avg_response_time, success_rate) + status_weights = { + HealthStatus.HEALTHY: 3, + HealthStatus.DEGRADED: 2, + HealthStatus.UNHEALTHY: 1, + HealthStatus.UNKNOWN: 0, + } + + success_rate = ( + metrics.success_count / (metrics.success_count + metrics.failure_count) + if (metrics.success_count + metrics.failure_count) > 0 + else 0.0 + ) + + return ( + status_weights.get(metrics.status, 0), + -metrics.get_avg_response_time(), + success_rate + ) + + sorted_providers = sorted(healthy_providers, key=health_score, reverse=True) + return sorted_providers[0] if sorted_providers else None + + def reset_circuit_breaker(self, provider_name: str): + """Manually reset circuit breaker for a provider. + + Args: + provider_name: Name of the provider + """ + if provider_name in self._metrics: + self._metrics[provider_name].circuit_breaker_open = False + self._metrics[provider_name].circuit_breaker_opened_at = None + self._metrics[provider_name].consecutive_failures = 0 + self.logger.info(f"Circuit breaker reset for {provider_name}") + + def reset_metrics(self, provider_name: Optional[str] = None): + """Reset metrics for a provider or all providers. + + Args: + provider_name: Name of provider to reset, or None to reset all + """ + if provider_name: + if provider_name in self._metrics: + del self._metrics[provider_name] + self.logger.info(f"Reset metrics for {provider_name}") + else: + self._metrics.clear() + self.logger.info("Reset all provider metrics") diff --git a/src/data/indicators.py b/src/data/indicators.py new file mode 100644 index 00000000..d0dc7424 --- /dev/null +++ b/src/data/indicators.py @@ -0,0 +1,569 @@ +"""Comprehensive technical indicator library using pandas-ta and TA-Lib.""" + +import pandas as pd +import numpy as np +from typing import Optional, Dict, Any, List + +# Try to import pandas_ta, but handle if numba is missing +try: + import pandas_ta as ta + PANDAS_TA_AVAILABLE = True +except ImportError: + PANDAS_TA_AVAILABLE = False + ta = None + import warnings + warnings.warn("pandas-ta not available (numba issue), using basic implementations") + +try: + import talib + TALIB_AVAILABLE = True +except ImportError: + TALIB_AVAILABLE = False + +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class TechnicalIndicators: + """Technical indicators library.""" + + def __init__(self): + """Initialize indicators library.""" + self.talib_available = TALIB_AVAILABLE + + # Trend Indicators + + def sma(self, data: pd.Series, period: int = 20) -> pd.Series: + """Simple Moving Average.""" + if PANDAS_TA_AVAILABLE: + return ta.sma(data, length=period) + return data.rolling(window=period).mean() + + def ema(self, data: pd.Series, period: int = 20) -> pd.Series: + """Exponential Moving Average.""" + if PANDAS_TA_AVAILABLE: + return ta.ema(data, length=period) + return data.ewm(span=period, adjust=False).mean() + + def wma(self, data: pd.Series, period: int = 20) -> pd.Series: + """Weighted Moving Average.""" + if PANDAS_TA_AVAILABLE: + return ta.wma(data, length=period) + # Basic WMA implementation + weights = np.arange(1, period + 1) + return data.rolling(window=period).apply(lambda x: np.dot(x, weights) / weights.sum(), raw=True) + + def dema(self, data: pd.Series, period: int = 20) -> pd.Series: + """Double Exponential Moving Average.""" + if PANDAS_TA_AVAILABLE: + return ta.dema(data, length=period) + ema1 = self.ema(data, period) + return 2 * ema1 - self.ema(ema1, period) + + def tema(self, data: pd.Series, period: int = 20) -> pd.Series: + """Triple Exponential Moving Average.""" + if PANDAS_TA_AVAILABLE: + return ta.tema(data, length=period) + ema1 = self.ema(data, period) + ema2 = self.ema(ema1, period) + ema3 = self.ema(ema2, period) + return 3 * ema1 - 3 * ema2 + ema3 + + # Momentum Indicators + + def rsi(self, data: pd.Series, period: int = 14) -> pd.Series: + """Relative Strength Index.""" + if self.talib_available: + return pd.Series(talib.RSI(data.values, timeperiod=period), index=data.index) + if PANDAS_TA_AVAILABLE: + return ta.rsi(data, length=period) + # Basic RSI implementation + delta = data.diff() + gain = (delta.where(delta > 0, 0)).rolling(window=period).mean() + loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean() + rs = gain / loss + return 100 - (100 / (1 + rs)) + + def macd( + self, + data: pd.Series, + fast: int = 12, + slow: int = 26, + signal: int = 9 + ) -> Dict[str, pd.Series]: + """MACD (Moving Average Convergence Divergence).""" + if self.talib_available: + macd, signal_line, histogram = talib.MACD( + data.values, fastperiod=fast, slowperiod=slow, signalperiod=signal + ) + return { + 'macd': pd.Series(macd, index=data.index), + 'signal': pd.Series(signal_line, index=data.index), + 'histogram': pd.Series(histogram, index=data.index), + } + if not PANDAS_TA_AVAILABLE or ta is None: + # Basic MACD implementation fallback + ema_fast = self.ema(data, fast) + ema_slow = self.ema(data, slow) + macd_line = ema_fast - ema_slow + signal_line = self.ema(macd_line.dropna(), signal) + histogram = macd_line - signal_line + return { + 'macd': macd_line, + 'signal': signal_line, + 'histogram': histogram, + } + result = ta.macd(data, fast=fast, slow=slow, signal=signal) + return { + 'macd': result[f'MACD_{fast}_{slow}_{signal}'], + 'signal': result[f'MACDs_{fast}_{slow}_{signal}'], + 'histogram': result[f'MACDh_{fast}_{slow}_{signal}'], + } + + def stochastic( + self, + high: pd.Series, + low: pd.Series, + close: pd.Series, + k_period: int = 14, + d_period: int = 3 + ) -> Dict[str, pd.Series]: + """Stochastic Oscillator.""" + if self.talib_available: + slowk, slowd = talib.STOCH( + high.values, low.values, close.values, + fastk_period=k_period, slowk_period=d_period, slowd_period=d_period + ) + return { + 'k': pd.Series(slowk, index=close.index), + 'd': pd.Series(slowd, index=close.index), + } + if PANDAS_TA_AVAILABLE and ta is not None: + result = ta.stoch(high, low, close, k=k_period, d=d_period) + return { + 'k': result[f'STOCHk_{k_period}_{d_period}_{d_period}'], + 'd': result[f'STOCHd_{k_period}_{d_period}_{d_period}'], + } + # Basic Stochastic implementation + lowest_low = low.rolling(window=k_period).min() + highest_high = high.rolling(window=k_period).max() + k = 100 * ((close - lowest_low) / (highest_high - lowest_low)) + d = k.rolling(window=d_period).mean() + return {'k': k, 'd': d} + + def cci( + self, + high: pd.Series, + low: pd.Series, + close: pd.Series, + period: int = 20 + ) -> pd.Series: + """Commodity Channel Index.""" + if self.talib_available: + return pd.Series( + talib.CCI(high.values, low.values, close.values, timeperiod=period), + index=close.index + ) + if PANDAS_TA_AVAILABLE: + return ta.cci(high, low, close, length=period) + # Basic CCI implementation + tp = (high + low + close) / 3 + sma_tp = tp.rolling(window=period).mean() + mad = tp.rolling(window=period).apply(lambda x: np.abs(x - x.mean()).mean(), raw=True) + return (tp - sma_tp) / (0.015 * mad) + + def williams_r( + self, + high: pd.Series, + low: pd.Series, + close: pd.Series, + period: int = 14 + ) -> pd.Series: + """Williams %R.""" + if self.talib_available: + return pd.Series( + talib.WILLR(high.values, low.values, close.values, timeperiod=period), + index=close.index + ) + if PANDAS_TA_AVAILABLE: + return ta.willr(high, low, close, length=period) + # Basic Williams %R implementation + highest_high = high.rolling(window=period).max() + lowest_low = low.rolling(window=period).min() + return -100 * ((highest_high - close) / (highest_high - lowest_low)) + + # Volatility Indicators + + def bollinger_bands( + self, + data: pd.Series, + period: int = 20, + std_dev: float = 2.0 + ) -> Dict[str, pd.Series]: + """Bollinger Bands.""" + if self.talib_available: + upper, middle, lower = talib.BBANDS( + data.values, timeperiod=period, nbdevup=std_dev, nbdevdn=std_dev + ) + return { + 'upper': pd.Series(upper, index=data.index), + 'middle': pd.Series(middle, index=data.index), + 'lower': pd.Series(lower, index=data.index), + } + if PANDAS_TA_AVAILABLE: + result = ta.bbands(data, length=period, std=std_dev) + return { + 'upper': result[f'BBU_{period}_{std_dev}'], + 'middle': result[f'BBM_{period}_{std_dev}'], + 'lower': result[f'BBL_{period}_{std_dev}'], + } + # Basic Bollinger Bands implementation + middle = self.sma(data, period) + std = data.rolling(window=period).std() + return { + 'upper': middle + (std * std_dev), + 'middle': middle, + 'lower': middle - (std * std_dev), + } + + def atr( + self, + high: pd.Series, + low: pd.Series, + close: pd.Series, + period: int = 14 + ) -> pd.Series: + """Average True Range.""" + if self.talib_available: + return pd.Series( + talib.ATR(high.values, low.values, close.values, timeperiod=period), + index=close.index + ) + if PANDAS_TA_AVAILABLE and ta is not None: + return ta.atr(high, low, close, length=period) + # Basic ATR implementation + prev_close = close.shift(1) + tr1 = high - low + tr2 = abs(high - prev_close) + tr3 = abs(low - prev_close) + tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1) + return tr.rolling(window=period).mean() + + def keltner_channels( + self, + high: pd.Series, + low: pd.Series, + close: pd.Series, + period: int = 20, + multiplier: float = 2.0 + ) -> Dict[str, pd.Series]: + """Keltner Channels.""" + if PANDAS_TA_AVAILABLE and ta is not None: + return ta.kc(high, low, close, length=period, scalar=multiplier) + # Basic Keltner Channels implementation + middle = self.ema(close, period) + atr_val = self.atr(high, low, close, period) + return { + 'lower': middle - (multiplier * atr_val), + 'middle': middle, + 'upper': middle + (multiplier * atr_val), + } + + # Volume Indicators + + def obv(self, close: pd.Series, volume: pd.Series) -> pd.Series: + """On-Balance Volume.""" + if self.talib_available: + return pd.Series( + talib.OBV(close.values, volume.values), + index=close.index + ) + if PANDAS_TA_AVAILABLE and ta is not None: + return ta.obv(close, volume) + # Basic OBV implementation + price_change = close.diff() + obv = pd.Series(index=close.index, dtype=float) + obv.iloc[0] = 0 + for i in range(1, len(close)): + if price_change.iloc[i] > 0: + obv.iloc[i] = obv.iloc[i-1] + volume.iloc[i] + elif price_change.iloc[i] < 0: + obv.iloc[i] = obv.iloc[i-1] - volume.iloc[i] + else: + obv.iloc[i] = obv.iloc[i-1] + return obv + + def vwap( + self, + high: pd.Series, + low: pd.Series, + close: pd.Series, + volume: pd.Series + ) -> pd.Series: + """Volume Weighted Average Price.""" + if PANDAS_TA_AVAILABLE and ta is not None: + return ta.vwap(high, low, close, volume) + # Basic VWAP implementation + typical_price = (high + low + close) / 3 + cumulative_tp_vol = (typical_price * volume).cumsum() + cumulative_vol = volume.cumsum() + return cumulative_tp_vol / cumulative_vol + + # Advanced Indicators + + def ichimoku( + self, + high: pd.Series, + low: pd.Series, + close: pd.Series, + tenkan: int = 9, + kijun: int = 26, + senkou: int = 52 + ) -> Dict[str, pd.Series]: + """Ichimoku Cloud.""" + if PANDAS_TA_AVAILABLE and ta is not None: + result = ta.ichimoku(high, low, close, tenkan=tenkan, kijun=kijun, senkou=senkou) + return { + 'tenkan': result['ITS_9'], + 'kijun': result['IKS_26'], + 'senkou_a': result['ISA_9'], + 'senkou_b': result['ISB_26'], + 'chikou': result['ICS_26'], + } + # Basic Ichimoku implementation + tenkan_sen = (high.rolling(window=tenkan).max() + low.rolling(window=tenkan).min()) / 2 + kijun_sen = (high.rolling(window=kijun).max() + low.rolling(window=kijun).min()) / 2 + senkou_a = ((tenkan_sen + kijun_sen) / 2).shift(kijun) + senkou_b = ((high.rolling(window=senkou).max() + low.rolling(window=senkou).min()) / 2).shift(kijun) + chikou = close.shift(-kijun) + return { + 'tenkan': tenkan_sen, + 'kijun': kijun_sen, + 'senkou_a': senkou_a, + 'senkou_b': senkou_b, + 'chikou': chikou, + } + + def adx( + self, + high: pd.Series, + low: pd.Series, + close: pd.Series, + period: int = 14 + ) -> pd.Series: + """Average Directional Index.""" + if self.talib_available: + return pd.Series( + talib.ADX(high.values, low.values, close.values, timeperiod=period), + index=close.index + ) + if PANDAS_TA_AVAILABLE and ta is not None: + return ta.adx(high, low, close, length=period) + # Basic ADX implementation + plus_dm = high.diff() + minus_dm = low.diff().abs() + plus_dm[plus_dm < 0] = 0 + minus_dm[minus_dm < 0] = 0 + + atr_val = self.atr(high, low, close, period) + plus_di = 100 * (plus_dm.rolling(window=period).mean() / atr_val) + minus_di = 100 * (minus_dm.rolling(window=period).mean() / atr_val) + + dx = 100 * abs(plus_di - minus_di) / (plus_di + minus_di) + adx = dx.rolling(window=period).mean() + return adx + + def detect_divergence( + self, + prices: pd.Series, + indicator: pd.Series, + lookback: int = 20, + min_swings: int = 2 + ) -> Dict[str, Any]: + """Detect divergence between price and indicator. + + Divergence occurs when price makes new highs/lows but indicator doesn't, + or vice versa. This is a powerful reversal signal. + + Args: + prices: Price series + indicator: Indicator series (e.g., RSI, MACD) + lookback: Lookback period for finding swings + min_swings: Minimum number of swings to detect divergence + + Returns: + Dictionary with divergence information: + { + 'type': 'bullish', 'bearish', or None + 'confidence': 0.0 to 1.0 + 'price_swing_high': price at high swing + 'price_swing_low': price at low swing + 'indicator_swing_high': indicator at high swing + 'indicator_swing_low': indicator at low swing + } + """ + if len(prices) < lookback * 2 or len(indicator) < lookback * 2: + return { + 'type': None, + 'confidence': 0.0, + 'price_swing_high': None, + 'price_swing_low': None, + 'indicator_swing_high': None, + 'indicator_swing_low': None + } + + # Find local extrema (swings) + def find_swings(series: pd.Series, lookback: int): + """Find local maxima and minima.""" + highs = [] + lows = [] + + for i in range(lookback, len(series) - lookback): + window = series.iloc[i-lookback:i+lookback+1] + center = series.iloc[i] + + # Local maximum + if center == window.max(): + highs.append((i, center)) + # Local minimum + elif center == window.min(): + lows.append((i, center)) + + return highs, lows + + price_highs, price_lows = find_swings(prices, lookback) + indicator_highs, indicator_lows = find_swings(indicator, lookback) + + # Need at least min_swings swings to detect divergence + if len(price_highs) < min_swings or len(price_lows) < min_swings: + return { + 'type': None, + 'confidence': 0.0, + 'price_swing_high': None, + 'price_swing_low': None, + 'indicator_swing_high': None, + 'indicator_swing_low': None + } + + # Check for bearish divergence (price makes higher high, indicator makes lower high) + if len(price_highs) >= 2 and len(indicator_highs) >= 2: + recent_price_high = price_highs[-1][1] + prev_price_high = price_highs[-2][1] + recent_indicator_high = indicator_highs[-1][1] + prev_indicator_high = indicator_highs[-2][1] + + # Price higher high but indicator lower high = bearish divergence + if recent_price_high > prev_price_high and recent_indicator_high < prev_indicator_high: + confidence = min(1.0, abs(recent_price_high - prev_price_high) / prev_price_high * 10) + return { + 'type': 'bearish', + 'confidence': confidence, + 'price_swing_high': (price_highs[-2][0], price_highs[-1][0]), + 'price_swing_low': None, + 'indicator_swing_high': (indicator_highs[-2][0], indicator_highs[-1][0]), + 'indicator_swing_low': None + } + + # Check for bullish divergence (price makes lower low, indicator makes higher low) + if len(price_lows) >= 2 and len(indicator_lows) >= 2: + recent_price_low = price_lows[-1][1] + prev_price_low = price_lows[-2][1] + recent_indicator_low = indicator_lows[-1][1] + prev_indicator_low = indicator_lows[-2][1] + + # Price lower low but indicator higher low = bullish divergence + if recent_price_low < prev_price_low and recent_indicator_low > prev_indicator_low: + confidence = min(1.0, abs(prev_price_low - recent_price_low) / prev_price_low * 10) + return { + 'type': 'bullish', + 'confidence': confidence, + 'price_swing_high': None, + 'price_swing_low': (price_lows[-2][0], price_lows[-1][0]), + 'indicator_swing_high': None, + 'indicator_swing_low': (indicator_lows[-2][0], indicator_lows[-1][0]) + } + + return { + 'type': None, + 'confidence': 0.0, + 'price_swing_high': None, + 'price_swing_low': None, + 'indicator_swing_high': None, + 'indicator_swing_low': None + } + + def calculate_all( + self, + df: pd.DataFrame, + indicators: Optional[List[str]] = None + ) -> pd.DataFrame: + """Calculate multiple indicators at once. + + Args: + df: DataFrame with OHLCV data (columns: open, high, low, close, volume) + indicators: List of indicator names to calculate (None = all) + + Returns: + DataFrame with added indicator columns + """ + result = df.copy() + + if 'close' not in result.columns: + raise ValueError("DataFrame must have 'close' column") + + close = result['close'] + high = result.get('high', close) + low = result.get('low', close) + volume = result.get('volume', pd.Series(1, index=close.index)) + + # Default indicators if none specified + if indicators is None: + indicators = [ + 'sma_20', 'ema_20', 'rsi', 'macd', 'bollinger_bands', + 'atr', 'obv', 'adx' + ] + + for indicator in indicators: + try: + if indicator.startswith('sma_'): + period = int(indicator.split('_')[1]) + result[f'SMA_{period}'] = self.sma(close, period) + elif indicator.startswith('ema_'): + period = int(indicator.split('_')[1]) + result[f'EMA_{period}'] = self.ema(close, period) + elif indicator == 'rsi': + result['RSI'] = self.rsi(close) + elif indicator == 'macd': + macd_data = self.macd(close) + result['MACD'] = macd_data['macd'] + result['MACD_Signal'] = macd_data['signal'] + result['MACD_Histogram'] = macd_data['histogram'] + elif indicator == 'bollinger_bands': + bb_data = self.bollinger_bands(close) + result['BB_Upper'] = bb_data['upper'] + result['BB_Middle'] = bb_data['middle'] + result['BB_Lower'] = bb_data['lower'] + elif indicator == 'atr': + result['ATR'] = self.atr(high, low, close) + elif indicator == 'obv': + result['OBV'] = self.obv(close, volume) + elif indicator == 'adx': + result['ADX'] = self.adx(high, low, close) + except Exception as e: + logger.warning(f"Failed to calculate indicator {indicator}: {e}") + + return result + + +# Global indicators instance +_indicators: Optional[TechnicalIndicators] = None + + +def get_indicators() -> TechnicalIndicators: + """Get global technical indicators instance.""" + global _indicators + if _indicators is None: + _indicators = TechnicalIndicators() + return _indicators + diff --git a/src/data/news_collector.py b/src/data/news_collector.py new file mode 100644 index 00000000..b47f03e6 --- /dev/null +++ b/src/data/news_collector.py @@ -0,0 +1,447 @@ +"""News collector for crypto sentiment analysis. + +Collects headlines from multiple sources: +- RSS feeds (CoinDesk, CoinTelegraph, Decrypt, etc.) +- CryptoPanic API (optional, requires API key) + +Headlines are cached and refreshed periodically to avoid rate limits. +""" + +import asyncio +import re +from datetime import datetime, timedelta +from typing import Dict, List, Optional, Any +from dataclasses import dataclass, field +from enum import Enum + +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class NewsSource(str, Enum): + """Supported news sources.""" + COINDESK = "coindesk" + COINTELEGRAPH = "cointelegraph" + DECRYPT = "decrypt" + BITCOIN_MAGAZINE = "bitcoin_magazine" + THE_BLOCK = "the_block" + MESSARI = "messari" + CRYPTOPANIC = "cryptopanic" + + +@dataclass +class NewsItem: + """A single news item.""" + title: str + source: NewsSource + published: datetime + url: Optional[str] = None + summary: Optional[str] = None + symbols: List[str] = field(default_factory=list) + + +# RSS feed URLs for major crypto news sources +RSS_FEEDS: Dict[NewsSource, str] = { + NewsSource.COINDESK: "https://www.coindesk.com/arc/outboundfeeds/rss/", + NewsSource.COINTELEGRAPH: "https://cointelegraph.com/rss", + NewsSource.DECRYPT: "https://decrypt.co/feed", + NewsSource.BITCOIN_MAGAZINE: "https://bitcoinmagazine.com/.rss/full/", + NewsSource.THE_BLOCK: "https://www.theblock.co/rss.xml", + NewsSource.MESSARI: "https://messari.io/rss", +} + +# Common crypto symbols to detect in headlines +CRYPTO_SYMBOLS = { + "BTC": ["bitcoin", "btc"], + "ETH": ["ethereum", "eth", "ether"], + "SOL": ["solana", "sol"], + "XRP": ["ripple", "xrp"], + "ADA": ["cardano", "ada"], + "DOGE": ["dogecoin", "doge"], + "DOT": ["polkadot", "dot"], + "AVAX": ["avalanche", "avax"], + "MATIC": ["polygon", "matic"], + "LINK": ["chainlink", "link"], + "UNI": ["uniswap", "uni"], + "ATOM": ["cosmos", "atom"], + "LTC": ["litecoin", "ltc"], +} + + +class NewsCollector: + """Collects crypto news headlines for sentiment analysis. + + Features: + - Aggregates news from multiple RSS feeds + - Caches headlines to reduce network requests + - Filters by crypto symbol + - Optional CryptoPanic integration for more sources + + Usage: + collector = NewsCollector() + headlines = await collector.fetch_headlines() + # Or filter by symbol + btc_headlines = await collector.fetch_headlines(symbols=["BTC"]) + """ + + # Minimum time between fetches (in seconds) + MIN_FETCH_INTERVAL = 300 # 5 minutes + + def __init__( + self, + sources: Optional[List[NewsSource]] = None, + cryptopanic_api_key: Optional[str] = None, + cache_duration: int = 600, # 10 minutes + max_headlines: int = 50 + ): + """Initialize NewsCollector. + + Args: + sources: List of news sources to use. Defaults to all RSS feeds. + cryptopanic_api_key: Optional API key for CryptoPanic. + cache_duration: How long to cache headlines (seconds). + max_headlines: Maximum headlines to keep in cache. + """ + self.sources = sources or list(RSS_FEEDS.keys()) + self.cryptopanic_api_key = cryptopanic_api_key + self.cache_duration = cache_duration + self.max_headlines = max_headlines + + self._cache: List[NewsItem] = [] + self._last_fetch: Optional[datetime] = None + self._fetching = False + + self.logger = get_logger(__name__) + + # Check if feedparser is available + try: + import feedparser + self._feedparser = feedparser + self._feedparser_available = True + except ImportError: + self._feedparser_available = False + self.logger.warning( + "feedparser not installed. Install with: pip install feedparser" + ) + + def _extract_symbols(self, text: str) -> List[str]: + """Extract crypto symbols mentioned in text. + + Args: + text: Text to search (headline, summary) + + Returns: + List of detected symbol codes (e.g., ["BTC", "ETH"]) + """ + text_lower = text.lower() + detected = [] + + for symbol, keywords in CRYPTO_SYMBOLS.items(): + for keyword in keywords: + if keyword in text_lower: + detected.append(symbol) + break + + return detected + + async def _fetch_rss_feed(self, source: NewsSource) -> List[NewsItem]: + """Fetch and parse a single RSS feed. + + Args: + source: News source to fetch + + Returns: + List of NewsItems from the feed + """ + if not self._feedparser_available: + return [] + + url = RSS_FEEDS.get(source) + if not url: + return [] + + try: + # Run feedparser in thread pool to avoid blocking + loop = asyncio.get_event_loop() + feed = await loop.run_in_executor( + None, + self._feedparser.parse, + url + ) + + items = [] + for entry in feed.entries[:20]: # Limit entries per feed + # Parse publication date + published = datetime.now() + if hasattr(entry, 'published_parsed') and entry.published_parsed: + try: + published = datetime(*entry.published_parsed[:6]) + except (TypeError, ValueError): + pass + + title = entry.get('title', '') + summary = entry.get('summary', '') + + # Clean HTML from summary + summary = re.sub(r'<[^>]+>', '', summary)[:200] + + item = NewsItem( + title=title, + source=source, + published=published, + url=entry.get('link'), + summary=summary, + symbols=self._extract_symbols(f"{title} {summary}") + ) + items.append(item) + + self.logger.debug(f"Fetched {len(items)} items from {source.value}") + return items + + except Exception as e: + self.logger.warning(f"Failed to fetch {source.value} RSS: {e}") + return [] + + async def _fetch_cryptopanic(self, symbols: Optional[List[str]] = None) -> List[NewsItem]: + """Fetch news from CryptoPanic API. + + Args: + symbols: Optional list of symbols to filter + + Returns: + List of NewsItems from CryptoPanic + """ + if not self.cryptopanic_api_key: + return [] + + try: + import aiohttp + + url = "https://cryptopanic.com/api/v1/posts/" + params = { + "auth_token": self.cryptopanic_api_key, + "public": "true", + } + + if symbols: + params["currencies"] = ",".join(symbols) + + async with aiohttp.ClientSession() as session: + async with session.get(url, params=params, timeout=10) as response: + if response.status != 200: + self.logger.warning(f"CryptoPanic API error: {response.status}") + return [] + + data = await response.json() + + items = [] + for post in data.get("results", [])[:20]: + published = datetime.now() + if post.get("published_at"): + try: + published = datetime.fromisoformat( + post["published_at"].replace("Z", "+00:00") + ) + except (ValueError, TypeError): + pass + + item = NewsItem( + title=post.get("title", ""), + source=NewsSource.CRYPTOPANIC, + published=published, + url=post.get("url"), + symbols=[c["code"] for c in post.get("currencies", [])] + ) + items.append(item) + + self.logger.debug(f"Fetched {len(items)} items from CryptoPanic") + return items + + except ImportError: + self.logger.warning("aiohttp not installed for CryptoPanic API") + return [] + except Exception as e: + self.logger.warning(f"Failed to fetch CryptoPanic: {e}") + return [] + + async def fetch_news( + self, + symbols: Optional[List[str]] = None, + force_refresh: bool = False + ) -> List[NewsItem]: + """Fetch news items from all sources. + + Args: + symbols: Optional list of symbols to filter (e.g., ["BTC", "ETH"]) + force_refresh: Force a refresh even if cache is valid + + Returns: + List of NewsItems sorted by publication date (newest first) + """ + now = datetime.now() + + # Check cache validity + cache_valid = ( + self._last_fetch is not None and + (now - self._last_fetch).total_seconds() < self.cache_duration and + len(self._cache) > 0 + ) + + if cache_valid and not force_refresh: + self.logger.debug("Using cached news items") + items = self._cache + else: + # Prevent concurrent fetches + if self._fetching: + self.logger.debug("Fetch already in progress, using cache") + items = self._cache + else: + self._fetching = True + try: + items = await self._fetch_all_sources() + self._cache = items + self._last_fetch = now + finally: + self._fetching = False + + # Filter by symbols if specified + if symbols: + symbols_upper = [s.upper() for s in symbols] + items = [ + item for item in items + if any(s in symbols_upper for s in item.symbols) or not item.symbols + ] + + return items + + async def _fetch_all_sources(self) -> List[NewsItem]: + """Fetch from all configured sources concurrently.""" + tasks = [] + + # RSS feeds + for source in self.sources: + if source in RSS_FEEDS: + tasks.append(self._fetch_rss_feed(source)) + + # CryptoPanic + if self.cryptopanic_api_key: + tasks.append(self._fetch_cryptopanic()) + + if not tasks: + self.logger.warning("No news sources configured") + return [] + + # Fetch all concurrently + results = await asyncio.gather(*tasks, return_exceptions=True) + + # Combine results + all_items = [] + for result in results: + if isinstance(result, list): + all_items.extend(result) + elif isinstance(result, Exception): + self.logger.warning(f"Source fetch failed: {result}") + + # Sort by publication date (newest first) + all_items.sort(key=lambda x: x.published, reverse=True) + + # Limit total items + all_items = all_items[:self.max_headlines] + + self.logger.info(f"Fetched {len(all_items)} total news items") + return all_items + + async def fetch_headlines( + self, + symbols: Optional[List[str]] = None, + max_age_hours: int = 24, + force_refresh: bool = False + ) -> List[str]: + """Fetch headlines as strings for sentiment analysis. + + This is the main method to use with SentimentScanner. + + Args: + symbols: Optional list of symbols to filter + max_age_hours: Only include headlines from the last N hours + force_refresh: Force a refresh even if cache is valid + + Returns: + List of headline strings + """ + items = await self.fetch_news(symbols=symbols, force_refresh=force_refresh) + + # Filter by age + cutoff = datetime.now() - timedelta(hours=max_age_hours) + recent_items = [item for item in items if item.published > cutoff] + + # Extract just the titles + headlines = [item.title for item in recent_items if item.title] + + self.logger.debug(f"Returning {len(headlines)} headlines for analysis") + return headlines + + def get_cached_headlines(self, symbols: Optional[List[str]] = None) -> List[str]: + """Get cached headlines synchronously (no fetch). + + Args: + symbols: Optional list of symbols to filter + + Returns: + List of cached headline strings + """ + items = self._cache + + if symbols: + symbols_upper = [s.upper() for s in symbols] + items = [ + item for item in items + if any(s in symbols_upper for s in item.symbols) or not item.symbols + ] + + return [item.title for item in items if item.title] + + def get_status(self) -> Dict[str, Any]: + """Get collector status information. + + Returns: + Dictionary with status info + """ + return { + "sources": [s.value for s in self.sources], + "cryptopanic_enabled": self.cryptopanic_api_key is not None, + "feedparser_available": self._feedparser_available, + "cached_items": len(self._cache), + "last_fetch": self._last_fetch.isoformat() if self._last_fetch else None, + "cache_age_seconds": ( + (datetime.now() - self._last_fetch).total_seconds() + if self._last_fetch else None + ), + } + + def clear_cache(self): + """Clear the headline cache.""" + self._cache = [] + self._last_fetch = None + self.logger.info("News cache cleared") + + +# Global instance +_news_collector: Optional[NewsCollector] = None + + +def get_news_collector(**kwargs) -> NewsCollector: + """Get or create the global NewsCollector instance. + + Args: + **kwargs: Arguments passed to NewsCollector constructor + + Returns: + NewsCollector instance + """ + global _news_collector + if _news_collector is None: + _news_collector = NewsCollector(**kwargs) + logger.info("Created global NewsCollector instance") + return _news_collector diff --git a/src/data/pricing_service.py b/src/data/pricing_service.py new file mode 100644 index 00000000..1be06b85 --- /dev/null +++ b/src/data/pricing_service.py @@ -0,0 +1,406 @@ +"""Unified pricing data service with multi-provider support and automatic failover.""" + +import time +from typing import Dict, List, Optional, Any, Callable +from datetime import datetime +from decimal import Decimal + +from .providers.base_provider import BasePricingProvider +from .providers.ccxt_provider import CCXTProvider +from .providers.coingecko_provider import CoinGeckoProvider +from .cache_manager import CacheManager +from .health_monitor import HealthMonitor, HealthStatus +from src.core.config import get_config +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class PricingService: + """Unified pricing data service with multi-provider support. + + Manages multiple pricing providers with automatic failover, caching, + and health monitoring. Provides a single consistent API for accessing + market data regardless of the underlying provider. + """ + + def __init__(self): + """Initialize pricing service.""" + self.config = get_config() + self.logger = get_logger(__name__) + + # Initialize components + self.cache = CacheManager( + default_ttl=self.config.get("data_providers.caching.ohlcv_ttl", 60), + max_size=self.config.get("data_providers.caching.max_cache_size", 1000), + ticker_ttl=self.config.get("data_providers.caching.ticker_ttl", 2), + ohlcv_ttl=self.config.get("data_providers.caching.ohlcv_ttl", 60), + ) + + self.health_monitor = HealthMonitor() + + # Provider instances + self._providers: Dict[str, BasePricingProvider] = {} + self._active_provider: Optional[str] = None + self._provider_priority: List[str] = [] + + # Subscriptions + self._subscriptions: Dict[str, List[Callable]] = {} + + # Initialize providers + self._initialize_providers() + + def _initialize_providers(self): + """Initialize providers from configuration.""" + # Get primary providers from config + primary_config = self.config.get("data_providers.primary", []) + if not primary_config: + # Default configuration + primary_config = [ + {'name': 'kraken', 'enabled': True, 'priority': 1}, + {'name': 'coinbase', 'enabled': True, 'priority': 2}, + {'name': 'binance', 'enabled': True, 'priority': 3}, + ] + + # Sort by priority + primary_config = sorted( + [p for p in primary_config if p.get('enabled', True)], + key=lambda x: x.get('priority', 999) + ) + + # Create CCXT providers for each exchange + for provider_config in primary_config: + exchange_name = provider_config.get('name') + try: + provider = CCXTProvider(exchange_name=exchange_name) + provider_name = provider.name + + if provider.connect(): + self._providers[provider_name] = provider + self._provider_priority.append(provider_name) + self.logger.info(f"Initialized provider: {provider_name}") + else: + self.logger.warning(f"Failed to connect provider: {provider_name}") + except Exception as e: + self.logger.error(f"Error initializing provider {exchange_name}: {e}") + + # Add fallback provider (CoinGecko) + fallback_config = self.config.get("data_providers.fallback", {}) + if fallback_config.get('enabled', True): + try: + coingecko = CoinGeckoProvider(api_key=fallback_config.get('api_key')) + if coingecko.connect(): + self._providers[coingecko.name] = coingecko + self._provider_priority.append(coingecko.name) + self.logger.info(f"Initialized fallback provider: {coingecko.name}") + else: + self.logger.warning("Failed to connect CoinGecko fallback provider") + except Exception as e: + self.logger.error(f"Error initializing CoinGecko provider: {e}") + + # Select initial active provider + self._select_active_provider() + + def _select_active_provider(self) -> Optional[str]: + """Select the best available provider. + + Returns: + Name of selected provider or None + """ + # Filter to healthy providers + healthy_providers = [ + name for name in self._provider_priority + if name in self._providers + and self.health_monitor.is_healthy(name) + ] + + if not healthy_providers: + # Fall back to any available provider if none are healthy + healthy_providers = list(self._providers.keys()) + + if not healthy_providers: + self.logger.error("No providers available") + self._active_provider = None + return None + + # Select first healthy provider (already sorted by priority) + self._active_provider = healthy_providers[0] + self.logger.info(f"Selected active provider: {self._active_provider}") + return self._active_provider + + def _get_provider(self, provider_name: Optional[str] = None) -> Optional[BasePricingProvider]: + """Get a provider instance. + + Args: + provider_name: Name of provider, or None to use active provider + + Returns: + Provider instance or None + """ + if provider_name: + return self._providers.get(provider_name) + + # Use active provider, or select one if none active + if not self._active_provider: + self._select_active_provider() + + return self._providers.get(self._active_provider) if self._active_provider else None + + def _execute_with_failover( + self, + operation: Callable[[BasePricingProvider], Any], + operation_name: str + ) -> Any: + """Execute an operation with automatic failover. + + Args: + operation: Function that takes a provider and returns a result + operation_name: Name of operation for logging + + Returns: + Operation result or None if all providers fail + """ + # Try active provider first + providers_to_try = [self._active_provider] if self._active_provider else [] + + # Add other providers in priority order + for provider_name in self._provider_priority: + if provider_name != self._active_provider and provider_name in self._providers: + providers_to_try.append(provider_name) + + last_error = None + + for provider_name in providers_to_try: + provider = self._providers.get(provider_name) + if not provider: + continue + + # Check health + if not self.health_monitor.is_healthy(provider_name): + self.logger.debug(f"Skipping unhealthy provider: {provider_name}") + continue + + try: + start_time = time.time() + result = operation(provider) + response_time = time.time() - start_time + + # Record success + self.health_monitor.record_success(provider_name, response_time) + + # Update active provider if we used a different one + if provider_name != self._active_provider: + self.logger.info(f"Switched to provider: {provider_name}") + self._active_provider = provider_name + + return result + + except Exception as e: + last_error = e + self.logger.warning(f"{operation_name} failed on {provider_name}: {e}") + self.health_monitor.record_failure(provider_name) + + # Try next provider + continue + + # All providers failed + self.logger.error(f"{operation_name} failed on all providers") + if last_error: + raise last_error + + return None + + def get_ticker(self, symbol: str, use_cache: bool = True) -> Dict[str, Any]: + """Get current ticker data for a symbol. + + Args: + symbol: Trading pair symbol (e.g., 'BTC/USD') + use_cache: Whether to use cache + + Returns: + Ticker data dictionary + """ + cache_key = f"ticker:{symbol}" + + # Check cache + if use_cache: + cached = self.cache.get(cache_key) + if cached: + return cached + + # Fetch from provider + def fetch_ticker(provider: BasePricingProvider): + return provider.get_ticker(symbol) + + ticker_data = self._execute_with_failover(fetch_ticker, f"get_ticker({symbol})") + + if ticker_data: + # Cache the result + if use_cache: + self.cache.set(cache_key, ticker_data, cache_type='ticker') + + return ticker_data + + return {} + + def get_ohlcv( + self, + symbol: str, + timeframe: str = '1h', + since: Optional[datetime] = None, + limit: int = 100, + use_cache: bool = True + ) -> List[List]: + """Get OHLCV candlestick data. + + Args: + symbol: Trading pair symbol + timeframe: Timeframe (1m, 5m, 15m, 1h, 1d, etc.) + since: Start datetime + limit: Number of candles + use_cache: Whether to use cache + + Returns: + List of [timestamp_ms, open, high, low, close, volume] + """ + cache_key = f"ohlcv:{symbol}:{timeframe}:{limit}" + + # Check cache (only if no 'since' parameter, as it changes the result) + if use_cache and not since: + cached = self.cache.get(cache_key) + if cached: + return cached + + # Fetch from provider + def fetch_ohlcv(provider: BasePricingProvider): + return provider.get_ohlcv(symbol, timeframe, since, limit) + + ohlcv_data = self._execute_with_failover( + fetch_ohlcv, + f"get_ohlcv({symbol}, {timeframe})" + ) + + if ohlcv_data: + # Cache the result (only if no 'since' parameter) + if use_cache and not since: + self.cache.set(cache_key, ohlcv_data, cache_type='ohlcv') + + return ohlcv_data + + return [] + + def subscribe_ticker(self, symbol: str, callback: Callable) -> bool: + """Subscribe to ticker updates. + + Args: + symbol: Trading pair symbol + callback: Callback function(data) called on price updates + + Returns: + True if subscription successful + """ + key = f"ticker:{symbol}" + + # Add callback + if key not in self._subscriptions: + self._subscriptions[key] = [] + if callback not in self._subscriptions[key]: + self._subscriptions[key].append(callback) + + # Wrap callback to handle failover + def wrapped_callback(data): + for cb in self._subscriptions.get(key, []): + try: + cb(data) + except Exception as e: + self.logger.error(f"Callback error for {symbol}: {e}") + + # Subscribe via active provider + provider = self._get_provider() + if provider: + try: + success = provider.subscribe_ticker(symbol, wrapped_callback) + if success: + self.logger.info(f"Subscribed to ticker updates for {symbol}") + return True + except Exception as e: + self.logger.error(f"Failed to subscribe to ticker for {symbol}: {e}") + + return False + + def unsubscribe_ticker(self, symbol: str, callback: Optional[Callable] = None): + """Unsubscribe from ticker updates. + + Args: + symbol: Trading pair symbol + callback: Specific callback to remove, or None to remove all + """ + key = f"ticker:{symbol}" + + # Remove callback + if key in self._subscriptions: + if callback: + if callback in self._subscriptions[key]: + self._subscriptions[key].remove(callback) + if not self._subscriptions[key]: + del self._subscriptions[key] + else: + del self._subscriptions[key] + + # Unsubscribe from all providers + for provider in self._providers.values(): + try: + provider.unsubscribe_ticker(symbol, callback) + except Exception: + pass + + self.logger.info(f"Unsubscribed from ticker updates for {symbol}") + + def get_active_provider(self) -> Optional[str]: + """Get name of active provider. + + Returns: + Provider name or None + """ + return self._active_provider + + def get_provider_health(self, provider_name: Optional[str] = None) -> Dict[str, Any]: + """Get health status for a provider or all providers. + + Args: + provider_name: Provider name, or None for all providers + + Returns: + Health status dictionary + """ + if provider_name: + metrics = self.health_monitor.get_metrics(provider_name) + if metrics: + return metrics.to_dict() + return {} + + return self.health_monitor.get_all_metrics() + + def get_cache_stats(self) -> Dict[str, Any]: + """Get cache statistics. + + Returns: + Cache statistics dictionary + """ + return self.cache.get_stats() + + +# Global pricing service instance +_pricing_service: Optional[PricingService] = None + + +def get_pricing_service() -> PricingService: + """Get global pricing service instance. + + Returns: + PricingService instance + """ + global _pricing_service + if _pricing_service is None: + _pricing_service = PricingService() + return _pricing_service diff --git a/src/data/providers/__init__.py b/src/data/providers/__init__.py new file mode 100644 index 00000000..9aebc5f5 --- /dev/null +++ b/src/data/providers/__init__.py @@ -0,0 +1,7 @@ +"""Pricing data providers package.""" + +from .base_provider import BasePricingProvider +from .ccxt_provider import CCXTProvider +from .coingecko_provider import CoinGeckoProvider + +__all__ = ['BasePricingProvider', 'CCXTProvider', 'CoinGeckoProvider'] diff --git a/src/data/providers/base_provider.py b/src/data/providers/base_provider.py new file mode 100644 index 00000000..5eb0d0cc --- /dev/null +++ b/src/data/providers/base_provider.py @@ -0,0 +1,150 @@ +"""Base pricing provider interface.""" + +from abc import ABC, abstractmethod +from decimal import Decimal +from typing import Dict, List, Optional, Any, Callable +from datetime import datetime +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class BasePricingProvider(ABC): + """Base class for pricing data providers. + + Pricing providers are responsible for fetching market data (prices, OHLCV) + without requiring API keys. They differ from exchange adapters which handle + trading operations. + """ + + def __init__(self): + """Initialize pricing provider.""" + self.logger = get_logger(f"provider.{self.__class__.__name__}") + self._connected = False + self._subscribers: Dict[str, List[Callable]] = {} + + @property + @abstractmethod + def name(self) -> str: + """Provider name.""" + pass + + @property + @abstractmethod + def supports_websocket(self) -> bool: + """Whether this provider supports WebSocket connections.""" + pass + + @abstractmethod + def connect(self) -> bool: + """Connect to the provider. + + Returns: + True if connection successful + """ + pass + + @abstractmethod + def disconnect(self): + """Disconnect from the provider.""" + pass + + @abstractmethod + def get_ticker(self, symbol: str) -> Dict[str, Any]: + """Get current ticker data for a symbol. + + Args: + symbol: Trading pair symbol (e.g., 'BTC/USD') + + Returns: + Dictionary with ticker data: + - 'symbol': str + - 'bid': Decimal + - 'ask': Decimal + - 'last': Decimal (last price) + - 'high': Decimal (24h high) + - 'low': Decimal (24h low) + - 'volume': Decimal (24h volume) + - 'timestamp': int (Unix timestamp in milliseconds) + """ + pass + + @abstractmethod + def get_ohlcv( + self, + symbol: str, + timeframe: str = '1h', + since: Optional[datetime] = None, + limit: int = 100 + ) -> List[List]: + """Get OHLCV (candlestick) data. + + Args: + symbol: Trading pair symbol + timeframe: Timeframe (1m, 5m, 15m, 1h, 1d, etc.) + since: Start datetime + limit: Number of candles + + Returns: + List of [timestamp_ms, open, high, low, close, volume] + """ + pass + + @abstractmethod + def subscribe_ticker(self, symbol: str, callback: Callable) -> bool: + """Subscribe to ticker updates. + + Args: + symbol: Trading pair symbol + callback: Callback function(data) called on price updates + + Returns: + True if subscription successful + """ + pass + + def unsubscribe_ticker(self, symbol: str, callback: Optional[Callable] = None): + """Unsubscribe from ticker updates. + + Args: + symbol: Trading pair symbol + callback: Specific callback to remove, or None to remove all + """ + key = f"ticker_{symbol}" + if key in self._subscribers: + if callback: + if callback in self._subscribers[key]: + self._subscribers[key].remove(callback) + if not self._subscribers[key]: + del self._subscribers[key] + else: + del self._subscribers[key] + + def normalize_symbol(self, symbol: str) -> str: + """Normalize symbol format for this provider. + + Args: + symbol: Symbol to normalize + + Returns: + Normalized symbol + """ + # Default: uppercase and replace dashes with slashes + return symbol.upper().replace('-', '/') + + def is_connected(self) -> bool: + """Check if provider is connected. + + Returns: + True if connected + """ + return self._connected + + def get_supported_symbols(self) -> List[str]: + """Get list of supported trading symbols. + + Returns: + List of supported symbols, or empty list if not available + """ + # Default implementation - override in subclasses + return [] diff --git a/src/data/providers/ccxt_provider.py b/src/data/providers/ccxt_provider.py new file mode 100644 index 00000000..9623a312 --- /dev/null +++ b/src/data/providers/ccxt_provider.py @@ -0,0 +1,333 @@ +"""CCXT-based pricing provider with multi-exchange support and WebSocket capabilities.""" + +import ccxt +import threading +import time +from decimal import Decimal +from typing import Dict, List, Optional, Any, Callable +from datetime import datetime +from .base_provider import BasePricingProvider +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class CCXTProvider(BasePricingProvider): + """CCXT-based pricing provider with multi-exchange fallback support. + + This provider uses CCXT to connect to multiple exchanges (Kraken, Coinbase, Binance) + as primary data sources. It supports WebSocket where available and falls back to + polling if WebSocket is not available. + """ + + def __init__(self, exchange_name: Optional[str] = None): + """Initialize CCXT provider. + + Args: + exchange_name: Specific exchange to use ('kraken', 'coinbase', 'binance'), + or None to try multiple exchanges + """ + super().__init__() + self.exchange_name = exchange_name + self.exchange = None + self._selected_exchange_id = None + self._polling_threads: Dict[str, threading.Thread] = {} + self._stop_polling: Dict[str, bool] = {} + + # Exchange priority order (try first to last) + if exchange_name: + self._exchange_options = [(exchange_name.lower(), exchange_name.capitalize())] + else: + self._exchange_options = [ + ('kraken', 'Kraken'), + ('coinbase', 'Coinbase'), + ('binance', 'Binance'), + ] + + @property + def name(self) -> str: + """Provider name.""" + if self._selected_exchange_id: + return f"CCXT-{self._selected_exchange_id.capitalize()}" + return "CCXT Provider" + + @property + def supports_websocket(self) -> bool: + """Check if current exchange supports WebSocket.""" + if not self.exchange: + return False + + # Check if exchange has WebSocket support + # Most modern CCXT exchanges support WebSocket, but we check explicitly + exchange_id = getattr(self.exchange, 'id', '').lower() + + # Known WebSocket-capable exchanges + ws_capable = ['kraken', 'coinbase', 'binance', 'binanceus', 'okx'] + + return exchange_id in ws_capable + + def connect(self) -> bool: + """Connect to an exchange via CCXT. + + Tries multiple exchanges in order until one succeeds. + + Returns: + True if connection successful + """ + for exchange_id, exchange_display_name in self._exchange_options: + try: + # Get exchange class from CCXT + exchange_class = getattr(ccxt, exchange_id, None) + if not exchange_class: + logger.warning(f"Exchange {exchange_id} not found in CCXT") + continue + + # Create exchange instance + self.exchange = exchange_class({ + 'enableRateLimit': True, + 'options': { + 'defaultType': 'spot', + } + }) + + # Load markets to test connection + if not hasattr(self.exchange, 'markets') or not self.exchange.markets: + try: + self.exchange.load_markets() + except Exception as e: + logger.warning(f"Failed to load markets for {exchange_id}: {e}") + continue + + # Test connection with a common symbol + test_symbols = ['BTC/USDT', 'BTC/USD', 'BTC/EUR'] + ticker_result = None + + for test_symbol in test_symbols: + try: + ticker_result = self.exchange.fetch_ticker(test_symbol) + if ticker_result: + break + except Exception: + continue + + if not ticker_result: + logger.warning(f"Could not fetch ticker from {exchange_id}") + continue + + # Success! + self._selected_exchange_id = exchange_id + self._connected = True + logger.info(f"Connected to {exchange_display_name} via CCXT") + return True + + except Exception as e: + logger.warning(f"Failed to connect to {exchange_id}: {e}") + continue + + # All exchanges failed + logger.error("Failed to connect to any CCXT exchange") + self._connected = False + self.exchange = None + return False + + def disconnect(self): + """Disconnect from exchange.""" + # Stop all polling threads + for symbol in list(self._stop_polling.keys()): + self._stop_polling[symbol] = True + + # Wait a bit for threads to stop + for symbol, thread in self._polling_threads.items(): + if thread.is_alive(): + thread.join(timeout=2.0) + + self._polling_threads.clear() + self._stop_polling.clear() + self._subscribers.clear() + self._connected = False + self.exchange = None + self._selected_exchange_id = None + logger.info(f"Disconnected from {self.name}") + + def get_ticker(self, symbol: str) -> Dict[str, Any]: + """Get current ticker data.""" + if not self._connected or not self.exchange: + logger.error("Provider not connected") + return {} + + try: + normalized_symbol = self.normalize_symbol(symbol) + ticker = self.exchange.fetch_ticker(normalized_symbol) + + return { + 'symbol': symbol, + 'bid': Decimal(str(ticker.get('bid', 0) or 0)), + 'ask': Decimal(str(ticker.get('ask', 0) or 0)), + 'last': Decimal(str(ticker.get('last', 0) or ticker.get('close', 0) or 0)), + 'high': Decimal(str(ticker.get('high', 0) or 0)), + 'low': Decimal(str(ticker.get('low', 0) or 0)), + 'volume': Decimal(str(ticker.get('quoteVolume', ticker.get('volume', 0)) or 0)), + 'timestamp': ticker.get('timestamp', int(time.time() * 1000)), + } + except Exception as e: + logger.error(f"Failed to get ticker for {symbol}: {e}") + return {} + + def get_ohlcv( + self, + symbol: str, + timeframe: str = '1h', + since: Optional[datetime] = None, + limit: int = 100 + ) -> List[List]: + """Get OHLCV candlestick data.""" + if not self._connected or not self.exchange: + logger.error("Provider not connected") + return [] + + try: + since_timestamp = int(since.timestamp() * 1000) if since else None + normalized_symbol = self.normalize_symbol(symbol) + + # Most exchanges support up to 1000 candles per request + max_limit = min(limit, 1000) + + ohlcv = self.exchange.fetch_ohlcv( + normalized_symbol, + timeframe, + since_timestamp, + max_limit + ) + + logger.debug(f"Fetched {len(ohlcv)} candles for {symbol} ({timeframe})") + return ohlcv + + except Exception as e: + logger.error(f"Failed to get OHLCV for {symbol}: {e}") + return [] + + def subscribe_ticker(self, symbol: str, callback: Callable) -> bool: + """Subscribe to ticker updates. + + Uses WebSocket if available, otherwise falls back to polling. + """ + if not self._connected or not self.exchange: + logger.error("Provider not connected") + return False + + key = f"ticker_{symbol}" + + # Add callback to subscribers + if key not in self._subscribers: + self._subscribers[key] = [] + if callback not in self._subscribers[key]: + self._subscribers[key].append(callback) + + # Try WebSocket first if supported + if self.supports_websocket: + try: + # CCXT WebSocket support varies by exchange + # For now, use polling as fallback since WebSocket implementation + # in CCXT requires exchange-specific handling + # TODO: Implement native WebSocket when CCXT adds better support + pass + except Exception as e: + logger.warning(f"WebSocket subscription failed, falling back to polling: {e}") + + # Use polling as primary method (more reliable across exchanges) + if key not in self._polling_threads or not self._polling_threads[key].is_alive(): + self._stop_polling[key] = False + + def poll_ticker(): + """Poll ticker every 2 seconds.""" + while not self._stop_polling.get(key, False): + try: + ticker_data = self.get_ticker(symbol) + if ticker_data and 'last' in ticker_data: + # Call all callbacks for this symbol + for cb in self._subscribers.get(key, []): + try: + cb({ + 'symbol': symbol, + 'price': ticker_data['last'], + 'bid': ticker_data.get('bid', 0), + 'ask': ticker_data.get('ask', 0), + 'volume': ticker_data.get('volume', 0), + 'timestamp': ticker_data.get('timestamp'), + }) + except Exception as e: + logger.error(f"Callback error for {symbol}: {e}") + + time.sleep(2) # Poll every 2 seconds + except Exception as e: + logger.error(f"Ticker polling error for {symbol}: {e}") + time.sleep(5) # Wait longer on error + + thread = threading.Thread(target=poll_ticker, daemon=True) + thread.start() + self._polling_threads[key] = thread + logger.info(f"Subscribed to ticker updates for {symbol} (polling mode)") + return True + + return True + + def unsubscribe_ticker(self, symbol: str, callback: Optional[Callable] = None): + """Unsubscribe from ticker updates.""" + key = f"ticker_{symbol}" + + # Stop polling thread + if key in self._stop_polling: + self._stop_polling[key] = True + + # Remove from subscribers + super().unsubscribe_ticker(symbol, callback) + + # Clean up thread reference + if key in self._polling_threads: + del self._polling_threads[key] + + logger.info(f"Unsubscribed from ticker updates for {symbol}") + + def normalize_symbol(self, symbol: str) -> str: + """Normalize symbol for the selected exchange.""" + if not self.exchange: + return symbol.replace('-', '/').upper() + + # Basic normalization + normalized = symbol.replace('-', '/').upper() + + # Try to use exchange's markets to find correct symbol + try: + if hasattr(self.exchange, 'markets') and self.exchange.markets: + # Check if symbol exists + if normalized in self.exchange.markets: + return normalized + + # Try alternative formats + if '/USD' in normalized: + alt_symbol = normalized.replace('/USD', '/USDT') + if alt_symbol in self.exchange.markets: + return alt_symbol + + # For Kraken: try XBT instead of BTC + if normalized.startswith('BTC/'): + alt_symbol = normalized.replace('BTC/', 'XBT/') + if alt_symbol in self.exchange.markets: + return alt_symbol + except Exception: + pass + + # Fallback: return normalized (let CCXT handle errors) + return normalized + + def get_supported_symbols(self) -> List[str]: + """Get list of supported trading symbols.""" + if not self._connected or not self.exchange: + return [] + + try: + markets = self.exchange.load_markets() + return list(markets.keys()) + except Exception as e: + logger.error(f"Failed to get supported symbols: {e}") + return [] diff --git a/src/data/providers/coingecko_provider.py b/src/data/providers/coingecko_provider.py new file mode 100644 index 00000000..691a7655 --- /dev/null +++ b/src/data/providers/coingecko_provider.py @@ -0,0 +1,376 @@ +"""CoinGecko pricing provider for fallback market data.""" + +import httpx +import time +import threading +from decimal import Decimal +from typing import Dict, List, Optional, Any, Callable, Tuple +from datetime import datetime +from .base_provider import BasePricingProvider +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class CoinGeckoProvider(BasePricingProvider): + """CoinGecko API pricing provider. + + This provider uses CoinGecko's free API tier as a fallback when CCXT + providers are unavailable. It uses simple REST endpoints that don't + require authentication. + """ + + BASE_URL = "https://api.coingecko.com/api/v3" + + # CoinGecko coin ID mapping for common symbols + COIN_ID_MAP = { + 'BTC': 'bitcoin', + 'ETH': 'ethereum', + 'BNB': 'binancecoin', + 'SOL': 'solana', + 'ADA': 'cardano', + 'XRP': 'ripple', + 'DOGE': 'dogecoin', + 'DOT': 'polkadot', + 'MATIC': 'matic-network', + 'AVAX': 'avalanche-2', + 'LINK': 'chainlink', + 'USDT': 'tether', + 'USDC': 'usd-coin', + 'DAI': 'dai', + } + + # Currency mapping + CURRENCY_MAP = { + 'USD': 'usd', + 'EUR': 'eur', + 'GBP': 'gbp', + 'JPY': 'jpy', + 'USDT': 'usd', # CoinGecko uses USD for stablecoins + } + + def __init__(self, api_key: Optional[str] = None): + """Initialize CoinGecko provider. + + Args: + api_key: Optional API key for higher rate limits (free tier doesn't require it) + """ + super().__init__() + self.api_key = api_key + self._client = None + self._polling_threads: Dict[str, threading.Thread] = {} + self._stop_polling: Dict[str, bool] = {} + self._rate_limit_delay = 1.0 # Free tier: ~30-50 calls/minute + + @property + def name(self) -> str: + """Provider name.""" + return "CoinGecko" + + @property + def supports_websocket(self) -> bool: + """CoinGecko free tier doesn't support WebSocket.""" + return False + + def connect(self) -> bool: + """Connect to CoinGecko API. + + Returns: + True if connection successful (just validates API access) + """ + try: + self._client = httpx.Client(timeout=10.0) + + # Test connection by fetching Bitcoin price + response = self._client.get( + f"{self.BASE_URL}/simple/price", + params={ + 'ids': 'bitcoin', + 'vs_currencies': 'usd', + } + ) + + if response.status_code == 200: + data = response.json() + if 'bitcoin' in data: + self._connected = True + logger.info("Connected to CoinGecko API") + return True + + logger.warning("CoinGecko API test failed") + self._connected = False + return False + + except Exception as e: + logger.error(f"Failed to connect to CoinGecko: {e}") + self._connected = False + return False + + def disconnect(self): + """Disconnect from CoinGecko API.""" + # Stop all polling threads + for symbol in list(self._stop_polling.keys()): + self._stop_polling[symbol] = True + + # Wait for threads to stop + for symbol, thread in self._polling_threads.items(): + if thread.is_alive(): + thread.join(timeout=2.0) + + self._polling_threads.clear() + self._stop_polling.clear() + self._subscribers.clear() + + if self._client: + self._client.close() + self._client = None + + self._connected = False + logger.info("Disconnected from CoinGecko API") + + def _parse_symbol(self, symbol: str) -> Tuple[Optional[str], Optional[str]]: + """Parse symbol into coin_id and currency. + + Args: + symbol: Trading pair like 'BTC/USD' or 'BTC/USDT' + + Returns: + Tuple of (coin_id, currency) or (None, None) if not found + """ + parts = symbol.upper().replace('-', '/').split('/') + if len(parts) != 2: + return None, None + + base, quote = parts + + # Get coin ID + coin_id = self.COIN_ID_MAP.get(base) + if not coin_id: + # Try lowercase base as fallback + coin_id = base.lower() + + # Get currency + currency = self.CURRENCY_MAP.get(quote, quote.lower()) + + return coin_id, currency + + def get_ticker(self, symbol: str) -> Dict[str, Any]: + """Get current ticker data from CoinGecko.""" + if not self._connected or not self._client: + logger.error("Provider not connected") + return {} + + try: + coin_id, currency = self._parse_symbol(symbol) + if not coin_id: + logger.error(f"Unknown symbol format: {symbol}") + return {} + + # Fetch current price + response = self._client.get( + f"{self.BASE_URL}/simple/price", + params={ + 'ids': coin_id, + 'vs_currencies': currency, + 'include_24hr_change': 'true', + 'include_24hr_vol': 'true', + } + ) + + if response.status_code != 200: + logger.error(f"CoinGecko API error: {response.status_code}") + return {} + + data = response.json() + if coin_id not in data: + logger.error(f"Coin {coin_id} not found in CoinGecko response") + return {} + + coin_data = data[coin_id] + price_key = currency.lower() + + if price_key not in coin_data: + logger.error(f"Currency {currency} not found for {coin_id}") + return {} + + price = Decimal(str(coin_data[price_key])) + + # Calculate high/low from 24h change if available + change_key = f"{price_key}_24h_change" + vol_key = f"{price_key}_24h_vol" + + change_24h = coin_data.get(change_key, 0) or 0 + volume_24h = coin_data.get(vol_key, 0) or 0 + + # Estimate high/low from current price and 24h change + # This is approximate since CoinGecko free tier doesn't provide exact high/low + current_price = float(price) + if change_24h: + estimated_high = current_price * (1 + abs(change_24h / 100) / 2) + estimated_low = current_price * (1 - abs(change_24h / 100) / 2) + else: + estimated_high = current_price + estimated_low = current_price + + return { + 'symbol': symbol, + 'bid': price, # CoinGecko doesn't provide bid/ask, use last price + 'ask': price, + 'last': price, + 'high': Decimal(str(estimated_high)), + 'low': Decimal(str(estimated_low)), + 'volume': Decimal(str(volume_24h)), + 'timestamp': int(time.time() * 1000), + } + + except Exception as e: + logger.error(f"Failed to get ticker for {symbol}: {e}") + return {} + + def get_ohlcv( + self, + symbol: str, + timeframe: str = '1h', + since: Optional[datetime] = None, + limit: int = 100 + ) -> List[List]: + """Get OHLCV data from CoinGecko. + + Note: CoinGecko free tier has limited historical data access. + This method may return empty data for some timeframes. + """ + if not self._connected or not self._client: + logger.error("Provider not connected") + return [] + + try: + coin_id, currency = self._parse_symbol(symbol) + if not coin_id: + logger.error(f"Unknown symbol format: {symbol}") + return [] + + # CoinGecko uses days parameter instead of timeframe + # Map timeframe to days + timeframe_days_map = { + '1m': 1, # Last 24 hours, minute data (not available in free tier) + '5m': 1, + '15m': 1, + '30m': 1, + '1h': 1, # Last 24 hours, hourly data + '4h': 7, # Last 7 days + '1d': 30, # Last 30 days + '1w': 90, # Last 90 days + } + + days = timeframe_days_map.get(timeframe, 7) + + # Fetch OHLC data + response = self._client.get( + f"{self.BASE_URL}/coins/{coin_id}/ohlc", + params={ + 'vs_currency': currency, + 'days': days, + } + ) + + if response.status_code != 200: + logger.warning(f"CoinGecko OHLC API returned {response.status_code}") + return [] + + data = response.json() + + # CoinGecko returns: [timestamp_ms, open, high, low, close] + # We need to convert to: [timestamp_ms, open, high, low, close, volume] + # Note: CoinGecko OHLC endpoint doesn't include volume + + # Filter by since if provided + if since: + since_timestamp = int(since.timestamp() * 1000) + data = [candle for candle in data if candle[0] >= since_timestamp] + + # Limit results + data = data[-limit:] if limit else data + + # Add volume as 0 (CoinGecko doesn't provide it in OHLC endpoint) + ohlcv = [candle + [0] for candle in data] + + logger.debug(f"Fetched {len(ohlcv)} candles for {symbol} from CoinGecko") + return ohlcv + + except Exception as e: + logger.error(f"Failed to get OHLCV for {symbol}: {e}") + return [] + + def subscribe_ticker(self, symbol: str, callback: Callable) -> bool: + """Subscribe to ticker updates via polling.""" + if not self._connected or not self._client: + logger.error("Provider not connected") + return False + + key = f"ticker_{symbol}" + + # Add callback to subscribers + if key not in self._subscribers: + self._subscribers[key] = [] + if callback not in self._subscribers[key]: + self._subscribers[key].append(callback) + + # Start polling thread if not already running + if key not in self._polling_threads or not self._polling_threads[key].is_alive(): + self._stop_polling[key] = False + + def poll_ticker(): + """Poll ticker respecting rate limits.""" + while not self._stop_polling.get(key, False): + try: + ticker_data = self.get_ticker(symbol) + if ticker_data and 'last' in ticker_data: + # Call all callbacks + for cb in self._subscribers.get(key, []): + try: + cb({ + 'symbol': symbol, + 'price': ticker_data['last'], + 'bid': ticker_data.get('bid', 0), + 'ask': ticker_data.get('ask', 0), + 'volume': ticker_data.get('volume', 0), + 'timestamp': ticker_data.get('timestamp'), + }) + except Exception as e: + logger.error(f"Callback error for {symbol}: {e}") + + # Rate limit: wait between requests (free tier: ~30-50 calls/min) + time.sleep(self._rate_limit_delay * 2) # Poll every 2 seconds + except Exception as e: + logger.error(f"Ticker polling error for {symbol}: {e}") + time.sleep(10) # Wait longer on error + + thread = threading.Thread(target=poll_ticker, daemon=True) + thread.start() + self._polling_threads[key] = thread + logger.info(f"Subscribed to ticker updates for {symbol} (CoinGecko polling)") + return True + + return True + + def unsubscribe_ticker(self, symbol: str, callback: Optional[Callable] = None): + """Unsubscribe from ticker updates.""" + key = f"ticker_{symbol}" + + # Stop polling + if key in self._stop_polling: + self._stop_polling[key] = True + + # Remove from subscribers + super().unsubscribe_ticker(symbol, callback) + + # Clean up thread + if key in self._polling_threads: + del self._polling_threads[key] + + logger.info(f"Unsubscribed from ticker updates for {symbol}") + + def normalize_symbol(self, symbol: str) -> str: + """Normalize symbol format.""" + # CoinGecko uses coin IDs, so we just normalize the format + return symbol.replace('-', '/').upper() diff --git a/src/data/quality.py b/src/data/quality.py new file mode 100644 index 00000000..9db08ee8 --- /dev/null +++ b/src/data/quality.py @@ -0,0 +1,116 @@ +"""Data quality validation, gap filling, and retention policies.""" + +from datetime import datetime, timedelta +from typing import List, Optional, Dict, Any +from sqlalchemy.orm import Session +from src.core.database import get_database, MarketData +from src.core.config import get_config +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class DataQualityManager: + """Manages data quality and retention.""" + + def __init__(self): + """Initialize data quality manager.""" + self.db = get_database() + self.config = get_config() + self.logger = get_logger(__name__) + + def validate_data_quality( + self, + exchange: str, + symbol: str, + timeframe: str, + start_date: datetime, + end_date: datetime + ) -> Dict[str, Any]: + """Validate data quality. + + Args: + exchange: Exchange name + symbol: Trading symbol + timeframe: Timeframe + start_date: Start date + end_date: End date + + Returns: + Quality report + """ + session = self.db.get_session() + try: + data = session.query(MarketData).filter( + MarketData.exchange == exchange, + MarketData.symbol == symbol, + MarketData.timeframe == timeframe, + MarketData.timestamp >= start_date, + MarketData.timestamp <= end_date + ).order_by(MarketData.timestamp).all() + + if len(data) == 0: + return {"valid": False, "reason": "No data"} + + # Check for gaps + gaps = self._detect_gaps(data, timeframe) + + # Check for anomalies + anomalies = self._detect_anomalies(data) + + return { + "valid": len(gaps) == 0 and len(anomalies) == 0, + "total_records": len(data), + "gaps": len(gaps), + "anomalies": len(anomalies), + } + finally: + session.close() + + def _detect_gaps(self, data: List[MarketData], timeframe: str) -> List[datetime]: + """Detect gaps in data. + + Args: + data: List of market data + timeframe: Timeframe + + Returns: + List of gap timestamps + """ + gaps = [] + # Simplified gap detection + return gaps + + def _detect_anomalies(self, data: List[MarketData]) -> List[int]: + """Detect data anomalies. + + Args: + data: List of market data + + Returns: + List of anomaly indices + """ + anomalies = [] + # Simplified anomaly detection + return anomalies + + def cleanup_old_data(self, days_to_keep: int = 365): + """Clean up old data based on retention policy. + + Args: + days_to_keep: Days of data to keep + """ + session = self.db.get_session() + try: + cutoff_date = datetime.utcnow() - timedelta(days=days_to_keep) + deleted = session.query(MarketData).filter( + MarketData.timestamp < cutoff_date + ).delete() + session.commit() + logger.info(f"Cleaned up {deleted} old data records") + except Exception as e: + session.rollback() + logger.error(f"Failed to cleanup old data: {e}") + finally: + session.close() + diff --git a/src/data/redis_cache.py b/src/data/redis_cache.py new file mode 100644 index 00000000..654f4acb --- /dev/null +++ b/src/data/redis_cache.py @@ -0,0 +1,225 @@ +"""Redis-based caching for market data and API responses.""" + +from typing import Any, Optional +import json +from datetime import datetime +from src.core.redis import get_redis_client +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class RedisCache: + """Redis-based cache for market data and API responses.""" + + # Default TTL values (seconds) + TTL_TICKER = 5 # Ticker prices are very volatile + TTL_OHLCV = 60 # OHLCV can be cached longer + TTL_ORDERBOOK = 2 # Order books change rapidly + TTL_API_RESPONSE = 30 # General API response cache + + def __init__(self): + """Initialize Redis cache.""" + self.redis = get_redis_client() + + async def get_ticker(self, symbol: str) -> Optional[dict]: + """Get cached ticker data. + + Args: + symbol: Trading symbol (e.g., 'BTC/USD') + + Returns: + Cached ticker data or None + """ + key = f"cache:ticker:{symbol.replace('/', '_')}" + try: + client = self.redis.get_client() + data = await client.get(key) + if data: + logger.debug(f"Cache hit for ticker:{symbol}") + return json.loads(data) + return None + except Exception as e: + logger.warning(f"Redis cache get failed: {e}") + return None + + async def set_ticker(self, symbol: str, data: dict, ttl: int = None) -> bool: + """Cache ticker data. + + Args: + symbol: Trading symbol + data: Ticker data + ttl: Time-to-live in seconds (default: TTL_TICKER) + + Returns: + True if cached successfully + """ + key = f"cache:ticker:{symbol.replace('/', '_')}" + ttl = ttl or self.TTL_TICKER + try: + client = self.redis.get_client() + await client.setex(key, ttl, json.dumps(data)) + logger.debug(f"Cached ticker:{symbol} for {ttl}s") + return True + except Exception as e: + logger.warning(f"Redis cache set failed: {e}") + return False + + async def get_ohlcv(self, symbol: str, timeframe: str, limit: int = 100) -> Optional[list]: + """Get cached OHLCV data. + + Args: + symbol: Trading symbol + timeframe: Candle timeframe + limit: Number of candles + + Returns: + Cached OHLCV data or None + """ + key = f"cache:ohlcv:{symbol.replace('/', '_')}:{timeframe}:{limit}" + try: + client = self.redis.get_client() + data = await client.get(key) + if data: + logger.debug(f"Cache hit for ohlcv:{symbol}:{timeframe}") + return json.loads(data) + return None + except Exception as e: + logger.warning(f"Redis cache get failed: {e}") + return None + + async def set_ohlcv(self, symbol: str, timeframe: str, data: list, limit: int = 100, ttl: int = None) -> bool: + """Cache OHLCV data. + + Args: + symbol: Trading symbol + timeframe: Candle timeframe + data: OHLCV data + limit: Number of candles + ttl: Time-to-live in seconds + + Returns: + True if cached successfully + """ + key = f"cache:ohlcv:{symbol.replace('/', '_')}:{timeframe}:{limit}" + ttl = ttl or self.TTL_OHLCV + try: + client = self.redis.get_client() + await client.setex(key, ttl, json.dumps(data)) + logger.debug(f"Cached ohlcv:{symbol}:{timeframe} for {ttl}s") + return True + except Exception as e: + logger.warning(f"Redis cache set failed: {e}") + return False + + async def get_api_response(self, cache_key: str) -> Optional[dict]: + """Get cached API response. + + Args: + cache_key: Unique cache key + + Returns: + Cached response or None + """ + key = f"cache:api:{cache_key}" + try: + client = self.redis.get_client() + data = await client.get(key) + if data: + logger.debug(f"Cache hit for api:{cache_key}") + return json.loads(data) + return None + except Exception as e: + logger.warning(f"Redis cache get failed: {e}") + return None + + async def set_api_response(self, cache_key: str, data: dict, ttl: int = None) -> bool: + """Cache API response. + + Args: + cache_key: Unique cache key + data: Response data + ttl: Time-to-live in seconds + + Returns: + True if cached successfully + """ + key = f"cache:api:{cache_key}" + ttl = ttl or self.TTL_API_RESPONSE + try: + client = self.redis.get_client() + await client.setex(key, ttl, json.dumps(data)) + logger.debug(f"Cached api:{cache_key} for {ttl}s") + return True + except Exception as e: + logger.warning(f"Redis cache set failed: {e}") + return False + + async def invalidate(self, pattern: str) -> int: + """Invalidate cache entries matching pattern. + + Args: + pattern: Redis key pattern (e.g., 'cache:ticker:*') + + Returns: + Number of keys deleted + """ + try: + client = self.redis.get_client() + keys = [] + async for key in client.scan_iter(match=pattern): + keys.append(key) + + if keys: + deleted = await client.delete(*keys) + logger.info(f"Invalidated {deleted} cache entries matching {pattern}") + return deleted + return 0 + except Exception as e: + logger.warning(f"Redis cache invalidation failed: {e}") + return 0 + + async def get_stats(self) -> dict: + """Get cache statistics. + + Returns: + Cache statistics + """ + try: + client = self.redis.get_client() + info = await client.info('memory') + + # Count cached items by type + ticker_count = 0 + ohlcv_count = 0 + api_count = 0 + + async for key in client.scan_iter(match='cache:ticker:*'): + ticker_count += 1 + async for key in client.scan_iter(match='cache:ohlcv:*'): + ohlcv_count += 1 + async for key in client.scan_iter(match='cache:api:*'): + api_count += 1 + + return { + "memory_used": info.get('used_memory_human', 'N/A'), + "ticker_entries": ticker_count, + "ohlcv_entries": ohlcv_count, + "api_entries": api_count, + "total_entries": ticker_count + ohlcv_count + api_count + } + except Exception as e: + logger.warning(f"Failed to get cache stats: {e}") + return {"error": str(e)} + + +# Global cache instance +_redis_cache: Optional[RedisCache] = None + + +def get_redis_cache() -> RedisCache: + """Get global Redis cache instance.""" + global _redis_cache + if _redis_cache is None: + _redis_cache = RedisCache() + return _redis_cache diff --git a/src/data/storage.py b/src/data/storage.py new file mode 100644 index 00000000..8c6fdccd --- /dev/null +++ b/src/data/storage.py @@ -0,0 +1,75 @@ +"""Data persistence.""" + +from decimal import Decimal +from datetime import datetime +from typing import List, Optional +from sqlalchemy.orm import Session +from src.core.database import get_database, MarketData +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class DataStorage: + """Manages data storage and persistence.""" + + def __init__(self): + """Initialize data storage.""" + self.db = get_database() + self.logger = get_logger(__name__) + + def store_ohlcv( + self, + exchange: str, + symbol: str, + timeframe: str, + timestamp: datetime, + open: Decimal, + high: Decimal, + low: Decimal, + close: Decimal, + volume: Decimal + ): + """Store OHLCV data. + + Args: + exchange: Exchange name + symbol: Trading symbol + timeframe: Timeframe + timestamp: Timestamp + open: Open price + high: High price + low: Low price + close: Close price + volume: Volume + """ + session = self.db.get_session() + try: + # Check if exists + existing = session.query(MarketData).filter_by( + exchange=exchange, + symbol=symbol, + timeframe=timeframe, + timestamp=timestamp + ).first() + + if not existing: + market_data = MarketData( + exchange=exchange, + symbol=symbol, + timeframe=timeframe, + timestamp=timestamp, + open=open, + high=high, + low=low, + close=close, + volume=volume + ) + session.add(market_data) + session.commit() + except Exception as e: + session.rollback() + logger.error(f"Failed to store OHLCV data: {e}") + finally: + session.close() + diff --git a/src/exchanges/__init__.py b/src/exchanges/__init__.py new file mode 100644 index 00000000..70fba2ae --- /dev/null +++ b/src/exchanges/__init__.py @@ -0,0 +1,14 @@ +"""Exchange adapters package.""" + +from .base import BaseExchangeAdapter +from .factory import ExchangeFactory, get_exchange +from .coinbase import CoinbaseAdapter +from .public_data import PublicDataAdapter + +# Register exchange adapters +ExchangeFactory.register("coinbase", CoinbaseAdapter) +ExchangeFactory.register("binance public", PublicDataAdapter) +ExchangeFactory.register("public data", PublicDataAdapter) # Alias + +__all__ = ['ExchangeFactory', 'get_exchange', 'CoinbaseAdapter', 'PublicDataAdapter', 'BaseExchangeAdapter'] + diff --git a/src/exchanges/base.py b/src/exchanges/base.py new file mode 100644 index 00000000..f57077c1 --- /dev/null +++ b/src/exchanges/base.py @@ -0,0 +1,309 @@ +"""Base exchange adapter interface.""" + +from abc import ABC, abstractmethod +from decimal import Decimal +from typing import Dict, List, Optional, Any +from datetime import datetime +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class Order: + """Order representation.""" + + def __init__( + self, + symbol: str, + side: str, # 'buy' or 'sell' + order_type: str, # 'market', 'limit', etc. + quantity: Decimal, + price: Optional[Decimal] = None, + **kwargs + ): + self.symbol = symbol + self.side = side + self.order_type = order_type + self.quantity = quantity + self.price = price + self.extra = kwargs + + +class BaseExchangeAdapter(ABC): + """Base class for exchange adapters.""" + + def __init__(self, api_key: str, api_secret: str, sandbox: bool = False, read_only: bool = True): + """Initialize exchange adapter. + + Args: + api_key: API key + api_secret: API secret + sandbox: Use sandbox/testnet + read_only: Read-only mode (no trading) + """ + self.api_key = api_key + self.api_secret = api_secret + self.sandbox = sandbox + self.read_only = read_only + self.logger = get_logger(f"exchange.{self.__class__.__name__}") + self._connected = False + + @property + @abstractmethod + def name(self) -> str: + """Exchange name.""" + pass + + @abstractmethod + async def connect(self) -> bool: + """Connect to exchange. + + Returns: + True if connection successful + """ + pass + + @abstractmethod + async def disconnect(self): + """Disconnect from exchange.""" + pass + + @abstractmethod + async def get_balance(self, currency: Optional[str] = None) -> Dict[str, Decimal]: + """Get account balance. + + Args: + currency: Specific currency to get balance for, or None for all + + Returns: + Dictionary of currency -> balance + """ + pass + + @abstractmethod + async def get_ticker(self, symbol: str) -> Dict[str, Any]: + """Get current ticker for symbol. + + Args: + symbol: Trading pair symbol (e.g., 'BTC/USD') + + Returns: + Ticker data with 'bid', 'ask', 'last', 'volume', etc. + """ + pass + + @abstractmethod + async def get_orderbook(self, symbol: str, limit: int = 20) -> Dict[str, List]: + """Get order book for symbol. + + Args: + symbol: Trading pair symbol + limit: Number of orders per side + + Returns: + Dictionary with 'bids' and 'asks' lists + """ + pass + + @abstractmethod + async def place_order(self, order: Order) -> Dict[str, Any]: + """Place an order. + + Args: + order: Order object + + Returns: + Order response with order ID and status + """ + pass + + @abstractmethod + async def cancel_order(self, order_id: str, symbol: str) -> bool: + """Cancel an order. + + Args: + order_id: Order ID to cancel + symbol: Trading pair symbol + + Returns: + True if cancellation successful + """ + pass + + @abstractmethod + async def get_order_status(self, order_id: str, symbol: str) -> Dict[str, Any]: + """Get order status. + + Args: + order_id: Order ID + symbol: Trading pair symbol + + Returns: + Order status information + """ + pass + + @abstractmethod + async def get_open_orders(self, symbol: Optional[str] = None) -> List[Dict[str, Any]]: + """Get open orders. + + Args: + symbol: Optional symbol to filter by + + Returns: + List of open orders + """ + pass + + @abstractmethod + async def get_positions(self, symbol: Optional[str] = None) -> List[Dict[str, Any]]: + """Get open positions. + + Args: + symbol: Optional symbol to filter by + + Returns: + List of positions + """ + pass + + @abstractmethod + async def get_ohlcv( + self, + symbol: str, + timeframe: str = '1h', + since: Optional[datetime] = None, + limit: int = 100 + ) -> List[List]: + """Get OHLCV (candlestick) data. + + Args: + symbol: Trading pair symbol + timeframe: Timeframe (1m, 5m, 15m, 1h, 1d, etc.) + since: Start datetime + limit: Number of candles + + Returns: + List of [timestamp, open, high, low, close, volume] + """ + pass + + @abstractmethod + async def subscribe_ticker(self, symbol: str, callback): + """Subscribe to ticker updates via WebSocket. + + Args: + symbol: Trading pair symbol + callback: Callback function(ticker_data) + """ + pass + + @abstractmethod + async def subscribe_orderbook(self, symbol: str, callback): + """Subscribe to order book updates via WebSocket. + + Args: + symbol: Trading pair symbol + callback: Callback function(orderbook_data) + """ + pass + + @abstractmethod + async def subscribe_trades(self, symbol: str, callback): + """Subscribe to trade updates via WebSocket. + + Args: + symbol: Trading pair symbol + callback: Callback function(trade_data) + """ + pass + + def validate_order(self, order: Order) -> bool: + """Validate order before placing. + + Args: + order: Order to validate + + Returns: + True if order is valid + """ + if self.read_only: + self.logger.warning("Exchange is in read-only mode") + return False + + if not order.symbol: + self.logger.error("Order symbol is required") + return False + + if order.quantity <= 0: + self.logger.error("Order quantity must be positive") + return False + + if order.order_type == 'limit' and not order.price: + self.logger.error("Limit orders require a price") + return False + + return True + + def normalize_symbol(self, symbol: str) -> str: + """Normalize symbol format. + + Args: + symbol: Symbol to normalize + + Returns: + Normalized symbol + """ + # Default implementation - override in subclasses if needed + return symbol.upper().replace('-', '/') + + def get_fee_structure(self) -> Dict[str, float]: + """Get fee structure (maker/taker fees). + + Returns: + Dictionary with 'maker' and 'taker' fee percentages, and optional 'minimum' + """ + # Default fees - override in subclasses + return { + 'maker': 0.001, # 0.1% + 'taker': 0.001, # 0.1% + 'minimum': 0.0, # Minimum fee amount + } + + def extract_fee_from_order_response(self, order_response: Dict[str, Any]) -> Optional[Decimal]: + """Extract actual fee from order response. + + Args: + order_response: Order response from exchange API + + Returns: + Fee amount as Decimal, or None if not available + """ + # Default implementation - override in subclasses + # Try common fee fields + if 'fee' in order_response: + try: + return Decimal(str(order_response['fee'])) + except (ValueError, TypeError): + pass + + if 'fees' in order_response: + # Some exchanges return fees as a list + fees = order_response['fees'] + if isinstance(fees, list) and len(fees) > 0: + try: + # Sum all fees + total_fee = sum(Decimal(str(f.get('cost', 0) if isinstance(f, dict) else f)) for f in fees) + return total_fee + except (ValueError, TypeError): + pass + + # Try 'cost' field (some exchanges use this) + if 'cost' in order_response: + try: + return Decimal(str(order_response['cost'])) + except (ValueError, TypeError): + pass + + return None + diff --git a/src/exchanges/coinbase.py b/src/exchanges/coinbase.py new file mode 100644 index 00000000..eaaf7ef6 --- /dev/null +++ b/src/exchanges/coinbase.py @@ -0,0 +1,392 @@ +"""Coinbase Advanced Trade API adapter with WebSocket support.""" + +import asyncio +import ccxt +import websockets +import json +from decimal import Decimal +from typing import Dict, List, Optional, Any, Callable +from datetime import datetime +from .base import BaseExchangeAdapter, Order +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class CoinbaseAdapter(BaseExchangeAdapter): + """Coinbase Advanced Trade API adapter.""" + + @property + def name(self) -> str: + """Exchange name.""" + return "Coinbase" + + def __init__(self, api_key: str, api_secret: str, sandbox: bool = False, read_only: bool = True): + """Initialize Coinbase adapter.""" + super().__init__(api_key, api_secret, sandbox, read_only) + + # Initialize ccxt exchange with async support + exchange_class = ccxt.async_support.coinbase + self.exchange = exchange_class({ + 'apiKey': api_key, + 'secret': api_secret, + 'sandbox': sandbox, + 'enableRateLimit': True, + 'options': { + 'defaultType': 'spot', # or 'future' for futures + } + }) + + # WebSocket connections + self._ws_connections = {} + self._ws_callbacks = {} + self._ws_loop = None + + async def connect(self) -> bool: + """Connect to exchange.""" + try: + # Test connection by fetching account info + if self.read_only: + # Just verify credentials work + await self.exchange.fetch_balance() + else: + # Verify trading permissions + await self.exchange.fetch_balance() + + self._connected = True + logger.info(f"Connected to {self.name} (sandbox={self.sandbox}, read_only={self.read_only})") + return True + except Exception as e: + logger.error(f"Failed to connect to {self.name}: {e}") + self._connected = False + return False + + async def disconnect(self): + """Disconnect from exchange.""" + # Close WebSocket connections + for ws in self._ws_connections.values(): + try: + # await ws.close() # If using real websockets + pass + except Exception: + pass + + await self.exchange.close() + + self._ws_connections.clear() + self._ws_callbacks.clear() + self._connected = False + logger.info(f"Disconnected from {self.name}") + + async def get_balance(self, currency: Optional[str] = None) -> Dict[str, Decimal]: + """Get account balance.""" + try: + balance = await self.exchange.fetch_balance() + result = {} + + for curr, amounts in balance.items(): + if isinstance(amounts, dict) and 'free' in amounts: + free = Decimal(str(amounts['free'])) + if free > 0 or currency == curr: + result[curr] = free + + if currency: + return {currency: result.get(currency, Decimal(0))} + + return result + except Exception as e: + logger.error(f"Failed to get balance: {e}") + return {} + + async def get_ticker(self, symbol: str) -> Dict[str, Any]: + """Get current ticker.""" + try: + ticker = await self.exchange.fetch_ticker(self.normalize_symbol(symbol)) + return { + 'symbol': symbol, + 'bid': Decimal(str(ticker.get('bid', 0))), + 'ask': Decimal(str(ticker.get('ask', 0))), + 'last': Decimal(str(ticker.get('last', 0))), + 'high': Decimal(str(ticker.get('high', 0))), + 'low': Decimal(str(ticker.get('low', 0))), + 'volume': Decimal(str(ticker.get('quoteVolume', 0))), + 'timestamp': ticker.get('timestamp'), + } + except Exception as e: + logger.error(f"Failed to get ticker for {symbol}: {e}") + return {} + + async def get_orderbook(self, symbol: str, limit: int = 20) -> Dict[str, List]: + """Get order book.""" + try: + orderbook = await self.exchange.fetch_order_book(self.normalize_symbol(symbol), limit) + return { + 'bids': [[Decimal(str(b[0])), Decimal(str(b[1]))] for b in orderbook['bids']], + 'asks': [[Decimal(str(a[0])), Decimal(str(a[1]))] for a in orderbook['asks']], + } + except Exception as e: + logger.error(f"Failed to get orderbook for {symbol}: {e}") + return {'bids': [], 'asks': []} + + async def place_order(self, order: Order) -> Dict[str, Any]: + """Place an order.""" + if not self.validate_order(order): + return {'error': 'Invalid order'} + + try: + symbol = self.normalize_symbol(order.symbol) + + if order.order_type == 'market': + result = await self.exchange.create_market_order( + symbol, + order.side, + float(order.quantity) + ) + elif order.order_type == 'limit': + result = await self.exchange.create_limit_order( + symbol, + order.side, + float(order.quantity), + float(order.price) + ) + else: + return {'error': f'Unsupported order type: {order.order_type}'} + + # Extract fee from result + fee = self.extract_fee_from_order_response(result) + + return { + 'id': result.get('id'), + 'symbol': symbol, + 'status': result.get('status', 'open'), + 'side': result.get('side'), + 'type': result.get('type'), + 'amount': Decimal(str(result.get('amount', 0))), + 'price': Decimal(str(result.get('price', 0))) if result.get('price') else None, + 'fee': fee, # Include fee in response + } + except Exception as e: + logger.error(f"Failed to place order: {e}") + return {'error': str(e)} + + async def cancel_order(self, order_id: str, symbol: str) -> bool: + """Cancel an order.""" + try: + await self.exchange.cancel_order(order_id, self.normalize_symbol(symbol)) + return True + except Exception as e: + logger.error(f"Failed to cancel order {order_id}: {e}") + return False + + async def get_order_status(self, order_id: str, symbol: str) -> Dict[str, Any]: + """Get order status.""" + try: + order = await self.exchange.fetch_order(order_id, self.normalize_symbol(symbol)) + + # Extract fee from order + fee = self.extract_fee_from_order_response(order) + + return { + 'id': order.get('id'), + 'symbol': symbol, + 'status': order.get('status'), + 'side': order.get('side'), + 'type': order.get('type'), + 'amount': Decimal(str(order.get('amount', 0))), + 'filled': Decimal(str(order.get('filled', 0))), + 'remaining': Decimal(str(order.get('remaining', 0))), + 'price': Decimal(str(order.get('price', 0))) if order.get('price') else None, + 'average': Decimal(str(order.get('average', 0))) if order.get('average') else None, + 'fee': fee, # Include fee in response + } + except Exception as e: + logger.error(f"Failed to get order status {order_id}: {e}") + return {} + + async def get_open_orders(self, symbol: Optional[str] = None) -> List[Dict[str, Any]]: + """Get open orders.""" + try: + if symbol: + orders = await self.exchange.fetch_open_orders(self.normalize_symbol(symbol)) + else: + orders = await self.exchange.fetch_open_orders() + + return [ + { + 'id': o.get('id'), + 'symbol': o.get('symbol'), + 'status': o.get('status'), + 'side': o.get('side'), + 'type': o.get('type'), + 'amount': Decimal(str(o.get('amount', 0))), + 'filled': Decimal(str(o.get('filled', 0))), + 'price': Decimal(str(o.get('price', 0))) if o.get('price') else None, + } + for o in orders + ] + except Exception as e: + logger.error(f"Failed to get open orders: {e}") + return [] + + async def get_positions(self, symbol: Optional[str] = None) -> List[Dict[str, Any]]: + """Get open positions.""" + try: + # Coinbase Advanced Trade uses fetch_positions for futures + # For spot, positions are derived from balances + positions = await self.exchange.fetch_positions(symbols=[symbol] if symbol else None) + + return [ + { + 'symbol': p.get('symbol'), + 'side': p.get('side'), + 'size': Decimal(str(p.get('size', 0))), + 'entry_price': Decimal(str(p.get('entryPrice', 0))), + 'mark_price': Decimal(str(p.get('markPrice', 0))), + 'unrealized_pnl': Decimal(str(p.get('unrealizedPnl', 0))), + } + for p in positions if p.get('size', 0) != 0 + ] + except Exception as e: + logger.error(f"Failed to get positions: {e}") + return [] + + async def get_ohlcv( + self, + symbol: str, + timeframe: str = '1h', + since: Optional[datetime] = None, + limit: int = 100 + ) -> List[List]: + """Get OHLCV data.""" + try: + since_timestamp = int(since.timestamp() * 1000) if since else None + ohlcv = await self.exchange.fetch_ohlcv( + self.normalize_symbol(symbol), + timeframe, + since_timestamp, + limit + ) + + return ohlcv + except Exception as e: + logger.error(f"Failed to get OHLCV for {symbol}: {e}") + return [] + + def subscribe_ticker(self, symbol: str, callback: Callable): + """Subscribe to ticker updates via WebSocket.""" + try: + import asyncio + import websockets + import json + + # Normalize symbol for Coinbase + normalized_symbol = self.normalize_symbol(symbol) + + # Store callback + self._ws_callbacks[f'ticker_{symbol}'] = callback + + # Start WebSocket connection if not already started + if not hasattr(self, '_ws_running') or not self._ws_running: + self._start_websocket_loop() + + logger.info(f"Subscribed to ticker updates for {symbol}") + except ImportError: + logger.warning("websockets library not available, using polling fallback") + self._ws_callbacks[f'ticker_{symbol}'] = callback + except Exception as e: + logger.error(f"Failed to subscribe to ticker: {e}") + self._ws_callbacks[f'ticker_{symbol}'] = callback + + def subscribe_orderbook(self, symbol: str, callback: Callable): + """Subscribe to order book updates via WebSocket.""" + try: + normalized_symbol = self.normalize_symbol(symbol) + self._ws_callbacks[f'orderbook_{symbol}'] = callback + + if not hasattr(self, '_ws_running') or not self._ws_running: + self._start_websocket_loop() + + logger.info(f"Subscribed to orderbook updates for {symbol}") + except Exception as e: + logger.error(f"Failed to subscribe to orderbook: {e}") + self._ws_callbacks[f'orderbook_{symbol}'] = callback + + def subscribe_trades(self, symbol: str, callback: Callable): + """Subscribe to trade updates via WebSocket.""" + try: + normalized_symbol = self.normalize_symbol(symbol) + self._ws_callbacks[f'trades_{symbol}'] = callback + + if not hasattr(self, '_ws_running') or not self._ws_running: + self._start_websocket_loop() + + logger.info(f"Subscribed to trades updates for {symbol}") + except Exception as e: + logger.error(f"Failed to subscribe to trades: {e}") + self._ws_callbacks[f'trades_{symbol}'] = callback + + def _start_websocket_loop(self): + """Start WebSocket connection loop.""" + try: + import threading + self._ws_running = True + # Start WebSocket in background thread + # Note: Full implementation would use asyncio event loop + logger.info("WebSocket connection started (basic implementation)") + except Exception as e: + logger.error(f"Failed to start WebSocket: {e}") + self._ws_running = False + + def normalize_symbol(self, symbol: str) -> str: + """Normalize symbol for Coinbase.""" + # Coinbase uses format like BTC-USD + return symbol.replace('/', '-').upper() + + def get_fee_structure(self) -> Dict[str, float]: + """Get Coinbase fee structure.""" + # Coinbase Advanced Trade fees (approximate) + # Actual fees may vary based on trading volume and account type + return { + 'maker': 0.004, # 0.4% + 'taker': 0.006, # 0.6% + 'minimum': 0.0, # No minimum fee + } + + def extract_fee_from_order_response(self, order_response: Dict[str, Any]) -> Optional[Decimal]: + """Extract actual fee from Coinbase order response. + + Args: + order_response: Order response from Coinbase API + + Returns: + Fee amount as Decimal, or None if not available + """ + # Coinbase/ccxt typically returns fees in 'fee' field or 'fees' list + if 'fee' in order_response and order_response['fee']: + try: + fee_data = order_response['fee'] + if isinstance(fee_data, dict): + # Fee is often {'cost': amount, 'currency': 'USD'} + return Decimal(str(fee_data.get('cost', 0))) + else: + return Decimal(str(fee_data)) + except (ValueError, TypeError): + pass + + # Try 'fees' list + if 'fees' in order_response: + fees = order_response['fees'] + if isinstance(fees, list) and len(fees) > 0: + try: + total_fee = Decimal(0) + for fee_item in fees: + if isinstance(fee_item, dict): + total_fee += Decimal(str(fee_item.get('cost', 0))) + else: + total_fee += Decimal(str(fee_item)) + return total_fee if total_fee > 0 else None + except (ValueError, TypeError): + pass + + return None + diff --git a/src/exchanges/factory.py b/src/exchanges/factory.py new file mode 100644 index 00000000..3edc0f53 --- /dev/null +++ b/src/exchanges/factory.py @@ -0,0 +1,165 @@ +"""Exchange factory for creating exchange adapters.""" + +from typing import Optional, Dict, Type, List +from src.core.database import get_database, Exchange +from src.core.logger import get_logger +from src.security.key_manager import get_key_manager +from .base import BaseExchangeAdapter + +logger = get_logger(__name__) + + +class ExchangeFactory: + """Factory for creating exchange adapter instances.""" + + _adapters: Dict[str, Type[BaseExchangeAdapter]] = {} + + @classmethod + def register(cls, name: str, adapter_class: Type[BaseExchangeAdapter]): + """Register an exchange adapter. + + Args: + name: Exchange name + adapter_class: Adapter class + """ + cls._adapters[name.lower()] = adapter_class + logger.info(f"Registered exchange adapter: {name}") + + @classmethod + async def create(cls, exchange_id: int) -> Optional[BaseExchangeAdapter]: + """Create exchange adapter from database. + + Args: + exchange_id: Exchange ID from database + + Returns: + Exchange adapter instance or None + """ + from sqlalchemy import select + db = get_database() + key_manager = get_key_manager() + + async with db.get_session() as session: + try: + stmt = select(Exchange).where(Exchange.id == exchange_id) + result = await session.execute(stmt) + exchange = result.scalar_one_or_none() + + if not exchange: + logger.error(f"Exchange {exchange_id} not found") + return None + + if not exchange.enabled: + logger.warning(f"Exchange {exchange.name} is disabled") + return None + + # Get adapter class + adapter_class = cls._adapters.get(exchange.name.lower()) + if not adapter_class: + logger.error(f"No adapter registered for exchange: {exchange.name}") + return None + + # Check if this is a public data adapter (doesn't need credentials) + from .public_data import PublicDataAdapter + is_public_data = adapter_class == PublicDataAdapter + + if is_public_data: + # Public data adapter doesn't need credentials + adapter = adapter_class( + api_key="", + api_secret="", + sandbox=False, + read_only=True + ) + else: + # Get credentials for regular exchanges + credentials = await key_manager.get_exchange_credentials(exchange_id) + if not credentials: + logger.error(f"No credentials found for exchange: {exchange.name}") + return None + + # Create adapter instance + adapter = adapter_class( + api_key=credentials['api_key'], + api_secret=credentials['api_secret'], + sandbox=credentials['sandbox'], + read_only=credentials['read_only'] + ) + + # Connect + if await adapter.connect(): + logger.info(f"Connected to {exchange.name}") + return adapter + else: + logger.error(f"Failed to connect to {exchange.name}") + return None + + except Exception as e: + logger.error(f"Failed to create exchange adapter: {e}") + return None + + @classmethod + async def create_by_name( + cls, + name: str, + api_key: str, + api_secret: str, + sandbox: bool = False, + read_only: bool = True + ) -> Optional[BaseExchangeAdapter]: + """Create exchange adapter directly. + + Args: + name: Exchange name + api_key: API key + api_secret: API secret + sandbox: Use sandbox/testnet + read_only: Read-only mode + + Returns: + Exchange adapter instance or None + """ + adapter_class = cls._adapters.get(name.lower()) + if not adapter_class: + logger.error(f"No adapter registered for exchange: {name}") + return None + + try: + adapter = adapter_class( + api_key=api_key, + api_secret=api_secret, + sandbox=sandbox, + read_only=read_only + ) + + if await adapter.connect(): + return adapter + else: + logger.error(f"Failed to connect to {name}") + return None + except Exception as e: + logger.error(f"Failed to create exchange adapter: {e}") + return None + + @classmethod + def list_available(cls) -> List[str]: + """List available exchange adapters. + + Returns: + List of exchange names + """ + return list(cls._adapters.keys()) + + +# Convenience function +async def get_exchange(exchange_id: int) -> Optional[BaseExchangeAdapter]: + """Get exchange adapter by ID. + + Args: + exchange_id: Exchange ID + + Returns: + Exchange adapter instance or None + """ + return await ExchangeFactory.create(exchange_id) + diff --git a/src/exchanges/public_data.py b/src/exchanges/public_data.py new file mode 100644 index 00000000..6fd35fb3 --- /dev/null +++ b/src/exchanges/public_data.py @@ -0,0 +1,433 @@ +"""Public Data Exchange Adapter - Free market data without API keys. + +Uses CCXT in public mode to fetch market data from Binance (or other exchanges) +without requiring API keys. Perfect for testing, backtesting, and paper trading. +""" + +import ccxt.async_support as ccxt +from decimal import Decimal +from typing import Dict, List, Optional, Any, Callable +from datetime import datetime +from .base import BaseExchangeAdapter, Order +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class PublicDataAdapter(BaseExchangeAdapter): + """Public market data adapter using CCXT in public mode (no API keys needed). + + This adapter uses Binance's public API to fetch: + - Historical OHLCV data + - Real-time ticker data + - Order book data + - Trade data + + No API keys required - perfect for testing and paper trading. + """ + + @property + def name(self) -> str: + """Exchange name.""" + return self._selected_exchange_name or "Public Data" + + def __init__( + self, + api_key: str = "", + api_secret: str = "", + sandbox: bool = False, + read_only: bool = True + ): + """Initialize Public Data adapter. + + Args: + api_key: Ignored (public data doesn't need API keys) + api_secret: Ignored (public data doesn't need API keys) + sandbox: Ignored (Binance public API is always live data) + read_only: Always True (this adapter is read-only by design) + """ + super().__init__("", "", False, True) # Always read-only, no keys needed + + # List of exchanges to try as fallbacks (in order of preference) + # These exchanges have good public API access without geographic restrictions + self._exchange_options = [ + ('kraken', 'Kraken Public'), + ('coinbase', 'Coinbase Public'), + ('binance', 'Binance Public'), # Try Binance last since it has geo restrictions + ] + + self.exchange = None + self._selected_exchange_name = None + + # WebSocket connections for real-time data + self._ws_callbacks = {} + self._polling_timer = None + + async def connect(self) -> bool: + """Connect to exchange (test public API access). + + Tries multiple exchanges as fallbacks if one is blocked. + + Returns: + True if connection successful (public API is always available) + """ + # Try each exchange until one works + for exchange_id, exchange_display_name in self._exchange_options: + try: + # Create exchange instance + exchange_class = getattr(ccxt, exchange_id, None) + if not exchange_class: + continue + + self.exchange = exchange_class({ + 'enableRateLimit': True, + 'options': { + 'defaultType': 'spot', + } + }) + + # Load markets if not already loaded + if not hasattr(self.exchange, 'markets') or not self.exchange.markets: + try: + await self.exchange.load_markets() + except Exception as e: + raise + + # Test connection with a common symbol + # Different exchanges use different symbol formats + test_symbols = ['BTC/USDT', 'BTC/USD', 'BTC/EUR'] + ticker_result = None + + for test_symbol in test_symbols: + try: + ticker_result = await self.exchange.fetch_ticker(test_symbol) + break + except Exception as e: + continue + + if not ticker_result: + raise Exception("Could not fetch ticker from any test symbol") + + # Success! Use this exchange + self._selected_exchange_name = exchange_display_name + self._connected = True + logger.info(f"Connected to {self.name} (public data, no API keys needed)") + + return True + + except Exception as e: + logger.warning(f"Failed to connect to {exchange_id}: {e}, trying next exchange...") + continue + + # All exchanges failed + logger.error("Failed to connect to any public exchange") + self._connected = False + return False + + async def disconnect(self): + """Disconnect from exchange.""" + self._ws_callbacks.clear() + if self._polling_timer: + self._polling_timer.cancel() + self._polling_timer = None + + if self.exchange: + await self.exchange.close() + + self._connected = False + logger.info(f"Disconnected from {self.name}") + + async def get_balance(self, currency: Optional[str] = None) -> Dict[str, Decimal]: + """Get account balance. + + Note: Public data adapter cannot access account balances. + Returns empty dict. Use paper trading balance instead. + """ + logger.warning("Public data adapter cannot access account balances") + return {} + + async def get_ticker(self, symbol: str) -> Dict[str, Any]: + """Get current ticker (public endpoint).""" + try: + ticker = await self.exchange.fetch_ticker(self.normalize_symbol(symbol)) + return { + 'symbol': symbol, + 'bid': Decimal(str(ticker.get('bid', 0))), + 'ask': Decimal(str(ticker.get('ask', 0))), + 'last': Decimal(str(ticker.get('last', 0))), + 'high': Decimal(str(ticker.get('high', 0))), + 'low': Decimal(str(ticker.get('low', 0))), + 'volume': Decimal(str(ticker.get('quoteVolume', 0))), + 'timestamp': ticker.get('timestamp'), + } + except Exception as e: + logger.error(f"Failed to get ticker for {symbol}: {e}") + return {} + + async def get_orderbook(self, symbol: str, limit: int = 20) -> Dict[str, List]: + """Get order book (public endpoint).""" + try: + orderbook = await self.exchange.fetch_order_book(self.normalize_symbol(symbol), limit) + return { + 'bids': [[Decimal(str(b[0])), Decimal(str(b[1]))] for b in orderbook['bids']], + 'asks': [[Decimal(str(a[0])), Decimal(str(a[1]))] for a in orderbook['asks']], + } + except Exception as e: + logger.error(f"Failed to get orderbook for {symbol}: {e}") + return {'bids': [], 'asks': []} + + async def place_order(self, order: Order) -> Dict[str, Any]: + """Place an order. + + Note: Public data adapter cannot place orders. + Returns error message. Use paper trading instead. + """ + logger.warning("Public data adapter cannot place orders - use paper trading") + return {'error': 'Public data adapter is read-only. Use paper trading for order execution.'} + + async def cancel_order(self, order_id: str, symbol: str) -> bool: + """Cancel an order. + + Note: Public data adapter cannot cancel orders. + """ + logger.warning("Public data adapter cannot cancel orders") + return False + + async def get_order_status(self, order_id: str, symbol: str) -> Dict[str, Any]: + """Get order status. + + Note: Public data adapter cannot access order status. + """ + logger.warning("Public data adapter cannot access order status") + return {} + + async def get_open_orders(self, symbol: Optional[str] = None) -> List[Dict[str, Any]]: + """Get open orders. + + Note: Public data adapter cannot access orders. + """ + logger.warning("Public data adapter cannot access orders") + return [] + + async def get_positions(self, symbol: Optional[str] = None) -> List[Dict[str, Any]]: + """Get open positions. + + Note: Public data adapter cannot access positions. + """ + logger.warning("Public data adapter cannot access positions") + return [] + + async def get_ohlcv( + self, + symbol: str, + timeframe: str = '1h', + since: Optional[datetime] = None, + limit: int = 100 + ) -> List[List]: + """Get OHLCV data (public endpoint). + + This is the main method for fetching historical data. + Most exchanges support up to 1000 candles per request. + """ + try: + since_timestamp = int(since.timestamp() * 1000) if since else None + + # Most exchanges support up to 1000 candles per request + # If limit > 1000, we'll need to make multiple requests + max_limit = min(limit, 1000) + + normalized_symbol = self.normalize_symbol(symbol) + + ohlcv = await self.exchange.fetch_ohlcv( + normalized_symbol, + timeframe, + since_timestamp, + max_limit + ) + + logger.info(f"Fetched {len(ohlcv)} candles for {symbol} ({timeframe})") + return ohlcv + except Exception as e: + logger.error(f"Failed to get OHLCV for {symbol}: {e}") + return [] + + def subscribe_ticker(self, symbol: str, callback: Callable): + """Subscribe to ticker updates (polling-based for public data). + + Since we don't have WebSocket auth, we poll the ticker endpoint. + """ + try: + import threading + import time + + normalized_symbol = self.normalize_symbol(symbol) + self._ws_callbacks[f'ticker_{symbol}'] = callback + + def poll_ticker(): + """Poll ticker every 2 seconds.""" + while f'ticker_{symbol}' in self._ws_callbacks: + try: + ticker = self.get_ticker(symbol) + if ticker and callback: + callback({ + 'price': ticker.get('last', 0), + 'bid': ticker.get('bid', 0), + 'ask': ticker.get('ask', 0), + 'volume': ticker.get('volume', 0), + 'timestamp': ticker.get('timestamp'), + }) + time.sleep(2) # Poll every 2 seconds + except Exception as e: + logger.error(f"Ticker polling error: {e}") + time.sleep(5) # Wait longer on error + + # Start polling thread + thread = threading.Thread(target=poll_ticker, daemon=True) + thread.start() + + logger.info(f"Subscribed to ticker updates for {symbol} (polling mode)") + except Exception as e: + logger.error(f"Failed to subscribe to ticker: {e}") + self._ws_callbacks[f'ticker_{symbol}'] = callback + + def subscribe_orderbook(self, symbol: str, callback: Callable): + """Subscribe to order book updates (polling-based).""" + try: + import threading + import time + + normalized_symbol = self.normalize_symbol(symbol) + self._ws_callbacks[f'orderbook_{symbol}'] = callback + + def poll_orderbook(): + """Poll order book every 1 second.""" + while f'orderbook_{symbol}' in self._ws_callbacks: + try: + orderbook = self.get_orderbook(symbol, limit=20) + if orderbook and callback: + callback(orderbook) + time.sleep(1) # Poll every second + except Exception as e: + logger.error(f"Orderbook polling error: {e}") + time.sleep(5) + + thread = threading.Thread(target=poll_orderbook, daemon=True) + thread.start() + + logger.info(f"Subscribed to orderbook updates for {symbol} (polling mode)") + except Exception as e: + logger.error(f"Failed to subscribe to orderbook: {e}") + self._ws_callbacks[f'orderbook_{symbol}'] = callback + + def subscribe_trades(self, symbol: str, callback: Callable): + """Subscribe to trade updates (polling-based).""" + try: + import threading + import time + + normalized_symbol = self.normalize_symbol(symbol) + self._ws_callbacks[f'trades_{symbol}'] = callback + + def poll_trades(): + """Poll recent trades every 1 second.""" + while f'trades_{symbol}' in self._ws_callbacks: + try: + trades = self.exchange.fetch_trades(normalized_symbol, limit=10) + if trades and callback: + for trade in trades[-5:]: # Last 5 trades + callback({ + 'price': trade.get('price', 0), + 'amount': trade.get('amount', 0), + 'side': trade.get('side', 'buy'), + 'timestamp': trade.get('timestamp'), + }) + time.sleep(1) + except Exception as e: + logger.error(f"Trades polling error: {e}") + time.sleep(5) + + thread = threading.Thread(target=poll_trades, daemon=True) + thread.start() + + logger.info(f"Subscribed to trades updates for {symbol} (polling mode)") + except Exception as e: + logger.error(f"Failed to subscribe to trades: {e}") + self._ws_callbacks[f'trades_{symbol}'] = callback + + def normalize_symbol(self, symbol: str) -> str: + """Normalize symbol for the selected exchange. + + Different exchanges use different symbol formats: + - Binance/Kraken: BTC/USDT, BTC/USD + - Some exchanges: BTC-USDT, BTC-USD + - Kraken sometimes uses: XBT/USD instead of BTC/USD + """ + if not self.exchange: + return symbol.replace('-', '/').upper() + + # Basic normalization: convert dashes to slashes, uppercase + normalized = symbol.replace('-', '/').upper() + + # Try to use exchange's built-in symbol normalization + try: + if hasattr(self.exchange, 'markets') and self.exchange.markets: + # Check if normalized symbol exists in markets + if normalized in self.exchange.markets: + return normalized + + # Try alternative formats + # For USD pairs, try USDT (common on many exchanges) + if '/USD' in normalized: + alt_symbol = normalized.replace('/USD', '/USDT') + if alt_symbol in self.exchange.markets: + return alt_symbol + + # For Kraken: try XBT instead of BTC + if normalized.startswith('BTC/'): + alt_symbol = normalized.replace('BTC/', 'XBT/') + if alt_symbol in self.exchange.markets: + return alt_symbol + + # Try to find similar symbol (fuzzy match) + base = normalized.split('/')[0] if '/' in normalized else normalized + quote = normalized.split('/')[1] if '/' in normalized else 'USD' + + # Search for matching symbols + for market_symbol in self.exchange.markets.keys(): + if market_symbol.startswith(base + '/') or market_symbol.endswith('/' + quote): + return market_symbol + except Exception as e: + pass + + # Fallback: return normalized symbol (let CCXT handle errors) + return normalized + + def get_fee_structure(self) -> Dict[str, float]: + """Get fee structure for the selected exchange.""" + # Default fees (approximate spot trading fees) + if not self.exchange or not hasattr(self.exchange, 'id'): + return {'maker': 0.001, 'taker': 0.001} + + exchange_id = self.exchange.id if hasattr(self.exchange, 'id') else None + + # Exchange-specific fees (approximate) + fee_map = { + 'binance': {'maker': 0.001, 'taker': 0.001}, # 0.1% + 'kraken': {'maker': 0.0016, 'taker': 0.0026}, # 0.16% / 0.26% + 'coinbase': {'maker': 0.004, 'taker': 0.006}, # 0.4% / 0.6% + } + + return fee_map.get(exchange_id, {'maker': 0.001, 'taker': 0.001}) + + def get_available_symbols(self) -> List[str]: + """Get list of available trading symbols. + + Returns: + List of available symbol pairs (e.g., ['BTC/USDT', 'ETH/USDT', ...]) + """ + try: + markets = self.exchange.load_markets() + return list(markets.keys()) + except Exception as e: + logger.error(f"Failed to get available symbols: {e}") + return [] diff --git a/src/optimization/__init__.py b/src/optimization/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/optimization/bayesian.py b/src/optimization/bayesian.py new file mode 100644 index 00000000..a480203c --- /dev/null +++ b/src/optimization/bayesian.py @@ -0,0 +1,76 @@ +"""Bayesian optimization.""" + +from typing import Dict, Any, Callable +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class BayesianOptimizer: + """Bayesian optimization using scikit-optimize.""" + + def __init__(self): + """Initialize Bayesian optimizer.""" + self.logger = get_logger(__name__) + + def optimize( + self, + param_space: Dict[str, Any], + objective_function: Callable[[Dict[str, Any]], float], + n_calls: int = 50, + maximize: bool = True + ) -> Dict[str, Any]: + """Run Bayesian optimization. + + Args: + param_space: Parameter space definition + objective_function: Objective function + n_calls: Number of optimization iterations + maximize: True to maximize, False to minimize + + Returns: + Best parameters and score + """ + try: + from skopt import gp_minimize + from skopt.space import Real, Integer + + # Convert param_space to skopt format + dimensions = [] + param_names = [] + for name, space in param_space.items(): + param_names.append(name) + if isinstance(space, tuple): + if isinstance(space[0], int): + dimensions.append(Integer(space[0], space[1], name=name)) + else: + dimensions.append(Real(space[0], space[1], name=name)) + + # Wrapper function + def objective(params): + param_dict = dict(zip(param_names, params)) + score = objective_function(param_dict) + return -score if maximize else score + + result = gp_minimize( + objective, + dimensions, + n_calls=n_calls, + random_state=42 + ) + + best_params = dict(zip(param_names, result.x)) + best_score = -result.fun if maximize else result.fun + + return { + "best_params": best_params, + "best_score": best_score, + } + except ImportError: + logger.warning("scikit-optimize not available, using grid search fallback") + from .grid_search import GridSearchOptimizer + optimizer = GridSearchOptimizer() + # Convert param_space to grid format + param_grid = {k: [v[0], v[1], (v[0] + v[1]) / 2] for k, v in param_space.items()} + return optimizer.optimize(param_grid, objective_function, maximize) + diff --git a/src/optimization/genetic.py b/src/optimization/genetic.py new file mode 100644 index 00000000..66e14fbc --- /dev/null +++ b/src/optimization/genetic.py @@ -0,0 +1,111 @@ +"""Genetic algorithm optimization.""" + +from typing import Dict, List, Any, Callable +import random +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class GeneticOptimizer: + """Genetic algorithm parameter optimization.""" + + def __init__(self): + """Initialize genetic optimizer.""" + self.logger = get_logger(__name__) + + def optimize( + self, + param_ranges: Dict[str, tuple], + objective_function: Callable[[Dict[str, Any]], float], + population_size: int = 50, + generations: int = 100, + maximize: bool = True + ) -> Dict[str, Any]: + """Run genetic algorithm optimization. + + Args: + param_ranges: Dictionary of parameter -> (min, max) range + objective_function: Objective function + population_size: Population size + generations: Number of generations + maximize: True to maximize, False to minimize + + Returns: + Best parameters and score + """ + # Simplified genetic algorithm implementation + best_score = float('-inf') if maximize else float('inf') + best_params = None + + # Initialize population + population = self._initialize_population(param_ranges, population_size) + + for generation in range(generations): + # Evaluate fitness + fitness_scores = [] + for individual in population: + try: + score = objective_function(individual) + fitness_scores.append((individual, score)) + except Exception: + continue + + # Sort by fitness + fitness_scores.sort(key=lambda x: x[1], reverse=maximize) + + # Update best + if fitness_scores: + best_individual, best_gen_score = fitness_scores[0] + if (maximize and best_gen_score > best_score) or (not maximize and best_gen_score < best_score): + best_score = best_gen_score + best_params = best_individual.copy() + + # Create next generation (simplified) + population = self._evolve_population(fitness_scores, param_ranges, population_size) + + return { + "best_params": best_params, + "best_score": best_score, + } + + def _initialize_population(self, param_ranges: Dict, size: int) -> List[Dict]: + """Initialize random population.""" + population = [] + for _ in range(size): + individual = {} + for param, (min_val, max_val) in param_ranges.items(): + if isinstance(min_val, int): + individual[param] = random.randint(min_val, max_val) + else: + individual[param] = random.uniform(min_val, max_val) + population.append(individual) + return population + + def _evolve_population(self, fitness_scores: List, param_ranges: Dict, size: int) -> List[Dict]: + """Evolve population (crossover and mutation).""" + # Simplified evolution + new_population = [] + elite_size = size // 10 + + # Keep elite + for i in range(min(elite_size, len(fitness_scores))): + new_population.append(fitness_scores[i][0].copy()) + + # Generate rest through mutation + while len(new_population) < size: + parent = random.choice(fitness_scores[:size//2])[0] + child = parent.copy() + + # Mutate + param = random.choice(list(param_ranges.keys())) + min_val, max_val = param_ranges[param] + if isinstance(min_val, int): + child[param] = random.randint(min_val, max_val) + else: + child[param] = random.uniform(min_val, max_val) + + new_population.append(child) + + return new_population[:size] + diff --git a/src/optimization/grid_search.py b/src/optimization/grid_search.py new file mode 100644 index 00000000..f31770da --- /dev/null +++ b/src/optimization/grid_search.py @@ -0,0 +1,57 @@ +"""Grid search optimization.""" + +from typing import Dict, List, Any, Callable +from itertools import product +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class GridSearchOptimizer: + """Grid search parameter optimization.""" + + def __init__(self): + """Initialize grid search optimizer.""" + self.logger = get_logger(__name__) + + def optimize( + self, + param_grid: Dict[str, List[Any]], + objective_function: Callable[[Dict[str, Any]], float], + maximize: bool = True + ) -> Dict[str, Any]: + """Run grid search optimization. + + Args: + param_grid: Dictionary of parameter -> list of values + objective_function: Function that takes parameters and returns score + maximize: True to maximize, False to minimize + + Returns: + Best parameters and score + """ + best_score = float('-inf') if maximize else float('inf') + best_params = None + + # Generate all parameter combinations + param_names = list(param_grid.keys()) + param_values = list(param_grid.values()) + + for combination in product(*param_values): + params = dict(zip(param_names, combination)) + + try: + score = objective_function(params) + + if (maximize and score > best_score) or (not maximize and score < best_score): + best_score = score + best_params = params + except Exception as e: + logger.warning(f"Failed to evaluate parameters {params}: {e}") + continue + + return { + "best_params": best_params, + "best_score": best_score, + } + diff --git a/src/portfolio/__init__.py b/src/portfolio/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/portfolio/analytics.py b/src/portfolio/analytics.py new file mode 100644 index 00000000..2803e0af --- /dev/null +++ b/src/portfolio/analytics.py @@ -0,0 +1,265 @@ +"""Advanced portfolio analytics (Sharpe ratio, Sortino, drawdown analysis, performance charts).""" + +import numpy as np +import pandas as pd +from decimal import Decimal +from typing import Dict, List, Any, Optional +from datetime import datetime, timedelta +from sqlalchemy import select +from src.core.database import get_database, PortfolioSnapshot, Trade +from src.core.logger import get_logger +from .tracker import get_portfolio_tracker + +logger = get_logger(__name__) + + +class PortfolioAnalytics: + """Advanced portfolio analytics.""" + + def __init__(self): + """Initialize portfolio analytics.""" + self.db = get_database() + self.tracker = get_portfolio_tracker() + self.logger = get_logger(__name__) + + def calculate_sharpe_ratio( + self, + returns: pd.Series, + risk_free_rate: float = 0.0, + periods_per_year: int = 252 + ) -> float: + """Calculate Sharpe ratio. + + Args: + returns: Series of returns + risk_free_rate: Risk-free rate (annual) + periods_per_year: Trading periods per year + + Returns: + Sharpe ratio + """ + if len(returns) == 0 or returns.std() == 0: + return 0.0 + + excess_returns = returns - (risk_free_rate / periods_per_year) + return float(np.sqrt(periods_per_year) * excess_returns.mean() / returns.std()) + + def calculate_sortino_ratio( + self, + returns: pd.Series, + risk_free_rate: float = 0.0, + periods_per_year: int = 252 + ) -> float: + """Calculate Sortino ratio. + + Args: + returns: Series of returns + risk_free_rate: Risk-free rate (annual) + periods_per_year: Trading periods per year + + Returns: + Sortino ratio + """ + if len(returns) == 0: + return 0.0 + + excess_returns = returns - (risk_free_rate / periods_per_year) + downside_returns = returns[returns < 0] + + if len(downside_returns) == 0 or downside_returns.std() == 0: + return 0.0 + + downside_std = downside_returns.std() + return float(np.sqrt(periods_per_year) * excess_returns.mean() / downside_std) + + def calculate_drawdown(self, values: pd.Series) -> Dict[str, Any]: + """Calculate drawdown metrics. + + Args: + values: Series of portfolio values + + Returns: + Dictionary with drawdown metrics + """ + if len(values) == 0: + return { + "max_drawdown": 0.0, + "current_drawdown": 0.0, + "drawdown_duration": 0, + } + + # Calculate running maximum + running_max = values.expanding().max() + drawdown = (values - running_max) / running_max + + max_drawdown = float(drawdown.min()) + current_drawdown = float(drawdown.iloc[-1]) + + # Calculate drawdown duration + in_drawdown = drawdown < 0 + drawdown_duration = 0 + if in_drawdown.iloc[-1]: + # Count consecutive days in drawdown + for i in range(len(in_drawdown) - 1, -1, -1): + if in_drawdown.iloc[i]: + drawdown_duration += 1 + else: + break + + return { + "max_drawdown": max_drawdown, + "current_drawdown": current_drawdown, + "drawdown_duration": drawdown_duration, + "drawdown_series": drawdown.tolist(), + } + + async def get_performance_metrics( + self, + days: int = 30, + paper_trading: bool = True + ) -> Dict[str, Any]: + """Get comprehensive performance metrics. + + Args: + days: Number of days to analyze + paper_trading: Paper trading flag + + Returns: + Dictionary of performance metrics + """ + history = await self.tracker.get_portfolio_history(days, paper_trading) + + if len(history) < 2: + return { + "total_return": 0.0, + "sharpe_ratio": 0.0, + "sortino_ratio": 0.0, + "max_drawdown": 0.0, + "win_rate": 0.0, + } + + # Convert to DataFrame + df = pd.DataFrame(history) + df['timestamp'] = pd.to_datetime(df['timestamp']) + df = df.set_index('timestamp').sort_index() + + # Calculate returns + returns = df['total_value'].pct_change().dropna() + + # Calculate metrics + initial_value = df['total_value'].iloc[0] + final_value = df['total_value'].iloc[-1] + total_return = (final_value - initial_value) / initial_value + + sharpe = self.calculate_sharpe_ratio(returns) + sortino = self.calculate_sortino_ratio(returns) + drawdown = self.calculate_drawdown(df['total_value']) + + # Calculate win rate from trades + win_rate = await self._calculate_win_rate(days, paper_trading) + + # Calculate fee metrics + fee_metrics = await self._calculate_fee_metrics(days, paper_trading, initial_value) + + return { + "total_return": float(total_return), + "total_return_percent": float(total_return * 100), + "sharpe_ratio": sharpe, + "sortino_ratio": sortino, + "max_drawdown": drawdown["max_drawdown"], + "current_drawdown": drawdown["current_drawdown"], + "win_rate": win_rate, + "initial_value": float(initial_value), + "final_value": float(final_value), + **fee_metrics, # Include fee metrics + } + + async def _calculate_fee_metrics( + self, + days: int, + paper_trading: bool, + initial_value: float + ) -> Dict[str, float]: + """Calculate fee-related metrics. + + Args: + days: Number of days + paper_trading: Paper trading flag + initial_value: Initial portfolio value + + Returns: + Dictionary of fee metrics + """ + try: + async with self.db.get_session() as session: + since = datetime.utcnow() - timedelta(days=days) + stmt = select(Trade).where( + Trade.paper_trading == paper_trading, + Trade.timestamp >= since + ) + result = await session.execute(stmt) + trades = result.scalars().all() + + total_fees = sum(float(trade.fee or 0) for trade in trades) + total_trades = len(trades) + avg_fee_per_trade = total_fees / total_trades if total_trades > 0 else 0.0 + + # Calculate fee percentage of initial value + fee_percentage = (total_fees / initial_value * 100) if initial_value > 0 else 0.0 + + return { + "total_fees": total_fees, + "avg_fee_per_trade": avg_fee_per_trade, + "fee_percentage": fee_percentage, + "total_trades_with_fees": total_trades, + } + except Exception as e: + logger.warning(f"Error calculating fee metrics: {e}") + return { + "total_fees": 0.0, + "avg_fee_per_trade": 0.0, + "fee_percentage": 0.0, + "total_trades_with_fees": 0, + } + + async def _calculate_win_rate(self, days: int, paper_trading: bool) -> float: + """Calculate win rate from trades. + + Args: + days: Number of days + paper_trading: Paper trading flag + + Returns: + Win rate (0.0 to 1.0) + """ + try: + async with self.db.get_session() as session: + since = datetime.utcnow() - timedelta(days=days) + stmt = select(Trade).where( + Trade.paper_trading == paper_trading, + Trade.timestamp >= since + ) + result = await session.execute(stmt) + trades = result.scalars().all() + + if len(trades) == 0: + return 0.0 + + # Simplified win rate calculation + # In practice, would need to match buy/sell pairs + return 0.5 # Placeholder + except Exception as e: + logger.warning(f"Error calculating win rate: {e}") + return 0.0 + + +# Global portfolio analytics +_portfolio_analytics: Optional[PortfolioAnalytics] = None + + +def get_portfolio_analytics() -> PortfolioAnalytics: + """Get global portfolio analytics instance.""" + global _portfolio_analytics + if _portfolio_analytics is None: + _portfolio_analytics = PortfolioAnalytics() + return _portfolio_analytics diff --git a/src/portfolio/tracker.py b/src/portfolio/tracker.py new file mode 100644 index 00000000..5bfc3f34 --- /dev/null +++ b/src/portfolio/tracker.py @@ -0,0 +1,144 @@ +"""Portfolio tracking with real-time P&L calculation.""" + +from decimal import Decimal +from datetime import datetime, timedelta +from typing import Dict, List, Optional, Any +from sqlalchemy import select +from src.core.database import get_database, Position, PortfolioSnapshot, Trade +from src.core.logger import get_logger +from src.trading.paper_trading import get_paper_trading + +logger = get_logger(__name__) + + +class PortfolioTracker: + """Tracks portfolio with real-time P&L calculation.""" + + def __init__(self): + """Initialize portfolio tracker.""" + self.db = get_database() + self.paper_trading = get_paper_trading() + self.logger = get_logger(__name__) + + async def get_current_portfolio(self, paper_trading: bool = True) -> Dict[str, Any]: + """Get current portfolio state. + + Args: + paper_trading: Paper trading flag + + Returns: + Portfolio dictionary + """ + if paper_trading: + performance = self.paper_trading.get_performance() + positions = self.paper_trading.get_positions() + else: + # Live trading - get from database + async with self.db.get_session() as session: + stmt = select(Position).where(Position.paper_trading == False) + result = await session.execute(stmt) + positions = result.scalars().all() + + # Calculate performance from positions + total_value = Decimal(0) + unrealized_pnl = Decimal(0) + for pos in positions: + if pos.current_price: + pos_value = pos.quantity * pos.current_price + total_value += pos_value + unrealized_pnl += (pos.current_price - pos.entry_price) * pos.quantity + + performance = { + "current_value": float(total_value), + "unrealized_pnl": float(unrealized_pnl), + "realized_pnl": float(sum(pos.realized_pnl for pos in positions)), + } + + return { + "positions": [ + { + "symbol": pos.symbol if hasattr(pos, 'symbol') else pos.symbol, + "quantity": float(pos.quantity), + "entry_price": float(pos.entry_price), + "current_price": float(pos.current_price) if pos.current_price else float(pos.entry_price), + "unrealized_pnl": float(pos.unrealized_pnl) if hasattr(pos, 'unrealized_pnl') else 0.0, + } + for pos in positions + ], + "performance": performance, + "timestamp": datetime.utcnow().isoformat(), + } + + async def update_positions_prices(self, prices: Dict[str, Decimal], paper_trading: bool = True): + """Update current prices for positions. + + Args: + prices: Dictionary of symbol -> current_price + paper_trading: Paper trading flag + """ + if paper_trading: + await self.paper_trading.update_positions_prices(prices) + else: + async with self.db.get_session() as session: + try: + stmt = select(Position).where(Position.paper_trading == False) + result = await session.execute(stmt) + positions = result.scalars().all() + + for pos in positions: + if pos.symbol in prices: + pos.current_price = prices[pos.symbol] + pos.unrealized_pnl = (prices[pos.symbol] - pos.entry_price) * pos.quantity + pos.updated_at = datetime.utcnow() + await session.commit() + except Exception as e: + await session.rollback() + logger.error(f"Failed to update positions prices: {e}") + + async def get_portfolio_history( + self, + days: int = 30, + paper_trading: bool = True + ) -> List[Dict[str, Any]]: + """Get portfolio history. + + Args: + days: Number of days + paper_trading: Paper trading flag + + Returns: + List of portfolio snapshots + """ + async with self.db.get_session() as session: + since = datetime.utcnow() - timedelta(days=days) + stmt = select(PortfolioSnapshot).where( + PortfolioSnapshot.paper_trading == paper_trading, + PortfolioSnapshot.timestamp >= since + ).order_by(PortfolioSnapshot.timestamp) + + result = await session.execute(stmt) + snapshots = result.scalars().all() + + return [ + { + "timestamp": snapshot.timestamp.isoformat(), + "total_value": float(snapshot.total_value), + "cash": float(snapshot.cash), + "positions_value": float(snapshot.positions_value), + "unrealized_pnl": float(snapshot.unrealized_pnl), + "realized_pnl": float(snapshot.realized_pnl), + } + for snapshot in snapshots + ] + + +# Global portfolio tracker +_portfolio_tracker: Optional[PortfolioTracker] = None + + +def get_portfolio_tracker() -> PortfolioTracker: + """Get global portfolio tracker instance.""" + global _portfolio_tracker + if _portfolio_tracker is None: + _portfolio_tracker = PortfolioTracker() + return _portfolio_tracker diff --git a/src/rebalancing/__init__.py b/src/rebalancing/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/rebalancing/engine.py b/src/rebalancing/engine.py new file mode 100644 index 00000000..46390158 --- /dev/null +++ b/src/rebalancing/engine.py @@ -0,0 +1,196 @@ +"""Portfolio rebalancing engine.""" + +from decimal import Decimal +from typing import Dict, List, Optional +from datetime import datetime +from sqlalchemy.orm import Session +from src.core.database import get_database, RebalancingEvent +from src.core.logger import get_logger +from src.portfolio.tracker import get_portfolio_tracker +from src.trading.engine import get_trading_engine + +logger = get_logger(__name__) + + +class RebalancingEngine: + """Portfolio rebalancing engine.""" + + def __init__(self): + """Initialize rebalancing engine.""" + self.db = get_database() + self.tracker = get_portfolio_tracker() + self.trading_engine = get_trading_engine() + self.logger = get_logger(__name__) + + def rebalance( + self, + target_allocations: Dict[str, float], + exchange_id: int, + paper_trading: bool = True + ) -> bool: + """Rebalance portfolio to target allocations. + + Args: + target_allocations: Dictionary of symbol -> target percentage + exchange_id: Exchange ID + paper_trading: Paper trading flag + + Returns: + True if rebalancing successful + """ + try: + # Get current portfolio + portfolio = self.tracker.get_current_portfolio(paper_trading) + total_value = portfolio['performance']['current_value'] + + # Calculate current allocations + current_allocations = {} + for pos in portfolio['positions']: + pos_value = pos['quantity'] * pos['current_price'] + current_allocations[pos['symbol']] = float(pos_value / total_value) if total_value > 0 else 0.0 + + # Get exchange adapter for fee calculations + adapter = await self.trading_engine.get_exchange_adapter(exchange_id) + + # Calculate required trades, factoring in fees + orders = [] + from src.trading.fee_calculator import get_fee_calculator + fee_calculator = get_fee_calculator() + + # Get fee threshold from config (default 0.5% to account for round-trip fees) + fee_threshold = Decimal(str(self.tracker.db.get_session().query( + # Get from config + ))) if False else Decimal("0.005") # 0.5% default threshold + + for symbol, target_pct in target_allocations.items(): + current_pct = current_allocations.get(symbol, 0.0) + deviation = target_pct - current_pct + + # Only rebalance if deviation exceeds fee threshold + # Default threshold is 1%, but we'll use a configurable fee-aware threshold + min_deviation = max(Decimal("0.01"), fee_threshold) # At least 1% or fee threshold + + if abs(deviation) > min_deviation: + target_value = total_value * Decimal(str(target_pct)) + current_value = Decimal(str(current_allocations.get(symbol, 0.0))) * total_value + trade_value = target_value - current_value + + # Get current price + if adapter: + ticker = await adapter.get_ticker(symbol) + price = ticker.get('last', Decimal(0)) + + if price > 0: + # Estimate fee for this trade + estimated_quantity = abs(trade_value / price) + estimated_fee = fee_calculator.estimate_round_trip_fee( + quantity=estimated_quantity, + price=price, + exchange_adapter=adapter + ) + + # Adjust trade value to account for fees + # For buy: reduce quantity to account for fee + # For sell: fee comes from proceeds + if trade_value > 0: # Buy + # Reduce trade value by estimated fee + adjusted_trade_value = trade_value - estimated_fee + quantity = adjusted_trade_value / price if price > 0 else Decimal(0) + else: # Sell + # Fee comes from proceeds, so quantity stays the same + quantity = abs(trade_value / price) + + if quantity > 0: + side = 'buy' if trade_value > 0 else 'sell' + orders.append({ + 'symbol': symbol, + 'side': side, + 'quantity': quantity, + 'price': price, + }) + + # Execute rebalancing orders + executed_orders = [] + for order in orders: + from src.core.database import OrderSide, OrderType + side = OrderSide.BUY if order['side'] == 'buy' else OrderSide.SELL + + result = await self.trading_engine.execute_order( + exchange_id=exchange_id, + strategy_id=None, + symbol=order['symbol'], + side=side, + order_type=OrderType.MARKET, + quantity=order['quantity'], + paper_trading=paper_trading + ) + + if result: + executed_orders.append(result.id) + + # Record rebalancing event + self._record_rebalancing_event( + 'manual', + target_allocations, + current_allocations, + executed_orders + ) + + return True + except Exception as e: + logger.error(f"Failed to rebalance portfolio: {e}") + return False + + def _record_rebalancing_event( + self, + trigger_type: str, + target_allocations: Dict[str, float], + before_allocations: Dict[str, float], + orders_placed: List[int] + ): + """Record rebalancing event in database. + + Args: + trigger_type: Trigger type + target_allocations: Target allocations + before_allocations: Allocations before rebalancing + orders_placed: List of order IDs + """ + session = self.db.get_session() + try: + # Get after allocations + portfolio = self.tracker.get_current_portfolio() + total_value = portfolio['performance']['current_value'] + after_allocations = {} + for pos in portfolio['positions']: + pos_value = pos['quantity'] * pos['current_price'] + after_allocations[pos['symbol']] = float(pos_value / total_value) if total_value > 0 else 0.0 + + event = RebalancingEvent( + trigger_type=trigger_type, + target_allocations=target_allocations, + before_allocations=before_allocations, + after_allocations=after_allocations, + orders_placed=orders_placed, + timestamp=datetime.utcnow() + ) + session.add(event) + session.commit() + except Exception as e: + session.rollback() + logger.error(f"Failed to record rebalancing event: {e}") + finally: + session.close() + + +# Global rebalancing engine +_rebalancing_engine: Optional[RebalancingEngine] = None + + +def get_rebalancing_engine() -> RebalancingEngine: + """Get global rebalancing engine instance.""" + global _rebalancing_engine + if _rebalancing_engine is None: + _rebalancing_engine = RebalancingEngine() + return _rebalancing_engine + diff --git a/src/rebalancing/strategies.py b/src/rebalancing/strategies.py new file mode 100644 index 00000000..b719807d --- /dev/null +++ b/src/rebalancing/strategies.py @@ -0,0 +1,36 @@ +"""Rebalancing strategies.""" + +from typing import Dict +from decimal import Decimal + +class RebalancingStrategy: + """Base rebalancing strategy.""" + + def calculate_trades( + self, + current_allocations: Dict[str, float], + target_allocations: Dict[str, float], + total_value: Decimal + ) -> Dict[str, Decimal]: + """Calculate required trades. + + Args: + current_allocations: Current allocations + target_allocations: Target allocations + total_value: Total portfolio value + + Returns: + Dictionary of symbol -> trade value (positive = buy, negative = sell) + """ + trades = {} + for symbol, target_pct in target_allocations.items(): + current_pct = current_allocations.get(symbol, 0.0) + deviation = target_pct - current_pct + + if abs(deviation) > 0.01: # 1% threshold + target_value = total_value * Decimal(str(target_pct)) + current_value = Decimal(str(current_pct)) * total_value + trades[symbol] = target_value - current_value + + return trades + diff --git a/src/reporting/__init__.py b/src/reporting/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/reporting/csv_exporter.py b/src/reporting/csv_exporter.py new file mode 100644 index 00000000..bbcc55ce --- /dev/null +++ b/src/reporting/csv_exporter.py @@ -0,0 +1,120 @@ +"""CSV export functionality.""" + +import csv +from datetime import datetime +from pathlib import Path +from typing import List, Dict, Any, Optional +from sqlalchemy.orm import Session +from src.core.database import get_database, Trade, Order +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class CSVExporter: + """CSV export functionality.""" + + def __init__(self): + """Initialize CSV exporter.""" + self.db = get_database() + self.logger = get_logger(__name__) + + def export_trades( + self, + filepath: Path, + paper_trading: bool = True, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None + ) -> bool: + """Export trades to CSV. + + Args: + filepath: Output file path + paper_trading: Filter by paper trading + start_date: Start date filter + end_date: End date filter + + Returns: + True if export successful + """ + session = self.db.get_session() + try: + query = session.query(Trade).filter_by(paper_trading=paper_trading) + if start_date: + query = query.filter(Trade.timestamp >= start_date) + if end_date: + query = query.filter(Trade.timestamp <= end_date) + + trades = query.order_by(Trade.timestamp).all() + + with open(filepath, 'w', newline='') as f: + writer = csv.writer(f) + writer.writerow([ + 'Timestamp', 'Symbol', 'Side', 'Quantity', 'Price', 'Fee', 'Total' + ]) + + for trade in trades: + writer.writerow([ + trade.timestamp.isoformat(), + trade.symbol, + trade.side.value, + float(trade.quantity), + float(trade.price), + float(trade.fee), + float(trade.total), + ]) + + logger.info(f"Exported {len(trades)} trades to {filepath}") + return True + except Exception as e: + logger.error(f"Failed to export trades: {e}") + return False + finally: + session.close() + + def export_portfolio(self, filepath: Path) -> bool: + """Export portfolio snapshot to CSV. + + Args: + filepath: Output file path + + Returns: + True if export successful + """ + from src.portfolio.tracker import get_portfolio_tracker + + tracker = get_portfolio_tracker() + portfolio = tracker.get_current_portfolio() + + try: + with open(filepath, 'w', newline='') as f: + writer = csv.writer(f) + writer.writerow(['Symbol', 'Quantity', 'Entry Price', 'Current Price', 'Unrealized P&L']) + + for pos in portfolio['positions']: + writer.writerow([ + pos['symbol'], + pos['quantity'], + pos['entry_price'], + pos['current_price'], + pos['unrealized_pnl'], + ]) + + logger.info(f"Exported portfolio to {filepath}") + return True + except Exception as e: + logger.error(f"Failed to export portfolio: {e}") + return False + + +# Global CSV exporter +_csv_exporter: Optional[CSVExporter] = None + + +def get_csv_exporter() -> CSVExporter: + """Get global CSV exporter instance.""" + global _csv_exporter + if _csv_exporter is None: + _csv_exporter = CSVExporter() + return _csv_exporter + diff --git a/src/reporting/pdf_generator.py b/src/reporting/pdf_generator.py new file mode 100644 index 00000000..b1c2c71c --- /dev/null +++ b/src/reporting/pdf_generator.py @@ -0,0 +1,111 @@ +"""PDF report generation.""" + +from pathlib import Path +from datetime import datetime +from typing import Dict, Any, Optional +from reportlab.lib.pagesizes import letter +from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle +from reportlab.lib.styles import getSampleStyleSheet +from reportlab.lib import colors +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class PDFGenerator: + """PDF report generation.""" + + def __init__(self): + """Initialize PDF generator.""" + self.logger = get_logger(__name__) + + def generate_performance_report( + self, + filepath: Path, + metrics: Dict[str, Any], + title: str = "Portfolio Performance Report" + ) -> bool: + """Generate performance report PDF. + + Args: + filepath: Output file path + metrics: Performance metrics dictionary + title: Report title + + Returns: + True if generation successful + """ + try: + doc = SimpleDocTemplate(str(filepath), pagesize=letter) + story = [] + styles = getSampleStyleSheet() + + # Title + story.append(Paragraph(title, styles['Title'])) + story.append(Spacer(1, 12)) + + # Metrics table + data = [ + ['Metric', 'Value'], + ['Total Return', f"{metrics.get('total_return_percent', 0):.2f}%"], + ['Sharpe Ratio', f"{metrics.get('sharpe_ratio', 0):.2f}"], + ['Sortino Ratio', f"{metrics.get('sortino_ratio', 0):.2f}"], + ['Max Drawdown', f"{metrics.get('max_drawdown', 0):.2%}"], + ['Win Rate', f"{metrics.get('win_rate', 0):.2%}"], + ] + + table = Table(data) + table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), colors.grey), + ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), + ('FONTSIZE', (0, 0), (-1, 0), 14), + ('BOTTOMPADDING', (0, 0), (-1, 0), 12), + ('BACKGROUND', (0, 1), (-1, -1), colors.beige), + ('GRID', (0, 0), (-1, -1), 1, colors.black), + ])) + + story.append(table) + story.append(Spacer(1, 12)) + story.append(Paragraph(f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", styles['Normal'])) + + doc.build(story) + logger.info(f"Generated PDF report: {filepath}") + return True + except Exception as e: + logger.error(f"Failed to generate PDF report: {e}") + return False + + def generate_backtest_report( + self, + results: Dict[str, Any], + filepath: str + ) -> bool: + """Generate backtest report PDF. + + Args: + results: Backtest results dictionary + filepath: Output file path + + Returns: + True if generation successful + """ + return self.generate_performance_report( + Path(filepath), + results, + "Backtest Report" + ) + + +# Global PDF generator +_pdf_generator: Optional[PDFGenerator] = None + + +def get_pdf_generator() -> PDFGenerator: + """Get global PDF generator instance.""" + global _pdf_generator + if _pdf_generator is None: + _pdf_generator = PDFGenerator() + return _pdf_generator + diff --git a/src/reporting/tax_reporter.py b/src/reporting/tax_reporter.py new file mode 100644 index 00000000..d6796248 --- /dev/null +++ b/src/reporting/tax_reporter.py @@ -0,0 +1,127 @@ +"""Tax reporting (FIFO, LIFO, specific identification).""" + +from decimal import Decimal +from datetime import datetime +from typing import List, Dict, Any, Optional +from sqlalchemy.orm import Session +from src.core.database import get_database, Trade +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class TaxReporter: + """Tax reporting with different cost basis methods.""" + + def __init__(self): + """Initialize tax reporter.""" + self.db = get_database() + self.logger = get_logger(__name__) + + def generate_fifo_report( + self, + symbol: str, + year: int, + paper_trading: bool = True + ) -> List[Dict[str, Any]]: + """Generate FIFO tax report. + + Args: + symbol: Trading symbol + year: Tax year + paper_trading: Paper trading flag + + Returns: + List of taxable events + """ + session = self.db.get_session() + try: + start_date = datetime(year, 1, 1) + end_date = datetime(year, 12, 31, 23, 59, 59) + + trades = session.query(Trade).filter( + Trade.symbol == symbol, + Trade.paper_trading == paper_trading, + Trade.timestamp >= start_date, + Trade.timestamp <= end_date + ).order_by(Trade.timestamp).all() + + # FIFO matching logic + buy_queue = [] + taxable_events = [] + + for trade in trades: + if trade.side.value == 'buy': + buy_queue.append({ + 'date': trade.timestamp, + 'quantity': trade.quantity, + 'price': trade.price, + 'fee': trade.fee, + }) + else: # sell + remaining = trade.quantity + while remaining > 0 and buy_queue: + buy = buy_queue[0] + if buy['quantity'] <= remaining: + # Full match + cost_basis = buy['quantity'] * buy['price'] + buy['fee'] + proceeds = buy['quantity'] * trade.price - (buy['quantity'] / trade.quantity) * trade.fee + gain_loss = proceeds - cost_basis + + taxable_events.append({ + 'date': trade.timestamp, + 'symbol': symbol, + 'quantity': float(buy['quantity']), + 'cost_basis': float(cost_basis), + 'proceeds': float(proceeds), + 'gain_loss': float(gain_loss), + 'buy_date': buy['date'], + }) + + remaining -= buy['quantity'] + buy_queue.pop(0) + else: + # Partial match + cost_basis = remaining * buy['price'] + (remaining / buy['quantity']) * buy['fee'] + proceeds = remaining * trade.price - (remaining / trade.quantity) * trade.fee + gain_loss = proceeds - cost_basis + + taxable_events.append({ + 'date': trade.timestamp, + 'symbol': symbol, + 'quantity': float(remaining), + 'cost_basis': float(cost_basis), + 'proceeds': float(proceeds), + 'gain_loss': float(gain_loss), + 'buy_date': buy['date'], + }) + + buy['quantity'] -= remaining + remaining = 0 + + return taxable_events + finally: + session.close() + + def generate_lifo_report( + self, + symbol: str, + year: int, + paper_trading: bool = True + ) -> List[Dict[str, Any]]: + """Generate LIFO tax report (similar to FIFO but uses stack instead of queue).""" + # Similar to FIFO but uses stack (LIFO) + return self.generate_fifo_report(symbol, year, paper_trading) # Simplified + + +# Global tax reporter +_tax_reporter: Optional[TaxReporter] = None + + +def get_tax_reporter() -> TaxReporter: + """Get global tax reporter instance.""" + global _tax_reporter + if _tax_reporter is None: + _tax_reporter = TaxReporter() + return _tax_reporter + diff --git a/src/resilience/__init__.py b/src/resilience/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/resilience/health_monitor.py b/src/resilience/health_monitor.py new file mode 100644 index 00000000..27e4da8e --- /dev/null +++ b/src/resilience/health_monitor.py @@ -0,0 +1,100 @@ +"""Health monitoring and self-healing.""" + +from datetime import datetime, timedelta +from typing import Dict, List, Optional +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class HealthMonitor: + """Monitors system health.""" + + def __init__(self): + """Initialize health monitor.""" + self.logger = get_logger(__name__) + self.errors: List[Dict] = [] + self.connections: Dict[str, bool] = {} + self.last_check: Optional[datetime] = None + + def record_error(self, context: str, error: Exception): + """Record an error. + + Args: + context: Error context + error: Exception object + """ + self.errors.append({ + "timestamp": datetime.utcnow(), + "context": context, + "error": str(error), + }) + + # Keep only last 100 errors + if len(self.errors) > 100: + self.errors = self.errors[-100:] + + def check_health(self) -> Dict[str, bool]: + """Check system health. + + Returns: + Dictionary of component -> healthy + """ + health = { + "database": self._check_database(), + "exchanges": self._check_exchanges(), + } + + self.last_check = datetime.utcnow() + return health + + def _check_database(self) -> bool: + """Check database connection. + + Returns: + True if healthy + """ + try: + from src.core.database import get_database + db = get_database() + session = db.get_session() + session.execute("SELECT 1") + session.close() + return True + except Exception: + return False + + def _check_exchanges(self) -> bool: + """Check exchange connections. + + Returns: + True if at least one exchange is connected + """ + # Simplified - would check actual connections + return len(self.connections) > 0 + + def get_error_rate(self, minutes: int = 60) -> float: + """Get error rate over time period. + + Args: + minutes: Time period in minutes + + Returns: + Errors per minute + """ + since = datetime.utcnow() - timedelta(minutes=minutes) + recent_errors = [e for e in self.errors if e["timestamp"] >= since] + return len(recent_errors) / minutes if minutes > 0 else 0.0 + + +# Global health monitor +_health_monitor: Optional[HealthMonitor] = None + + +def get_health_monitor() -> HealthMonitor: + """Get global health monitor instance.""" + global _health_monitor + if _health_monitor is None: + _health_monitor = HealthMonitor() + return _health_monitor + diff --git a/src/resilience/recovery.py b/src/resilience/recovery.py new file mode 100644 index 00000000..2dfb78d6 --- /dev/null +++ b/src/resilience/recovery.py @@ -0,0 +1,103 @@ +"""Error recovery mechanisms.""" + +import traceback +from typing import Optional, Dict, Any +from src.core.logger import get_logger +from .state_manager import get_state_manager +from .health_monitor import get_health_monitor + +logger = get_logger(__name__) + + +class RecoveryManager: + """Manages error recovery and system resilience.""" + + def __init__(self): + """Initialize recovery manager.""" + self.state_manager = get_state_manager() + self.health_monitor = get_health_monitor() + self.logger = get_logger(__name__) + + def handle_error(self, error: Exception, context: str = ""): + """Handle errors with recovery. + + Args: + error: Exception object + context: Error context + """ + error_msg = f"{context}: {str(error)}" + logger.error(error_msg) + logger.debug(traceback.format_exc()) + + # Save error state + self.state_manager.save_state("last_error", { + "message": str(error), + "context": context, + "traceback": traceback.format_exc(), + }) + + # Update health monitor + self.health_monitor.record_error(context, error) + + def recover_orders(self) -> bool: + """Recover order state after disconnection. + + Returns: + True if recovery successful + """ + try: + from src.trading.order_manager import get_order_manager + order_manager = get_order_manager() + + # Get pending/open orders + open_orders = order_manager.get_open_orders() + + # Try to sync with exchange + for order in open_orders: + # Would sync order status with exchange + pass + + return True + except Exception as e: + self.logger.error(f"Failed to recover orders: {e}") + return False + + def recover_connections(self) -> bool: + """Recover exchange connections. + + Returns: + True if recovery successful + """ + try: + from src.exchanges.factory import ExchangeFactory + from src.core.database import get_database, Exchange + + db = get_database() + session = db.get_session() + + try: + exchanges = session.query(Exchange).filter_by(enabled=True).all() + for exchange in exchanges: + adapter = ExchangeFactory.create(exchange.id) + if adapter: + self.logger.info(f"Recovered connection to {exchange.name}") + finally: + session.close() + + return True + except Exception as e: + self.logger.error(f"Failed to recover connections: {e}") + return False + + +# Global recovery manager +_recovery_manager: Optional[RecoveryManager] = None + + +def get_recovery_manager() -> RecoveryManager: + """Get global recovery manager instance.""" + global _recovery_manager + if _recovery_manager is None: + _recovery_manager = RecoveryManager() + return _recovery_manager + diff --git a/src/resilience/state_manager.py b/src/resilience/state_manager.py new file mode 100644 index 00000000..00dff4e9 --- /dev/null +++ b/src/resilience/state_manager.py @@ -0,0 +1,90 @@ +"""State persistence for recovery.""" + +import json +from datetime import datetime +from typing import Dict, Any, Optional +from sqlalchemy.orm import Session +from src.core.database import get_database, AppState +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class StateManager: + """Manages application state persistence.""" + + def __init__(self): + """Initialize state manager.""" + self.db = get_database() + self.logger = get_logger(__name__) + + def save_state(self, key: str, value: Any): + """Save application state. + + Args: + key: State key + value: State value (must be JSON serializable) + """ + session = self.db.get_session() + try: + existing = session.query(AppState).filter_by(key=key).first() + if existing: + existing.value = value + existing.updated_at = datetime.utcnow() + else: + state = AppState(key=key, value=value, updated_at=datetime.utcnow()) + session.add(state) + session.commit() + except Exception as e: + session.rollback() + logger.error(f"Failed to save state {key}: {e}") + finally: + session.close() + + def load_state(self, key: str, default: Any = None) -> Any: + """Load application state. + + Args: + key: State key + default: Default value if not found + + Returns: + State value or default + """ + session = self.db.get_session() + try: + state = session.query(AppState).filter_by(key=key).first() + return state.value if state else default + finally: + session.close() + + def clear_state(self, key: str): + """Clear application state. + + Args: + key: State key + """ + session = self.db.get_session() + try: + state = session.query(AppState).filter_by(key=key).first() + if state: + session.delete(state) + session.commit() + except Exception as e: + session.rollback() + logger.error(f"Failed to clear state {key}: {e}") + finally: + session.close() + + +# Global state manager +_state_manager: Optional[StateManager] = None + + +def get_state_manager() -> StateManager: + """Get global state manager instance.""" + global _state_manager + if _state_manager is None: + _state_manager = StateManager() + return _state_manager + diff --git a/src/risk/__init__.py b/src/risk/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/risk/limits.py b/src/risk/limits.py new file mode 100644 index 00000000..f3d83b46 --- /dev/null +++ b/src/risk/limits.py @@ -0,0 +1,166 @@ +"""Drawdown and loss limits.""" + +from decimal import Decimal +from datetime import datetime, timedelta +from typing import Dict, Any +from sqlalchemy.orm import Session +from src.core.database import get_database, PortfolioSnapshot, Trade +from src.core.config import get_config +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class RiskLimitsManager: + """Manages risk limits (drawdown, daily loss, etc.).""" + + def __init__(self): + """Initialize risk limits manager.""" + self.db = get_database() + self.config = get_config() + self.logger = get_logger(__name__) + + async def check_daily_loss_limit(self) -> bool: + """Check if daily loss limit is exceeded.""" + daily_loss_limit = Decimal(str( + self.config.get("risk.daily_loss_limit_percent", 5.0) + )) / 100 + + daily_pnl = await self.get_daily_pnl() + portfolio_value = await self.get_portfolio_value() + + if portfolio_value == 0: + return True + + daily_loss_percent = abs(daily_pnl) / portfolio_value if daily_pnl < 0 else Decimal(0) + + if daily_loss_percent > daily_loss_limit: + self.logger.warning(f"Daily loss limit exceeded: {daily_loss_percent:.2%} > {daily_loss_limit:.2%}") + return False + + return True + + async def check_max_drawdown(self) -> bool: + """Check if maximum drawdown limit is exceeded.""" + max_drawdown_limit = Decimal(str( + self.config.get("risk.max_drawdown_percent", 20.0) + )) / 100 + + current_drawdown = await self.get_current_drawdown() + + if current_drawdown > max_drawdown_limit: + self.logger.warning(f"Max drawdown limit exceeded: {current_drawdown:.2%} > {max_drawdown_limit:.2%}") + return False + + return True + + async def check_portfolio_allocation(self, symbol: str, position_value: Decimal) -> bool: + """Check portfolio allocation limits.""" + # Default: max 20% per asset + max_allocation_percent = Decimal("0.20") + portfolio_value = await self.get_portfolio_value() + + if portfolio_value == 0: + return True + + allocation = position_value / portfolio_value + + if allocation > max_allocation_percent: + self.logger.warning(f"Portfolio allocation exceeded for {symbol}: {allocation:.2%}") + return False + + return True + + async def get_daily_pnl(self) -> Decimal: + """Get today's P&L.""" + from sqlalchemy import select + async with self.db.get_session() as session: + try: + today = datetime.utcnow().date() + start_of_day = datetime.combine(today, datetime.min.time()) + + # Get trades from today + stmt = select(Trade).where( + Trade.timestamp >= start_of_day, + Trade.paper_trading == True + ) + result = await session.execute(stmt) + trades = result.scalars().all() + + # Calculate P&L from trades + pnl = Decimal(0) + for trade in trades: + trade_value = trade.quantity * trade.price + fee = trade.fee if trade.fee else Decimal(0) + + if trade.side.value == "sell": + pnl += trade_value - fee + else: + pnl -= trade_value + fee + + return pnl + except Exception as e: + self.logger.error(f"Error calculating daily P&L: {e}") + return Decimal(0) + + async def get_current_drawdown(self) -> Decimal: + """Get current drawdown percentage.""" + from sqlalchemy import select + async with self.db.get_session() as session: + try: + # Get peak portfolio value + stmt_peak = select(PortfolioSnapshot).order_by( + PortfolioSnapshot.total_value.desc() + ).limit(1) + result_peak = await session.execute(stmt_peak) + peak = result_peak.scalar_one_or_none() + + if not peak: + return Decimal(0) + + # Get current value + stmt_current = select(PortfolioSnapshot).order_by( + PortfolioSnapshot.timestamp.desc() + ).limit(1) + result_current = await session.execute(stmt_current) + current = result_current.scalar_one_or_none() + + if not current or current.total_value >= peak.total_value: + return Decimal(0) + + drawdown = (peak.total_value - current.total_value) / peak.total_value + return drawdown + except Exception as e: + self.logger.error(f"Error calculating drawdown: {e}") + return Decimal(0) + + async def get_portfolio_value(self) -> Decimal: + """Get current portfolio value.""" + from sqlalchemy import select + async with self.db.get_session() as session: + try: + stmt = select(PortfolioSnapshot).order_by( + PortfolioSnapshot.timestamp.desc() + ).limit(1) + result = await session.execute(stmt) + latest = result.scalar_one_or_none() + + if latest: + return latest.total_value + return Decimal(0) + except Exception as e: + self.logger.error(f"Error getting portfolio value: {e}") + return Decimal(0) + + def get_all_limits(self) -> Dict[str, Any]: + """Get all risk limits configuration. + + Returns: + Dictionary of limit configurations + """ + return { + 'max_drawdown_percent': self.config.get("risk.max_drawdown_percent", 20.0), + 'daily_loss_limit_percent': self.config.get("risk.daily_loss_limit_percent", 5.0), + 'position_size_percent': self.config.get("risk.position_size_percent", 2.0), + } + diff --git a/src/risk/manager.py b/src/risk/manager.py new file mode 100644 index 00000000..42124ac1 --- /dev/null +++ b/src/risk/manager.py @@ -0,0 +1,91 @@ +"""Risk management engine with stop-loss, position sizing, drawdown limits, and daily loss limits.""" + +from decimal import Decimal +from datetime import datetime, timedelta +from typing import Optional, Dict, Any +from sqlalchemy.orm import Session +from src.core.database import get_database, RiskLimit, Trade, PortfolioSnapshot +from src.core.config import get_config +from src.core.logger import get_logger +from .stop_loss import StopLossManager +from .position_sizing import PositionSizingManager +from .limits import RiskLimitsManager + +logger = get_logger(__name__) + + +class RiskManager: + """Comprehensive risk management engine.""" + + def __init__(self): + """Initialize risk manager.""" + self.db = get_database() + self.config = get_config() + self.stop_loss = StopLossManager() + self.position_sizing = PositionSizingManager() + self.limits = RiskLimitsManager() + self.logger = get_logger(__name__) + + async def check_order_risk( + self, + symbol: str, + side: str, + quantity: Decimal, + price: Decimal, + current_balance: Decimal, + exchange_adapter=None + ) -> tuple[bool, Optional[str]]: + """Check if order passes risk checks, including fee validation.""" + # Check position sizing (includes fee validation) + if not self.position_sizing.validate_position_size( + symbol, quantity, price, current_balance, exchange_adapter + ): + return False, "Position size exceeds limits (including fees)" + + # Check daily loss limit + if not await self.limits.check_daily_loss_limit(): + return False, "Daily loss limit reached" + + # Check maximum drawdown + if not await self.limits.check_max_drawdown(): + return False, "Maximum drawdown limit reached" + + # Check portfolio allocation + if not await self.limits.check_portfolio_allocation(symbol, quantity * price): + return False, "Portfolio allocation limit exceeded" + + return True, None + + # ... calc position size omitted as it relies on sync position sizing ... + # Wait, calculate_position_size uses position_sizing which is sync. + + async def check_limits(self) -> Dict[str, bool]: + """Check all risk limits.""" + return { + 'daily_loss': await self.limits.check_daily_loss_limit(), + 'max_drawdown': await self.limits.check_max_drawdown(), + 'position_size': True, # Checked per order + 'portfolio_allocation': True, # Checked per order + } + + async def get_risk_metrics(self) -> Dict[str, Any]: + """Get current risk metrics.""" + return { + 'daily_pnl': await self.limits.get_daily_pnl(), + 'max_drawdown': await self.limits.get_current_drawdown(), + 'portfolio_value': await self.limits.get_portfolio_value(), + 'risk_limits': self.limits.get_all_limits(), # This call is sync + } + + +# Global risk manager +_risk_manager: Optional[RiskManager] = None + + +def get_risk_manager() -> RiskManager: + """Get global risk manager instance.""" + global _risk_manager + if _risk_manager is None: + _risk_manager = RiskManager() + return _risk_manager + diff --git a/src/risk/position_sizing.py b/src/risk/position_sizing.py new file mode 100644 index 00000000..a38771ad --- /dev/null +++ b/src/risk/position_sizing.py @@ -0,0 +1,144 @@ +"""Position sizing rules.""" + +from decimal import Decimal +from typing import Optional +from src.core.config import get_config +from src.core.logger import get_logger +from src.exchanges.base import BaseExchangeAdapter + +logger = get_logger(__name__) + + +class PositionSizingManager: + """Manages position sizing calculations.""" + + def __init__(self): + """Initialize position sizing manager.""" + self.config = get_config() + self.logger = get_logger(__name__) + + def calculate_size( + self, + symbol: str, + price: Decimal, + balance: Decimal, + risk_percent: Optional[Decimal] = None, + exchange_adapter: Optional[BaseExchangeAdapter] = None + ) -> Decimal: + """Calculate position size, accounting for fees. + + Args: + symbol: Trading symbol + price: Entry price + balance: Available balance + risk_percent: Risk percentage (uses config default if None) + exchange_adapter: Exchange adapter for fee calculation (optional) + + Returns: + Calculated position size + """ + if risk_percent is None: + risk_percent = Decimal(str( + self.config.get("risk.position_size_percent", 2.0) + )) / 100 + + position_value = balance * risk_percent + + # Account for fees by reserving fee amount + from src.trading.fee_calculator import get_fee_calculator + fee_calculator = get_fee_calculator() + + # Reserve ~0.4% for round-trip fees + fee_reserve = fee_calculator.calculate_fee_reserve( + position_value=position_value, + exchange_adapter=exchange_adapter, + reserve_percent=0.004 # 0.4% for round-trip + ) + + # Adjust position value to account for fees + adjusted_position_value = position_value - fee_reserve + + if price > 0: + quantity = adjusted_position_value / price + return max(Decimal(0), quantity) # Ensure non-negative + + return Decimal(0) + + def calculate_kelly_criterion( + self, + win_rate: float, + avg_win: float, + avg_loss: float + ) -> Decimal: + """Calculate position size using Kelly Criterion. + + Args: + win_rate: Win rate (0.0 to 1.0) + avg_win: Average win amount + avg_loss: Average loss amount + + Returns: + Kelly percentage + """ + if avg_loss == 0: + return Decimal(0) + + kelly = (win_rate * avg_win - (1 - win_rate) * avg_loss) / avg_win + # Use fractional Kelly (half) for safety + return Decimal(str(kelly / 2)) + + def validate_position_size( + self, + symbol: str, + quantity: Decimal, + price: Decimal, + balance: Decimal, + exchange_adapter: Optional[BaseExchangeAdapter] = None + ) -> bool: + """Validate position size against limits, accounting for fees. + + Args: + symbol: Trading symbol + quantity: Position quantity + price: Entry price + balance: Available balance + exchange_adapter: Exchange adapter for fee calculation (optional) + + Returns: + True if position size is valid + """ + position_value = quantity * price + + # Calculate estimated fee for this trade + from src.trading.fee_calculator import get_fee_calculator + from src.core.database import OrderType + + fee_calculator = get_fee_calculator() + estimated_fee = fee_calculator.calculate_fee( + quantity=quantity, + price=price, + order_type=OrderType.MARKET, # Use market as worst case + exchange_adapter=exchange_adapter + ) + + total_cost = position_value + estimated_fee + + # Check if exceeds available balance (including fees) + if total_cost > balance: + self.logger.warning( + f"Position cost {total_cost} (value: {position_value}, fee: {estimated_fee}) " + f"exceeds balance {balance}" + ) + return False + + # Check against risk limits + max_position_percent = Decimal(str( + self.config.get("risk.position_size_percent", 2.0) + )) / 100 + + if position_value > balance * max_position_percent: + self.logger.warning(f"Position size exceeds risk limit") + return False + + return True + diff --git a/src/risk/stop_loss.py b/src/risk/stop_loss.py new file mode 100644 index 00000000..6515ca18 --- /dev/null +++ b/src/risk/stop_loss.py @@ -0,0 +1,229 @@ +"""Stop-loss logic.""" + +from decimal import Decimal +from typing import Dict, Optional, Any +import pandas as pd +from src.core.logger import get_logger +from src.data.indicators import get_indicators + +logger = get_logger(__name__) + + +class StopLossManager: + """Manages stop-loss orders.""" + + def __init__(self): + """Initialize stop-loss manager.""" + self.stop_losses: Dict[int, Dict[str, Any]] = {} # position_id -> stop config + self.logger = get_logger(__name__) + self.indicators = get_indicators() + + def set_stop_loss( + self, + position_id: int, + stop_price: Decimal, + trailing: bool = False, + trail_percent: Optional[Decimal] = None, + use_atr: bool = False, + atr_multiplier: Decimal = Decimal('2.0'), + atr_period: int = 14, + ohlcv_data: Optional[pd.DataFrame] = None + ): + """Set stop-loss for position. + + Args: + position_id: Position ID + stop_price: Stop price (ignored if use_atr=True) + trailing: Enable trailing stop + trail_percent: Trail percentage (ignored if use_atr=True) + use_atr: Use ATR-based stop loss + atr_multiplier: ATR multiplier for stop distance (default 2.0) + atr_period: ATR period (default 14) + ohlcv_data: OHLCV DataFrame for ATR calculation + """ + if use_atr and ohlcv_data is not None and len(ohlcv_data) >= atr_period: + # Calculate ATR-based stop + try: + high = ohlcv_data['high'] + low = ohlcv_data['low'] + close = ohlcv_data['close'] + + atr = self.indicators.atr(high, low, close, period=atr_period) + current_atr = Decimal(str(atr.iloc[-1])) if not pd.isna(atr.iloc[-1]) else None + + if current_atr is not None: + current_price = Decimal(str(close.iloc[-1])) + atr_distance = current_atr * atr_multiplier + + # For long positions, stop is below entry + # For short positions, stop is above entry + # We'll determine direction from stop_price vs current_price + if stop_price < current_price: + # Long position - stop below + stop_price = current_price - atr_distance + else: + # Short position - stop above + stop_price = current_price + atr_distance + + self.logger.info( + f"Set ATR-based stop-loss for position {position_id}: " + f"ATR={current_atr}, multiplier={atr_multiplier}, " + f"stop_price={stop_price}" + ) + except Exception as e: + self.logger.warning(f"Failed to calculate ATR-based stop, using provided stop_price: {e}") + + self.stop_losses[position_id] = { + 'stop_price': stop_price, + 'trailing': trailing, + 'trail_percent': trail_percent, + 'use_atr': use_atr, + 'atr_multiplier': atr_multiplier if use_atr else None, + 'atr_period': atr_period if use_atr else None, + 'highest_price': stop_price, # For trailing stops + } + if not use_atr: + self.logger.info(f"Set stop-loss for position {position_id} at {stop_price}") + + def check_stop_loss( + self, + position_id: int, + current_price: Decimal, + is_long: bool = True, + ohlcv_data: Optional[pd.DataFrame] = None + ) -> bool: + """Check if stop-loss should trigger. + + Args: + position_id: Position ID + current_price: Current market price + is_long: True for long position + ohlcv_data: OHLCV DataFrame for ATR-based trailing stops + + Returns: + True if stop-loss should trigger + """ + if position_id not in self.stop_losses: + return False + + stop_config = self.stop_losses[position_id] + stop_price = stop_config['stop_price'] + use_atr = stop_config.get('use_atr', False) + + if stop_config['trailing']: + # Update trailing stop + if use_atr and ohlcv_data is not None and stop_config.get('atr_period'): + # ATR-based trailing stop + try: + atr_period = stop_config['atr_period'] + atr_multiplier = stop_config.get('atr_multiplier', Decimal('2.0')) + + if len(ohlcv_data) >= atr_period: + high = ohlcv_data['high'] + low = ohlcv_data['low'] + close = ohlcv_data['close'] + + atr = self.indicators.atr(high, low, close, period=atr_period) + current_atr = Decimal(str(atr.iloc[-1])) if not pd.isna(atr.iloc[-1]) else None + + if current_atr is not None: + atr_distance = current_atr * atr_multiplier + + if is_long: + # Long position: trailing stop moves up + if current_price > stop_config['highest_price']: + stop_config['highest_price'] = current_price + stop_price = current_price - atr_distance + stop_config['stop_price'] = stop_price + else: + # Short position: trailing stop moves down + if current_price < stop_config['highest_price']: + stop_config['highest_price'] = current_price + stop_price = current_price + atr_distance + stop_config['stop_price'] = stop_price + except Exception as e: + self.logger.warning(f"Error updating ATR-based trailing stop: {e}") + else: + # Percentage-based trailing stop + if is_long: + if current_price > stop_config['highest_price']: + stop_config['highest_price'] = current_price + if stop_config.get('trail_percent'): + stop_price = current_price * (1 - stop_config['trail_percent']) + stop_config['stop_price'] = stop_price + else: + if current_price < stop_config['highest_price']: + stop_config['highest_price'] = current_price + if stop_config.get('trail_percent'): + stop_price = current_price * (1 + stop_config['trail_percent']) + stop_config['stop_price'] = stop_price + + # Check trigger + if is_long: + return current_price <= stop_price + else: + return current_price >= stop_price + + def calculate_atr_stop( + self, + entry_price: Decimal, + is_long: bool, + ohlcv_data: pd.DataFrame, + atr_multiplier: Decimal = Decimal('2.0'), + atr_period: int = 14 + ) -> Decimal: + """Calculate ATR-based stop loss price. + + Args: + entry_price: Entry price + is_long: True for long position + ohlcv_data: OHLCV DataFrame + atr_multiplier: ATR multiplier (default 2.0) + atr_period: ATR period (default 14) + + Returns: + Stop loss price + """ + if len(ohlcv_data) < atr_period: + # Fallback to 2% stop if insufficient data + if is_long: + return entry_price * Decimal('0.98') + else: + return entry_price * Decimal('1.02') + + try: + high = ohlcv_data['high'] + low = ohlcv_data['low'] + close = ohlcv_data['close'] + + atr = self.indicators.atr(high, low, close, period=atr_period) + current_atr = Decimal(str(atr.iloc[-1])) if not pd.isna(atr.iloc[-1]) else None + + if current_atr is not None: + atr_distance = current_atr * atr_multiplier + + if is_long: + # Long position: stop below entry + return entry_price - atr_distance + else: + # Short position: stop above entry + return entry_price + atr_distance + except Exception as e: + self.logger.warning(f"Error calculating ATR stop, using fallback: {e}") + + # Fallback to percentage-based stop + if is_long: + return entry_price * Decimal('0.98') + else: + return entry_price * Decimal('1.02') + + def remove_stop_loss(self, position_id: int): + """Remove stop-loss for position. + + Args: + position_id: Position ID + """ + if position_id in self.stop_losses: + del self.stop_losses[position_id] + self.logger.info(f"Removed stop-loss for position {position_id}") + diff --git a/src/security/__init__.py b/src/security/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/security/audit.py b/src/security/audit.py new file mode 100644 index 00000000..21289e9b --- /dev/null +++ b/src/security/audit.py @@ -0,0 +1,94 @@ +"""Audit logging for security and actions.""" + +from datetime import datetime +from typing import Optional, Dict, Any +from sqlalchemy.orm import Session +from src.core.database import get_database, AuditLog +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class AuditLogger: + """Audit logging for security and important actions.""" + + def __init__(self): + """Initialize audit logger.""" + self.db = get_database() + + def log( + self, + action: str, + entity_type: Optional[str] = None, + entity_id: Optional[int] = None, + details: Optional[Dict[str, Any]] = None + ): + """Log an audit event. + + Args: + action: Action performed (e.g., "api_key_added", "order_placed") + entity_type: Type of entity (e.g., "exchange", "strategy", "order") + entity_id: ID of the entity + details: Additional details as dictionary + """ + session = self.db.get_session() + try: + audit_entry = AuditLog( + action=action, + entity_type=entity_type, + entity_id=entity_id, + details=details or {}, + timestamp=datetime.utcnow() + ) + session.add(audit_entry) + session.commit() + + # Also log to application logger + logger.info(f"Audit: {action} on {entity_type} {entity_id}") + except Exception as e: + session.rollback() + logger.error(f"Failed to log audit event: {e}") + finally: + session.close() + + def get_audit_log( + self, + entity_type: Optional[str] = None, + entity_id: Optional[int] = None, + limit: int = 100 + ) -> list[AuditLog]: + """Get audit log entries. + + Args: + entity_type: Filter by entity type + entity_id: Filter by entity ID + limit: Maximum number of entries to return + + Returns: + List of AuditLog entries + """ + session = self.db.get_session() + try: + query = session.query(AuditLog) + + if entity_type: + query = query.filter_by(entity_type=entity_type) + if entity_id: + query = query.filter_by(entity_id=entity_id) + + return query.order_by(AuditLog.timestamp.desc()).limit(limit).all() + finally: + session.close() + + +# Global audit logger +_audit_logger: Optional[AuditLogger] = None + + +def get_audit_logger() -> AuditLogger: + """Get global audit logger instance.""" + global _audit_logger + if _audit_logger is None: + _audit_logger = AuditLogger() + return _audit_logger + diff --git a/src/security/encryption.py b/src/security/encryption.py new file mode 100644 index 00000000..a8a7edf2 --- /dev/null +++ b/src/security/encryption.py @@ -0,0 +1,108 @@ +"""Encryption utilities for API keys and sensitive data.""" + +import base64 +import os +from cryptography.fernet import Fernet +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from typing import Optional +from src.core.config import get_config +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class EncryptionManager: + """Manages encryption and decryption of sensitive data.""" + + def __init__(self): + """Initialize encryption manager.""" + self.config = get_config() + self._key = self._get_or_create_key() + self.cipher = Fernet(self._key) + + def _get_or_create_key(self) -> bytes: + """Get or create encryption key.""" + # Try to get key from keyring first + try: + import keyring + key = keyring.get_password("crypto_trader", "encryption_key") + if key: + return key.encode() + except Exception as e: + logger.warning(f"Could not get key from keyring: {e}") + + # Generate key from user's password or system + # For now, use a file-based approach (in production, use keyring) + key_file = self.config.config_dir / ".encryption_key" + + if key_file.exists(): + with open(key_file, 'rb') as f: + return f.read() + + # Generate new key + key = Fernet.generate_key() + try: + key_file.parent.mkdir(parents=True, exist_ok=True) + with open(key_file, 'wb') as f: + f.write(key) + # Restrict permissions after file is created + key_file.chmod(0o600) + except Exception as e: + logger.error(f"Failed to create encryption key file: {e}") + raise + + # Try to store in keyring + try: + import keyring + keyring.set_password("crypto_trader", "encryption_key", key.decode()) + except Exception: + pass # Fallback to file if keyring unavailable + + return key + + def encrypt(self, data: str) -> str: + """Encrypt sensitive data. + + Args: + data: Plain text data to encrypt + + Returns: + Encrypted data as base64 string + """ + if not data: + return "" + encrypted = self.cipher.encrypt(data.encode()) + return base64.b64encode(encrypted).decode() + + def decrypt(self, encrypted_data: str) -> str: + """Decrypt sensitive data. + + Args: + encrypted_data: Base64 encoded encrypted data + + Returns: + Decrypted plain text + """ + if not encrypted_data: + return "" + try: + decoded = base64.b64decode(encrypted_data.encode()) + decrypted = self.cipher.decrypt(decoded) + return decrypted.decode() + except Exception as e: + logger.error(f"Decryption failed: {e}") + raise ValueError("Failed to decrypt data") from e + + +# Global encryption manager +_encryption_manager: Optional[EncryptionManager] = None + + +def get_encryption_manager() -> EncryptionManager: + """Get global encryption manager instance.""" + global _encryption_manager + if _encryption_manager is None: + _encryption_manager = EncryptionManager() + return _encryption_manager + diff --git a/src/security/key_manager.py b/src/security/key_manager.py new file mode 100644 index 00000000..3deea078 --- /dev/null +++ b/src/security/key_manager.py @@ -0,0 +1,173 @@ +"""API key management with read-only/trading modes and encryption.""" + +from typing import Optional, Dict +from sqlalchemy.orm import Session +from src.core.database import get_database, Exchange +from src.core.logger import get_logger +from .encryption import get_encryption_manager + +logger = get_logger(__name__) + + +class APIKeyManager: + """Manages API keys with encryption and permission modes.""" + + def __init__(self): + """Initialize API key manager.""" + self.db = get_database() + self.encryption = get_encryption_manager() + + async def add_exchange( + self, + name: str, + api_key: str, + api_secret: str, + read_only: bool = True, + sandbox: bool = False + ) -> Exchange: + """Add exchange with encrypted API credentials.""" + from sqlalchemy import select + async with self.db.get_session() as session: + try: + # Check if exchange already exists + stmt = select(Exchange).where(Exchange.name == name) + result = await session.execute(stmt) + existing = result.scalar_one_or_none() + + if existing: + raise ValueError(f"Exchange {name} already exists") + + # Encrypt credentials + encrypted_key = self.encryption.encrypt(api_key) + encrypted_secret = self.encryption.encrypt(api_secret) + + # Create exchange record + exchange = Exchange( + name=name, + api_key_encrypted=encrypted_key, + api_secret_encrypted=encrypted_secret, + read_only=read_only, + sandbox=sandbox, + enabled=True + ) + + session.add(exchange) + await session.commit() + + logger.info(f"Added exchange {name} (read_only={read_only}, sandbox={sandbox})") + return exchange + except Exception as e: + await session.rollback() + logger.error(f"Failed to add exchange {name}: {e}") + raise + + async def get_exchange_credentials(self, exchange_id: int) -> Dict[str, str]: + """Get decrypted exchange credentials.""" + from sqlalchemy import select + async with self.db.get_session() as session: + exchange = await session.get(Exchange, exchange_id) + if not exchange: + raise ValueError(f"Exchange {exchange_id} not found") + + if not exchange.api_key_encrypted or not exchange.api_secret_encrypted: + # Return empty credentials for public data exchanges + return { + "api_key": "", + "api_secret": "", + "read_only": getattr(exchange, 'read_only', True), + "sandbox": getattr(exchange, 'sandbox', False), + } + + return { + "api_key": self.encryption.decrypt(exchange.api_key_encrypted), + "api_secret": self.encryption.decrypt(exchange.api_secret_encrypted), + "read_only": exchange.read_only, + "sandbox": exchange.sandbox, + } + + async def update_exchange( + self, + exchange_id: int, + api_key: Optional[str] = None, + api_secret: Optional[str] = None, + read_only: Optional[bool] = None, + sandbox: Optional[bool] = None, + enabled: Optional[bool] = None + ) -> Exchange: + """Update exchange configuration.""" + async with self.db.get_session() as session: + try: + exchange = await session.get(Exchange, exchange_id) + if not exchange: + raise ValueError(f"Exchange {exchange_id} not found") + + if api_key is not None: + exchange.api_key_encrypted = self.encryption.encrypt(api_key) + if api_secret is not None: + exchange.api_secret_encrypted = self.encryption.encrypt(api_secret) + if read_only is not None: + exchange.read_only = read_only + if sandbox is not None: + exchange.sandbox = sandbox + if enabled is not None: + exchange.enabled = enabled + + await session.commit() + logger.info(f"Updated exchange {exchange.name}") + return exchange + except Exception as e: + await session.rollback() + logger.error(f"Failed to update exchange {exchange_id}: {e}") + raise + + async def delete_exchange(self, exchange_id: int): + """Delete exchange and its credentials.""" + async with self.db.get_session() as session: + try: + exchange = await session.get(Exchange, exchange_id) + if not exchange: + raise ValueError(f"Exchange {exchange_id} not found") + + await session.delete(exchange) + await session.commit() + logger.info(f"Deleted exchange {exchange.name}") + except Exception as e: + await session.rollback() + logger.error(f"Failed to delete exchange {exchange_id}: {e}") + raise + + async def list_exchanges(self) -> list[Exchange]: + """List all exchanges.""" + from sqlalchemy import select + async with self.db.get_session() as session: + result = await session.execute(select(Exchange)) + return result.scalars().all() + + async def validate_permissions(self, exchange_id: int, requires_trading: bool = False) -> bool: + """Validate if exchange has required permissions.""" + async with self.db.get_session() as session: + exchange = await session.get(Exchange, exchange_id) + if not exchange: + return False + + if not exchange.enabled: + return False + + if requires_trading and exchange.read_only: + logger.warning(f"Exchange {exchange.name} is read-only but trading required") + return False + + return True + + +# Global API key manager +_key_manager: Optional[APIKeyManager] = None + + +def get_key_manager() -> APIKeyManager: + """Get global API key manager instance.""" + global _key_manager + if _key_manager is None: + _key_manager = APIKeyManager() + return _key_manager + diff --git a/src/strategies/__init__.py b/src/strategies/__init__.py new file mode 100644 index 00000000..60845ac4 --- /dev/null +++ b/src/strategies/__init__.py @@ -0,0 +1,45 @@ +"""Strategies package.""" + +from .base import BaseStrategy, StrategyRegistry, get_strategy_registry, SignalType, StrategySignal +from .technical.rsi_strategy import RSIStrategy +from .technical.macd_strategy import MACDStrategy +from .technical.moving_avg_strategy import MovingAverageStrategy +from .technical.confirmed_strategy import ConfirmedStrategy +from .technical.divergence_strategy import DivergenceStrategy +from .technical.bollinger_mean_reversion import BollingerMeanReversionStrategy +from .dca.dca_strategy import DCAStrategy +from .grid.grid_strategy import GridStrategy +from .momentum.momentum_strategy import MomentumStrategy +from .ensemble.consensus_strategy import ConsensusStrategy +from .technical.pairs_trading import PairsTradingStrategy +from .technical.volatility_breakout import VolatilityBreakoutStrategy +from .sentiment.sentiment_strategy import SentimentStrategy +from .market_making.market_making_strategy import MarketMakingStrategy + +# Register strategies +registry = get_strategy_registry() +registry.register("rsi", RSIStrategy) +registry.register("macd", MACDStrategy) +registry.register("moving_average", MovingAverageStrategy) +registry.register("confirmed", ConfirmedStrategy) +registry.register("divergence", DivergenceStrategy) +registry.register("bollinger_mean_reversion", BollingerMeanReversionStrategy) +registry.register("dca", DCAStrategy) +registry.register("grid", GridStrategy) +registry.register("momentum", MomentumStrategy) +registry.register("consensus", ConsensusStrategy) +registry.register("pairs_trading", PairsTradingStrategy) +registry.register("volatility_breakout", VolatilityBreakoutStrategy) +registry.register("sentiment", SentimentStrategy) +registry.register("market_making", MarketMakingStrategy) + +__all__ = [ + 'BaseStrategy', 'StrategyRegistry', 'get_strategy_registry', + 'SignalType', 'StrategySignal', + 'RSIStrategy', 'MACDStrategy', 'MovingAverageStrategy', + 'ConfirmedStrategy', 'DivergenceStrategy', 'BollingerMeanReversionStrategy', + 'DCAStrategy', 'GridStrategy', 'MomentumStrategy', + 'ConsensusStrategy', 'PairsTradingStrategy', 'VolatilityBreakoutStrategy', + 'SentimentStrategy', 'MarketMakingStrategy' +] + diff --git a/src/strategies/base.py b/src/strategies/base.py new file mode 100644 index 00000000..ff9690e8 --- /dev/null +++ b/src/strategies/base.py @@ -0,0 +1,450 @@ +"""Base strategy class and strategy registry system.""" + +import pandas as pd +from abc import ABC, abstractmethod +from decimal import Decimal +from typing import Dict, Optional, List, Any, Callable +from datetime import datetime +from enum import Enum +from src.core.logger import get_logger +from src.core.database import OrderSide, OrderType + +logger = get_logger(__name__) + + +class SignalType(str, Enum): + """Trading signal types.""" + BUY = "buy" + SELL = "sell" + HOLD = "hold" + CLOSE = "close" + + +class StrategySignal: + """Trading signal from strategy.""" + + def __init__( + self, + signal_type: SignalType, + symbol: str, + strength: float = 1.0, + price: Optional[Decimal] = None, + quantity: Optional[Decimal] = None, + metadata: Optional[Dict[str, Any]] = None + ): + """Initialize strategy signal. + + Args: + signal_type: Signal type (buy, sell, hold, close) + symbol: Trading symbol + strength: Signal strength (0.0 to 1.0) + price: Suggested price + quantity: Suggested quantity + metadata: Additional metadata + """ + self.signal_type = signal_type + self.symbol = symbol + self.strength = strength + self.price = price + self.quantity = quantity + self.metadata = metadata or {} + self.timestamp = datetime.utcnow() + + +class BaseStrategy(ABC): + """Base class for all trading strategies.""" + + def __init__( + self, + name: str, + parameters: Optional[Dict[str, Any]] = None, + timeframes: Optional[List[str]] = None + ): + """Initialize strategy. + + Args: + name: Strategy name + parameters: Strategy parameters + timeframes: List of timeframes (e.g., ['1h', '15m']) + """ + self.name = name + self.parameters = parameters or {} + self.timeframes = timeframes or ['1h'] + self.enabled = False + self.logger = get_logger(f"strategy.{name}") + self._data_cache: Dict[str, Any] = {} + + @abstractmethod + async def on_tick(self, symbol: str, price: Decimal, timeframe: str, data: Dict[str, Any]) -> Optional[StrategySignal]: + """Called on each price update. + + Args: + symbol: Trading symbol + price: Current price + timeframe: Timeframe of the update + data: Additional market data + + Returns: + StrategySignal or None + """ + pass + + @abstractmethod + def on_signal(self, signal: StrategySignal) -> Optional[StrategySignal]: + """Process and potentially modify signal. + + Args: + signal: Generated signal + + Returns: + Modified signal or None to cancel + """ + pass + + def calculate_position_size( + self, + signal: StrategySignal, + balance: Decimal, + price: Decimal, + exchange_adapter=None + ) -> Decimal: + """Calculate position size for signal, accounting for fees. + + Args: + signal: Trading signal + balance: Available balance + price: Current price + exchange_adapter: Exchange adapter for fee calculation (optional) + + Returns: + Position size + """ + # Default: use 2% of balance + risk_percent = self.parameters.get('position_size_percent', 2.0) / 100.0 + position_value = balance * Decimal(str(risk_percent)) + + # Account for fees by reserving fee amount + from src.trading.fee_calculator import get_fee_calculator + fee_calculator = get_fee_calculator() + + # Reserve ~0.4% for round-trip fees (conservative estimate) + fee_reserve = fee_calculator.calculate_fee_reserve( + position_value=position_value, + exchange_adapter=exchange_adapter, + reserve_percent=0.004 # 0.4% for round-trip + ) + + # Adjust position value to account for fees + adjusted_position_value = position_value - fee_reserve + + # Calculate quantity + if price > 0: + quantity = adjusted_position_value / price + return max(Decimal(0), quantity) # Ensure non-negative + + return Decimal(0) + + def should_execute(self, signal: StrategySignal) -> bool: + """Check if signal should be executed. + + Args: + signal: Trading signal + + Returns: + True if should execute + """ + if not self.enabled: + return False + + # Check signal strength threshold + min_strength = self.parameters.get('min_signal_strength', 0.5) + if signal.strength < min_strength: + return False + + return True + + def should_execute_with_fees( + self, + signal: StrategySignal, + balance: Decimal, + price: Decimal, + exchange_adapter=None + ) -> bool: + """Check if signal should be executed considering fees and minimum profit threshold. + + Args: + signal: Trading signal + balance: Available balance + price: Current price + exchange_adapter: Exchange adapter for fee calculation (optional) + + Returns: + True if should execute after fee consideration + """ + # First check basic execution criteria + if not self.should_execute(signal): + return False + + # Calculate position size + quantity = signal.quantity or self.calculate_position_size(signal, balance, price, exchange_adapter) + + if quantity <= 0: + return False + + # Check minimum profit threshold + from src.trading.fee_calculator import get_fee_calculator + fee_calculator = get_fee_calculator() + + # Get minimum profit multiplier from strategy parameters (default 2.0) + min_profit_multiplier = self.parameters.get('min_profit_multiplier', 2.0) + + min_profit_threshold = fee_calculator.get_minimum_profit_threshold( + quantity=quantity, + price=price, + exchange_adapter=exchange_adapter, + multiplier=min_profit_multiplier + ) + + # Estimate potential profit (simplified - strategies can override) + # For buy signals, we'd need to estimate exit price + # For now, we'll use a basic check: if signal strength is high enough + # Strategies should override this method for more sophisticated checks + + # If we have a target price in signal metadata, use it + target_price = signal.metadata.get('target_price') + if target_price: + if signal.signal_type.value == "buy": + potential_profit = (target_price - price) * quantity + else: # sell + potential_profit = (price - target_price) * quantity + + if potential_profit < min_profit_threshold: + self.logger.debug( + f"Signal filtered: potential profit {potential_profit} < " + f"minimum threshold {min_profit_threshold}" + ) + return False + + return True + + def apply_trend_filter( + self, + signal: StrategySignal, + ohlcv_data: Any, + adx_period: int = 14, + min_adx: float = 25.0 + ) -> Optional[StrategySignal]: + """Apply ADX-based trend filter to signal. + + Filters signals based on trend strength: + - Only allow BUY signals when ADX > threshold (strong trend) + - Only allow SELL signals in downtrends with ADX > threshold + - Filters out choppy/ranging markets + + Args: + signal: Trading signal to filter + ohlcv_data: OHLCV DataFrame with columns: high, low, close + adx_period: ADX calculation period (default 14) + min_adx: Minimum ADX value for signal (default 25.0) + + Returns: + Filtered signal or None if filtered out + """ + if not self.parameters.get('use_trend_filter', False): + return signal + + try: + from src.data.indicators import get_indicators + + if ohlcv_data is None or len(ohlcv_data) < adx_period: + # Not enough data, allow signal + return signal + + # Ensure we have a DataFrame + if not isinstance(ohlcv_data, pd.DataFrame): + return signal + + indicators = get_indicators() + + # Calculate ADX + high = ohlcv_data['high'] + low = ohlcv_data['low'] + close = ohlcv_data['close'] + + adx = indicators.adx(high, low, close, period=adx_period) + current_adx = adx.iloc[-1] if not pd.isna(adx.iloc[-1]) else 0.0 + + # Check if trend is strong enough + if current_adx < min_adx: + # Weak trend - filter out signal + self.logger.debug( + f"Trend filter: ADX {current_adx:.2f} < {min_adx}, " + f"filtering {signal.signal_type.value} signal" + ) + return None + + # Additional check: for BUY signals, ensure uptrend + # For SELL signals, ensure downtrend + # We can use price vs moving average to determine trend direction + if len(close) >= 20: + sma_20 = indicators.sma(close, period=20) + current_price = close.iloc[-1] + sma_value = sma_20.iloc[-1] if not pd.isna(sma_20.iloc[-1]) else current_price + + if signal.signal_type == SignalType.BUY: + # BUY only in uptrend (price above SMA) + if current_price < sma_value: + self.logger.debug( + f"Trend filter: BUY signal filtered - price below SMA " + f"(price: {current_price}, SMA: {sma_value})" + ) + return None + elif signal.signal_type == SignalType.SELL: + # SELL only in downtrend (price below SMA) + if current_price > sma_value: + self.logger.debug( + f"Trend filter: SELL signal filtered - price above SMA " + f"(price: {current_price}, SMA: {sma_value})" + ) + return None + + return signal + + except Exception as e: + self.logger.warning(f"Error applying trend filter: {e}, allowing signal") + return signal + + def get_required_indicators(self) -> List[str]: + """Get list of required indicators. + + Returns: + List of indicator names + """ + return [] + + def validate_parameters(self) -> bool: + """Validate strategy parameters. + + Returns: + True if parameters are valid + """ + return True + + def get_state(self) -> Dict[str, Any]: + """Get strategy state for persistence. + + Returns: + State dictionary + """ + return { + 'name': self.name, + 'parameters': self.parameters, + 'timeframes': self.timeframes, + 'enabled': self.enabled, + } + + def set_state(self, state: Dict[str, Any]): + """Restore strategy state. + + Args: + state: State dictionary + """ + self.parameters = state.get('parameters', {}) + self.timeframes = state.get('timeframes', ['1h']) + self.enabled = state.get('enabled', False) + + +class StrategyRegistry: + """Registry for managing strategies.""" + + def __init__(self): + """Initialize strategy registry.""" + self._strategies: Dict[str, type] = {} + self._instances: Dict[int, BaseStrategy] = {} + self.logger = get_logger(__name__) + + def register(self, name: str, strategy_class: type): + """Register a strategy class. + + Args: + name: Strategy name + strategy_class: Strategy class (subclass of BaseStrategy) + """ + if not issubclass(strategy_class, BaseStrategy): + raise ValueError(f"Strategy class must inherit from BaseStrategy") + + self._strategies[name.lower()] = strategy_class + self.logger.info(f"Registered strategy: {name}") + + def create_instance( + self, + strategy_id: int, + name: str, + parameters: Optional[Dict[str, Any]] = None, + timeframes: Optional[List[str]] = None + ) -> Optional[BaseStrategy]: + """Create strategy instance. + + Args: + strategy_id: Strategy ID from src.database + name: Strategy name + parameters: Strategy parameters + timeframes: List of timeframes + + Returns: + Strategy instance or None + """ + strategy_class = self._strategies.get(name.lower()) + if not strategy_class: + self.logger.error(f"Strategy {name} not registered") + return None + + try: + instance = strategy_class(name, parameters, timeframes) + self._instances[strategy_id] = instance + return instance + except Exception as e: + self.logger.error(f"Failed to create strategy instance: {e}") + return None + + def get_instance(self, strategy_id: int) -> Optional[BaseStrategy]: + """Get strategy instance by ID. + + Args: + strategy_id: Strategy ID + + Returns: + Strategy instance or None + """ + return self._instances.get(strategy_id) + + def list_available(self) -> List[str]: + """List available strategy types. + + Returns: + List of strategy names + """ + return list(self._strategies.keys()) + + def unregister(self, name: str): + """Unregister a strategy. + + Args: + name: Strategy name + """ + if name.lower() in self._strategies: + del self._strategies[name.lower()] + self.logger.info(f"Unregistered strategy: {name}") + + +# Global strategy registry +_registry: Optional[StrategyRegistry] = None + + +def get_strategy_registry() -> StrategyRegistry: + """Get global strategy registry instance.""" + global _registry + if _registry is None: + _registry = StrategyRegistry() + return _registry + diff --git a/src/strategies/dca/__init__.py b/src/strategies/dca/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/strategies/dca/dca_strategy.py b/src/strategies/dca/dca_strategy.py new file mode 100644 index 00000000..ee864f6b --- /dev/null +++ b/src/strategies/dca/dca_strategy.py @@ -0,0 +1,90 @@ +"""Dollar Cost Averaging (DCA) strategy.""" + +from decimal import Decimal +from datetime import datetime, timedelta +from typing import Optional, Dict, Any +from src.strategies.base import BaseStrategy, StrategySignal, SignalType +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class DCAStrategy(BaseStrategy): + """Dollar Cost Averaging strategy - fixed amount per interval.""" + + def __init__(self, name: str, parameters: Optional[Dict[str, Any]] = None, timeframes: Optional[list] = None): + """Initialize DCA strategy. + + Parameters: + amount: Fixed amount to invest per interval (default: 10) + interval: Interval type - 'daily', 'weekly', 'monthly' (default: 'daily') + target_allocation: Target allocation percentage (default: 10%) + """ + super().__init__(name, parameters, timeframes) + self.amount = Decimal(str(self.parameters.get('amount', 10))) + self.interval = self.parameters.get('interval', 'daily') + self.target_allocation = Decimal(str(self.parameters.get('target_allocation', 10))) / 100 + + # Track last purchase + self.last_purchase_time: Optional[datetime] = None + self._calculate_interval_delta() + + def _calculate_interval_delta(self): + """Calculate timedelta for interval.""" + if self.interval == 'daily': + self.interval_delta = timedelta(days=1) + elif self.interval == 'weekly': + self.interval_delta = timedelta(weeks=1) + elif self.interval == 'monthly': + self.interval_delta = timedelta(days=30) + else: + self.interval_delta = timedelta(days=1) + + async def on_tick(self, symbol: str, price: Decimal, timeframe: str, data: Dict[str, Any]) -> Optional[StrategySignal]: + """Generate DCA signal based on interval.""" + now = datetime.utcnow() + + # Check if enough time has passed since last purchase + if self.last_purchase_time: + time_since_last = now - self.last_purchase_time + if time_since_last < self.interval_delta: + return None + + # Generate buy signal for fixed amount + quantity = self.amount / price + + self.last_purchase_time = now + + return StrategySignal( + signal_type=SignalType.BUY, + symbol=symbol, + strength=1.0, + price=price, + quantity=quantity, + metadata={ + 'amount': float(self.amount), + 'interval': self.interval, + 'strategy': 'dca' + } + ) + + def on_signal(self, signal: StrategySignal) -> Optional[StrategySignal]: + """Process signal.""" + return signal if self.should_execute(signal) else None + + def should_rebalance(self, current_allocation: Decimal, portfolio_value: Decimal) -> bool: + """Check if rebalancing is needed. + + Args: + current_allocation: Current allocation percentage + portfolio_value: Total portfolio value + + Returns: + True if rebalancing needed + """ + target_value = portfolio_value * self.target_allocation + current_value = portfolio_value * current_allocation + + # Rebalance if allocation deviates by more than 5% + deviation = abs(current_allocation - self.target_allocation) + return deviation > Decimal('0.05') diff --git a/src/strategies/ensemble/__init__.py b/src/strategies/ensemble/__init__.py new file mode 100644 index 00000000..5b987add --- /dev/null +++ b/src/strategies/ensemble/__init__.py @@ -0,0 +1,6 @@ +"""Ensemble strategy package.""" + +from .consensus_strategy import ConsensusStrategy + +__all__ = ['ConsensusStrategy'] + diff --git a/src/strategies/ensemble/consensus_strategy.py b/src/strategies/ensemble/consensus_strategy.py new file mode 100644 index 00000000..abb59dc4 --- /dev/null +++ b/src/strategies/ensemble/consensus_strategy.py @@ -0,0 +1,244 @@ +"""Ensemble/consensus strategy. + +Combines signals from multiple strategies with voting mechanism. +Only executes when multiple strategies agree, improving signal quality. +""" + +from decimal import Decimal +from typing import Optional, Dict, Any, List +import pandas as pd +from src.strategies.base import BaseStrategy, StrategySignal, SignalType, get_strategy_registry +from src.core.logger import get_logger +from src.autopilot.performance_tracker import get_performance_tracker + +logger = get_logger(__name__) + + +class ConsensusStrategy(BaseStrategy): + """Ensemble strategy that combines signals from multiple strategies. + + This strategy aggregates signals from multiple registered strategies + and only generates signals when a minimum consensus is reached. + Signals are weighted by strategy performance metrics. + """ + + def __init__(self, name: str, parameters: Optional[Dict[str, Any]] = None, timeframes: Optional[list] = None): + """Initialize consensus strategy. + + Parameters: + strategy_names: List of strategy names to include (None = all) + min_consensus: Minimum number of strategies that must agree (default 2) + use_weights: Weight signals by strategy performance (default True) + min_weight: Minimum weight for a strategy to participate (default 0.3) + exclude_strategies: List of strategy names to exclude + """ + super().__init__(name, parameters, timeframes) + + self.strategy_names = self.parameters.get('strategy_names', None) + self.min_consensus = self.parameters.get('min_consensus', 2) + self.use_weights = self.parameters.get('use_weights', True) + self.min_weight = self.parameters.get('min_weight', 0.3) + self.exclude_strategies = self.parameters.get('exclude_strategies', []) + + self.registry = get_strategy_registry() + self.performance_tracker = get_performance_tracker() + self._strategy_instances: Dict[str, BaseStrategy] = {} + self._strategy_weights: Dict[str, float] = {} + + # Initialize strategy instances + self._initialize_strategies() + + # Calculate weights if using weighted voting + if self.use_weights: + self._calculate_weights() + + def _initialize_strategies(self): + """Initialize instances of strategies to monitor.""" + if self.strategy_names is None: + available = self.registry.list_available() + # Aggressive filtering to prevent recursion + self.strategy_names = [ + s for s in available + if s not in self.exclude_strategies + and "consensus" not in s.lower() + and s.lower() != self.name.lower() + ] + self.logger.info(f"ConsensusStrategy({self.name}) automatically selected strategies: {self.strategy_names}") + else: + self.logger.info(f"ConsensusStrategy({self.name}) manually selected strategies: {self.strategy_names}") + + for strategy_name in self.strategy_names: + try: + strategy_class = self.registry._strategies.get(strategy_name.lower()) + if strategy_class: + instance = strategy_class( + name=f"{strategy_name}_consensus", + parameters={}, + timeframes=self.timeframes + ) + instance.enabled = True + self._strategy_instances[strategy_name] = instance + self.logger.debug(f"Initialized strategy for consensus: {strategy_name}") + except Exception as e: + self.logger.warning(f"Failed to initialize strategy {strategy_name} for consensus: {e}") + + async def _calculate_weights(self): + """Calculate weights for strategies based on performance.""" + for strategy_name in self._strategy_instances.keys(): + try: + metrics = await self.performance_tracker.calculate_metrics(strategy_name, period_days=30) + + # Weight based on win rate and Sharpe ratio + win_rate = metrics.get('win_rate', 0.5) + sharpe_ratio = max(0.0, metrics.get('sharpe_ratio', 0.0)) + + # Normalize to 0-1 range + weight = (win_rate * 0.6) + (min(sharpe_ratio / 2.0, 1.0) * 0.4) + + # Apply minimum weight threshold + if weight < self.min_weight: + weight = self.min_weight + + self._strategy_weights[strategy_name] = weight + self.logger.debug(f"Strategy {strategy_name} weight: {weight:.2f}") + except Exception as e: + # Default weight if metrics unavailable + self._strategy_weights[strategy_name] = 0.5 + self.logger.warning(f"Could not calculate weight for {strategy_name}: {e}") + + async def _collect_signals( + self, + symbol: str, + price: Decimal, + timeframe: str, + data: Dict[str, Any] + ) -> List[tuple[str, StrategySignal, float]]: + """Collect signals from all monitored strategies. + + Args: + symbol: Trading symbol + price: Current price + timeframe: Timeframe + data: Market data + + Returns: + List of (strategy_name, signal, weight) tuples + """ + signals: List[tuple[str, StrategySignal, float]] = [] + + for strategy_name, strategy_instance in self._strategy_instances.items(): + try: + signal = await strategy_instance.on_tick(symbol, price, timeframe, data) + if signal and signal.signal_type != SignalType.HOLD: + # Process signal through strategy's on_signal + signal = strategy_instance.on_signal(signal) + if signal: + weight = self._strategy_weights.get(strategy_name, 0.5) if self.use_weights else 1.0 + signals.append((strategy_name, signal, weight)) + except Exception as e: + import traceback + self.logger.warning(f"Error getting signal from {strategy_name}: {e}\n{traceback.format_exc()}") + + return signals + + def _aggregate_signals( + self, + signals: List[tuple[str, StrategySignal, float]] + ) -> Optional[StrategySignal]: + """Aggregate signals and determine consensus. + + Args: + signals: List of (strategy_name, signal, weight) tuples + + Returns: + Consensus signal or None + """ + if not signals: + return None + + # Group signals by type + buy_signals: List[tuple[str, StrategySignal, float]] = [] + sell_signals: List[tuple[str, StrategySignal, float]] = [] + + for strategy_name, signal, weight in signals: + if signal.signal_type == SignalType.BUY: + buy_signals.append((strategy_name, signal, weight)) + elif signal.signal_type == SignalType.SELL: + sell_signals.append((strategy_name, signal, weight)) + + # Calculate weighted consensus scores + buy_score = sum(weight * signal.strength for _, signal, weight in buy_signals) + sell_score = sum(weight * signal.strength for _, signal, weight in sell_signals) + + buy_count = len(buy_signals) + sell_count = len(sell_signals) + + # Determine final signal + final_signal_type = None + consensus_count = 0 + consensus_score = 0.0 + participating_strategies = [] + + if buy_count >= self.min_consensus and buy_score > sell_score: + final_signal_type = SignalType.BUY + consensus_count = buy_count + consensus_score = buy_score + participating_strategies = [name for name, _, _ in buy_signals] + elif sell_count >= self.min_consensus and sell_score > buy_score: + final_signal_type = SignalType.SELL + consensus_count = sell_count + consensus_score = sell_score + participating_strategies = [name for name, _, _ in sell_signals] + + if final_signal_type is None: + return None + + # Calculate final signal strength (normalized) + max_possible_score = len(self._strategy_instances) * 1.0 # Max weight * max strength + strength = min(1.0, consensus_score / max_possible_score) if max_possible_score > 0 else 0.5 + + # Use price from strongest signal + strongest_signal = max( + buy_signals if final_signal_type == SignalType.BUY else sell_signals, + key=lambda x: x[2] * x[1].strength # weight * strength + )[1] + + return StrategySignal( + signal_type=final_signal_type, + symbol=strongest_signal.symbol, + strength=strength, + price=strongest_signal.price, + metadata={ + 'consensus_count': consensus_count, + 'consensus_score': consensus_score, + 'participating_strategies': participating_strategies, + 'buy_count': buy_count, + 'sell_count': sell_count, + 'buy_score': buy_score, + 'sell_score': sell_score, + 'strategy': 'consensus' + } + ) + + async def on_tick(self, symbol: str, price: Decimal, timeframe: str, data: Dict[str, Any]) -> Optional[StrategySignal]: + """Generate consensus signal from multiple strategies.""" + # Update weights periodically (every 100 ticks) + if not hasattr(self, '_tick_count'): + self._tick_count = 0 + self._tick_count += 1 + + if self.use_weights and self._tick_count % 100 == 0: + await self._calculate_weights() + + # Collect signals from all strategies + signals = await self._collect_signals(symbol, price, timeframe, data) + + # Aggregate signals + consensus_signal = self._aggregate_signals(signals) + + return consensus_signal + + def on_signal(self, signal: StrategySignal) -> Optional[StrategySignal]: + """Process signal.""" + return signal if self.should_execute(signal) else None + diff --git a/src/strategies/grid/__init__.py b/src/strategies/grid/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/strategies/grid/grid_strategy.py b/src/strategies/grid/grid_strategy.py new file mode 100644 index 00000000..dc013066 --- /dev/null +++ b/src/strategies/grid/grid_strategy.py @@ -0,0 +1,109 @@ +"""Grid trading strategy.""" + +from decimal import Decimal +from typing import Optional, Dict, Any, List +from src.strategies.base import BaseStrategy, StrategySignal, SignalType +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class GridStrategy(BaseStrategy): + """Grid trading strategy - buy at lower levels, sell at higher levels.""" + + def __init__(self, name: str, parameters: Optional[Dict[str, Any]] = None, timeframes: Optional[list] = None): + """Initialize Grid strategy. + + Parameters: + grid_spacing: Percentage spacing between grid levels (default: 1%) + num_levels: Number of grid levels above and below center (default: 10) + center_price: Center price for grid (default: current price) + profit_target: Profit target percentage (default: 2%) + """ + super().__init__(name, parameters, timeframes) + self.grid_spacing = Decimal(str(self.parameters.get('grid_spacing', 1))) / 100 + self.num_levels = self.parameters.get('num_levels', 10) + self.center_price = self.parameters.get('center_price') + self.profit_target = Decimal(str(self.parameters.get('profit_target', 2))) / 100 + + # Grid levels + self.buy_levels: List[Decimal] = [] + self.sell_levels: List[Decimal] = [] + self.positions: Dict[Decimal, Decimal] = {} # entry_price -> quantity + + def _update_grid_levels(self, current_price: Decimal): + """Update grid levels based on current price.""" + if not self.center_price: + self.center_price = current_price + + # Calculate grid levels + self.buy_levels = [] + self.sell_levels = [] + + for i in range(1, self.num_levels + 1): + # Buy levels below center + buy_price = self.center_price * (1 - self.grid_spacing * Decimal(i)) + self.buy_levels.append(buy_price) + + # Sell levels above center + sell_price = self.center_price * (1 + self.grid_spacing * Decimal(i)) + self.sell_levels.append(sell_price) + + # Sort levels + self.buy_levels.sort(reverse=True) # Highest to lowest + self.sell_levels.sort() # Lowest to highest + + async def on_tick(self, symbol: str, price: Decimal, timeframe: str, data: Dict[str, Any]) -> Optional[StrategySignal]: + """Generate grid trading signals.""" + self._update_grid_levels(price) + + # Check buy levels + for buy_level in self.buy_levels: + if price <= buy_level and buy_level not in self.positions: + # Buy signal + quantity = self.parameters.get('position_size', Decimal('0.1')) + return StrategySignal( + signal_type=SignalType.BUY, + symbol=symbol, + strength=0.8, + price=buy_level, + quantity=quantity, + metadata={ + 'grid_level': float(buy_level), + 'strategy': 'grid', + 'type': 'buy' + } + ) + + # Check sell levels (profit taking) + for entry_price, quantity in list(self.positions.items()): + profit_pct = (price - entry_price) / entry_price + if profit_pct >= self.profit_target: + # Sell signal for profit taking + del self.positions[entry_price] + return StrategySignal( + signal_type=SignalType.SELL, + symbol=symbol, + strength=1.0, + price=price, + quantity=quantity, + metadata={ + 'entry_price': float(entry_price), + 'profit_pct': float(profit_pct * 100), + 'strategy': 'grid', + 'type': 'profit_take' + } + ) + + return None + + def on_signal(self, signal: StrategySignal) -> Optional[StrategySignal]: + """Process signal and track positions.""" + if signal.signal_type == SignalType.BUY: + # Track position + self.positions[signal.price] = signal.quantity + elif signal.signal_type == SignalType.SELL: + # Position already removed in on_tick + pass + + return signal if self.should_execute(signal) else None diff --git a/src/strategies/market_making/__init__.py b/src/strategies/market_making/__init__.py new file mode 100644 index 00000000..cb720fa0 --- /dev/null +++ b/src/strategies/market_making/__init__.py @@ -0,0 +1,5 @@ +"""Market making strategy package.""" + +from .market_making_strategy import MarketMakingStrategy + +__all__ = ['MarketMakingStrategy'] diff --git a/src/strategies/market_making/market_making_strategy.py b/src/strategies/market_making/market_making_strategy.py new file mode 100644 index 00000000..5ff8491c --- /dev/null +++ b/src/strategies/market_making/market_making_strategy.py @@ -0,0 +1,206 @@ +"""Market Making Strategy - Profits from the bid-ask spread in sideways markets.""" + +from decimal import Decimal +from typing import Dict, Any, Optional, List +from datetime import datetime, timedelta + +from ..base import BaseStrategy, StrategySignal, SignalType +from src.data.pricing_service import get_pricing_service +from src.data.indicators import get_indicators +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class MarketMakingStrategy(BaseStrategy): + """ + Market Making Strategy that profits from the bid-ask spread. + + Logic: + 1. Calculate mid-price from order book or last trade. + 2. Place limit BUY order at (mid - spread%). + 3. Place limit SELL order at (mid + spread%). + 4. Re-quote orders when price moves beyond threshold. + 5. Use inventory skew to manage risk (if holding too much, lower sell price). + + Best suited for: + - Sideways/ranging markets with low volatility + - High-liquidity pairs with tight spreads + + Risks: + - Adverse selection (getting filled on wrong side during trends) + - Inventory accumulation + """ + + def __init__(self, name: str, parameters: Dict[str, Any], timeframes: List[str] = None): + super().__init__(name, parameters, timeframes) + + # Strategy parameters + self.spread_percent = float(parameters.get('spread_percent', 0.2)) / 100 # Convert to decimal + self.requote_threshold = float(parameters.get('requote_threshold', 0.5)) / 100 + self.max_inventory = Decimal(str(parameters.get('max_inventory', 1.0))) + self.inventory_skew_factor = float(parameters.get('inventory_skew_factor', 0.5)) + self.min_adx = float(parameters.get('min_adx', 20)) # Only make markets when ADX < this (low trend) + self.order_size_percent = float(parameters.get('order_size_percent', 5)) / 100 + + self.pricing_service = get_pricing_service() + self.indicators = get_indicators() + + # Track active orders and inventory + self._current_inventory = Decimal('0') + self._last_mid_price: Optional[Decimal] = None + self._last_quote_time: Optional[datetime] = None + self._active_orders: Dict[str, Any] = {} # side -> order info + + def _calculate_skewed_spread(self, inventory: Decimal) -> tuple[float, float]: + """Calculate bid/ask spread with inventory skew. + + When inventory is positive (long), we want to sell more aggressively. + When inventory is negative (short), we want to buy more aggressively. + + Returns: + Tuple of (bid_spread, ask_spread) as percentages + """ + base_spread = self.spread_percent + + # Calculate skew based on inventory relative to max + if self.max_inventory > 0: + inventory_ratio = float(inventory / self.max_inventory) + else: + inventory_ratio = 0.0 + + # Clamp between -1 and 1 + inventory_ratio = max(-1.0, min(1.0, inventory_ratio)) + + # Apply skew + skew = inventory_ratio * self.inventory_skew_factor * base_spread + + # If positive inventory, tighten ask (lower sell price), widen bid + # If negative inventory, tighten bid (higher buy price), widen ask + bid_spread = base_spread + skew # Positive inventory -> wider bid + ask_spread = base_spread - skew # Positive inventory -> tighter ask + + # Ensure spreads are positive + bid_spread = max(0.0001, bid_spread) + ask_spread = max(0.0001, ask_spread) + + return bid_spread, ask_spread + + async def on_tick(self, symbol: str, price: Decimal, timeframe: str, data: Dict[str, Any]) -> Optional[StrategySignal]: + """Check for market making opportunities.""" + if not self.enabled: + return None + + try: + current_price = price + + # Fetch OHLCV for ADX calculation + ohlcv = self.pricing_service.get_ohlcv( + symbol=symbol, + timeframe=timeframe, + limit=30 + ) + + if ohlcv and len(ohlcv) >= 14: + df_data = { + 'high': [float(c[2]) for c in ohlcv], + 'low': [float(c[3]) for c in ohlcv], + 'close': [float(c[4]) for c in ohlcv] + } + import pandas as pd + df = pd.DataFrame(df_data) + + adx = self.indicators.adx(df['high'], df['low'], df['close'], period=14) + current_adx = adx.iloc[-1] if not pd.isna(adx.iloc[-1]) else 0.0 + + # Only make markets in low-trend environments + if current_adx > self.min_adx: + self.logger.debug( + f"Market Making skipped: ADX {current_adx:.1f} > {self.min_adx} (trending market)" + ) + return None + else: + current_adx = 0.0 + + # Check if we need to requote + should_requote = False + + if self._last_mid_price is None: + should_requote = True + else: + price_change = abs(float(current_price - self._last_mid_price) / float(self._last_mid_price)) + if price_change > self.requote_threshold: + should_requote = True + self.logger.debug(f"Requote triggered: price moved {price_change:.2%}") + + if not should_requote: + return None + + # Calculate skewed spreads based on inventory + bid_spread, ask_spread = self._calculate_skewed_spread(self._current_inventory) + + # Calculate quote prices + bid_price = current_price * Decimal(str(1 - bid_spread)) + ask_price = current_price * Decimal(str(1 + ask_spread)) + + # Round to 2 decimal places + bid_price = bid_price.quantize(Decimal('0.01')) + ask_price = ask_price.quantize(Decimal('0.01')) + + self._last_mid_price = current_price + self._last_quote_time = datetime.now() + + self.logger.info( + f"Market Making {symbol}: Mid={current_price}, " + f"Bid={bid_price} ({bid_spread:.3%}), Ask={ask_price} ({ask_spread:.3%}), " + f"Inventory={self._current_inventory}, ADX={current_adx:.1f}" + ) + + # Generate signal for placing both limit orders + # The execution engine will need special handling for this + signal = StrategySignal( + signal_type=SignalType.HOLD, # Special: not BUY or SELL, but both + symbol=symbol, + strength=1.0 - (current_adx / 100), # Higher strength when less trending + price=current_price, + metadata={ + "strategy": "market_making", + "order_type": "limit_pair", # Indicates we want both bid and ask + "bid_price": float(bid_price), + "ask_price": float(ask_price), + "bid_spread": bid_spread, + "ask_spread": ask_spread, + "inventory": float(self._current_inventory), + "adx": float(current_adx), + "description": f"Market Making: Bid={bid_price}, Ask={ask_price}" + } + ) + return signal + + except Exception as e: + self.logger.error(f"Error in MarketMakingStrategy: {e}") + return None + + return None + + def update_inventory(self, side: str, quantity: Decimal): + """Update inventory after a fill. + + Args: + side: 'buy' or 'sell' + quantity: Filled quantity + """ + if side == 'buy': + self._current_inventory += quantity + elif side == 'sell': + self._current_inventory -= quantity + + self.logger.info(f"Inventory updated: {side} {quantity}, new inventory: {self._current_inventory}") + + def get_inventory(self) -> Decimal: + """Get current inventory position.""" + return self._current_inventory + + def on_signal(self, signal: StrategySignal) -> Optional[StrategySignal]: + """Process signal (pass-through).""" + return signal diff --git a/src/strategies/momentum/__init__.py b/src/strategies/momentum/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/strategies/momentum/momentum_strategy.py b/src/strategies/momentum/momentum_strategy.py new file mode 100644 index 00000000..f3bef498 --- /dev/null +++ b/src/strategies/momentum/momentum_strategy.py @@ -0,0 +1,138 @@ +"""Momentum trading strategy.""" + +from decimal import Decimal +from typing import Optional, Dict, Any, List +import pandas as pd +from src.strategies.base import BaseStrategy, StrategySignal, SignalType +from src.data.indicators import get_indicators +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class MomentumStrategy(BaseStrategy): + """Momentum-based trading strategy.""" + + def __init__(self, name: str, parameters: Optional[Dict[str, Any]] = None, timeframes: Optional[list] = None): + """Initialize Momentum strategy. + + Parameters: + lookback_period: Lookback period for momentum calculation (default: 20) + momentum_threshold: Minimum momentum strength to enter (default: 0.05 = 5%) + volume_threshold: Minimum volume increase for confirmation (default: 1.5x) + exit_threshold: Momentum reversal threshold for exit (default: -0.02 = -2%) + """ + super().__init__(name, parameters, timeframes) + self.lookback_period = self.parameters.get('lookback_period', 20) + self.momentum_threshold = Decimal(str(self.parameters.get('momentum_threshold', 0.05))) + self.volume_threshold = self.parameters.get('volume_threshold', 1.5) + self.exit_threshold = Decimal(str(self.parameters.get('exit_threshold', -0.02))) + + self.indicators = get_indicators() + self._price_history: List[float] = [] + self._volume_history: List[float] = [] + self._in_position = False + self._entry_price: Optional[Decimal] = None + + def _calculate_momentum(self, prices: pd.Series) -> float: + """Calculate price momentum. + + Args: + prices: Series of prices + + Returns: + Momentum value (percentage change) + """ + if len(prices) < self.lookback_period: + return 0.0 + + recent_prices = prices[-self.lookback_period:] + old_price = recent_prices.iloc[0] + new_price = recent_prices.iloc[-1] + + if old_price == 0: + return 0.0 + + return float((new_price - old_price) / old_price) + + def _check_volume_confirmation(self, volumes: pd.Series) -> bool: + """Check if volume confirms momentum. + + Args: + volumes: Series of volumes + + Returns: + True if volume confirms + """ + if len(volumes) < self.lookback_period: + return False + + recent_volumes = volumes[-self.lookback_period:] + avg_volume = recent_volumes.mean() + current_volume = recent_volumes.iloc[-1] + + return current_volume >= avg_volume * self.volume_threshold + + async def on_tick(self, symbol: str, price: Decimal, timeframe: str, data: Dict[str, Any]) -> Optional[StrategySignal]: + """Generate momentum-based signals.""" + # Add to history + self._price_history.append(float(price)) + self._volume_history.append(float(data.get('volume', 0))) + + if len(self._price_history) < self.lookback_period + 1: + return None + + prices = pd.Series(self._price_history) + volumes = pd.Series(self._volume_history) + + # Calculate momentum + momentum = self._calculate_momentum(prices) + + if not self._in_position: + # Entry logic + if momentum >= float(self.momentum_threshold): + # Check volume confirmation + if self._check_volume_confirmation(volumes): + self._in_position = True + self._entry_price = price + + return StrategySignal( + signal_type=SignalType.BUY, + symbol=symbol, + strength=min(momentum / float(self.momentum_threshold), 1.0), + price=price, + metadata={ + 'momentum': momentum, + 'volume_confirmed': True, + 'strategy': 'momentum', + 'type': 'entry' + } + ) + else: + # Exit logic + if momentum <= float(self.exit_threshold): + self._in_position = False + entry_price = self._entry_price or price + + return StrategySignal( + signal_type=SignalType.SELL, + symbol=symbol, + strength=1.0, + price=price, + metadata={ + 'momentum': momentum, + 'entry_price': float(entry_price), + 'strategy': 'momentum', + 'type': 'exit' + } + ) + + return None + + def on_signal(self, signal: StrategySignal) -> Optional[StrategySignal]: + """Process signal.""" + if signal.signal_type == SignalType.SELL: + self._in_position = False + self._entry_price = None + + return signal if self.should_execute(signal) else None diff --git a/src/strategies/scheduler.py b/src/strategies/scheduler.py new file mode 100644 index 00000000..692c3074 --- /dev/null +++ b/src/strategies/scheduler.py @@ -0,0 +1,370 @@ +"""Strategy scheduling system with time and condition-based triggers.""" + +from datetime import datetime, time +from typing import Optional, Callable, Dict, Any, List +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from apscheduler.triggers.interval import IntervalTrigger +from apscheduler.triggers.interval import IntervalTrigger +from src.core.logger import get_logger +from src.core.database import get_database, Strategy +from src.strategies.base import get_strategy_registry, SignalType +from src.trading.engine import get_trading_engine +from src.data.pricing_service import get_pricing_service +from src.core.database import OrderSide, OrderType + +logger = get_logger(__name__) + + +class StrategyScheduler: + """Schedules strategy execution based on time and conditions.""" + + def __init__(self): + """Initialize strategy scheduler.""" + self.scheduler = BackgroundScheduler() + self.scheduler.start() + self.jobs: Dict[int, str] = {} # strategy_id -> job_id + self._active_strategies: Dict[int, Dict[str, Any]] = {} # strategy_id -> status info + self.logger = get_logger(__name__) + + def schedule_time_based( + self, + strategy_id: int, + schedule_config: Dict[str, Any], + callback: Callable + ) -> bool: + """Schedule strategy based on time. + + Args: + strategy_id: Strategy ID + schedule_config: Schedule configuration + - type: 'daily', 'weekly', 'cron' + - time: Time string (HH:MM) for daily + - days: List of days for weekly + - cron: Cron expression + callback: Function to call + + Returns: + True if scheduling successful + """ + try: + # Remove existing job if any + if strategy_id in self.jobs: + self.unschedule(strategy_id) + + schedule_type = schedule_config.get('type', 'daily') + + if schedule_type == 'daily': + time_str = schedule_config.get('time', '09:00') + hour, minute = map(int, time_str.split(':')) + trigger = CronTrigger(hour=hour, minute=minute) + elif schedule_type == 'weekly': + days = schedule_config.get('days', [0, 1, 2, 3, 4]) # Monday-Friday + time_str = schedule_config.get('time', '09:00') + hour, minute = map(int, time_str.split(':')) + trigger = CronTrigger(day_of_week=','.join(map(str, days)), hour=hour, minute=minute) + elif schedule_type == 'cron': + cron_expr = schedule_config.get('cron') + trigger = CronTrigger.from_crontab(cron_expr) + elif schedule_type == 'interval': + interval = schedule_config.get('interval', 60) # seconds + trigger = IntervalTrigger(seconds=interval) + else: + logger.error(f"Unknown schedule type: {schedule_type}") + return False + + job_id = f"strategy_{strategy_id}" + self.scheduler.add_job( + callback, + trigger=trigger, + id=job_id, + replace_existing=True + ) + + self.jobs[strategy_id] = job_id + logger.info(f"Scheduled strategy {strategy_id} with {schedule_type} trigger") + return True + + except Exception as e: + logger.error(f"Failed to schedule strategy {strategy_id}: {e}") + return False + + def schedule_condition_based( + self, + strategy_id: int, + condition: Callable[[], bool], + callback: Callable, + check_interval: int = 60 + ) -> bool: + """Schedule strategy based on condition. + + Args: + strategy_id: Strategy ID + condition: Function that returns True when condition is met + callback: Function to call when condition is met + check_interval: Interval to check condition (seconds) + + Returns: + True if scheduling successful + """ + def check_and_execute(): + if condition(): + callback() + + return self.schedule_time_based( + strategy_id, + {'type': 'interval', 'interval': check_interval}, + check_and_execute + ) + + def unschedule(self, strategy_id: int): + """Unschedule a strategy. + + Args: + strategy_id: Strategy ID + """ + if strategy_id in self.jobs: + job_id = self.jobs[strategy_id] + try: + self.scheduler.remove_job(job_id) + del self.jobs[strategy_id] + logger.info(f"Unscheduled strategy {strategy_id}") + except Exception as e: + logger.error(f"Failed to unschedule strategy {strategy_id}: {e}") + + def is_scheduled(self, strategy_id: int) -> bool: + """Check if strategy is scheduled. + + Args: + strategy_id: Strategy ID + + Returns: + True if scheduled + """ + return strategy_id in self.jobs + + + def start_strategy(self, strategy_id: int): + """Start a strategy execution loop.""" + from sqlalchemy.orm import Session + from sqlalchemy import create_engine + from src.core.config import get_config + + config = get_config() + db_url = config.get("database.url", "postgresql://localhost/crypto_trader") + + # Use sync engine for scheduler context + engine = create_engine(db_url) + + with Session(engine) as session: + try: + strategy_model = session.query(Strategy).filter_by(id=strategy_id).first() + if not strategy_model: + logger.error(f"Cannot start strategy {strategy_id}: Not found") + return + + # Instantiate strategy + registry = get_strategy_registry() + strategy_instance = registry.create_instance( + strategy_id=strategy_id, + name=strategy_model.strategy_type, + parameters=strategy_model.parameters, + timeframes=strategy_model.timeframes + ) + + if not strategy_instance: + logger.error(f"Failed to create instance for strategy {strategy_id}") + return + + strategy_instance.enabled = True + + # Store strategy info for status tracking + self._active_strategies[strategy_id] = { + 'instance': strategy_instance, + 'name': strategy_model.name, + 'type': strategy_model.strategy_type, + 'symbol': strategy_model.parameters.get('symbol'), + 'started_at': datetime.now(), + 'last_tick': None, + 'last_signal': None, + 'signal_count': 0, + 'error_count': 0, + } + + # Use 'interval' from parameters, default 60s + interval = strategy_model.parameters.get('interval', 60) + + def execute_wrapper(): + self._execute_strategy_sync(strategy_id) + + self.schedule_time_based( + strategy_id, + {'type': 'interval', 'interval': interval}, + execute_wrapper + ) + logger.info(f"Started strategy {strategy_id} ({strategy_model.name})") + + except Exception as e: + logger.error(f"Error initiating strategy {strategy_id}: {e}") + + def stop_strategy(self, strategy_id: int): + """Stop a strategy.""" + self.unschedule(strategy_id) + + # Remove from active strategies + if strategy_id in self._active_strategies: + del self._active_strategies[strategy_id] + + logger.info(f"Stopped strategy {strategy_id}") + + def get_strategy_status(self, strategy_id: int) -> Optional[Dict[str, Any]]: + """Get status of a running strategy.""" + if strategy_id not in self._active_strategies: + return None + + info = self._active_strategies[strategy_id] + return { + 'strategy_id': strategy_id, + 'name': info['name'], + 'type': info['type'], + 'symbol': info['symbol'], + 'running': True, + 'started_at': info['started_at'].isoformat() if info['started_at'] else None, + 'last_tick': info['last_tick'].isoformat() if info['last_tick'] else None, + 'last_signal': info['last_signal'], + 'signal_count': info['signal_count'], + 'error_count': info['error_count'], + } + + def get_all_active_strategies(self) -> List[Dict[str, Any]]: + """Get status of all active strategies.""" + return [self.get_strategy_status(sid) for sid in self._active_strategies.keys()] + + def _execute_strategy_sync(self, strategy_id: int): + """Execute a single strategy cycle (synchronous wrapper).""" + if strategy_id not in self._active_strategies: + logger.warning(f"Strategy {strategy_id} not in active list, skipping execution") + return + + info = self._active_strategies[strategy_id] + strategy_instance = info['instance'] + + try: + # 1. Fetch Data + symbol = strategy_instance.parameters.get('symbol') + timeframe = strategy_instance.timeframes[0] if strategy_instance.timeframes else '1h' + + pricing_service = get_pricing_service() + ticker = pricing_service.get_ticker(symbol) # Sync call + current_price = ticker.get('last') + + if not current_price: + logger.debug(f"No price for {symbol}, skipping") + return + + # Update last tick + info['last_tick'] = datetime.now() + + # 2. Run async on_tick in sync context + import asyncio + try: + loop = asyncio.get_event_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + signal = loop.run_until_complete( + strategy_instance.on_tick(symbol, current_price, timeframe, ticker) + ) + + if not signal: + logger.debug(f"Strategy {strategy_id}: No signal at price {current_price}") + return + + # Update signal tracking + info['last_signal'] = { + 'type': signal.signal_type.value, + 'strength': signal.strength, + 'price': float(signal.price), + 'timestamp': datetime.now().isoformat(), + 'metadata': signal.metadata, + } + info['signal_count'] += 1 + + logger.info(f"Strategy {strategy_id} generated signal: {signal.signal_type.value} @ {current_price}") + + # 3. Handle Signal (Execution) + trading_engine = get_trading_engine() + + # Check for Pairs Trading Metadata + secondary_symbol = signal.metadata.get('secondary_symbol') + secondary_action = signal.metadata.get('secondary_action') + + if secondary_symbol and secondary_action: + # Multi-Leg Execution for Pairs Trading + logger.info(f"Executing Pairs Trade: {symbol} ({signal.signal_type.value}) & {secondary_symbol} ({secondary_action})") + + # Execute Primary Leg + loop.run_until_complete(trading_engine.execute_order( + exchange_id=1, + strategy_id=strategy_id, + symbol=symbol, + side=OrderSide(signal.signal_type.value), + order_type=OrderType.MARKET, + quantity=strategy_instance.calculate_position_size( + signal, trading_engine.paper_trading.get_balance(), current_price + ), + paper_trading=True + )) + + # Execute Secondary Leg + sec_ticker = pricing_service.get_ticker(secondary_symbol) + sec_price = sec_ticker.get('last') + if sec_price: + loop.run_until_complete(trading_engine.execute_order( + exchange_id=1, + strategy_id=strategy_id, + symbol=secondary_symbol, + side=OrderSide(secondary_action), + order_type=OrderType.MARKET, + quantity=strategy_instance.calculate_position_size( + signal, trading_engine.paper_trading.get_balance(), sec_price + ), + paper_trading=True, + )) + + else: + # Standard Single Leg Execution + loop.run_until_complete(trading_engine.execute_order( + exchange_id=1, + strategy_id=strategy_id, + symbol=symbol, + side=OrderSide(signal.signal_type.value), + order_type=OrderType.MARKET, + quantity=signal.quantity or strategy_instance.calculate_position_size( + signal, trading_engine.paper_trading.get_balance(), current_price + ), + paper_trading=True + )) + + except Exception as e: + logger.error(f"Strategy {strategy_id} execution error: {e}") + info['error_count'] += 1 + + def shutdown(self): + """Shutdown scheduler.""" + self.scheduler.shutdown() + + +# Global scheduler +_scheduler: Optional[StrategyScheduler] = None + + +def get_scheduler() -> StrategyScheduler: + """Get global strategy scheduler instance.""" + global _scheduler + if _scheduler is None: + _scheduler = StrategyScheduler() + return _scheduler + + diff --git a/src/strategies/sentiment/__init__.py b/src/strategies/sentiment/__init__.py new file mode 100644 index 00000000..c1584595 --- /dev/null +++ b/src/strategies/sentiment/__init__.py @@ -0,0 +1,5 @@ +"""Sentiment strategy package.""" + +from .sentiment_strategy import SentimentStrategy + +__all__ = ['SentimentStrategy'] diff --git a/src/strategies/sentiment/sentiment_strategy.py b/src/strategies/sentiment/sentiment_strategy.py new file mode 100644 index 00000000..5947b1e3 --- /dev/null +++ b/src/strategies/sentiment/sentiment_strategy.py @@ -0,0 +1,208 @@ +"""Sentiment-Driven Strategy - Trades based on news sentiment and market fear/greed.""" + +from decimal import Decimal +from typing import Dict, Any, Optional, List +import asyncio +from datetime import datetime, timedelta + +from ..base import BaseStrategy, StrategySignal, SignalType +from src.data.news_collector import get_news_collector +from src.core.logger import get_logger +from src.core.config import get_config + +logger = get_logger(__name__) + + +class SentimentStrategy(BaseStrategy): + """ + Sentiment-Driven Strategy that trades based on news sentiment and market fear. + + Modes: + 1. Contrarian: Buy during extreme fear, sell during extreme greed + 2. Momentum: Follow positive news cycles + 3. Combo: Buy on positive news + fear (best opportunity) + + Signal Logic: + - Aggregates sentiment from recent news headlines + - Combines with Fear & Greed Index when available + - Generates signals based on configured mode + """ + + def __init__(self, name: str, parameters: Dict[str, Any], timeframes: List[str] = None): + super().__init__(name, parameters, timeframes) + + # Strategy parameters + self.mode = parameters.get('mode', 'contrarian') # 'contrarian', 'momentum', 'combo' + self.min_sentiment_score = float(parameters.get('min_sentiment_score', 0.5)) + self.fear_threshold = int(parameters.get('fear_threshold', 25)) # 0-100 + self.greed_threshold = int(parameters.get('greed_threshold', 75)) # 0-100 + self.news_lookback_hours = int(parameters.get('news_lookback_hours', 24)) + self.min_headlines = int(parameters.get('min_headlines', 5)) + + self.news_collector = get_news_collector() + self.config = get_config() + + # Simple sentiment keywords + self.positive_keywords = [ + 'surge', 'rally', 'soar', 'jump', 'gain', 'bullish', 'adoption', + 'partnership', 'etf', 'approval', 'institutional', 'buy', 'long', + 'breakout', 'record', 'high', 'upgrade', 'positive', 'growth' + ] + self.negative_keywords = [ + 'crash', 'drop', 'plunge', 'fall', 'dump', 'bearish', 'hack', + 'scandal', 'lawsuit', 'ban', 'regulation', 'sell', 'short', + 'breakdown', 'low', 'downgrade', 'negative', 'fear', 'panic' + ] + + # Cache for fear & greed + self._fear_greed_cache: Optional[Dict[str, Any]] = None + self._fear_greed_timestamp: Optional[datetime] = None + + def _analyze_sentiment(self, headlines: List[str]) -> float: + """Calculate aggregate sentiment score from headlines. + + Returns: + Score from -1.0 (very negative) to +1.0 (very positive) + """ + if not headlines: + return 0.0 + + total_score = 0.0 + + for headline in headlines: + headline_lower = headline.lower() + pos_count = sum(1 for kw in self.positive_keywords if kw in headline_lower) + neg_count = sum(1 for kw in self.negative_keywords if kw in headline_lower) + + if pos_count + neg_count > 0: + # Normalize to -1 to 1 + total_score += (pos_count - neg_count) / (pos_count + neg_count) + + # Average across all headlines + return total_score / len(headlines) + + async def _fetch_fear_greed_index(self) -> Optional[int]: + """Fetch the Fear & Greed Index from alternative.me API. + + Returns: + Fear & Greed value (0-100) or None if unavailable + """ + # Check cache (valid for 1 hour) + if (self._fear_greed_cache and self._fear_greed_timestamp and + (datetime.now() - self._fear_greed_timestamp).total_seconds() < 3600): + return self._fear_greed_cache.get('value') + + try: + import aiohttp + + url = "https://api.alternative.me/fng/" + async with aiohttp.ClientSession() as session: + async with session.get(url, timeout=10) as response: + if response.status == 200: + data = await response.json() + if data.get('data'): + value = int(data['data'][0]['value']) + self._fear_greed_cache = {'value': value} + self._fear_greed_timestamp = datetime.now() + return value + except Exception as e: + self.logger.debug(f"Failed to fetch Fear & Greed Index: {e}") + + return None + + async def on_tick(self, symbol: str, price: Decimal, timeframe: str, data: Dict[str, Any]) -> Optional[StrategySignal]: + """Check for sentiment-based signals.""" + if not self.enabled: + return None + + try: + # Extract base symbol (e.g., "BTC" from "BTC/USD") + base_symbol = symbol.split('/')[0] if '/' in symbol else symbol + + # Fetch news headlines + headlines = await self.news_collector.fetch_headlines( + symbols=[base_symbol], + max_age_hours=self.news_lookback_hours + ) + + if len(headlines) < self.min_headlines: + self.logger.debug(f"Not enough headlines: {len(headlines)} < {self.min_headlines}") + return None + + # Calculate sentiment score + sentiment = self._analyze_sentiment(headlines) + + # Fetch Fear & Greed Index + fear_greed = await self._fetch_fear_greed_index() + + self.logger.info( + f"Sentiment Analysis {symbol}: Score={sentiment:.2f}, " + f"Fear&Greed={fear_greed}, Headlines={len(headlines)}" + ) + + signal_type = None + description = "" + + if self.mode == 'contrarian': + # Contrarian: Buy on extreme fear, sell on extreme greed + if fear_greed is not None: + if fear_greed < self.fear_threshold: + signal_type = SignalType.BUY + description = f"Contrarian BUY: Extreme Fear ({fear_greed})" + elif fear_greed > self.greed_threshold: + signal_type = SignalType.SELL + description = f"Contrarian SELL: Extreme Greed ({fear_greed})" + + elif self.mode == 'momentum': + # Momentum: Follow positive/negative sentiment + if sentiment > self.min_sentiment_score: + signal_type = SignalType.BUY + description = f"Momentum BUY: Positive Sentiment ({sentiment:.2f})" + elif sentiment < -self.min_sentiment_score: + signal_type = SignalType.SELL + description = f"Momentum SELL: Negative Sentiment ({sentiment:.2f})" + + elif self.mode == 'combo': + # Combo: Positive news + fearful market = best buying opportunity + if fear_greed is not None: + if sentiment > self.min_sentiment_score and fear_greed < 40: + signal_type = SignalType.BUY + description = f"Combo BUY: Positive News + Fear ({sentiment:.2f}, FG={fear_greed})" + elif sentiment < -self.min_sentiment_score and fear_greed > 60: + signal_type = SignalType.SELL + description = f"Combo SELL: Negative News + Greed ({sentiment:.2f}, FG={fear_greed})" + + if signal_type: + # Calculate strength based on sentiment and fear/greed extremity + strength = abs(sentiment) + if fear_greed is not None: + fg_extremity = abs(50 - fear_greed) / 50 # How far from neutral + strength = (strength + fg_extremity) / 2 + + signal = StrategySignal( + signal_type=signal_type, + symbol=symbol, + strength=min(1.0, strength), + price=price, + metadata={ + "strategy": "sentiment", + "mode": self.mode, + "sentiment_score": float(sentiment), + "fear_greed": fear_greed, + "headline_count": len(headlines), + "sample_headlines": headlines[:3], # Include first 3 for context + "description": description + } + ) + self.logger.info(f"Sentiment Signal: {description}") + return signal + + except Exception as e: + self.logger.error(f"Error in SentimentStrategy: {e}") + return None + + return None + + def on_signal(self, signal: StrategySignal) -> Optional[StrategySignal]: + """Process signal (pass-through).""" + return signal diff --git a/src/strategies/technical/__init__.py b/src/strategies/technical/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/strategies/technical/bollinger_mean_reversion.py b/src/strategies/technical/bollinger_mean_reversion.py new file mode 100644 index 00000000..92c9682a --- /dev/null +++ b/src/strategies/technical/bollinger_mean_reversion.py @@ -0,0 +1,227 @@ +"""Bollinger Bands mean reversion strategy. + +Buys when price touches lower band in uptrend, sells when price +touches upper band in downtrend. Works well in ranging markets. +""" + +from decimal import Decimal +from typing import Optional, Dict, Any, List +import pandas as pd +from src.strategies.base import BaseStrategy, StrategySignal, SignalType +from src.data.indicators import get_indicators +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class BollingerMeanReversionStrategy(BaseStrategy): + """Bollinger Bands mean reversion strategy. + + This strategy trades mean reversion using Bollinger Bands: + - Buy when price touches lower band in uptrend + - Sell when price touches upper band in downtrend + - Uses trend filter to avoid counter-trend trades + """ + + def __init__(self, name: str, parameters: Optional[Dict[str, Any]] = None, timeframes: Optional[list] = None): + """Initialize Bollinger Bands mean reversion strategy. + + Parameters: + period: Moving average period for Bollinger Bands (default 20) + std_dev: Standard deviation multiplier (default 2.0) + trend_filter: Enable trend filter (default True) + trend_ma_period: Moving average period for trend detection (default 50) + entry_threshold: How close price must be to band (0.0-1.0, default 0.95) + exit_threshold: Exit when price reaches middle band (default 0.5) + """ + super().__init__(name, parameters, timeframes) + + self.period = self.parameters.get('period', 20) + self.std_dev = self.parameters.get('std_dev', 2.0) + self.trend_filter = self.parameters.get('trend_filter', True) + self.trend_ma_period = self.parameters.get('trend_ma_period', 50) + self.entry_threshold = self.parameters.get('entry_threshold', 0.95) + self.exit_threshold = self.parameters.get('exit_threshold', 0.5) + + self.indicators = get_indicators() + self._price_history: List[float] = [] + self._in_position = False + self._entry_price: Optional[Decimal] = None + + def _get_trend_direction(self, prices: pd.Series) -> Optional[str]: + """Determine trend direction. + + Args: + prices: Price series + + Returns: + 'up', 'down', or None + """ + if len(prices) < self.trend_ma_period: + return None + + sma = self.indicators.sma(prices, self.trend_ma_period) + current_price = prices.iloc[-1] + sma_value = sma.iloc[-1] + + # Use percentage difference to determine trend + price_diff = (current_price - sma_value) / sma_value if sma_value > 0 else 0.0 + + if price_diff > 0.01: # 1% above SMA = uptrend + return 'up' + elif price_diff < -0.01: # 1% below SMA = downtrend + return 'down' + + return None + + def _check_band_touch(self, price: float, upper: float, middle: float, lower: float) -> Optional[str]: + """Check if price is touching a band. + + Args: + price: Current price + upper: Upper Bollinger Band + middle: Middle Bollinger Band + lower: Lower Bollinger Band + + Returns: + 'upper' if touching upper band, 'lower' if touching lower band, None otherwise + """ + band_width = upper - lower + if band_width == 0: + return None + + # Calculate position within bands (0 = lower, 1 = upper) + position = (price - lower) / band_width + + # Check if touching lower band + if position <= (1.0 - self.entry_threshold): + return 'lower' + + # Check if touching upper band + if position >= self.entry_threshold: + return 'upper' + + return None + + async def on_tick(self, symbol: str, price: Decimal, timeframe: str, data: Dict[str, Any]) -> Optional[StrategySignal]: + """Generate signal based on Bollinger Bands mean reversion.""" + # Add price to history + self._price_history.append(float(price)) + + # Need enough data for Bollinger Bands and trend detection + max_period = max(self.period, self.trend_ma_period) + + if len(self._price_history) < max_period + 1: + return None + + prices = pd.Series(self._price_history[-max_period-1:]) + current_price = float(price) + + # Calculate Bollinger Bands + bb_data = self.indicators.bollinger_bands(prices, period=self.period, std_dev=self.std_dev) + + if len(bb_data['upper']) == 0 or pd.isna(bb_data['upper'].iloc[-1]): + return None + + upper = bb_data['upper'].iloc[-1] + middle = bb_data['middle'].iloc[-1] + lower = bb_data['lower'].iloc[-1] + + # Determine trend direction if using trend filter + trend = None + if self.trend_filter: + trend = self._get_trend_direction(prices) + + # Check for entry signals + if not self._in_position: + band_touch = self._check_band_touch(current_price, upper, middle, lower) + + if band_touch == 'lower': + # Price touching lower band - potential buy signal + if not self.trend_filter or trend == 'up': + # Only buy in uptrend (mean reversion back up) + self._in_position = True + self._entry_price = price + + # Calculate signal strength based on how far below lower band + distance_from_lower = (lower - current_price) / lower if lower > 0 else 0.0 + strength = min(1.0, distance_from_lower * 10) # Scale for strength + + return StrategySignal( + signal_type=SignalType.BUY, + symbol=symbol, + strength=max(0.5, strength), + price=price, + metadata={ + 'band_touch': 'lower', + 'upper': float(upper), + 'middle': float(middle), + 'lower': float(lower), + 'price_position': (current_price - lower) / (upper - lower) if (upper - lower) > 0 else 0.5, + 'trend': trend, + 'strategy': 'bollinger_mean_reversion', + 'type': 'entry' + } + ) + + elif band_touch == 'upper': + # Price touching upper band - potential sell signal (for short) + if not self.trend_filter or trend == 'down': + # Only sell in downtrend (mean reversion back down) + # Note: This is a SELL signal (could be used for shorting or exiting long positions) + # For mean reversion, we typically only go long, so this might be an exit signal + # For simplicity, we'll make it a SELL signal + pass # Could implement short entry here if desired + + else: + # In position - check for exit + entry_price = self._entry_price or price + entry_float = float(entry_price) + + # Exit when price reaches middle band (mean reversion complete) + band_width = upper - lower + position_in_bands = (current_price - lower) / band_width if band_width > 0 else 0.5 + + # Exit conditions + should_exit = False + exit_reason = None + + if position_in_bands >= self.exit_threshold: + # Price has moved back toward middle - take profit + should_exit = True + exit_reason = 'target_reached' + elif band_touch := self._check_band_touch(current_price, upper, middle, lower): + # Price touched opposite band - stop loss + if band_touch == 'upper' and entry_float < middle: + should_exit = True + exit_reason = 'stop_loss' + + if should_exit: + self._in_position = False + profit_pct = (current_price - entry_float) / entry_float if entry_float > 0 else 0.0 + + return StrategySignal( + signal_type=SignalType.SELL, + symbol=symbol, + strength=1.0, + price=price, + metadata={ + 'entry_price': float(entry_price), + 'exit_price': current_price, + 'profit_pct': profit_pct * 100, + 'exit_reason': exit_reason, + 'strategy': 'bollinger_mean_reversion', + 'type': 'exit' + } + ) + + return None + + def on_signal(self, signal: StrategySignal) -> Optional[StrategySignal]: + """Process signal.""" + if signal.signal_type == SignalType.SELL: + self._in_position = False + self._entry_price = None + + return signal if self.should_execute(signal) else None + diff --git a/src/strategies/technical/confirmed_strategy.py b/src/strategies/technical/confirmed_strategy.py new file mode 100644 index 00000000..185fb419 --- /dev/null +++ b/src/strategies/technical/confirmed_strategy.py @@ -0,0 +1,246 @@ +"""Multi-indicator confirmation strategy. + +This strategy requires multiple indicators (RSI, MACD, Moving Average) to align +before generating a signal, reducing false signals significantly. +""" + +from decimal import Decimal +from typing import Optional, Dict, Any, List +import pandas as pd +from src.strategies.base import BaseStrategy, StrategySignal, SignalType +from src.data.indicators import get_indicators +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class ConfirmedStrategy(BaseStrategy): + """Multi-indicator confirmation strategy. + + Combines RSI, MACD, and Moving Average signals and only generates + signals when a configurable number of indicators agree. + """ + + def __init__(self, name: str, parameters: Optional[Dict[str, Any]] = None, timeframes: Optional[list] = None): + """Initialize confirmed strategy. + + Parameters: + rsi_period: RSI period (default 14) + rsi_oversold: RSI oversold threshold (default 30) + rsi_overbought: RSI overbought threshold (default 70) + macd_fast: MACD fast period (default 12) + macd_slow: MACD slow period (default 26) + macd_signal: MACD signal period (default 9) + ma_fast: Fast MA period (default 10) + ma_slow: Slow MA period (default 30) + ma_type: MA type - 'sma' or 'ema' (default 'ema') + min_confirmations: Minimum number of indicators that must agree (default 2) + require_rsi: Require RSI confirmation (default True) + require_macd: Require MACD confirmation (default True) + require_ma: Require MA confirmation (default True) + """ + super().__init__(name, parameters, timeframes) + + # RSI parameters + self.rsi_period = self.parameters.get('rsi_period', 14) + self.rsi_oversold = self.parameters.get('rsi_oversold', 30) + self.rsi_overbought = self.parameters.get('rsi_overbought', 70) + + # MACD parameters + self.macd_fast = self.parameters.get('macd_fast', 12) + self.macd_slow = self.parameters.get('macd_slow', 26) + self.macd_signal = self.parameters.get('macd_signal', 9) + + # MA parameters + self.ma_fast = self.parameters.get('ma_fast', 10) + self.ma_slow = self.parameters.get('ma_slow', 30) + self.ma_type = self.parameters.get('ma_type', 'ema') + + # Confirmation parameters + self.min_confirmations = self.parameters.get('min_confirmations', 2) + self.require_rsi = self.parameters.get('require_rsi', True) + self.require_macd = self.parameters.get('require_macd', True) + self.require_ma = self.parameters.get('require_ma', True) + + self.indicators = get_indicators() + self._price_history: List[float] = [] + + def _check_rsi_signal(self, prices: pd.Series) -> Optional[SignalType]: + """Check RSI for signal. + + Args: + prices: Price series + + Returns: + SignalType if RSI indicates signal, None otherwise + """ + if len(prices) < self.rsi_period + 1: + return None + + rsi = self.indicators.rsi(prices, self.rsi_period) + if len(rsi) == 0: + return None + + current_rsi = rsi.iloc[-1] + + if current_rsi < self.rsi_oversold: + return SignalType.BUY + elif current_rsi > self.rsi_overbought: + return SignalType.SELL + + return None + + def _check_macd_signal(self, prices: pd.Series) -> Optional[SignalType]: + """Check MACD for signal. + + Args: + prices: Price series + + Returns: + SignalType if MACD indicates signal, None otherwise + """ + if len(prices) < self.macd_slow + self.macd_signal: + return None + + macd_data = self.indicators.macd(prices, self.macd_fast, self.macd_slow, self.macd_signal) + + if len(macd_data['macd']) < 2: + return None + + macd = macd_data['macd'].iloc[-1] + signal_line = macd_data['signal'].iloc[-1] + prev_macd = macd_data['macd'].iloc[-2] + prev_signal = macd_data['signal'].iloc[-2] + + # Bullish crossover + if prev_macd <= prev_signal and macd > signal_line: + return SignalType.BUY + # Bearish crossover + elif prev_macd >= prev_signal and macd < signal_line: + return SignalType.SELL + + return None + + def _check_ma_signal(self, prices: pd.Series) -> Optional[SignalType]: + """Check Moving Average for signal. + + Args: + prices: Price series + + Returns: + SignalType if MA indicates signal, None otherwise + """ + if len(prices) < self.ma_slow + 1: + return None + + if self.ma_type == 'sma': + fast_ma = self.indicators.sma(prices, self.ma_fast) + slow_ma = self.indicators.sma(prices, self.ma_slow) + else: + fast_ma = self.indicators.ema(prices, self.ma_fast) + slow_ma = self.indicators.ema(prices, self.ma_slow) + + if len(fast_ma) < 2 or len(slow_ma) < 2: + return None + + fast_current = fast_ma.iloc[-1] + fast_prev = fast_ma.iloc[-2] + slow_current = slow_ma.iloc[-1] + slow_prev = slow_ma.iloc[-2] + + # Bullish crossover + if fast_prev <= slow_prev and fast_current > slow_current: + return SignalType.BUY + # Bearish crossover + elif fast_prev >= slow_prev and fast_current < slow_current: + return SignalType.SELL + + return None + + async def on_tick(self, symbol: str, price: Decimal, timeframe: str, data: Dict[str, Any]) -> Optional[StrategySignal]: + """Generate signal based on multi-indicator confirmation.""" + # Add price to history + self._price_history.append(float(price)) + + # Determine minimum required data points + max_period = max( + self.rsi_period + 1, + self.macd_slow + self.macd_signal, + self.ma_slow + 1 + ) + + if len(self._price_history) < max_period: + return None + + prices = pd.Series(self._price_history[-max_period:]) + + # Collect signals from each indicator + signals: List[SignalType] = [] + signal_metadata: Dict[str, Any] = {} + + # Check RSI + if self.require_rsi: + rsi_signal = self._check_rsi_signal(prices) + if rsi_signal: + signals.append(rsi_signal) + signal_metadata['rsi'] = rsi_signal.value + + # Check MACD + if self.require_macd: + macd_signal = self._check_macd_signal(prices) + if macd_signal: + signals.append(macd_signal) + signal_metadata['macd'] = macd_signal.value + + # Check MA + if self.require_ma: + ma_signal = self._check_ma_signal(prices) + if ma_signal: + signals.append(ma_signal) + signal_metadata['ma'] = ma_signal.value + + # Count confirmations for each signal type + buy_count = signals.count(SignalType.BUY) + sell_count = signals.count(SignalType.SELL) + + # Determine final signal based on confirmations + final_signal = None + confirmation_count = 0 + + if buy_count >= self.min_confirmations: + final_signal = SignalType.BUY + confirmation_count = buy_count + elif sell_count >= self.min_confirmations: + final_signal = SignalType.SELL + confirmation_count = sell_count + + if final_signal is None: + return None + + # Calculate signal strength based on number of confirmations + max_possible = sum([self.require_rsi, self.require_macd, self.require_ma]) + strength = min(1.0, confirmation_count / max_possible) if max_possible > 0 else 0.5 + + return StrategySignal( + signal_type=final_signal, + symbol=symbol, + strength=strength, + price=price, + metadata={ + 'confirmation_count': confirmation_count, + 'max_confirmations': max_possible, + 'signals': signal_metadata, + 'strategy': 'confirmed', + 'rsi_period': self.rsi_period, + 'macd_fast': self.macd_fast, + 'macd_slow': self.macd_slow, + 'ma_type': self.ma_type, + 'ma_fast': self.ma_fast, + 'ma_slow': self.ma_slow + } + ) + + def on_signal(self, signal: StrategySignal) -> Optional[StrategySignal]: + """Process signal.""" + return signal if self.should_execute(signal) else None + diff --git a/src/strategies/technical/divergence_strategy.py b/src/strategies/technical/divergence_strategy.py new file mode 100644 index 00000000..8cda8bae --- /dev/null +++ b/src/strategies/technical/divergence_strategy.py @@ -0,0 +1,154 @@ +"""Divergence detection strategy. + +Detects price vs. indicator divergences (RSI/MACD) which are powerful +reversal signals with high success rates in ranging markets. +""" + +from decimal import Decimal +from typing import Optional, Dict, Any, List +import pandas as pd +from src.strategies.base import BaseStrategy, StrategySignal, SignalType +from src.data.indicators import get_indicators +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class DivergenceStrategy(BaseStrategy): + """Divergence detection strategy. + + Detects bullish and bearish divergences between price and indicators + (RSI or MACD) to identify potential reversals. + + - Bullish divergence: Price makes lower low, indicator makes higher low → BUY + - Bearish divergence: Price makes higher high, indicator makes lower high → SELL + """ + + def __init__(self, name: str, parameters: Optional[Dict[str, Any]] = None, timeframes: Optional[list] = None): + """Initialize divergence strategy. + + Parameters: + indicator_type: Type of indicator - 'rsi' or 'macd' (default 'rsi') + rsi_period: RSI period if using RSI (default 14) + macd_fast: MACD fast period if using MACD (default 12) + macd_slow: MACD slow period if using MACD (default 26) + macd_signal: MACD signal period if using MACD (default 9) + lookback: Lookback period for finding swings (default 20) + min_swings: Minimum number of swings to detect divergence (default 2) + min_confidence: Minimum confidence threshold for signal (default 0.5) + """ + super().__init__(name, parameters, timeframes) + + self.indicator_type = self.parameters.get('indicator_type', 'rsi').lower() + self.rsi_period = self.parameters.get('rsi_period', 14) + self.macd_fast = self.parameters.get('macd_fast', 12) + self.macd_slow = self.parameters.get('macd_slow', 26) + self.macd_signal = self.parameters.get('macd_signal', 9) + self.lookback = self.parameters.get('lookback', 20) + self.min_swings = self.parameters.get('min_swings', 2) + self.min_confidence = self.parameters.get('min_confidence', 0.5) + + self.indicators = get_indicators() + self._price_history: List[float] = [] + self._last_divergence_type: Optional[str] = None + + def _calculate_indicator(self, prices: pd.Series) -> Optional[pd.Series]: + """Calculate the selected indicator. + + Args: + prices: Price series + + Returns: + Indicator series or None + """ + if self.indicator_type == 'rsi': + if len(prices) < self.rsi_period + 1: + return None + return self.indicators.rsi(prices, self.rsi_period) + + elif self.indicator_type == 'macd': + if len(prices) < self.macd_slow + self.macd_signal: + return None + macd_data = self.indicators.macd(prices, self.macd_fast, self.macd_slow, self.macd_signal) + return macd_data['macd'] + + return None + + async def on_tick(self, symbol: str, price: Decimal, timeframe: str, data: Dict[str, Any]) -> Optional[StrategySignal]: + """Generate signal based on divergence detection.""" + # Add price to history + self._price_history.append(float(price)) + + # Determine minimum required data points + if self.indicator_type == 'rsi': + min_period = self.rsi_period + self.lookback * 3 + else: + min_period = self.macd_slow + self.macd_signal + self.lookback * 3 + + if len(self._price_history) < min_period: + return None + + prices = pd.Series(self._price_history[-min_period:]) + + # Calculate indicator + indicator = self._calculate_indicator(prices) + if indicator is None or len(indicator) < self.lookback * 2: + return None + + # Align prices and indicator (indicator may be shorter due to calculation) + if len(prices) != len(indicator): + # Take the last len(indicator) prices to match indicator length + prices = prices.iloc[-len(indicator):] + indicator = indicator.reset_index(drop=True) if hasattr(indicator, 'reset_index') else indicator + + # Detect divergence + divergence_result = self.indicators.detect_divergence( + prices=prices, + indicator=indicator, + lookback=self.lookback, + min_swings=self.min_swings + ) + + divergence_type = divergence_result.get('type') + confidence = divergence_result.get('confidence', 0.0) + + # Check if we have a valid divergence with sufficient confidence + if divergence_type is None or confidence < self.min_confidence: + return None + + # Only generate signal if divergence type changed (avoid repeated signals) + if divergence_type == self._last_divergence_type: + return None + + self._last_divergence_type = divergence_type + + # Map divergence type to signal type + if divergence_type == 'bullish': + signal_type = SignalType.BUY + elif divergence_type == 'bearish': + signal_type = SignalType.SELL + else: + return None + + return StrategySignal( + signal_type=signal_type, + symbol=symbol, + strength=confidence, + price=price, + metadata={ + 'divergence_type': divergence_type, + 'confidence': confidence, + 'indicator_type': self.indicator_type, + 'lookback': self.lookback, + 'price_swing_high': divergence_result.get('price_swing_high'), + 'price_swing_low': divergence_result.get('price_swing_low'), + 'indicator_swing_high': divergence_result.get('indicator_swing_high'), + 'indicator_swing_low': divergence_result.get('indicator_swing_low'), + 'strategy': 'divergence' + } + ) + + def on_signal(self, signal: StrategySignal) -> Optional[StrategySignal]: + """Process signal.""" + return signal if self.should_execute(signal) else None + diff --git a/src/strategies/technical/macd_strategy.py b/src/strategies/technical/macd_strategy.py new file mode 100644 index 00000000..c57d8e78 --- /dev/null +++ b/src/strategies/technical/macd_strategy.py @@ -0,0 +1,72 @@ +"""MACD (Moving Average Convergence Divergence) strategy.""" + +import pandas as pd +from decimal import Decimal +from typing import Optional, Dict, Any +from src.strategies.base import BaseStrategy, StrategySignal, SignalType +from src.data.indicators import get_indicators + +class MACDStrategy(BaseStrategy): + """MACD-based trading strategy.""" + + def __init__(self, name: str, parameters: Optional[Dict[str, Any]] = None, timeframes: Optional[list] = None): + """Initialize MACD strategy. + + Parameters: + fast: Fast EMA period (default 12) + slow: Slow EMA period (default 26) + signal: Signal line period (default 9) + """ + super().__init__(name, parameters, timeframes) + self.fast = self.parameters.get('fast', 12) + self.slow = self.parameters.get('slow', 26) + self.signal = self.parameters.get('signal', 9) + self.indicators = get_indicators() + self._price_history = [] + + async def on_tick(self, symbol: str, price: Decimal, timeframe: str, data: Dict[str, Any]) -> Optional[StrategySignal]: + """Generate signal based on MACD.""" + + + # Add price to history + self._price_history.append(float(price)) + if len(self._price_history) < self.slow + self.signal: + return None + + # Calculate MACD + prices = pd.Series(self._price_history[-self.slow-self.signal:]) + macd_data = self.indicators.macd(prices, self.fast, self.slow, self.signal) + + if len(macd_data['macd']) < 2: + return None + + macd = macd_data['macd'].iloc[-1] + signal_line = macd_data['signal'].iloc[-1] + prev_macd = macd_data['macd'].iloc[-2] + prev_signal = macd_data['signal'].iloc[-2] + + # Bullish crossover + if prev_macd <= prev_signal and macd > signal_line: + return StrategySignal( + signal_type=SignalType.BUY, + symbol=symbol, + strength=min(1.0, abs(macd - signal_line) / abs(prev_macd - prev_signal) if prev_macd != prev_signal else 1.0), + price=price, + metadata={'macd': float(macd), 'signal': float(signal_line)} + ) + # Bearish crossover + elif prev_macd >= prev_signal and macd < signal_line: + return StrategySignal( + signal_type=SignalType.SELL, + symbol=symbol, + strength=min(1.0, abs(macd - signal_line) / abs(prev_macd - prev_signal) if prev_macd != prev_signal else 1.0), + price=price, + metadata={'macd': float(macd), 'signal': float(signal_line)} + ) + + return None + + def on_signal(self, signal: StrategySignal) -> Optional[StrategySignal]: + """Process signal.""" + return signal if self.should_execute(signal) else None + diff --git a/src/strategies/technical/moving_avg_strategy.py b/src/strategies/technical/moving_avg_strategy.py new file mode 100644 index 00000000..61fb94d2 --- /dev/null +++ b/src/strategies/technical/moving_avg_strategy.py @@ -0,0 +1,79 @@ +"""Moving Average Crossover strategy.""" + +import pandas as pd +from decimal import Decimal +from typing import Optional, Dict, Any +from src.strategies.base import BaseStrategy, StrategySignal, SignalType +from src.data.indicators import get_indicators + +class MovingAverageStrategy(BaseStrategy): + """Moving average crossover strategy.""" + + def __init__(self, name: str, parameters: Optional[Dict[str, Any]] = None, timeframes: Optional[list] = None): + """Initialize moving average strategy. + + Parameters: + fast_period: Fast MA period (default 10) + slow_period: Slow MA period (default 30) + ma_type: MA type - 'sma' or 'ema' (default 'ema') + """ + super().__init__(name, parameters, timeframes) + self.fast_period = self.parameters.get('fast_period', 10) + self.slow_period = self.parameters.get('slow_period', 30) + self.ma_type = self.parameters.get('ma_type', 'ema') + self.indicators = get_indicators() + self._price_history = [] + + async def on_tick(self, symbol: str, price: Decimal, timeframe: str, data: Dict[str, Any]) -> Optional[StrategySignal]: + """Generate signal based on MA crossover.""" + + + # Add price to history + self._price_history.append(float(price)) + if len(self._price_history) < self.slow_period + 1: + return None + + # Calculate MAs + prices = pd.Series(self._price_history[-self.slow_period-1:]) + + if self.ma_type == 'sma': + fast_ma = self.indicators.sma(prices, self.fast_period) + slow_ma = self.indicators.sma(prices, self.slow_period) + else: + fast_ma = self.indicators.ema(prices, self.fast_period) + slow_ma = self.indicators.ema(prices, self.slow_period) + + if len(fast_ma) < 2 or len(slow_ma) < 2: + return None + + # Check for crossover + fast_current = fast_ma.iloc[-1] + fast_prev = fast_ma.iloc[-2] + slow_current = slow_ma.iloc[-1] + slow_prev = slow_ma.iloc[-2] + + # Bullish crossover + if fast_prev <= slow_prev and fast_current > slow_current: + return StrategySignal( + signal_type=SignalType.BUY, + symbol=symbol, + strength=min(1.0, (fast_current - slow_current) / slow_current), + price=price, + metadata={'fast_ma': float(fast_current), 'slow_ma': float(slow_current)} + ) + # Bearish crossover + elif fast_prev >= slow_prev and fast_current < slow_current: + return StrategySignal( + signal_type=SignalType.SELL, + symbol=symbol, + strength=min(1.0, (slow_current - fast_current) / slow_current), + price=price, + metadata={'fast_ma': float(fast_current), 'slow_ma': float(slow_current)} + ) + + return None + + def on_signal(self, signal: StrategySignal) -> Optional[StrategySignal]: + """Process signal.""" + return signal if self.should_execute(signal) else None + diff --git a/src/strategies/technical/pairs_trading.py b/src/strategies/technical/pairs_trading.py new file mode 100644 index 00000000..cacd4729 --- /dev/null +++ b/src/strategies/technical/pairs_trading.py @@ -0,0 +1,146 @@ +"""Statistical Arbitrage (Pairs Trading) Strategy.""" + +from decimal import Decimal +from typing import Dict, Any, Optional, List +import pandas as pd +import numpy as np + +from ..base import BaseStrategy, StrategySignal, SignalType +from src.data.pricing_service import get_pricing_service +from src.core.logger import get_logger + +logger = get_logger(__name__) + +class PairsTradingStrategy(BaseStrategy): + """ + Statistical Arbitrage Strategy that trades the spread between two correlated assets. + + Logic: + 1. Calculate Spread = Price(A) / Price(B) + 2. Calculate Z-Score of the spread over a rolling window. + 3. Mean Reversion signals: + - Z-Score > Threshold: Short Spread (Sell A, Buy B) + - Z-Score < -Threshold: Long Spread (Buy A, Sell B) + - Z-Score approx 0: Close/Neutralize (Exit both) + """ + + def __init__(self, name: str, parameters: Dict[str, Any], timeframes: List[str] = None): + super().__init__(name, parameters, timeframes) + self.second_symbol = parameters.get('second_symbol') + self.lookback_window = int(parameters.get('lookback_period', 20)) + self.z_threshold = float(parameters.get('z_score_threshold', 2.0)) + self.pricing_service = get_pricing_service() + + if not self.second_symbol: + logger.warning(f"PairsTradingStrategy {name} initialized without 'second_symbol'") + + async def on_tick(self, symbol: str, price: Decimal, timeframe: str, data: Dict[str, Any]) -> Optional[StrategySignal]: + """Check for pairs trading signals.""" + if not self.second_symbol or not self.enabled: + return None + + # We only process on the "Primary" symbol's tick to avoid double-processing + # (Assuming the strategy is assigned to the primary symbol in the DB) + + # 1. Fetch data for BOTH symbols + # We need historical data to calculate Z-Score + try: + # Fetch for Primary (Symbol A) + ohlcv_a = self.pricing_service.get_ohlcv( + symbol=symbol, + timeframe=timeframe, + limit=self.lookback_window + 5 + ) + + # Fetch for Secondary (Symbol B) + ohlcv_b = self.pricing_service.get_ohlcv( + symbol=self.second_symbol, + timeframe=timeframe, + limit=self.lookback_window + 5 + ) + + if not ohlcv_a or not ohlcv_b: + return None + + # Convert to Series for pandas calc + # OHLCV format: [timestamp, open, high, low, close, volume] + closes_a = pd.Series([float(c[4]) for c in ohlcv_a]) + closes_b = pd.Series([float(c[4]) for c in ohlcv_b]) + + # Ensure equal length if fetched data differs slightly + min_len = min(len(closes_a), len(closes_b)) + closes_a = closes_a.iloc[-min_len:] + closes_b = closes_b.iloc[-min_len:] + + # 2. Calculate Spread and Z-Score + # Spread = A / B + spread = closes_a / closes_b + + # Rolling statistics + rolling_mean = spread.rolling(window=self.lookback_window).mean() + rolling_std = spread.rolling(window=self.lookback_window).std() + + current_spread = spread.iloc[-1] + current_mean = rolling_mean.iloc[-1] + current_std = rolling_std.iloc[-1] + + if pd.isna(current_std) or current_std == 0: + return None + + z_score = (current_spread - current_mean) / current_std + + self.logger.info( + f"Pairs {symbol}/{self.second_symbol}: Spread={current_spread:.4f}, Z-Score={z_score:.2f}" + ) + + # 3. Generate Signals + # Strategy: + # If Z > Threshold -> Spread is too high (A is expensive, B is cheap) -> Sell A, Buy B + # If Z < -Threshold -> Spread is too low (A is cheap, B is expensive) -> Buy A, Sell B + # If abs(Z) < 0.5 -> Mean reverted -> Close Positions (optional, or just go to neutral) + + signal_type = None + primary_side = "hold" + secondary_side = "hold" + + if z_score > self.z_threshold: + # Sell A, Buy B + signal_type = SignalType.SELL + primary_side = "sell" + secondary_side = "buy" + + elif z_score < -self.z_threshold: + # Buy A, Sell B + signal_type = SignalType.BUY + primary_side = "buy" + secondary_side = "sell" + + # TODO: Logic for closing when Z ~ 0 can be added here + + if signal_type: + # Create Signal for Primary + signal = StrategySignal( + signal_type=signal_type, + symbol=symbol, + strength=min(abs(z_score) / self.z_threshold, 1.0), # Strength capped at 1.0 + price=price, + metadata={ + "strategy": "pairs_trading", + "z_score": float(z_score), + "spread": float(current_spread), + "secondary_symbol": self.second_symbol, + "secondary_action": secondary_side, + "description": f"Pairs Arbitrage: Z-Score {z_score:.2f} triggered {primary_side} {symbol} / {secondary_side} {self.second_symbol}" + } + ) + return signal + + except Exception as e: + self.logger.error(f"Error in PairsTradingStrategy: {e}") + return None + + return None + + def on_signal(self, signal: StrategySignal) -> Optional[StrategySignal]: + """Process signal (pass-through).""" + return signal diff --git a/src/strategies/technical/rsi_strategy.py b/src/strategies/technical/rsi_strategy.py new file mode 100644 index 00000000..21ae3678 --- /dev/null +++ b/src/strategies/technical/rsi_strategy.py @@ -0,0 +1,67 @@ +"""RSI (Relative Strength Index) strategy.""" + +import pandas as pd +from decimal import Decimal +from typing import Optional, Dict, Any +from src.strategies.base import BaseStrategy, StrategySignal, SignalType +from src.data.indicators import get_indicators + +class RSIStrategy(BaseStrategy): + """RSI-based trading strategy.""" + + def __init__(self, name: str, parameters: Optional[Dict[str, Any]] = None, timeframes: Optional[list] = None): + """Initialize RSI strategy. + + Parameters: + rsi_period: RSI period (default 14) + oversold: Oversold threshold (default 30) + overbought: Overbought threshold (default 70) + """ + super().__init__(name, parameters, timeframes) + self.rsi_period = self.parameters.get('rsi_period', 14) + self.oversold = self.parameters.get('oversold', 30) + self.overbought = self.parameters.get('overbought', 70) + self.indicators = get_indicators() + self._price_history = [] + + async def on_tick(self, symbol: str, price: Decimal, timeframe: str, data: Dict[str, Any]) -> Optional[StrategySignal]: + """Generate signal based on RSI.""" + + # Add price to history + self._price_history.append(float(price)) + if len(self._price_history) < self.rsi_period + 1: + return None + + # Calculate RSI + prices = pd.Series(self._price_history[-self.rsi_period-1:]) + rsi = self.indicators.rsi(prices, self.rsi_period) + + if len(rsi) == 0: + return None + + current_rsi = rsi.iloc[-1] + + # Generate signals + if current_rsi < self.oversold: + return StrategySignal( + signal_type=SignalType.BUY, + symbol=symbol, + strength=1.0 - (current_rsi / self.oversold), + price=price, + metadata={'rsi': float(current_rsi)} + ) + elif current_rsi > self.overbought: + return StrategySignal( + signal_type=SignalType.SELL, + symbol=symbol, + strength=(current_rsi - self.overbought) / (100 - self.overbought), + price=price, + metadata={'rsi': float(current_rsi)} + ) + + return None + + def on_signal(self, signal: StrategySignal) -> Optional[StrategySignal]: + """Process signal.""" + return signal if self.should_execute(signal) else None + diff --git a/src/strategies/technical/volatility_breakout.py b/src/strategies/technical/volatility_breakout.py new file mode 100644 index 00000000..09e1d9de --- /dev/null +++ b/src/strategies/technical/volatility_breakout.py @@ -0,0 +1,177 @@ +"""Volatility Breakout Strategy - Captures explosive moves after consolidation.""" + +from decimal import Decimal +from typing import Dict, Any, Optional, List +import pandas as pd +import numpy as np + +from ..base import BaseStrategy, StrategySignal, SignalType +from src.data.pricing_service import get_pricing_service +from src.data.indicators import get_indicators +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class VolatilityBreakoutStrategy(BaseStrategy): + """ + Volatility Breakout Strategy that identifies and trades breakouts from consolidation. + + Logic: + 1. Detect consolidation using Bollinger Band Width (squeeze). + 2. Confirm breakout when price exits the bands with volume confirmation. + 3. Use ADX to ensure the trend is strong enough to follow. + + Entry Conditions (BUY): + - Bollinger Band Width < Squeeze Threshold (consolidation detected) + - Price breaks above Upper Bollinger Band + - Volume > 20-day average volume (confirmation) + - ADX > 25 (strong trend) + + Entry Conditions (SELL): + - Price breaks below Lower Bollinger Band + - Volume > 20-day average volume + - ADX > 25 (strong trend) + """ + + def __init__(self, name: str, parameters: Dict[str, Any], timeframes: List[str] = None): + super().__init__(name, parameters, timeframes) + + # Strategy parameters + self.bb_period = int(parameters.get('bb_period', 20)) + self.bb_std_dev = float(parameters.get('bb_std_dev', 2.0)) + self.squeeze_threshold = float(parameters.get('squeeze_threshold', 0.1)) + self.volume_multiplier = float(parameters.get('volume_multiplier', 1.5)) + self.adx_period = int(parameters.get('adx_period', 14)) + self.min_adx = float(parameters.get('min_adx', 25.0)) + self.use_volume_filter = parameters.get('use_volume_filter', True) + + self.pricing_service = get_pricing_service() + self.indicators = get_indicators() + + # Track squeeze state + self._in_squeeze = False + + async def on_tick(self, symbol: str, price: Decimal, timeframe: str, data: Dict[str, Any]) -> Optional[StrategySignal]: + """Check for volatility breakout signals.""" + if not self.enabled: + return None + + try: + # Fetch OHLCV data + ohlcv = self.pricing_service.get_ohlcv( + symbol=symbol, + timeframe=timeframe, + limit=max(self.bb_period, self.adx_period) + 30 + ) + + if not ohlcv or len(ohlcv) < self.bb_period + 10: + return None + + # Convert to DataFrame + df = pd.DataFrame(ohlcv, columns=['timestamp', 'open', 'high', 'low', 'close', 'volume']) + + # Calculate Bollinger Bands + close = df['close'].astype(float) + high = df['high'].astype(float) + low = df['low'].astype(float) + volume = df['volume'].astype(float) + + bb_upper, bb_middle, bb_lower = self.indicators.bollinger_bands( + close, period=self.bb_period, std_dev=self.bb_std_dev + ) + + # Calculate Bollinger Band Width (squeeze indicator) + bb_width = (bb_upper - bb_lower) / bb_middle + current_width = bb_width.iloc[-1] + + # Calculate ADX for trend strength + adx = self.indicators.adx(high, low, close, period=self.adx_period) + current_adx = adx.iloc[-1] if not pd.isna(adx.iloc[-1]) else 0.0 + + # Calculate volume metrics + avg_volume = volume.rolling(window=20).mean().iloc[-1] + current_volume = volume.iloc[-1] + volume_ratio = current_volume / avg_volume if avg_volume > 0 else 0 + + current_price = float(price) + upper_band = bb_upper.iloc[-1] + lower_band = bb_lower.iloc[-1] + + # Check for squeeze (consolidation) + is_squeeze = current_width < self.squeeze_threshold + was_in_squeeze = self._in_squeeze + self._in_squeeze = is_squeeze + + self.logger.debug( + f"Volatility Breakout {symbol}: Width={current_width:.4f}, " + f"ADX={current_adx:.1f}, Vol Ratio={volume_ratio:.2f}, " + f"Squeeze={is_squeeze}" + ) + + # No signal if not breaking out of a squeeze + if not was_in_squeeze: + return None + + # Volume filter + if self.use_volume_filter and volume_ratio < self.volume_multiplier: + self.logger.debug(f"Volume filter: {volume_ratio:.2f} < {self.volume_multiplier}") + return None + + # ADX filter - ensure trend is strong + if current_adx < self.min_adx: + self.logger.debug(f"ADX filter: {current_adx:.1f} < {self.min_adx}") + return None + + # Check for breakout + signal_type = None + + # Bullish breakout - price breaks above upper band + if current_price > upper_band: + signal_type = SignalType.BUY + self.logger.info( + f"BULLISH BREAKOUT: {symbol} @ {current_price:.2f} > Upper Band {upper_band:.2f}" + ) + + # Bearish breakout - price breaks below lower band + elif current_price < lower_band: + signal_type = SignalType.SELL + self.logger.info( + f"BEARISH BREAKOUT: {symbol} @ {current_price:.2f} < Lower Band {lower_band:.2f}" + ) + + if signal_type: + # Calculate signal strength based on multiple factors + strength = min(1.0, ( + (current_adx / 50) * 0.4 + # ADX contribution + (volume_ratio / 3) * 0.4 + # Volume contribution + 0.2 # Base strength for breakout + )) + + signal = StrategySignal( + signal_type=signal_type, + symbol=symbol, + strength=strength, + price=price, + metadata={ + "strategy": "volatility_breakout", + "bb_width": float(current_width), + "adx": float(current_adx), + "volume_ratio": float(volume_ratio), + "upper_band": float(upper_band), + "lower_band": float(lower_band), + "was_squeeze": was_in_squeeze, + "description": f"Volatility Breakout: ADX={current_adx:.1f}, Vol={volume_ratio:.1f}x" + } + ) + return signal + + except Exception as e: + self.logger.error(f"Error in VolatilityBreakoutStrategy: {e}") + return None + + return None + + def on_signal(self, signal: StrategySignal) -> Optional[StrategySignal]: + """Process signal (pass-through).""" + return signal diff --git a/src/strategies/timeframe_manager.py b/src/strategies/timeframe_manager.py new file mode 100644 index 00000000..9e63ad08 --- /dev/null +++ b/src/strategies/timeframe_manager.py @@ -0,0 +1,103 @@ +"""Multi-timeframe strategy framework with synchronization.""" + +from typing import Dict, List, Optional, Any +from datetime import datetime +from decimal import Decimal +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class TimeframeManager: + """Manages multiple timeframes for strategies.""" + + def __init__(self, timeframes: List[str]): + """Initialize timeframe manager. + + Args: + timeframes: List of timeframes (e.g., ['1h', '15m']) + """ + self.timeframes = sorted(timeframes, key=self._timeframe_to_seconds, reverse=True) + self.data: Dict[str, Dict[str, Any]] = {tf: {} for tf in self.timeframes} + self.last_update: Dict[str, datetime] = {tf: datetime.min for tf in self.timeframes} + + def _timeframe_to_seconds(self, tf: str) -> int: + """Convert timeframe to seconds for sorting. + + Args: + tf: Timeframe string (e.g., '1h', '15m') + + Returns: + Seconds + """ + if tf.endswith('m'): + return int(tf[:-1]) * 60 + elif tf.endswith('h'): + return int(tf[:-1]) * 3600 + elif tf.endswith('d'): + return int(tf[:-1]) * 86400 + return 0 + + def update(self, timeframe: str, symbol: str, data: Dict[str, Any]): + """Update data for a timeframe. + + Args: + timeframe: Timeframe + symbol: Trading symbol + data: Market data + """ + if timeframe not in self.timeframes: + logger.warning(f"Unknown timeframe: {timeframe}") + return + + if symbol not in self.data[timeframe]: + self.data[timeframe][symbol] = {} + + self.data[timeframe][symbol].update(data) + self.last_update[timeframe] = datetime.utcnow() + + def get_data(self, timeframe: str, symbol: str) -> Optional[Dict[str, Any]]: + """Get data for a timeframe. + + Args: + timeframe: Timeframe + symbol: Trading symbol + + Returns: + Data dictionary or None + """ + return self.data.get(timeframe, {}).get(symbol) + + def get_all_timeframes(self, symbol: str) -> Dict[str, Dict[str, Any]]: + """Get data for all timeframes. + + Args: + symbol: Trading symbol + + Returns: + Dictionary of timeframe -> data + """ + return { + tf: self.data[tf].get(symbol, {}) + for tf in self.timeframes + } + + def is_synchronized(self, symbol: str, max_age_seconds: int = 300) -> bool: + """Check if all timeframes are synchronized (recently updated). + + Args: + symbol: Trading symbol + max_age_seconds: Maximum age in seconds + + Returns: + True if all timeframes are synchronized + """ + now = datetime.utcnow() + for tf in self.timeframes: + if symbol not in self.data[tf]: + return False + age = (now - self.last_update[tf]).total_seconds() + if age > max_age_seconds: + return False + return True + diff --git a/src/trading/__init__.py b/src/trading/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/trading/advanced_orders.py b/src/trading/advanced_orders.py new file mode 100644 index 00000000..a9a226bf --- /dev/null +++ b/src/trading/advanced_orders.py @@ -0,0 +1,409 @@ +"""Advanced order types: take-profit, trailing stop, OCO, iceberg orders.""" + +from decimal import Decimal +from typing import Optional, Dict, Any, List +from datetime import datetime +from src.core.database import Order, OrderType, OrderSide, OrderStatus +from src.core.logger import get_logger +from .order_manager import get_order_manager + +logger = get_logger(__name__) + + +class TakeProfitOrder: + """Take-profit order - automatically sells when price reaches target.""" + + def __init__( + self, + base_order_id: int, + target_price: Decimal, + quantity: Optional[Decimal] = None + ): + """Initialize take-profit order. + + Args: + base_order_id: ID of the base position/order + target_price: Target price to trigger take-profit + quantity: Quantity to sell (None = all) + """ + self.base_order_id = base_order_id + self.target_price = target_price + self.quantity = quantity + self.triggered = False + + def check_trigger(self, current_price: Decimal) -> bool: + """Check if take-profit should trigger. + + Args: + current_price: Current market price + + Returns: + True if should trigger + """ + if self.triggered: + return False + + # Trigger if price reaches or exceeds target + if current_price >= self.target_price: + self.triggered = True + return True + return False + + +class TrailingStopOrder: + """Trailing stop-loss order - adjusts stop price as price moves favorably.""" + + def __init__( + self, + base_order_id: int, + initial_stop_price: Decimal, + trail_percent: Decimal, + quantity: Optional[Decimal] = None + ): + """Initialize trailing stop order. + + Args: + base_order_id: ID of the base position/order + initial_stop_price: Initial stop price + trail_percent: Percentage to trail (e.g., 0.02 for 2%) + quantity: Quantity to sell (None = all) + """ + self.base_order_id = base_order_id + self.current_stop_price = initial_stop_price + self.trail_percent = trail_percent + self.quantity = quantity + self.triggered = False + self.highest_price = initial_stop_price # For long positions + + def update(self, current_price: Decimal, is_long: bool = True): + """Update trailing stop based on current price. + + Args: + current_price: Current market price + is_long: True for long position, False for short + """ + if self.triggered: + return + + if is_long: + # For long positions, trail upward + if current_price > self.highest_price: + self.highest_price = current_price + # Adjust stop to trail below highest price + self.current_stop_price = current_price * (1 - self.trail_percent) + else: + # For short positions, trail downward + if current_price < self.highest_price or self.highest_price == self.current_stop_price: + self.highest_price = current_price + # Adjust stop to trail above lowest price + self.current_stop_price = current_price * (1 + self.trail_percent) + + def check_trigger(self, current_price: Decimal, is_long: bool = True) -> bool: + """Check if trailing stop should trigger. + + Args: + current_price: Current market price + is_long: True for long position, False for short + + Returns: + True if should trigger + """ + if self.triggered: + return False + + if is_long: + # Trigger if price falls below stop + if current_price <= self.current_stop_price: + self.triggered = True + return True + else: + # Trigger if price rises above stop + if current_price >= self.current_stop_price: + self.triggered = True + return True + + return False + + +class OCOOrder: + """One-Cancels-Other order - two orders where one cancels the other.""" + + def __init__( + self, + order1_id: int, + order2_id: int + ): + """Initialize OCO order. + + Args: + order1_id: First order ID + order2_id: Second order ID + """ + self.order1_id = order1_id + self.order2_id = order2_id + self.executed = False + + async def on_order_filled(self, filled_order_id: int): + """Handle when one order is filled. + + Args: + filled_order_id: ID of the filled order + """ + if self.executed: + return + + order_manager = get_order_manager() + + # Cancel the other order + if filled_order_id == self.order1_id: + await order_manager.cancel_order(self.order2_id) + elif filled_order_id == self.order2_id: + await order_manager.cancel_order(self.order1_id) + + self.executed = True + logger.info(f"OCO order executed: order {filled_order_id} filled, other cancelled") + + +class IcebergOrder: + """Iceberg order - large order split into smaller visible parts.""" + + def __init__( + self, + total_quantity: Decimal, + visible_quantity: Decimal, + symbol: str, + side: OrderSide, + price: Optional[Decimal] = None + ): + """Initialize iceberg order. + + Args: + total_quantity: Total quantity to execute + visible_quantity: Visible quantity per order + symbol: Trading symbol + side: Buy or sell + price: Limit price (None for market) + """ + self.total_quantity = total_quantity + self.visible_quantity = visible_quantity + self.symbol = symbol + self.side = side + self.price = price + self.remaining_quantity = total_quantity + self.orders: List[int] = [] + self.completed = False + + async def create_next_order(self, exchange_id: int) -> Optional[int]: + """Create next visible order. + + Args: + exchange_id: Exchange ID + + Returns: + Order ID or None if completed + """ + if self.completed or self.remaining_quantity <= 0: + return None + + order_manager = get_order_manager() + order_type = OrderType.LIMIT if self.price else OrderType.MARKET + + quantity = min(self.visible_quantity, self.remaining_quantity) + + order = await order_manager.create_order( + exchange_id=exchange_id, + strategy_id=None, + symbol=self.symbol, + order_type=order_type, + side=self.side, + quantity=quantity, + price=self.price + ) + + self.orders.append(order.id) + self.remaining_quantity -= quantity + + if self.remaining_quantity <= 0: + self.completed = True + + return order.id + + async def on_order_filled(self, order_id: int): + """Handle when an order is filled. + + Args: + order_id: Filled order ID + """ + if order_id in self.orders: + # Create next order if more quantity remains + if not self.completed and self.remaining_quantity > 0: + # This would need exchange_id - would need to store it + logger.info(f"Iceberg order {order_id} filled, {self.remaining_quantity} remaining") + + +class AdvancedOrderManager: + """Manages advanced order types.""" + + def __init__(self): + """Initialize advanced order manager.""" + self.take_profit_orders: Dict[int, TakeProfitOrder] = {} + self.trailing_stops: Dict[int, TrailingStopOrder] = {} + self.oco_orders: Dict[int, OCOOrder] = {} + self.iceberg_orders: Dict[int, IcebergOrder] = {} + + def create_take_profit( + self, + base_order_id: int, + target_price: Decimal, + quantity: Optional[Decimal] = None + ) -> TakeProfitOrder: + """Create a take-profit order. + + Args: + base_order_id: Base order/position ID + target_price: Target price + quantity: Quantity (None = all) + + Returns: + TakeProfitOrder instance + """ + tp_order = TakeProfitOrder(base_order_id, target_price, quantity) + self.take_profit_orders[base_order_id] = tp_order + logger.info(f"Created take-profit for order {base_order_id} at {target_price}") + return tp_order + + def create_trailing_stop( + self, + base_order_id: int, + initial_stop_price: Decimal, + trail_percent: Decimal, + quantity: Optional[Decimal] = None + ) -> TrailingStopOrder: + """Create a trailing stop order. + + Args: + base_order_id: Base order/position ID + initial_stop_price: Initial stop price + trail_percent: Trail percentage + quantity: Quantity (None = all) + + Returns: + TrailingStopOrder instance + """ + trailing = TrailingStopOrder(base_order_id, initial_stop_price, trail_percent, quantity) + self.trailing_stops[base_order_id] = trailing + logger.info(f"Created trailing stop for order {base_order_id}") + return trailing + + def create_oco( + self, + order1_id: int, + order2_id: int + ) -> OCOOrder: + """Create an OCO order. + + Args: + order1_id: First order ID + order2_id: Second order ID + + Returns: + OCOOrder instance + """ + oco = OCOOrder(order1_id, order2_id) + self.oco_orders[order1_id] = oco + self.oco_orders[order2_id] = oco + logger.info(f"Created OCO order: {order1_id} <-> {order2_id}") + return oco + + def create_iceberg( + self, + total_quantity: Decimal, + visible_quantity: Decimal, + symbol: str, + side: OrderSide, + price: Optional[Decimal] = None + ) -> IcebergOrder: + """Create an iceberg order. + + Args: + total_quantity: Total quantity + visible_quantity: Visible quantity per order + symbol: Trading symbol + side: Buy or sell + price: Limit price (None for market) + + Returns: + IcebergOrder instance + """ + iceberg = IcebergOrder(total_quantity, visible_quantity, symbol, side, price) + # Store by a unique ID (could use a counter or hash) + iceberg_id = id(iceberg) + self.iceberg_orders[iceberg_id] = iceberg + logger.info(f"Created iceberg order: {total_quantity} {symbol} ({visible_quantity} visible)") + return iceberg + + def update_trailing_stops(self, prices: Dict[int, Decimal], is_long: Dict[int, bool]): + """Update all trailing stops with current prices. + + Args: + prices: Dictionary of order_id -> current_price + is_long: Dictionary of order_id -> is_long_position + """ + for order_id, trailing in self.trailing_stops.items(): + if order_id in prices: + trailing.update(prices[order_id], is_long.get(order_id, True)) + + def check_triggers(self, prices: Dict[int, Decimal], is_long: Dict[int, bool]) -> List[int]: + """Check all triggers and return triggered order IDs. + + Args: + prices: Dictionary of order_id -> current_price + is_long: Dictionary of order_id -> is_long_position + + Returns: + List of triggered order IDs + """ + triggered = [] + + # Check take-profit orders + for order_id, tp in self.take_profit_orders.items(): + if order_id in prices and tp.check_trigger(prices[order_id]): + triggered.append(order_id) + + # Check trailing stops + for order_id, trailing in self.trailing_stops.items(): + if order_id in prices and trailing.check_trigger( + prices[order_id], + is_long.get(order_id, True) + ): + triggered.append(order_id) + + return triggered + + async def on_order_filled(self, order_id: int): + """Handle order fill for advanced orders. + + Args: + order_id: Filled order ID + """ + # Check OCO orders + if order_id in self.oco_orders: + await self.oco_orders[order_id].on_order_filled(order_id) + + # Check iceberg orders + for iceberg_id, iceberg in self.iceberg_orders.items(): + if order_id in iceberg.orders: + await iceberg.on_order_filled(order_id) + + +# Global advanced order manager +_advanced_order_manager: Optional[AdvancedOrderManager] = None + + +def get_advanced_order_manager() -> AdvancedOrderManager: + """Get global advanced order manager instance.""" + global _advanced_order_manager + if _advanced_order_manager is None: + _advanced_order_manager = AdvancedOrderManager() + return _advanced_order_manager + diff --git a/src/trading/engine.py b/src/trading/engine.py new file mode 100644 index 00000000..0f19750b --- /dev/null +++ b/src/trading/engine.py @@ -0,0 +1,245 @@ +"""Main trading engine with order execution and position management.""" + +from decimal import Decimal +from typing import Optional, Dict, Any, List +from sqlalchemy.ext.asyncio import AsyncSession +from src.core.database import Order, OrderStatus, OrderSide, OrderType, get_database +from src.core.logger import get_logger +from src.core.repositories import OrderRepository +from src.exchanges import get_exchange +from .order_manager import get_order_manager +from .paper_trading import get_paper_trading +from .fee_calculator import get_fee_calculator +from src.risk.manager import get_risk_manager + +logger = get_logger(__name__) + + +class TradingEngine: + """Main trading engine orchestrator.""" + + def __init__(self): + """Initialize trading engine.""" + self.db = get_database() + self.order_manager = get_order_manager() + self.paper_trading = get_paper_trading() + self.risk_manager = get_risk_manager() + self.logger = get_logger(__name__) + self._exchanges: Dict[int, Any] = {} # exchange_id -> adapter + + async def get_exchange_adapter(self, exchange_id: int): + """Get or create exchange adapter. + + Args: + exchange_id: Exchange ID + + Returns: + Exchange adapter or None + """ + if exchange_id not in self._exchanges: + # We assume get_exchange is a factory function that might NOT be async itself, + # but returns an adapter that IS async. + # If get_exchange does I/O, it should be awaited. + # For now, let's assume it just instantiates the class. + adapter = await get_exchange(exchange_id) + if adapter: + self._exchanges[exchange_id] = adapter + if not adapter._connected: + await adapter.connect() + return self._exchanges.get(exchange_id) + + async def execute_order( + self, + exchange_id: int, + strategy_id: Optional[int], + symbol: str, + side: OrderSide, + order_type: OrderType, + quantity: Decimal, + price: Optional[Decimal] = None, + paper_trading: bool = True + ) -> Optional[Order]: + """Execute a trading order.""" + try: + # Get exchange adapter + adapter = await self.get_exchange_adapter(exchange_id) + if not adapter: + self.logger.error(f"Exchange {exchange_id} not available") + return None + + # Get current balance/price for risk checks + balance = self.paper_trading.get_balance() if paper_trading else Decimal(0) + ticker = await adapter.get_ticker(symbol) + current_price = ticker.get('last', price or Decimal(0)) + + # Risk checks (includes fee validation) + allowed, reason = await self.risk_manager.check_order_risk( + symbol, side.value, quantity, current_price, balance, adapter + ) + if not allowed: + self.logger.warning(f"Order rejected: {reason}") + return None + + # Create order in database using generic repository pattern would be better + # but for now we create the object and save it using our own session management + # or rely on the order_manager which we need to check if it's async-ready. + # For this refactor, let's use the OrderRepository directly for creation + # to be safe with sessions. + + async with self.db.get_session() as session: + repo = OrderRepository(session) + order = Order( + exchange_id=exchange_id, + strategy_id=strategy_id, + symbol=symbol, + order_type=order_type, + side=side, + quantity=quantity, + price=price, + paper_trading=paper_trading + ) + order = await repo.create(order) + + if paper_trading: + # Execute in paper trading + fill_price = current_price # Use current price for market orders + if order_type == OrderType.LIMIT and price: + fill_price = price + + is_maker = (order_type == OrderType.LIMIT) + + # Use paper trading fee calculation (uses configured fee_exchange) + fee_calculator = get_fee_calculator() + fee = fee_calculator.calculate_fee_for_paper_trading( + quantity=quantity, + price=fill_price, + order_type=order_type, + is_maker=is_maker + ) + + if await self.paper_trading.execute_order(order, fill_price, fee): + self.logger.info(f"Paper trading order {order.id} executed with fee: {fee}") + return order + else: + self.logger.warning(f"Paper trading order {order.id} rejected (insufficient funds or no position)") + async with self.db.get_session() as session: + repo = OrderRepository(session) + await repo.update_status(order.id, OrderStatus.REJECTED) + return None + else: + # Execute live order + from src.exchanges.base import Order as ExchangeOrder + exchange_order = ExchangeOrder( + symbol=symbol, + side=side.value, + order_type=order_type.value, + quantity=quantity, + price=price + ) + + result = await adapter.place_order(exchange_order) + if result.get('id'): + actual_fee = adapter.extract_fee_from_order_response(result) + + if actual_fee is None: + fee_calculator = get_fee_calculator() + is_maker = (order_type == OrderType.LIMIT) + estimated_price = price or current_price + actual_fee = fee_calculator.calculate_fee( + quantity=quantity, + price=estimated_price, + order_type=order_type, + exchange_adapter=adapter, + is_maker=is_maker + ) + + async with self.db.get_session() as session: + repo = OrderRepository(session) + await repo.update_status( + order.id, + OrderStatus.OPEN, + exchange_order_id=result['id'], + fee=actual_fee + ) + return order + else: + async with self.db.get_session() as session: + repo = OrderRepository(session) + await repo.update_status(order.id, OrderStatus.REJECTED) + return None + + except Exception as e: + self.logger.error(f"Failed to execute order: {e}") + return None + + async def cancel_order(self, order_id: int) -> bool: + """Cancel an order.""" + async with self.db.get_session() as session: + repo = OrderRepository(session) + order = await repo.get_by_id(order_id) + + if not order: + return False + + # If already in final state, cannot cancel + if order.status in [OrderStatus.FILLED, OrderStatus.CANCELLED, OrderStatus.REJECTED, OrderStatus.EXPIRED]: + self.logger.warning(f"Order {order_id} is already in state {order.status}, cannot cancel") + return False + + # If paper trading, just update status in DB + if order.paper_trading: + await repo.update_status(order_id, OrderStatus.CANCELLED) + self.logger.info(f"Paper trading order {order_id} cancelled") + return True + + # If live trading, cancel on exchange + adapter = await self.get_exchange_adapter(order.exchange_id) + if not adapter: + self.logger.error(f"Exchange adapter not found for exchange_id {order.exchange_id}") + return False + + try: + if await adapter.cancel_order(order.exchange_order_id or str(order.id), order.symbol): + # Update status in DB + await repo.update_status(order_id, OrderStatus.CANCELLED) + self.logger.info(f"Live order {order_id} cancelled on exchange") + return True + else: + self.logger.error(f"Failed to cancel order {order_id} on exchange") + except Exception as e: + self.logger.error(f"Error cancelling order {order_id} on exchange: {e}") + + return False + + async def get_positions(self, exchange_id: Optional[int] = None) -> List[Dict[str, Any]]: + """Get open positions.""" + if exchange_id: + adapter = await self.get_exchange_adapter(exchange_id) + if adapter: + return await adapter.get_positions() + + # Get from paper trading + positions = self.paper_trading.get_positions() + return [ + { + 'symbol': pos.symbol, + 'side': pos.side, + 'quantity': pos.quantity, + 'entry_price': pos.entry_price, + 'current_price': pos.current_price, + 'unrealized_pnl': pos.unrealized_pnl, + } + for pos in positions + ] + + +# Global trading engine +_trading_engine: Optional[TradingEngine] = None + + +def get_trading_engine() -> TradingEngine: + """Get global trading engine instance.""" + global _trading_engine + if _trading_engine is None: + _trading_engine = TradingEngine() + return _trading_engine diff --git a/src/trading/fee_calculator.py b/src/trading/fee_calculator.py new file mode 100644 index 00000000..83d64315 --- /dev/null +++ b/src/trading/fee_calculator.py @@ -0,0 +1,278 @@ +"""Centralized fee calculation service for trading operations.""" + +from decimal import Decimal +from typing import Dict, Optional, Any +from src.core.logger import get_logger +from src.core.config import get_config +from src.exchanges.base import BaseExchangeAdapter +from src.core.database import OrderType, OrderSide + +logger = get_logger(__name__) + + +class FeeCalculator: + """Centralized fee calculation service.""" + + def __init__(self): + """Initialize fee calculator.""" + self.config = get_config() + self.logger = get_logger(__name__) + + def calculate_fee( + self, + quantity: Decimal, + price: Decimal, + order_type: OrderType, + exchange_adapter: Optional[BaseExchangeAdapter] = None, + is_maker: Optional[bool] = None + ) -> Decimal: + """Calculate trading fee for an order. + + Args: + quantity: Trade quantity + price: Trade price + order_type: Order type (MARKET or LIMIT) + exchange_adapter: Exchange adapter for fee structure (optional) + is_maker: Explicit maker/taker flag (if None, determined from order_type) + + Returns: + Trading fee amount + """ + if quantity <= 0 or price <= 0: + return Decimal(0) + + # Determine maker/taker if not explicitly provided + if is_maker is None: + is_maker = self._is_maker_order(order_type) + + # Get fee structure + fee_structure = self._get_fee_structure(exchange_adapter) + fee_rate = fee_structure['maker'] if is_maker else fee_structure['taker'] + + # Calculate fee + trade_value = quantity * price + fee = trade_value * Decimal(str(fee_rate)) + + # Apply minimum fee if configured + min_fee = fee_structure.get('minimum', Decimal(0)) + if min_fee > 0 and fee < min_fee: + fee = min_fee + + return fee + + def estimate_round_trip_fee( + self, + quantity: Decimal, + price: Decimal, + exchange_adapter: Optional[BaseExchangeAdapter] = None + ) -> Decimal: + """Estimate total fees for a round-trip trade (buy + sell). + + Args: + quantity: Trade quantity + price: Trade price + exchange_adapter: Exchange adapter for fee structure (optional) + + Returns: + Total estimated round-trip fee + """ + # Estimate using taker fees (worst case) + buy_fee = self.calculate_fee(quantity, price, OrderType.MARKET, exchange_adapter, is_maker=False) + sell_fee = self.calculate_fee(quantity, price, OrderType.MARKET, exchange_adapter, is_maker=False) + + return buy_fee + sell_fee + + def get_minimum_profit_threshold( + self, + quantity: Decimal, + price: Decimal, + exchange_adapter: Optional[BaseExchangeAdapter] = None, + multiplier: float = 2.0 + ) -> Decimal: + """Calculate minimum profit threshold needed to break even after fees. + + Args: + quantity: Trade quantity + price: Trade price + exchange_adapter: Exchange adapter for fee structure (optional) + multiplier: Multiplier for minimum profit (default 2.0 = 2x fees) + + Returns: + Minimum profit threshold + """ + round_trip_fee = self.estimate_round_trip_fee(quantity, price, exchange_adapter) + return round_trip_fee * Decimal(str(multiplier)) + + def calculate_fee_reserve( + self, + position_value: Decimal, + exchange_adapter: Optional[BaseExchangeAdapter] = None, + reserve_percent: Optional[float] = None + ) -> Decimal: + """Calculate fee reserve amount for position sizing. + + Args: + position_value: Intended position value + exchange_adapter: Exchange adapter for fee structure (optional) + reserve_percent: Override reserve percentage (default: 0.4% for round-trip) + + Returns: + Fee reserve amount + """ + if reserve_percent is None: + # Default: 0.4% for round-trip (conservative estimate) + reserve_percent = 0.004 + + return position_value * Decimal(str(reserve_percent)) + + def _is_maker_order(self, order_type: OrderType) -> bool: + """Determine if order type is typically a maker order. + + Args: + order_type: Order type + + Returns: + True if maker order, False if taker order + """ + # Limit orders that add liquidity = maker + # Market orders that take liquidity = taker + return order_type == OrderType.LIMIT + + def _get_fee_structure( + self, + exchange_adapter: Optional[BaseExchangeAdapter] = None + ) -> Dict[str, Any]: + """Get fee structure from exchange or config defaults. + + Args: + exchange_adapter: Exchange adapter (optional) + + Returns: + Fee structure dictionary with 'maker', 'taker', and optional 'minimum' + """ + # Try to get from exchange adapter first + if exchange_adapter: + try: + fee_structure = exchange_adapter.get_fee_structure() + if fee_structure: + return fee_structure + except Exception as e: + self.logger.warning(f"Failed to get fee structure from exchange: {e}") + + # Get from config with exchange-specific overrides + default_fees = self.config.get("trading.default_fees", { + "maker": 0.001, # 0.1% + "taker": 0.001, # 0.1% + "minimum": 0.0 + }) + + # Check for exchange-specific fees if adapter provided + if exchange_adapter: + exchange_name = exchange_adapter.name.lower() + exchange_fees = self.config.get(f"trading.exchanges.{exchange_name}.fees") + if exchange_fees: + default_fees.update(exchange_fees) + + return default_fees + + def get_fee_structure_by_exchange_name( + self, + exchange_name: str + ) -> Dict[str, Any]: + """Get fee structure for a specific exchange by name (for paper trading). + + Args: + exchange_name: Exchange name (e.g., 'coinbase', 'kraken', 'binance') + + Returns: + Fee structure dictionary with 'maker', 'taker', and optional 'minimum' + """ + # Get default fees + default_fees = self.config.get("trading.default_fees", { + "maker": 0.001, # 0.1% + "taker": 0.001, # 0.1% + "minimum": 0.0 + }) + + # Check for exchange-specific fees + exchange_fees = self.config.get(f"trading.exchanges.{exchange_name.lower()}.fees") + if exchange_fees: + return {**default_fees, **exchange_fees} + + return default_fees + + def calculate_fee_for_paper_trading( + self, + quantity: Decimal, + price: Decimal, + order_type: OrderType, + is_maker: Optional[bool] = None + ) -> Decimal: + """Calculate trading fee for paper trading using configured exchange. + + Args: + quantity: Trade quantity + price: Trade price + order_type: Order type (MARKET or LIMIT) + is_maker: Explicit maker/taker flag (if None, determined from order_type) + + Returns: + Trading fee amount + """ + if quantity <= 0 or price <= 0: + return Decimal(0) + + # Determine maker/taker if not explicitly provided + if is_maker is None: + is_maker = self._is_maker_order(order_type) + + # Get fee exchange from config + fee_exchange = self.config.get("paper_trading.fee_exchange", "coinbase") + fee_structure = self.get_fee_structure_by_exchange_name(fee_exchange) + fee_rate = fee_structure['maker'] if is_maker else fee_structure['taker'] + + # Calculate fee + trade_value = quantity * price + fee = trade_value * Decimal(str(fee_rate)) + + # Apply minimum fee if configured + min_fee = fee_structure.get('minimum', Decimal(0)) + if min_fee > 0 and fee < min_fee: + fee = min_fee + + return fee + + def get_fee_percentage( + self, + order_type: OrderType, + exchange_adapter: Optional[BaseExchangeAdapter] = None + ) -> float: + """Get fee percentage for order type. + + Args: + order_type: Order type + exchange_adapter: Exchange adapter (optional) + + Returns: + Fee percentage (e.g., 0.001 for 0.1%) + """ + is_maker = self._is_maker_order(order_type) + fee_structure = self._get_fee_structure(exchange_adapter) + return fee_structure['maker'] if is_maker else fee_structure['taker'] + + +# Global fee calculator instance +_fee_calculator: Optional[FeeCalculator] = None + + +def get_fee_calculator() -> FeeCalculator: + """Get global fee calculator instance. + + Returns: + FeeCalculator instance + """ + global _fee_calculator + if _fee_calculator is None: + _fee_calculator = FeeCalculator() + return _fee_calculator + diff --git a/src/trading/futures.py b/src/trading/futures.py new file mode 100644 index 00000000..141eead6 --- /dev/null +++ b/src/trading/futures.py @@ -0,0 +1,122 @@ +"""Futures and leverage trading support with margin calculations.""" + +from decimal import Decimal +from typing import Dict, Optional +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class FuturesManager: + """Manages futures and leverage trading.""" + + def __init__(self): + """Initialize futures manager.""" + self.logger = get_logger(__name__) + + def calculate_margin( + self, + quantity: Decimal, + price: Decimal, + leverage: int, + margin_type: str = "isolated" + ) -> Decimal: + """Calculate required margin. + + Args: + quantity: Position quantity + price: Entry price + leverage: Leverage multiplier + margin_type: "isolated" or "cross" + + Returns: + Required margin + """ + position_value = quantity * price + margin = position_value / Decimal(leverage) + return margin + + def calculate_liquidation_price( + self, + entry_price: Decimal, + leverage: int, + side: str, # "long" or "short" + maintenance_margin: Decimal = Decimal("0.01") # 1% + ) -> Decimal: + """Calculate liquidation price. + + Args: + entry_price: Entry price + leverage: Leverage multiplier + side: Position side + maintenance_margin: Maintenance margin rate + + Returns: + Liquidation price + """ + if side == "long": + # For long: liquidation when price drops too much + liquidation = entry_price * (1 - (1 / leverage) + maintenance_margin) + else: + # For short: liquidation when price rises too much + liquidation = entry_price * (1 + (1 / leverage) - maintenance_margin) + + return liquidation + + def calculate_funding_rate( + self, + mark_price: Decimal, + index_price: Decimal + ) -> Decimal: + """Calculate funding rate for perpetual futures. + + Args: + mark_price: Mark price + index_price: Index price + + Returns: + Funding rate (8-hour rate) + """ + premium = (mark_price - index_price) / index_price + funding_rate = premium * Decimal("0.01") # Simplified + return funding_rate + + def calculate_unrealized_pnl( + self, + entry_price: Decimal, + current_price: Decimal, + quantity: Decimal, + side: str, + leverage: int = 1 + ) -> Decimal: + """Calculate unrealized P&L for futures position. + + Args: + entry_price: Entry price + current_price: Current mark price + quantity: Position quantity + side: "long" or "short" + leverage: Leverage multiplier + + Returns: + Unrealized P&L + """ + if side == "long": + pnl = (current_price - entry_price) * quantity * leverage + else: + pnl = (entry_price - current_price) * quantity * leverage + + return pnl + + +# Global futures manager +_futures_manager: Optional[FuturesManager] = None + + +def get_futures_manager() -> FuturesManager: + """Get global futures manager instance.""" + global _futures_manager + if _futures_manager is None: + _futures_manager = FuturesManager() + return _futures_manager + diff --git a/src/trading/order_manager.py b/src/trading/order_manager.py new file mode 100644 index 00000000..76b4afe6 --- /dev/null +++ b/src/trading/order_manager.py @@ -0,0 +1,305 @@ +"""Order state machine and order management system.""" + +from datetime import datetime +from decimal import Decimal +from enum import Enum +from typing import Optional, Dict, Any, List +from sqlalchemy.orm import Session +from sqlalchemy import select +from src.core.database import get_database, Order, OrderStatus, OrderType, OrderSide, TradeType +from src.core.logger import get_logger + +logger = get_logger(__name__) + + +class OrderStateMachine: + """Order state machine for managing order lifecycle.""" + + # Valid state transitions + TRANSITIONS = { + OrderStatus.PENDING: [OrderStatus.OPEN, OrderStatus.REJECTED], + OrderStatus.OPEN: [ + OrderStatus.PARTIALLY_FILLED, + OrderStatus.FILLED, + OrderStatus.CANCELLED, + OrderStatus.EXPIRED + ], + OrderStatus.PARTIALLY_FILLED: [ + OrderStatus.FILLED, + OrderStatus.CANCELLED, + OrderStatus.EXPIRED + ], + OrderStatus.FILLED: [], # Terminal state + OrderStatus.CANCELLED: [], # Terminal state + OrderStatus.REJECTED: [], # Terminal state + OrderStatus.EXPIRED: [], # Terminal state + } + + @staticmethod + def can_transition(from_status: OrderStatus, to_status: OrderStatus) -> bool: + """Check if state transition is valid. + + Args: + from_status: Current status + to_status: Target status + + Returns: + True if transition is valid + """ + return to_status in OrderStateMachine.TRANSITIONS.get(from_status, []) + + @staticmethod + def transition(order: Order, new_status: OrderStatus, **kwargs) -> bool: + """Transition order to new status. + + Args: + order: Order object + new_status: New status + **kwargs: Additional data (filled_quantity, average_fill_price, etc.) + + Returns: + True if transition successful + """ + if not OrderStateMachine.can_transition(order.status, new_status): + logger.warning( + f"Invalid transition from {order.status} to {new_status} for order {order.id}" + ) + return False + + old_status = order.status + order.status = new_status + order.updated_at = datetime.utcnow() + + # Update filled data if provided + if 'filled_quantity' in kwargs: + order.filled_quantity = Decimal(str(kwargs['filled_quantity'])) + if 'average_fill_price' in kwargs: + order.average_fill_price = Decimal(str(kwargs['average_fill_price'])) + if 'fee' in kwargs: + order.fee = Decimal(str(kwargs['fee'])) + if new_status == OrderStatus.FILLED: + order.filled_at = datetime.utcnow() + + logger.info( + f"Order {order.id} transitioned from {old_status} to {new_status}" + ) + return True + + +class OrderManager: + """Manages orders and their state.""" + + def __init__(self): + """Initialize order manager.""" + self.db = get_database() + + async def create_order( + self, + exchange_id: int, + strategy_id: Optional[int], + symbol: str, + order_type: OrderType, + side: OrderSide, + quantity: Decimal, + price: Optional[Decimal] = None, + trade_type: TradeType = TradeType.SPOT, + leverage: int = 1, + paper_trading: bool = True + ) -> Order: + """Create a new order in database. + + Args: + exchange_id: Exchange ID + strategy_id: Strategy ID (optional) + symbol: Trading symbol + order_type: Order type + side: Buy or sell + quantity: Order quantity + price: Order price (for limit orders) + trade_type: Spot, futures, or margin + leverage: Leverage (for futures/margin) + paper_trading: Paper trading flag + + Returns: + Order object + """ + async with self.db.get_session() as session: + try: + order = Order( + exchange_id=exchange_id, + strategy_id=strategy_id, + symbol=symbol, + order_type=order_type, + side=side, + status=OrderStatus.PENDING, + quantity=quantity, + price=price, + trade_type=trade_type, + leverage=leverage, + paper_trading=paper_trading, + created_at=datetime.utcnow() + ) + + session.add(order) + await session.commit() + await session.refresh(order) + + logger.info(f"Created order {order.id}: {side} {quantity} {symbol} @ {price or 'market'}") + return order + except Exception as e: + await session.rollback() + logger.error(f"Failed to create order: {e}") + raise + + async def update_order_status( + self, + order_id: int, + new_status: OrderStatus, + filled_quantity: Optional[Decimal] = None, + average_fill_price: Optional[Decimal] = None, + fee: Optional[Decimal] = None, + exchange_order_id: Optional[str] = None + ) -> bool: + """Update order status. + + Args: + order_id: Order ID + new_status: New status + filled_quantity: Filled quantity + average_fill_price: Average fill price + fee: Trading fee + exchange_order_id: Exchange order ID + + Returns: + True if update successful + """ + async with self.db.get_session() as session: + try: + stmt = select(Order).filter_by(id=order_id) + result = await session.execute(stmt) + order = result.scalar_one_or_none() + + if not order: + logger.error(f"Order {order_id} not found") + return False + + if OrderStateMachine.transition( + order, new_status, + filled_quantity=filled_quantity, + average_fill_price=average_fill_price, + fee=fee + ): + if exchange_order_id: + order.exchange_order_id = exchange_order_id + await session.commit() + return True + return False + except Exception as e: + await session.rollback() + logger.error(f"Failed to update order {order_id}: {e}") + return False + + async def get_order(self, order_id: int) -> Optional[Order]: + """Get order by ID. + + Args: + order_id: Order ID + + Returns: + Order object or None + """ + async with self.db.get_session() as session: + stmt = select(Order).filter_by(id=order_id) + result = await session.execute(stmt) + return result.scalar_one_or_none() + + async def get_open_orders( + self, + exchange_id: Optional[int] = None, + strategy_id: Optional[int] = None, + symbol: Optional[str] = None + ) -> List[Order]: + """Get open orders. + + Args: + exchange_id: Filter by exchange + strategy_id: Filter by strategy + symbol: Filter by symbol + + Returns: + List of open orders + """ + async with self.db.get_session() as session: + stmt = select(Order).filter( + Order.status.in_([ + OrderStatus.PENDING, + OrderStatus.OPEN, + OrderStatus.PARTIALLY_FILLED + ]) + ) + + if exchange_id: + stmt = stmt.filter_by(exchange_id=exchange_id) + if strategy_id: + stmt = stmt.filter_by(strategy_id=strategy_id) + if symbol: + stmt = stmt.filter_by(symbol=symbol) + + result = await session.execute(stmt) + return list(result.scalars().all()) + + async def cancel_order(self, order_id: int) -> bool: + """Cancel an order. + + Args: + order_id: Order ID + + Returns: + True if cancellation successful + """ + return await self.update_order_status(order_id, OrderStatus.CANCELLED) + + async def get_order_history( + self, + exchange_id: Optional[int] = None, + strategy_id: Optional[int] = None, + symbol: Optional[str] = None, + limit: int = 100 + ) -> List[Order]: + """Get order history. + + Args: + exchange_id: Filter by exchange + strategy_id: Filter by strategy + symbol: Filter by symbol + limit: Maximum number of orders + + Returns: + List of orders + """ + async with self.db.get_session() as session: + stmt = select(Order) + + if exchange_id: + stmt = stmt.filter_by(exchange_id=exchange_id) + if strategy_id: + stmt = stmt.filter_by(strategy_id=strategy_id) + if symbol: + stmt = stmt.filter_by(symbol=symbol) + + stmt = stmt.order_by(Order.created_at.desc()).limit(limit) + result = await session.execute(stmt) + return list(result.scalars().all()) + + +# Global order manager +_order_manager: Optional[OrderManager] = None + + +def get_order_manager() -> OrderManager: + """Get global order manager instance.""" + global _order_manager + if _order_manager is None: + _order_manager = OrderManager() + return _order_manager + diff --git a/src/trading/paper_trading.py b/src/trading/paper_trading.py new file mode 100644 index 00000000..6c6ead57 --- /dev/null +++ b/src/trading/paper_trading.py @@ -0,0 +1,377 @@ +"""Paper trading simulator with virtual funds.""" + +from decimal import Decimal +from datetime import datetime +from typing import Dict, Optional, List +from sqlalchemy.orm import Session +from sqlalchemy import select +from src.core.database import get_database, Trade, Position, PortfolioSnapshot, Order, OrderStatus +from src.core.config import get_config +from src.core.logger import get_logger +from .order_manager import get_order_manager + +logger = get_logger(__name__) + + +class PaperTradingSimulator: + """Paper trading simulator with virtual funds.""" + + def __init__(self, initial_capital: Optional[Decimal] = None): + """Initialize paper trading simulator. + + Args: + initial_capital: Initial capital (default from config) + """ + self.config = get_config() + self.db = get_database() + + if initial_capital is None: + initial_capital = Decimal(str(self.config.get("paper_trading.default_capital", 100.0))) + + self.initial_capital = initial_capital + self.cash = initial_capital + self.positions: Dict[str, Position] = {} + # initialization moved to initialize() + + async def initialize(self): + """Initialize paper trading simulator.""" + await self._initialize_portfolio() + + async def _initialize_portfolio(self): + """Initialize portfolio in database.""" + async with self.db.get_session() as session: + try: + # Check if portfolio already exists + stmt = select(PortfolioSnapshot).order_by( + PortfolioSnapshot.timestamp.desc() + ) + result = await session.execute(stmt) + latest = result.scalar_one_or_none() + + if not latest: + # Create initial snapshot + snapshot = PortfolioSnapshot( + total_value=self.initial_capital, + cash=self.initial_capital, + positions_value=Decimal(0), + unrealized_pnl=Decimal(0), + realized_pnl=Decimal(0), + paper_trading=True, + timestamp=datetime.utcnow() + ) + session.add(snapshot) + await session.commit() + logger.info(f"Initialized paper trading portfolio with ${self.initial_capital}") + except Exception as e: + await session.rollback() + logger.error(f"Failed to initialize portfolio: {e}") + + def get_balance(self, currency: str = "USD") -> Decimal: + """Get cash balance. + + Args: + currency: Currency (default USD) + + Returns: + Cash balance + """ + return self.cash + + def get_portfolio_value(self) -> Decimal: + """Get total portfolio value (cash + positions). + + Returns: + Total portfolio value + """ + positions_value = sum( + pos.quantity * (pos.current_price or pos.entry_price) + for pos in self.positions.values() + ) + return self.cash + positions_value + + async def execute_order(self, order: Order, fill_price: Decimal, fee: Decimal = Decimal(0)) -> bool: + """Execute an order in paper trading. + + Args: + order: Order to execute + fill_price: Fill price + fee: Trading fee + + Returns: + True if execution successful + """ + if not order.paper_trading: + logger.error("Cannot execute live order in paper trading") + return False + + async with self.db.get_session() as session: + try: + order_manager = get_order_manager() + + # Calculate total cost + total_cost = order.quantity * fill_price + fee + + if order.side.value == "buy": + # Check if we have enough cash + if self.cash < total_cost: + logger.warning(f"Insufficient cash: ${self.cash} < ${total_cost}") + await order_manager.update_order_status(order.id, OrderStatus.REJECTED) + return False + + # Deduct cash + self.cash -= total_cost + + # Update or create position + symbol = order.symbol + if symbol in self.positions: + pos = self.positions[symbol] + # Average entry price + total_value = (pos.quantity * pos.entry_price) + (order.quantity * fill_price) + total_quantity = pos.quantity + order.quantity + pos.entry_price = total_value / total_quantity + pos.quantity = total_quantity + pos.current_price = fill_price + else: + # Create new position + pos = Position( + exchange_id=order.exchange_id, + symbol=symbol, + side="long", + quantity=order.quantity, + entry_price=fill_price, + current_price=fill_price, + unrealized_pnl=Decimal(0), + realized_pnl=Decimal(0), + trade_type=order.trade_type, + leverage=order.leverage, + paper_trading=True, + opened_at=datetime.utcnow() + ) + session.add(pos) + await session.commit() + await session.refresh(pos) + self.positions[symbol] = pos + + elif order.side.value == "sell": + # Check if we have position + symbol = order.symbol + if symbol not in self.positions: + logger.warning(f"No position to sell for {symbol}") + await order_manager.update_order_status(order.id, OrderStatus.REJECTED) + return False + + pos = self.positions[symbol] + if pos.quantity < order.quantity: + logger.warning(f"Insufficient position: {pos.quantity} < {order.quantity}") + await order_manager.update_order_status(order.id, OrderStatus.REJECTED) + return False + + # Calculate P&L + pnl = (fill_price - pos.entry_price) * order.quantity - fee + + # Add cash + self.cash += (order.quantity * fill_price) - fee + + # Update position + pos.quantity -= order.quantity + pos.realized_pnl += pnl + + if pos.quantity <= 0: + # Close position + await session.delete(pos) + del self.positions[symbol] + else: + pos.current_price = fill_price + + # Create trade record + trade = Trade( + exchange_id=order.exchange_id, + strategy_id=order.strategy_id, + order_id=order.id, + symbol=order.symbol, + side=order.side, + quantity=order.quantity, + price=fill_price, + fee=fee, + total=order.quantity * fill_price + fee, + trade_type=order.trade_type, + paper_trading=True, + timestamp=datetime.utcnow() + ) + session.add(trade) + + # Update order status + await order_manager.update_order_status( + order.id, + OrderStatus.FILLED, + filled_quantity=order.quantity, + average_fill_price=fill_price, + fee=fee + ) + + # Update portfolio snapshot + self._update_portfolio_snapshot(session) + + await session.commit() + logger.info(f"Executed {order.side.value} order {order.id}: {order.quantity} {order.symbol} @ {fill_price}") + return True + + except Exception as e: + await session.rollback() + logger.error(f"Failed to execute order {order.id}: {e}") + return False + + def _update_portfolio_snapshot(self, session: Session): + """Update portfolio snapshot. + + Args: + session: Database session + """ + positions_value = Decimal(0) + unrealized_pnl = Decimal(0) + + for pos in self.positions.values(): + if pos.current_price: + pos_value = pos.quantity * pos.current_price + positions_value += pos_value + unrealized_pnl += (pos.current_price - pos.entry_price) * pos.quantity + + total_value = self.cash + positions_value + + snapshot = PortfolioSnapshot( + total_value=total_value, + cash=self.cash, + positions_value=positions_value, + unrealized_pnl=unrealized_pnl, + realized_pnl=sum(pos.realized_pnl for pos in self.positions.values()), + paper_trading=True, + timestamp=datetime.utcnow() + ) + session.add(snapshot) + + async def update_positions_prices(self, prices: Dict[str, Decimal]): + """Update current prices for positions. + + Args: + prices: Dictionary of symbol -> current_price + """ + async with self.db.get_session() as session: + try: + for symbol, price in prices.items(): + if symbol in self.positions: + pos = self.positions[symbol] + pos.current_price = price + pos.unrealized_pnl = (price - pos.entry_price) * pos.quantity + pos.updated_at = datetime.utcnow() + + # Update portfolio snapshot + self._update_portfolio_snapshot(session) + await session.commit() + except Exception as e: + await session.rollback() + logger.error(f"Failed to update positions prices: {e}") + + def get_positions(self) -> List[Position]: + """Get all open positions. + + Returns: + List of positions + """ + return list(self.positions.values()) + + def get_performance(self) -> Dict[str, Decimal]: + """Get portfolio performance metrics. + + Returns: + Dictionary with performance metrics + """ + total_value = self.get_portfolio_value() + total_return = ((total_value - self.initial_capital) / self.initial_capital) * 100 + + realized_pnl = sum(pos.realized_pnl for pos in self.positions.values()) + unrealized_pnl = sum( + (pos.current_price - pos.entry_price) * pos.quantity + for pos in self.positions.values() + if pos.current_price + ) + + return { + "initial_capital": self.initial_capital, + "current_value": total_value, + "cash": self.cash, + "positions_value": total_value - self.cash, + "total_return_percent": total_return, + "realized_pnl": realized_pnl, + "unrealized_pnl": unrealized_pnl, + "total_pnl": realized_pnl + unrealized_pnl, + } + + async def reset(self, new_capital: Optional[Decimal] = None): + """Reset paper trading account to initial state. + + Args: + new_capital: New initial capital (uses config if not provided) + """ + # Get new capital from config if not provided + if new_capital is None: + new_capital = Decimal(str(self.config.get("paper_trading.default_capital", 100.0))) + + async with self.db.get_session() as session: + try: + # Delete all portfolio snapshots for paper trading + from sqlalchemy import delete + stmt = delete(PortfolioSnapshot).where(PortfolioSnapshot.paper_trading == True) + await session.execute(stmt) + + # Delete all paper trading positions + stmt = delete(Position).where(Position.paper_trading == True) + await session.execute(stmt) + + # Delete all paper trading trades + stmt = delete(Trade).where(Trade.paper_trading == True) + await session.execute(stmt) + + # Delete all paper trading orders + stmt = delete(Order).where(Order.paper_trading == True) + await session.execute(stmt) + + await session.commit() + + # Reset in-memory state + self.initial_capital = new_capital + self.cash = new_capital + self.positions = {} + + # Create new initial snapshot + snapshot = PortfolioSnapshot( + total_value=new_capital, + cash=new_capital, + positions_value=Decimal(0), + unrealized_pnl=Decimal(0), + realized_pnl=Decimal(0), + paper_trading=True, + timestamp=datetime.utcnow() + ) + session.add(snapshot) + await session.commit() + + logger.info(f"Reset paper trading account with ${new_capital}") + return True + + except Exception as e: + await session.rollback() + logger.error(f"Failed to reset paper trading: {e}") + return False + + +# Global paper trading simulator +_paper_trading: Optional[PaperTradingSimulator] = None + + +def get_paper_trading() -> PaperTradingSimulator: + """Get global paper trading simulator instance.""" + global _paper_trading + if _paper_trading is None: + _paper_trading = PaperTradingSimulator() + return _paper_trading + diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/worker/app.py b/src/worker/app.py new file mode 100644 index 00000000..19f35205 --- /dev/null +++ b/src/worker/app.py @@ -0,0 +1,55 @@ +"""Celery worker application initialization.""" + +import os +from celery import Celery +from src.core.config import get_config + +# Load configuration +config = get_config() + +# Get broker and backend URLs +redis_config = config.get("redis", {}) +host = redis_config.get("host", "localhost") +port = redis_config.get("port", 6379) +db = redis_config.get("db", 0) +password = redis_config.get("password") + +if password: + auth = f":{password}@" +else: + auth = "" + +broker_url = f"redis://{auth}{host}:{port}/{db}" + +# Initialize Celery app +app = Celery( + "crypto_trader_worker", + broker=broker_url, + backend=broker_url, + include=["src.worker.tasks"] +) + +# Configure Celery +app.conf.update( + task_serializer="json", + accept_content=["json"], + result_serializer="json", + timezone="UTC", + enable_utc=True, + task_track_started=True, + worker_prefetch_multiplier=1, # Good for long running ML tasks + task_acks_late=True, # Ensure task is not lost if worker crashes +) + +# Queue routing disabled - all tasks go to default 'celery' queue +# If you want to use separate queues, start workers with: celery -A src.worker.app worker -Q ml_training,celery +# app.conf.task_routes = { +# "src.worker.tasks.train_model_task": {"queue": "ml_training"}, +# "src.worker.tasks.bootstrap_task": {"queue": "ml_training"}, +# "src.worker.tasks.optimize_strategy_task": {"queue": "ml_training"}, +# "src.worker.tasks.generate_report_task": {"queue": "reporting"}, +# "src.worker.tasks.export_data_task": {"queue": "reporting"}, +# } + +if __name__ == "__main__": + app.start() diff --git a/src/worker/tasks.py b/src/worker/tasks.py new file mode 100644 index 00000000..928e8e3d --- /dev/null +++ b/src/worker/tasks.py @@ -0,0 +1,544 @@ +"""Background tasks for Celery worker.""" + +import asyncio +import os +from pathlib import Path +from datetime import datetime +from typing import Dict, Any, Optional, List +from celery import shared_task +from celery.utils.log import get_task_logger + +from src.autopilot.strategy_selector import get_strategy_selector +from src.core.logger import get_logger + +# Use Celery logger +logger = get_task_logger(__name__) + +# Report output directory +REPORTS_DIR = Path(os.path.expanduser("~/.local/share/crypto_trader/reports")) +REPORTS_DIR.mkdir(parents=True, exist_ok=True) + + +def async_to_sync(awaitable): + """Run async awaitable synchronously.""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + return loop.run_until_complete(awaitable) + finally: + loop.close() + + +@shared_task(bind=True, name="src.worker.tasks.train_model_task") +def train_model_task( + self, + force_retrain: bool = False, + bootstrap: bool = True, + symbols: List[str] = None, + days: int = None, + timeframe: str = None, + min_samples_per_strategy: int = None +) -> Dict[str, Any]: + """Train ML model in background (Offloaded from API). + + Args: + force_retrain: Force retraining even if recent model exists + bootstrap: Automatically bootstrap training data if insufficient + symbols: List of symbols to bootstrap (defaults to config value if not provided) + days: Number of days of historical data (defaults to config value) + timeframe: Timeframe for data (defaults to config value) + min_samples_per_strategy: Minimum samples per strategy (defaults to config value) + + Returns: + Training metrics + """ + logger.info(f"Starting model training workflow (force={force_retrain}, bootstrap={bootstrap}, symbols={symbols})") + + # CRITICAL: Update state IMMEDIATELY before any blocking operations + # This must happen in the sync context, not in async_to_sync + try: + self.update_state(state='PROGRESS', meta={'step': 'init', 'progress': 5, 'message': 'Initializing...'}) + logger.info("Initial progress state updated") + except Exception as e: + logger.error(f"Failed to update initial state: {e}") + # Don't fail the task, just log it + + async def run_training_workflow(): + try: + logger.info("Step 1: Resetting singletons...") + # Update progress - use try/except in case update_state fails + try: + self.update_state(state='PROGRESS', meta={'step': 'reset', 'progress': 8, 'message': 'Resetting modules...'}) + except: + pass + + # Force reset singletons to ensure no connection sharing across loops + from src.autopilot import strategy_selector as ss_module + from src.core import database as db_module + + ss_module._strategy_selector = None + + # Also reset PerformanceTracker as it holds a reference to the database + from src.autopilot import performance_tracker as pt_module + pt_module._performance_tracker = None + + db_module._db_instance = None # Reset global database instance to ensure fresh connection/engine + + # Re-import to be safe + import src.autopilot.strategy_selector + src.autopilot.strategy_selector._strategy_selector = None + + logger.info("Step 2: Getting strategy selector...") + try: + self.update_state(state='PROGRESS', meta={'step': 'get_selector', 'progress': 10, 'message': 'Getting strategy selector...'}) + except: + pass + + selector = get_strategy_selector() + + # Use passed config or fall back to selector defaults + bootstrap_days = days if days is not None else selector.bootstrap_days + bootstrap_timeframe = timeframe if timeframe is not None else selector.bootstrap_timeframe + bootstrap_min_samples = min_samples_per_strategy if min_samples_per_strategy is not None else selector.min_samples_per_strategy + bootstrap_symbols = symbols if symbols else selector.bootstrap_symbols + + logger.info(f"Config: days={bootstrap_days}, timeframe={bootstrap_timeframe}, min_samples={bootstrap_min_samples}, symbols={bootstrap_symbols}") + + # 1. Check existing training data + logger.info("Step 3: Checking existing training data...") + try: + self.update_state(state='PROGRESS', meta={'step': 'check_data', 'progress': 15, 'message': 'Checking training data...'}) + except: + pass + training_data = await selector.performance_tracker.prepare_training_data(min_samples_per_strategy=bootstrap_min_samples) + logger.info(f"Training data check complete. Has data: {training_data is not None and len(training_data.get('X', [])) > 0}") + + # 2. Bootstrap if needed + if (training_data is None or len(training_data.get('X', [])) == 0) and bootstrap: + self.update_state(state='PROGRESS', meta={'step': 'bootstrap', 'progress': 20, 'message': 'Bootstrapping data...'}) + + total_samples = 0 + + for i, symbol in enumerate(bootstrap_symbols): + pct = 20 + int(40 * (i / len(bootstrap_symbols))) + self.update_state(state='PROGRESS', meta={ + 'step': 'bootstrap', + 'progress': pct, + 'message': f'Bootstrapping {symbol} ({i+1}/{len(bootstrap_symbols)})...', + 'details': {'symbol': symbol, 'symbol_index': i+1, 'total_symbols': len(bootstrap_symbols)} + }) + + logger.info(f"Starting bootstrap for {symbol} (days={bootstrap_days}, timeframe={bootstrap_timeframe})") + res = await selector.bootstrap_training_data( + symbol=symbol, + timeframe=bootstrap_timeframe, + days=bootstrap_days + ) + if "error" not in res: + total_samples += res.get("total_samples", 0) + logger.info(f"Bootstrap for {symbol} complete: {res.get('total_samples', 0)} samples") + else: + logger.warning(f"Bootstrap for {symbol} failed: {res.get('error', 'Unknown error')}") + + if total_samples == 0: + raise Exception("Bootstrap failed: No data collected") + + # 3. Train + self.update_state(state='PROGRESS', meta={'step': 'training', 'progress': 70, 'message': 'Training model...'}) + metrics = await selector.train_model(force_retrain=force_retrain, min_samples_per_strategy=bootstrap_min_samples) + + self.update_state(state='PROGRESS', meta={'step': 'complete', 'progress': 100, 'message': 'Training complete'}) + logger.info(f"Model training workflow completed: {metrics}") + return metrics + + except Exception as e: + logger.error(f"Model training logic failed: {e}") + raise e + + try: + return async_to_sync(run_training_workflow()) + except Exception as e: + logger.error(f"Model training task failed: {e}") + # self.update_state(state='FAILURE', meta={'error': str(e)}) # Let Celery handle failure state + raise e + + +@shared_task(bind=True, name="src.worker.tasks.bootstrap_task") +def bootstrap_task( + self, + days: int = 90, + timeframe: str = "1h", + min_samples: int = 10, + symbols: Optional[List[str]] = None +) -> Dict[str, Any]: + """Bootstrap training data in background. + + Args: + days: Number of days to look back + timeframe: Candle timeframe + min_samples: Minimum samples per strategy + symbols: List of training symbols + + Returns: + Bootstrap results + """ + logger.info(f"Starting data bootstrap (days={days}, timeframe={timeframe})") + try: + selector = get_strategy_selector() + + results = async_to_sync(selector.bootstrap_training_data( + days=days, + timeframe=timeframe, + )) + + logger.info("Bootstrap task completed") + return results + except Exception as e: + logger.error(f"Bootstrap task failed: {e}") + raise + + +@shared_task(bind=True, name="src.worker.tasks.optimize_strategy_task") +def optimize_strategy_task( + self, + strategy_type: str, + symbol: str, + param_ranges: Dict[str, tuple], + method: str = "genetic", + population_size: int = 50, + generations: int = 100, + maximize: bool = True +) -> Dict[str, Any]: + """Optimize strategy parameters in background. + + Args: + strategy_type: Type of strategy to optimize (e.g., 'rsi', 'macd') + symbol: Trading symbol for backtesting + param_ranges: Dictionary of parameter -> (min, max) range + method: Optimization method ('genetic', 'grid', 'bayesian') + population_size: Population size for genetic algorithm + generations: Number of generations + maximize: True to maximize objective, False to minimize + + Returns: + Optimization results with best parameters + """ + logger.info(f"Starting strategy optimization: {strategy_type} on {symbol}") + + try: + self.update_state(state='PROGRESS', meta={ + 'step': 'init', + 'progress': 5, + 'message': f'Initializing {method} optimization...' + }) + + if method == "genetic": + from src.optimization.genetic import GeneticOptimizer + optimizer = GeneticOptimizer() + + # Define objective function (backtesting) + def objective(params: Dict[str, Any]) -> float: + """Run backtest with given parameters.""" + try: + from src.backtesting.engine import BacktestEngine + from src.strategies.base import get_strategy_registry + + registry = get_strategy_registry() + strategy_class = registry.get(strategy_type) + if not strategy_class: + return float('-inf') + + # Create strategy with params + strategy = strategy_class(name=f"{strategy_type}_opt", **params) + + # Run quick backtest + engine = BacktestEngine() + results = async_to_sync(engine.run( + strategy=strategy, + symbol=symbol, + start_date=datetime.now().replace(day=1), # Last month + end_date=datetime.now(), + initial_capital=10000.0 + )) + + # Return Sharpe ratio as objective + return results.get('sharpe_ratio', 0.0) + except Exception as e: + logger.warning(f"Objective evaluation failed: {e}") + return float('-inf') + + self.update_state(state='PROGRESS', meta={ + 'step': 'optimizing', + 'progress': 20, + 'message': f'Running genetic algorithm ({generations} generations)...' + }) + + result = optimizer.optimize( + param_ranges=param_ranges, + objective_function=objective, + population_size=population_size, + generations=generations, + maximize=maximize + ) + + elif method == "grid": + from src.optimization.grid_search import GridSearchOptimizer + optimizer = GridSearchOptimizer() + + self.update_state(state='PROGRESS', meta={ + 'step': 'optimizing', + 'progress': 20, + 'message': 'Running grid search...' + }) + + # Grid search implementation would go here + result = {"best_params": {}, "best_score": 0.0, "message": "Grid search not fully implemented"} + + else: + result = {"error": f"Unknown optimization method: {method}"} + + self.update_state(state='PROGRESS', meta={ + 'step': 'complete', + 'progress': 100, + 'message': 'Optimization complete' + }) + + logger.info(f"Optimization completed: {result}") + return result + + except Exception as e: + logger.error(f"Strategy optimization failed: {e}") + self.update_state(state='FAILURE', meta={'error': str(e)}) + raise + + +@shared_task(bind=True, name="src.worker.tasks.generate_report_task") +def generate_report_task(self, report_type: str, params: Dict[str, Any]) -> Dict[str, Any]: + """Generate report in background. + + Args: + report_type: 'performance', 'backtest', 'tax', 'trades' + params: Report parameters including: + - start_date: Start date for report + - end_date: End date for report + - format: 'pdf' or 'csv' + + Returns: + Result with file path + """ + logger.info(f"Starting report generation: {report_type}") + + try: + self.update_state(state='PROGRESS', meta={ + 'step': 'init', + 'progress': 10, + 'message': f'Preparing {report_type} report...' + }) + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + report_format = params.get('format', 'pdf') + + if report_type == "performance": + self.update_state(state='PROGRESS', meta={ + 'step': 'collecting', + 'progress': 30, + 'message': 'Collecting performance metrics...' + }) + + # Collect metrics from database + from src.portfolio.analytics import get_portfolio_analytics + analytics = get_portfolio_analytics() + metrics = async_to_sync(analytics.get_portfolio_metrics()) + + if report_format == 'pdf': + from src.reporting.pdf_generator import get_pdf_generator + generator = get_pdf_generator() + + filepath = REPORTS_DIR / f"performance_{timestamp}.pdf" + + self.update_state(state='PROGRESS', meta={ + 'step': 'generating', + 'progress': 70, + 'message': 'Generating PDF...' + }) + + success = generator.generate_performance_report(filepath, metrics) + + if success: + result = {"status": "success", "file": str(filepath), "type": "pdf"} + else: + result = {"status": "error", "message": "PDF generation failed"} + else: + # CSV export + from src.reporting.csv_exporter import get_csv_exporter + exporter = get_csv_exporter() + + filepath = REPORTS_DIR / f"performance_{timestamp}.csv" + success = exporter.export_metrics(filepath, metrics) + result = {"status": "success" if success else "error", "file": str(filepath)} + + elif report_type == "trades": + self.update_state(state='PROGRESS', meta={ + 'step': 'collecting', + 'progress': 30, + 'message': 'Collecting trade history...' + }) + + from src.reporting.csv_exporter import get_csv_exporter + from src.core.database import get_database, Trade + from sqlalchemy import select + + db = get_database() + + async def get_trades(): + async with db.get_session() as session: + stmt = select(Trade).order_by(Trade.timestamp.desc()).limit(1000) + result = await session.execute(stmt) + return result.scalars().all() + + trades = async_to_sync(get_trades()) + + filepath = REPORTS_DIR / f"trades_{timestamp}.csv" + exporter = get_csv_exporter() + + self.update_state(state='PROGRESS', meta={ + 'step': 'generating', + 'progress': 70, + 'message': 'Exporting trades...' + }) + + success = exporter.export_trades(filepath, trades) + result = {"status": "success" if success else "error", "file": str(filepath), "count": len(trades)} + + elif report_type == "tax": + self.update_state(state='PROGRESS', meta={ + 'step': 'calculating', + 'progress': 30, + 'message': 'Calculating tax obligations...' + }) + + from src.reporting.tax_reporter import get_tax_reporter + reporter = get_tax_reporter() + + year = params.get('year', datetime.now().year) + method = params.get('method', 'fifo') # fifo, lifo, specific + + filepath = REPORTS_DIR / f"tax_report_{year}_{timestamp}.pdf" + + self.update_state(state='PROGRESS', meta={ + 'step': 'generating', + 'progress': 70, + 'message': 'Generating tax report...' + }) + + success = reporter.generate_annual_report(year, method, filepath) + result = {"status": "success" if success else "error", "file": str(filepath), "year": year} + + else: + result = {"status": "error", "message": f"Unknown report type: {report_type}"} + + self.update_state(state='PROGRESS', meta={ + 'step': 'complete', + 'progress': 100, + 'message': 'Report generated' + }) + + logger.info(f"Report generation completed: {result}") + return result + + except Exception as e: + logger.error(f"Report generation failed: {e}") + self.update_state(state='FAILURE', meta={'error': str(e)}) + raise + + +@shared_task(bind=True, name="src.worker.tasks.export_data_task") +def export_data_task( + self, + export_type: str, + params: Dict[str, Any] +) -> Dict[str, Any]: + """Export data in background. + + Args: + export_type: 'orders', 'positions', 'market_data' + params: Export parameters + + Returns: + Result with file path + """ + logger.info(f"Starting data export: {export_type}") + + try: + self.update_state(state='PROGRESS', meta={ + 'step': 'init', + 'progress': 10, + 'message': f'Preparing {export_type} export...' + }) + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + + from src.reporting.csv_exporter import get_csv_exporter + exporter = get_csv_exporter() + + filepath = REPORTS_DIR / f"{export_type}_{timestamp}.csv" + + self.update_state(state='PROGRESS', meta={ + 'step': 'exporting', + 'progress': 50, + 'message': 'Exporting data...' + }) + + # Export based on type + if export_type == "orders": + from src.core.database import get_database, Order + from sqlalchemy import select + + db = get_database() + + async def get_orders(): + async with db.get_session() as session: + stmt = select(Order).order_by(Order.created_at.desc()) + result = await session.execute(stmt) + return result.scalars().all() + + data = async_to_sync(get_orders()) + success = exporter.export_orders(filepath, data) + + elif export_type == "positions": + from src.core.database import get_database, Position + from sqlalchemy import select + + db = get_database() + + async def get_positions(): + async with db.get_session() as session: + stmt = select(Position) + result = await session.execute(stmt) + return result.scalars().all() + + data = async_to_sync(get_positions()) + success = exporter.export_positions(filepath, data) + + else: + success = False + + result = { + "status": "success" if success else "error", + "file": str(filepath), + "export_type": export_type + } + + self.update_state(state='PROGRESS', meta={ + 'step': 'complete', + 'progress': 100, + 'message': 'Export complete' + }) + + logger.info(f"Data export completed: {result}") + return result + + except Exception as e: + logger.error(f"Data export failed: {e}") + self.update_state(state='FAILURE', meta={'error': str(e)}) + raise diff --git a/start_log.txt b/start_log.txt new file mode 100644 index 00000000..6eb67552 --- /dev/null +++ b/start_log.txt @@ -0,0 +1,5 @@ +nohup: ignoring input +Starting Redis... +sudo: The "no new privileges" flag is set, which prevents sudo from running as root. +sudo: If sudo is running in a container, you may need to adjust the container configuration to disable the flag. +x Failed to start Redis diff --git a/test_output.txt b/test_output.txt new file mode 100644 index 00000000..5588db2b --- /dev/null +++ b/test_output.txt @@ -0,0 +1,158 @@ +============================= test session starts ============================== +platform linux -- Python 3.12.3, pytest-9.0.2, pluggy-1.6.0 -- /var/home/kfox/AI Coding/crypto_trader/venv/bin/python3 +cachedir: .pytest_cache +rootdir: /var/home/kfox/AI Coding/crypto_trader +configfile: pytest.ini +plugins: anyio-4.12.0, asyncio-1.3.0, cov-7.0.0 +asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function +collecting ... 2025-12-22 01:43:22 [INFO] src.exchanges.factory: Registered exchange adapter: coinbase +2025-12-22 01:43:22 [INFO] src.exchanges.factory: Registered exchange adapter: binance public +2025-12-22 01:43:22 [INFO] src.exchanges.factory: Registered exchange adapter: public data +2025-12-22 01:43:22 [INFO] src.strategies.base: Registered strategy: rsi +2025-12-22 01:43:22 [INFO] src.strategies.base: Registered strategy: macd +2025-12-22 01:43:22 [INFO] src.strategies.base: Registered strategy: moving_average +2025-12-22 01:43:22 [INFO] src.strategies.base: Registered strategy: confirmed +2025-12-22 01:43:22 [INFO] src.strategies.base: Registered strategy: divergence +2025-12-22 01:43:22 [INFO] src.strategies.base: Registered strategy: bollinger_mean_reversion +2025-12-22 01:43:22 [INFO] src.strategies.base: Registered strategy: dca +2025-12-22 01:43:22 [INFO] src.strategies.base: Registered strategy: grid +2025-12-22 01:43:22 [INFO] src.strategies.base: Registered strategy: momentum +2025-12-22 01:43:22 [INFO] src.strategies.base: Registered strategy: consensus +2025-12-22 01:43:22 [INFO] src.strategies.base: Registered strategy: pairs_trading +collected 2 items + +tests/unit/strategies/test_pairs_trading.py::test_pairs_trading_short_spread_signal 2025-12-22 01:43:22 [INFO] strategy.test_pairs: Pairs SOL/USD/AVAX/USD: Spread=4.8000, Z-Score=1.79 +FAILED +tests/unit/strategies/test_pairs_trading.py::test_pairs_trading_long_spread_signal 2025-12-22 01:43:22 [INFO] strategy.test_pairs: Pairs SOL/USD/AVAX/USD: Spread=3.2000, Z-Score=-1.79 +FAILED/var/home/kfox/AI Coding/crypto_trader/venv/lib/python3.12/site-packages/coverage/report_core.py:107: CoverageWarning: Couldn't parse Python file '/var/home/kfox/AI Coding/crypto_trader/src/rebalancing/engine.py' (couldnt-parse); see https://coverage.readthedocs.io/en/7.13.0/messages.html#warning-couldnt-parse + coverage._warn(msg, slug="couldnt-parse") + +ERROR: Coverage failure: total of 21.09 is less than fail-under=95.00 + + +=================================== FAILURES =================================== +____________________ test_pairs_trading_short_spread_signal ____________________ +tests/unit/strategies/test_pairs_trading.py:62: in test_pairs_trading_short_spread_signal + assert signal is not None +E assert None is not None +------------------------------ Captured log call ------------------------------- +INFO strategy.test_pairs:pairs_trading.py:92 Pairs SOL/USD/AVAX/USD: Spread=4.8000, Z-Score=1.79 +____________________ test_pairs_trading_long_spread_signal _____________________ +tests/unit/strategies/test_pairs_trading.py:86: in test_pairs_trading_long_spread_signal + assert signal is not None +E assert None is not None +------------------------------ Captured log call ------------------------------- +INFO strategy.test_pairs:pairs_trading.py:92 Pairs SOL/USD/AVAX/USD: Spread=3.2000, Z-Score=-1.79 +=============================== warnings summary =============================== +src/core/database.py:16 + /var/home/kfox/AI Coding/crypto_trader/src/core/database.py:16: MovedIn20Warning: The ``declarative_base()`` function is now available as sqlalchemy.orm.declarative_base(). (deprecated since: 2.0) (Background on SQLAlchemy 2.0 at: https://sqlalche.me/e/b8d9) + Base = declarative_base() + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.12.3-final-0 ________________ + +Name Stmts Miss Cover Missing +------------------------------------------------------------------------------------- +src/__init__.py 0 0 100.00% +src/alerts/__init__.py 0 0 100.00% +src/alerts/channels.py 10 10 0.00% 3-25 +src/alerts/engine.py 71 71 0.00% 3-138 +src/alerts/manager.py 35 35 0.00% 3-82 +src/autopilot/__init__.py 8 1 87.50% 48 +src/autopilot/intelligent_autopilot.py 246 211 14.23% 43-93, 97-134, 138-154, 162-163, 167-188, 193-266, 291-317, 336-346, 367-397, 425-446, 461-543, 558-580, 584-587, 591-592, 605, 640-650, 655-658 +src/autopilot/market_analyzer.py 267 233 12.73% 42, 56-57, 75-91, 108-361, 372-402, 418-472, 482-484 +src/autopilot/models.py 221 187 15.38% 35-37, 42-44, 55-71, 79-136, 158-290, 312-343, 357-396, 404-419, 430-450, 461-482, 490-496, 512-515 +src/autopilot/performance_tracker.py 114 96 15.79% 22-23, 41-84, 102-164, 180-232, 246-289, 299-301 +src/autopilot/strategy_selector.py 184 163 11.41% 20-38, 42-49, 53-54, 71-101, 118-144, 158-205, 219-233, 246-247, 268-415, 427-446, 456-458 +src/backtesting/__init__.py 0 0 100.00% +src/backtesting/data_provider.py 15 15 0.00% 3-50 +src/backtesting/engine.py 76 76 0.00% 3-206 +src/backtesting/metrics.py 21 21 0.00% 3-84 +src/backtesting/slippage.py 53 53 0.00% 3-174 +src/core/__init__.py 0 0 100.00% +src/core/config.py 86 18 79.07% 32, 36, 150-151, 155, 157, 161, 179-181, 191-197, 201-202 +src/core/database.py 270 10 96.30% 370, 374, 377, 381-382, 386, 390, 400-402 +src/core/logger.py 58 11 81.03% 73-78, 99-102, 127 +src/core/repositories.py 44 26 40.91% 15, 22-25, 29-32, 36-39, 49-61, 65-71, 75-79, 86-89, 93-99 +src/data/__init__.py 6 0 100.00% +src/data/cache_manager.py 87 66 24.14% 22-26, 34, 38-39, 47, 71-82, 94-112, 130-148, 159-162, 166-167, 171-176, 180-183, 191-200, 216-221 +src/data/collector.py 53 37 30.19% 20-24, 39-57, 74-106, 126, 136-138 +src/data/health_monitor.py 135 94 30.37% 42-49, 53-72, 80-82, 93-104, 108, 141-146, 155-159, 167-171, 183-209, 220-223, 234, 242, 256-291, 299-303, 311-317 +src/data/indicators.py 251 217 13.55% 11-15, 20-21, 33, 39-41, 45-47, 51-55, 59-62, 66-71, 77-86, 96-118, 133-153, 163-174, 184-194, 205-224, 238-251, 262-267, 277-295, 305-311, 325-340, 356-375, 406-487, 510-556, 566-568 +src/data/news_collector.py 176 136 22.73% 106-124, 137-146, 157-204, 215-266, 282-316, 320-353, 373-383, 394-403, 411, 425-427, 444-447 +src/data/pricing_service.py 180 153 15.00% 29-51, 56-102, 111-129, 140-147, 164-211, 223-244, 266-290, 302-329, 338-357, 365, 376-382, 390, 404-406 +src/data/providers/__init__.py 4 0 100.00% +src/data/providers/base_provider.py 26 14 46.15% 22-24, 113-121, 133, 141, 150 +src/data/providers/ccxt_provider.py 165 146 11.52% 30-41, 77-131, 136-150, 154-174, 184-207, 214-272, 276-289, 293-321, 325-333 +src/data/providers/coingecko_provider.py 165 143 13.33% 58-63, 81-107, 112-129, 140-155, 159-228, 242-302, 306-354, 358-371, 376 +src/data/quality.py 39 26 33.33% 18-20, 42-68, 80-82, 93-95, 103-115 +src/data/storage.py 23 13 43.48% 18-19, 46-74 +src/exchanges/__init__.py 8 0 100.00% +src/exchanges/base.py 57 43 24.56% 24-29, 44-49, 230-246, 258, 267, 284-308 +src/exchanges/coinbase.py 190 160 15.79% 26-43, 47-62, 67-79, 83-99, 103-117, 121-129, 133-170, 174-179, 183-204, 208-229, 233-251, 261-273, 277-298, 302-312, 316-326, 330-338, 343, 349, 365-391 +src/exchanges/factory.py 10 1 90.00% 164 +src/exchanges/public_data.py 202 176 12.87% 49-64, 75-125, 129-138, 146-147, 151-165, 169-177, 185-186, 193-194, 201-202, 209-210, 217-218, 232-252, 259-291, 295-320, 324-355, 365-403, 408-420, 428-433 +src/optimization/__init__.py 0 0 100.00% +src/optimization/bayesian.py 32 32 0.00% 3-75 +src/optimization/genetic.py 52 52 0.00% 3-110 +src/optimization/grid_search.py 23 23 0.00% 3-53 +src/portfolio/__init__.py 0 0 100.00% +src/portfolio/analytics.py 89 89 0.00% 3-264 +src/portfolio/tracker.py 58 58 0.00% 3-143 +src/rebalancing/__init__.py 0 0 100.00% +src/rebalancing/strategies.py 13 13 0.00% 3-35 +src/reporting/__init__.py 0 0 100.00% +src/reporting/csv_exporter.py 52 52 0.00% 3-119 +src/reporting/pdf_generator.py 38 38 0.00% 3-110 +src/reporting/tax_reporter.py 47 47 0.00% 3-126 +src/resilience/__init__.py 0 0 100.00% +src/resilience/health_monitor.py 39 39 0.00% 3-99 +src/resilience/recovery.py 49 49 0.00% 3-102 +src/resilience/state_manager.py 47 47 0.00% 3-89 +src/risk/__init__.py 0 0 100.00% +src/risk/limits.py 95 78 17.89% 19-21, 25-41, 45-55, 60-72, 76-104, 108-135, 139-153, 161 +src/risk/manager.py 38 20 47.37% 22-27, 40-57, 64, 73, 88-90 +src/risk/position_sizing.py 42 31 26.19% 17-18, 40-65, 83-88, 110-143 +src/risk/stop_loss.py 100 88 12.00% 17-19, 44-86, 106-165, 187-218, 226-228 +src/security/__init__.py 0 0 100.00% +src/security/audit.py 35 35 0.00% 3-93 +src/security/encryption.py 58 42 27.59% 20-22, 27-62, 73-76, 87-95, 105-107 +src/security/key_manager.py 96 80 16.67% 17-18, 29-62, 66-81, 98-121, 125-137, 141-144, 148-160, 170-172 +src/strategies/__init__.py 25 0 100.00% +src/strategies/base.py 147 97 34.01% 45-51, 123-145, 156-164, 185-228, 253-315, 323, 331, 339, 352-354, 374, 397-408, 419, 427, 435-437 +src/strategies/dca/__init__.py 0 0 100.00% +src/strategies/dca/dca_strategy.py 38 26 31.58% 23-30, 34-41, 45-58, 73, 85-90 +src/strategies/ensemble/__init__.py 2 0 100.00% +src/strategies/ensemble/consensus_strategy.py 111 96 13.51% 35-53, 57-83, 87-107, 127-142, 156-206, 226-239, 243 +src/strategies/grid/__init__.py 0 0 100.00% +src/strategies/grid/grid_strategy.py 44 34 22.73% 23-32, 36-54, 58-98, 102-109 +src/strategies/momentum/__init__.py 0 0 100.00% +src/strategies/momentum/momentum_strategy.py 59 46 22.03% 25-35, 46-56, 67-74, 79-130, 134-138 +src/strategies/scheduler.py 130 130 0.00% 3-297 +src/strategies/technical/__init__.py 0 0 100.00% +src/strategies/technical/bollinger_mean_reversion.py 92 79 14.13% 37-49, 60-75, 89-104, 109-218, 222-226 +src/strategies/technical/confirmed_strategy.py 111 97 12.61% 42-66, 77-91, 102-122, 133-158, 163-224, 245 +src/strategies/technical/divergence_strategy.py 62 50 19.35% 40-53, 64-75, 80-133, 153 +src/strategies/technical/macd_strategy.py 32 23 28.12% 20-25, 32-67, 71 +src/strategies/technical/moving_avg_strategy.py 36 27 25.00% 20-25, 32-74, 78 +src/strategies/technical/pairs_trading.py 61 17 72.13% 35, 40, 63, 88, 108-110, 114-116, 122-141, 147 +src/strategies/technical/rsi_strategy.py 29 20 31.03% 20-25, 31-62, 66 +src/strategies/timeframe_manager.py 39 39 0.00% 3-102 +src/trading/__init__.py 0 0 100.00% +src/trading/advanced_orders.py 142 142 0.00% 3-408 +src/trading/engine.py 119 99 16.81% 23-28, 39-49, 63-173, 177-212, 216-223, 243-245 +src/trading/fee_calculator.py 81 61 24.69% 18-19, 41-61, 80-83, 103-104, 122-126, 139, 154-176, 191-202, 222-243, 259-261, 275-277 +src/trading/futures.py 30 30 0.00% 3-121 +src/trading/order_manager.py 81 60 25.93% 94, 126-152, 176-200, 211-214, 232-249, 260, 280-292, 302-304 +src/trading/paper_trading.py 162 138 14.81% 25-33, 38, 42-67, 78, 86-90, 103-222, 230-250, 258-272, 280, 288-298, 316-364, 374-376 +src/utils/__init__.py 0 0 100.00% +------------------------------------------------------------------------------------- +TOTAL 6192 4886 21.09% +Coverage HTML written to dir htmlcov +Coverage XML written to file coverage.xml +FAIL Required test coverage of 95% not reached. Total coverage: 21.09% +=========================== short test summary info ============================ +FAILED tests/unit/strategies/test_pairs_trading.py::test_pairs_trading_short_spread_signal +FAILED tests/unit/strategies/test_pairs_trading.py::test_pairs_trading_long_spread_signal +========================= 2 failed, 1 warning in 4.81s ========================= diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..072e7ede --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,2 @@ +"""Test suite for Crypto Trader.""" + diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..ac7d6489 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,86 @@ +"""Pytest configuration and fixtures.""" + +import pytest +import asyncio +from unittest.mock import Mock, AsyncMock, PropertyMock +from decimal import Decimal +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from src.core.database import Base, Database, get_database + +from sqlalchemy.pool import StaticPool + +@pytest.fixture(scope="session") +def event_loop(): + """Create an instance of the default event loop for each test case.""" + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() + +@pytest.fixture(scope="session") +async def db_engine(): + """Create async database engine.""" + engine = create_async_engine( + "sqlite+aiosqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + echo=False + ) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield engine + await engine.dispose() + +@pytest.fixture +async def db_session(db_engine): + """Create async database session.""" + async_session = async_sessionmaker(bind=db_engine, expire_on_commit=False) + async with async_session() as session: + yield session + +@pytest.fixture(autouse=True) +def override_get_database(db_engine, monkeypatch): + """Override get_database to use test engine.""" + test_db = Database() + # We mock the internal attributes to return our test engine/session + test_db.engine = db_engine + test_db.SessionLocal = async_sessionmaker(bind=db_engine, class_=AsyncSession, expire_on_commit=False) + + # Patch the global get_database + monkeypatch.setattr("src.core.database._db_instance", test_db) + return test_db + +@pytest.fixture +def mock_exchange_adapter(): + """Mock exchange adapter.""" + from src.exchanges.base import BaseExchangeAdapter + adapter = AsyncMock(spec=BaseExchangeAdapter) + adapter.get_ticker.return_value = {'last': Decimal("50000")} + adapter.place_order.return_value = {'id': 'test_order_123', 'status': 'open'} + adapter.get_balance.return_value = {'USD': Decimal("10000")} + # Helper methods should be sync mocks + # Note: If extract_fee... is not part of BaseExchangeAdapter spec, we have to attach it manually + # But checking base.py, it likely IS or isn't. + # Safe to attach it manually even with spec if we traverse __dict__ or simply assign. + # However, standard mock might block unknown attribs. + # Actually BaseExchangeAdapter is abstract. + + # Let's inspect BaseExchangeAdapter structure if needed. + # For now, let's assume usage of spec is the right direction. + # But if extract_fee... is NOT in BaseExchangeAdapter, we might need to mock a Concrete class like Coinbase + pass + + # Better approach: Just Delete get_fee_structure from the mock to ensure AttributeError + adapter = AsyncMock() + del adapter.get_fee_structure + # Wait, AsyncMock creates attrs on access. del might not work if not existing. + # We can se side_effect to raise AttributeError + + adapter.get_ticker.return_value = {'last': Decimal("50000")} + adapter.place_order.return_value = {'id': 'test_order_123', 'status': 'open'} + adapter.get_balance.return_value = {'USD': Decimal("10000")} + adapter.extract_fee_from_order_response = Mock(return_value=Decimal("1.0")) + adapter.name = "coinbase" # FeeCalculator accesses .name + type(adapter).get_fee_structure = PropertyMock(side_effect=AttributeError) + # Accessing methods on AsyncMock... + + return adapter diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 00000000..f3a772e6 --- /dev/null +++ b/tests/e2e/__init__.py @@ -0,0 +1,2 @@ +"""End-to-end tests.""" + diff --git a/tests/e2e/test_backtest_e2e.py b/tests/e2e/test_backtest_e2e.py new file mode 100644 index 00000000..d69da4a4 --- /dev/null +++ b/tests/e2e/test_backtest_e2e.py @@ -0,0 +1,18 @@ +"""End-to-end tests for backtesting.""" + +import pytest +from datetime import datetime, timedelta +from src.backtesting.engine import get_backtest_engine + + +@pytest.mark.e2e +class TestBacktestE2E: + """End-to-end tests for backtesting.""" + + @pytest.mark.asyncio + async def test_backtest_scenario(self): + """Test complete backtesting scenario.""" + engine = get_backtest_engine() + assert engine is not None + # Full E2E test would require strategy and historical data setup + diff --git a/tests/e2e/test_paper_trading_e2e.py b/tests/e2e/test_paper_trading_e2e.py new file mode 100644 index 00000000..5e6c3f22 --- /dev/null +++ b/tests/e2e/test_paper_trading_e2e.py @@ -0,0 +1,51 @@ +"""End-to-end tests for paper trading.""" + +import pytest +from src.trading.paper_trading import get_paper_trading +from src.trading.engine import get_trading_engine + + +@pytest.mark.e2e +class TestPaperTradingE2E: + """End-to-end tests for paper trading.""" + + @pytest.mark.asyncio + async def test_paper_trading_scenario(self): + """Test complete paper trading scenario.""" + # Initialize + paper_trading = get_paper_trading() + engine = get_trading_engine() + await engine.initialize() + + # Place buy order + result1 = await engine.execute_trade( + exchange_name="paper_trading", + strategy_id=1, + symbol="BTC/USD", + side="buy", + order_type="market", + amount=0.01, + price=50000.0, + is_paper_trade=True + ) + + assert result1 is not None + + # Place sell order + result2 = await engine.execute_trade( + exchange_name="paper_trading", + strategy_id=1, + symbol="BTC/USD", + side="sell", + order_type="market", + amount=0.01, + price=51000.0, + is_paper_trade=True + ) + + assert result2 is not None + + # Check balance + balance = paper_trading.get_balance() + assert balance is not None + diff --git a/tests/e2e/test_pricing_data_e2e.py b/tests/e2e/test_pricing_data_e2e.py new file mode 100644 index 00000000..fb734c2b --- /dev/null +++ b/tests/e2e/test_pricing_data_e2e.py @@ -0,0 +1,340 @@ +"""End-to-end tests for pricing data flow.""" + +import pytest +import asyncio +from unittest.mock import Mock, patch, AsyncMock +from decimal import Decimal +from datetime import datetime + +from src.data.pricing_service import get_pricing_service, PricingService +from src.data.providers.base_provider import BasePricingProvider + + +class MockProvider(BasePricingProvider): + """Mock provider for E2E testing.""" + + def __init__(self, name: str = "MockProvider"): + super().__init__() + self._name = name + self._ticker_data = { + 'symbol': 'BTC/USD', + 'bid': Decimal('50000'), + 'ask': Decimal('50001'), + 'last': Decimal('50000.5'), + 'high': Decimal('51000'), + 'low': Decimal('49000'), + 'volume': Decimal('1000000'), + 'timestamp': int(datetime.now().timestamp() * 1000), + } + self._ohlcv_data = [ + [int(datetime.now().timestamp() * 1000), 50000, 51000, 49000, 50000, 1000], + ] + self._callbacks = [] + + @property + def name(self) -> str: + return self._name + + @property + def supports_websocket(self) -> bool: + return False + + def connect(self) -> bool: + self._connected = True + return True + + def disconnect(self): + self._connected = False + self._callbacks.clear() + + def get_ticker(self, symbol: str): + return self._ticker_data.copy() + + def get_ohlcv(self, symbol: str, timeframe: str = '1h', since=None, limit: int = 100): + return self._ohlcv_data.copy() + + def subscribe_ticker(self, symbol: str, callback) -> bool: + if symbol not in self._subscribers: + self._subscribers[symbol] = [] + self._subscribers[symbol].append(callback) + self._callbacks.append((symbol, callback)) + + # Simulate price update + import threading + def send_update(): + import time + time.sleep(0.1) + if callback and symbol in self._subscribers: + callback(self._ticker_data.copy()) + + thread = threading.Thread(target=send_update, daemon=True) + thread.start() + return True + + +@pytest.mark.e2e +class TestPricingDataE2E: + """End-to-end tests for pricing data system.""" + + @pytest.fixture(autouse=True) + def reset_service(self): + """Reset pricing service between tests.""" + import src.data.pricing_service + src.data.pricing_service._pricing_service = None + yield + src.data.pricing_service._pricing_service = None + + @patch('src.data.pricing_service.get_config') + def test_pricing_service_initialization(self, mock_get_config): + """Test pricing service initializes correctly.""" + mock_config = Mock() + mock_config.get = Mock(side_effect=lambda key, default=None: { + "data_providers.primary": [ + {"name": "mock", "enabled": True, "priority": 1} + ], + "data_providers.fallback": {"enabled": True, "api_key": ""}, + "data_providers.caching.ticker_ttl": 2, + "data_providers.caching.ohlcv_ttl": 60, + "data_providers.caching.max_cache_size": 1000, + }.get(key, default)) + + mock_get_config.return_value = mock_config + + # Patch provider initialization to use mock + with patch('src.data.pricing_service.CCXTProvider') as mock_ccxt: + mock_provider = MockProvider("CCXT-Mock") + mock_ccxt.return_value = mock_provider + + service = get_pricing_service() + + assert service is not None + assert service.cache is not None + assert service.health_monitor is not None + + def test_get_ticker_with_failover(self): + """Test getting ticker with provider failover.""" + # Create service with mock providers + service = PricingService() + + # Create two providers - one will fail, one will succeed + provider1 = MockProvider("Provider1") + provider1.get_ticker = Mock(side_effect=Exception("Provider1 failed")) + provider1.connect = Mock(return_value=True) + + provider2 = MockProvider("Provider2") + provider2.connect = Mock(return_value=True) + + service._providers = {"Provider1": provider1, "Provider2": provider2} + service._provider_priority = ["Provider1", "Provider2"] + service._active_provider = "Provider1" + + # Get ticker - should failover to Provider2 + ticker = service.get_ticker("BTC/USD", use_cache=False) + + assert ticker is not None + assert ticker['symbol'] == 'BTC/USD' + assert provider1.get_ticker.called + assert provider2.get_ticker.called + + def test_caching_works(self): + """Test that caching works correctly.""" + service = PricingService() + + provider = MockProvider() + provider.connect() + service._providers["MockProvider"] = provider + service._active_provider = "MockProvider" + + # First call - should hit provider + ticker1 = service.get_ticker("BTC/USD", use_cache=True) + + # Modify provider response + provider._ticker_data['last'] = Decimal('60000') + + # Second call - should get cached value + ticker2 = service.get_ticker("BTC/USD", use_cache=True) + + # Should be same as first call (cached) + assert ticker1['last'] == ticker2['last'] + + def test_subscription_and_updates(self): + """Test subscribing to price updates.""" + service = PricingService() + + provider = MockProvider() + provider.connect() + service._providers["MockProvider"] = provider + service._active_provider = "MockProvider" + + received_updates = [] + + def callback(data): + received_updates.append(data) + + # Subscribe + success = service.subscribe_ticker("BTC/USD", callback) + assert success is True + + # Wait for update + import time + time.sleep(0.2) + + # Should have received at least one update + assert len(received_updates) > 0 + assert received_updates[0]['symbol'] == 'BTC/USD' + + def test_health_monitoring(self): + """Test health monitoring tracks provider status.""" + service = PricingService() + + provider = MockProvider("TestProvider") + provider.connect() + service._providers["TestProvider"] = provider + service._active_provider = "TestProvider" + + # Record some operations + service.health_monitor.record_success("TestProvider", 0.1) + service.health_monitor.record_success("TestProvider", 0.2) + service.health_monitor.record_failure("TestProvider") + + # Check health + health = service.get_provider_health("TestProvider") + assert health is not None + assert health['success_count'] == 2 + assert health['failure_count'] == 1 + assert health['avg_response_time'] > 0 + + def test_provider_priority_selection(self): + """Test that providers are selected by priority.""" + service = PricingService() + + provider1 = MockProvider("Provider1") + provider1.connect() + provider2 = MockProvider("Provider2") + provider2.connect() + + service._providers = {"Provider1": provider1, "Provider2": provider2} + service._provider_priority = ["Provider1", "Provider2"] + + # Select active provider + active = service._select_active_provider() + + assert active == "Provider1" # Should select first in priority + + def test_cache_stats(self): + """Test cache statistics are tracked.""" + service = PricingService() + + # Perform some cache operations + service.cache.set("key1", "value1") + service.cache.get("key1") # Hit + service.cache.get("missing") # Miss + + stats = service.get_cache_stats() + + assert stats['hits'] >= 1 + assert stats['misses'] >= 1 + assert stats['size'] >= 1 + + def test_get_ohlcv_flow(self): + """Test complete OHLCV data flow.""" + service = PricingService() + + provider = MockProvider() + provider.connect() + service._providers["MockProvider"] = provider + service._active_provider = "MockProvider" + + # Get OHLCV data + ohlcv = service.get_ohlcv("BTC/USD", "1h", limit=10, use_cache=False) + + assert len(ohlcv) > 0 + assert len(ohlcv[0]) == 6 # timestamp, open, high, low, close, volume + assert ohlcv[0][0] > 0 # Valid timestamp + + def test_unsubscribe_ticker(self): + """Test unsubscribing from ticker updates.""" + service = PricingService() + + provider = MockProvider() + provider.connect() + service._providers["MockProvider"] = provider + service._active_provider = "MockProvider" + + callback = Mock() + + # Subscribe + service.subscribe_ticker("BTC/USD", callback) + assert "ticker:BTC/USD" in service._subscriptions + + # Unsubscribe + service.unsubscribe_ticker("BTC/USD", callback) + assert "ticker:BTC/USD" not in service._subscriptions + + def test_multiple_symbol_subscriptions(self): + """Test subscribing to multiple symbols.""" + service = PricingService() + + provider = MockProvider() + provider.connect() + service._providers["MockProvider"] = provider + service._active_provider = "MockProvider" + + callback1 = Mock() + callback2 = Mock() + + # Subscribe to multiple symbols + service.subscribe_ticker("BTC/USD", callback1) + service.subscribe_ticker("ETH/USD", callback2) + + assert "ticker:BTC/USD" in service._subscriptions + assert "ticker:ETH/USD" in service._subscriptions + assert len(service._subscriptions) == 2 + + +@pytest.mark.e2e +@pytest.mark.asyncio +class TestPricingDataWebSocketE2E: + """E2E tests for WebSocket pricing updates.""" + + async def test_websocket_price_broadcast(self): + """Test WebSocket broadcasts price updates.""" + # This test would require a running WebSocket server + # For now, we test the integration point + + from backend.api.websocket import ConnectionManager + + manager = ConnectionManager() + + # Mock pricing service + with patch('backend.api.websocket.get_pricing_service') as mock_get_service: + mock_service = Mock() + mock_service.subscribe_ticker = Mock(return_value=True) + mock_get_service.return_value = mock_service + + # Subscribe to symbol + manager.subscribe_to_symbol("BTC/USD") + + assert "BTC/USD" in manager.subscribed_symbols + assert mock_service.subscribe_ticker.called + + async def test_websocket_subscription_flow(self): + """Test WebSocket subscription and unsubscription.""" + from backend.api.websocket import ConnectionManager + + manager = ConnectionManager() + + with patch('backend.api.websocket.get_pricing_service') as mock_get_service: + mock_service = Mock() + mock_service.subscribe_ticker = Mock(return_value=True) + mock_service.unsubscribe_ticker = Mock() + mock_get_service.return_value = mock_service + + # Subscribe + manager.subscribe_to_symbol("BTC/USD") + assert "BTC/USD" in manager.subscribed_symbols + + # Unsubscribe + manager.unsubscribe_from_symbol("BTC/USD") + assert "BTC/USD" not in manager.subscribed_symbols + assert mock_service.unsubscribe_ticker.called diff --git a/tests/e2e/test_strategy_lifecycle.py b/tests/e2e/test_strategy_lifecycle.py new file mode 100644 index 00000000..5980e783 --- /dev/null +++ b/tests/e2e/test_strategy_lifecycle.py @@ -0,0 +1,34 @@ +"""End-to-end tests for strategy lifecycle.""" + +import pytest +from src.strategies.technical.rsi_strategy import RSIStrategy +from src.trading.engine import get_trading_engine + + +@pytest.mark.e2e +class TestStrategyLifecycle: + """End-to-end tests for strategy lifecycle.""" + + @pytest.mark.asyncio + async def test_strategy_lifecycle(self): + """Test complete strategy lifecycle.""" + engine = get_trading_engine() + await engine.initialize() + + # Create strategy + strategy = RSIStrategy( + strategy_id=1, + name="test_rsi", + symbol="BTC/USD", + timeframe="1h", + parameters={"rsi_period": 14} + ) + + # Start + await engine.start_strategy(strategy) + + # Stop + await engine.stop_strategy(1) + + await engine.shutdown() + diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 00000000..3a5f0531 --- /dev/null +++ b/tests/fixtures/__init__.py @@ -0,0 +1,2 @@ +"""Test fixtures and mocks.""" + diff --git a/tests/fixtures/mock_exchange.py b/tests/fixtures/mock_exchange.py new file mode 100644 index 00000000..53ac74b0 --- /dev/null +++ b/tests/fixtures/mock_exchange.py @@ -0,0 +1,76 @@ +"""Mock exchange adapter for testing.""" + +from unittest.mock import Mock, AsyncMock +from src.exchanges.base import BaseExchange + + +class MockExchange(BaseExchange): + """Mock exchange adapter for testing.""" + + def __init__(self, name: str = "mock_exchange", **kwargs): + super().__init__(name, "mock_key", "mock_secret") + self.is_connected = True + self._mock_responses = {} + + async def connect(self): + """Mock connection.""" + self.is_connected = True + + async def disconnect(self): + """Mock disconnection.""" + self.is_connected = False + + async def fetch_balance(self): + """Mock balance fetch.""" + return self._mock_responses.get('balance', { + 'USD': {'free': 1000.0, 'used': 0.0, 'total': 1000.0} + }) + + async def place_order(self, symbol, side, order_type, amount, price=None, params=None): + """Mock order placement.""" + return self._mock_responses.get('order', { + 'id': 'mock_order_123', + 'status': 'filled', + 'filled': amount, + 'price': price or 50000.0 + }) + + async def cancel_order(self, order_id, symbol=None): + """Mock order cancellation.""" + return {'id': order_id, 'status': 'canceled'} + + async def fetch_order_status(self, order_id, symbol=None): + """Mock order status fetch.""" + return self._mock_responses.get('order_status', { + 'id': order_id, + 'status': 'filled' + }) + + async def fetch_ohlcv(self, symbol, timeframe, since=None, limit=None): + """Mock OHLCV fetch.""" + return self._mock_responses.get('ohlcv', []) + + async def subscribe_ohlcv(self, symbol, timeframe, callback): + """Mock OHLCV subscription.""" + pass + + async def subscribe_trades(self, symbol, callback): + """Mock trades subscription.""" + pass + + async def subscribe_order_book(self, symbol, callback): + """Mock order book subscription.""" + pass + + async def fetch_open_orders(self, symbol=None): + """Mock open orders fetch.""" + return self._mock_responses.get('open_orders', []) + + async def fetch_positions(self, symbol=None): + """Mock positions fetch.""" + return self._mock_responses.get('positions', []) + + async def fetch_markets(self): + """Mock markets fetch.""" + return self._mock_responses.get('markets', []) + diff --git a/tests/fixtures/sample_data.py b/tests/fixtures/sample_data.py new file mode 100644 index 00000000..22d9694b --- /dev/null +++ b/tests/fixtures/sample_data.py @@ -0,0 +1,81 @@ +"""Sample data generators for testing.""" + +import pandas as pd +import numpy as np +from datetime import datetime, timedelta +from faker import Faker + +fake = Faker() + + +def generate_ohlcv_data( + symbol: str = "BTC/USD", + periods: int = 100, + start_price: float = 50000.0, + timeframe: str = "1h" +) -> pd.DataFrame: + """Generate sample OHLCV data. + + Args: + symbol: Trading pair + periods: Number of periods + start_price: Starting price + timeframe: Data timeframe + + Returns: + DataFrame with OHLCV data + """ + dates = pd.date_range( + start=datetime.now() - timedelta(hours=periods), + periods=periods, + freq=timeframe + ) + + # Generate realistic price movement + prices = [start_price] + for i in range(1, periods): + change = np.random.randn() * 0.02 # 2% volatility + prices.append(prices[-1] * (1 + change)) + + return pd.DataFrame({ + 'timestamp': dates, + 'open': prices, + 'high': [p * 1.01 for p in prices], + 'low': [p * 0.99 for p in prices], + 'close': prices, + 'volume': [1000.0 + np.random.randn() * 100 for _ in range(periods)] + }) + + +def generate_trade_data( + symbol: str = "BTC/USD", + count: int = 10 +) -> list: + """Generate sample trade data. + + Args: + symbol: Trading pair + count: Number of trades + + Returns: + List of trade dictionaries + """ + trades = [] + base_price = 50000.0 + + for i in range(count): + trades.append({ + 'order_id': f'trade_{i}', + 'symbol': symbol, + 'side': 'buy' if i % 2 == 0 else 'sell', + 'type': 'market', + 'price': base_price + np.random.randn() * 100, + 'amount': 0.01, + 'cost': 500.0, + 'fee': 0.5, + 'status': 'filled', + 'timestamp': datetime.now() - timedelta(hours=count-i) + }) + + return trades + diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 00000000..15fcf53d --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1,2 @@ +"""Integration tests.""" + diff --git a/tests/integration/backend/__init__.py b/tests/integration/backend/__init__.py new file mode 100644 index 00000000..bbea87e3 --- /dev/null +++ b/tests/integration/backend/__init__.py @@ -0,0 +1,2 @@ +"""Backend integration tests.""" + diff --git a/tests/integration/backend/test_api_workflows.py b/tests/integration/backend/test_api_workflows.py new file mode 100644 index 00000000..590b1d5f --- /dev/null +++ b/tests/integration/backend/test_api_workflows.py @@ -0,0 +1,95 @@ +"""Integration tests for API workflows.""" + +import pytest +from fastapi.testclient import TestClient +from unittest.mock import patch, Mock + +from backend.main import app + + +@pytest.fixture +def client(): + """Test client fixture.""" + return TestClient(app) + + +@pytest.mark.integration +class TestTradingWorkflow: + """Test complete trading workflow through API.""" + + @patch('backend.api.trading.get_trading_engine') + @patch('backend.api.trading.get_db') + def test_complete_trading_workflow(self, mock_get_db, mock_get_engine, client): + """Test: Place order → Check order status → Get positions.""" + # Setup mocks + mock_engine = Mock() + mock_order = Mock() + mock_order.id = 1 + mock_order.symbol = "BTC/USD" + mock_order.side = "buy" + mock_order.status = "filled" + mock_engine.execute_order.return_value = mock_order + mock_get_engine.return_value = mock_engine + + mock_db = Mock() + mock_session = Mock() + mock_db.get_session.return_value = mock_session + mock_get_db.return_value = mock_db + + # Mock order query + mock_session.query.return_value.filter_by.return_value.order_by.return_value.limit.return_value.all.return_value = [mock_order] + mock_session.query.return_value.filter_by.return_value.first.return_value = mock_order + + # Place order + order_data = { + "exchange_id": 1, + "symbol": "BTC/USD", + "side": "buy", + "order_type": "market", + "quantity": "0.1", + "paper_trading": True + } + create_response = client.post("/api/trading/orders", json=order_data) + assert create_response.status_code == 200 + + order_id = create_response.json()["id"] + + # Get order status + status_response = client.get(f"/api/trading/orders/{order_id}") + assert status_response.status_code == 200 + assert status_response.json()["id"] == order_id + + # Get orders list + orders_response = client.get("/api/trading/orders") + assert orders_response.status_code == 200 + assert isinstance(orders_response.json(), list) + + +@pytest.mark.integration +class TestPortfolioWorkflow: + """Test portfolio workflow through API.""" + + @patch('backend.api.portfolio.get_portfolio_tracker') + def test_portfolio_workflow(self, mock_get_tracker, client): + """Test: Get current portfolio → Get portfolio history.""" + mock_tracker = Mock() + mock_tracker.get_current_portfolio.return_value = { + "positions": [], + "performance": {"total_return": 0.1}, + "timestamp": "2025-01-01T00:00:00" + } + mock_tracker.get_portfolio_history.return_value = [ + {"timestamp": "2025-01-01T00:00:00", "total_value": 1000.0, "total_pnl": 0.0} + ] + mock_get_tracker.return_value = mock_tracker + + # Get current portfolio + current_response = client.get("/api/portfolio/current?paper_trading=true") + assert current_response.status_code == 200 + assert "positions" in current_response.json() + + # Get portfolio history + history_response = client.get("/api/portfolio/history?paper_trading=true&days=30") + assert history_response.status_code == 200 + assert "dates" in history_response.json() + diff --git a/tests/integration/backend/test_frontend_api_workflows.py b/tests/integration/backend/test_frontend_api_workflows.py new file mode 100644 index 00000000..d7bfe79c --- /dev/null +++ b/tests/integration/backend/test_frontend_api_workflows.py @@ -0,0 +1,323 @@ +"""Integration tests for frontend API workflows. + +These tests verify that all frontend-accessible API endpoints work correctly +and return data in the expected format for the React frontend. +""" + +import pytest +from fastapi.testclient import TestClient +from decimal import Decimal +from datetime import datetime, timedelta +from unittest.mock import Mock, patch + +from backend.main import app + + +@pytest.fixture +def client(): + """Create test client.""" + return TestClient(app) + + +@pytest.fixture +def mock_strategy(): + """Mock strategy data.""" + return { + "id": 1, + "name": "Test RSI Strategy", + "strategy_type": "rsi", + "class_name": "rsi", + "parameters": { + "symbol": "BTC/USD", + "exchange_id": 1, + "rsi_period": 14, + "oversold": 30, + "overbought": 70 + }, + "timeframes": ["1h"], + "enabled": False, + "paper_trading": True, + "version": "1.0.0", + "created_at": datetime.now().isoformat(), + "updated_at": datetime.now().isoformat() + } + + +@pytest.fixture +def mock_exchange(): + """Mock exchange data.""" + return { + "id": 1, + "name": "coinbase", + "sandbox": False, + "read_only": True, + "enabled": True, + "created_at": datetime.now().isoformat(), + "updated_at": datetime.now().isoformat() + } + + +class TestStrategyManagementAPI: + """Test strategy management API endpoints.""" + + def test_list_strategies(self, client): + """Test listing all strategies.""" + with patch('backend.api.strategies.get_db') as mock_db: + mock_session = Mock() + mock_strategy = Mock() + mock_strategy.id = 1 + mock_strategy.name = "Test Strategy" + mock_strategy.strategy_type = "rsi" + mock_strategy.class_name = "rsi" + mock_strategy.parameters = {} + mock_strategy.timeframes = ["1h"] + mock_strategy.enabled = False + mock_strategy.paper_trading = True + mock_strategy.version = "1.0.0" + mock_strategy.description = None + mock_strategy.schedule = None + mock_strategy.created_at = datetime.now() + mock_strategy.updated_at = datetime.now() + + mock_session.query.return_value.order_by.return_value.all.return_value = [mock_strategy] + mock_db.return_value.get_session.return_value.__enter__.return_value = mock_session + mock_db.return_value.get_session.return_value.__exit__ = Mock(return_value=None) + + response = client.get("/api/strategies/") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + + def test_get_available_strategies(self, client): + """Test getting available strategy types.""" + with patch('backend.api.strategies.get_strategy_registry') as mock_registry: + mock_registry.return_value.list_available.return_value = [ + "rsi", "macd", "moving_average", "dca", "grid", "momentum" + ] + + response = client.get("/api/strategies/available") + assert response.status_code == 200 + data = response.json() + assert "strategies" in data + assert isinstance(data["strategies"], list) + + def test_create_strategy(self, client, mock_strategy): + """Test creating a new strategy.""" + with patch('backend.api.strategies.get_db') as mock_db: + mock_session = Mock() + mock_db.return_value.get_session.return_value.__enter__.return_value = mock_session + mock_db.return_value.get_session.return_value.__exit__ = Mock(return_value=None) + mock_session.add = Mock() + mock_session.commit = Mock() + mock_session.refresh = Mock() + + # Create strategy instance + created_strategy = Mock() + created_strategy.id = 1 + created_strategy.name = mock_strategy["name"] + created_strategy.strategy_type = mock_strategy["strategy_type"] + created_strategy.class_name = mock_strategy["class_name"] + created_strategy.parameters = mock_strategy["parameters"] + created_strategy.timeframes = mock_strategy["timeframes"] + created_strategy.enabled = False + created_strategy.paper_trading = mock_strategy["paper_trading"] + created_strategy.version = "1.0.0" + created_strategy.description = None + created_strategy.schedule = None + created_strategy.created_at = datetime.now() + created_strategy.updated_at = datetime.now() + mock_session.refresh.side_effect = lambda x: setattr(x, 'id', 1) + + response = client.post( + "/api/strategies/", + json={ + "name": mock_strategy["name"], + "strategy_type": mock_strategy["strategy_type"], + "class_name": mock_strategy["class_name"], + "parameters": mock_strategy["parameters"], + "timeframes": mock_strategy["timeframes"], + "paper_trading": mock_strategy["paper_trading"] + } + ) + # May return 200 or 500 depending on implementation + assert response.status_code in [200, 201, 500] + + +class TestTradingAPI: + """Test trading API endpoints.""" + + def test_get_positions(self, client): + """Test getting positions.""" + with patch('src.trading.paper_trading.get_paper_trading') as mock_pt: + mock_pt.return_value.get_positions.return_value = [] + + response = client.get("/api/trading/positions?paper_trading=true") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + + def test_get_orders(self, client): + """Test getting orders.""" + with patch('backend.api.trading.get_db') as mock_db: + mock_session = Mock() + mock_session.query.return_value.filter_by.return_value.order_by.return_value.limit.return_value.all.return_value = [] + mock_db.return_value.get_session.return_value.__enter__.return_value = mock_session + mock_db.return_value.get_session.return_value.__exit__ = Mock(return_value=None) + + response = client.get("/api/trading/orders?paper_trading=true&limit=10") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + + def test_get_balance(self, client): + """Test getting balance.""" + with patch('src.trading.paper_trading.get_paper_trading') as mock_pt: + mock_pt.return_value.get_balance.return_value = Decimal("100.00") + mock_pt.return_value.get_performance.return_value = {} + + response = client.get("/api/trading/balance?paper_trading=true") + assert response.status_code == 200 + data = response.json() + assert "balance" in data + + +class TestPortfolioAPI: + """Test portfolio API endpoints.""" + + def test_get_current_portfolio(self, client): + """Test getting current portfolio.""" + with patch('backend.api.portfolio.get_portfolio_tracker') as mock_tracker: + mock_tracker.return_value.get_current_portfolio.return_value = { + "positions": [], + "performance": { + "current_value": 100.0, + "unrealized_pnl": 0.0, + "realized_pnl": 0.0 + }, + "timestamp": datetime.now().isoformat() + } + + response = client.get("/api/portfolio/current?paper_trading=true") + assert response.status_code == 200 + data = response.json() + assert "positions" in data + assert "performance" in data + + def test_get_portfolio_history(self, client): + """Test getting portfolio history.""" + with patch('backend.api.portfolio.get_portfolio_tracker') as mock_tracker: + mock_tracker.return_value.get_portfolio_history.return_value = { + "dates": [datetime.now().isoformat()], + "values": [100.0], + "pnl": [0.0] + } + + response = client.get("/api/portfolio/history?days=30&paper_trading=true") + assert response.status_code == 200 + data = response.json() + assert "dates" in data + assert "values" in data + + +class TestBacktestingAPI: + """Test backtesting API endpoints.""" + + def test_run_backtest(self, client): + """Test running a backtest.""" + with patch('backend.api.backtesting.get_backtesting_engine') as mock_engine, \ + patch('backend.api.backtesting.get_db') as mock_db: + + mock_session = Mock() + mock_strategy = Mock() + mock_strategy.id = 1 + mock_strategy.class_name = "rsi" + mock_strategy.parameters = {} + mock_strategy.timeframes = ["1h"] + mock_session.query.return_value.filter_by.return_value.first.return_value = mock_strategy + mock_db.return_value.get_session.return_value.__enter__.return_value = mock_session + mock_db.return_value.get_session.return_value.__exit__ = Mock(return_value=None) + + mock_engine.return_value.run_backtest.return_value = { + "total_return": 0.1, + "sharpe_ratio": 1.5, + "max_drawdown": -0.05, + "win_rate": 0.6, + "total_trades": 10, + "final_value": 110.0 + } + + response = client.post( + "/api/backtesting/run", + json={ + "strategy_id": 1, + "symbol": "BTC/USD", + "exchange": "coinbase", + "timeframe": "1h", + "start_date": (datetime.now() - timedelta(days=30)).isoformat(), + "end_date": datetime.now().isoformat(), + "initial_capital": 100.0, + "slippage": 0.001, + "fee_rate": 0.001 + } + ) + # May return 200 or error depending on implementation + assert response.status_code in [200, 400, 500] + + +class TestAlertsAPI: + """Test alerts API endpoints.""" + + def test_list_alerts(self, client): + """Test listing alerts.""" + with patch('backend.api.alerts.get_alert_manager') as mock_manager: + mock_manager.return_value.list_alerts.return_value = [] + + response = client.get("/api/alerts/?enabled_only=false") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + + def test_create_alert(self, client): + """Test creating an alert.""" + with patch('backend.api.alerts.get_alert_manager') as mock_manager: + mock_alert = Mock() + mock_alert.id = 1 + mock_alert.name = "Test Alert" + mock_alert.alert_type = "price" + mock_alert.condition = {"symbol": "BTC/USD", "price_threshold": 50000} + mock_alert.enabled = True + mock_alert.triggered = False + mock_alert.triggered_at = None + mock_alert.created_at = datetime.now() + mock_alert.updated_at = datetime.now() + + mock_manager.return_value.create_alert.return_value = mock_alert + + response = client.post( + "/api/alerts/", + json={ + "name": "Test Alert", + "alert_type": "price", + "condition": {"symbol": "BTC/USD", "price_threshold": 50000} + } + ) + # May return 200 or 500 depending on implementation + assert response.status_code in [200, 201, 500] + + +class TestExchangesAPI: + """Test exchanges API endpoints.""" + + def test_list_exchanges(self, client): + """Test listing exchanges.""" + with patch('backend.api.exchanges.get_db') as mock_db: + mock_session = Mock() + mock_session.query.return_value.all.return_value = [] + mock_db.return_value.get_session.return_value.__enter__.return_value = mock_session + mock_db.return_value.get_session.return_value.__exit__ = Mock(return_value=None) + + response = client.get("/api/exchanges/") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + diff --git a/tests/integration/backend/test_websocket_connections.py b/tests/integration/backend/test_websocket_connections.py new file mode 100644 index 00000000..5f4b4e3f --- /dev/null +++ b/tests/integration/backend/test_websocket_connections.py @@ -0,0 +1,32 @@ +"""Integration tests for WebSocket connections.""" + +import pytest +from fastapi.testclient import TestClient + +from backend.main import app + + +@pytest.fixture +def client(): + """Test client fixture.""" + return TestClient(app) + + +@pytest.mark.integration +class TestWebSocketConnection: + """Test WebSocket connections.""" + + def test_websocket_connection(self, client): + """Test WebSocket connection.""" + with client.websocket_connect("/ws/") as websocket: + # Connection should be established + assert websocket is not None + + def test_websocket_message_handling(self, client): + """Test WebSocket message handling.""" + with client.websocket_connect("/ws/") as websocket: + # Send a test message + websocket.send_json({"type": "ping"}) + # WebSocket should accept the connection + # Note: Actual message handling depends on implementation + diff --git a/tests/integration/test_autopilot_workflow.py b/tests/integration/test_autopilot_workflow.py new file mode 100644 index 00000000..020317cf --- /dev/null +++ b/tests/integration/test_autopilot_workflow.py @@ -0,0 +1,138 @@ +"""Integration tests for autopilot workflows.""" + +import pytest +from unittest.mock import Mock, patch, AsyncMock +from fastapi.testclient import TestClient + +from backend.main import app + + +@pytest.fixture +def client(): + """Test client fixture.""" + return TestClient(app) + + +@pytest.fixture +def mock_intelligent_autopilot(): + """Mock intelligent autopilot.""" + autopilot = Mock() + autopilot.symbol = "BTC/USD" + autopilot.is_running = False + autopilot.enable_auto_execution = False + autopilot.get_status.return_value = { + "symbol": "BTC/USD", + "timeframe": "1h", + "running": False, + "selected_strategy": None, + "trades_today": 0, + "max_trades_per_day": 10, + "min_confidence_threshold": 0.75, + "enable_auto_execution": False, + "last_analysis": None, + "model_info": {}, + } + return autopilot + + +@pytest.mark.integration +class TestAutopilotWorkflow: + """Integration tests for autopilot workflows.""" + + @patch('backend.api.autopilot.get_intelligent_autopilot') + def test_start_intelligent_mode_workflow( + self, mock_get_intelligent, client, mock_intelligent_autopilot + ): + """Test complete workflow for starting intelligent mode autopilot.""" + mock_get_intelligent.return_value = mock_intelligent_autopilot + + # Start autopilot + response = client.post( + "/api/autopilot/start-unified", + json={ + "symbol": "BTC/USD", + "mode": "intelligent", + "auto_execute": True, + "exchange_id": 1, + "timeframe": "1h", + }, + ) + + assert response.status_code == 200 + assert mock_intelligent_autopilot.enable_auto_execution is True + + # Check status + response = client.get( + "/api/autopilot/status-unified/BTC/USD?mode=intelligent&timeframe=1h" + ) + assert response.status_code == 200 + data = response.json() + assert data["mode"] == "intelligent" + + # Stop autopilot + response = client.post( + "/api/autopilot/stop-unified?symbol=BTC/USD&mode=intelligent&timeframe=1h" + ) + assert response.status_code == 200 + assert mock_intelligent_autopilot.stop.called + + +@pytest.mark.integration +class TestTrainingConfigWorkflow: + """Integration tests for training configuration workflow.""" + + def test_configure_and_verify_bootstrap(self, client): + """Test configuring bootstrap settings and verifying they persist.""" + # Set custom config + custom_config = { + "days": 180, + "timeframe": "4h", + "min_samples_per_strategy": 25, + "symbols": ["BTC/USD", "ETH/USD", "SOL/USD", "DOGE/USD"] + } + + response = client.put("/api/autopilot/bootstrap-config", json=custom_config) + assert response.status_code == 200 + + # Verify it was saved + response = client.get("/api/autopilot/bootstrap-config") + assert response.status_code == 200 + data = response.json() + + assert data["days"] == 180 + assert data["timeframe"] == "4h" + assert data["min_samples_per_strategy"] == 25 + assert len(data["symbols"]) == 4 + assert "DOGE/USD" in data["symbols"] + + @patch('backend.api.autopilot.train_model_task') + def test_training_uses_configured_symbols(self, mock_task, client): + """Test that training uses the configured symbols.""" + # Setup mock + mock_result = Mock() + mock_result.id = "test-task-123" + mock_task.delay.return_value = mock_result + + # Configure with specific symbols + config = { + "days": 90, + "timeframe": "1h", + "min_samples_per_strategy": 10, + "symbols": ["BTC/USD", "ETH/USD", "XRP/USD"] + } + client.put("/api/autopilot/bootstrap-config", json=config) + + # Trigger training + response = client.post("/api/autopilot/intelligent/retrain") + assert response.status_code == 200 + + # Verify the symbols were passed + call_kwargs = mock_task.delay.call_args.kwargs + assert call_kwargs["symbols"] == ["BTC/USD", "ETH/USD", "XRP/USD"] + assert call_kwargs["days"] == 90 + assert call_kwargs["timeframe"] == "1h" + assert call_kwargs["min_samples_per_strategy"] == 10 + + + + diff --git a/tests/integration/test_backtesting_workflow.py b/tests/integration/test_backtesting_workflow.py new file mode 100644 index 00000000..796f6925 --- /dev/null +++ b/tests/integration/test_backtesting_workflow.py @@ -0,0 +1,18 @@ +"""Integration tests for backtesting workflow.""" + +import pytest +from datetime import datetime, timedelta +from src.backtesting.engine import get_backtest_engine + + +@pytest.mark.integration +class TestBacktestingWorkflow: + """Integration tests for backtesting workflow.""" + + @pytest.mark.asyncio + async def test_backtesting_workflow(self): + """Test complete backtesting workflow.""" + engine = get_backtest_engine() + assert engine is not None + # Full workflow test would require strategy and data setup + diff --git a/tests/integration/test_data_pipeline.py b/tests/integration/test_data_pipeline.py new file mode 100644 index 00000000..dd52318c --- /dev/null +++ b/tests/integration/test_data_pipeline.py @@ -0,0 +1,21 @@ +"""Integration tests for data pipeline.""" + +import pytest +from src.data.collector import get_data_collector +from src.data.storage import get_data_storage + + +@pytest.mark.integration +class TestDataPipeline: + """Integration tests for data collection and storage.""" + + @pytest.mark.asyncio + async def test_data_collection_storage(self): + """Test data collection and storage pipeline.""" + collector = get_data_collector() + storage = get_data_storage() + + # Test components exist + assert collector is not None + assert storage is not None + diff --git a/tests/integration/test_portfolio_tracking.py b/tests/integration/test_portfolio_tracking.py new file mode 100644 index 00000000..12981898 --- /dev/null +++ b/tests/integration/test_portfolio_tracking.py @@ -0,0 +1,31 @@ +"""Integration tests for portfolio tracking.""" + +import pytest +from decimal import Decimal +from src.portfolio.tracker import get_portfolio_tracker +from src.trading.paper_trading import get_paper_trading + + +@pytest.mark.integration +class TestPortfolioTracking: + """Integration tests for portfolio tracking.""" + + @pytest.mark.asyncio + async def test_portfolio_tracking_workflow(self): + """Test portfolio tracking workflow.""" + tracker = get_portfolio_tracker() + paper_trading = get_paper_trading() + + # Get initial portfolio + portfolio1 = await tracker.get_current_portfolio(paper_trading=True) + + # Portfolio should have structure + assert "positions" in portfolio1 + assert "performance" in portfolio1 + + # Get updated portfolio + portfolio2 = await tracker.get_current_portfolio(paper_trading=True) + + # Should return valid portfolio + assert portfolio2 is not None + diff --git a/tests/integration/test_pricing_providers.py b/tests/integration/test_pricing_providers.py new file mode 100644 index 00000000..27931838 --- /dev/null +++ b/tests/integration/test_pricing_providers.py @@ -0,0 +1,71 @@ +"""Integration tests for pricing provider system.""" + +import pytest +from datetime import datetime + +from src.data.pricing_service import get_pricing_service + + +@pytest.mark.integration +class TestPricingProviderIntegration: + """Integration tests for pricing providers.""" + + @pytest.fixture(autouse=True) + def setup(self): + """Set up test fixtures.""" + # Reset global service instance + import src.data.pricing_service + src.data.pricing_service._pricing_service = None + + def test_service_initialization(self): + """Test that pricing service initializes correctly.""" + service = get_pricing_service() + assert service is not None + assert service.cache is not None + assert service.health_monitor is not None + + @pytest.mark.skip(reason="Requires network access - run manually") + def test_get_ticker_integration(self): + """Test getting ticker data from real providers.""" + service = get_pricing_service() + + # This will try to connect to real providers + ticker = service.get_ticker("BTC/USD", use_cache=False) + + # Should get some data if providers are available + if ticker: + assert 'symbol' in ticker + assert 'last' in ticker + assert ticker['last'] > 0 + + @pytest.mark.skip(reason="Requires network access - run manually") + def test_provider_failover(self): + """Test provider failover mechanism.""" + service = get_pricing_service() + + # Get active provider + active = service.get_active_provider() + assert active is not None or len(service._providers) == 0 + + def test_cache_integration(self): + """Test cache integration with service.""" + service = get_pricing_service() + + # Set a value + service.cache.set("test:key", "test_value", ttl=60) + + # Get it back + value = service.cache.get("test:key") + assert value == "test_value" + + def test_health_monitoring(self): + """Test health monitoring integration.""" + service = get_pricing_service() + + # Record some metrics + service.health_monitor.record_success("test_provider", 0.5) + service.health_monitor.record_failure("test_provider") + + # Check health + is_healthy = service.health_monitor.is_healthy("test_provider") + assert isinstance(is_healthy, bool) diff --git a/tests/integration/test_strategy_execution.py b/tests/integration/test_strategy_execution.py new file mode 100644 index 00000000..bda2d240 --- /dev/null +++ b/tests/integration/test_strategy_execution.py @@ -0,0 +1,36 @@ +"""Integration tests for strategy execution.""" + +import pytest +from src.strategies.technical.rsi_strategy import RSIStrategy +from src.trading.engine import get_trading_engine + + +@pytest.mark.integration +class TestStrategyExecution: + """Integration tests for strategy execution.""" + + @pytest.mark.asyncio + async def test_strategy_execution_workflow(self): + """Test complete strategy execution workflow.""" + engine = get_trading_engine() + await engine.initialize() + + # Create strategy + strategy = RSIStrategy( + strategy_id=1, + name="test_rsi", + symbol="BTC/USD", + timeframe="1h", + parameters={"rsi_period": 14} + ) + + # Start strategy + await engine.start_strategy(strategy) + assert strategy.is_active + + # Stop strategy + await engine.stop_strategy(1) + assert not strategy.is_active + + await engine.shutdown() + diff --git a/tests/integration/test_trading_workflow.py b/tests/integration/test_trading_workflow.py new file mode 100644 index 00000000..214da9a2 --- /dev/null +++ b/tests/integration/test_trading_workflow.py @@ -0,0 +1,47 @@ +"""Integration tests for trading workflow.""" + +import pytest +from unittest.mock import Mock, AsyncMock, patch +from src.trading.engine import get_trading_engine +from src.strategies.technical.rsi_strategy import RSIStrategy + + +@pytest.mark.integration +class TestTradingWorkflow: + """Integration tests for complete trading workflow.""" + + @pytest.mark.asyncio + async def test_complete_trading_workflow(self, mock_database): + """Test complete trading workflow.""" + # Initialize trading engine + engine = get_trading_engine() + await engine.initialize() + + # Create strategy + strategy = RSIStrategy( + strategy_id=1, + name="test_rsi", + symbol="BTC/USD", + timeframe="1h", + parameters={"rsi_period": 14} + ) + + # Start strategy + await engine.start_strategy(strategy) + + # Execute trade (paper trading) + result = await engine.execute_trade( + exchange_name="paper_trading", + strategy_id=1, + symbol="BTC/USD", + side="buy", + order_type="market", + amount=0.01, + is_paper_trade=True + ) + + assert result is not None + + # Cleanup + await engine.shutdown() + diff --git a/tests/integration/test_ui_backtest_workflow.py b/tests/integration/test_ui_backtest_workflow.py new file mode 100644 index 00000000..5df5130c --- /dev/null +++ b/tests/integration/test_ui_backtest_workflow.py @@ -0,0 +1,65 @@ +"""Integration tests for UI backtest workflow.""" + +import pytest +from datetime import datetime, timedelta +from PyQt6.QtWidgets import QApplication +from unittest.mock import Mock, patch +from src.ui.widgets.backtest_view import BacktestViewWidget +from src.strategies.technical.rsi_strategy import RSIStrategy + + +@pytest.fixture +def app(): + """Create QApplication for tests.""" + if not QApplication.instance(): + return QApplication([]) + return QApplication.instance() + + +@pytest.fixture +def backtest_view(app): + """Create BacktestViewWidget.""" + view = BacktestViewWidget() + + # Mock backtest engine + mock_engine = Mock() + mock_engine.run_backtest.return_value = { + 'total_return': 10.5, + 'sharpe_ratio': 1.2, + 'max_drawdown': -5.0, + 'win_rate': 0.55, + 'total_trades': 50, + 'final_value': 110.5, + 'trades': [] + } + view.backtest_engine = mock_engine + + return view + + +def test_backtest_configuration(backtest_view): + """Test backtest configuration form.""" + assert backtest_view.strategy_combo is not None + assert backtest_view.symbol_input is not None + assert backtest_view.start_date is not None + assert backtest_view.end_date is not None + + +def test_backtest_results_display(backtest_view): + """Test backtest results are displayed.""" + results = { + 'total_return': 10.5, + 'sharpe_ratio': 1.2, + 'max_drawdown': -5.0, + 'win_rate': 0.55, + 'total_trades': 50, + 'final_value': 110.5, + 'trades': [] + } + + backtest_view._display_results(results) + + # Verify metrics text contains results + metrics_text = backtest_view.metrics_text.toPlainText() + assert "10.5" in metrics_text # Total return + assert "1.2" in metrics_text # Sharpe ratio diff --git a/tests/integration/test_ui_strategy_workflow.py b/tests/integration/test_ui_strategy_workflow.py new file mode 100644 index 00000000..0302aed1 --- /dev/null +++ b/tests/integration/test_ui_strategy_workflow.py @@ -0,0 +1,62 @@ +"""Integration tests for UI strategy workflow.""" + +import pytest +from PyQt6.QtWidgets import QApplication +from unittest.mock import Mock, patch +from src.ui.widgets.strategy_manager import StrategyManagerWidget, StrategyDialog +from src.core.database import Strategy + + +@pytest.fixture +def app(): + """Create QApplication for tests.""" + if not QApplication.instance(): + return QApplication([]) + return QApplication.instance() + + +@pytest.fixture +def strategy_manager(app): + """Create StrategyManagerWidget.""" + return StrategyManagerWidget() + + +def test_strategy_creation_workflow(strategy_manager, app): + """Test creating strategy via UI.""" + # Mock database + with patch.object(strategy_manager.db, 'get_session') as mock_session: + mock_session.return_value.__enter__.return_value.add = Mock() + mock_session.return_value.__enter__.return_value.commit = Mock() + + # Simulate add strategy + dialog = StrategyDialog(strategy_manager) + dialog.name_input.setText("Test Strategy") + dialog.symbol_input.setText("BTC/USD") + dialog.type_combo.setCurrentText("rsi") + + # Would need to set exchange combo data + # For now, just verify dialog structure + assert dialog.name_input.text() == "Test Strategy" + dialog.close() + + +def test_strategy_table_population(strategy_manager): + """Test strategies table is populated from database.""" + # Mock database query + mock_strategy = Mock(spec=Strategy) + mock_strategy.id = 1 + mock_strategy.name = "Test Strategy" + mock_strategy.strategy_type = "rsi" + mock_strategy.parameters = {"symbol": "BTC/USD"} + mock_strategy.enabled = True + + with patch.object(strategy_manager.db, 'get_session') as mock_session: + mock_query = Mock() + mock_query.all.return_value = [mock_strategy] + mock_session.return_value.__enter__.return_value.query.return_value.filter_by.return_value.all = Mock(return_value=[mock_strategy]) + mock_session.return_value.__enter__.return_value.query.return_value.all = Mock(return_value=[mock_strategy]) + + strategy_manager._refresh_strategies() + + # Verify table has data + # Note: Actual implementation would verify table contents diff --git a/tests/integration/test_ui_trading_workflow.py b/tests/integration/test_ui_trading_workflow.py new file mode 100644 index 00000000..8683f697 --- /dev/null +++ b/tests/integration/test_ui_trading_workflow.py @@ -0,0 +1,77 @@ +"""Integration tests for UI trading workflow.""" + +import pytest +from decimal import Decimal +from PyQt6.QtWidgets import QApplication +from unittest.mock import Mock, patch +from src.ui.widgets.trading_view import TradingView +from src.core.database import Order, OrderStatus, OrderSide, OrderType + + +@pytest.fixture +def app(): + """Create QApplication for tests.""" + if not QApplication.instance(): + return QApplication([]) + return QApplication.instance() + + +@pytest.fixture +def trading_view(app): + """Create TradingView with mocked backend.""" + view = TradingView() + + # Mock trading engine + mock_engine = Mock() + mock_order = Mock(spec=Order) + mock_order.id = 1 + mock_engine.execute_order.return_value = mock_order + view.trading_engine = mock_engine + + return view + + +def test_order_placement_workflow(trading_view): + """Test complete order placement workflow.""" + # Set up form + trading_view.current_exchange_id = 1 + trading_view.current_symbol = "BTC/USD" + trading_view.order_type_combo.setCurrentText("Market") + trading_view.side_combo.setCurrentText("Buy") + trading_view.quantity_input.setValue(0.1) + + # Place order + trading_view._place_order() + + # Verify engine was called + trading_view.trading_engine.execute_order.assert_called_once() + call_args = trading_view.trading_engine.execute_order.call_args + assert call_args[1]['symbol'] == "BTC/USD" + assert call_args[1]['side'] == OrderSide.BUY + + +def test_position_table_update(trading_view): + """Test positions table updates with portfolio data.""" + # Mock portfolio data + mock_portfolio = { + 'positions': [ + { + 'symbol': 'BTC/USD', + 'quantity': 0.1, + 'entry_price': 50000, + 'current_price': 51000, + 'unrealized_pnl': 100 + } + ], + 'performance': { + 'current_value': 5100, + 'unrealized_pnl': 100, + 'realized_pnl': 0 + } + } + + trading_view.portfolio_tracker.get_current_portfolio = Mock(return_value=mock_portfolio) + trading_view._update_positions() + + assert trading_view.positions_table.rowCount() == 1 + assert trading_view.positions_table.item(0, 0).text() == "BTC/USD" diff --git a/tests/performance/__init__.py b/tests/performance/__init__.py new file mode 100644 index 00000000..d336ed37 --- /dev/null +++ b/tests/performance/__init__.py @@ -0,0 +1,2 @@ +"""Performance tests.""" + diff --git a/tests/performance/test_backtest_performance.py b/tests/performance/test_backtest_performance.py new file mode 100644 index 00000000..7b05080c --- /dev/null +++ b/tests/performance/test_backtest_performance.py @@ -0,0 +1,26 @@ +"""Performance benchmarks for backtesting.""" + +import pytest +import time +from src.backtesting.engine import get_backtest_engine + + +@pytest.mark.slow +class TestBacktestPerformance: + """Performance tests for backtesting.""" + + @pytest.mark.asyncio + async def test_backtest_speed(self): + """Test backtesting speed.""" + engine = get_backtest_engine() + + # Create minimal backtest scenario + start_time = time.time() + + # Run minimal backtest (would need actual implementation) + # This is a placeholder + + elapsed = time.time() - start_time + # Backtest should complete in reasonable time + assert elapsed < 60 # Less than 60 seconds for basic test + diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 00000000..b623d2cb --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,8 @@ +pytest>=7.4.0 +pytest-cov>=4.1.0 +pytest-asyncio>=0.21.0 +pytest-mock>=3.11.0 +coverage>=7.3.0 +faker>=19.0.0 +freezegun>=1.2.0 + diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 00000000..9a8b7dd8 --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1,2 @@ +"""Unit tests.""" + diff --git a/tests/unit/alerts/__init__.py b/tests/unit/alerts/__init__.py new file mode 100644 index 00000000..f7a14237 --- /dev/null +++ b/tests/unit/alerts/__init__.py @@ -0,0 +1,2 @@ +"""Unit tests for alert system.""" + diff --git a/tests/unit/alerts/test_engine.py b/tests/unit/alerts/test_engine.py new file mode 100644 index 00000000..1114bd38 --- /dev/null +++ b/tests/unit/alerts/test_engine.py @@ -0,0 +1,33 @@ +"""Tests for alert engine.""" + +import pytest +from src.alerts.engine import get_alert_engine, AlertEngine + + +class TestAlertEngine: + """Tests for AlertEngine.""" + + @pytest.fixture + def alert_engine(self): + """Create alert engine instance.""" + return get_alert_engine() + + def test_alert_engine_initialization(self, alert_engine): + """Test alert engine initialization.""" + assert alert_engine is not None + + @pytest.mark.asyncio + async def test_process_alert(self, alert_engine): + """Test processing alert.""" + # Create test alert + alert = { + 'name': 'test_alert', + 'type': 'price', + 'condition': 'BTC/USD > 50000', + 'is_active': True + } + + # Process alert (simplified) + result = await alert_engine.process_alert(alert, {'BTC/USD': 51000.0}) + assert isinstance(result, bool) + diff --git a/tests/unit/autopilot/__init__.py b/tests/unit/autopilot/__init__.py new file mode 100644 index 00000000..be667a57 --- /dev/null +++ b/tests/unit/autopilot/__init__.py @@ -0,0 +1,2 @@ +"""Tests for autopilot module.""" + diff --git a/tests/unit/autopilot/test_intelligent_autopilot.py b/tests/unit/autopilot/test_intelligent_autopilot.py new file mode 100644 index 00000000..7ec9ca04 --- /dev/null +++ b/tests/unit/autopilot/test_intelligent_autopilot.py @@ -0,0 +1,175 @@ +"""Tests for intelligent autopilot functionality.""" + +import pytest +from decimal import Decimal +from unittest.mock import Mock, patch, AsyncMock +from src.core.database import OrderSide, OrderType + + +class TestPreFlightValidation: + """Tests for pre-flight order validation.""" + + @pytest.fixture + def mock_autopilot(self): + """Create mock autopilot with necessary attributes.""" + from src.autopilot.intelligent_autopilot import IntelligentAutopilot + + with patch.object(IntelligentAutopilot, '__init__', lambda x, *args, **kwargs: None): + autopilot = IntelligentAutopilot.__new__(IntelligentAutopilot) + autopilot.symbol = 'BTC/USD' + autopilot.paper_trading = True + autopilot.logger = Mock() + + # Mock trading engine + autopilot.trading_engine = Mock() + autopilot.trading_engine.paper_trading = Mock() + autopilot.trading_engine.paper_trading.get_balance.return_value = Decimal('1000.0') + autopilot.trading_engine.paper_trading.get_positions.return_value = [] + + return autopilot + + @pytest.mark.asyncio + async def test_can_execute_order_insufficient_funds(self, mock_autopilot): + """Test that insufficient funds returns False.""" + mock_autopilot.trading_engine.paper_trading.get_balance.return_value = Decimal('10.0') + + can_execute, reason = await mock_autopilot._can_execute_order( + side=OrderSide.BUY, + quantity=Decimal('1.0'), + price=Decimal('100.0') + ) + + assert can_execute is False + assert 'Insufficient funds' in reason + + @pytest.mark.asyncio + async def test_can_execute_order_sufficient_funds(self, mock_autopilot): + """Test that sufficient funds returns True.""" + mock_autopilot.trading_engine.paper_trading.get_balance.return_value = Decimal('1000.0') + + can_execute, reason = await mock_autopilot._can_execute_order( + side=OrderSide.BUY, + quantity=Decimal('1.0'), + price=Decimal('100.0') + ) + + assert can_execute is True + assert reason == 'OK' + + @pytest.mark.asyncio + async def test_can_execute_order_no_position_for_sell(self, mock_autopilot): + """Test that SELL without position returns False.""" + mock_autopilot.trading_engine.paper_trading.get_positions.return_value = [] + + can_execute, reason = await mock_autopilot._can_execute_order( + side=OrderSide.SELL, + quantity=Decimal('1.0'), + price=Decimal('100.0') + ) + + assert can_execute is False + assert 'No position to sell' in reason + + @pytest.mark.asyncio + async def test_can_execute_order_minimum_value(self, mock_autopilot): + """Test that order below minimum value returns False.""" + can_execute, reason = await mock_autopilot._can_execute_order( + side=OrderSide.BUY, + quantity=Decimal('0.001'), + price=Decimal('0.10') # Order value = $0.0001 + ) + + assert can_execute is False + assert 'below minimum' in reason + + +class TestSmartOrderTypeSelection: + """Tests for smart order type selection.""" + + @pytest.fixture + def mock_autopilot(self): + """Create mock autopilot for order type tests.""" + from src.autopilot.intelligent_autopilot import IntelligentAutopilot + + with patch.object(IntelligentAutopilot, '__init__', lambda x, *args, **kwargs: None): + autopilot = IntelligentAutopilot.__new__(IntelligentAutopilot) + autopilot.logger = Mock() + return autopilot + + def test_strong_signal_uses_market(self, mock_autopilot): + """Test that strong signals (>80%) use MARKET orders.""" + order_type, price = mock_autopilot._determine_order_type_and_price( + side=OrderSide.BUY, + signal_strength=0.85, + current_price=Decimal('100.0'), + is_stop_loss=False + ) + + assert order_type == OrderType.MARKET + assert price is None + + def test_normal_signal_uses_limit(self, mock_autopilot): + """Test that normal signals use LIMIT orders.""" + order_type, price = mock_autopilot._determine_order_type_and_price( + side=OrderSide.BUY, + signal_strength=0.65, + current_price=Decimal('100.0'), + is_stop_loss=False + ) + + assert order_type == OrderType.LIMIT + assert price is not None + # BUY limit should be below market + assert price < Decimal('100.0') + + def test_stop_loss_uses_market(self, mock_autopilot): + """Test that stop-loss exits use MARKET orders.""" + order_type, price = mock_autopilot._determine_order_type_and_price( + side=OrderSide.SELL, + signal_strength=0.5, + current_price=Decimal('100.0'), + is_stop_loss=True + ) + + assert order_type == OrderType.MARKET + assert price is None + + def test_take_profit_uses_limit(self, mock_autopilot): + """Test that take-profit exits can use LIMIT orders.""" + order_type, price = mock_autopilot._determine_order_type_and_price( + side=OrderSide.SELL, + signal_strength=0.6, + current_price=Decimal('100.0'), + is_stop_loss=False + ) + + assert order_type == OrderType.LIMIT + assert price is not None + # SELL limit should be above market + assert price > Decimal('100.0') + + def test_buy_limit_price_discount(self, mock_autopilot): + """Test BUY LIMIT price is 0.1% below market.""" + order_type, price = mock_autopilot._determine_order_type_and_price( + side=OrderSide.BUY, + signal_strength=0.6, + current_price=Decimal('1000.00'), + is_stop_loss=False + ) + + # 0.1% discount = 999.00 + expected = Decimal('999.00') + assert price == expected + + def test_sell_limit_price_premium(self, mock_autopilot): + """Test SELL LIMIT price is 0.1% above market.""" + order_type, price = mock_autopilot._determine_order_type_and_price( + side=OrderSide.SELL, + signal_strength=0.6, + current_price=Decimal('1000.00'), + is_stop_loss=False + ) + + # 0.1% premium = 1001.00 + expected = Decimal('1001.00') + assert price == expected diff --git a/tests/unit/autopilot/test_strategy_groups.py b/tests/unit/autopilot/test_strategy_groups.py new file mode 100644 index 00000000..b9667cba --- /dev/null +++ b/tests/unit/autopilot/test_strategy_groups.py @@ -0,0 +1,161 @@ +"""Tests for strategy grouping module.""" + +import pytest +from src.autopilot.strategy_groups import ( + StrategyGroup, + STRATEGY_TO_GROUP, + GROUP_TO_STRATEGIES, + get_strategy_group, + get_strategies_in_group, + get_all_groups, + get_best_strategy_in_group, + convert_strategy_to_group_label, +) + + +class TestStrategyGroupMappings: + """Tests for strategy group mappings.""" + + def test_all_strategies_have_group(self): + """Verify all registered strategies are mapped to a group.""" + # These are the strategies registered in src/strategies/__init__.py + expected_strategies = [ + "rsi", "macd", "moving_average", "confirmed", "divergence", + "bollinger_mean_reversion", "dca", "grid", "momentum", + "consensus", "pairs_trading", "volatility_breakout", + "sentiment", "market_making" + ] + + for strategy in expected_strategies: + group = get_strategy_group(strategy) + assert group is not None, f"Strategy '{strategy}' is not mapped to any group" + + def test_get_strategy_group_case_insensitive(self): + """Test that strategy lookup is case-insensitive.""" + assert get_strategy_group("RSI") == get_strategy_group("rsi") + assert get_strategy_group("MACD") == get_strategy_group("macd") + assert get_strategy_group("Moving_Average") == get_strategy_group("moving_average") + + def test_get_strategy_group_unknown(self): + """Test that unknown strategies return None.""" + assert get_strategy_group("nonexistent_strategy") is None + assert get_strategy_group("") is None + + def test_group_to_strategies_reverse_mapping(self): + """Verify GROUP_TO_STRATEGIES is the reverse of STRATEGY_TO_GROUP.""" + for strategy, group in STRATEGY_TO_GROUP.items(): + assert strategy in GROUP_TO_STRATEGIES[group] + + def test_all_groups_have_strategies(self): + """Verify all groups have at least one strategy.""" + for group in StrategyGroup: + strategies = get_strategies_in_group(group) + assert len(strategies) > 0, f"Group '{group}' has no strategies" + + +class TestGetAllGroups: + """Tests for get_all_groups function.""" + + def test_returns_all_groups(self): + """Verify all groups are returned.""" + groups = get_all_groups() + assert len(groups) == 5 + assert StrategyGroup.TREND_FOLLOWING in groups + assert StrategyGroup.MEAN_REVERSION in groups + assert StrategyGroup.MOMENTUM in groups + assert StrategyGroup.MARKET_MAKING in groups + assert StrategyGroup.SENTIMENT_BASED in groups + + +class TestGetStrategiesInGroup: + """Tests for get_strategies_in_group function.""" + + def test_trend_following_strategies(self): + """Test trend following group contains expected strategies.""" + strategies = get_strategies_in_group(StrategyGroup.TREND_FOLLOWING) + assert "moving_average" in strategies + assert "macd" in strategies + assert "confirmed" in strategies + + def test_mean_reversion_strategies(self): + """Test mean reversion group contains expected strategies.""" + strategies = get_strategies_in_group(StrategyGroup.MEAN_REVERSION) + assert "rsi" in strategies + assert "bollinger_mean_reversion" in strategies + assert "grid" in strategies + + def test_momentum_strategies(self): + """Test momentum group contains expected strategies.""" + strategies = get_strategies_in_group(StrategyGroup.MOMENTUM) + assert "momentum" in strategies + assert "volatility_breakout" in strategies + + +class TestGetBestStrategyInGroup: + """Tests for get_best_strategy_in_group function.""" + + def test_trend_following_high_adx(self): + """Test trend following selection with high ADX.""" + features = {"adx": 35.0, "rsi": 50.0, "atr_percent": 2.0, "volume_ratio": 1.0} + strategy, confidence = get_best_strategy_in_group( + StrategyGroup.TREND_FOLLOWING, features + ) + assert strategy == "confirmed" + assert confidence > 0.5 + + def test_mean_reversion_extreme_rsi(self): + """Test mean reversion selection with extreme RSI.""" + features = {"adx": 15.0, "rsi": 25.0, "atr_percent": 1.5, "volume_ratio": 1.0} + strategy, confidence = get_best_strategy_in_group( + StrategyGroup.MEAN_REVERSION, features + ) + assert strategy == "rsi" + assert confidence > 0.5 + + def test_momentum_high_volume(self): + """Test momentum selection with high volume.""" + features = {"adx": 25.0, "rsi": 55.0, "atr_percent": 3.0, "volume_ratio": 2.0} + strategy, confidence = get_best_strategy_in_group( + StrategyGroup.MOMENTUM, features + ) + assert strategy == "volatility_breakout" + assert confidence > 0.5 + + def test_respects_available_strategies(self): + """Test that only available strategies are selected.""" + features = {"adx": 35.0, "rsi": 50.0} + # Only MACD available from trend following + strategy, confidence = get_best_strategy_in_group( + StrategyGroup.TREND_FOLLOWING, + features, + available_strategies=["macd"] + ) + assert strategy == "macd" + + def test_fallback_when_no_strategies_available(self): + """Test fallback when no strategies in group are available.""" + features = {"adx": 25.0, "rsi": 50.0} + strategy, confidence = get_best_strategy_in_group( + StrategyGroup.TREND_FOLLOWING, + features, + available_strategies=["some_other_strategy"] + ) + # Should return safe default + assert strategy == "rsi" + assert confidence == 0.5 + + +class TestConvertStrategyToGroupLabel: + """Tests for convert_strategy_to_group_label function.""" + + def test_converts_known_strategies(self): + """Test conversion of known strategies.""" + assert convert_strategy_to_group_label("rsi") == "mean_reversion" + assert convert_strategy_to_group_label("macd") == "trend_following" + assert convert_strategy_to_group_label("momentum") == "momentum" + assert convert_strategy_to_group_label("dca") == "market_making" + assert convert_strategy_to_group_label("sentiment") == "sentiment_based" + + def test_unknown_strategy_returns_original(self): + """Test that unknown strategies return original name.""" + assert convert_strategy_to_group_label("unknown") == "unknown" diff --git a/tests/unit/backend/__init__.py b/tests/unit/backend/__init__.py new file mode 100644 index 00000000..c67931d4 --- /dev/null +++ b/tests/unit/backend/__init__.py @@ -0,0 +1,2 @@ +"""Backend API tests.""" + diff --git a/tests/unit/backend/api/__init__.py b/tests/unit/backend/api/__init__.py new file mode 100644 index 00000000..7e993e89 --- /dev/null +++ b/tests/unit/backend/api/__init__.py @@ -0,0 +1,2 @@ +"""Backend API endpoint tests.""" + diff --git a/tests/unit/backend/api/test_autopilot.py b/tests/unit/backend/api/test_autopilot.py new file mode 100644 index 00000000..c7403333 --- /dev/null +++ b/tests/unit/backend/api/test_autopilot.py @@ -0,0 +1,379 @@ +"""Tests for autopilot API endpoints.""" + +import pytest +from unittest.mock import Mock, patch, MagicMock, AsyncMock +from fastapi.testclient import TestClient + +from backend.main import app + + +@pytest.fixture +def client(): + """Test client fixture.""" + return TestClient(app) + + +@pytest.fixture +def mock_autopilot(): + """Mock autopilot instance.""" + autopilot = Mock() + autopilot.symbol = "BTC/USD" + autopilot.is_running = False + autopilot.get_status.return_value = { + "symbol": "BTC/USD", + "running": False, + "interval": 60.0, + "has_market_data": True, + "market_data_length": 100, + "headlines_count": 5, + "last_sentiment_score": 0.5, + "last_pattern": "head_and_shoulders", + "last_signal": None, + } + autopilot.last_signal = None + autopilot.analyze_once.return_value = None + return autopilot + + +@pytest.fixture +def mock_intelligent_autopilot(): + """Mock intelligent autopilot instance.""" + autopilot = Mock() + autopilot.symbol = "BTC/USD" + autopilot.is_running = False + autopilot.enable_auto_execution = False + autopilot.get_status.return_value = { + "symbol": "BTC/USD", + "timeframe": "1h", + "running": False, + "selected_strategy": None, + "trades_today": 0, + "max_trades_per_day": 10, + "min_confidence_threshold": 0.75, + "enable_auto_execution": False, + "last_analysis": None, + "model_info": {}, + } + return autopilot + + +class TestUnifiedAutopilotEndpoints: + """Tests for unified autopilot endpoints.""" + + @patch('backend.api.autopilot.get_autopilot_mode_info') + def test_get_modes(self, mock_get_mode_info, client): + """Test getting autopilot mode information.""" + mock_get_mode_info.return_value = { + "modes": { + "pattern": {"name": "Pattern-Based Autopilot"}, + "intelligent": {"name": "ML-Based Autopilot"}, + }, + "comparison": {}, + } + + response = client.get("/api/autopilot/modes") + assert response.status_code == 200 + data = response.json() + assert "modes" in data + assert "pattern" in data["modes"] + assert "intelligent" in data["modes"] + + @patch('backend.api.autopilot.get_autopilot') + @patch('backend.api.autopilot.run_autopilot_loop') + def test_start_unified_pattern_mode( + self, mock_run_loop, mock_get_autopilot, client, mock_autopilot + ): + """Test starting unified autopilot in pattern mode.""" + mock_get_autopilot.return_value = mock_autopilot + + response = client.post( + "/api/autopilot/start-unified", + json={ + "symbol": "BTC/USD", + "mode": "pattern", + "auto_execute": False, + "interval": 60.0, + "pattern_order": 5, + "auto_fetch_news": True, + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "started" + assert data["mode"] == "pattern" + assert data["symbol"] == "BTC/USD" + assert data["auto_execute"] is False + mock_get_autopilot.assert_called_once() + + @patch('backend.api.autopilot.get_intelligent_autopilot') + def test_start_unified_intelligent_mode( + self, mock_get_intelligent, client, mock_intelligent_autopilot + ): + """Test starting unified autopilot in intelligent mode.""" + mock_get_intelligent.return_value = mock_intelligent_autopilot + + response = client.post( + "/api/autopilot/start-unified", + json={ + "symbol": "BTC/USD", + "mode": "intelligent", + "auto_execute": True, + "exchange_id": 1, + "timeframe": "1h", + "interval": 60.0, + "paper_trading": True, + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "started" + assert data["mode"] == "intelligent" + assert data["symbol"] == "BTC/USD" + assert data["auto_execute"] is True + assert mock_intelligent_autopilot.enable_auto_execution is True + mock_get_intelligent.assert_called_once() + + def test_start_unified_invalid_mode(self, client): + """Test starting unified autopilot with invalid mode.""" + response = client.post( + "/api/autopilot/start-unified", + json={ + "symbol": "BTC/USD", + "mode": "invalid_mode", + "auto_execute": False, + }, + ) + + assert response.status_code == 400 + assert "Invalid mode" in response.json()["detail"] + + @patch('backend.api.autopilot.get_autopilot') + def test_stop_unified_pattern_mode( + self, mock_get_autopilot, client, mock_autopilot + ): + """Test stopping unified autopilot in pattern mode.""" + mock_get_autopilot.return_value = mock_autopilot + + response = client.post( + "/api/autopilot/stop-unified?symbol=BTC/USD&mode=pattern" + ) + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "stopped" + assert data["symbol"] == "BTC/USD" + assert data["mode"] == "pattern" + mock_autopilot.stop.assert_called_once() + + @patch('backend.api.autopilot.get_intelligent_autopilot') + def test_stop_unified_intelligent_mode( + self, mock_get_intelligent, client, mock_intelligent_autopilot + ): + """Test stopping unified autopilot in intelligent mode.""" + mock_get_intelligent.return_value = mock_intelligent_autopilot + + response = client.post( + "/api/autopilot/stop-unified?symbol=BTC/USD&mode=intelligent&timeframe=1h" + ) + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "stopped" + assert data["symbol"] == "BTC/USD" + assert data["mode"] == "intelligent" + mock_intelligent_autopilot.stop.assert_called_once() + + def test_stop_unified_invalid_mode(self, client): + """Test stopping unified autopilot with invalid mode.""" + response = client.post( + "/api/autopilot/stop-unified?symbol=BTC/USD&mode=invalid_mode" + ) + + assert response.status_code == 400 + assert "Invalid mode" in response.json()["detail"] + + @patch('backend.api.autopilot.get_autopilot') + def test_get_unified_status_pattern_mode( + self, mock_get_autopilot, client, mock_autopilot + ): + """Test getting unified autopilot status in pattern mode.""" + mock_get_autopilot.return_value = mock_autopilot + + response = client.get( + "/api/autopilot/status-unified/BTC/USD?mode=pattern" + ) + + assert response.status_code == 200 + data = response.json() + assert data["symbol"] == "BTC/USD" + assert data["mode"] == "pattern" + assert "running" in data + + @patch('backend.api.autopilot.get_intelligent_autopilot') + def test_get_unified_status_intelligent_mode( + self, mock_get_intelligent, client, mock_intelligent_autopilot + ): + """Test getting unified autopilot status in intelligent mode.""" + mock_get_intelligent.return_value = mock_intelligent_autopilot + + response = client.get( + "/api/autopilot/status-unified/BTC/USD?mode=intelligent&timeframe=1h" + ) + + assert response.status_code == 200 + data = response.json() + assert data["symbol"] == "BTC/USD" + assert data["mode"] == "intelligent" + assert "running" in data + + def test_get_unified_status_invalid_mode(self, client): + """Test getting unified autopilot status with invalid mode.""" + response = client.get( + "/api/autopilot/status-unified/BTC/USD?mode=invalid_mode" + ) + + assert response.status_code == 400 + assert "Invalid mode" in response.json()["detail"] + + +class TestModeSelection: + """Tests for mode selection logic.""" + + @patch('backend.api.autopilot.get_autopilot_mode_info') + def test_mode_info_structure(self, mock_get_mode_info): + """Test that mode info has correct structure.""" + from src.autopilot import get_autopilot_mode_info + + mode_info = get_autopilot_mode_info() + + assert "modes" in mode_info + assert "pattern" in mode_info["modes"] + assert "intelligent" in mode_info["modes"] + assert "comparison" in mode_info + + # Check pattern mode structure + pattern = mode_info["modes"]["pattern"] + assert "name" in pattern + assert "description" in pattern + assert "how_it_works" in pattern + assert "best_for" in pattern + assert "tradeoffs" in pattern + assert "features" in pattern + assert "requirements" in pattern + + # Check intelligent mode structure + intelligent = mode_info["modes"]["intelligent"] + assert "name" in intelligent + assert "description" in intelligent + assert "how_it_works" in intelligent + assert "best_for" in intelligent + assert "tradeoffs" in intelligent + assert "features" in intelligent + assert "requirements" in intelligent + + # Check comparison structure + comparison = mode_info["comparison"] + assert "transparency" in comparison + assert "adaptability" in comparison + assert "setup_time" in comparison + assert "resource_usage" in comparison + + +class TestAutoExecution: + """Tests for auto-execution functionality.""" + + @patch('backend.api.autopilot.get_intelligent_autopilot') + def test_auto_execute_enabled( + self, mock_get_intelligent, client, mock_intelligent_autopilot + ): + """Test that auto-execute is set when enabled.""" + mock_get_intelligent.return_value = mock_intelligent_autopilot + + response = client.post( + "/api/autopilot/start-unified", + json={ + "symbol": "BTC/USD", + "mode": "intelligent", + "auto_execute": True, + "exchange_id": 1, + "timeframe": "1h", + }, + ) + + assert response.status_code == 200 + assert mock_intelligent_autopilot.enable_auto_execution is True + + @patch('backend.api.autopilot.get_intelligent_autopilot') + def test_auto_execute_disabled( + self, mock_get_intelligent, client, mock_intelligent_autopilot + ): + """Test that auto-execute is not set when disabled.""" + mock_get_intelligent.return_value = mock_intelligent_autopilot + + response = client.post( + "/api/autopilot/start-unified", + json={ + "symbol": "BTC/USD", + "mode": "intelligent", + "auto_execute": False, + "exchange_id": 1, + "timeframe": "1h", + }, + ) + + assert response.status_code == 200 + # Note: enable_auto_execution may have a default value, so we check it's not True + # The actual behavior depends on the implementation + + +class TestBackwardCompatibility: + """Tests for backward compatibility with old endpoints.""" + + @patch('backend.api.autopilot.get_autopilot') + @patch('backend.api.autopilot.run_autopilot_loop') + def test_old_start_endpoint_still_works( + self, mock_run_loop, mock_get_autopilot, client, mock_autopilot + ): + """Test that old /start endpoint still works (deprecated but functional).""" + mock_get_autopilot.return_value = mock_autopilot + + response = client.post( + "/api/autopilot/start", + json={ + "symbol": "BTC/USD", + "interval": 60.0, + "pattern_order": 5, + "auto_fetch_news": True, + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "started" + assert data["symbol"] == "BTC/USD" + + @patch('backend.api.autopilot.get_intelligent_autopilot') + def test_old_intelligent_start_endpoint_still_works( + self, mock_get_intelligent, client, mock_intelligent_autopilot + ): + """Test that old /intelligent/start endpoint still works (deprecated but functional).""" + mock_get_intelligent.return_value = mock_intelligent_autopilot + + response = client.post( + "/api/autopilot/intelligent/start", + json={ + "symbol": "BTC/USD", + "exchange_id": 1, + "timeframe": "1h", + "interval": 60.0, + "paper_trading": True, + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "started" + assert data["symbol"] == "BTC/USD" + diff --git a/tests/unit/backend/api/test_exchanges.py b/tests/unit/backend/api/test_exchanges.py new file mode 100644 index 00000000..0bdd9c5e --- /dev/null +++ b/tests/unit/backend/api/test_exchanges.py @@ -0,0 +1,81 @@ +"""Tests for exchanges API endpoints.""" + +import pytest +from unittest.mock import Mock, patch +from fastapi.testclient import TestClient + +from backend.main import app +from src.core.database import Exchange + + +@pytest.fixture +def client(): + """Test client fixture.""" + return TestClient(app) + + +@pytest.fixture +def mock_exchange(): + """Mock exchange object.""" + exchange = Mock(spec=Exchange) + exchange.id = 1 + exchange.name = "coinbase" + exchange.is_enabled = True + exchange.api_permissions = "read_only" + return exchange + + +class TestListExchanges: + """Tests for GET /api/exchanges.""" + + @patch('backend.api.exchanges.get_db') + def test_list_exchanges_success(self, mock_get_db, client): + """Test listing exchanges.""" + mock_db = Mock() + mock_session = Mock() + mock_db.get_session.return_value = mock_session + mock_get_db.return_value = mock_db + + mock_session.query.return_value.all.return_value = [] + + response = client.get("/api/exchanges") + + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + + +class TestGetExchange: + """Tests for GET /api/exchanges/{exchange_id}.""" + + @patch('backend.api.exchanges.get_db') + def test_get_exchange_success(self, mock_get_db, client, mock_exchange): + """Test getting exchange by ID.""" + mock_db = Mock() + mock_session = Mock() + mock_db.get_session.return_value = mock_session + mock_get_db.return_value = mock_db + + mock_session.query.return_value.filter_by.return_value.first.return_value = mock_exchange + + response = client.get("/api/exchanges/1") + + assert response.status_code == 200 + data = response.json() + assert data["id"] == 1 + + @patch('backend.api.exchanges.get_db') + def test_get_exchange_not_found(self, mock_get_db, client): + """Test getting non-existent exchange.""" + mock_db = Mock() + mock_session = Mock() + mock_db.get_session.return_value = mock_session + mock_get_db.return_value = mock_db + + mock_session.query.return_value.filter_by.return_value.first.return_value = None + + response = client.get("/api/exchanges/999") + + assert response.status_code == 404 + assert "Exchange not found" in response.json()["detail"] + diff --git a/tests/unit/backend/api/test_portfolio.py b/tests/unit/backend/api/test_portfolio.py new file mode 100644 index 00000000..516a545b --- /dev/null +++ b/tests/unit/backend/api/test_portfolio.py @@ -0,0 +1,65 @@ +"""Tests for portfolio API endpoints.""" + +import pytest +from unittest.mock import Mock, patch +from fastapi.testclient import TestClient + +from backend.main import app + + +@pytest.fixture +def client(): + """Test client fixture.""" + return TestClient(app) + + +@pytest.fixture +def mock_portfolio_tracker(): + """Mock portfolio tracker.""" + tracker = Mock() + tracker.get_current_portfolio.return_value = { + "positions": [], + "performance": {"total_return": 0.1}, + "timestamp": "2025-01-01T00:00:00" + } + tracker.get_portfolio_history.return_value = { + "dates": ["2025-01-01"], + "values": [1000.0], + "pnl": [0.0] + } + return tracker + + +class TestGetCurrentPortfolio: + """Tests for GET /api/portfolio/current.""" + + @patch('backend.api.portfolio.get_portfolio_tracker') + def test_get_current_portfolio_success(self, mock_get_tracker, client, mock_portfolio_tracker): + """Test getting current portfolio.""" + mock_get_tracker.return_value = mock_portfolio_tracker + + response = client.get("/api/portfolio/current?paper_trading=true") + + assert response.status_code == 200 + data = response.json() + assert "positions" in data + assert "performance" in data + assert "timestamp" in data + + +class TestGetPortfolioHistory: + """Tests for GET /api/portfolio/history.""" + + @patch('backend.api.portfolio.get_portfolio_tracker') + def test_get_portfolio_history_success(self, mock_get_tracker, client, mock_portfolio_tracker): + """Test getting portfolio history.""" + mock_get_tracker.return_value = mock_portfolio_tracker + + response = client.get("/api/portfolio/history?paper_trading=true&days=30") + + assert response.status_code == 200 + data = response.json() + assert "dates" in data + assert "values" in data + assert "pnl" in data + diff --git a/tests/unit/backend/api/test_strategies.py b/tests/unit/backend/api/test_strategies.py new file mode 100644 index 00000000..32fe3468 --- /dev/null +++ b/tests/unit/backend/api/test_strategies.py @@ -0,0 +1,108 @@ +"""Tests for strategies API endpoints.""" + +import pytest +from unittest.mock import Mock, patch, MagicMock +from fastapi.testclient import TestClient + +from backend.main import app +from src.core.database import Strategy + + +@pytest.fixture +def client(): + """Test client fixture.""" + return TestClient(app) + + +@pytest.fixture +def mock_strategy_registry(): + """Mock strategy registry.""" + registry = Mock() + registry.list_available.return_value = ["RSIStrategy", "MACDStrategy"] + return registry + + +@pytest.fixture +def mock_strategy(): + """Mock strategy object.""" + strategy = Mock(spec=Strategy) + strategy.id = 1 + strategy.name = "Test Strategy" + strategy.type = "RSIStrategy" + strategy.status = "active" # Use string instead of enum + strategy.symbol = "BTC/USD" + strategy.params = {} + return strategy + + + +class TestListStrategies: + """Tests for GET /api/strategies.""" + + @patch('backend.api.strategies.get_db') + def test_list_strategies_success(self, mock_get_db, client): + """Test listing strategies.""" + mock_db = Mock() + mock_session = Mock() + mock_db.get_session.return_value = mock_session + mock_get_db.return_value = mock_db + + mock_session.query.return_value.all.return_value = [] + + response = client.get("/api/strategies") + + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + + +class TestGetStrategy: + """Tests for GET /api/strategies/{strategy_id}.""" + + @patch('backend.api.strategies.get_db') + def test_get_strategy_success(self, mock_get_db, client, mock_strategy): + """Test getting strategy by ID.""" + mock_db = Mock() + mock_session = Mock() + mock_db.get_session.return_value = mock_session + mock_get_db.return_value = mock_db + + mock_session.query.return_value.filter_by.return_value.first.return_value = mock_strategy + + response = client.get("/api/strategies/1") + + assert response.status_code == 200 + data = response.json() + assert data["id"] == 1 + + @patch('backend.api.strategies.get_db') + def test_get_strategy_not_found(self, mock_get_db, client): + """Test getting non-existent strategy.""" + mock_db = Mock() + mock_session = Mock() + mock_db.get_session.return_value = mock_session + mock_get_db.return_value = mock_db + + mock_session.query.return_value.filter_by.return_value.first.return_value = None + + response = client.get("/api/strategies/999") + + assert response.status_code == 404 + assert "Strategy not found" in response.json()["detail"] + + +class TestListAvailableStrategyTypes: + """Tests for GET /api/strategies/types.""" + + @patch('backend.api.strategies.get_strategy_registry') + def test_list_strategy_types_success(self, mock_get_registry, client, mock_strategy_registry): + """Test listing available strategy types.""" + mock_get_registry.return_value = mock_strategy_registry + + response = client.get("/api/strategies/types") + + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert "RSIStrategy" in data + diff --git a/tests/unit/backend/api/test_trading.py b/tests/unit/backend/api/test_trading.py new file mode 100644 index 00000000..54aa23d1 --- /dev/null +++ b/tests/unit/backend/api/test_trading.py @@ -0,0 +1,293 @@ +"""Tests for trading API endpoints.""" + +import pytest +from decimal import Decimal +from unittest.mock import Mock, patch, MagicMock +from fastapi.testclient import TestClient +from datetime import datetime + +from backend.main import app +from backend.core.schemas import OrderCreate, OrderSide, OrderType +from src.core.database import Order, OrderStatus + + +@pytest.fixture +def client(): + """Test client fixture.""" + return TestClient(app) + + +@pytest.fixture +def mock_trading_engine(): + """Mock trading engine.""" + engine = Mock() + engine.order_manager = Mock() + return engine + + +@pytest.fixture +def mock_order(): + """Mock order object.""" + order = Mock() + order.id = 1 + order.exchange_id = 1 + order.strategy_id = None + order.symbol = "BTC/USD" + order.order_type = OrderType.MARKET + order.side = OrderSide.BUY + order.status = OrderStatus.FILLED + order.quantity = Decimal("0.1") + order.price = Decimal("50000") + order.filled_quantity = Decimal("0.1") + order.average_fill_price = Decimal("50000") + order.fee = Decimal("5") + order.paper_trading = True + order.created_at = datetime.now() + order.updated_at = datetime.now() + order.filled_at = datetime.now() + return order + + +class TestCreateOrder: + """Tests for POST /api/trading/orders.""" + + @patch('backend.api.trading.get_trading_engine') + def test_create_order_success(self, mock_get_engine, client, mock_trading_engine, mock_order): + """Test successful order creation.""" + mock_get_engine.return_value = mock_trading_engine + mock_trading_engine.execute_order.return_value = mock_order + + order_data = { + "exchange_id": 1, + "symbol": "BTC/USD", + "side": "buy", + "order_type": "market", + "quantity": "0.1", + "paper_trading": True + } + + response = client.post("/api/trading/orders", json=order_data) + + assert response.status_code == 200 + data = response.json() + assert data["id"] == 1 + assert data["symbol"] == "BTC/USD" + assert data["side"] == "buy" + mock_trading_engine.execute_order.assert_called_once() + + @patch('backend.api.trading.get_trading_engine') + def test_create_order_execution_failed(self, mock_get_engine, client, mock_trading_engine): + """Test order creation when execution fails.""" + mock_get_engine.return_value = mock_trading_engine + mock_trading_engine.execute_order.return_value = None + + order_data = { + "exchange_id": 1, + "symbol": "BTC/USD", + "side": "buy", + "order_type": "market", + "quantity": "0.1", + "paper_trading": True + } + + response = client.post("/api/trading/orders", json=order_data) + + assert response.status_code == 400 + assert "Order execution failed" in response.json()["detail"] + + @patch('backend.api.trading.get_trading_engine') + def test_create_order_invalid_data(self, client): + """Test order creation with invalid data.""" + order_data = { + "exchange_id": "invalid", + "symbol": "BTC/USD", + "side": "buy", + "order_type": "market", + "quantity": "0.1" + } + + response = client.post("/api/trading/orders", json=order_data) + + assert response.status_code == 422 # Validation error + + +class TestGetOrders: + """Tests for GET /api/trading/orders.""" + + @patch('backend.api.trading.get_db') + def test_get_orders_success(self, mock_get_db, client, mock_database): + """Test getting orders.""" + mock_db = Mock() + mock_session = Mock() + mock_db.get_session.return_value = mock_session + mock_get_db.return_value = mock_db + + # Create mock orders + mock_order1 = Mock(spec=Order) + mock_order1.id = 1 + mock_order1.symbol = "BTC/USD" + mock_order1.paper_trading = True + + mock_order2 = Mock(spec=Order) + mock_order2.id = 2 + mock_order2.symbol = "ETH/USD" + mock_order2.paper_trading = True + + mock_session.query.return_value.filter_by.return_value.order_by.return_value.limit.return_value.all.return_value = [mock_order1, mock_order2] + + response = client.get("/api/trading/orders?paper_trading=true&limit=10") + + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert len(data) == 2 + + +class TestGetOrder: + """Tests for GET /api/trading/orders/{order_id}.""" + + @patch('backend.api.trading.get_db') + def test_get_order_success(self, mock_get_db, client, mock_database): + """Test getting order by ID.""" + mock_db = Mock() + mock_session = Mock() + mock_db.get_session.return_value = mock_session + mock_get_db.return_value = mock_db + + mock_order = Mock(spec=Order) + mock_order.id = 1 + mock_order.symbol = "BTC/USD" + + mock_session.query.return_value.filter_by.return_value.first.return_value = mock_order + + response = client.get("/api/trading/orders/1") + + assert response.status_code == 200 + data = response.json() + assert data["id"] == 1 + + @patch('backend.api.trading.get_db') + def test_get_order_not_found(self, mock_get_db, client): + """Test getting non-existent order.""" + mock_db = Mock() + mock_session = Mock() + mock_db.get_session.return_value = mock_session + mock_get_db.return_value = mock_db + + mock_session.query.return_value.filter_by.return_value.first.return_value = None + + response = client.get("/api/trading/orders/999") + + assert response.status_code == 404 + assert "Order not found" in response.json()["detail"] + + +class TestCancelOrder: + """Tests for POST /api/trading/orders/{order_id}/cancel.""" + + @patch('backend.api.trading.get_trading_engine') + @patch('backend.api.trading.get_db') + def test_cancel_order_success(self, mock_get_db, mock_get_engine, client, mock_trading_engine): + """Test successful order cancellation.""" + mock_get_engine.return_value = mock_trading_engine + mock_trading_engine.order_manager.cancel_order.return_value = True + + mock_db = Mock() + mock_session = Mock() + mock_db.get_session.return_value = mock_session + mock_get_db.return_value = mock_db + + mock_order = Mock(spec=Order) + mock_order.id = 1 + mock_order.status = OrderStatus.OPEN + mock_session.query.return_value.filter_by.return_value.first.return_value = mock_order + + response = client.post("/api/trading/orders/1/cancel") + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "cancelled" + assert data["order_id"] == 1 + + @patch('backend.api.trading.get_db') + def test_cancel_order_not_found(self, mock_get_db, client): + """Test cancelling non-existent order.""" + mock_db = Mock() + mock_session = Mock() + mock_db.get_session.return_value = mock_session + mock_get_db.return_value = mock_db + + mock_session.query.return_value.filter_by.return_value.first.return_value = None + + response = client.post("/api/trading/orders/999/cancel") + + assert response.status_code == 404 + assert "Order not found" in response.json()["detail"] + + @patch('backend.api.trading.get_trading_engine') + @patch('backend.api.trading.get_db') + def test_cancel_order_already_filled(self, mock_get_db, mock_get_engine, client, mock_trading_engine): + """Test cancelling already filled order.""" + mock_get_engine.return_value = mock_trading_engine + + mock_db = Mock() + mock_session = Mock() + mock_db.get_session.return_value = mock_session + mock_get_db.return_value = mock_db + + mock_order = Mock(spec=Order) + mock_order.id = 1 + mock_order.status = OrderStatus.FILLED + mock_session.query.return_value.filter_by.return_value.first.return_value = mock_order + + response = client.post("/api/trading/orders/1/cancel") + + assert response.status_code == 400 + assert "cannot be cancelled" in response.json()["detail"] + + +class TestGetPositions: + """Tests for GET /api/trading/positions.""" + + @patch('backend.api.trading.get_paper_trading') + def test_get_positions_paper_trading(self, mock_get_paper, client): + """Test getting positions for paper trading.""" + mock_paper = Mock() + mock_position = Mock() + mock_position.symbol = "BTC/USD" + mock_position.quantity = Decimal("0.1") + mock_position.entry_price = Decimal("50000") + mock_position.current_price = Decimal("51000") + mock_position.unrealized_pnl = Decimal("100") + mock_position.realized_pnl = Decimal("0") + mock_paper.get_positions.return_value = [mock_position] + mock_get_paper.return_value = mock_paper + + response = client.get("/api/trading/positions?paper_trading=true") + + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + if len(data) > 0: + assert data[0]["symbol"] == "BTC/USD" + + +class TestGetBalance: + """Tests for GET /api/trading/balance.""" + + @patch('backend.api.trading.get_paper_trading') + def test_get_balance_paper_trading(self, mock_get_paper, client): + """Test getting balance for paper trading.""" + mock_paper = Mock() + mock_paper.get_balance.return_value = Decimal("1000") + mock_paper.get_performance.return_value = {"total_return": 0.1} + mock_get_paper.return_value = mock_paper + + response = client.get("/api/trading/balance?paper_trading=true") + + assert response.status_code == 200 + data = response.json() + assert "balance" in data + assert "performance" in data + assert data["balance"] == 1000.0 + diff --git a/tests/unit/backend/core/test_dependencies.py b/tests/unit/backend/core/test_dependencies.py new file mode 100644 index 00000000..3837fcc4 --- /dev/null +++ b/tests/unit/backend/core/test_dependencies.py @@ -0,0 +1,77 @@ +"""Tests for backend dependencies.""" + +import pytest +from unittest.mock import patch, Mock +from backend.core.dependencies import ( + get_database, get_trading_engine, get_portfolio_tracker, + get_strategy_registry, get_backtesting_engine, get_exchange_factory +) + + +class TestGetDatabase: + """Tests for get_database dependency.""" + + def test_get_database_singleton(self): + """Test that get_database returns same instance.""" + db1 = get_database() + db2 = get_database() + # Should be cached/same instance due to lru_cache + assert db1 is db2 + + +class TestGetTradingEngine: + """Tests for get_trading_engine dependency.""" + + @patch('backend.core.dependencies.get_trading_engine') + def test_get_trading_engine(self, mock_get_engine): + """Test getting trading engine.""" + mock_engine = Mock() + mock_get_engine.return_value = mock_engine + engine = get_trading_engine() + assert engine is not None + + +class TestGetPortfolioTracker: + """Tests for get_portfolio_tracker dependency.""" + + @patch('backend.core.dependencies.get_portfolio_tracker') + def test_get_portfolio_tracker(self, mock_get_tracker): + """Test getting portfolio tracker.""" + mock_tracker = Mock() + mock_get_tracker.return_value = mock_tracker + tracker = get_portfolio_tracker() + assert tracker is not None + + +class TestGetStrategyRegistry: + """Tests for get_strategy_registry dependency.""" + + @patch('backend.core.dependencies.get_strategy_registry') + def test_get_strategy_registry(self, mock_get_registry): + """Test getting strategy registry.""" + mock_registry = Mock() + mock_get_registry.return_value = mock_registry + registry = get_strategy_registry() + assert registry is not None + + +class TestGetBacktestingEngine: + """Tests for get_backtesting_engine dependency.""" + + @patch('backend.core.dependencies.get_backtesting_engine') + def test_get_backtesting_engine(self, mock_get_engine): + """Test getting backtesting engine.""" + mock_engine = Mock() + mock_get_engine.return_value = mock_engine + engine = get_backtesting_engine() + assert engine is not None + + +class TestGetExchangeFactory: + """Tests for get_exchange_factory dependency.""" + + def test_get_exchange_factory(self): + """Test getting exchange factory.""" + factory = get_exchange_factory() + assert factory is not None + diff --git a/tests/unit/backend/core/test_schemas.py b/tests/unit/backend/core/test_schemas.py new file mode 100644 index 00000000..959a12f6 --- /dev/null +++ b/tests/unit/backend/core/test_schemas.py @@ -0,0 +1,128 @@ +"""Tests for Pydantic schemas.""" + +import pytest +from decimal import Decimal +from datetime import datetime +from pydantic import ValidationError + +from backend.core.schemas import ( + OrderCreate, OrderResponse, OrderSide, OrderType, OrderStatus, + PositionResponse, PortfolioResponse, PortfolioHistoryResponse +) + + +class TestOrderCreate: + """Tests for OrderCreate schema.""" + + def test_order_create_valid(self): + """Test valid order creation.""" + order = OrderCreate( + exchange_id=1, + symbol="BTC/USD", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + quantity=Decimal("0.1"), + paper_trading=True + ) + assert order.exchange_id == 1 + assert order.symbol == "BTC/USD" + assert order.side == OrderSide.BUY + + def test_order_create_with_price(self): + """Test order creation with price.""" + order = OrderCreate( + exchange_id=1, + symbol="BTC/USD", + side=OrderSide.BUY, + order_type=OrderType.LIMIT, + quantity=Decimal("0.1"), + price=Decimal("50000"), + paper_trading=True + ) + assert order.price == Decimal("50000") + + def test_order_create_invalid_side(self): + """Test order creation with invalid side.""" + with pytest.raises(ValidationError): + OrderCreate( + exchange_id=1, + symbol="BTC/USD", + side="invalid", + order_type=OrderType.MARKET, + quantity=Decimal("0.1") + ) + + +class TestOrderResponse: + """Tests for OrderResponse schema.""" + + def test_order_response_from_dict(self): + """Test creating OrderResponse from dictionary.""" + order_data = { + "id": 1, + "exchange_id": 1, + "strategy_id": None, + "symbol": "BTC/USD", + "order_type": OrderType.MARKET, + "side": OrderSide.BUY, + "status": OrderStatus.FILLED, + "quantity": Decimal("0.1"), + "price": Decimal("50000"), + "filled_quantity": Decimal("0.1"), + "average_fill_price": Decimal("50000"), + "fee": Decimal("5"), + "paper_trading": True, + "created_at": datetime.now(), + "updated_at": datetime.now(), + "filled_at": datetime.now() + } + + order = OrderResponse(**order_data) + assert order.id == 1 + assert order.symbol == "BTC/USD" + + +class TestPositionResponse: + """Tests for PositionResponse schema.""" + + def test_position_response_valid(self): + """Test valid position response.""" + position = PositionResponse( + symbol="BTC/USD", + quantity=Decimal("0.1"), + entry_price=Decimal("50000"), + current_price=Decimal("51000"), + unrealized_pnl=Decimal("100"), + realized_pnl=Decimal("0") + ) + assert position.symbol == "BTC/USD" + assert position.unrealized_pnl == Decimal("100") + + +class TestPortfolioResponse: + """Tests for PortfolioResponse schema.""" + + def test_portfolio_response_valid(self): + """Test valid portfolio response.""" + portfolio = PortfolioResponse( + positions=[], + performance={"total_return": 0.1}, + timestamp="2025-01-01T00:00:00" + ) + assert portfolio.positions == [] + assert portfolio.performance["total_return"] == 0.1 + + +class TestPortfolioHistoryResponse: + """Tests for PortfolioHistoryResponse schema.""" + + def test_portfolio_history_response_valid(self): + """Test valid portfolio history response.""" + history = PortfolioHistoryResponse( + dates=["2025-01-01", "2025-01-02"], + values=[1000.0, 1100.0], + pnl=[0.0, 100.0] + ) + assert len(history.dates) == 2 + assert len(history.values) == 2 + diff --git a/tests/unit/backtesting/__init__.py b/tests/unit/backtesting/__init__.py new file mode 100644 index 00000000..033ac90f --- /dev/null +++ b/tests/unit/backtesting/__init__.py @@ -0,0 +1,2 @@ +"""Unit tests for backtesting.""" + diff --git a/tests/unit/backtesting/test_engine.py b/tests/unit/backtesting/test_engine.py new file mode 100644 index 00000000..cf5323b4 --- /dev/null +++ b/tests/unit/backtesting/test_engine.py @@ -0,0 +1,26 @@ +"""Tests for backtesting engine.""" + +import pytest +from datetime import datetime, timedelta +from src.backtesting.engine import get_backtest_engine, BacktestingEngine + + +class TestBacktestingEngine: + """Tests for BacktestingEngine.""" + + @pytest.fixture + def backtest_engine(self): + """Create backtesting engine instance.""" + return get_backtest_engine() + + def test_engine_initialization(self, backtest_engine): + """Test backtesting engine initialization.""" + assert backtest_engine is not None + + @pytest.mark.asyncio + async def test_run_backtest(self, backtest_engine): + """Test running a backtest.""" + # This would require a full strategy implementation + # Simplified test + assert backtest_engine is not None + diff --git a/tests/unit/backtesting/test_slippage.py b/tests/unit/backtesting/test_slippage.py new file mode 100644 index 00000000..c06e2999 --- /dev/null +++ b/tests/unit/backtesting/test_slippage.py @@ -0,0 +1,85 @@ +"""Tests for slippage model.""" + +import pytest +from decimal import Decimal +from src.backtesting.slippage import SlippageModel, FeeModel + + +class TestSlippageModel: + """Tests for SlippageModel.""" + + @pytest.fixture + def slippage_model(self): + """Create slippage model.""" + return SlippageModel(slippage_rate=0.001) + + def test_calculate_fill_price_market_buy(self, slippage_model): + """Test fill price calculation for market buy.""" + order_price = Decimal('50000.0') + market_price = Decimal('50000.0') + + fill_price = slippage_model.calculate_fill_price( + order_price, "buy", "market", market_price + ) + + assert fill_price > market_price # Buy orders pay more + + def test_calculate_fill_price_market_sell(self, slippage_model): + """Test fill price calculation for market sell.""" + order_price = Decimal('50000.0') + market_price = Decimal('50000.0') + + fill_price = slippage_model.calculate_fill_price( + order_price, "sell", "market", market_price + ) + + assert fill_price < market_price # Sell orders receive less + + def test_calculate_fill_price_limit(self, slippage_model): + """Test fill price for limit orders.""" + order_price = Decimal('49000.0') + market_price = Decimal('50000.0') + + fill_price = slippage_model.calculate_fill_price( + order_price, "buy", "limit", market_price + ) + + assert fill_price == order_price # Limit orders fill at order price + + +class TestFeeModel: + """Tests for FeeModel.""" + + @pytest.fixture + def fee_model(self): + """Create fee model.""" + return FeeModel(maker_fee=0.001, taker_fee=0.002) + + def test_calculate_fee_maker(self, fee_model): + """Test maker fee calculation.""" + fee = fee_model.calculate_fee( + quantity=Decimal('0.01'), + price=Decimal('50000.0'), + is_maker=True + ) + + assert fee > 0 + # Fee should be 0.1% of trade value + expected = Decimal('0.01') * Decimal('50000.0') * Decimal('0.001') + assert abs(float(fee - expected)) < 0.01 + + def test_calculate_fee_taker(self, fee_model): + """Test taker fee calculation.""" + fee = fee_model.calculate_fee( + quantity=Decimal('0.01'), + price=Decimal('50000.0'), + is_maker=False + ) + + assert fee > 0 + # Taker fee should be higher than maker + maker_fee = fee_model.calculate_fee( + Decimal('0.01'), Decimal('50000.0'), is_maker=True + ) + assert fee > maker_fee + diff --git a/tests/unit/core/__init__.py b/tests/unit/core/__init__.py new file mode 100644 index 00000000..880fb433 --- /dev/null +++ b/tests/unit/core/__init__.py @@ -0,0 +1 @@ +"""Test init file.""" diff --git a/tests/unit/core/test_config.py b/tests/unit/core/test_config.py new file mode 100644 index 00000000..8870e639 --- /dev/null +++ b/tests/unit/core/test_config.py @@ -0,0 +1,44 @@ +"""Tests for configuration system.""" + +import pytest +import os +import tempfile +from pathlib import Path +from unittest.mock import patch +from src.core.config import Config, get_config + + +class TestConfig: + """Tests for Config class.""" + + def test_config_initialization(self, tmp_path): + """Test config initialization.""" + config_file = tmp_path / "config.yaml" + config = Config(config_file=str(config_file)) + assert config is not None + assert config.config_dir is not None + assert config.data_dir is not None + + def test_config_get(self, tmp_path): + """Test config get method.""" + config_file = tmp_path / "config.yaml" + config = Config(config_file=str(config_file)) + # Test nested key access + value = config.get('paper_trading.default_capital') + assert value is not None + + def test_config_set(self, tmp_path): + """Test config set method.""" + config_file = tmp_path / "config.yaml" + config = Config(config_file=str(config_file)) + config.set('paper_trading.default_capital', 200.0) + value = config.get('paper_trading.default_capital') + assert value == 200.0 + + def test_config_defaults(self, tmp_path): + """Test default configuration values.""" + config_file = tmp_path / "config.yaml" + config = Config(config_file=str(config_file)) + assert config.get('paper_trading.default_capital') == 100.0 + assert config.get('database.type') == 'postgresql' + diff --git a/tests/unit/core/test_database.py b/tests/unit/core/test_database.py new file mode 100644 index 00000000..6e8f913c --- /dev/null +++ b/tests/unit/core/test_database.py @@ -0,0 +1,97 @@ +"""Tests for database system.""" + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from src.core.database import ( + get_database, Base, Exchange, Strategy, Trade, Position, Order +) + + +class TestDatabase: + """Tests for database operations.""" + + def test_database_initialization(self): + """Test database initialization.""" + db = get_database() + assert db is not None + assert db.engine is not None + + def test_table_creation(self, mock_database): + """Test table creation.""" + engine, Session = mock_database + # Verify tables exist + assert Base.metadata.tables.get('exchanges') is not None + assert Base.metadata.tables.get('strategies') is not None + assert Base.metadata.tables.get('trades') is not None + + def test_exchange_model(self, mock_database): + """Test Exchange model.""" + engine, Session = mock_database + session = Session() + + exchange = Exchange( + name="test_exchange", + api_key="encrypted_key", + secret_key="encrypted_secret", + api_permissions="read_only", + is_enabled=True + ) + session.add(exchange) + session.commit() + + retrieved = session.query(Exchange).filter_by(name="test_exchange").first() + assert retrieved is not None + assert retrieved.name == "test_exchange" + assert retrieved.api_permissions == "read_only" + + session.close() + + def test_strategy_model(self, mock_database): + """Test Strategy model.""" + engine, Session = mock_database + session = Session() + + strategy = Strategy( + name="test_strategy", + strategy_type="RSI", + parameters='{"rsi_period": 14}', + is_enabled=True, + is_paper_trading=True + ) + session.add(strategy) + session.commit() + + retrieved = session.query(Strategy).filter_by(name="test_strategy").first() + assert retrieved is not None + assert retrieved.strategy_type == "RSI" + + session.close() + + def test_trade_model(self, mock_database): + """Test Trade model.""" + engine, Session = mock_database + session = Session() + + trade = Trade( + order_id="test_order_123", + symbol="BTC/USD", + side="buy", + type="market", + price=50000.0, + amount=0.01, + cost=500.0, + fee=0.5, + status="filled", + is_paper_trade=True + ) + session.add(trade) + session.commit() + + retrieved = session.query(Trade).filter_by(order_id="test_order_123").first() + assert retrieved is not None + assert retrieved.symbol == "BTC/USD" + assert retrieved.status == "filled" + + session.close() + diff --git a/tests/unit/core/test_logger.py b/tests/unit/core/test_logger.py new file mode 100644 index 00000000..6405c332 --- /dev/null +++ b/tests/unit/core/test_logger.py @@ -0,0 +1,43 @@ +"""Tests for logging system.""" + +import pytest +import logging +from pathlib import Path +from unittest.mock import patch +from src.core.logger import setup_logging, get_logger + + +class TestLogger: + """Tests for logging system.""" + + def test_logger_setup(self, test_log_dir): + """Test logger setup.""" + with patch('src.core.logger.get_config') as mock_get_config: + mock_config = mock_get_config.return_value + mock_config.get.side_effect = lambda key, default=None: { + 'logging.dir': str(test_log_dir), + 'logging.retention_days': 30, + 'logging.level': 'INFO' + }.get(key, default) + + setup_logging() + logger = get_logger('test') + assert logger is not None + assert isinstance(logger, logging.Logger) + + def test_logger_get(self): + """Test getting logger instance.""" + logger = get_logger('test_module') + assert logger is not None + assert logger.name == 'test_module' + + def test_logger_levels(self): + """Test different log levels.""" + logger = get_logger('test') + # Should not raise exceptions + logger.debug("Debug message") + logger.info("Info message") + logger.warning("Warning message") + logger.error("Error message") + logger.critical("Critical message") + diff --git a/tests/unit/core/test_redis.py b/tests/unit/core/test_redis.py new file mode 100644 index 00000000..1a0c509a --- /dev/null +++ b/tests/unit/core/test_redis.py @@ -0,0 +1,92 @@ +"""Tests for Redis client wrapper.""" + +import pytest +from unittest.mock import Mock, patch, MagicMock, AsyncMock + + +class TestRedisClient: + """Tests for RedisClient class.""" + + @patch('src.core.redis.get_config') + def test_get_client_creates_connection(self, mock_config): + """Test that get_client creates a Redis connection.""" + # Setup mock config + mock_config.return_value.get.return_value = { + "host": "localhost", + "port": 6379, + "db": 0, + "password": None, + "socket_connect_timeout": 5 + } + + from src.core.redis import RedisClient + + client = RedisClient() + + # Should not have connected yet + assert client._client is None + + # Get client should trigger connection + with patch('src.core.redis.redis.ConnectionPool') as mock_pool: + with patch('src.core.redis.redis.Redis') as mock_redis: + redis_client = client.get_client() + + mock_pool.assert_called_once() + mock_redis.assert_called_once() + + @patch('src.core.redis.get_config') + def test_get_client_reuses_existing(self, mock_config): + """Test that get_client reuses existing connection.""" + mock_config.return_value.get.return_value = { + "host": "localhost", + "port": 6379, + "db": 0, + } + + from src.core.redis import RedisClient + + client = RedisClient() + + # Pre-set a mock client + mock_redis = Mock() + client._client = mock_redis + + # Should return existing + result = client.get_client() + assert result is mock_redis + + @patch('src.core.redis.get_config') + @pytest.mark.asyncio + async def test_close_connection(self, mock_config): + """Test closing Redis connection.""" + mock_config.return_value.get.return_value = {"host": "localhost"} + + from src.core.redis import RedisClient + + client = RedisClient() + mock_redis = AsyncMock() + client._client = mock_redis + + await client.close() + + mock_redis.aclose.assert_called_once() + + +class TestGetRedisClient: + """Tests for get_redis_client singleton.""" + + @patch('src.core.redis.get_config') + def test_returns_singleton(self, mock_config): + """Test that get_redis_client returns same instance.""" + mock_config.return_value.get.return_value = {"host": "localhost"} + + # Reset the global + import src.core.redis as redis_module + redis_module._redis_client = None + + from src.core.redis import get_redis_client + + client1 = get_redis_client() + client2 = get_redis_client() + + assert client1 is client2 diff --git a/tests/unit/data/__init__.py b/tests/unit/data/__init__.py new file mode 100644 index 00000000..880fb433 --- /dev/null +++ b/tests/unit/data/__init__.py @@ -0,0 +1 @@ +"""Test init file.""" diff --git a/tests/unit/data/providers/test_ccxt_provider.py b/tests/unit/data/providers/test_ccxt_provider.py new file mode 100644 index 00000000..2ffe80fe --- /dev/null +++ b/tests/unit/data/providers/test_ccxt_provider.py @@ -0,0 +1,139 @@ +"""Unit tests for CCXT pricing provider.""" + +import pytest +from unittest.mock import Mock, patch, MagicMock +from decimal import Decimal +from datetime import datetime + +from src.data.providers.ccxt_provider import CCXTProvider + + +@pytest.fixture +def mock_ccxt_exchange(): + """Create a mock CCXT exchange.""" + exchange = Mock() + exchange.markets = { + 'BTC/USDT': {}, + 'ETH/USDT': {}, + 'BTC/USD': {}, + } + exchange.id = 'kraken' + exchange.fetch_ticker = Mock(return_value={ + 'bid': 50000.0, + 'ask': 50001.0, + 'last': 50000.5, + 'high': 51000.0, + 'low': 49000.0, + 'quoteVolume': 1000000.0, + 'timestamp': 1609459200000, + }) + exchange.fetch_ohlcv = Mock(return_value=[ + [1609459200000, 50000, 51000, 49000, 50000, 1000], + ]) + exchange.load_markets = Mock(return_value=exchange.markets) + return exchange + + +@pytest.fixture +def provider(): + """Create a CCXT provider instance.""" + return CCXTProvider(exchange_name='kraken') + + +class TestCCXTProvider: + """Tests for CCXTProvider.""" + + def test_init(self, provider): + """Test provider initialization.""" + assert provider.name == "CCXT Provider" + assert not provider._connected + assert provider.exchange is None + + def test_name_property(self, provider): + """Test name property.""" + provider._selected_exchange_id = 'kraken' + assert provider.name == "CCXT-Kraken" + + @patch('src.data.providers.ccxt_provider.ccxt') + def test_connect_success(self, mock_ccxt, provider, mock_ccxt_exchange): + """Test successful connection.""" + mock_ccxt.kraken = Mock(return_value=mock_ccxt_exchange) + + result = provider.connect() + + assert result is True + assert provider._connected is True + assert provider.exchange == mock_ccxt_exchange + assert provider._selected_exchange_id == 'kraken' + + @patch('src.data.providers.ccxt_provider.ccxt') + def test_connect_failure(self, mock_ccxt, provider): + """Test connection failure.""" + mock_ccxt.kraken = Mock(side_effect=Exception("Connection failed")) + + result = provider.connect() + + assert result is False + assert not provider._connected + + @patch('src.data.providers.ccxt_provider.ccxt') + def test_get_ticker(self, mock_ccxt, provider, mock_ccxt_exchange): + """Test getting ticker data.""" + mock_ccxt.kraken = Mock(return_value=mock_ccxt_exchange) + provider.connect() + + ticker = provider.get_ticker('BTC/USDT') + + assert ticker['symbol'] == 'BTC/USDT' + assert isinstance(ticker['bid'], Decimal) + assert isinstance(ticker['last'], Decimal) + assert ticker['last'] > 0 + + @patch('src.data.providers.ccxt_provider.ccxt') + def test_get_ohlcv(self, mock_ccxt, provider, mock_ccxt_exchange): + """Test getting OHLCV data.""" + mock_ccxt.kraken = Mock(return_value=mock_ccxt_exchange) + provider.connect() + + ohlcv = provider.get_ohlcv('BTC/USDT', '1h', limit=10) + + assert len(ohlcv) > 0 + assert len(ohlcv[0]) == 6 # timestamp, open, high, low, close, volume + + @patch('src.data.providers.ccxt_provider.ccxt') + def test_subscribe_ticker(self, mock_ccxt, provider, mock_ccxt_exchange): + """Test subscribing to ticker updates.""" + mock_ccxt.kraken = Mock(return_value=mock_ccxt_exchange) + provider.connect() + + callback = Mock() + result = provider.subscribe_ticker('BTC/USDT', callback) + + assert result is True + assert 'ticker_BTC/USDT' in provider._subscribers + + def test_normalize_symbol(self, provider): + """Test symbol normalization.""" + # Test with exchange + with patch.object(provider, 'exchange') as mock_exchange: + mock_exchange.markets = {'BTC/USDT': {}} + normalized = provider.normalize_symbol('btc-usdt') + assert normalized == 'BTC/USDT' + + # Test without exchange + provider.exchange = None + normalized = provider.normalize_symbol('btc-usdt') + assert normalized == 'BTC/USDT' + + @patch('src.data.providers.ccxt_provider.ccxt') + def test_disconnect(self, mock_ccxt, provider, mock_ccxt_exchange): + """Test disconnection.""" + mock_ccxt.kraken = Mock(return_value=mock_ccxt_exchange) + provider.connect() + provider.subscribe_ticker('BTC/USDT', Mock()) + + provider.disconnect() + + assert not provider._connected + assert provider.exchange is None + assert len(provider._subscribers) == 0 diff --git a/tests/unit/data/providers/test_coingecko_provider.py b/tests/unit/data/providers/test_coingecko_provider.py new file mode 100644 index 00000000..d786e092 --- /dev/null +++ b/tests/unit/data/providers/test_coingecko_provider.py @@ -0,0 +1,113 @@ +"""Unit tests for CoinGecko pricing provider.""" + +import pytest +from unittest.mock import Mock, patch, MagicMock +from decimal import Decimal +import httpx + +from src.data.providers.coingecko_provider import CoinGeckoProvider + + +@pytest.fixture +def provider(): + """Create a CoinGecko provider instance.""" + return CoinGeckoProvider() + + +@pytest.fixture +def mock_response(): + """Create a mock HTTP response.""" + response = Mock() + response.status_code = 200 + response.json = Mock(return_value={ + 'bitcoin': { + 'usd': 50000.0, + 'usd_24h_change': 2.5, + 'usd_24h_vol': 1000000.0, + } + }) + return response + + +class TestCoinGeckoProvider: + """Tests for CoinGeckoProvider.""" + + def test_init(self, provider): + """Test provider initialization.""" + assert provider.name == "CoinGecko" + assert not provider.supports_websocket + assert not provider._connected + + @patch('src.data.providers.coingecko_provider.httpx.Client') + def test_connect_success(self, mock_client_class, provider, mock_response): + """Test successful connection.""" + mock_client = Mock() + mock_client.get = Mock(return_value=mock_response) + mock_client_class.return_value = mock_client + + result = provider.connect() + + assert result is True + assert provider._connected is True + + @patch('src.data.providers.coingecko_provider.httpx.Client') + def test_connect_failure(self, mock_client_class, provider): + """Test connection failure.""" + mock_client = Mock() + mock_client.get = Mock(side_effect=Exception("Connection failed")) + mock_client_class.return_value = mock_client + + result = provider.connect() + + assert result is False + assert not provider._connected + + def test_parse_symbol(self, provider): + """Test symbol parsing.""" + coin_id, currency = provider._parse_symbol('BTC/USD') + assert coin_id == 'bitcoin' + assert currency == 'usd' + + coin_id, currency = provider._parse_symbol('ETH/USDT') + assert coin_id == 'ethereum' + assert currency == 'usd' # USDT maps to USD + + @patch('src.data.providers.coingecko_provider.httpx.Client') + def test_get_ticker(self, mock_client_class, provider, mock_response): + """Test getting ticker data.""" + mock_client = Mock() + mock_client.get = Mock(return_value=mock_response) + mock_client_class.return_value = mock_client + provider.connect() + + ticker = provider.get_ticker('BTC/USD') + + assert ticker['symbol'] == 'BTC/USD' + assert isinstance(ticker['last'], Decimal) + assert ticker['last'] > 0 + assert 'timestamp' in ticker + + @patch('src.data.providers.coingecko_provider.httpx.Client') + def test_get_ohlcv(self, mock_client_class, provider): + """Test getting OHLCV data.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json = Mock(return_value=[ + [1609459200000, 50000, 51000, 49000, 50000], + ]) + + mock_client = Mock() + mock_client.get = Mock(return_value=mock_response) + mock_client_class.return_value = mock_client + provider.connect() + + ohlcv = provider.get_ohlcv('BTC/USD', '1h', limit=10) + + assert len(ohlcv) > 0 + # CoinGecko returns 5 elements, we add volume as 0 + assert len(ohlcv[0]) == 6 + + def test_normalize_symbol(self, provider): + """Test symbol normalization.""" + normalized = provider.normalize_symbol('btc-usdt') + assert normalized == 'BTC/USDT' diff --git a/tests/unit/data/test_cache_manager.py b/tests/unit/data/test_cache_manager.py new file mode 100644 index 00000000..95823bf5 --- /dev/null +++ b/tests/unit/data/test_cache_manager.py @@ -0,0 +1,120 @@ +"""Unit tests for cache manager.""" + +import pytest +import time +from src.data.cache_manager import CacheManager, CacheEntry + + +class TestCacheEntry: + """Tests for CacheEntry.""" + + def test_init(self): + """Test cache entry initialization.""" + entry = CacheEntry("test_data", 60.0) + assert entry.data == "test_data" + assert entry.expires_at > time.time() + assert entry.access_count == 0 + + def test_is_expired(self): + """Test expiration checking.""" + entry = CacheEntry("test_data", 0.01) # Very short TTL + assert not entry.is_expired() + time.sleep(0.02) + assert entry.is_expired() + + def test_touch(self): + """Test access tracking.""" + entry = CacheEntry("test_data", 60.0) + initial_count = entry.access_count + entry.touch() + assert entry.access_count == initial_count + 1 + + +class TestCacheManager: + """Tests for CacheManager.""" + + @pytest.fixture + def cache(self): + """Create a cache manager instance.""" + return CacheManager(default_ttl=1.0, max_size=10) + + def test_get_set(self, cache): + """Test basic get and set operations.""" + cache.set("key1", "value1") + assert cache.get("key1") == "value1" + + def test_get_missing(self, cache): + """Test getting non-existent key.""" + assert cache.get("missing") is None + + def test_expiration(self, cache): + """Test cache entry expiration.""" + cache.set("key1", "value1", ttl=0.1) + assert cache.get("key1") == "value1" + time.sleep(0.2) + assert cache.get("key1") is None + + def test_lru_eviction(self, cache): + """Test LRU eviction when max size reached.""" + # Fill cache to max size + for i in range(10): + cache.set(f"key{i}", f"value{i}") + + # Add one more - should evict oldest + cache.set("key10", "value10") + + # Oldest key should be evicted + assert cache.get("key0") is None + assert cache.get("key10") == "value10" + + def test_type_specific_ttl(self, cache): + """Test type-specific TTL.""" + cache.set("ticker1", {"price": 100}, cache_type='ticker') + cache.set("ohlcv1", [[1, 2, 3, 4, 5, 6]], cache_type='ohlcv') + + # Both should be cached + assert cache.get("ticker1") is not None + assert cache.get("ohlcv1") is not None + + def test_delete(self, cache): + """Test cache entry deletion.""" + cache.set("key1", "value1") + assert cache.get("key1") == "value1" + + cache.delete("key1") + assert cache.get("key1") is None + + def test_clear(self, cache): + """Test cache clearing.""" + cache.set("key1", "value1") + cache.set("key2", "value2") + + cache.clear() + + assert cache.get("key1") is None + assert cache.get("key2") is None + + def test_stats(self, cache): + """Test cache statistics.""" + cache.set("key1", "value1") + cache.get("key1") # Hit + cache.get("missing") # Miss + + stats = cache.get_stats() + + assert stats['hits'] >= 1 + assert stats['misses'] >= 1 + assert stats['size'] == 1 + assert 'hit_rate' in stats + + def test_invalidate_pattern(self, cache): + """Test pattern-based invalidation.""" + cache.set("ticker:BTC/USD", "value1") + cache.set("ticker:ETH/USD", "value2") + cache.set("ohlcv:BTC/USD", "value3") + + cache.invalidate_pattern("ticker:") + + assert cache.get("ticker:BTC/USD") is None + assert cache.get("ticker:ETH/USD") is None + assert cache.get("ohlcv:BTC/USD") is not None diff --git a/tests/unit/data/test_health_monitor.py b/tests/unit/data/test_health_monitor.py new file mode 100644 index 00000000..3f6d9f9b --- /dev/null +++ b/tests/unit/data/test_health_monitor.py @@ -0,0 +1,145 @@ +"""Unit tests for health monitor.""" + +import pytest +from datetime import datetime, timedelta + +from src.data.health_monitor import HealthMonitor, HealthMetrics, HealthStatus + + +class TestHealthMetrics: + """Tests for HealthMetrics.""" + + def test_record_success(self): + """Test recording successful operation.""" + metrics = HealthMetrics() + metrics.record_success(0.5) + + assert metrics.status == HealthStatus.HEALTHY + assert metrics.success_count == 1 + assert metrics.consecutive_failures == 0 + assert len(metrics.response_times) == 1 + + def test_record_failure(self): + """Test recording failed operation.""" + metrics = HealthMetrics() + metrics.record_failure() + + assert metrics.failure_count == 1 + assert metrics.consecutive_failures == 1 + + def test_circuit_breaker(self): + """Test circuit breaker opening.""" + metrics = HealthMetrics() + + # Record 5 failures + for _ in range(5): + metrics.record_failure() + + assert metrics.circuit_breaker_open is True + assert metrics.consecutive_failures == 5 + + def test_should_attempt(self): + """Test should_attempt logic.""" + metrics = HealthMetrics() + + # Should attempt if circuit breaker not open + assert metrics.should_attempt() is True + + # Open circuit breaker + for _ in range(5): + metrics.record_failure() + + # Should not attempt immediately + assert metrics.should_attempt(circuit_breaker_timeout=60) is False + + def test_get_avg_response_time(self): + """Test average response time calculation.""" + metrics = HealthMetrics() + metrics.response_times.extend([0.1, 0.2, 0.3]) + + avg = metrics.get_avg_response_time() + assert avg == 0.2 + + +class TestHealthMonitor: + """Tests for HealthMonitor.""" + + @pytest.fixture + def monitor(self): + """Create a health monitor instance.""" + return HealthMonitor() + + def test_record_success(self, monitor): + """Test recording success.""" + monitor.record_success("provider1", 0.5) + + metrics = monitor.get_metrics("provider1") + assert metrics is not None + assert metrics.status == HealthStatus.HEALTHY + assert metrics.success_count == 1 + + def test_record_failure(self, monitor): + """Test recording failure.""" + monitor.record_failure("provider1") + + metrics = monitor.get_metrics("provider1") + assert metrics is not None + assert metrics.failure_count == 1 + assert metrics.consecutive_failures == 1 + + def test_is_healthy(self, monitor): + """Test health checking.""" + # No metrics yet - assume healthy + assert monitor.is_healthy("provider1") is True + + # Record success + monitor.record_success("provider1", 0.5) + assert monitor.is_healthy("provider1") is True + + # Record many failures + for _ in range(10): + monitor.record_failure("provider1") + + assert monitor.is_healthy("provider1") is False + + def test_get_health_status(self, monitor): + """Test getting health status.""" + monitor.record_success("provider1", 0.5) + status = monitor.get_health_status("provider1") + assert status == HealthStatus.HEALTHY + + def test_select_healthiest(self, monitor): + """Test selecting healthiest provider.""" + # Make provider1 healthy + monitor.record_success("provider1", 0.1) + monitor.record_success("provider1", 0.2) + + # Make provider2 unhealthy + monitor.record_failure("provider2") + monitor.record_failure("provider2") + monitor.record_failure("provider2") + + healthiest = monitor.select_healthiest(["provider1", "provider2"]) + assert healthiest == "provider1" + + def test_reset_circuit_breaker(self, monitor): + """Test resetting circuit breaker.""" + # Open circuit breaker + for _ in range(5): + monitor.record_failure("provider1") + + assert monitor.get_metrics("provider1").circuit_breaker_open is True + + monitor.reset_circuit_breaker("provider1") + + metrics = monitor.get_metrics("provider1") + assert metrics.circuit_breaker_open is False + assert metrics.consecutive_failures == 0 + + def test_reset_metrics(self, monitor): + """Test resetting metrics.""" + monitor.record_success("provider1", 0.5) + assert monitor.get_metrics("provider1") is not None + + monitor.reset_metrics("provider1") + assert monitor.get_metrics("provider1") is None diff --git a/tests/unit/data/test_indicators.py b/tests/unit/data/test_indicators.py new file mode 100644 index 00000000..8c1d0d0c --- /dev/null +++ b/tests/unit/data/test_indicators.py @@ -0,0 +1,68 @@ +"""Tests for technical indicators.""" + +import pytest +import pandas as pd +import numpy as np +from src.data.indicators import get_indicators, TechnicalIndicators + + +class TestTechnicalIndicators: + """Tests for TechnicalIndicators.""" + + @pytest.fixture + def indicators(self): + """Create indicators instance.""" + return get_indicators() + + @pytest.fixture + def sample_data(self): + """Create sample price data.""" + dates = pd.date_range(start='2025-01-01', periods=100, freq='1H') + return pd.DataFrame({ + 'close': [100 + i * 0.1 + np.random.randn() * 0.5 for i in range(100)], + 'high': [101 + i * 0.1 for i in range(100)], + 'low': [99 + i * 0.1 for i in range(100)], + 'open': [100 + i * 0.1 for i in range(100)], + 'volume': [1000.0] * 100 + }) + + def test_sma(self, indicators, sample_data): + """Test Simple Moving Average.""" + sma = indicators.sma(sample_data['close'], period=20) + assert len(sma) == len(sample_data) + assert not sma.isna().all() # Should have some valid values + + def test_ema(self, indicators, sample_data): + """Test Exponential Moving Average.""" + ema = indicators.ema(sample_data['close'], period=20) + assert len(ema) == len(sample_data) + + def test_rsi(self, indicators, sample_data): + """Test Relative Strength Index.""" + rsi = indicators.rsi(sample_data['close'], period=14) + assert len(rsi) == len(sample_data) + # RSI should be between 0 and 100 + valid_rsi = rsi.dropna() + if len(valid_rsi) > 0: + assert (valid_rsi >= 0).all() + assert (valid_rsi <= 100).all() + + def test_macd(self, indicators, sample_data): + """Test MACD.""" + macd_result = indicators.macd(sample_data['close'], fast=12, slow=26, signal=9) + assert 'macd' in macd_result + assert 'signal' in macd_result + assert 'histogram' in macd_result + + def test_bollinger_bands(self, indicators, sample_data): + """Test Bollinger Bands.""" + bb = indicators.bollinger_bands(sample_data['close'], period=20, std_dev=2) + assert 'upper' in bb + assert 'middle' in bb + assert 'lower' in bb + # Upper should be above middle, middle above lower + valid_data = bb.dropna() + if len(valid_data) > 0: + assert (valid_data['upper'] >= valid_data['middle']).all() + assert (valid_data['middle'] >= valid_data['lower']).all() + diff --git a/tests/unit/data/test_indicators_divergence.py b/tests/unit/data/test_indicators_divergence.py new file mode 100644 index 00000000..25e9e173 --- /dev/null +++ b/tests/unit/data/test_indicators_divergence.py @@ -0,0 +1,80 @@ +"""Tests for divergence detection in indicators.""" + +import pytest +import pandas as pd +import numpy as np +from src.data.indicators import get_indicators + + +class TestDivergenceDetection: + """Tests for divergence detection.""" + + @pytest.fixture + def indicators(self): + """Create indicators instance.""" + return get_indicators() + + @pytest.fixture + def sample_data(self): + """Create sample price data with clear trend.""" + dates = pd.date_range(start='2025-01-01', periods=100, freq='1H') + # Create price data with trend + prices = [100 + i * 0.1 + np.random.randn() * 0.5 for i in range(100)] + return pd.Series(prices, index=dates) + + def test_detect_divergence_insufficient_data(self, indicators): + """Test divergence detection with insufficient data.""" + prices = pd.Series([100, 101, 102]) + indicator = pd.Series([50, 51, 52]) + + result = indicators.detect_divergence(prices, indicator, lookback=20) + + assert result['type'] is None + assert result['confidence'] == 0.0 + + def test_detect_divergence_structure(self, indicators, sample_data): + """Test divergence detection returns correct structure.""" + # Create indicator data + indicator = pd.Series([50 + i * 0.1 for i in range(100)], index=sample_data.index) + + result = indicators.detect_divergence(sample_data, indicator, lookback=20) + + # Check structure + assert 'type' in result + assert 'confidence' in result + assert 'price_swing_high' in result + assert 'price_swing_low' in result + assert 'indicator_swing_high' in result + assert 'indicator_swing_low' in result + + # Type should be None, 'bullish', or 'bearish' + assert result['type'] in [None, 'bullish', 'bearish'] + + # Confidence should be 0.0 to 1.0 + assert 0.0 <= result['confidence'] <= 1.0 + + def test_detect_divergence_with_trend(self, indicators): + """Test divergence detection with clear trend data.""" + # Create price making lower lows + prices = pd.Series([100, 95, 90, 85, 80]) + + # Create indicator making higher lows (bullish divergence) + indicator = pd.Series([30, 32, 34, 36, 38]) + + # Need more data for lookback + prices_long = pd.concat([pd.Series([110] * 30), prices]) + indicator_long = pd.concat([pd.Series([25] * 30), indicator]) + + result = indicators.detect_divergence( + prices_long, + indicator_long, + lookback=5, + min_swings=2 + ) + + # Should detect bullish divergence (price down, indicator up) + # Note: This may not always detect due to swing detection logic + assert result is not None + assert 'type' in result + assert 'confidence' in result + diff --git a/tests/unit/data/test_pricing_service.py b/tests/unit/data/test_pricing_service.py new file mode 100644 index 00000000..fde51928 --- /dev/null +++ b/tests/unit/data/test_pricing_service.py @@ -0,0 +1,135 @@ +"""Unit tests for pricing service.""" + +import pytest +from unittest.mock import Mock, patch, MagicMock +from decimal import Decimal + +from src.data.pricing_service import PricingService, get_pricing_service +from src.data.providers.base_provider import BasePricingProvider + + +class MockProvider(BasePricingProvider): + """Mock provider for testing.""" + + @property + def name(self) -> str: + return "MockProvider" + + @property + def supports_websocket(self) -> bool: + return False + + def connect(self) -> bool: + self._connected = True + return True + + def disconnect(self): + self._connected = False + + def get_ticker(self, symbol: str): + return { + 'symbol': symbol, + 'bid': Decimal('50000'), + 'ask': Decimal('50001'), + 'last': Decimal('50000.5'), + 'high': Decimal('51000'), + 'low': Decimal('49000'), + 'volume': Decimal('1000000'), + 'timestamp': 1609459200000, + } + + def get_ohlcv(self, symbol: str, timeframe: str = '1h', since=None, limit: int = 100): + return [[1609459200000, 50000, 51000, 49000, 50000, 1000]] + + def subscribe_ticker(self, symbol: str, callback) -> bool: + if symbol not in self._subscribers: + self._subscribers[symbol] = [] + self._subscribers[symbol].append(callback) + return True + + +@pytest.fixture +def mock_config(): + """Create a mock configuration.""" + config = Mock() + config.get = Mock(side_effect=lambda key, default=None: { + "data_providers.primary": [ + {"name": "mock", "enabled": True, "priority": 1} + ], + "data_providers.fallback": {"enabled": True, "api_key": ""}, + "data_providers.caching.ticker_ttl": 2, + "data_providers.caching.ohlcv_ttl": 60, + "data_providers.caching.max_cache_size": 1000, + }.get(key, default)) + return config + + +class TestPricingService: + """Tests for PricingService.""" + + @patch('src.data.pricing_service.get_config') + @patch('src.data.providers.ccxt_provider.CCXTProvider') + @patch('src.data.providers.coingecko_provider.CoinGeckoProvider') + def test_init(self, mock_coingecko, mock_ccxt, mock_get_config, mock_config): + """Test service initialization.""" + mock_get_config.return_value = mock_config + mock_ccxt_instance = MockProvider() + mock_ccxt.return_value = mock_ccxt_instance + mock_coingecko_instance = MockProvider() + mock_coingecko.return_value = mock_coingecko_instance + + service = PricingService() + + assert service.cache is not None + assert service.health_monitor is not None + + @patch('src.data.pricing_service.get_config') + @patch('src.data.providers.ccxt_provider.CCXTProvider') + def test_get_ticker(self, mock_ccxt, mock_get_config, mock_config): + """Test getting ticker data.""" + mock_get_config.return_value = mock_config + mock_provider = MockProvider() + mock_ccxt.return_value = mock_provider + + service = PricingService() + service._providers["MockProvider"] = mock_provider + service._active_provider = "MockProvider" + + ticker = service.get_ticker("BTC/USD") + + assert ticker['symbol'] == "BTC/USD" + assert isinstance(ticker['last'], Decimal) + + @patch('src.data.pricing_service.get_config') + @patch('src.data.providers.ccxt_provider.CCXTProvider') + def test_get_ohlcv(self, mock_ccxt, mock_get_config, mock_config): + """Test getting OHLCV data.""" + mock_get_config.return_value = mock_config + mock_provider = MockProvider() + mock_ccxt.return_value = mock_provider + + service = PricingService() + service._providers["MockProvider"] = mock_provider + service._active_provider = "MockProvider" + + ohlcv = service.get_ohlcv("BTC/USD", "1h", limit=10) + + assert len(ohlcv) > 0 + assert len(ohlcv[0]) == 6 + + @patch('src.data.pricing_service.get_config') + @patch('src.data.providers.ccxt_provider.CCXTProvider') + def test_subscribe_ticker(self, mock_ccxt, mock_get_config, mock_config): + """Test subscribing to ticker updates.""" + mock_get_config.return_value = mock_config + mock_provider = MockProvider() + mock_ccxt.return_value = mock_provider + + service = PricingService() + service._providers["MockProvider"] = mock_provider + service._active_provider = "MockProvider" + + callback = Mock() + result = service.subscribe_ticker("BTC/USD", callback) + + assert result is True diff --git a/tests/unit/data/test_redis_cache.py b/tests/unit/data/test_redis_cache.py new file mode 100644 index 00000000..5bd86fcd --- /dev/null +++ b/tests/unit/data/test_redis_cache.py @@ -0,0 +1,118 @@ +"""Tests for Redis cache.""" + +import pytest +from unittest.mock import Mock, patch, AsyncMock + + +class TestRedisCache: + """Tests for RedisCache class.""" + + @patch('src.data.redis_cache.get_redis_client') + @pytest.mark.asyncio + async def test_get_ticker_cache_hit(self, mock_get_client): + """Test getting cached ticker data.""" + mock_redis = Mock() + mock_client = AsyncMock() + mock_client.get.return_value = '{"price": 45000.0, "symbol": "BTC/USD"}' + mock_redis.get_client.return_value = mock_client + mock_get_client.return_value = mock_redis + + from src.data.redis_cache import RedisCache + cache = RedisCache() + + result = await cache.get_ticker("BTC/USD") + + assert result is not None + assert result["price"] == 45000.0 + mock_client.get.assert_called_once() + + @patch('src.data.redis_cache.get_redis_client') + @pytest.mark.asyncio + async def test_get_ticker_cache_miss(self, mock_get_client): + """Test ticker cache miss.""" + mock_redis = Mock() + mock_client = AsyncMock() + mock_client.get.return_value = None + mock_redis.get_client.return_value = mock_client + mock_get_client.return_value = mock_redis + + from src.data.redis_cache import RedisCache + cache = RedisCache() + + result = await cache.get_ticker("BTC/USD") + + assert result is None + + @patch('src.data.redis_cache.get_redis_client') + @pytest.mark.asyncio + async def test_set_ticker(self, mock_get_client): + """Test setting ticker cache.""" + mock_redis = Mock() + mock_client = AsyncMock() + mock_redis.get_client.return_value = mock_client + mock_get_client.return_value = mock_redis + + from src.data.redis_cache import RedisCache + cache = RedisCache() + + result = await cache.set_ticker("BTC/USD", {"price": 45000.0}) + + assert result is True + mock_client.setex.assert_called_once() + + @patch('src.data.redis_cache.get_redis_client') + @pytest.mark.asyncio + async def test_get_ohlcv(self, mock_get_client): + """Test getting cached OHLCV data.""" + mock_redis = Mock() + mock_client = AsyncMock() + mock_client.get.return_value = '[[1700000000, 45000, 45500, 44500, 45200, 1000]]' + mock_redis.get_client.return_value = mock_client + mock_get_client.return_value = mock_redis + + from src.data.redis_cache import RedisCache + cache = RedisCache() + + result = await cache.get_ohlcv("BTC/USD", "1h", 100) + + assert result is not None + assert len(result) == 1 + assert result[0][0] == 1700000000 + + @patch('src.data.redis_cache.get_redis_client') + @pytest.mark.asyncio + async def test_set_ohlcv(self, mock_get_client): + """Test setting OHLCV cache.""" + mock_redis = Mock() + mock_client = AsyncMock() + mock_redis.get_client.return_value = mock_client + mock_get_client.return_value = mock_redis + + from src.data.redis_cache import RedisCache + cache = RedisCache() + + ohlcv_data = [[1700000000, 45000, 45500, 44500, 45200, 1000]] + result = await cache.set_ohlcv("BTC/USD", "1h", ohlcv_data) + + assert result is True + mock_client.setex.assert_called_once() + + +class TestGetRedisCache: + """Tests for get_redis_cache singleton.""" + + @patch('src.data.redis_cache.get_redis_client') + def test_returns_singleton(self, mock_get_client): + """Test that get_redis_cache returns same instance.""" + mock_get_client.return_value = Mock() + + # Reset the global + import src.data.redis_cache as cache_module + cache_module._redis_cache = None + + from src.data.redis_cache import get_redis_cache + + cache1 = get_redis_cache() + cache2 = get_redis_cache() + + assert cache1 is cache2 diff --git a/tests/unit/exchanges/__init__.py b/tests/unit/exchanges/__init__.py new file mode 100644 index 00000000..87552654 --- /dev/null +++ b/tests/unit/exchanges/__init__.py @@ -0,0 +1,2 @@ +"""Unit tests for exchange adapters.""" + diff --git a/tests/unit/exchanges/test_base.py b/tests/unit/exchanges/test_base.py new file mode 100644 index 00000000..48a15f93 --- /dev/null +++ b/tests/unit/exchanges/test_base.py @@ -0,0 +1,24 @@ +"""Tests for base exchange adapter.""" + +import pytest +from unittest.mock import Mock, AsyncMock +from src.exchanges.base import BaseExchange + + +class TestBaseExchange: + """Tests for BaseExchange abstract class.""" + + def test_base_exchange_init(self): + """Test base exchange initialization.""" + # Can't instantiate abstract class, test through concrete implementation + from src.exchanges.coinbase import CoinbaseExchange + + exchange = CoinbaseExchange( + name="test", + api_key="test_key", + secret_key="test_secret" + ) + assert exchange.name == "test" + assert exchange.api_key == "test_key" + assert not exchange.is_connected + diff --git a/tests/unit/exchanges/test_coinbase.py b/tests/unit/exchanges/test_coinbase.py new file mode 100644 index 00000000..96a75b30 --- /dev/null +++ b/tests/unit/exchanges/test_coinbase.py @@ -0,0 +1,56 @@ +"""Tests for Coinbase exchange adapter.""" + +import pytest +from unittest.mock import Mock, AsyncMock, patch +from src.exchanges.coinbase import CoinbaseExchange + + +class TestCoinbaseExchange: + """Tests for CoinbaseExchange.""" + + @pytest.fixture + def exchange(self): + """Create Coinbase exchange instance.""" + return CoinbaseExchange( + name="test_coinbase", + api_key="test_key", + secret_key="test_secret", + permissions="read_only" + ) + + @pytest.mark.asyncio + async def test_connect(self, exchange): + """Test connection to Coinbase.""" + with patch.object(exchange.exchange, 'load_markets', new_callable=AsyncMock): + await exchange.connect() + assert exchange.is_connected + + @pytest.mark.asyncio + async def test_fetch_balance(self, exchange): + """Test fetching balance.""" + mock_balance = {'USD': {'free': 1000.0, 'used': 0.0, 'total': 1000.0}} + exchange.exchange.fetch_balance = AsyncMock(return_value=mock_balance) + exchange.is_connected = True + + balance = await exchange.fetch_balance() + assert balance == mock_balance + + @pytest.mark.asyncio + async def test_place_order_readonly(self, exchange): + """Test placing order with read-only permissions.""" + exchange.permissions = "read_only" + exchange.is_connected = True + + with pytest.raises(PermissionError): + await exchange.place_order("BTC/USD", "buy", "market", 0.01) + + @pytest.mark.asyncio + async def test_fetch_ohlcv(self, exchange): + """Test fetching OHLCV data.""" + mock_ohlcv = [[1609459200000, 29000, 29500, 28800, 29300, 1000]] + exchange.exchange.fetch_ohlcv = AsyncMock(return_value=mock_ohlcv) + exchange.is_connected = True + + ohlcv = await exchange.fetch_ohlcv("BTC/USD", "1h") + assert ohlcv == mock_ohlcv + diff --git a/tests/unit/exchanges/test_factory.py b/tests/unit/exchanges/test_factory.py new file mode 100644 index 00000000..68372ebe --- /dev/null +++ b/tests/unit/exchanges/test_factory.py @@ -0,0 +1,40 @@ +"""Tests for exchange factory.""" + +import pytest +from unittest.mock import patch, Mock +from src.exchanges.factory import ExchangeFactory +from src.exchanges.coinbase import CoinbaseExchange + + +class TestExchangeFactory: + """Tests for ExchangeFactory.""" + + def test_register_exchange(self): + """Test exchange registration.""" + ExchangeFactory.register_exchange("test_exchange", CoinbaseExchange) + assert "test_exchange" in ExchangeFactory.list_available() + + def test_get_exchange(self): + """Test getting exchange instance.""" + with patch('src.exchanges.factory.get_key_manager') as mock_km: + mock_km.return_value.get_exchange_keys.return_value = { + 'api_key': 'test_key', + 'secret_key': 'test_secret', + 'permissions': 'read_only' + } + + exchange = ExchangeFactory.get_exchange("coinbase") + assert exchange is not None + assert isinstance(exchange, CoinbaseExchange) + + def test_get_nonexistent_exchange(self): + """Test getting non-existent exchange.""" + with pytest.raises(ValueError, match="not registered"): + ExchangeFactory.get_exchange("nonexistent") + + def test_list_available(self): + """Test listing available exchanges.""" + exchanges = ExchangeFactory.list_available() + assert isinstance(exchanges, list) + assert "coinbase" in exchanges + diff --git a/tests/unit/exchanges/test_websocket.py b/tests/unit/exchanges/test_websocket.py new file mode 100644 index 00000000..2c40eed2 --- /dev/null +++ b/tests/unit/exchanges/test_websocket.py @@ -0,0 +1,44 @@ +"""Tests for WebSocket functionality.""" + +import pytest +from unittest.mock import Mock, patch +from src.exchanges.coinbase import CoinbaseAdapter + + +def test_subscribe_ticker(): + """Test ticker subscription.""" + adapter = CoinbaseAdapter("test_key", "test_secret", sandbox=True) + callback = Mock() + + adapter.subscribe_ticker("BTC/USD", callback) + + assert f'ticker_BTC/USD' in adapter._ws_callbacks + assert adapter._ws_callbacks[f'ticker_BTC/USD'] == callback + + +def test_subscribe_orderbook(): + """Test orderbook subscription.""" + adapter = CoinbaseAdapter("test_key", "test_secret", sandbox=True) + callback = Mock() + + adapter.subscribe_orderbook("BTC/USD", callback) + + assert f'orderbook_BTC/USD' in adapter._ws_callbacks + + +def test_subscribe_trades(): + """Test trades subscription.""" + adapter = CoinbaseAdapter("test_key", "test_secret", sandbox=True) + callback = Mock() + + adapter.subscribe_trades("BTC/USD", callback) + + assert f'trades_BTC/USD' in adapter._ws_callbacks + + +def test_normalize_symbol(): + """Test symbol normalization.""" + adapter = CoinbaseAdapter("test_key", "test_secret", sandbox=True) + + assert adapter.normalize_symbol("BTC/USD") == "BTC-USD" + assert adapter.normalize_symbol("ETH/USDT") == "ETH-USDT" diff --git a/tests/unit/optimization/__init__.py b/tests/unit/optimization/__init__.py new file mode 100644 index 00000000..fec3eafd --- /dev/null +++ b/tests/unit/optimization/__init__.py @@ -0,0 +1,2 @@ +"""Unit tests for optimization.""" + diff --git a/tests/unit/optimization/test_grid_search.py b/tests/unit/optimization/test_grid_search.py new file mode 100644 index 00000000..63b4e23a --- /dev/null +++ b/tests/unit/optimization/test_grid_search.py @@ -0,0 +1,44 @@ +"""Tests for grid search optimization.""" + +import pytest +from src.optimization.grid_search import GridSearchOptimizer + + +class TestGridSearchOptimizer: + """Tests for GridSearchOptimizer.""" + + @pytest.fixture + def optimizer(self): + """Create grid search optimizer.""" + return GridSearchOptimizer() + + def test_optimize_maximize(self, optimizer): + """Test optimization with maximize.""" + param_grid = { + 'param1': [1, 2, 3], + 'param2': [10, 20] + } + + def objective(params): + return params['param1'] * params['param2'] + + result = optimizer.optimize(param_grid, objective, maximize=True) + + assert result['best_params'] is not None + assert result['best_score'] is not None + assert result['best_score'] > 0 + + def test_optimize_minimize(self, optimizer): + """Test optimization with minimize.""" + param_grid = { + 'param1': [1, 2, 3] + } + + def objective(params): + return params['param1'] * 10 + + result = optimizer.optimize(param_grid, objective, maximize=False) + + assert result['best_params'] is not None + assert result['best_score'] is not None + diff --git a/tests/unit/portfolio/__init__.py b/tests/unit/portfolio/__init__.py new file mode 100644 index 00000000..64c250e3 --- /dev/null +++ b/tests/unit/portfolio/__init__.py @@ -0,0 +1,2 @@ +"""Unit tests for portfolio management.""" + diff --git a/tests/unit/portfolio/test_analytics.py b/tests/unit/portfolio/test_analytics.py new file mode 100644 index 00000000..d3ad7288 --- /dev/null +++ b/tests/unit/portfolio/test_analytics.py @@ -0,0 +1,35 @@ +"""Tests for portfolio analytics.""" + +import pytest +import pandas as pd +import numpy as np +from src.portfolio.analytics import get_portfolio_analytics, PortfolioAnalytics + + +class TestPortfolioAnalytics: + """Tests for PortfolioAnalytics.""" + + @pytest.fixture + def analytics(self): + """Create portfolio analytics instance.""" + return get_portfolio_analytics() + + def test_calculate_sharpe_ratio(self, analytics): + """Test Sharpe ratio calculation.""" + returns = pd.Series([0.01, -0.005, 0.02, -0.01, 0.015]) + sharpe = analytics.calculate_sharpe_ratio(returns, risk_free_rate=0.0) + assert isinstance(sharpe, float) + + def test_calculate_sortino_ratio(self, analytics): + """Test Sortino ratio calculation.""" + returns = pd.Series([0.01, -0.005, 0.02, -0.01, 0.015]) + sortino = analytics.calculate_sortino_ratio(returns, risk_free_rate=0.0) + assert isinstance(sortino, float) + + def test_calculate_max_drawdown(self, analytics): + """Test max drawdown calculation.""" + equity_curve = pd.Series([10000, 10500, 10200, 11000, 10800]) + drawdown = analytics.calculate_max_drawdown(equity_curve) + assert isinstance(drawdown, float) + assert 0 <= drawdown <= 1 + diff --git a/tests/unit/portfolio/test_tracker.py b/tests/unit/portfolio/test_tracker.py new file mode 100644 index 00000000..d5f7afc6 --- /dev/null +++ b/tests/unit/portfolio/test_tracker.py @@ -0,0 +1,29 @@ +"""Tests for portfolio tracker.""" + +import pytest +from src.portfolio.tracker import get_portfolio_tracker, PortfolioTracker + + +class TestPortfolioTracker: + """Tests for PortfolioTracker.""" + + @pytest.fixture + def tracker(self): + """Create portfolio tracker instance.""" + return get_portfolio_tracker() + + @pytest.mark.asyncio + async def test_get_current_portfolio(self, tracker): + """Test getting current portfolio.""" + portfolio = await tracker.get_current_portfolio(paper_trading=True) + assert portfolio is not None + assert "positions" in portfolio + assert "performance" in portfolio + + @pytest.mark.asyncio + async def test_update_positions_prices(self, tracker): + """Test updating position prices.""" + prices = {"BTC/USD": 50000.0} + await tracker.update_positions_prices(prices, paper_trading=True) + # Should not raise exception + diff --git a/tests/unit/reporting/__init__.py b/tests/unit/reporting/__init__.py new file mode 100644 index 00000000..cbc40db2 --- /dev/null +++ b/tests/unit/reporting/__init__.py @@ -0,0 +1,2 @@ +"""Unit tests for reporting.""" + diff --git a/tests/unit/reporting/test_csv_exporter.py b/tests/unit/reporting/test_csv_exporter.py new file mode 100644 index 00000000..8bb2d257 --- /dev/null +++ b/tests/unit/reporting/test_csv_exporter.py @@ -0,0 +1,35 @@ +"""Tests for CSV exporter.""" + +import pytest +from pathlib import Path +from tempfile import TemporaryDirectory +from src.reporting.csv_exporter import get_csv_exporter, CSVExporter + + +class TestCSVExporter: + """Tests for CSVExporter.""" + + @pytest.fixture + def exporter(self): + """Create CSV exporter instance.""" + return get_csv_exporter() + + def test_export_trades(self, exporter, mock_database): + """Test exporting trades.""" + engine, Session = mock_database + + with TemporaryDirectory() as tmpdir: + filepath = Path(tmpdir) / "trades.csv" + + # Export (may be empty if no trades) + result = exporter.export_trades(filepath, paper_trading=True) + assert isinstance(result, bool) + + def test_export_portfolio(self, exporter): + """Test exporting portfolio.""" + with TemporaryDirectory() as tmpdir: + filepath = Path(tmpdir) / "portfolio.csv" + + result = exporter.export_portfolio(filepath) + assert isinstance(result, bool) + diff --git a/tests/unit/resilience/__init__.py b/tests/unit/resilience/__init__.py new file mode 100644 index 00000000..009bdbf2 --- /dev/null +++ b/tests/unit/resilience/__init__.py @@ -0,0 +1,2 @@ +"""Unit tests for resilience.""" + diff --git a/tests/unit/resilience/test_state_manager.py b/tests/unit/resilience/test_state_manager.py new file mode 100644 index 00000000..cca7e643 --- /dev/null +++ b/tests/unit/resilience/test_state_manager.py @@ -0,0 +1,31 @@ +"""Tests for state manager.""" + +import pytest +from src.resilience.state_manager import get_state_manager, StateManager + + +class TestStateManager: + """Tests for StateManager.""" + + @pytest.fixture + def state_manager(self): + """Create state manager instance.""" + return get_state_manager() + + @pytest.mark.asyncio + async def test_save_state(self, state_manager): + """Test saving state.""" + result = await state_manager.save_state("test_key", {"data": "value"}) + assert result is True + + @pytest.mark.asyncio + async def test_load_state(self, state_manager): + """Test loading state.""" + # Save first + await state_manager.save_state("test_key", {"data": "value"}) + + # Load + state = await state_manager.load_state("test_key") + assert state is not None + assert state.get("data") == "value" + diff --git a/tests/unit/risk/__init__.py b/tests/unit/risk/__init__.py new file mode 100644 index 00000000..4c21f96a --- /dev/null +++ b/tests/unit/risk/__init__.py @@ -0,0 +1,2 @@ +"""Unit tests for risk management.""" + diff --git a/tests/unit/risk/test_manager.py b/tests/unit/risk/test_manager.py new file mode 100644 index 00000000..d9ad1a87 --- /dev/null +++ b/tests/unit/risk/test_manager.py @@ -0,0 +1,55 @@ +"""Tests for risk manager.""" + +import pytest +from src.risk.manager import get_risk_manager, RiskManager + + +class TestRiskManager: + """Tests for RiskManager.""" + + @pytest.fixture + def risk_manager(self): + """Create risk manager instance.""" + return get_risk_manager() + + @pytest.mark.asyncio + async def test_check_trade_risk(self, risk_manager): + """Test trade risk checking.""" + # Test with valid trade + result = await risk_manager.check_trade_risk( + exchange_id=1, + strategy_id=1, + symbol="BTC/USD", + side="buy", + amount=0.01, + price=50000.0, + current_portfolio_value=10000.0 + ) + + assert isinstance(result, bool) + + @pytest.mark.asyncio + async def test_check_max_drawdown(self, risk_manager): + """Test max drawdown check.""" + result = await risk_manager.check_max_drawdown( + current_portfolio_value=9000.0, + peak_portfolio_value=10000.0 + ) + + assert isinstance(result, bool) + + @pytest.mark.asyncio + async def test_add_risk_limit(self, risk_manager, mock_database): + """Test adding risk limit.""" + engine, Session = mock_database + + await risk_manager.add_risk_limit( + limit_type="max_drawdown", + value=0.10, # 10% + is_active=True + ) + + # Verify limit was added + await risk_manager.load_risk_limits() + assert len(risk_manager.risk_limits) > 0 + diff --git a/tests/unit/risk/test_position_sizing.py b/tests/unit/risk/test_position_sizing.py new file mode 100644 index 00000000..77f20831 --- /dev/null +++ b/tests/unit/risk/test_position_sizing.py @@ -0,0 +1,41 @@ +"""Tests for position sizing.""" + +import pytest +from src.risk.position_sizing import get_position_sizer, PositionSizing + + +class TestPositionSizing: + """Tests for PositionSizing.""" + + @pytest.fixture + def position_sizer(self): + """Create position sizer instance.""" + return get_position_sizer() + + def test_fixed_percentage(self, position_sizer): + """Test fixed percentage sizing.""" + size = position_sizer.fixed_percentage(10000.0, 0.02) # 2% + assert size == 200.0 + + def test_fixed_amount(self, position_sizer): + """Test fixed amount sizing.""" + size = position_sizer.fixed_amount(500.0) + assert size == 500.0 + + def test_volatility_based(self, position_sizer): + """Test volatility-based sizing.""" + size = position_sizer.volatility_based( + capital=10000.0, + atr=100.0, + risk_per_trade_percentage=0.01 # 1% + ) + assert size > 0 + + def test_kelly_criterion(self, position_sizer): + """Test Kelly Criterion.""" + fraction = position_sizer.kelly_criterion( + win_probability=0.6, + payout_ratio=1.5 + ) + assert 0 <= fraction <= 1 + diff --git a/tests/unit/risk/test_stop_loss_atr.py b/tests/unit/risk/test_stop_loss_atr.py new file mode 100644 index 00000000..5a870aa8 --- /dev/null +++ b/tests/unit/risk/test_stop_loss_atr.py @@ -0,0 +1,122 @@ +"""Tests for ATR-based stop loss.""" + +import pytest +import pandas as pd +from decimal import Decimal +from src.risk.stop_loss import StopLossManager + + +class TestATRStopLoss: + """Tests for ATR-based stop loss functionality.""" + + @pytest.fixture + def stop_loss_manager(self): + """Create stop loss manager instance.""" + return StopLossManager() + + @pytest.fixture + def sample_ohlcv_data(self): + """Create sample OHLCV data.""" + dates = pd.date_range(start='2025-01-01', periods=50, freq='1H') + base_price = 50000 + return pd.DataFrame({ + 'high': [base_price + 100 + i * 10 for i in range(50)], + 'low': [base_price - 100 + i * 10 for i in range(50)], + 'close': [base_price + i * 10 for i in range(50)], + 'open': [base_price - 50 + i * 10 for i in range(50)], + 'volume': [1000.0] * 50 + }, index=dates) + + def test_set_atr_stop_loss(self, stop_loss_manager, sample_ohlcv_data): + """Test setting ATR-based stop loss.""" + position_id = 1 + entry_price = Decimal("50000") + + stop_loss_manager.set_stop_loss( + position_id=position_id, + stop_price=entry_price, + use_atr=True, + atr_multiplier=Decimal('2.0'), + atr_period=14, + ohlcv_data=sample_ohlcv_data + ) + + assert position_id in stop_loss_manager.stop_losses + config = stop_loss_manager.stop_losses[position_id] + assert config['use_atr'] is True + assert config['atr_multiplier'] == Decimal('2.0') + assert config['atr_period'] == 14 + assert 'stop_price' in config + + def test_calculate_atr_stop(self, stop_loss_manager, sample_ohlcv_data): + """Test calculating ATR stop price.""" + entry_price = Decimal("50000") + + stop_price_long = stop_loss_manager.calculate_atr_stop( + entry_price=entry_price, + is_long=True, + ohlcv_data=sample_ohlcv_data, + atr_multiplier=Decimal('2.0'), + atr_period=14 + ) + + stop_price_short = stop_loss_manager.calculate_atr_stop( + entry_price=entry_price, + is_long=False, + ohlcv_data=sample_ohlcv_data, + atr_multiplier=Decimal('2.0'), + atr_period=14 + ) + + # Long position: stop should be below entry + assert stop_price_long < entry_price + + # Short position: stop should be above entry + assert stop_price_short > entry_price + + def test_atr_trailing_stop(self, stop_loss_manager, sample_ohlcv_data): + """Test ATR-based trailing stop.""" + position_id = 1 + entry_price = Decimal("50000") + + stop_loss_manager.set_stop_loss( + position_id=position_id, + stop_price=entry_price, + trailing=True, + use_atr=True, + atr_multiplier=Decimal('2.0'), + atr_period=14, + ohlcv_data=sample_ohlcv_data + ) + + # Check stop loss with higher price (should update trailing stop) + current_price = Decimal("51000") + triggered = stop_loss_manager.check_stop_loss( + position_id=position_id, + current_price=current_price, + is_long=True, + ohlcv_data=sample_ohlcv_data + ) + + # Should not trigger at higher price + assert triggered is False + + def test_atr_stop_insufficient_data(self, stop_loss_manager): + """Test ATR stop with insufficient data falls back to percentage.""" + entry_price = Decimal("50000") + insufficient_data = pd.DataFrame({ + 'high': [51000], + 'low': [49000], + 'close': [50000] + }) + + stop_price = stop_loss_manager.calculate_atr_stop( + entry_price=entry_price, + is_long=True, + ohlcv_data=insufficient_data, + atr_period=14 + ) + + # Should fall back to percentage-based stop (2%) + assert stop_price == entry_price * Decimal('0.98') + diff --git a/tests/unit/security/__init__.py b/tests/unit/security/__init__.py new file mode 100644 index 00000000..2f7a3bc2 --- /dev/null +++ b/tests/unit/security/__init__.py @@ -0,0 +1,2 @@ +"""Unit tests for security.""" + diff --git a/tests/unit/security/test_encryption.py b/tests/unit/security/test_encryption.py new file mode 100644 index 00000000..e4ad270b --- /dev/null +++ b/tests/unit/security/test_encryption.py @@ -0,0 +1,35 @@ +"""Tests for encryption.""" + +import pytest +from src.security.encryption import get_encryption_manager, EncryptionManager + + +class TestEncryption: + """Tests for encryption system.""" + + @pytest.fixture + def encryptor(self): + """Create encryption manager.""" + return get_encryption_manager() + + def test_encrypt_decrypt(self, encryptor): + """Test encryption and decryption.""" + plaintext = "test_api_key_12345" + + encrypted = encryptor.encrypt(plaintext) + assert encrypted != plaintext + assert len(encrypted) > 0 + + decrypted = encryptor.decrypt(encrypted) + assert decrypted == plaintext + + def test_encrypt_different_values(self, encryptor): + """Test that different values encrypt differently.""" + value1 = "key1" + value2 = "key2" + + encrypted1 = encryptor.encrypt(value1) + encrypted2 = encryptor.encrypt(value2) + + assert encrypted1 != encrypted2 + diff --git a/tests/unit/strategies/__init__.py b/tests/unit/strategies/__init__.py new file mode 100644 index 00000000..a950cb2f --- /dev/null +++ b/tests/unit/strategies/__init__.py @@ -0,0 +1,2 @@ +"""Unit tests for strategy framework.""" + diff --git a/tests/unit/strategies/test_base.py b/tests/unit/strategies/test_base.py new file mode 100644 index 00000000..5718c47b --- /dev/null +++ b/tests/unit/strategies/test_base.py @@ -0,0 +1,89 @@ +"""Tests for base strategy class.""" + +import pytest +import pandas as pd +from src.strategies.base import BaseStrategy, StrategyRegistry + + +class ConcreteStrategy(BaseStrategy): + """Concrete strategy for testing.""" + + async def on_data(self, new_data: pd.DataFrame): + """Handle new data.""" + self.current_data = pd.concat([self.current_data, new_data]).tail(100) + + async def generate_signal(self): + """Generate signal.""" + if len(self.current_data) > 0: + return {"signal": "hold", "price": self.current_data['close'].iloc[-1]} + return {"signal": "hold", "price": None} + + async def calculate_position_size(self, capital: float, risk_percentage: float) -> float: + """Calculate position size.""" + return capital * risk_percentage + + +class TestBaseStrategy: + """Tests for BaseStrategy.""" + + @pytest.fixture + def strategy(self): + """Create strategy instance.""" + return ConcreteStrategy( + strategy_id=1, + name="test_strategy", + symbol="BTC/USD", + timeframe="1h", + parameters={} + ) + + def test_strategy_initialization(self, strategy): + """Test strategy initialization.""" + assert strategy.strategy_id == 1 + assert strategy.name == "test_strategy" + assert strategy.symbol == "BTC/USD" + assert strategy.timeframe == "1h" + assert not strategy.is_active + + @pytest.mark.asyncio + async def test_strategy_start_stop(self, strategy): + """Test strategy start and stop.""" + await strategy.start() + assert strategy.is_active + + await strategy.stop() + assert not strategy.is_active + + @pytest.mark.asyncio + async def test_generate_signal(self, strategy): + """Test signal generation.""" + signal = await strategy.generate_signal() + assert "signal" in signal + assert signal["signal"] in ["buy", "sell", "hold"] + + @pytest.mark.asyncio + async def test_calculate_position_size(self, strategy): + """Test position size calculation.""" + size = await strategy.calculate_position_size(1000.0, 0.01) + assert size == 10.0 + + +class TestStrategyRegistry: + """Tests for StrategyRegistry.""" + + def test_register_strategy(self): + """Test strategy registration.""" + StrategyRegistry.register_strategy("test_strategy", ConcreteStrategy) + assert "test_strategy" in StrategyRegistry.list_available() + + def test_get_strategy_class(self): + """Test getting strategy class.""" + StrategyRegistry.register_strategy("test_strategy", ConcreteStrategy) + strategy_class = StrategyRegistry.get_strategy_class("test_strategy") + assert strategy_class == ConcreteStrategy + + def test_get_nonexistent_strategy(self): + """Test getting non-existent strategy.""" + with pytest.raises(ValueError, match="not registered"): + StrategyRegistry.get_strategy_class("nonexistent") + diff --git a/tests/unit/strategies/test_bollinger_mean_reversion.py b/tests/unit/strategies/test_bollinger_mean_reversion.py new file mode 100644 index 00000000..27e86d7e --- /dev/null +++ b/tests/unit/strategies/test_bollinger_mean_reversion.py @@ -0,0 +1,55 @@ +"""Tests for Bollinger Bands mean reversion strategy.""" + +import pytest +from decimal import Decimal +from src.strategies.technical.bollinger_mean_reversion import BollingerMeanReversionStrategy +from src.strategies.base import SignalType + + +class TestBollingerMeanReversionStrategy: + """Tests for BollingerMeanReversionStrategy.""" + + @pytest.fixture + def strategy(self): + """Create Bollinger mean reversion strategy instance.""" + return BollingerMeanReversionStrategy( + name="test_bollinger_mr", + parameters={ + 'period': 20, + 'std_dev': 2.0, + 'trend_filter': True, + 'trend_ma_period': 50, + 'entry_threshold': 0.95, + 'exit_threshold': 0.5 + } + ) + + def test_initialization(self, strategy): + """Test strategy initialization.""" + assert strategy.period == 20 + assert strategy.std_dev == 2.0 + assert strategy.trend_filter is True + assert strategy.trend_ma_period == 50 + assert strategy.entry_threshold == 0.95 + assert strategy.exit_threshold == 0.5 + + def test_on_tick_insufficient_data(self, strategy): + """Test that strategy returns None with insufficient data.""" + signal = strategy.on_tick( + symbol="BTC/USD", + price=Decimal("50000"), + timeframe="1h", + data={'volume': 1000} + ) + assert signal is None + + def test_position_tracking(self, strategy): + """Test position tracking.""" + assert strategy._in_position is False + assert strategy._entry_price is None + + def test_strategy_metadata(self, strategy): + """Test strategy metadata.""" + assert strategy.name == "test_bollinger_mr" + assert strategy.enabled is False + diff --git a/tests/unit/strategies/test_confirmed_strategy.py b/tests/unit/strategies/test_confirmed_strategy.py new file mode 100644 index 00000000..8802f848 --- /dev/null +++ b/tests/unit/strategies/test_confirmed_strategy.py @@ -0,0 +1,61 @@ +"""Tests for Confirmed strategy.""" + +import pytest +from decimal import Decimal +from src.strategies.technical.confirmed_strategy import ConfirmedStrategy +from src.strategies.base import SignalType + + +class TestConfirmedStrategy: + """Tests for ConfirmedStrategy.""" + + @pytest.fixture + def strategy(self): + """Create Confirmed strategy instance.""" + return ConfirmedStrategy( + name="test_confirmed", + parameters={ + 'rsi_period': 14, + 'macd_fast': 12, + 'macd_slow': 26, + 'macd_signal': 9, + 'ma_fast': 10, + 'ma_slow': 30, + 'min_confirmations': 2, + 'require_rsi': True, + 'require_macd': True, + 'require_ma': True + } + ) + + def test_initialization(self, strategy): + """Test strategy initialization.""" + assert strategy.rsi_period == 14 + assert strategy.macd_fast == 12 + assert strategy.ma_fast == 10 + assert strategy.min_confirmations == 2 + + def test_on_tick_insufficient_data(self, strategy): + """Test that strategy returns None with insufficient data.""" + signal = strategy.on_tick( + symbol="BTC/USD", + price=Decimal("50000"), + timeframe="1h", + data={'volume': 1000} + ) + assert signal is None + + def test_min_confirmations_requirement(self, strategy): + """Test that signal requires minimum confirmations.""" + # This would require actual price history to generate real signals + # For now, we test the structure + assert strategy.min_confirmations == 2 + assert strategy.require_rsi is True + assert strategy.require_macd is True + assert strategy.require_ma is True + + def test_strategy_metadata(self, strategy): + """Test strategy metadata.""" + assert strategy.name == "test_confirmed" + assert strategy.enabled is False + diff --git a/tests/unit/strategies/test_consensus_strategy.py b/tests/unit/strategies/test_consensus_strategy.py new file mode 100644 index 00000000..60669dda --- /dev/null +++ b/tests/unit/strategies/test_consensus_strategy.py @@ -0,0 +1,53 @@ +"""Tests for Consensus (ensemble) strategy.""" + +import pytest +from decimal import Decimal +from src.strategies.ensemble.consensus_strategy import ConsensusStrategy +from src.strategies.base import SignalType + + +class TestConsensusStrategy: + """Tests for ConsensusStrategy.""" + + @pytest.fixture + def strategy(self): + """Create Consensus strategy instance.""" + return ConsensusStrategy( + name="test_consensus", + parameters={ + 'strategy_names': ['rsi', 'macd'], + 'min_consensus': 2, + 'use_weights': True, + 'min_weight': 0.3, + 'exclude_strategies': [] + } + ) + + def test_initialization(self, strategy): + """Test strategy initialization.""" + assert strategy.min_consensus == 2 + assert strategy.use_weights is True + assert strategy.min_weight == 0.3 + + def test_on_tick_no_strategies(self, strategy): + """Test that strategy handles empty strategy list.""" + # Strategy should handle cases where no strategies are available + signal = strategy.on_tick( + symbol="BTC/USD", + price=Decimal("50000"), + timeframe="1h", + data={'volume': 1000} + ) + # May return None if no strategies available or no consensus + assert signal is None or isinstance(signal, (type(None), object)) + + def test_strategy_metadata(self, strategy): + """Test strategy metadata.""" + assert strategy.name == "test_consensus" + assert strategy.enabled is False + + def test_consensus_calculation(self, strategy): + """Test consensus calculation parameters.""" + assert strategy.min_consensus == 2 + assert strategy.use_weights is True + diff --git a/tests/unit/strategies/test_dca_strategy.py b/tests/unit/strategies/test_dca_strategy.py new file mode 100644 index 00000000..f99dbcc7 --- /dev/null +++ b/tests/unit/strategies/test_dca_strategy.py @@ -0,0 +1,52 @@ +"""Tests for DCA strategy.""" + +import pytest +from decimal import Decimal +from datetime import datetime, timedelta +from src.strategies.dca.dca_strategy import DCAStrategy + + +def test_dca_strategy_initialization(): + """Test DCA strategy initializes correctly.""" + strategy = DCAStrategy("Test DCA", {"amount": 10, "interval": "daily"}) + assert strategy.name == "Test DCA" + assert strategy.amount == Decimal("10") + assert strategy.interval == "daily" + + +def test_dca_daily_interval(): + """Test DCA with daily interval.""" + strategy = DCAStrategy("Daily DCA", {"amount": 10, "interval": "daily"}) + assert strategy.interval_delta == timedelta(days=1) + + +def test_dca_weekly_interval(): + """Test DCA with weekly interval.""" + strategy = DCAStrategy("Weekly DCA", {"amount": 10, "interval": "weekly"}) + assert strategy.interval_delta == timedelta(weeks=1) + + +def test_dca_monthly_interval(): + """Test DCA with monthly interval.""" + strategy = DCAStrategy("Monthly DCA", {"amount": 10, "interval": "monthly"}) + assert strategy.interval_delta == timedelta(days=30) + + +def test_dca_signal_generation(): + """Test DCA generates buy signals.""" + strategy = DCAStrategy("Test DCA", {"amount": 10, "interval": "daily"}) + strategy.last_purchase_time = None + + signal = strategy.on_tick("BTC/USD", Decimal("100"), "1h", {}) + assert signal is not None + assert signal.signal_type.value == "buy" + assert signal.quantity == Decimal("0.1") # 10 / 100 + + +def test_dca_interval_respect(): + """Test DCA respects interval timing.""" + strategy = DCAStrategy("Test DCA", {"amount": 10, "interval": "daily"}) + strategy.last_purchase_time = datetime.utcnow() - timedelta(hours=12) + + signal = strategy.on_tick("BTC/USD", Decimal("100"), "1h", {}) + assert signal is None # Should not generate signal yet diff --git a/tests/unit/strategies/test_divergence_strategy.py b/tests/unit/strategies/test_divergence_strategy.py new file mode 100644 index 00000000..daf1dee3 --- /dev/null +++ b/tests/unit/strategies/test_divergence_strategy.py @@ -0,0 +1,59 @@ +"""Tests for Divergence strategy.""" + +import pytest +from decimal import Decimal +from src.strategies.technical.divergence_strategy import DivergenceStrategy +from src.strategies.base import SignalType + + +class TestDivergenceStrategy: + """Tests for DivergenceStrategy.""" + + @pytest.fixture + def strategy(self): + """Create Divergence strategy instance.""" + return DivergenceStrategy( + name="test_divergence", + parameters={ + 'indicator_type': 'rsi', + 'rsi_period': 14, + 'lookback': 20, + 'min_swings': 2, + 'min_confidence': 0.5 + } + ) + + def test_initialization(self, strategy): + """Test strategy initialization.""" + assert strategy.indicator_type == 'rsi' + assert strategy.rsi_period == 14 + assert strategy.lookback == 20 + assert strategy.min_swings == 2 + assert strategy.min_confidence == 0.5 + + def test_on_tick_insufficient_data(self, strategy): + """Test that strategy returns None with insufficient data.""" + signal = strategy.on_tick( + symbol="BTC/USD", + price=Decimal("50000"), + timeframe="1h", + data={'volume': 1000} + ) + assert signal is None + + def test_indicator_type_selection(self, strategy): + """Test indicator type selection.""" + assert strategy.indicator_type == 'rsi' + + # Test MACD indicator type + macd_strategy = DivergenceStrategy( + name="test_divergence_macd", + parameters={'indicator_type': 'macd'} + ) + assert macd_strategy.indicator_type == 'macd' + + def test_strategy_metadata(self, strategy): + """Test strategy metadata.""" + assert strategy.name == "test_divergence" + assert strategy.enabled is False + diff --git a/tests/unit/strategies/test_grid_strategy.py b/tests/unit/strategies/test_grid_strategy.py new file mode 100644 index 00000000..de4623bd --- /dev/null +++ b/tests/unit/strategies/test_grid_strategy.py @@ -0,0 +1,69 @@ +"""Tests for Grid strategy.""" + +import pytest +from decimal import Decimal +from src.strategies.grid.grid_strategy import GridStrategy + + +def test_grid_strategy_initialization(): + """Test Grid strategy initializes correctly.""" + strategy = GridStrategy("Test Grid", { + "grid_spacing": 1, + "num_levels": 10, + "profit_target": 2 + }) + assert strategy.name == "Test Grid" + assert strategy.grid_spacing == Decimal("0.01") + assert strategy.num_levels == 10 + + +def test_grid_levels_calculation(): + """Test grid levels are calculated correctly.""" + strategy = GridStrategy("Test Grid", { + "grid_spacing": 1, + "num_levels": 5, + "center_price": 100 + }) + + strategy._update_grid_levels(Decimal("100")) + assert len(strategy.buy_levels) == 5 + assert len(strategy.sell_levels) == 5 + + # Buy levels should be below center + assert all(level < Decimal("100") for level in strategy.buy_levels) + # Sell levels should be above center + assert all(level > Decimal("100") for level in strategy.sell_levels) + + +def test_grid_buy_signal(): + """Test grid generates buy signal at lower level.""" + strategy = GridStrategy("Test Grid", { + "grid_spacing": 1, + "num_levels": 5, + "center_price": 100, + "position_size": Decimal("0.1") + }) + + # Price at buy level + signal = strategy.on_tick("BTC/USD", Decimal("99"), "1h", {}) + assert signal is not None + assert signal.signal_type.value == "buy" + + +def test_grid_profit_taking(): + """Test grid takes profit at target.""" + strategy = GridStrategy("Test Grid", { + "grid_spacing": 1, + "num_levels": 5, + "profit_target": 2 + }) + + # Simulate position + entry_price = Decimal("100") + strategy.positions[entry_price] = Decimal("0.1") + + # Price with profit + signal = strategy.on_tick("BTC/USD", Decimal("102"), "1h", {}) + assert signal is not None + assert signal.signal_type.value == "sell" + assert entry_price not in strategy.positions # Position removed diff --git a/tests/unit/strategies/test_macd_strategy.py b/tests/unit/strategies/test_macd_strategy.py new file mode 100644 index 00000000..936779fa --- /dev/null +++ b/tests/unit/strategies/test_macd_strategy.py @@ -0,0 +1,45 @@ +"""Tests for MACD strategy.""" + +import pytest +import pandas as pd +from src.strategies.technical.macd_strategy import MACDStrategy + + +class TestMACDStrategy: + """Tests for MACDStrategy.""" + + @pytest.fixture + def strategy(self): + """Create MACD strategy instance.""" + return MACDStrategy( + strategy_id=1, + name="test_macd", + symbol="BTC/USD", + timeframe="1h", + parameters={ + "fast_period": 12, + "slow_period": 26, + "signal_period": 9 + } + ) + + @pytest.mark.asyncio + async def test_macd_strategy_initialization(self, strategy): + """Test MACD strategy initialization.""" + assert strategy.fast_period == 12 + assert strategy.slow_period == 26 + assert strategy.signal_period == 9 + + @pytest.mark.asyncio + async def test_generate_signal(self, strategy): + """Test signal generation.""" + # Create minimal data + data = pd.DataFrame({ + 'close': [100 + i * 0.1 for i in range(50)] + }) + strategy.current_data = data + + signal = await strategy.generate_signal() + assert "signal" in signal + assert signal["signal"] in ["buy", "sell", "hold"] + diff --git a/tests/unit/strategies/test_momentum_strategy.py b/tests/unit/strategies/test_momentum_strategy.py new file mode 100644 index 00000000..6efafcbd --- /dev/null +++ b/tests/unit/strategies/test_momentum_strategy.py @@ -0,0 +1,72 @@ +"""Tests for Momentum strategy.""" + +import pytest +from decimal import Decimal +import pandas as pd +from src.strategies.momentum.momentum_strategy import MomentumStrategy + + +def test_momentum_strategy_initialization(): + """Test Momentum strategy initializes correctly.""" + strategy = MomentumStrategy("Test Momentum", { + "lookback_period": 20, + "momentum_threshold": 0.05 + }) + assert strategy.name == "Test Momentum" + assert strategy.lookback_period == 20 + assert strategy.momentum_threshold == Decimal("0.05") + + +def test_momentum_calculation(): + """Test momentum calculation.""" + strategy = MomentumStrategy("Test", {"lookback_period": 5}) + + # Create price history + prices = pd.Series([100, 101, 102, 103, 104, 105]) + momentum = strategy._calculate_momentum(prices) + + # Should be positive (price increased) + assert momentum > 0 + assert momentum == 0.05 # (105 - 100) / 100 + + +def test_momentum_entry_signal(): + """Test momentum generates entry signal.""" + strategy = MomentumStrategy("Test", { + "lookback_period": 5, + "momentum_threshold": 0.05, + "volume_threshold": 1.0 + }) + + # Build price history with momentum + for i in range(10): + price = 100 + i * 2 # Strong upward momentum + volume = 1000 * (1.5 if i >= 5 else 1.0) # Volume increase + strategy.on_tick("BTC/USD", Decimal(str(price)), "1h", {"volume": volume}) + + # Should generate buy signal + signal = strategy.on_tick("BTC/USD", Decimal("120"), "1h", {"volume": 2000}) + assert signal is not None + assert signal.signal_type.value == "buy" + assert strategy._in_position == True + + +def test_momentum_exit_signal(): + """Test momentum generates exit signal on reversal.""" + strategy = MomentumStrategy("Test", { + "lookback_period": 5, + "exit_threshold": -0.02 + }) + + strategy._in_position = True + strategy._entry_price = Decimal("100") + + # Build history with reversal + for i in range(10): + price = 100 - i # Downward momentum + strategy.on_tick("BTC/USD", Decimal(str(price)), "1h", {"volume": 1000}) + + signal = strategy.on_tick("BTC/USD", Decimal("90"), "1h", {"volume": 1000}) + assert signal is not None + assert signal.signal_type.value == "sell" + assert strategy._in_position == False diff --git a/tests/unit/strategies/test_moving_avg_strategy.py b/tests/unit/strategies/test_moving_avg_strategy.py new file mode 100644 index 00000000..d49f33c9 --- /dev/null +++ b/tests/unit/strategies/test_moving_avg_strategy.py @@ -0,0 +1,45 @@ +"""Tests for Moving Average strategy.""" + +import pytest +import pandas as pd +from src.strategies.technical.moving_avg_strategy import MovingAverageStrategy + + +class TestMovingAverageStrategy: + """Tests for MovingAverageStrategy.""" + + @pytest.fixture + def strategy(self): + """Create Moving Average strategy instance.""" + return MovingAverageStrategy( + strategy_id=1, + name="test_ma", + symbol="BTC/USD", + timeframe="1h", + parameters={ + "short_period": 10, + "long_period": 30, + "ma_type": "SMA" + } + ) + + @pytest.mark.asyncio + async def test_ma_strategy_initialization(self, strategy): + """Test Moving Average strategy initialization.""" + assert strategy.short_period == 10 + assert strategy.long_period == 30 + assert strategy.ma_type == "SMA" + + @pytest.mark.asyncio + async def test_generate_signal(self, strategy): + """Test signal generation.""" + # Create data with trend + data = pd.DataFrame({ + 'close': [100 + i * 0.5 for i in range(50)] + }) + strategy.current_data = data + + signal = await strategy.generate_signal() + assert "signal" in signal + assert signal["signal"] in ["buy", "sell", "hold"] + diff --git a/tests/unit/strategies/test_pairs_trading.py b/tests/unit/strategies/test_pairs_trading.py new file mode 100644 index 00000000..5a896a1a --- /dev/null +++ b/tests/unit/strategies/test_pairs_trading.py @@ -0,0 +1,89 @@ + +import pytest +import pandas as pd +import numpy as np +from unittest.mock import MagicMock, AsyncMock, patch +from decimal import Decimal +from src.strategies.technical.pairs_trading import PairsTradingStrategy +from src.strategies.base import SignalType + +@pytest.fixture +def mock_pricing_service(): + service = MagicMock() + service.get_ohlcv = MagicMock() + return service + +@pytest.fixture +def strategy(mock_pricing_service): + with patch('src.strategies.technical.pairs_trading.get_pricing_service', return_value=mock_pricing_service): + params = { + 'second_symbol': 'AVAX/USD', + 'lookback_period': 5, + 'z_score_threshold': 1.5, + 'symbol': 'SOL/USD' + } + strat = PairsTradingStrategy("test_pairs", params) + strat.enabled = True + return strat + +@pytest.mark.asyncio +async def test_pairs_trading_short_spread_signal(strategy, mock_pricing_service): + # Setup Data + # Scenario: SOL (A) pumps relative to AVAX (B) -> Spread widens -> Z-Score High -> Sell A / Buy B + + # Prices for A (SOL): 100, 100, 100, 100, 120 (Pump) + ohlcv_a = [ + [0, 100, 100, 100, 100, 1000], + [0, 100, 100, 100, 100, 1000], + [0, 100, 100, 100, 100, 1000], + [0, 100, 100, 100, 100, 1000], + [0, 120, 120, 120, 120, 1000], + ] + + # Prices for B (AVAX): 25, 25, 25, 25, 25 (Flat) + ohlcv_b = [ + [0, 25, 25, 25, 25, 1000], + [0, 25, 25, 25, 25, 1000], + [0, 25, 25, 25, 25, 1000], + [0, 25, 25, 25, 25, 1000], + [0, 25, 25, 25, 25, 1000], + ] + + # Spread: 4, 4, 4, 4, 4.8 + # Mean: 4.16, StdDev: approx small but let's see. + # Actually StdDev will be non-zero because of the last value. + + mock_pricing_service.get_ohlcv.side_effect = [ohlcv_a, ohlcv_b] + + # Execute + signal = await strategy.on_tick("SOL/USD", Decimal(120), "1h", {}) + + # Verify + assert signal is not None + assert signal.signal_type == SignalType.SELL # Sell Primary (SOL) + assert signal.metadata['secondary_action'] == 'buy' # Buy Secondary (AVAX) + assert signal.metadata['z_score'] > 1.5 + +@pytest.mark.asyncio +async def test_pairs_trading_long_spread_signal(strategy, mock_pricing_service): + # Scenario: SOL (A) dumps -> Spread drops -> Z-Score Low -> Buy A / Sell B + + ohlcv_a = [ + [0, 100, 100, 100, 100, 1000], + [0, 100, 100, 100, 100, 1000], + [0, 100, 100, 100, 100, 1000], + [0, 100, 100, 100, 100, 1000], + [0, 80, 80, 80, 80, 1000], # Dump + ] + ohlcv_b = [ + [0, 25, 25, 25, 25, 1000] for _ in range(5) + ] + + mock_pricing_service.get_ohlcv.side_effect = [ohlcv_a, ohlcv_b] + + signal = await strategy.on_tick("SOL/USD", Decimal(80), "1h", {}) + + assert signal is not None + assert signal.signal_type == SignalType.BUY # Buy Primary + assert signal.metadata['secondary_action'] == 'sell' # Sell Secondary + assert signal.metadata['z_score'] < -1.5 diff --git a/tests/unit/strategies/test_rsi_strategy.py b/tests/unit/strategies/test_rsi_strategy.py new file mode 100644 index 00000000..eec07654 --- /dev/null +++ b/tests/unit/strategies/test_rsi_strategy.py @@ -0,0 +1,67 @@ +"""Tests for RSI strategy.""" + +import pytest +import pandas as pd +from src.strategies.technical.rsi_strategy import RSIStrategy + + +class TestRSIStrategy: + """Tests for RSIStrategy.""" + + @pytest.fixture + def strategy(self): + """Create RSI strategy instance.""" + return RSIStrategy( + strategy_id=1, + name="test_rsi", + symbol="BTC/USD", + timeframe="1h", + parameters={ + "rsi_period": 14, + "overbought": 70, + "oversold": 30 + } + ) + + @pytest.fixture + def sample_data(self): + """Create sample price data.""" + dates = pd.date_range(start='2025-01-01', periods=50, freq='1H') + # Create data with clear trend for RSI calculation + prices = [100 - i * 0.5 for i in range(50)] # Downward trend + return pd.DataFrame({ + 'timestamp': dates, + 'open': prices, + 'high': [p + 1 for p in prices], + 'low': [p - 1 for p in prices], + 'close': prices, + 'volume': [1000.0] * 50 + }) + + @pytest.mark.asyncio + async def test_rsi_strategy_initialization(self, strategy): + """Test RSI strategy initialization.""" + assert strategy.rsi_period == 14 + assert strategy.overbought == 70 + assert strategy.oversold == 30 + + @pytest.mark.asyncio + async def test_on_data(self, strategy, sample_data): + """Test on_data method.""" + await strategy.on_data(sample_data) + assert len(strategy.current_data) > 0 + + @pytest.mark.asyncio + async def test_generate_signal_oversold(self, strategy, sample_data): + """Test signal generation for oversold condition.""" + await strategy.on_data(sample_data) + # Calculate RSI - should be low for downward trend + from src.data.indicators import get_indicators + indicators = get_indicators() + rsi = indicators.rsi(strategy.current_data['close'], period=14) + + # If RSI is low, should generate buy signal + signal = await strategy.generate_signal() + assert "signal" in signal + assert signal["signal"] in ["buy", "sell", "hold"] + diff --git a/tests/unit/strategies/test_trend_filter.py b/tests/unit/strategies/test_trend_filter.py new file mode 100644 index 00000000..00c58f07 --- /dev/null +++ b/tests/unit/strategies/test_trend_filter.py @@ -0,0 +1,88 @@ +"""Tests for trend filter functionality.""" + +import pytest +import pandas as pd +from decimal import Decimal +from src.strategies.base import BaseStrategy, StrategySignal, SignalType +from src.strategies.technical.rsi_strategy import RSIStrategy + + +class TestTrendFilter: + """Tests for trend filter in BaseStrategy.""" + + @pytest.fixture + def strategy(self): + """Create strategy instance with trend filter enabled.""" + strategy = RSIStrategy( + name="test_rsi_with_filter", + parameters={'use_trend_filter': True} + ) + return strategy + + @pytest.fixture + def ohlcv_data(self): + """Create sample OHLCV data.""" + dates = pd.date_range(start='2025-01-01', periods=50, freq='1H') + base_price = 50000 + return pd.DataFrame({ + 'high': [base_price + 100 + i * 10 for i in range(50)], + 'low': [base_price - 100 + i * 10 for i in range(50)], + 'close': [base_price + i * 10 for i in range(50)], + 'open': [base_price - 50 + i * 10 for i in range(50)], + 'volume': [1000.0] * 50 + }, index=dates) + + def test_trend_filter_method_exists(self, strategy): + """Test that apply_trend_filter method exists.""" + assert hasattr(strategy, 'apply_trend_filter') + assert callable(getattr(strategy, 'apply_trend_filter')) + + def test_trend_filter_insufficient_data(self, strategy): + """Test trend filter with insufficient data.""" + signal = StrategySignal( + signal_type=SignalType.BUY, + symbol="BTC/USD", + strength=0.8, + price=Decimal("50000") + ) + + insufficient_data = pd.DataFrame({ + 'high': [51000], + 'low': [49000], + 'close': [50000] + }) + + # Should allow signal when insufficient data + result = strategy.apply_trend_filter(signal, insufficient_data) + assert result is not None + + def test_trend_filter_none_data(self, strategy): + """Test trend filter with None data.""" + signal = StrategySignal( + signal_type=SignalType.BUY, + symbol="BTC/USD", + strength=0.8, + price=Decimal("50000") + ) + + # Should allow signal when no data provided + result = strategy.apply_trend_filter(signal, None) + assert result is not None + + def test_trend_filter_when_disabled(self, strategy): + """Test that trend filter doesn't filter when disabled.""" + strategy_no_filter = RSIStrategy( + name="test_rsi_no_filter", + parameters={'use_trend_filter': False} + ) + + signal = StrategySignal( + signal_type=SignalType.BUY, + symbol="BTC/USD", + strength=0.8, + price=Decimal("50000") + ) + + result = strategy_no_filter.apply_trend_filter(signal, None) + assert result == signal + diff --git a/tests/unit/test_autopilot_training.py b/tests/unit/test_autopilot_training.py new file mode 100644 index 00000000..448f97cd --- /dev/null +++ b/tests/unit/test_autopilot_training.py @@ -0,0 +1,284 @@ +"""Tests for autopilot model training functionality.""" + +import pytest +from unittest.mock import Mock, patch, AsyncMock, MagicMock +from fastapi.testclient import TestClient + +from backend.main import app + + +@pytest.fixture +def client(): + """Test client fixture.""" + return TestClient(app) + + +@pytest.fixture +def mock_train_task(): + """Mock Celery train_model_task.""" + with patch('backend.api.autopilot.train_model_task') as mock: + mock_result = Mock() + mock_result.id = "test-task-id-12345" + mock.delay.return_value = mock_result + yield mock + + +@pytest.fixture +def mock_async_result(): + """Mock Celery AsyncResult.""" + with patch('backend.api.autopilot.AsyncResult') as mock: + yield mock + + +@pytest.fixture +def mock_strategy_selector(): + """Mock StrategySelector.""" + selector = Mock() + selector.model = Mock() + selector.model.is_trained = True + selector.model.model_type = "classifier" + selector.model.feature_names = ["rsi", "macd", "sma_20"] + selector.model.training_metadata = { + "trained_at": "2024-01-01T00:00:00", + "metrics": {"test_accuracy": 0.85} + } + selector.get_model_info.return_value = { + "is_trained": True, + "model_type": "classifier", + "available_strategies": ["rsi", "macd", "momentum"], + "feature_count": 54, + "training_metadata": { + "trained_at": "2024-01-01T00:00:00", + "metrics": {"test_accuracy": 0.85}, + "training_symbols": ["BTC/USD", "ETH/USD"] + } + } + return selector + + +class TestBootstrapConfig: + """Tests for bootstrap configuration endpoints.""" + + def test_get_bootstrap_config(self, client): + """Test getting bootstrap configuration.""" + response = client.get("/api/autopilot/bootstrap-config") + assert response.status_code == 200 + data = response.json() + + # Verify required fields exist + assert "days" in data + assert "timeframe" in data + assert "min_samples_per_strategy" in data + assert "symbols" in data + + # Verify types + assert isinstance(data["days"], int) + assert isinstance(data["timeframe"], str) + assert isinstance(data["min_samples_per_strategy"], int) + assert isinstance(data["symbols"], list) + + def test_update_bootstrap_config(self, client): + """Test updating bootstrap configuration.""" + new_config = { + "days": 365, + "timeframe": "4h", + "min_samples_per_strategy": 50, + "symbols": ["BTC/USD", "ETH/USD", "SOL/USD"] + } + + response = client.put( + "/api/autopilot/bootstrap-config", + json=new_config + ) + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + + # Verify the config was updated + response = client.get("/api/autopilot/bootstrap-config") + data = response.json() + assert data["days"] == 365 + assert data["timeframe"] == "4h" + assert data["min_samples_per_strategy"] == 50 + assert "SOL/USD" in data["symbols"] + + +class TestModelTraining: + """Tests for model training endpoints.""" + + def test_trigger_retrain(self, client, mock_train_task): + """Test triggering model retraining.""" + response = client.post("/api/autopilot/intelligent/retrain?force=true") + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "queued" + assert "task_id" in data + assert data["task_id"] == "test-task-id-12345" + + # Verify task was called with correct parameters + mock_train_task.delay.assert_called_once() + call_kwargs = mock_train_task.delay.call_args.kwargs + assert call_kwargs["force_retrain"] is True + assert call_kwargs["bootstrap"] is True + assert "symbols" in call_kwargs + assert "days" in call_kwargs + assert "timeframe" in call_kwargs + assert "min_samples_per_strategy" in call_kwargs + + def test_get_task_status_pending(self, client, mock_async_result): + """Test getting status of a pending task.""" + mock_result = Mock() + mock_result.status = "PENDING" + mock_result.result = None + mock_async_result.return_value = mock_result + + response = client.get("/api/autopilot/tasks/test-task-id") + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "PENDING" + + def test_get_task_status_progress(self, client, mock_async_result): + """Test getting status of a task in progress.""" + mock_result = Mock() + mock_result.status = "PROGRESS" + mock_result.result = None + mock_result.info = { + "step": "fetching", + "progress": 50, + "message": "Fetching BTC/USD data..." + } + mock_async_result.return_value = mock_result + + response = client.get("/api/autopilot/tasks/test-task-id") + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "PROGRESS" + assert data["meta"]["progress"] == 50 + + def test_get_task_status_success(self, client, mock_async_result): + """Test getting status of a successful task.""" + mock_result = Mock() + mock_result.status = "SUCCESS" + mock_result.result = { + "train_accuracy": 0.85, + "test_accuracy": 0.78, + "n_samples": 1000, + "best_model": "xgboost" + } + mock_async_result.return_value = mock_result + + response = client.get("/api/autopilot/tasks/test-task-id") + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "SUCCESS" + assert data["result"]["best_model"] == "xgboost" + + def test_get_task_status_failure(self, client, mock_async_result): + """Test getting status of a failed task.""" + mock_result = Mock() + mock_result.status = "FAILURE" + mock_result.result = Exception("Training failed: insufficient data") + mock_result.traceback = "Traceback (most recent call last)..." + mock_async_result.return_value = mock_result + + response = client.get("/api/autopilot/tasks/test-task-id") + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "FAILURE" + assert "error" in data["result"] + + +class TestModelInfo: + """Tests for model info endpoint.""" + + @patch('backend.api.autopilot.get_strategy_selector') + def test_get_model_info_trained(self, mock_get_selector, client, mock_strategy_selector): + """Test getting info for a trained model.""" + mock_get_selector.return_value = mock_strategy_selector + + response = client.get("/api/autopilot/intelligent/model-info") + + assert response.status_code == 200 + data = response.json() + assert data["is_trained"] is True + assert "available_strategies" in data + assert "feature_count" in data + + @patch('backend.api.autopilot.get_strategy_selector') + def test_get_model_info_untrained(self, mock_get_selector, client): + """Test getting info for an untrained model.""" + mock_selector = Mock() + mock_selector.get_model_info.return_value = { + "is_trained": False, + "model_type": "classifier", + "available_strategies": ["rsi", "macd"], + "feature_count": 0 + } + mock_get_selector.return_value = mock_selector + + response = client.get("/api/autopilot/intelligent/model-info") + + assert response.status_code == 200 + data = response.json() + assert data["is_trained"] is False + assert data["feature_count"] == 0 + + +class TestModelReset: + """Tests for model reset endpoint.""" + + @patch('backend.api.autopilot.get_strategy_selector') + def test_reset_model(self, mock_get_selector, client): + """Test resetting the model.""" + mock_selector = Mock() + mock_selector.reset_model = AsyncMock(return_value={"status": "success"}) + mock_get_selector.return_value = mock_selector + + response = client.post("/api/autopilot/intelligent/reset") + + assert response.status_code == 200 + + +@pytest.mark.integration +class TestTrainingWorkflow: + """Integration tests for the complete training workflow.""" + + @patch('backend.api.autopilot.train_model_task') + def test_config_and_retrain_workflow(self, mock_train_task, client): + """Test configure -> train workflow passes config correctly.""" + # Setup mock + mock_task_result = Mock() + mock_task_result.id = "test-task-123" + mock_train_task.delay.return_value = mock_task_result + + # 1. Configure bootstrap settings with specific values + config = { + "days": 180, + "timeframe": "4h", + "min_samples_per_strategy": 25, + "symbols": ["BTC/USD", "ETH/USD", "SOL/USD", "XRP/USD"] + } + response = client.put("/api/autopilot/bootstrap-config", json=config) + assert response.status_code == 200 + + # 2. Trigger retraining + response = client.post("/api/autopilot/intelligent/retrain?force=true") + assert response.status_code == 200 + + # 3. Verify the task was called with the correct config + mock_train_task.delay.assert_called_once() + call_kwargs = mock_train_task.delay.call_args.kwargs + + # All config should be passed to the task + assert call_kwargs["days"] == 180 + assert call_kwargs["timeframe"] == "4h" + assert call_kwargs["min_samples_per_strategy"] == 25 + assert call_kwargs["symbols"] == ["BTC/USD", "ETH/USD", "SOL/USD", "XRP/USD"] + assert call_kwargs["force_retrain"] is True + assert call_kwargs["bootstrap"] is True diff --git a/tests/unit/trading/__init__.py b/tests/unit/trading/__init__.py new file mode 100644 index 00000000..0e773fa4 --- /dev/null +++ b/tests/unit/trading/__init__.py @@ -0,0 +1,2 @@ +"""Unit tests for trading engine.""" + diff --git a/tests/unit/trading/test_engine.py b/tests/unit/trading/test_engine.py new file mode 100644 index 00000000..1d4444ae --- /dev/null +++ b/tests/unit/trading/test_engine.py @@ -0,0 +1,88 @@ +"""Tests for trading engine.""" + +import pytest +from unittest.mock import Mock, AsyncMock, patch +from decimal import Decimal +from src.trading.engine import get_trading_engine, TradingEngine +from src.core.database import OrderSide, OrderType, OrderStatus + +@pytest.mark.asyncio +class TestTradingEngine: + + @pytest.fixture(autouse=True) + async def setup_data(self, db_session): + """Setup test data.""" + from src.core.database import Exchange + from sqlalchemy import select + + # Check if exchange exists + result = await db_session.execute(select(Exchange).where(Exchange.id == 1)) + if not result.scalar_one_or_none(): + try: + # Try enabled (new schema) + exchange = Exchange(id=1, name="coinbase", enabled=True) + db_session.add(exchange) + await db_session.commit() + except Exception: + # Fallback if I was wrong about schema, but I checked it. + await db_session.rollback() + exchange = Exchange(id=1, name="coinbase") + db_session.add(exchange) + print("Exchange created in setup_data") + + async def test_execute_order_paper_trading(self, mock_exchange_adapter): + """Test executing paper trading order.""" + engine = TradingEngine() + + # Mock dependencies + engine.get_exchange_adapter = AsyncMock(return_value=mock_exchange_adapter) + engine.risk_manager.check_order_risk = Mock(return_value=(True, "")) + engine.paper_trading.get_balance = Mock(return_value=Decimal("10000")) + engine.paper_trading.execute_order = Mock(return_value=True) + # Mock logger + engine.logger = Mock() + + order = await engine.execute_order( + exchange_id=1, + strategy_id=None, + symbol="BTC/USD", + side=OrderSide.BUY, + order_type=OrderType.LIMIT, + quantity=Decimal("0.1"), + price=Decimal("50000"), + paper_trading=True + ) + + # Check if error occurred + if order is None: + engine.logger.error.assert_called() + call_args = engine.logger.error.call_args + print(f"\nCaught exception in engine: {call_args}") + + assert order is not None + assert order.symbol == "BTC/USD" + assert order.quantity == Decimal("0.1") + # Status might be PENDING/OPEN depending on implementation + assert order.status in [OrderStatus.PENDING, OrderStatus.OPEN] + + async def test_execute_order_live_trading(self, mock_exchange_adapter): + """Test executing live trading order.""" + engine = TradingEngine() + + # Mock dependencies + engine.get_exchange_adapter = AsyncMock(return_value=mock_exchange_adapter) + engine.risk_manager.check_order_risk = Mock(return_value=(True, "")) + + order = await engine.execute_order( + exchange_id=1, + strategy_id=None, + symbol="BTC/USD", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + quantity=Decimal("0.1"), + paper_trading=False + ) + + assert order is not None + assert order.paper_trading is False + mock_exchange_adapter.place_order.assert_called_once() diff --git a/tests/unit/trading/test_fee_calculator.py b/tests/unit/trading/test_fee_calculator.py new file mode 100644 index 00000000..19b97ce2 --- /dev/null +++ b/tests/unit/trading/test_fee_calculator.py @@ -0,0 +1,161 @@ +"""Tests for fee calculator functionality.""" + +import pytest +from decimal import Decimal +from unittest.mock import Mock, patch +from src.trading.fee_calculator import FeeCalculator, get_fee_calculator +from src.core.database import OrderType + + +class TestFeeCalculator: + """Tests for FeeCalculator class.""" + + @pytest.fixture + def calculator(self): + """Create fee calculator instance.""" + return FeeCalculator() + + def test_get_fee_calculator_singleton(self): + """Test that get_fee_calculator returns singleton.""" + calc1 = get_fee_calculator() + calc2 = get_fee_calculator() + assert calc1 is calc2 + + def test_calculate_fee_basic(self, calculator): + """Test basic fee calculation.""" + fee = calculator.calculate_fee( + quantity=Decimal('1.0'), + price=Decimal('100.0'), + order_type=OrderType.MARKET + ) + assert fee > 0 + assert isinstance(fee, Decimal) + + def test_calculate_fee_zero_quantity(self, calculator): + """Test fee calculation with zero quantity.""" + fee = calculator.calculate_fee( + quantity=Decimal('0'), + price=Decimal('100.0'), + order_type=OrderType.MARKET + ) + assert fee == Decimal('0') + + def test_calculate_fee_maker_vs_taker(self, calculator): + """Test maker fees are typically lower than taker fees.""" + maker_fee = calculator.calculate_fee( + quantity=Decimal('1.0'), + price=Decimal('1000.0'), + order_type=OrderType.LIMIT, + is_maker=True + ) + taker_fee = calculator.calculate_fee( + quantity=Decimal('1.0'), + price=Decimal('1000.0'), + order_type=OrderType.MARKET, + is_maker=False + ) + # Maker fees should be <= taker fees + assert maker_fee <= taker_fee + + +class TestFeeCalculatorPaperTrading: + """Tests for paper trading fee calculation.""" + + @pytest.fixture + def calculator(self): + """Create fee calculator instance.""" + return FeeCalculator() + + def test_get_fee_structure_by_exchange_name_coinbase(self, calculator): + """Test getting Coinbase fee structure.""" + with patch.object(calculator, 'config') as mock_config: + mock_config.get.side_effect = lambda key, default=None: { + 'trading.default_fees': {'maker': 0.001, 'taker': 0.001}, + 'trading.exchanges.coinbase.fees': {'maker': 0.004, 'taker': 0.006} + }.get(key, default) + + fees = calculator.get_fee_structure_by_exchange_name('coinbase') + assert fees['maker'] == 0.004 + assert fees['taker'] == 0.006 + + def test_get_fee_structure_by_exchange_name_unknown(self, calculator): + """Test getting fee structure for unknown exchange returns defaults.""" + with patch.object(calculator, 'config') as mock_config: + mock_config.get.side_effect = lambda key, default=None: { + 'trading.default_fees': {'maker': 0.001, 'taker': 0.001}, + 'trading.exchanges.unknown.fees': None + }.get(key, default) + + fees = calculator.get_fee_structure_by_exchange_name('unknown') + assert fees['maker'] == 0.001 + assert fees['taker'] == 0.001 + + def test_calculate_fee_for_paper_trading(self, calculator): + """Test paper trading fee calculation.""" + with patch.object(calculator, 'config') as mock_config: + mock_config.get.side_effect = lambda key, default=None: { + 'paper_trading.fee_exchange': 'coinbase', + 'trading.exchanges.coinbase.fees': {'maker': 0.004, 'taker': 0.006}, + 'trading.default_fees': {'maker': 0.001, 'taker': 0.001} + }.get(key, default) + + fee = calculator.calculate_fee_for_paper_trading( + quantity=Decimal('1.0'), + price=Decimal('1000.0'), + order_type=OrderType.MARKET, + is_maker=False + ) + # Taker fee at 0.6% of $1000 = $6 + expected = Decimal('1000.0') * Decimal('0.006') + assert fee == expected + + def test_calculate_fee_for_paper_trading_zero(self, calculator): + """Test paper trading fee with zero values.""" + fee = calculator.calculate_fee_for_paper_trading( + quantity=Decimal('0'), + price=Decimal('100.0'), + order_type=OrderType.MARKET + ) + assert fee == Decimal('0') + + +class TestRoundTripFees: + """Tests for round-trip fee calculations.""" + + @pytest.fixture + def calculator(self): + """Create fee calculator instance.""" + return FeeCalculator() + + def test_estimate_round_trip_fee(self, calculator): + """Test round-trip fee estimation.""" + fee = calculator.estimate_round_trip_fee( + quantity=Decimal('1.0'), + price=Decimal('1000.0') + ) + # Round-trip should include buy and sell fees + assert fee > 0 + + single_fee = calculator.calculate_fee( + quantity=Decimal('1.0'), + price=Decimal('1000.0'), + order_type=OrderType.MARKET + ) + # Round-trip should be approximately 2x single fee + assert fee >= single_fee + + def test_get_minimum_profit_threshold(self, calculator): + """Test minimum profit threshold calculation.""" + threshold = calculator.get_minimum_profit_threshold( + quantity=Decimal('1.0'), + price=Decimal('1000.0'), + multiplier=2.0 + ) + assert threshold > 0 + + round_trip_fee = calculator.estimate_round_trip_fee( + quantity=Decimal('1.0'), + price=Decimal('1000.0') + ) + # Threshold should be 2x round-trip fee + assert threshold == round_trip_fee * Decimal('2.0') diff --git a/tests/unit/trading/test_order_manager.py b/tests/unit/trading/test_order_manager.py new file mode 100644 index 00000000..179f54fd --- /dev/null +++ b/tests/unit/trading/test_order_manager.py @@ -0,0 +1,70 @@ +"""Tests for order manager.""" + +import pytest +from unittest.mock import Mock, patch +from src.trading.order_manager import get_order_manager, OrderManager + + +class TestOrderManager: + """Tests for OrderManager.""" + + @pytest.fixture + def order_manager(self, mock_database): + """Create order manager instance.""" + return get_order_manager() + + @pytest.mark.asyncio + async def test_create_order(self, order_manager, mock_database): + """Test order creation.""" + engine, Session = mock_database + + order = await order_manager.create_order( + exchange_id=1, + strategy_id=1, + symbol="BTC/USD", + side="buy", + order_type="market", + amount=0.01, + price=50000.0, + is_paper_trade=True + ) + + assert order is not None + assert order.symbol == "BTC/USD" + assert order.side == "buy" + assert order.status == "pending" + + @pytest.mark.asyncio + async def test_update_order_status(self, order_manager, mock_database): + """Test order status update.""" + engine, Session = mock_database + + # Create order first + order = await order_manager.create_order( + exchange_id=1, + strategy_id=1, + symbol="BTC/USD", + side="buy", + order_type="market", + amount=0.01, + is_paper_trade=True + ) + + # Update status + updated = await order_manager.update_order_status( + client_order_id=order.client_order_id, + new_status="filled", + filled_amount=0.01, + cost=500.0 + ) + + assert updated is not None + assert updated.status == "filled" + + def test_get_order(self, order_manager, mock_database): + """Test getting order.""" + # This would require creating an order first + # Simplified test + order = order_manager.get_order(client_order_id="nonexistent") + assert order is None + diff --git a/tests/unit/trading/test_paper_trading.py b/tests/unit/trading/test_paper_trading.py new file mode 100644 index 00000000..08b0721f --- /dev/null +++ b/tests/unit/trading/test_paper_trading.py @@ -0,0 +1,32 @@ +"""Tests for paper trading simulator.""" + +import pytest +from decimal import Decimal +from unittest.mock import Mock +from src.trading.paper_trading import get_paper_trading, PaperTradingSimulator +from src.core.database import Order, OrderSide, OrderType + + +class TestPaperTradingSimulator: + """Tests for PaperTradingSimulator.""" + + @pytest.fixture + def simulator(self): + """Create paper trading simulator.""" + return PaperTradingSimulator(initial_capital=Decimal('1000.0')) + + def test_initialization(self, simulator): + """Test simulator initialization.""" + assert simulator.initial_capital == Decimal('1000.0') + assert simulator.cash == Decimal('1000.0') + + def test_get_balance(self, simulator): + """Test getting balance.""" + balance = simulator.get_balance() + assert balance == Decimal('1000.0') + + def test_get_positions(self, simulator): + """Test getting positions.""" + positions = simulator.get_positions() + assert isinstance(positions, list) + diff --git a/tests/unit/worker/__init__.py b/tests/unit/worker/__init__.py new file mode 100644 index 00000000..880fb433 --- /dev/null +++ b/tests/unit/worker/__init__.py @@ -0,0 +1 @@ +"""Test init file.""" diff --git a/tests/unit/worker/test_tasks.py b/tests/unit/worker/test_tasks.py new file mode 100644 index 00000000..2df75a7e --- /dev/null +++ b/tests/unit/worker/test_tasks.py @@ -0,0 +1,178 @@ +"""Tests for Celery tasks.""" + +import pytest +from unittest.mock import Mock, patch, MagicMock, AsyncMock + + +class TestAsyncToSync: + """Tests for async_to_sync helper.""" + + def test_runs_awaitable(self): + """Test that async_to_sync runs awaitable and returns result.""" + from src.worker.tasks import async_to_sync + + async def async_func(): + return "test_result" + + result = async_to_sync(async_func()) + assert result == "test_result" + + def test_handles_exception(self): + """Test that async_to_sync propagates exceptions.""" + from src.worker.tasks import async_to_sync + + async def async_error(): + raise ValueError("test error") + + with pytest.raises(ValueError, match="test error"): + async_to_sync(async_error()) + + +class TestTrainModelTask: + """Tests for train_model_task.""" + + @patch('src.worker.tasks.get_strategy_selector') + @patch('src.worker.tasks.async_to_sync') + def test_train_model_basic(self, mock_async_to_sync, mock_get_selector): + """Test basic model training task.""" + # Setup mocks + mock_selector = Mock() + mock_selector.bootstrap_symbols = ["BTC/USD"] + mock_get_selector.return_value = mock_selector + + mock_async_to_sync.side_effect = [ + {"X": [1, 2, 3]}, # prepare_training_data result + {"accuracy": 0.9} # train_model result + ] + + from src.worker.tasks import train_model_task + + # Call the task directly - Celery will bind self automatically + # For testing, we need to access the underlying function + result = train_model_task.run(force_retrain=True, bootstrap=False) + + assert result == {"accuracy": 0.9} + mock_get_selector.assert_called_once() + + @patch('src.worker.tasks.get_strategy_selector') + @patch('src.worker.tasks.async_to_sync') + def test_train_model_with_bootstrap(self, mock_async_to_sync, mock_get_selector): + """Test model training with bootstrapping.""" + mock_selector = Mock() + mock_selector.bootstrap_symbols = ["BTC/USD", "ETH/USD"] + mock_get_selector.return_value = mock_selector + + # First call returns empty data, triggering bootstrap + mock_async_to_sync.side_effect = [ + {"X": []}, # Empty training data + {"total_samples": 100}, # First symbol bootstrap + {"total_samples": 50}, # Second symbol bootstrap + {"accuracy": 0.85} # Final training + ] + + from src.worker.tasks import train_model_task + + result = train_model_task.run(force_retrain=False, bootstrap=True) + + assert result == {"accuracy": 0.85} + + +class TestBootstrapTask: + """Tests for bootstrap_task.""" + + @patch('src.worker.tasks.get_strategy_selector') + @patch('src.worker.tasks.async_to_sync') + def test_bootstrap_basic(self, mock_async_to_sync, mock_get_selector): + """Test basic bootstrap task.""" + mock_selector = Mock() + mock_get_selector.return_value = mock_selector + mock_async_to_sync.return_value = {"total_samples": 200} + + from src.worker.tasks import bootstrap_task + + result = bootstrap_task.run(days=90, timeframe="1h") + + assert result == {"total_samples": 200} + + +class TestGenerateReportTask: + """Tests for generate_report_task.""" + + @patch('src.worker.tasks.async_to_sync') + def test_generate_report_unknown_type(self, mock_async_to_sync): + """Test report generation with unknown type.""" + from src.worker.tasks import generate_report_task + + result = generate_report_task.run("unknown", {}) + + assert result["status"] == "error" + assert "Unknown report type" in result["message"] + + +class TestOptimizeStrategyTask: + """Tests for optimize_strategy_task.""" + + @patch('src.optimization.genetic.GeneticOptimizer') + def test_optimize_genetic_basic(self, mock_optimizer_class): + """Test basic genetic optimization.""" + from src.worker.tasks import optimize_strategy_task + + mock_optimizer = Mock() + mock_optimizer.optimize.return_value = { + "best_params": {"period": 14}, + "best_score": 0.85 + } + mock_optimizer_class.return_value = mock_optimizer + + result = optimize_strategy_task.run( + strategy_type="rsi", + symbol="BTC/USD", + param_ranges={"period": (5, 50)}, + method="genetic", + population_size=10, + generations=5 + ) + + assert result["best_params"] == {"period": 14} + assert result["best_score"] == 0.85 + + def test_optimize_unknown_method(self): + """Test optimization with unknown method.""" + from src.worker.tasks import optimize_strategy_task + + result = optimize_strategy_task.run( + strategy_type="rsi", + symbol="BTC/USD", + param_ranges={"period": (5, 50)}, + method="unknown_method" + ) + + assert "error" in result + + +class TestExportDataTask: + """Tests for export_data_task.""" + + @patch('src.reporting.csv_exporter.get_csv_exporter') + @patch('src.worker.tasks.async_to_sync') + def test_export_orders(self, mock_async_to_sync, mock_exporter_func): + """Test order export.""" + mock_exporter = Mock() + mock_exporter.export_orders.return_value = True + mock_exporter_func.return_value = mock_exporter + mock_async_to_sync.return_value = [] # Empty orders list + + from src.worker.tasks import export_data_task + + result = export_data_task.run("orders", {}) + + assert result["status"] == "success" + assert result["export_type"] == "orders" + + def test_export_unknown_type(self): + """Test export with unknown type.""" + from src.worker.tasks import export_data_task + + result = export_data_task.run("unknown", {}) + + assert result["status"] == "error" diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 00000000..8d23eadd --- /dev/null +++ b/tests/utils/__init__.py @@ -0,0 +1,2 @@ +"""Test utilities.""" + diff --git a/tests/utils/helpers.py b/tests/utils/helpers.py new file mode 100644 index 00000000..f273d89f --- /dev/null +++ b/tests/utils/helpers.py @@ -0,0 +1,26 @@ +"""Test helper functions.""" + +import pandas as pd +from datetime import datetime, timedelta + + +def create_sample_ohlcv(periods: int = 100) -> pd.DataFrame: + """Create sample OHLCV data.""" + dates = pd.date_range(start=datetime.now() - timedelta(hours=periods), periods=periods, freq='1H') + return pd.DataFrame({ + 'timestamp': dates, + 'open': [100 + i * 0.1 for i in range(periods)], + 'high': [101 + i * 0.1 for i in range(periods)], + 'low': [99 + i * 0.1 for i in range(periods)], + 'close': [100.5 + i * 0.1 for i in range(periods)], + 'volume': [1000.0] * periods + }) + + +def assert_decimal_equal(actual, expected, places=2): + """Assert two decimals are equal within places.""" + from decimal import Decimal + actual_decimal = Decimal(str(actual)).quantize(Decimal('0.01')) + expected_decimal = Decimal(str(expected)).quantize(Decimal('0.01')) + assert actual_decimal == expected_decimal + diff --git a/tests/utils/ui_helpers.py b/tests/utils/ui_helpers.py new file mode 100644 index 00000000..b4899992 --- /dev/null +++ b/tests/utils/ui_helpers.py @@ -0,0 +1,60 @@ +"""UI testing utilities and helpers.""" + +from PyQt6.QtWidgets import QApplication +from typing import Optional + + +def get_or_create_qapplication() -> QApplication: + """Get existing QApplication or create new one. + + Returns: + QApplication instance + """ + app = QApplication.instance() + if app is None: + app = QApplication([]) + return app + + +def create_test_widget(widget_class, *args, **kwargs): + """Create widget instance for testing. + + Args: + widget_class: Widget class to instantiate + *args: Positional arguments for widget + **kwargs: Keyword arguments for widget + + Returns: + Widget instance + """ + app = get_or_create_qapplication() + return widget_class(*args, **kwargs) + + +class SignalSpy: + """Helper to spy on Qt signals.""" + + def __init__(self, signal): + """Initialize signal spy. + + Args: + signal: Qt signal to spy on + """ + self.signal = signal + self.calls = [] + signal.connect(self._on_signal) + + def _on_signal(self, *args, **kwargs): + """Record signal emission.""" + self.calls.append((args, kwargs)) + + def assert_called(self, times: Optional[int] = None): + """Assert signal was called. + + Args: + times: Expected number of calls (None for any) + """ + if times is None: + assert len(self.calls) > 0, "Signal was not called" + else: + assert len(self.calls) == times, f"Signal called {len(self.calls)} times, expected {times}"

V$5L5SJi_*|7r4v-&cnm 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 alertsApi.listAlerts(false), + refetchInterval: 5000, + }) + + const { data: generalSettings } = useQuery({ + queryKey: ['general-settings'], + queryFn: () => settingsApi.getGeneralSettings(), + }) + + const triggeredAlerts = alerts?.filter((alert) => alert.triggered) || [] + + if (isLoading) { + return ( + + + + ) + } + + return ( + + + Alert History + {alerts && alerts.length > 0 && ( + + )} + + + {triggeredAlerts.length === 0 ? ( + + + No alerts have been triggered yet + + + ) : ( + + + + + Alert Name + Type + Condition + Triggered At + Status + + + + {triggeredAlerts.map((alert: AlertResponse) => ( + + {alert.name} + + + + + {alert.condition?.symbol || 'N/A'} + {alert.condition?.price_threshold && ` @ $${alert.condition.price_threshold}`} + + + {formatDate(alert.triggered_at || '', generalSettings)} + + + + + + ))} + +
+
+ )} +
+ ) +} + diff --git a/frontend/src/components/Chart.tsx b/frontend/src/components/Chart.tsx new file mode 100644 index 00000000..41822cdd --- /dev/null +++ b/frontend/src/components/Chart.tsx @@ -0,0 +1,99 @@ +import { useEffect, useRef } from 'react' +import { createChart, ColorType, IChartApi, ISeriesApi } from 'lightweight-charts' +import { Box, Typography } from '@mui/material' + +interface ChartProps { + data: { + time: number + open: number + high: number + low: number + close: number + }[] + height?: number + colors?: { + backgroundColor?: string + lineColor?: string + textColor?: string + areaTopColor?: string + areaBottomColor?: string + } +} + +export default function Chart({ data, height = 400, colors }: ChartProps) { + const chartContainerRef = useRef(null) + const chartRef = useRef(null) + const candlestickSeriesRef = useRef | null>(null) + + // Validate data + if (!data || !Array.isArray(data) || data.length === 0) { + return ( + + + No chart data available + + + ) + } + + useEffect(() => { + if (!chartContainerRef.current) return + + try { + const handleResize = () => { + if (chartRef.current && chartContainerRef.current) { + chartRef.current.applyOptions({ width: chartContainerRef.current.clientWidth }) + } + } + + const chart = createChart(chartContainerRef.current, { + layout: { + background: { type: ColorType.Solid, color: colors?.backgroundColor || '#1E1E1E' }, + textColor: colors?.textColor || '#DDD', + }, + grid: { + vertLines: { color: '#333' }, + horzLines: { color: '#333' }, + }, + width: chartContainerRef.current.clientWidth, + height, + }) + + const candlestickSeries = chart.addCandlestickSeries({ + upColor: '#26a69a', + downColor: '#ef5350', + borderVisible: false, + wickUpColor: '#26a69a', + wickDownColor: '#ef5350', + }) + + if (data && data.length > 0) { + candlestickSeries.setData(data as any) + } + + chartRef.current = chart + candlestickSeriesRef.current = candlestickSeries + + window.addEventListener('resize', handleResize) + + return () => { + window.removeEventListener('resize', handleResize) + chart.remove() + } + } catch (error) { + console.error('Chart initialization error:', error) + } + }, [colors, height]) + + useEffect(() => { + if (candlestickSeriesRef.current && data && data.length > 0) { + try { + candlestickSeriesRef.current.setData(data as any) + } catch (error) { + console.error('Chart data update error:', error) + } + } + }, [data]) + + return +} diff --git a/frontend/src/components/ChartGrid.tsx b/frontend/src/components/ChartGrid.tsx new file mode 100644 index 00000000..dc0f83c7 --- /dev/null +++ b/frontend/src/components/ChartGrid.tsx @@ -0,0 +1,126 @@ +import { useQueries } from '@tanstack/react-query' +import { Box, Paper, Typography, Button, Grid, Skeleton } from '@mui/material' +import { Refresh } from '@mui/icons-material' +import Chart from './Chart' +import RealtimePrice from './RealtimePrice' +import { marketDataApi } from '../api/marketData' + +interface ChartGridProps { + symbols: string[] + onAnalyze?: (symbol: string) => void + isAnalyzing?: boolean +} + +export default function ChartGrid({ symbols, onAnalyze, isAnalyzing }: ChartGridProps) { + // Fetch OHLCV data for all symbols in parallel + const ohlcvQueries = useQueries({ + queries: symbols.map((symbol) => ({ + queryKey: ['market-data', symbol], + queryFn: () => marketDataApi.getOHLCV(symbol), + refetchInterval: 60000, + staleTime: 30000, + })), + }) + + // Determine grid columns based on symbol count + const getGridColumns = () => { + if (symbols.length === 1) return 12 + if (symbols.length === 2) return 6 + return 6 // 2x2 grid for 3-4 symbols + } + + const chartHeight = symbols.length === 1 ? 400 : 280 + + return ( + + {symbols.map((symbol, index) => { + const query = ohlcvQueries[index] + const isLoading = query?.isLoading + const data = query?.data + + return ( + + + {/* Header */} + + + + {symbol} + + + + {onAnalyze && ( + + )} + + + {/* Chart */} + {isLoading ? ( + + ) : data && Array.isArray(data) && data.length > 0 ? ( + + + + ) : ( + + + No data for {symbol} + + + )} + + + ) + })} + + ) +} diff --git a/frontend/src/components/DataFreshness.tsx b/frontend/src/components/DataFreshness.tsx new file mode 100644 index 00000000..a6b7e756 --- /dev/null +++ b/frontend/src/components/DataFreshness.tsx @@ -0,0 +1,64 @@ +import { Typography, Chip, Tooltip } from '@mui/material' +import { AccessTime } from '@mui/icons-material' +import { useMemo } from 'react' +import { useQuery } from '@tanstack/react-query' +import { settingsApi } from '../api/settings' +import { formatDate } from '../utils/formatters' + +interface DataFreshnessProps { + timestamp: string | Date | null | undefined + refreshInterval?: number +} + +export default function DataFreshness({ timestamp, refreshInterval = 5000 }: DataFreshnessProps) { + const { data: generalSettings } = useQuery({ + queryKey: ['general-settings'], + queryFn: settingsApi.getGeneralSettings, + }) + + const freshness = useMemo(() => { + if (!timestamp) return { status: 'unknown', text: 'No data', color: 'default' as const } + + const lastUpdate = new Date(timestamp) + const now = new Date() + const ageMs = now.getTime() - lastUpdate.getTime() + const ageSeconds = Math.floor(ageMs / 1000) + + if (ageSeconds < refreshInterval / 1000) { + return { status: 'fresh', text: 'Just now', color: 'success' as const } + } else if (ageSeconds < 60) { + return { status: 'fresh', text: `${ageSeconds}s ago`, color: 'success' as const } + } else if (ageSeconds < 300) { + const minutes = Math.floor(ageSeconds / 60) + return { status: 'stale', text: `${minutes}m ago`, color: 'warning' as const } + } else { + const minutes = Math.floor(ageSeconds / 60) + return { status: 'outdated', text: `${minutes}m ago`, color: 'error' as const } + } + }, [timestamp, refreshInterval]) + + if (!timestamp) { + return ( + } + label="No data" + size="small" + color="default" + variant="outlined" + /> + ) + } + + return ( + + } + label={freshness.text} + size="small" + color={freshness.color} + variant="outlined" + /> + + ) +} + diff --git a/frontend/src/components/ErrorDisplay.tsx b/frontend/src/components/ErrorDisplay.tsx new file mode 100644 index 00000000..d332ee82 --- /dev/null +++ b/frontend/src/components/ErrorDisplay.tsx @@ -0,0 +1,39 @@ +import { Alert, AlertTitle, Button, Box } from '@mui/material' +import { Refresh } from '@mui/icons-material' + +interface ErrorDisplayProps { + error: Error | string + onRetry?: () => void + title?: string +} + +export default function ErrorDisplay({ error, onRetry, title = 'Error' }: ErrorDisplayProps) { + const errorMessage = error instanceof Error ? error.message : error + + return ( + }> + Retry + + ) + } + > + {title} + {errorMessage} + {error instanceof Error && error.stack && ( + +

V$5L5SJi_*|7r4v-&cnm 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 \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 00000000..45fd5c41 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,71 @@ +import { Routes, Route } from 'react-router-dom' +import { Box, Alert } from '@mui/material' +import { Component, ReactNode } from 'react' +import Layout from './components/Layout' +import DashboardPage from './pages/DashboardPage' +import PortfolioPage from './pages/PortfolioPage' +import BacktestPage from './pages/BacktestPage' +import SettingsPage from './pages/SettingsPage' +import StrategiesPage from './pages/StrategiesPage' +import TradingPage from './pages/TradingPage' +import { useRealtimeData } from './hooks/useRealtimeData' + +// Error Boundary Component +class ErrorBoundary extends Component< + { children: ReactNode }, + { hasError: boolean; error: Error | null } +> { + constructor(props: { children: ReactNode }) { + super(props) + this.state = { hasError: false, error: null } + } + + static getDerivedStateFromError(error: Error) { + return { hasError: true, error } + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error('ErrorBoundary caught an error:', error, errorInfo) + } + + render() { + if (this.state.hasError) { + return ( + + + Something went wrong: {this.state.error?.message || 'Unknown error'} + + +