From 3cbbd6ccaa64c86e331f40cbc3bfa83e05321f86 Mon Sep 17 00:00:00 2001 From: Saravana Achu Mac Date: Sat, 4 Apr 2026 11:18:21 -0700 Subject: [PATCH] feat: scaffold trading monorepo foundation --- .env.example | 31 + .gitignore | 18 + README.md | 32 + backend/.gitignore | 16 + backend/ADMIN_TRADE_CONTROL_ARCHITECTURE.md | 266 + backend/ADMIN_TRADE_CONTROL_IMPLEMENTATION.md | 336 + backend/ADMIN_TRADE_CONTROL_QUICK_REF.md | 314 + backend/ADMIN_TRADE_CONTROL_TEST_PLAN.md | 576 + backend/ARCHITECTURE_RISK_ANALYSIS.md | 122 + backend/AUTH_THREAT_MODEL.md | 54 + backend/CAPITAL_FLOW_VALIDATION.md | 47 + backend/COVERAGE_BEFORE_AFTER_2026-02-16.md | 129 + .../CROSS_REPO_TEST_BUGREPORT_2026-02-15.md | 107 + backend/Dockerfile | 35 + backend/ENTERPRISE_ARCHITECTURE_REFERENCE.md | 176 + backend/HISTORY_PURGE_RUNBOOK.md | 67 + backend/INCIDENT_RUNBOOKS.md | 99 + backend/MOBILE_APP_BOOTSTRAP_ROADMAP.md | 152 + backend/ORDER_STATUS_SYNC.md | 263 + backend/PRODUCT_FLOW_TRADING_LOGIC.md | 529 + backend/README.md | 321 + backend/REPO_AUDIT_ROADMAP.md | Bin 0 -> 83686 bytes backend/SECRET_ROTATION_RUNBOOK.md | 49 + backend/SESSION_RULE_FIX.md | 92 + backend/STRATEGY_MARKETPLACE.md | 95 + backend/TRADE_LIFECYCLE_INTEGRITY_PLAN.md | 85 + backend/TRADING_CONTROL_PERSISTENCE.md | 349 + backend/admin-observability.md | 42 + backend/apply_standard_risk_profiles.ts | 76 + backend/architecture.md | 114 + backend/audit_profile_mapping.ts | 33 + backend/audit_profile_structure.ts | 35 + backend/backtesting.md | 57 + backend/checkAlpacaPositions.ts | 19 + backend/check_alerts.ts | 17 + backend/check_alpaca_pos.ts | 20 + backend/check_cols.ts | 19 + backend/check_counts.ts | 47 + backend/check_db_users.ts | 22 + backend/check_orders_cols.ts | 11 + backend/check_persistence.ts | 34 + backend/check_user_schema.ts | 19 + backend/create_aggressive_profile.ts | 67 + backend/create_low_risk_profile.ts | 65 + backend/debugProfiles.ts | 54 + backend/debug_config.ts | 11 + backend/debug_db_logging.ts | 86 + backend/debug_mode.ts | 14 + backend/debug_supabase.ts | 35 + backend/diagnose_profiles.ts | 26 + backend/docker-compose.yml | 30 + backend/dump_db.ts | 28 + backend/dump_recent.ts | 40 + backend/e2e_full_scenario.ts | 181 + backend/example_profile_config.json | 70 + backend/final_e2e_param_verification.ts | 96 + backend/fix_dashboard_rules.ts | 45 + backend/fix_imports.ts | 52 + backend/force_rules_reset.ts | 44 + .../provisioning/datasources/datasource.yml | 8 + backend/inspect_btc.ts | 30 + backend/invariants.md | 59 + backend/list_history.ts | 27 + backend/live_signal_check.js | 23 + backend/manualOverrideCloseTrades.ts | 297 + backend/monitorFreshWindow.ts | 218 + backend/package.json | 74 + backend/phase8-validation.md | 60 + backend/pre-deploy.md | 142 + backend/print_btc_rules.ts | 5 + backend/print_symbols.js | 9 + backend/prometheus-metrics.md | 66 + backend/prometheus.yml | 10 + backend/proposed_risk_profiles.json | 417 + backend/quick_eth.ts | 19 + backend/reconcileAlpacaVsSupabase.ts | 847 + backend/reconcileAttributionRepair.ts | 761 + backend/reconcileCapitalLedgerState.ts | 636 + backend/reconcileClosedOrderFillData.ts | 196 + backend/reconcileExitBackfillOnce.ts | 217 + backend/reconcileMissingOrderCoverage.ts | 308 + backend/reconcileNoGoQtyMismatchOnce.ts | 425 + backend/reconcileSubTagRepair.ts | 183 + backend/reconcileTradeHistoryLifecycle.ts | 372 + backend/rename_profile.ts | 33 + backend/runCoverageSuite.ts | 67 + backend/runCriticalCoverageSuite.ts | 68 + backend/run_all_tests.ts | 43 + backend/runbooks/capital-invariant.md | 34 + backend/runbooks/database-outage.md | 34 + backend/runbooks/exchange-degradation.md | 34 + backend/runbooks/lock-contention.md | 34 + backend/runbooks/loop-health.md | 34 + .../runbooks/reconciliation-exit-backfill.md | 194 + backend/runbooks/reconciliation.md | 70 + backend/schema/002_add_strategy_config.sql | 28 + ...3_add_profile_id_to_orders_and_history.sql | 18 + backend/schema/004_full_schema_sync.sql | 161 + backend/schema/005_add_trade_id_tracking.sql | 24 + backend/schema/006_add_trade_source.sql | 25 + ..._trade_lifecycle_integrity_constraints.sql | 123 + backend/schema/008_schema_gap_backfill.sql | 226 + .../009_tenant_rls_orders_trade_history.sql | 43 + backend/schema/010_bot_state_snapshot.sql | 26 + backend/schema/011_capital_ledger.sql | 109 + backend/schema/012_entry_atomic_lifecycle.sql | 216 + backend/schema/013_distributed_entry_lock.sql | 49 + backend/schema/014_entry_row_lock.sql | 79 + backend/schema/015_reconciliation_lock.sql | 73 + .../schema/016_add_strategy_marketplace.sql | 52 + ...017_reconciliation_exit_backfill_audit.sql | 47 + .../018_orders_order_id_uniqueness_repair.sql | 211 + .../019_orders_order_id_on_conflict_fix.sql | 70 + backend/schema/020_add_orders_sub_tag.sql | 20 + ...apital_ledger_realized_pnl_reserve_fix.sql | 49 + backend/seedTwoBestProfiles.ts | 230 + backend/seed_profile_data.ts | 90 + backend/simple_check.ts | 17 + backend/simulate_high_activity.ts | 142 + backend/simulate_hot_loading.ts | 77 + backend/src/backtest/data/csvLoader.ts | 74 + .../backtest/data/exchangeReplayAdapter.ts | 15 + backend/src/backtest/data/jsonLoader.ts | 46 + backend/src/backtest/data/krakenLoader.ts | 256 + .../src/backtest/data/loadHistoricalData.ts | 65 + backend/src/backtest/data/normalize.ts | 198 + backend/src/backtest/engine/BacktestRunner.ts | 384 + .../backtest/engine/VirtualExecutionEngine.ts | 609 + backend/src/backtest/engine/VirtualLedger.ts | 77 + backend/src/backtest/engine/timeFreeze.ts | 26 + backend/src/backtest/engine/warmup.ts | 88 + .../exchange/ReplayExchangeConnector.ts | 86 + backend/src/backtest/guards.ts | 14 + backend/src/backtest/index.ts | 24 + .../src/backtest/metrics/computeSummary.ts | 60 + backend/src/backtest/types.ts | 207 + backend/src/config/index.ts | 455 + backend/src/connectors/alpaca.ts | 460 + backend/src/connectors/ccxt.ts | 198 + backend/src/connectors/factory.ts | 21 + backend/src/connectors/types.ts | 62 + backend/src/domain/operationalEvents.ts | 23 + backend/src/domain/tradingEnums.ts | 44 + backend/src/index.ts | 1076 ++ backend/src/scripts/README.md | 55 + backend/src/scripts/backfillTradeIds.ts | 324 + backend/src/scripts/cleanupStaleOrders.ts | 50 + .../src/scripts/reconcileTradeLifecycle.ts | 216 + backend/src/scripts/revertExpiredOrders.ts | 50 + .../src/scripts/verifyWebsocketContract.ts | 210 + backend/src/services/AutoTrader.ts | 365 + backend/src/services/CapitalLedger.ts | 216 + backend/src/services/ManualTrader.ts | 156 + backend/src/services/MetricsService.ts | 135 + .../src/services/OrderStatusSyncService.ts | 511 + backend/src/services/SupabaseService.ts | 3050 ++++ backend/src/services/TradeExecutor.ts | 2522 +++ backend/src/services/aiClient.ts | 237 + backend/src/services/apiServer.ts | 2488 +++ .../src/services/canonicalLifecycleService.ts | 612 + .../src/services/distributedLockService.ts | 143 + backend/src/services/executionManager.ts | 397 + backend/src/services/healthTracker.ts | 170 + backend/src/services/notifier.ts | 106 + backend/src/services/observabilityService.ts | 309 + .../reconciliationExitBackfillService.ts | 953 ++ .../reconciliationOrderCoverageService.ts | 577 + .../reconciliationParityHeartbeatService.ts | 456 + backend/src/services/reconciliationService.ts | 505 + .../reconciliationSubTagRepairService.ts | 77 + ...reconciliationWatchdogAutoResumeService.ts | 117 + backend/src/services/riskEngine.ts | 156 + backend/src/services/stateMerge.ts | 268 + backend/src/services/tradeMonitor.ts | 520 + backend/src/strategies/ProStrategyEngine.ts | 323 + backend/src/strategies/directionTracker.ts | 109 + .../src/strategies/rules/AIAnalysisRule.ts | 140 + .../src/strategies/rules/EntryTriggerRule.ts | 110 + backend/src/strategies/rules/MomentumRule.ts | 62 + .../strategies/rules/RiskManagementRule.ts | 99 + backend/src/strategies/rules/SessionRule.ts | 125 + backend/src/strategies/rules/TrendBiasRule.ts | 64 + backend/src/strategies/rules/ZoneRule.ts | 50 + backend/src/strategies/rules/types.ts | 50 + backend/src/test_simulation.js | 71 + backend/src/utils/alpacaSubTag.ts | 232 + backend/src/utils/botSymbolScope.ts | 57 + backend/src/utils/indicators.ts | 62 + backend/src/utils/logger.ts | 19 + backend/src/utils/symbolMapper.ts | 48 + backend/strategy_config_schema.json | 41 + backend/testAlpacaSubTag.ts | 157 + backend/testBacktestIsolation.ts | 181 + backend/testConnectorAndAiCoverage.ts | 245 + backend/testCoreModuleCoverage.ts | 505 + backend/testFailureInjection.ts | 93 + backend/testLifecycleRegressions.ts | 229 + backend/testManualTraderCapitalGuard.ts | 102 + backend/testOrderStatusSyncRegressions.ts | 264 + ...ReconciliationExitBackfillEvidenceGuard.ts | 248 + backend/testReconciliationParityHeartbeat.ts | 297 + .../testReconciliationWatchdogAutoResume.ts | 132 + backend/testSessionRuleNormalization.ts | 63 + backend/testStateMergeCoverage.ts | 156 + backend/testStrictCapitalGuard.ts | 230 + ...testSupabaseOrderPersistenceRegressions.ts | 283 + .../testSupabaseTradeHistorySourceFallback.ts | 77 + backend/testTradeExecutorLifecycle.ts | 269 + backend/test_ai_cache.ts | 46 + backend/test_ai_fallback.ts | 27 + backend/test_ai_layer.ts | 91 + backend/test_alert.ts | 15 + backend/test_alpaca.ts | 28 + backend/test_alpaca_execution.ts | 40 + backend/test_alpaca_exhaustive.ts | 36 + backend/test_alpaca_exit.ts | 51 + backend/test_alpaca_full_cycle.ts | 44 + backend/test_broadcast_logic.ts | 55 + backend/test_feed_us.ts | 28 + backend/test_final.ts | 26 + backend/test_fuzzy_match.ts | 62 + backend/test_hot_loading.ts | 57 + backend/test_noslash_hardcoded.ts | 28 + backend/test_pro_strategy.ts | 214 + backend/test_query.ts | 28 + backend/test_risk_formula.ts | 42 + backend/test_risk_scenarios.ts | 84 + backend/test_safe.ts | 36 + backend/test_signal_sim.ts | 47 + backend/test_simulation.ts | 78 + backend/test_simulation_pro.ts | 116 + backend/test_slash.ts | 31 + backend/test_stock_baseline.ts | 31 + backend/test_strategy_logic.ts | 92 + backend/test_verbose_insert.ts | 50 + backend/test_wa.ts | 33 + backend/tsconfig.json | 19 + backend/verifyRlsPolicies.ts | 46 + backend/verifySchemaContract.ts | 53 + backend/verifySecretHygiene.ts | 111 + backend/verifySecurityGuards.ts | 54 + backend/verifyTenantIsolation.ts | 253 + backend/verify_btc_logic.ts | 55 + backend/verify_dynamic_config.ts | 29 + backend/verify_e2e_fix.ts | 156 + backend/verify_fetch.ts | 26 + backend/verify_final_e2e.ts | 62 + backend/verify_full_lifecycle.ts | 53 + backend/verify_order_logging.ts | 37 + backend/verify_profiles.ts | 10 + backend/verify_profiles_e2e.ts | 69 + backend/verify_realtime.ts | 56 + backend/verify_signals_live.ts | 31 + backend/verify_sl_tp_persistence.ts | 83 + backend/verify_sync.ts | 43 + backend/verify_traceability.ts | 120 + docker-compose.yml | 21 + docs/ROADMAP.md | 225 +- mobile/.bolt/config.json | 3 + mobile/.gitignore | 35 + mobile/.prettierrc | 6 + mobile/README.md | 1 + mobile/app.json | 28 + mobile/app/(tabs)/_layout.tsx | 32 + mobile/app/(tabs)/history.tsx | 279 + mobile/app/(tabs)/index.tsx | 78 + mobile/app/(tabs)/positions.tsx | 279 + mobile/app/(tabs)/settings.tsx | 453 + mobile/app/(tabs)/strategies.tsx | 297 + mobile/app/+not-found.tsx | 33 + mobile/app/_layout.tsx | 71 + mobile/app/chat.tsx | 323 + mobile/app/marketplace.tsx | 229 + mobile/assets/images/favicon.png | Bin 0 -> 1466 bytes mobile/assets/images/icon.png | Bin 0 -> 22380 bytes mobile/components/AnimatedCard.tsx | 38 + mobile/components/CustomTabBar.tsx | 131 + mobile/components/FloatingChatButton.tsx | 101 + mobile/components/PillBadge.tsx | 56 + mobile/components/PressableScale.tsx | 54 + mobile/components/ProductAvailabilityGate.tsx | 97 + mobile/components/PulsingDot.tsx | 48 + mobile/components/SegmentedControl.tsx | 80 + mobile/components/SkeletonLoader.tsx | 62 + mobile/components/Sparkline.tsx | 55 + mobile/components/dashboard/ActiveAlerts.tsx | 116 + mobile/components/dashboard/MarketTicker.tsx | 104 + .../dashboard/PortfolioHeroCard.tsx | 223 + .../components/dashboard/QuickPositions.tsx | 159 + mobile/components/dashboard/StatusBanner.tsx | 63 + mobile/components/dashboard/WinRateStrip.tsx | 61 + mobile/constants/mockData.ts | 186 + mobile/constants/theme.ts | 157 + mobile/docs/prompt.md | 0 mobile/eslint.config.js | 10 + mobile/hooks/useFrameworkReady.ts | 13 + mobile/lib/runtime.ts | 11 + mobile/package.json | 57 + mobile/prompt.md | 46 + mobile/tsconfig.json | 18 + mobile/utils/format.ts | 28 + mobile/utils/haptics.ts | 22 + package.json | 22 + pnpm-lock.yaml | 13264 ++++++++++++++++ pnpm-workspace.yaml | 4 + scripts/verify.sh | 8 + shared/backend-control-config.ts | 27 + shared/control-plane.ts | 58 + shared/platform-clients.ts | 35 + shared/platform-mobile.ts | 21 + shared/platform-web.ts | 24 + shared/product.json | 20 + shared/product.ts | 22 + shared/runtime.ts | 44 + shared/web-auth.tsx | 37 + tsconfig.base.json | 14 + web/.dockerignore | 6 + web/.gitignore | 27 + web/ADMIN_TRADE_CONTROL_UI.md | 532 + web/Dockerfile | 34 + web/LIVE_PULSE_TICKER.md | 79 + web/NAVIGATION_ACCESS_CONTROL.md | 70 + web/README.md | 176 + web/backtesting.md | 72 + web/dashboard-metrics-contract.md | 132 + web/eslint.config.js | 30 + web/guided-strategy-builder.md | 107 + web/index.html | 13 + web/metric-validation-runbook.md | 28 + web/package.json | 56 + web/pre-deploy.md | 179 + web/schema_reference.sql | 238 + web/src/App.css | 668 + web/src/App.dom.test.tsx | 155 + web/src/App.tsx | 428 + web/src/assets/react.svg | 1 + web/src/backtest/api.ts | 27 + .../components/BacktestComparePanel.tsx | 583 + .../components/BacktestConfigurator.tsx | 354 + .../components/BacktestResultsDashboard.tsx | 349 + .../BacktestRunnerPanel.dom.test.tsx | 115 + .../components/BacktestRunnerPanel.tsx | 77 + web/src/backtest/flags.ts | 75 + web/src/backtest/types.ts | 132 + web/src/backtest/useBacktestFeatureGate.ts | 80 + web/src/backtest/utils.test.ts | 68 + web/src/backtest/utils.ts | 53 + web/src/components/AlertFeed.css | 116 + web/src/components/AlertFeed.dom.test.tsx | 90 + web/src/components/AlertFeed.tsx | 51 + web/src/components/AuthContext.dom.test.tsx | 208 + web/src/components/AuthContext.test.ts | 74 + web/src/components/AuthContext.tsx | 168 + web/src/components/ChatControl.dom.test.tsx | 150 + web/src/components/ChatControl.test.ts | 71 + web/src/components/ChatControl.tsx | 776 + web/src/components/ComponentsSmoke.test.ts | 205 + web/src/components/EntryForm.dom.test.tsx | 157 + web/src/components/EntryForm.tsx | 452 + .../GlobalConfigManager.dom.test.tsx | 99 + web/src/components/GlobalConfigManager.tsx | 114 + web/src/components/LivePulseTicker.tsx | 88 + web/src/components/Login.dom.test.tsx | 100 + web/src/components/Login.tsx | 232 + .../MarketOpportunities.dom.test.tsx | 70 + web/src/components/MarketOpportunities.tsx | 56 + web/src/components/MarketTicker.tsx | 40 + web/src/components/PresetMarketplace.tsx | 408 + web/src/components/PriceChart.css | 44 + web/src/components/PriceChart.dom.test.tsx | 45 + web/src/components/PriceChart.tsx | 72 + .../components/ProductAccessibilityGate.tsx | 96 + web/src/components/ResetPassword.dom.test.tsx | 58 + web/src/components/ResetPassword.tsx | 160 + web/src/components/StrategyWizard.tsx | 514 + web/src/components/SymbolCard.css | 405 + web/src/components/SymbolCard.tsx | 268 + .../TradeProfileManager.dom.test.tsx | 280 + .../components/TradeProfileManager.test.ts | 153 + web/src/components/TradeProfileManager.tsx | 1473 ++ web/src/hooks/useCanonicalLifecycle.ts | 59 + web/src/hooks/useWebSocket.dom.test.tsx | 171 + web/src/hooks/useWebSocket.test.ts | 145 + web/src/hooks/useWebSocket.ts | 401 + web/src/index.css | 58 + web/src/lib/PresetRegistry.ts | 53 + web/src/lib/RiskStyleTemplates.ts | 47 + web/src/lib/StrategyExplanationService.ts | 70 + web/src/lib/TierPolicy.ts | 63 + web/src/lib/canonicalLifecycleApi.ts | 169 + web/src/lib/const.test.ts | 20 + web/src/lib/const.ts | 8 + web/src/lib/orderLifecycleLedger.test.ts | 117 + web/src/lib/orderLifecycleLedger.ts | 287 + web/src/lib/runtime.ts | 6 + web/src/lib/supabaseClient.test.ts | 12 + web/src/lib/supabaseClient.ts | 21 + .../lib/tradeHistoryLedger.diagnostic.test.ts | 19 + web/src/lib/tradeHistoryLedger.test.ts | 370 + web/src/lib/tradeHistoryLedger.ts | 266 + web/src/main.tsx | 30 + web/src/tabs/AdminTab.dom.test.tsx | 78 + web/src/tabs/AdminTab.tsx | 989 ++ web/src/tabs/BacktestTab.tsx | 270 + web/src/tabs/ConfigTab.dom.test.tsx | 124 + web/src/tabs/ConfigTab.tsx | 332 + web/src/tabs/EntriesTab.dom.test.tsx | 150 + web/src/tabs/EntriesTab.tsx | 296 + web/src/tabs/HistoryTab.dom.test.tsx | 124 + web/src/tabs/HistoryTab.tsx | 746 + web/src/tabs/MarketplaceTab.tsx | 119 + web/src/tabs/MembershipTab.tsx | 316 + web/src/tabs/MyStrategiesTab.tsx | 558 + web/src/tabs/OverviewTab.dom.test.tsx | 274 + web/src/tabs/OverviewTab.test.ts | 162 + web/src/tabs/OverviewTab.tsx | 1048 ++ web/src/tabs/PositionsTab.dom.test.tsx | 566 + web/src/tabs/PositionsTab.helpers.test.ts | 311 + web/src/tabs/PositionsTab.test.ts | 330 + web/src/tabs/PositionsTab.tsx | 1948 +++ web/src/tabs/ReconciliationAuditPanel.tsx | 655 + web/src/tabs/SettingsTab.dom.test.tsx | 137 + web/src/tabs/SettingsTab.tsx | 342 + web/src/tabs/SignalsTab.tsx | 29 + web/src/tabs/TabHelpers.test.ts | 136 + web/src/tabs/TabSuite.test.ts | 151 + web/src/test/setup.ts | 28 + web/tsconfig.app.json | 28 + web/tsconfig.json | 7 + web/tsconfig.node.json | 26 + web/vercel.json | 8 + web/vite.config.ts | 16 + web/vite.svg | 1 + web/vitest.full.config.ts | 23 + web/workspace-system-scan-2026-02-21.md | 242 + 435 files changed, 85802 insertions(+), 108 deletions(-) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/.gitignore create mode 100644 backend/ADMIN_TRADE_CONTROL_ARCHITECTURE.md create mode 100644 backend/ADMIN_TRADE_CONTROL_IMPLEMENTATION.md create mode 100644 backend/ADMIN_TRADE_CONTROL_QUICK_REF.md create mode 100644 backend/ADMIN_TRADE_CONTROL_TEST_PLAN.md create mode 100644 backend/ARCHITECTURE_RISK_ANALYSIS.md create mode 100644 backend/AUTH_THREAT_MODEL.md create mode 100644 backend/CAPITAL_FLOW_VALIDATION.md create mode 100644 backend/COVERAGE_BEFORE_AFTER_2026-02-16.md create mode 100644 backend/CROSS_REPO_TEST_BUGREPORT_2026-02-15.md create mode 100644 backend/Dockerfile create mode 100644 backend/ENTERPRISE_ARCHITECTURE_REFERENCE.md create mode 100644 backend/HISTORY_PURGE_RUNBOOK.md create mode 100644 backend/INCIDENT_RUNBOOKS.md create mode 100644 backend/MOBILE_APP_BOOTSTRAP_ROADMAP.md create mode 100644 backend/ORDER_STATUS_SYNC.md create mode 100644 backend/PRODUCT_FLOW_TRADING_LOGIC.md create mode 100644 backend/README.md create mode 100644 backend/REPO_AUDIT_ROADMAP.md create mode 100644 backend/SECRET_ROTATION_RUNBOOK.md create mode 100644 backend/SESSION_RULE_FIX.md create mode 100644 backend/STRATEGY_MARKETPLACE.md create mode 100644 backend/TRADE_LIFECYCLE_INTEGRITY_PLAN.md create mode 100644 backend/TRADING_CONTROL_PERSISTENCE.md create mode 100644 backend/admin-observability.md create mode 100644 backend/apply_standard_risk_profiles.ts create mode 100644 backend/architecture.md create mode 100644 backend/audit_profile_mapping.ts create mode 100644 backend/audit_profile_structure.ts create mode 100644 backend/backtesting.md create mode 100644 backend/checkAlpacaPositions.ts create mode 100644 backend/check_alerts.ts create mode 100644 backend/check_alpaca_pos.ts create mode 100644 backend/check_cols.ts create mode 100644 backend/check_counts.ts create mode 100644 backend/check_db_users.ts create mode 100644 backend/check_orders_cols.ts create mode 100644 backend/check_persistence.ts create mode 100644 backend/check_user_schema.ts create mode 100644 backend/create_aggressive_profile.ts create mode 100644 backend/create_low_risk_profile.ts create mode 100644 backend/debugProfiles.ts create mode 100644 backend/debug_config.ts create mode 100644 backend/debug_db_logging.ts create mode 100644 backend/debug_mode.ts create mode 100644 backend/debug_supabase.ts create mode 100644 backend/diagnose_profiles.ts create mode 100644 backend/docker-compose.yml create mode 100644 backend/dump_db.ts create mode 100644 backend/dump_recent.ts create mode 100644 backend/e2e_full_scenario.ts create mode 100644 backend/example_profile_config.json create mode 100644 backend/final_e2e_param_verification.ts create mode 100644 backend/fix_dashboard_rules.ts create mode 100644 backend/fix_imports.ts create mode 100644 backend/force_rules_reset.ts create mode 100644 backend/grafana/provisioning/datasources/datasource.yml create mode 100644 backend/inspect_btc.ts create mode 100644 backend/invariants.md create mode 100644 backend/list_history.ts create mode 100644 backend/live_signal_check.js create mode 100644 backend/manualOverrideCloseTrades.ts create mode 100644 backend/monitorFreshWindow.ts create mode 100644 backend/package.json create mode 100644 backend/phase8-validation.md create mode 100644 backend/pre-deploy.md create mode 100644 backend/print_btc_rules.ts create mode 100644 backend/print_symbols.js create mode 100644 backend/prometheus-metrics.md create mode 100644 backend/prometheus.yml create mode 100644 backend/proposed_risk_profiles.json create mode 100644 backend/quick_eth.ts create mode 100644 backend/reconcileAlpacaVsSupabase.ts create mode 100644 backend/reconcileAttributionRepair.ts create mode 100644 backend/reconcileCapitalLedgerState.ts create mode 100644 backend/reconcileClosedOrderFillData.ts create mode 100644 backend/reconcileExitBackfillOnce.ts create mode 100644 backend/reconcileMissingOrderCoverage.ts create mode 100644 backend/reconcileNoGoQtyMismatchOnce.ts create mode 100644 backend/reconcileSubTagRepair.ts create mode 100644 backend/reconcileTradeHistoryLifecycle.ts create mode 100644 backend/rename_profile.ts create mode 100644 backend/runCoverageSuite.ts create mode 100644 backend/runCriticalCoverageSuite.ts create mode 100644 backend/run_all_tests.ts create mode 100644 backend/runbooks/capital-invariant.md create mode 100644 backend/runbooks/database-outage.md create mode 100644 backend/runbooks/exchange-degradation.md create mode 100644 backend/runbooks/lock-contention.md create mode 100644 backend/runbooks/loop-health.md create mode 100644 backend/runbooks/reconciliation-exit-backfill.md create mode 100644 backend/runbooks/reconciliation.md create mode 100644 backend/schema/002_add_strategy_config.sql create mode 100644 backend/schema/003_add_profile_id_to_orders_and_history.sql create mode 100644 backend/schema/004_full_schema_sync.sql create mode 100644 backend/schema/005_add_trade_id_tracking.sql create mode 100644 backend/schema/006_add_trade_source.sql create mode 100644 backend/schema/007_trade_lifecycle_integrity_constraints.sql create mode 100644 backend/schema/008_schema_gap_backfill.sql create mode 100644 backend/schema/009_tenant_rls_orders_trade_history.sql create mode 100644 backend/schema/010_bot_state_snapshot.sql create mode 100644 backend/schema/011_capital_ledger.sql create mode 100644 backend/schema/012_entry_atomic_lifecycle.sql create mode 100644 backend/schema/013_distributed_entry_lock.sql create mode 100644 backend/schema/014_entry_row_lock.sql create mode 100644 backend/schema/015_reconciliation_lock.sql create mode 100644 backend/schema/016_add_strategy_marketplace.sql create mode 100644 backend/schema/017_reconciliation_exit_backfill_audit.sql create mode 100644 backend/schema/018_orders_order_id_uniqueness_repair.sql create mode 100644 backend/schema/019_orders_order_id_on_conflict_fix.sql create mode 100644 backend/schema/020_add_orders_sub_tag.sql create mode 100644 backend/schema/021_capital_ledger_realized_pnl_reserve_fix.sql create mode 100644 backend/seedTwoBestProfiles.ts create mode 100644 backend/seed_profile_data.ts create mode 100644 backend/simple_check.ts create mode 100644 backend/simulate_high_activity.ts create mode 100644 backend/simulate_hot_loading.ts create mode 100644 backend/src/backtest/data/csvLoader.ts create mode 100644 backend/src/backtest/data/exchangeReplayAdapter.ts create mode 100644 backend/src/backtest/data/jsonLoader.ts create mode 100644 backend/src/backtest/data/krakenLoader.ts create mode 100644 backend/src/backtest/data/loadHistoricalData.ts create mode 100644 backend/src/backtest/data/normalize.ts create mode 100644 backend/src/backtest/engine/BacktestRunner.ts create mode 100644 backend/src/backtest/engine/VirtualExecutionEngine.ts create mode 100644 backend/src/backtest/engine/VirtualLedger.ts create mode 100644 backend/src/backtest/engine/timeFreeze.ts create mode 100644 backend/src/backtest/engine/warmup.ts create mode 100644 backend/src/backtest/exchange/ReplayExchangeConnector.ts create mode 100644 backend/src/backtest/guards.ts create mode 100644 backend/src/backtest/index.ts create mode 100644 backend/src/backtest/metrics/computeSummary.ts create mode 100644 backend/src/backtest/types.ts create mode 100644 backend/src/config/index.ts create mode 100644 backend/src/connectors/alpaca.ts create mode 100644 backend/src/connectors/ccxt.ts create mode 100644 backend/src/connectors/factory.ts create mode 100644 backend/src/connectors/types.ts create mode 100644 backend/src/domain/operationalEvents.ts create mode 100644 backend/src/domain/tradingEnums.ts create mode 100644 backend/src/index.ts create mode 100644 backend/src/scripts/README.md create mode 100644 backend/src/scripts/backfillTradeIds.ts create mode 100644 backend/src/scripts/cleanupStaleOrders.ts create mode 100644 backend/src/scripts/reconcileTradeLifecycle.ts create mode 100644 backend/src/scripts/revertExpiredOrders.ts create mode 100644 backend/src/scripts/verifyWebsocketContract.ts create mode 100644 backend/src/services/AutoTrader.ts create mode 100644 backend/src/services/CapitalLedger.ts create mode 100644 backend/src/services/ManualTrader.ts create mode 100644 backend/src/services/MetricsService.ts create mode 100644 backend/src/services/OrderStatusSyncService.ts create mode 100644 backend/src/services/SupabaseService.ts create mode 100644 backend/src/services/TradeExecutor.ts create mode 100644 backend/src/services/aiClient.ts create mode 100644 backend/src/services/apiServer.ts create mode 100644 backend/src/services/canonicalLifecycleService.ts create mode 100644 backend/src/services/distributedLockService.ts create mode 100644 backend/src/services/executionManager.ts create mode 100644 backend/src/services/healthTracker.ts create mode 100644 backend/src/services/notifier.ts create mode 100644 backend/src/services/observabilityService.ts create mode 100644 backend/src/services/reconciliationExitBackfillService.ts create mode 100644 backend/src/services/reconciliationOrderCoverageService.ts create mode 100644 backend/src/services/reconciliationParityHeartbeatService.ts create mode 100644 backend/src/services/reconciliationService.ts create mode 100644 backend/src/services/reconciliationSubTagRepairService.ts create mode 100644 backend/src/services/reconciliationWatchdogAutoResumeService.ts create mode 100644 backend/src/services/riskEngine.ts create mode 100644 backend/src/services/stateMerge.ts create mode 100644 backend/src/services/tradeMonitor.ts create mode 100644 backend/src/strategies/ProStrategyEngine.ts create mode 100644 backend/src/strategies/directionTracker.ts create mode 100644 backend/src/strategies/rules/AIAnalysisRule.ts create mode 100644 backend/src/strategies/rules/EntryTriggerRule.ts create mode 100644 backend/src/strategies/rules/MomentumRule.ts create mode 100644 backend/src/strategies/rules/RiskManagementRule.ts create mode 100644 backend/src/strategies/rules/SessionRule.ts create mode 100644 backend/src/strategies/rules/TrendBiasRule.ts create mode 100644 backend/src/strategies/rules/ZoneRule.ts create mode 100644 backend/src/strategies/rules/types.ts create mode 100644 backend/src/test_simulation.js create mode 100644 backend/src/utils/alpacaSubTag.ts create mode 100644 backend/src/utils/botSymbolScope.ts create mode 100644 backend/src/utils/indicators.ts create mode 100644 backend/src/utils/logger.ts create mode 100644 backend/src/utils/symbolMapper.ts create mode 100644 backend/strategy_config_schema.json create mode 100644 backend/testAlpacaSubTag.ts create mode 100644 backend/testBacktestIsolation.ts create mode 100644 backend/testConnectorAndAiCoverage.ts create mode 100644 backend/testCoreModuleCoverage.ts create mode 100644 backend/testFailureInjection.ts create mode 100644 backend/testLifecycleRegressions.ts create mode 100644 backend/testManualTraderCapitalGuard.ts create mode 100644 backend/testOrderStatusSyncRegressions.ts create mode 100644 backend/testReconciliationExitBackfillEvidenceGuard.ts create mode 100644 backend/testReconciliationParityHeartbeat.ts create mode 100644 backend/testReconciliationWatchdogAutoResume.ts create mode 100644 backend/testSessionRuleNormalization.ts create mode 100644 backend/testStateMergeCoverage.ts create mode 100644 backend/testStrictCapitalGuard.ts create mode 100644 backend/testSupabaseOrderPersistenceRegressions.ts create mode 100644 backend/testSupabaseTradeHistorySourceFallback.ts create mode 100644 backend/testTradeExecutorLifecycle.ts create mode 100644 backend/test_ai_cache.ts create mode 100644 backend/test_ai_fallback.ts create mode 100644 backend/test_ai_layer.ts create mode 100644 backend/test_alert.ts create mode 100644 backend/test_alpaca.ts create mode 100644 backend/test_alpaca_execution.ts create mode 100644 backend/test_alpaca_exhaustive.ts create mode 100644 backend/test_alpaca_exit.ts create mode 100644 backend/test_alpaca_full_cycle.ts create mode 100644 backend/test_broadcast_logic.ts create mode 100644 backend/test_feed_us.ts create mode 100644 backend/test_final.ts create mode 100644 backend/test_fuzzy_match.ts create mode 100644 backend/test_hot_loading.ts create mode 100644 backend/test_noslash_hardcoded.ts create mode 100644 backend/test_pro_strategy.ts create mode 100644 backend/test_query.ts create mode 100644 backend/test_risk_formula.ts create mode 100644 backend/test_risk_scenarios.ts create mode 100644 backend/test_safe.ts create mode 100644 backend/test_signal_sim.ts create mode 100644 backend/test_simulation.ts create mode 100644 backend/test_simulation_pro.ts create mode 100644 backend/test_slash.ts create mode 100644 backend/test_stock_baseline.ts create mode 100644 backend/test_strategy_logic.ts create mode 100644 backend/test_verbose_insert.ts create mode 100644 backend/test_wa.ts create mode 100644 backend/tsconfig.json create mode 100644 backend/verifyRlsPolicies.ts create mode 100644 backend/verifySchemaContract.ts create mode 100644 backend/verifySecretHygiene.ts create mode 100644 backend/verifySecurityGuards.ts create mode 100644 backend/verifyTenantIsolation.ts create mode 100644 backend/verify_btc_logic.ts create mode 100644 backend/verify_dynamic_config.ts create mode 100644 backend/verify_e2e_fix.ts create mode 100644 backend/verify_fetch.ts create mode 100644 backend/verify_final_e2e.ts create mode 100644 backend/verify_full_lifecycle.ts create mode 100644 backend/verify_order_logging.ts create mode 100644 backend/verify_profiles.ts create mode 100644 backend/verify_profiles_e2e.ts create mode 100644 backend/verify_realtime.ts create mode 100644 backend/verify_signals_live.ts create mode 100644 backend/verify_sl_tp_persistence.ts create mode 100644 backend/verify_sync.ts create mode 100644 backend/verify_traceability.ts create mode 100644 docker-compose.yml create mode 100644 mobile/.bolt/config.json create mode 100644 mobile/.gitignore create mode 100644 mobile/.prettierrc create mode 100644 mobile/README.md create mode 100644 mobile/app.json create mode 100644 mobile/app/(tabs)/_layout.tsx create mode 100644 mobile/app/(tabs)/history.tsx create mode 100644 mobile/app/(tabs)/index.tsx create mode 100644 mobile/app/(tabs)/positions.tsx create mode 100644 mobile/app/(tabs)/settings.tsx create mode 100644 mobile/app/(tabs)/strategies.tsx create mode 100644 mobile/app/+not-found.tsx create mode 100644 mobile/app/_layout.tsx create mode 100644 mobile/app/chat.tsx create mode 100644 mobile/app/marketplace.tsx create mode 100644 mobile/assets/images/favicon.png create mode 100644 mobile/assets/images/icon.png create mode 100644 mobile/components/AnimatedCard.tsx create mode 100644 mobile/components/CustomTabBar.tsx create mode 100644 mobile/components/FloatingChatButton.tsx create mode 100644 mobile/components/PillBadge.tsx create mode 100644 mobile/components/PressableScale.tsx create mode 100644 mobile/components/ProductAvailabilityGate.tsx create mode 100644 mobile/components/PulsingDot.tsx create mode 100644 mobile/components/SegmentedControl.tsx create mode 100644 mobile/components/SkeletonLoader.tsx create mode 100644 mobile/components/Sparkline.tsx create mode 100644 mobile/components/dashboard/ActiveAlerts.tsx create mode 100644 mobile/components/dashboard/MarketTicker.tsx create mode 100644 mobile/components/dashboard/PortfolioHeroCard.tsx create mode 100644 mobile/components/dashboard/QuickPositions.tsx create mode 100644 mobile/components/dashboard/StatusBanner.tsx create mode 100644 mobile/components/dashboard/WinRateStrip.tsx create mode 100644 mobile/constants/mockData.ts create mode 100644 mobile/constants/theme.ts create mode 100644 mobile/docs/prompt.md create mode 100644 mobile/eslint.config.js create mode 100644 mobile/hooks/useFrameworkReady.ts create mode 100644 mobile/lib/runtime.ts create mode 100644 mobile/package.json create mode 100644 mobile/prompt.md create mode 100644 mobile/tsconfig.json create mode 100644 mobile/utils/format.ts create mode 100644 mobile/utils/haptics.ts create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 scripts/verify.sh create mode 100644 shared/backend-control-config.ts create mode 100644 shared/control-plane.ts create mode 100644 shared/platform-clients.ts create mode 100644 shared/platform-mobile.ts create mode 100644 shared/platform-web.ts create mode 100644 shared/product.json create mode 100644 shared/product.ts create mode 100644 shared/runtime.ts create mode 100644 shared/web-auth.tsx create mode 100644 tsconfig.base.json create mode 100644 web/.dockerignore create mode 100644 web/.gitignore create mode 100644 web/ADMIN_TRADE_CONTROL_UI.md create mode 100644 web/Dockerfile create mode 100644 web/LIVE_PULSE_TICKER.md create mode 100644 web/NAVIGATION_ACCESS_CONTROL.md create mode 100644 web/README.md create mode 100644 web/backtesting.md create mode 100644 web/dashboard-metrics-contract.md create mode 100644 web/eslint.config.js create mode 100644 web/guided-strategy-builder.md create mode 100644 web/index.html create mode 100644 web/metric-validation-runbook.md create mode 100644 web/package.json create mode 100644 web/pre-deploy.md create mode 100644 web/schema_reference.sql create mode 100644 web/src/App.css create mode 100644 web/src/App.dom.test.tsx create mode 100644 web/src/App.tsx create mode 100644 web/src/assets/react.svg create mode 100644 web/src/backtest/api.ts create mode 100644 web/src/backtest/components/BacktestComparePanel.tsx create mode 100644 web/src/backtest/components/BacktestConfigurator.tsx create mode 100644 web/src/backtest/components/BacktestResultsDashboard.tsx create mode 100644 web/src/backtest/components/BacktestRunnerPanel.dom.test.tsx create mode 100644 web/src/backtest/components/BacktestRunnerPanel.tsx create mode 100644 web/src/backtest/flags.ts create mode 100644 web/src/backtest/types.ts create mode 100644 web/src/backtest/useBacktestFeatureGate.ts create mode 100644 web/src/backtest/utils.test.ts create mode 100644 web/src/backtest/utils.ts create mode 100644 web/src/components/AlertFeed.css create mode 100644 web/src/components/AlertFeed.dom.test.tsx create mode 100644 web/src/components/AlertFeed.tsx create mode 100644 web/src/components/AuthContext.dom.test.tsx create mode 100644 web/src/components/AuthContext.test.ts create mode 100644 web/src/components/AuthContext.tsx create mode 100644 web/src/components/ChatControl.dom.test.tsx create mode 100644 web/src/components/ChatControl.test.ts create mode 100644 web/src/components/ChatControl.tsx create mode 100644 web/src/components/ComponentsSmoke.test.ts create mode 100644 web/src/components/EntryForm.dom.test.tsx create mode 100644 web/src/components/EntryForm.tsx create mode 100644 web/src/components/GlobalConfigManager.dom.test.tsx create mode 100644 web/src/components/GlobalConfigManager.tsx create mode 100644 web/src/components/LivePulseTicker.tsx create mode 100644 web/src/components/Login.dom.test.tsx create mode 100644 web/src/components/Login.tsx create mode 100644 web/src/components/MarketOpportunities.dom.test.tsx create mode 100644 web/src/components/MarketOpportunities.tsx create mode 100644 web/src/components/MarketTicker.tsx create mode 100644 web/src/components/PresetMarketplace.tsx create mode 100644 web/src/components/PriceChart.css create mode 100644 web/src/components/PriceChart.dom.test.tsx create mode 100644 web/src/components/PriceChart.tsx create mode 100644 web/src/components/ProductAccessibilityGate.tsx create mode 100644 web/src/components/ResetPassword.dom.test.tsx create mode 100644 web/src/components/ResetPassword.tsx create mode 100644 web/src/components/StrategyWizard.tsx create mode 100644 web/src/components/SymbolCard.css create mode 100644 web/src/components/SymbolCard.tsx create mode 100644 web/src/components/TradeProfileManager.dom.test.tsx create mode 100644 web/src/components/TradeProfileManager.test.ts create mode 100644 web/src/components/TradeProfileManager.tsx create mode 100644 web/src/hooks/useCanonicalLifecycle.ts create mode 100644 web/src/hooks/useWebSocket.dom.test.tsx create mode 100644 web/src/hooks/useWebSocket.test.ts create mode 100644 web/src/hooks/useWebSocket.ts create mode 100644 web/src/index.css create mode 100644 web/src/lib/PresetRegistry.ts create mode 100644 web/src/lib/RiskStyleTemplates.ts create mode 100644 web/src/lib/StrategyExplanationService.ts create mode 100644 web/src/lib/TierPolicy.ts create mode 100644 web/src/lib/canonicalLifecycleApi.ts create mode 100644 web/src/lib/const.test.ts create mode 100644 web/src/lib/const.ts create mode 100644 web/src/lib/orderLifecycleLedger.test.ts create mode 100644 web/src/lib/orderLifecycleLedger.ts create mode 100644 web/src/lib/runtime.ts create mode 100644 web/src/lib/supabaseClient.test.ts create mode 100644 web/src/lib/supabaseClient.ts create mode 100644 web/src/lib/tradeHistoryLedger.diagnostic.test.ts create mode 100644 web/src/lib/tradeHistoryLedger.test.ts create mode 100644 web/src/lib/tradeHistoryLedger.ts create mode 100644 web/src/main.tsx create mode 100644 web/src/tabs/AdminTab.dom.test.tsx create mode 100644 web/src/tabs/AdminTab.tsx create mode 100644 web/src/tabs/BacktestTab.tsx create mode 100644 web/src/tabs/ConfigTab.dom.test.tsx create mode 100644 web/src/tabs/ConfigTab.tsx create mode 100644 web/src/tabs/EntriesTab.dom.test.tsx create mode 100644 web/src/tabs/EntriesTab.tsx create mode 100644 web/src/tabs/HistoryTab.dom.test.tsx create mode 100644 web/src/tabs/HistoryTab.tsx create mode 100644 web/src/tabs/MarketplaceTab.tsx create mode 100644 web/src/tabs/MembershipTab.tsx create mode 100644 web/src/tabs/MyStrategiesTab.tsx create mode 100644 web/src/tabs/OverviewTab.dom.test.tsx create mode 100644 web/src/tabs/OverviewTab.test.ts create mode 100644 web/src/tabs/OverviewTab.tsx create mode 100644 web/src/tabs/PositionsTab.dom.test.tsx create mode 100644 web/src/tabs/PositionsTab.helpers.test.ts create mode 100644 web/src/tabs/PositionsTab.test.ts create mode 100644 web/src/tabs/PositionsTab.tsx create mode 100644 web/src/tabs/ReconciliationAuditPanel.tsx create mode 100644 web/src/tabs/SettingsTab.dom.test.tsx create mode 100644 web/src/tabs/SettingsTab.tsx create mode 100644 web/src/tabs/SignalsTab.tsx create mode 100644 web/src/tabs/TabHelpers.test.ts create mode 100644 web/src/tabs/TabSuite.test.ts create mode 100644 web/src/test/setup.ts create mode 100644 web/tsconfig.app.json create mode 100644 web/tsconfig.json create mode 100644 web/tsconfig.node.json create mode 100644 web/vercel.json create mode 100644 web/vite.config.ts create mode 100644 web/vite.svg create mode 100644 web/vitest.full.config.ts create mode 100644 web/workspace-system-scan-2026-02-21.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8370369 --- /dev/null +++ b/.env.example @@ -0,0 +1,31 @@ +# Shared product identity +PRODUCT_ID=invttrdg +PRODUCT_DISPLAY_NAME=ByteLyst Trading + +# Shared platform-service endpoint +PLATFORM_API_URL=http://localhost:4003/api + +# Product backend endpoint +TRADING_API_URL=http://localhost:4018/api + +# Web-specific public envs +NEXT_PUBLIC_PRODUCT_ID=invttrdg +NEXT_PUBLIC_PLATFORM_URL=http://localhost:4003/api +NEXT_PUBLIC_TRADING_API_URL=http://localhost:4018/api +VITE_PRODUCT_ID=invttrdg +VITE_PLATFORM_URL=http://localhost:4003/api +VITE_TRADING_API_URL=http://localhost:4018/api + +# Mobile public envs +EXPO_PUBLIC_PRODUCT_ID=invttrdg +EXPO_PUBLIC_PLATFORM_URL=http://localhost:4003/api +EXPO_PUBLIC_TRADING_API_URL=http://localhost:4018/api + +# Backend envs +PORT=4018 +NODE_ENV=development +CORS_ALLOWED_ORIGINS=http://localhost:3048,http://localhost:8081 +SUPABASE_URL= +SUPABASE_ANON_KEY= +SUPABASE_SERVICE_ROLE_KEY= + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c66b123 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +node_modules +.pnpm-store +dist +build +coverage +.turbo +.next +.expo +.expo-shared +android +ios +web-build +*.log +.DS_Store +.env +.env.local +.env.*.local +backend/bot_state.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..e369e61 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# ByteLyst Investment Trading + +Canonical monorepo for the ByteLyst trading product. + +## Workspaces + +- `backend/` — trading backend and execution/runtime APIs +- `web/` — trading dashboard +- `mobile/` — Expo mobile app +- `shared/` — canonical product identity and shared runtime helpers + +## Shared dependencies + +This repo consumes local ByteLyst common-platform packages from: + +- `../learning_ai_common_plat/packages/*` + +## Core principles + +- backend-authoritative trading state +- platform-service for auth, kill switch, telemetry, and flags +- no duplicated bootstrap logic across surfaces +- domain-specific trading logic stays product-owned + +## Common commands + +```bash +pnpm install +pnpm verify +pnpm build +``` + diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..e9d9a97 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,16 @@ +node_modules +! .env.example +.env +.env.* +*.env +old_env.txt +new_env.txt +temp_env.txt +users_dump.json +profiles_dump.json +bot_state.json +*.log +dist +coverage +.DS_Store +bot_state.json.bak diff --git a/backend/ADMIN_TRADE_CONTROL_ARCHITECTURE.md b/backend/ADMIN_TRADE_CONTROL_ARCHITECTURE.md new file mode 100644 index 0000000..b1869c0 --- /dev/null +++ b/backend/ADMIN_TRADE_CONTROL_ARCHITECTURE.md @@ -0,0 +1,266 @@ +# Admin Trade Control - Architecture Diagram + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ ADMIN TRADE CONTROL SYSTEM │ +│ │ +│ ┌───────────────────────────────────────────────────────────────────────┐ │ +│ │ FRONTEND (Dashboard) │ │ +│ │ │ │ +│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ +│ │ │ Header (All Pages) │ │ │ +│ │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ +│ │ │ │ Trading Status Badge │ │ │ │ +│ │ │ │ ⏸️ Trading Paused OR ▶️ Trading Active │ │ │ │ +│ │ │ │ (Orange) (Green) │ │ │ │ +│ │ │ │ Tooltip: "Paused by admin@example.com" │ │ │ │ +│ │ │ └──────────────────────────────────────────────────────────┘ │ │ │ +│ │ └─────────────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ +│ │ │ Admin Tab (Admin Users Only) │ │ │ +│ │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ +│ │ │ │ Trading Control Panel │ │ │ │ +│ │ │ │ ┌────────────────────────────────────────────────────┐ │ │ │ │ +│ │ │ │ │ Status Banner │ │ │ │ │ +│ │ │ │ │ AUTO-TRADING: PAUSED / RUNNING │ │ │ │ │ +│ │ │ │ │ "No new positions will be opened..." │ │ │ │ │ +│ │ │ │ └────────────────────────────────────────────────────┘ │ │ │ │ +│ │ │ │ ┌────────────────────┐ ┌────────────────────────────┐ │ │ │ │ +│ │ │ │ │ ⏸️ Pause Auto │ │ ▶️ Resume Auto Trading │ │ │ │ │ +│ │ │ │ │ Trading │ │ │ │ │ │ │ +│ │ │ │ │ (disabled if │ │ (disabled if running) │ │ │ │ │ +│ │ │ │ │ already paused) │ │ │ │ │ │ │ +│ │ │ │ └────────────────────┘ └────────────────────────────┘ │ │ │ │ +│ │ │ │ ┌────────────────────────────────────────────────────┐ │ │ │ │ +│ │ │ │ │ Safety Notice │ │ │ │ │ +│ │ │ │ │ "Pausing blocks new entries only. Existing │ │ │ │ │ +│ │ │ │ │ positions continue to be managed." │ │ │ │ │ +│ │ │ │ └────────────────────────────────────────────────────┘ │ │ │ │ +│ │ │ └──────────────────────────────────────────────────────────┘ │ │ │ +│ │ └─────────────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ +│ │ │ WebSocket Connection │ │ │ +│ │ │ • Receives 'health_update' events │ │ │ +│ │ │ • Updates botState.health.tradingControl │ │ │ +│ │ │ • UI reflects backend state in real-time │ │ │ +│ │ └─────────────────────────────────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ▲ │ +│ │ │ +│ WebSocket │ HTTP API │ +│ health_update POST /internal/trading/pause │ +│ │ POST /internal/trading/resume │ +│ │ GET /internal/trading/status │ +│ │ │ +│ ┌───────────────────────────────────────────────────────────────────────┐ │ +│ │ BACKEND (Bot Service) │ │ +│ │ │ │ +│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ +│ │ │ API Server (apiServer.ts) │ │ │ +│ │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ +│ │ │ │ Admin Control Endpoints │ │ │ │ +│ │ │ │ • requireAuth middleware │ │ │ │ +│ │ │ │ • requireAdmin middleware │ │ │ │ +│ │ │ │ • Audit logging │ │ │ │ +│ │ │ │ • Idempotent operations │ │ │ │ +│ │ │ └──────────────────────────────────────────────────────────┘ │ │ │ +│ │ │ │ │ │ │ +│ │ │ ▼ │ │ │ +│ │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ +│ │ │ │ healthTracker.recordTradingControl() │ │ │ │ +│ │ │ │ • Updates in-memory state │ │ │ │ +│ │ │ │ • Broadcasts health_update via WebSocket │ │ │ │ +│ │ │ │ • Persists to disk (bot_state.json) │ │ │ │ +│ │ │ │ • Persists to Supabase │ │ │ │ +│ │ │ └──────────────────────────────────────────────────────────┘ │ │ │ +│ │ └─────────────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ +│ │ │ Health Tracker (healthTracker.ts) │ │ │ +│ │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ +│ │ │ │ Trading Control State │ │ │ │ +│ │ │ │ { │ │ │ │ +│ │ │ │ mode: 'RUNNING' | 'PAUSED', │ │ │ │ +│ │ │ │ lastChangedBy: string, │ │ │ │ +│ │ │ │ lastChangedAt: number, │ │ │ │ +│ │ │ │ reason?: string │ │ │ │ +│ │ │ │ } │ │ │ │ +│ │ │ └──────────────────────────────────────────────────────────┘ │ │ │ +│ │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ +│ │ │ │ isPaused(): boolean │ │ │ │ +│ │ │ │ • Returns true if mode === 'PAUSED' │ │ │ │ +│ │ │ │ • Called by enforcement points │ │ │ │ +│ │ │ └──────────────────────────────────────────────────────────┘ │ │ │ +│ │ └─────────────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ +│ │ │ ENFORCEMENT POINTS │ │ │ +│ │ │ │ │ │ +│ │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ +│ │ │ │ AutoTrader.handleSignal() (line 106-109) │ │ │ │ +│ │ │ │ ┌────────────────────────────────────────────────────┐ │ │ │ │ +│ │ │ │ │ if (healthTracker.isPaused()) { │ │ │ │ │ +│ │ │ │ │ logger.info("Entry BLOCKED: Bot is PAUSED"); │ │ │ │ │ +│ │ │ │ │ return; // ❌ Block new entry │ │ │ │ │ +│ │ │ │ │ } │ │ │ │ │ +│ │ │ │ └────────────────────────────────────────────────────┘ │ │ │ │ +│ │ │ └──────────────────────────────────────────────────────────┘ │ │ │ +│ │ │ │ │ │ +│ │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ +│ │ │ │ TradeExecutor.openPosition() (line 531-534) │ │ │ │ +│ │ │ │ ┌────────────────────────────────────────────────────┐ │ │ │ │ +│ │ │ │ │ if (healthTracker.isPaused()) { │ │ │ │ │ +│ │ │ │ │ logger.info("Entry BLOCKED: Bot is PAUSED"); │ │ │ │ │ +│ │ │ │ │ return { success: false, error: '...' }; │ │ │ │ │ +│ │ │ │ │ } │ │ │ │ │ +│ │ │ │ └────────────────────────────────────────────────────┘ │ │ │ │ +│ │ │ └──────────────────────────────────────────────────────────┘ │ │ │ +│ │ │ │ │ │ +│ │ │ ✅ CONTINUES WHEN PAUSED: │ │ │ +│ │ │ • closePosition() - Exit orders │ │ │ +│ │ │ • monitorStopLoss() - SL monitoring │ │ │ +│ │ │ • monitorTakeProfit() - TP monitoring │ │ │ +│ │ │ • reconcilePositions() - Position sync │ │ │ +│ │ │ • syncOrderStatus() - Order status updates │ │ │ +│ │ └─────────────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ +│ │ │ Persistence Layer │ │ │ +│ │ │ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────┐ │ │ │ +│ │ │ │ In-Memory │ │ Disk │ │ Supabase │ │ │ │ +│ │ │ │ (HealthTracker) │→ │ (bot_state.json)│→ │ (Database) │ │ │ │ +│ │ │ │ Singleton │ │ Local file │ │ Remote DB │ │ │ │ +│ │ │ └──────────────────┘ └──────────────────┘ └──────────────┘ │ │ │ +│ │ │ • State restored on bot restart │ │ │ +│ │ │ • Supabase preferred, disk fallback │ │ │ +│ │ └─────────────────────────────────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────────────────┐ +│ CONTROL FLOW │ +└─────────────────────────────────────────────────────────────────────────────┘ + +PAUSE FLOW: +1. Admin clicks "Pause Auto Trading" button +2. Frontend calls POST /internal/trading/pause with auth token +3. API Server validates: requireAuth + requireAdmin +4. API Server calls healthTracker.recordTradingControl({ mode: 'PAUSED' }) +5. HealthTracker updates in-memory state +6. API Server broadcasts health_update via WebSocket +7. API Server persists to disk and Supabase +8. Frontend receives health_update, updates UI +9. Header badge shows "⏸️ Trading Paused" +10. AutoTrader/TradeExecutor check isPaused() before new entries +11. New entries blocked, existing positions continue + +RESUME FLOW: +1. Admin clicks "Resume Auto Trading" button +2. Frontend calls POST /internal/trading/resume with auth token +3. API Server validates: requireAuth + requireAdmin +4. API Server calls healthTracker.recordTradingControl({ mode: 'RUNNING' }) +5. HealthTracker updates in-memory state +6. API Server broadcasts health_update via WebSocket +7. API Server persists to disk and Supabase +8. Frontend receives health_update, updates UI +9. Header badge shows "▶️ Trading Active" +10. AutoTrader/TradeExecutor allow new entries +11. Normal trading resumes + +RESTART RECOVERY: +1. Bot starts up +2. API Server calls loadState() +3. Reads bot_state.json from disk +4. Restores tradingControl state +5. Calls healthTracker.recordTradingControl() with restored state +6. Trading resumes in last known mode (PAUSED or RUNNING) + +┌─────────────────────────────────────────────────────────────────────────────┐ +│ SECURITY LAYERS │ +└─────────────────────────────────────────────────────────────────────────────┘ + +Layer 1: Authentication +├─ All endpoints require valid JWT token +├─ Token verified via Supabase auth +└─ Unauthorized requests → 401 + +Layer 2: Authorization +├─ Pause/Resume require role = 'admin' +├─ Role checked in user profile +└─ Non-admin requests → 403 + +Layer 3: UI Guards +├─ Trading Control Panel hidden for non-admin +├─ Header badge visible to all (read-only) +└─ Buttons disabled when already in target state + +Layer 4: Audit Trail +├─ All pause/resume actions logged +├─ Includes: userId, timestamp, reason +└─ Logs written to console and observability + +┌─────────────────────────────────────────────────────────────────────────────┐ +│ DATA FLOW DIAGRAM │ +└─────────────────────────────────────────────────────────────────────────────┘ + +Admin Action + │ + ▼ +Frontend (AdminTab.tsx) + │ + │ HTTP POST /internal/trading/pause + │ Authorization: Bearer + │ Body: { reason: "..." } + ▼ +API Server (apiServer.ts) + │ + ├─ requireAuth middleware → Verify JWT + ├─ requireAdmin middleware → Check role + │ + ▼ +healthTracker.recordTradingControl() + │ + ├─ Update in-memory state + ├─ Broadcast WebSocket health_update + ├─ Persist to bot_state.json + └─ Persist to Supabase + │ + ▼ +WebSocket Broadcast + │ + ▼ +Frontend (useWebSocket.ts) + │ + ├─ Receive health_update event + ├─ Update botState.health.tradingControl + │ + ▼ +UI Updates + │ + ├─ Header badge: "⏸️ Trading Paused" + ├─ Admin panel: Status banner updates + └─ Buttons: Pause disabled, Resume enabled + +Trading Loop + │ + ▼ +AutoTrader.handleSignal() + │ + ├─ Check: healthTracker.isPaused() + │ ├─ if true → Block entry, return + │ └─ if false → Continue to entry logic + │ + ▼ +TradeExecutor.openPosition() + │ + ├─ Check: healthTracker.isPaused() + │ ├─ if true → Return { success: false, error: '...' } + │ └─ if false → Place order on exchange + │ + ▼ +Exchange Order Placement +``` diff --git a/backend/ADMIN_TRADE_CONTROL_IMPLEMENTATION.md b/backend/ADMIN_TRADE_CONTROL_IMPLEMENTATION.md new file mode 100644 index 0000000..2f918e0 --- /dev/null +++ b/backend/ADMIN_TRADE_CONTROL_IMPLEMENTATION.md @@ -0,0 +1,336 @@ +# Admin Trade Control Implementation + +## Overview + +This document describes the implementation of the **Admin Trade Control Feature** that allows authorized administrators to pause and resume auto-trading from the dashboard. This is a production-grade safety control that prevents new trade entries while allowing existing positions to be managed. + +## Architecture + +### Backend (Authoritative) + +The backend is the **single source of truth** for trading control state. All enforcement happens server-side. + +#### 1. Trading Control State + +**Location**: `bytelyst-trading-bot-service/src/services/healthTracker.ts` + +```typescript +export interface TradingControlSnapshot { + mode: 'RUNNING' | 'PAUSED'; + lastChangedBy: string; + lastChangedAt: number; + reason?: string; +} +``` + +The state is: +- Stored in-memory in `HealthTracker` singleton +- Persisted to disk in `bot_state.json` +- Persisted to Supabase for multi-instance recovery +- Restored on bot restart + +#### 2. Enforcement Points (MANDATORY) + +**Auto-Trading Enforcement**: + +1. **AutoTrader.ts** (Line 106-109): + ```typescript + if (healthTracker.isPaused()) { + logger.info(`[AutoTrader] 🛑 Entry BLOCKED for ${symbol}: Bot is PAUSED by admin.`); + return; + } + ``` + +2. **TradeExecutor.ts** (Line 531-534): + ```typescript + if (healthTracker.isPaused()) { + logger.info(`[TradeExecutor] 🛑 Entry BLOCKED for ${symbol}: Bot is PAUSED by admin.`); + return { success: false, error: 'Trade execution is paused by administrator' }; + } + ``` + +**What Continues When Paused**: +- Exit order execution +- Stop-loss monitoring +- Take-profit monitoring +- Position reconciliation +- Order status synchronization +- Health monitoring + +#### 3. Admin Control API + +**Location**: `bytelyst-trading-bot-service/src/services/apiServer.ts` (Lines 1049-1090) + +**Endpoints**: + +``` +GET /internal/trading/status +POST /internal/trading/pause +POST /internal/trading/resume +``` + +**Security**: +- All endpoints require authentication (`requireAuth` middleware) +- Pause/Resume require admin role (`requireAdmin` middleware) +- Actions are logged with audit trail + +**Example Request**: +```bash +curl -X POST http://localhost:5000/internal/trading/pause \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"reason": "Manual admin pause"}' +``` + +**Response**: +```json +{ + "success": true, + "status": { + "mode": "PAUSED", + "lastChangedBy": "user@example.com", + "lastChangedAt": 1708200000000, + "reason": "Manual admin pause" + } +} +``` + +#### 4. Health & Observability + +The trading control state is included in the health snapshot: + +```typescript +export interface HealthSnapshot { + // ... other health metrics + tradingControl: TradingControlSnapshot; +} +``` + +This flows through: +- WebSocket `health_update` events +- `/internal/health` endpoint +- Bot state persistence + +### Frontend (Read-Only Reflection) + +The frontend **never assumes** trading state. It always reflects the backend state. + +#### 1. Admin Panel Controls + +**Location**: `bytelyst-trading-dashboard-web/src/tabs/AdminTab.tsx` + +**Features**: +- **Status Banner**: Shows current trading mode (PAUSED/RUNNING) +- **Pause Button**: Calls `/internal/trading/pause` +- **Resume Button**: Calls `/internal/trading/resume` +- **Error Display**: Shows API errors +- **Safety Notice**: Explains behavior when paused + +**UI Rules**: +- Buttons are disabled when already in target state +- Loading state shown during API calls +- Status derived from `botState.health.tradingControl` +- Tooltips explain behavior + +#### 2. Header Status Indicator + +**Location**: `bytelyst-trading-dashboard-web/src/App.tsx` (Lines 195-231) + +A global status badge in the header shows: +- ⏸️ **Trading Paused** (orange) - when paused +- ▶️ **Trading Active** (green) - when running +- Tooltip with who paused and when + +This is visible on **all pages**, not just Admin. + +#### 3. WebSocket Integration + +**Location**: `bytelyst-trading-dashboard-web/src/hooks/useWebSocket.ts` + +The `health_update` event updates the trading control state: + +```typescript +newSocket.on('health_update', (health: HealthSnapshot) => { + setBotState(prev => ({ + ...prev, + health + })); +}); +``` + +## Security + +### Authorization + +1. **Authentication**: All endpoints require valid JWT token +2. **Admin Role**: Pause/Resume require `role = 'admin'` in user profile +3. **UI Guards**: Admin controls hidden for non-admin users +4. **Backend Re-check**: Authorization verified on every request + +### Audit Trail + +All trading control changes are logged: + +``` +[Admin] Trading PAUSED by user@example.com. Reason: Manual admin pause +[Admin] Trading RESUMED by user@example.com. +``` + +## Failure & Edge Cases + +### API Failure +- Frontend shows error toast +- UI state does not change +- User can retry + +### WebSocket Delay +- Last-known timestamp shown in status +- User can see staleness +- Status updates when websocket reconnects + +### Backend Restart +- Trading control mode restored from: + 1. Supabase snapshot (preferred) + 2. `bot_state.json` (fallback) +- Default mode: `RUNNING` + +### Race Conditions +- Backend state is authoritative +- UI always reflects backend state +- No client-side assumptions + +## Testing + +### Backend Tests + +**Unit Tests**: +```typescript +describe('HealthTracker', () => { + it('should block entries when paused', () => { + healthTracker.recordTradingControl({ mode: 'PAUSED', ... }); + expect(healthTracker.isPaused()).toBe(true); + }); + + it('should allow exits when paused', () => { + // exits still execute + }); +}); +``` + +**Integration Tests**: +```typescript +describe('Trading Control', () => { + it('should block new entries when paused', async () => { + await pauseTrading(); + const result = await autoTrader.handleSignal(...); + expect(result).toBeUndefined(); // entry blocked + }); + + it('should allow entries after resume', async () => { + await resumeTrading(); + const result = await autoTrader.handleSignal(...); + expect(result).toBeDefined(); // entry allowed + }); +}); +``` + +### Frontend Tests + +**DOM Tests**: +```typescript +describe('AdminTab', () => { + it('should disable pause button when already paused', () => { + render(); + expect(screen.getByText('Pause Auto Trading')).toBeDisabled(); + }); + + it('should show paused banner when paused', () => { + render(); + expect(screen.getByText('AUTO-TRADING: PAUSED')).toBeInTheDocument(); + }); +}); +``` + +## Deployment Checklist + +- [x] Backend enforcement points implemented +- [x] Admin API endpoints secured +- [x] Trading control state persisted +- [x] Frontend UI controls implemented +- [x] Header status indicator added +- [x] WebSocket updates configured +- [x] Error handling implemented +- [x] Security guards in place +- [x] Audit logging enabled +- [ ] Backend unit tests written +- [ ] Backend integration tests written +- [ ] Frontend DOM tests written +- [ ] End-to-end testing completed +- [ ] Documentation reviewed + +## Usage + +### For Admins + +1. Navigate to **Admin** tab (🛡️ icon in header) +2. Scroll to **Trading Control** section +3. Click **Pause Auto Trading** to stop new entries +4. Click **Resume Auto Trading** to allow new entries +5. Monitor status in header badge (visible on all pages) + +### For Developers + +**Check if trading is paused**: +```typescript +if (healthTracker.isPaused()) { + // Block entry logic + return; +} +``` + +**Programmatically pause trading**: +```typescript +healthTracker.recordTradingControl({ + mode: 'PAUSED', + lastChangedBy: 'system', + lastChangedAt: Date.now(), + reason: 'Automated safety pause' +}); +``` + +## Monitoring + +### Metrics to Track + +1. **Pause/Resume Events**: Count and frequency +2. **Blocked Entry Attempts**: How many entries were blocked while paused +3. **Pause Duration**: Time between pause and resume +4. **Who Paused**: Track which admins are using this feature + +### Alerts + +Consider alerting on: +- Trading paused for > 1 hour +- Multiple pause/resume cycles in short time +- Pause during high-volatility periods + +## Future Enhancements + +1. **Scheduled Pause**: Allow scheduling pause/resume +2. **Profile-Level Control**: Pause specific profiles only +3. **Symbol-Level Control**: Pause specific symbols only +4. **Conditional Resume**: Auto-resume based on conditions +5. **Pause Reasons**: Predefined reason dropdown +6. **Pause History**: Log of all pause/resume events + +## Conclusion + +This implementation provides a **production-grade safety control** for managing auto-trading execution. It prioritizes: + +✅ **Correctness**: Backend enforcement, no shortcuts +✅ **Security**: Admin-only, audited, idempotent +✅ **Observability**: Clear status, audit logs, health metrics +✅ **Safety**: Existing positions continue lifecycle +✅ **UX**: Clear indicators, tooltips, error handling + +The system is designed to handle money-at-risk scenarios with appropriate safeguards and fail-safes. diff --git a/backend/ADMIN_TRADE_CONTROL_QUICK_REF.md b/backend/ADMIN_TRADE_CONTROL_QUICK_REF.md new file mode 100644 index 0000000..3fb860f --- /dev/null +++ b/backend/ADMIN_TRADE_CONTROL_QUICK_REF.md @@ -0,0 +1,314 @@ +# Admin Trade Control - Quick Reference + +## 🎯 What It Does + +Allows **admin users** to pause and resume auto-trading from the dashboard. + +- **When PAUSED**: No new trade entries are placed +- **When RUNNING**: Normal auto-trading resumes +- **Always**: Existing positions continue to be managed (exits, SL, TP) + +--- + +## 🔐 Security + +| Check | Implementation | +|-------|----------------| +| Authentication | All endpoints require valid JWT token | +| Authorization | Pause/Resume require `role = 'admin'` | +| UI Guards | Controls hidden for non-admin users | +| Audit Logs | All actions logged with user and reason | + +--- + +## 📡 API Endpoints + +### Get Status +```bash +GET /internal/trading/status +Authorization: Bearer + +Response: +{ + "mode": "RUNNING", + "lastChangedBy": "admin@example.com", + "lastChangedAt": 1708200000000, + "reason": "Manual resume" +} +``` + +### Pause Trading +```bash +POST /internal/trading/pause +Authorization: Bearer +Content-Type: application/json + +{ + "reason": "Market volatility" +} + +Response: +{ + "success": true, + "status": { "mode": "PAUSED", ... } +} +``` + +### Resume Trading +```bash +POST /internal/trading/resume +Authorization: Bearer +Content-Type: application/json + +{ + "reason": "Conditions normalized" +} + +Response: +{ + "success": true, + "status": { "mode": "RUNNING", ... } +} +``` + +--- + +## 🖥️ UI Components + +### Header Badge (All Pages) +``` +┌─────────────────────────┐ +│ ⏸️ Trading Paused │ ← Orange when paused +│ No new entries │ +└─────────────────────────┘ + +┌─────────────────────────┐ +│ ▶️ Trading Active │ ← Green when running +│ Entries allowed │ +└─────────────────────────┘ +``` + +### Admin Tab Controls +``` +Trading Control +┌───────────────────────────────────────────┐ +│ AUTO-TRADING: PAUSED │ +│ No new positions will be opened. │ +│ Existing positions are still managed. │ +│ │ +│ Last Changed: 2026-02-17 5:30 PM │ +│ by admin@example.com │ +└───────────────────────────────────────────┘ + +┌──────────────────┐ ┌──────────────────────┐ +│ ⏸️ Pause Auto │ │ ▶️ Resume Auto │ +│ Trading │ │ Trading │ +│ (disabled) │ │ (enabled) │ +└──────────────────┘ └──────────────────────┘ + +⚠️ Safety Note: Pausing blocks new entries only. + Existing positions continue to be managed. +``` + +--- + +## 🔧 Code Integration + +### Check if Paused +```typescript +import { healthTracker } from './services/healthTracker'; + +if (healthTracker.isPaused()) { + logger.info('Entry blocked: Trading is paused'); + return; +} +``` + +### Programmatic Control +```typescript +// Pause +healthTracker.recordTradingControl({ + mode: 'PAUSED', + lastChangedBy: 'system', + lastChangedAt: Date.now(), + reason: 'Automated safety pause' +}); + +// Resume +healthTracker.recordTradingControl({ + mode: 'RUNNING', + lastChangedBy: 'system', + lastChangedAt: Date.now(), + reason: 'Conditions normalized' +}); +``` + +--- + +## 🛡️ Enforcement Points + +| File | Line | What It Does | +|------|------|--------------| +| `AutoTrader.ts` | 106-109 | Blocks new entries when paused | +| `TradeExecutor.ts` | 531-534 | Blocks openPosition when paused | + +**What Continues:** +- ✅ Exit orders +- ✅ Stop-loss monitoring +- ✅ Take-profit monitoring +- ✅ Position reconciliation +- ✅ Order status sync + +--- + +## 💾 State Persistence + +``` +In-Memory (HealthTracker) + ↓ +Disk (bot_state.json) + ↓ +Database (Supabase) +``` + +**On Restart:** +1. Load from Supabase (preferred) +2. Fallback to `bot_state.json` +3. Default to `RUNNING` if not found + +--- + +## 🧪 Testing Checklist + +### Backend +- [ ] Pause blocks new entries +- [ ] Resume allows new entries +- [ ] Existing positions continue when paused +- [ ] State persists across restart +- [ ] Non-admin gets 403 + +### Frontend +- [ ] Pause button disabled when paused +- [ ] Resume button disabled when running +- [ ] Header badge shows correct status +- [ ] Error displayed on API failure +- [ ] Loading state shown during API call + +--- + +## 📊 Monitoring + +### Metrics to Track +- Pause/resume event count +- Blocked entry attempts while paused +- Pause duration (time between pause/resume) +- Who paused (user tracking) + +### Alerts +- Trading paused > 1 hour +- Multiple pause/resume cycles in short time +- Pause during high volatility + +--- + +## 🚨 Troubleshooting + +### Issue: Pause button not working +**Check:** +1. User has `role = 'admin'` in profile +2. Valid JWT token in request +3. Backend logs for errors +4. Network tab for API response + +### Issue: Status not updating in UI +**Check:** +1. WebSocket connection active +2. `health_update` events received +3. Browser console for errors +4. Refresh page to force sync + +### Issue: Entries still being placed when paused +**Check:** +1. Backend logs show pause enforcement +2. `healthTracker.isPaused()` returns true +3. AutoTrader/TradeExecutor checking pause state +4. No cached state in trading loop + +### Issue: State lost after restart +**Check:** +1. `bot_state.json` exists and readable +2. Supabase connection working +3. `loadState()` called on startup +4. Logs show state restoration + +--- + +## 📚 Documentation Files + +| File | Purpose | +|------|---------| +| `ADMIN_TRADE_CONTROL_SUMMARY.md` | Executive summary | +| `ADMIN_TRADE_CONTROL_IMPLEMENTATION.md` | Full implementation guide | +| `ADMIN_TRADE_CONTROL_TEST_PLAN.md` | Testing requirements | +| `ADMIN_TRADE_CONTROL_ARCHITECTURE.md` | Visual diagrams | +| `ADMIN_TRADE_CONTROL_QUICK_REF.md` | This quick reference | + +--- + +## ✅ Pre-Production Checklist + +- [ ] All backend unit tests pass +- [ ] All frontend component tests pass +- [ ] Integration tests pass +- [ ] Manual testing completed +- [ ] Security review completed +- [ ] Documentation reviewed +- [ ] Audit logging verified +- [ ] State persistence verified +- [ ] Error handling verified +- [ ] UI/UX reviewed + +--- + +## 🎉 Quick Start + +### For Admins +1. Login as admin user +2. Go to Admin tab (🛡️) +3. Find "Trading Control" section +4. Click "Pause" or "Resume" +5. Check header badge for status + +### For Developers +1. Read `ADMIN_TRADE_CONTROL_IMPLEMENTATION.md` +2. Review enforcement points in code +3. Run test suite +4. Deploy to staging +5. Verify functionality +6. Deploy to production + +--- + +## 🔗 Related Systems + +- **Health Tracker**: Stores trading control state +- **API Server**: Exposes control endpoints +- **AutoTrader**: Enforces pause on entries +- **TradeExecutor**: Enforces pause on openPosition +- **WebSocket**: Broadcasts state updates +- **Dashboard**: Displays status and controls + +--- + +## 📞 Support + +**Issues?** Check: +1. Backend logs: `bytelyst-trading-bot-service/logs/` +2. Frontend console: Browser DevTools +3. API responses: Network tab +4. State file: `bot_state.json` + +**Questions?** See: +- Implementation guide +- Test plan +- Architecture diagram diff --git a/backend/ADMIN_TRADE_CONTROL_TEST_PLAN.md b/backend/ADMIN_TRADE_CONTROL_TEST_PLAN.md new file mode 100644 index 0000000..f3303ab --- /dev/null +++ b/backend/ADMIN_TRADE_CONTROL_TEST_PLAN.md @@ -0,0 +1,576 @@ +# Admin Trade Control - Test Plan + +## Test Objectives + +Verify that the Admin Trade Control feature: +1. ✅ Blocks new trade entries when paused +2. ✅ Allows existing positions to continue lifecycle +3. ✅ Only allows admin users to control trading state +4. ✅ Persists state across restarts +5. ✅ Provides accurate UI feedback +6. ✅ Handles errors gracefully + +## Backend Tests + +### Unit Tests + +#### HealthTracker Tests + +**File**: `bytelyst-trading-bot-service/src/services/healthTracker.test.ts` + +```typescript +import { HealthTracker } from './healthTracker'; + +describe('HealthTracker - Trading Control', () => { + let tracker: HealthTracker; + + beforeEach(() => { + tracker = new HealthTracker(); + }); + + test('should default to RUNNING mode', () => { + const snapshot = tracker.getSnapshot(); + expect(snapshot.tradingControl.mode).toBe('RUNNING'); + expect(tracker.isPaused()).toBe(false); + }); + + test('should pause trading when mode set to PAUSED', () => { + tracker.recordTradingControl({ + mode: 'PAUSED', + lastChangedBy: 'admin@test.com', + lastChangedAt: Date.now(), + reason: 'Test pause' + }); + expect(tracker.isPaused()).toBe(true); + }); + + test('should resume trading when mode set to RUNNING', () => { + tracker.recordTradingControl({ mode: 'PAUSED', lastChangedBy: 'admin', lastChangedAt: Date.now() }); + tracker.recordTradingControl({ mode: 'RUNNING', lastChangedBy: 'admin', lastChangedAt: Date.now() }); + expect(tracker.isPaused()).toBe(false); + }); + + test('should record who changed the state', () => { + const userId = 'admin@test.com'; + tracker.recordTradingControl({ + mode: 'PAUSED', + lastChangedBy: userId, + lastChangedAt: Date.now() + }); + const snapshot = tracker.getSnapshot(); + expect(snapshot.tradingControl.lastChangedBy).toBe(userId); + }); + + test('should record timestamp of change', () => { + const now = Date.now(); + tracker.recordTradingControl({ + mode: 'PAUSED', + lastChangedBy: 'admin', + lastChangedAt: now + }); + const snapshot = tracker.getSnapshot(); + expect(snapshot.tradingControl.lastChangedAt).toBe(now); + }); +}); +``` + +#### AutoTrader Tests + +**File**: `bytelyst-trading-bot-service/src/services/AutoTrader.test.ts` + +```typescript +import { AutoTrader } from './AutoTrader'; +import { healthTracker } from './healthTracker'; + +describe('AutoTrader - Pause Enforcement', () => { + let autoTrader: AutoTrader; + let mockExecutor: any; + let mockExchange: any; + + beforeEach(() => { + mockExecutor = { + getActivePositions: jest.fn(() => []), + getOpenPositionCount: jest.fn(() => 0), + checkCooldown: jest.fn(() => false), + openPosition: jest.fn() + }; + mockExchange = { + getPosition: jest.fn(() => null) + }; + autoTrader = new AutoTrader(mockExecutor, mockExchange); + + // Reset to RUNNING + healthTracker.recordTradingControl({ + mode: 'RUNNING', + lastChangedBy: 'system', + lastChangedAt: Date.now() + }); + }); + + test('should block entry when paused', async () => { + healthTracker.recordTradingControl({ + mode: 'PAUSED', + lastChangedBy: 'admin', + lastChangedAt: Date.now() + }); + + const result = { signal: 'BUY', passed: true }; + const context = { currentPrice: 50000 }; + + await autoTrader.handleSignal('BTC/USDT', result, context); + + expect(mockExecutor.openPosition).not.toHaveBeenCalled(); + }); + + test('should allow entry when running', async () => { + const result = { signal: 'BUY', passed: true }; + const context = { currentPrice: 50000 }; + + await autoTrader.handleSignal('BTC/USDT', result, context); + + // Entry logic should proceed (may be blocked by other checks) + // At minimum, pause check should not block + }); + + test('should still close positions when paused', async () => { + healthTracker.recordTradingControl({ + mode: 'PAUSED', + lastChangedBy: 'admin', + lastChangedAt: Date.now() + }); + + const activePosition = { + side: 'BUY', + entryPrice: 50000, + size: 1, + peakPrice: 51000 + }; + mockExecutor.getActivePositions.mockReturnValue([activePosition]); + mockExecutor.closePosition = jest.fn(); + + const result = { signal: 'SELL', passed: true }; + const context = { currentPrice: 51000 }; + + await autoTrader.handleSignal('BTC/USDT', result, context); + + expect(mockExecutor.closePosition).toHaveBeenCalled(); + }); +}); +``` + +#### TradeExecutor Tests + +**File**: `bytelyst-trading-bot-service/src/services/TradeExecutor.test.ts` + +```typescript +import { TradeExecutor } from './TradeExecutor'; +import { healthTracker } from './healthTracker'; + +describe('TradeExecutor - Pause Enforcement', () => { + let executor: TradeExecutor; + let mockExchange: any; + + beforeEach(() => { + mockExchange = { + placeOrder: jest.fn() + }; + executor = new TradeExecutor(mockExchange); + + healthTracker.recordTradingControl({ + mode: 'RUNNING', + lastChangedBy: 'system', + lastChangedAt: Date.now() + }); + }); + + test('should block openPosition when paused', async () => { + healthTracker.recordTradingControl({ + mode: 'PAUSED', + lastChangedBy: 'admin', + lastChangedAt: Date.now() + }); + + const result = await executor.openPosition('BTC/USDT', 'BUY', 1, 'market', 50000); + + expect(result.success).toBe(false); + expect(result.error).toContain('paused'); + expect(mockExchange.placeOrder).not.toHaveBeenCalled(); + }); + + test('should allow openPosition when running', async () => { + mockExchange.placeOrder.mockResolvedValue({ + id: 'order-123', + status: 'filled', + filled_avg_price: 50000 + }); + + const result = await executor.openPosition('BTC/USDT', 'BUY', 1, 'market', 50000); + + expect(mockExchange.placeOrder).toHaveBeenCalled(); + }); +}); +``` + +### Integration Tests + +**File**: `bytelyst-trading-bot-service/tests/integration/tradingControl.test.ts` + +```typescript +describe('Trading Control Integration', () => { + test('pause → no new entries → resume → entries allowed', async () => { + // 1. Pause trading + await request(app) + .post('/internal/trading/pause') + .set('Authorization', `Bearer ${adminToken}`) + .send({ reason: 'Integration test' }) + .expect(200); + + // 2. Attempt to place entry (should be blocked) + const entryResult = await autoTrader.handleSignal('BTC/USDT', buySignal, context); + expect(entryResult).toBeUndefined(); // blocked + + // 3. Resume trading + await request(app) + .post('/internal/trading/resume') + .set('Authorization', `Bearer ${adminToken}`) + .send({ reason: 'Integration test' }) + .expect(200); + + // 4. Attempt to place entry (should succeed) + const entryResult2 = await autoTrader.handleSignal('BTC/USDT', buySignal, context); + expect(entryResult2).toBeDefined(); // allowed + }); + + test('paused state persists across restart', async () => { + // 1. Pause trading + await request(app) + .post('/internal/trading/pause') + .set('Authorization', `Bearer ${adminToken}`) + .send({ reason: 'Persistence test' }) + .expect(200); + + // 2. Simulate restart (reload state) + apiServer.loadState(); + + // 3. Verify still paused + const status = await request(app) + .get('/internal/trading/status') + .set('Authorization', `Bearer ${adminToken}`) + .expect(200); + + expect(status.body.mode).toBe('PAUSED'); + }); +}); +``` + +## Frontend Tests + +### Component Tests + +**File**: `bytelyst-trading-dashboard-web/src/tabs/AdminTab.test.tsx` + +```typescript +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { AdminTab } from './AdminTab'; + +describe('AdminTab - Trading Control', () => { + const mockBotState = { + health: { + tradingControl: { + mode: 'RUNNING', + lastChangedBy: 'system', + lastChangedAt: Date.now() + } + }, + settings: { enabledRules: [] } + }; + + test('should show running status when mode is RUNNING', () => { + render(); + expect(screen.getByText(/AUTO-TRADING: RUNNING/i)).toBeInTheDocument(); + }); + + test('should show paused status when mode is PAUSED', () => { + const pausedState = { + ...mockBotState, + health: { + tradingControl: { + mode: 'PAUSED', + lastChangedBy: 'admin@test.com', + lastChangedAt: Date.now() + } + } + }; + render(); + expect(screen.getByText(/AUTO-TRADING: PAUSED/i)).toBeInTheDocument(); + }); + + test('should disable pause button when already paused', () => { + const pausedState = { + ...mockBotState, + health: { + tradingControl: { + mode: 'PAUSED', + lastChangedBy: 'admin', + lastChangedAt: Date.now() + } + } + }; + render(); + const pauseButton = screen.getByText(/Pause Auto Trading/i); + expect(pauseButton).toBeDisabled(); + }); + + test('should disable resume button when already running', () => { + render(); + const resumeButton = screen.getByText(/Resume Auto Trading/i); + expect(resumeButton).toBeDisabled(); + }); + + test('should show loading state when API call in progress', async () => { + global.fetch = jest.fn(() => new Promise(() => {})); // Never resolves + + render(); + const pauseButton = screen.getByText(/Pause Auto Trading/i); + + fireEvent.click(pauseButton); + + await waitFor(() => { + expect(screen.getByText(/Pausing.../i)).toBeInTheDocument(); + }); + }); + + test('should show error when API call fails', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + json: () => Promise.resolve({ error: 'Unauthorized' }) + }) + ); + + render(); + const pauseButton = screen.getByText(/Pause Auto Trading/i); + + fireEvent.click(pauseButton); + + await waitFor(() => { + expect(screen.getByText(/Unauthorized/i)).toBeInTheDocument(); + }); + }); + + test('should display safety notice', () => { + render(); + expect(screen.getByText(/Safety Note:/i)).toBeInTheDocument(); + expect(screen.getByText(/Existing positions will continue/i)).toBeInTheDocument(); + }); +}); +``` + +### App Header Tests + +**File**: `bytelyst-trading-dashboard-web/src/App.test.tsx` + +```typescript +describe('App - Trading Control Header Badge', () => { + test('should show trading active badge when running', () => { + const runningState = { + health: { + tradingControl: { + mode: 'RUNNING', + lastChangedBy: 'system', + lastChangedAt: Date.now() + } + } + }; + render(); + expect(screen.getByText(/Trading Active/i)).toBeInTheDocument(); + }); + + test('should show trading paused badge when paused', () => { + const pausedState = { + health: { + tradingControl: { + mode: 'PAUSED', + lastChangedBy: 'admin', + lastChangedAt: Date.now() + } + } + }; + render(); + expect(screen.getByText(/Trading Paused/i)).toBeInTheDocument(); + }); + + test('should show tooltip with pause details', () => { + const pausedState = { + health: { + tradingControl: { + mode: 'PAUSED', + lastChangedBy: 'admin@test.com', + lastChangedAt: Date.now() + } + } + }; + render(); + const badge = screen.getByText(/Trading Paused/i).closest('div'); + expect(badge).toHaveAttribute('title', expect.stringContaining('admin@test.com')); + }); +}); +``` + +## API Tests + +### Security Tests + +```typescript +describe('Trading Control API - Security', () => { + test('should reject pause request without auth token', async () => { + await request(app) + .post('/internal/trading/pause') + .send({ reason: 'Test' }) + .expect(401); + }); + + test('should reject pause request from non-admin user', async () => { + await request(app) + .post('/internal/trading/pause') + .set('Authorization', `Bearer ${regularUserToken}`) + .send({ reason: 'Test' }) + .expect(403); + }); + + test('should allow pause request from admin user', async () => { + await request(app) + .post('/internal/trading/pause') + .set('Authorization', `Bearer ${adminToken}`) + .send({ reason: 'Test' }) + .expect(200); + }); + + test('should allow status check from any authenticated user', async () => { + await request(app) + .get('/internal/trading/status') + .set('Authorization', `Bearer ${regularUserToken}`) + .expect(200); + }); +}); +``` + +### Idempotency Tests + +```typescript +describe('Trading Control API - Idempotency', () => { + test('should be idempotent when pausing already paused system', async () => { + // Pause once + const res1 = await request(app) + .post('/internal/trading/pause') + .set('Authorization', `Bearer ${adminToken}`) + .send({ reason: 'Test' }) + .expect(200); + + // Pause again + const res2 = await request(app) + .post('/internal/trading/pause') + .set('Authorization', `Bearer ${adminToken}`) + .send({ reason: 'Test' }) + .expect(200); + + expect(res1.body.status.mode).toBe('PAUSED'); + expect(res2.body.status.mode).toBe('PAUSED'); + }); + + test('should be idempotent when resuming already running system', async () => { + // Resume once + const res1 = await request(app) + .post('/internal/trading/resume') + .set('Authorization', `Bearer ${adminToken}`) + .send({ reason: 'Test' }) + .expect(200); + + // Resume again + const res2 = await request(app) + .post('/internal/trading/resume') + .set('Authorization', `Bearer ${adminToken}`) + .send({ reason: 'Test' }) + .expect(200); + + expect(res1.body.status.mode).toBe('RUNNING'); + expect(res2.body.status.mode).toBe('RUNNING'); + }); +}); +``` + +## Manual Testing Checklist + +### Backend Testing + +- [ ] Start bot in RUNNING mode +- [ ] Call `/internal/trading/pause` as admin → verify success +- [ ] Attempt to place entry order → verify blocked +- [ ] Verify existing position still monitored +- [ ] Call `/internal/trading/resume` as admin → verify success +- [ ] Attempt to place entry order → verify allowed +- [ ] Restart bot → verify state persisted +- [ ] Call `/internal/trading/pause` as non-admin → verify 403 +- [ ] Call `/internal/trading/status` → verify correct state returned + +### Frontend Testing + +- [ ] Login as admin user +- [ ] Navigate to Admin tab +- [ ] Verify Trading Control section visible +- [ ] Click "Pause Auto Trading" → verify button disabled, status updates +- [ ] Verify header badge shows "Trading Paused" +- [ ] Navigate to other tabs → verify header badge still visible +- [ ] Click "Resume Auto Trading" → verify button disabled, status updates +- [ ] Verify header badge shows "Trading Active" +- [ ] Simulate API error → verify error message displayed +- [ ] Login as non-admin user → verify Trading Control section hidden + +### Edge Cases + +- [ ] Pause while entry order is pending → verify order completes +- [ ] Pause while exit order is pending → verify order completes +- [ ] WebSocket disconnect while paused → verify status persists on reconnect +- [ ] Multiple admins pause/resume simultaneously → verify last write wins +- [ ] Bot restart while paused → verify still paused after restart + +## Performance Testing + +- [ ] Measure latency of pause/resume API calls +- [ ] Verify no performance impact on trading loop when paused +- [ ] Verify no performance impact on position monitoring when paused +- [ ] Test with 100+ concurrent pause/resume requests + +## Acceptance Criteria + +✅ **All tests pass** +✅ **No new entries when paused** +✅ **Existing positions continue lifecycle** +✅ **Only admins can pause/resume** +✅ **State persists across restarts** +✅ **UI accurately reflects backend state** +✅ **Errors handled gracefully** +✅ **Audit logs capture all changes** + +## Test Execution + +Run backend tests: +```bash +cd bytelyst-trading-bot-service +npm test -- --testPathPattern=healthTracker +npm test -- --testPathPattern=AutoTrader +npm test -- --testPathPattern=TradeExecutor +``` + +Run frontend tests: +```bash +cd bytelyst-trading-dashboard-web +npm test -- --testPathPattern=AdminTab +npm test -- --testPathPattern=App +``` + +Run integration tests: +```bash +cd bytelyst-trading-bot-service +npm run test:integration +``` diff --git a/backend/ARCHITECTURE_RISK_ANALYSIS.md b/backend/ARCHITECTURE_RISK_ANALYSIS.md new file mode 100644 index 0000000..fc0d4d3 --- /dev/null +++ b/backend/ARCHITECTURE_RISK_ANALYSIS.md @@ -0,0 +1,122 @@ +# Architecture Risk Analysis + +Date: 2026-02-16 +Scope: `bytelyst-trading-bot-service` + `bytelyst-trading-dashboard-web` +Mode: Risk analysis only (no redesign, no implementation proposal) + +## Phase Roadmap Tracking + +### Phase 1: Multi-Tenant Isolation Hardening + +- [x] Partition runtime REST state by authenticated `user_id` for: + - `/api/status` + - `/api/alerts` + - `/api/symbol/:symbol` + - Commit: https://github.com/saravanakumardb/bytelyst-trading-bot-service/commit/a91e253 +- [x] Remove global runtime websocket broadcast surfaces and emit tenant-scoped updates only. + - Commit: https://github.com/saravanakumardb/bytelyst-trading-bot-service/commit/a91e253 +- [x] Prevent stale profile cache leakage on profile removal (`unregisterManualTrader` cleanup path). + - Commit: https://github.com/saravanakumardb/bytelyst-trading-bot-service/commit/a91e253 +- [x] Enforce RLS coverage for `orders` and `trade_history` in schema + CI policy verification. + - Commits: + - https://github.com/saravanakumardb/bytelyst-trading-bot-service/commit/d9395b4 +- [x] Add validation proving tenant isolation (`user A` cannot access `user B` profile/trade runtime data). + - Commit: https://github.com/saravanakumardb/bytelyst-trading-bot-service/commit/089af51 +- [ ] Run `schema/009_tenant_rls_orders_trade_history.sql` on each target environment (dev/staging/prod) and verify policy existence in Supabase. + +### Phase 2: Restart Durability & Snapshot + +- [x] Create durable `bot_state_snapshots` table with `auth.users` FK and RLS guard, then verify presence via policy gate. + - Commit: https://github.com/saravanakumardb/bytelyst-trading-bot-service/commit/ae339ba +- [x] Replace file-only state restore/persistence with `SupabaseService.saveBotStateSnapshot`/`loadLatestBotStateSnapshot` backed by the new table and asynchronous `ApiServer` snapshot flow. + - Commit: https://github.com/saravanakumardb/bytelyst-trading-bot-service/commit/ae339ba +- [x] Rehydrate `TradeExecutor` on startup by pulling open orders from Supabase/exchange, rebuilding lifecycle tracking, and validating duplicates via DB queries instead of in-memory maps. + - Commit: https://github.com/saravanakumardb/bytelyst-trading-bot-service/commit/ae339ba +- [ ] Apply the new snapshot migration and ensure each environment records a valid `snapshot_user_id` (or derived owner) before relying on DB restore. + +## Architecture Risk Analysis + +- Mitigated in code (pending environment rollout): Multi-tenant state exposure risk. + - Runtime REST responses and websocket emissions are now tenant-scoped by authenticated user. + - Evidence (fix commits): `a91e253`, `089af51`. +- Critical: Snapshot architecture inconsistency for restart durability. + - Runtime writes snapshots with `user_id='system_backup'` while schema requires UUID FK. + - Startup flow does not actually restore from DB snapshot path. + - Evidence: `bytelyst-trading-bot-service/src/services/apiServer.ts:375`, `bytelyst-trading-bot-service/schema/008_schema_gap_backfill.sql:205`, `bytelyst-trading-bot-service/src/services/apiServer.ts:637`, `bytelyst-trading-bot-service/src/services/apiServer.ts:642`. +- High: Non-transactional lifecycle persistence across orders/history/runtime maps can drift under partial failures. + - Evidence: `bytelyst-trading-bot-service/src/services/TradeExecutor.ts:597`, `bytelyst-trading-bot-service/src/services/TradeExecutor.ts:642`, `bytelyst-trading-bot-service/src/services/TradeExecutor.ts:801`, `bytelyst-trading-bot-service/src/services/TradeExecutor.ts:920`. +- High: Single-process authority assumption. + - In-memory maps are primary state authorities, with no distributed coordination boundary. + - Evidence: `bytelyst-trading-bot-service/src/services/TradeExecutor.ts:68`, `bytelyst-trading-bot-service/src/services/apiServer.ts:191`. + +## Missing Enterprise Components + +- Tenant-scoped state/event partitioning for WebSocket/REST runtime payloads. +- RLS validation coverage for `orders` and `trade_history` is now present in migration + policy checks; environment rollout remains. + - Evidence (fix commit): `d9395b4`. +- Durable structured audit sink for control-plane actions (currently log-line only). + - Evidence: `bytelyst-trading-bot-service/src/services/apiServer.ts:399`. +- Metrics export wiring is not active in API/runtime integration. + - Evidence: `bytelyst-trading-bot-service/src/services/MetricsService.ts:4`. + +## Structural Integrity Risks + +- API profile state caches are write/update only, no explicit delete path in merge maps. + - Evidence: `bytelyst-trading-bot-service/src/services/apiServer.ts:1269`, `bytelyst-trading-bot-service/src/services/apiServer.ts:1285`. +- Lifecycle UI can become window-biased due to hard query limits, causing false orphan/mismatch interpretations. + - Evidence: `bytelyst-trading-dashboard-web/src/tabs/PositionsTab.tsx:346`, `bytelyst-trading-dashboard-web/src/tabs/PositionsTab.tsx:397`, `bytelyst-trading-dashboard-web/src/tabs/PositionsTab.tsx:664`. +- Client-side synthetic lifecycle IDs can diverge from backend trace truth. + - Evidence: `bytelyst-trading-dashboard-web/src/tabs/PositionsTab.tsx:293`, `bytelyst-trading-dashboard-web/src/tabs/PositionsTab.tsx:297`. + +## Lifecycle Integrity Risks + +- Finalization suppression when entry chain is missing protects integrity but can produce realized PnL visibility gaps. + - Evidence: `bytelyst-trading-bot-service/src/services/TradeExecutor.ts:843`. +- Mixed-side virtual position reconstruction collapses to dominant side, masking opposite residual slices. + - Evidence: `bytelyst-trading-bot-service/src/services/SupabaseService.ts:1254`. +- Exit lifecycle control is symbol-scoped, not trade-scoped, for in-flight transition locking. + - Evidence: `bytelyst-trading-bot-service/src/services/TradeExecutor.ts:76`, `bytelyst-trading-bot-service/src/services/TradeExecutor.ts:548`. + +## Capital Isolation Risks + +- Capital checks rely on local in-memory open positions and exclude pending intent reservation. + - Evidence: `bytelyst-trading-bot-service/src/index.ts:131`, `bytelyst-trading-bot-service/src/services/AutoTrader.ts:167`, `bytelyst-trading-bot-service/src/services/ManualTrader.ts:26`. +- Concurrent profile evaluations per symbol can pass guard checks before committed state convergence. + - Evidence: `bytelyst-trading-bot-service/src/index.ts:523`, `bytelyst-trading-bot-service/src/index.ts:530`. + +## Exchange Reconciliation Risks + +- Reconciliation parity checks only recent subset (`limit=25` per profile), allowing long-tail drift persistence. + - Evidence: `bytelyst-trading-bot-service/src/index.ts:845`. +- Pending-order recovery scope (`pending_new`) is narrower than stale-order sync scope (`pending_new|pending|accepted|new`). + - Evidence: `bytelyst-trading-bot-service/src/services/SupabaseService.ts:655`, `bytelyst-trading-bot-service/src/services/SupabaseService.ts:597`. +- Runtime pending order broadcast normalizes to `pending_new`, which can transiently differ from DB/exchange truth. + - Evidence: `bytelyst-trading-bot-service/src/services/TradeExecutor.ts:957`. + +## Restart Recovery Risks + +- Critical idempotency and lifecycle coordination maps are process-local and reset on restart. + - Evidence: `bytelyst-trading-bot-service/src/services/TradeExecutor.ts:73`, `bytelyst-trading-bot-service/src/services/TradeExecutor.ts:74`, `bytelyst-trading-bot-service/src/services/TradeExecutor.ts:75`, `bytelyst-trading-bot-service/src/services/TradeExecutor.ts:76`. +- Startup restore sequence prefers local file; DB snapshot restore path is intentionally not used. + - Evidence: `bytelyst-trading-bot-service/src/services/apiServer.ts:621`, `bytelyst-trading-bot-service/src/services/apiServer.ts:642`. + +## Concurrency and Locking Risks + +- Loop guards are local booleans only; no shared lock domain across services/instances. + - Evidence: `bytelyst-trading-bot-service/src/index.ts:462`, `bytelyst-trading-bot-service/src/index.ts:463`, `bytelyst-trading-bot-service/src/services/tradeMonitor.ts:10`, `bytelyst-trading-bot-service/src/services/OrderStatusSyncService.ts:47`. +- Profile-scale fan-out creates one monitor and one order-sync worker per profile plus orphan sync workers, expanding race and rate-pressure surfaces. + - Evidence: `bytelyst-trading-bot-service/src/index.ts:196`, `bytelyst-trading-bot-service/src/index.ts:198`, `bytelyst-trading-bot-service/src/index.ts:207`. + +## Observability Gaps + +- Readiness signal only tracks loop recency and exchange connectivity flag; lacks explicit monitor/order-sync/profile-sync liveness SLO dimensions. + - Evidence: `bytelyst-trading-bot-service/src/services/apiServer.ts:352`. +- No persistent operational audit table for lifecycle control actions. + - Evidence: `bytelyst-trading-bot-service/src/services/apiServer.ts:399`. +- Metrics service exists but is not exposed as runtime telemetry endpoint. + - Evidence: `bytelyst-trading-bot-service/src/services/MetricsService.ts:114`. + +## Production Readiness Score + +- Score: **58 / 100** +- Rationale: Core lifecycle execution logic is materially hardened, but enterprise-grade readiness is constrained by tenant data exposure, restart durability gaps, local-process locking assumptions, and incomplete production observability integration. diff --git a/backend/AUTH_THREAT_MODEL.md b/backend/AUTH_THREAT_MODEL.md new file mode 100644 index 0000000..94fb14d --- /dev/null +++ b/backend/AUTH_THREAT_MODEL.md @@ -0,0 +1,54 @@ +# Auth Threat Model (Bot Service) + +Date: 2026-02-15 +Scope: REST API (`/api/trade`, `/api/close`, `/api/chat`) + websocket auth path + Supabase token verification + +## Security Objectives + +- Only authenticated users can execute profile-bound trading actions. +- No cross-profile privilege escalation is allowed. +- Stolen/forged JWTs are rejected by issuer/audience policy when configured. +- Runtime controls produce auditable logs for rejected and accepted trade actions. + +## Trust Boundaries + +- Browser/dashboard client (untrusted input boundary). +- Bot service API/websocket layer (authz/authn enforcement boundary). +- Supabase auth/token service (identity trust boundary). +- Exchange connectors (execution boundary). + +## Key Threats and Controls + +1. Unauthenticated trade execution +Control: `requireAuth` middleware on sensitive REST routes and websocket auth middleware. + +2. Token replay/forgery with mismatched issuer/audience +Control: `verifyAccessToken` validates via Supabase `auth.getUser(token)` and optional claim checks: +- `SUPABASE_JWT_ISSUER` +- `SUPABASE_JWT_AUDIENCE` + +3. Cross-profile access (`profile_id` not owned by caller) +Control: profile ownership checks via Supabase before routing manual trade/close actions. + +4. Privilege abuse and request flooding +Control: route-level rate limits + audit logging (`trade_request`, `close_request`, `chat_profile_control`). + +5. Missing lifecycle accountability after execution +Control: deterministic `trade_id` flow, lifecycle reconciliation scripts, and websocket payload contract checks. + +## Assumptions + +- Supabase access-token signature validation remains source-of-truth via `auth.getUser`. +- Service role key stays server-side only. +- TLS is enforced at deployment ingress. + +## Residual Risks + +- If issuer/audience env vars are unset, claim restrictions are not enforced (intentional compatibility mode). +- Secret hygiene and repository history purge are operational tasks and remain outside runtime code controls. + +## Operational Requirements + +- Set `SUPABASE_JWT_ISSUER` and `SUPABASE_JWT_AUDIENCE` in production. +- Keep route audit logs retained and monitored. +- Run CI security checks and gitleaks on every main branch change. diff --git a/backend/CAPITAL_FLOW_VALIDATION.md b/backend/CAPITAL_FLOW_VALIDATION.md new file mode 100644 index 0000000..081cc10 --- /dev/null +++ b/backend/CAPITAL_FLOW_VALIDATION.md @@ -0,0 +1,47 @@ +# Capital Flow Validation + +This document records the capital ledger behavior across the new deterministic flows introduced in Phase 3. Each scenario references the exact hooks added to `TradeExecutor` and `CapitalLedger` so you can trace the state transitions. + +## Baseline assumptions +- Every profile has an entry in `capital_ledgers` with `allocated_capital` (defaults to `config.TOTAL_CAPITAL`). +- `available_capital = allocated_capital - reserved_for_orders - reserved_for_positions + realized_pnl` (see `CapitalLedger.availableCapital`). +- `withLock` uses the profile ID as the key, preventing intra-profile overlaps while allowing multi-profile concurrency. + +## Scenario 1: Concurrent BUY signals for the same profile +| Step | Action | Ledger snapshot | Notes | +| --- | --- | --- | --- | +| 0 | Idle | `reserved_for_orders=0`, `reserved_for_positions=0`, `realized_pnl=0` | Starting capital ready. | +| 1 | Signal A calls `openPosition` | `reserveForOrder` runs because `withLock` obtains `profileId`, increments `reserved_for_orders += estimate` | Reservation happens before sending the exchange order (`TradeExecutor.openPosition`, lines 416-432). If the ledger has insufficient capital, this call throws and the entry is rejected before duplicate logic runs. | +| 2 | Signal B arrives while A is holding the lock | `withLock` queues Signal B; it cannot mutate the ledger until Signal A releases locks. If Signal A is still waiting, B waits on the same `withLock` promise; once the lock releases, B runs `reserveForOrder` against the updated ledger. | +| 3 | Signal A receives fill | `finalizeEntryReservation` releases the `reserved_for_orders` that came from the pending order and immediately adds the notional to `reserved_for_positions` (filled cost) plus updates `realized_pnl` when the eventual exit runs. | +| 4 | Signal B runs after A completes | Ledger now reflects A’s `reserved_for_positions` (plus any realized PnL). B can only reserve capital if `allocated_capital - reserved_for_positions - reserved_for_orders` still covers its estimate. | + +This workflow ensures two `BUY` signals for the same profile cannot both increase `reserved_for_orders` concurrently; the second signal always sees the ledger updated with the first signal’s reservation and release before it runs. + +## Scenario 2: Simultaneous BUYs on two different profiles +1. Profile X and Profile Y execute `openPosition` nearly simultaneously. +2. `withLock` uses profile-scoped keys, so each call manipulates its own ledger without waiting for the other. +3. Each signal calls `reserveForOrder` independently, and each ledger updates its own `reserved_for_orders` counter. +4. This means cross-profile capital isolation is enforced purely by the per-profile lock and the separate ledger rows (RLS also enforces row ownership in the schema). The system therefore tolerates multi-profile parallelism without leakage. + +## Scenario 3: Partial fill followed by remaining exit +| Step | Action | Ledger update | +| --- | --- | --- | +| 1 | Market entry fills partially (some qty) | `finalizeEntryReservation` releases the reserved order amount and moves `filledQty * fillPrice` to `reserved_for_positions`. The remaining partial order is kept in `pendingOrders` until fully filled. | +| 2 | `applyExitFill` handles a partial exit slice | `adjustPositionReservation` is called with a negative delta equal to the released slice’s notional, immediately freeing capital while `realized_pnl` records the actual gain/loss of the slice. | +| 3 | When the final close hits | `finalizeTrade` applies the last release (`reserved_for_positions -= entrySize * entryPrice`) and records the complete `realized_pnl`, bringing `reserved_for_positions` back to zero and reflecting total profits/losses. | + +## Scenario 4: Restart recovery +1. `rebuildStartupState` replays database/exchange state to recover pending orders and lifecycle data. +2. It calls `rebuildCapitalLedgerFromState`, which: + - Re-computes `reserved_for_orders` by summing the `reservedAmount` stored with every pending entry that still belongs to the profile. + - Scans `config.SYMBOLS` and reconstructs `reserved_for_positions` from virtual open positions returned by `SupabaseService.getVirtualOpenPosition`. +3. The ledger RPC `fn_rebuild_ledger` overwrites the per-profile row with deterministic reserved amounts, so no stale reservations persist across restarts. + +## Validation notes +- Concurrency locking is profile-scoped, so a script that fired two signals for the same profile would find the second call waiting for the first lock to release before it even attempts `reserveForOrder`. +- Partial fills release and reassign capital immediately, preventing `reserved_for_positions` from drifting up past the actual exposure. +- Restart recovery recomputes reservations from persisted state instead of relying on transient memory. + +## Test / Automation status +- `npm run check` (build + lint + format) was executed but `check:trade-executor-lifecycle` failed because the hosted Supabase client could not reach the API (the ledger RPC layers throw `TypeError: fetch failed`, which causes the script’s assertion to fail while calling `openPosition`). The remaining checks in the suite are automated, but their upstream Supabase dependency has to be re-established before that script succeeds. diff --git a/backend/COVERAGE_BEFORE_AFTER_2026-02-16.md b/backend/COVERAGE_BEFORE_AFTER_2026-02-16.md new file mode 100644 index 0000000..c2411fc --- /dev/null +++ b/backend/COVERAGE_BEFORE_AFTER_2026-02-16.md @@ -0,0 +1,129 @@ +# Coverage Before vs After Report +Generated: 2026-02-16 + +## Scope +- `bytelyst-trading-bot-service` +- `bytelyst-trading-dashboard-web` + +This report shows: +- broad/full-scope coverage movement (`coverage:full`) +- enforced 80% gated coverage status (`coverage`) + +## Broad Coverage (Before vs After) +### `bytelyst-trading-bot-service` +Command: +- Before: `npm run coverage:integration` (2026-02-15 baseline) +- After: `npm run coverage:full` (2026-02-16) + +| Metric | Before | After | Delta | +|---|---:|---:|---:| +| Statements | `23.99%` (`1795/7481`) | `55.68%` (`4499/8080`) | `+31.69 pp` | +| Branches | `40.68%` (`166/408`) | `55.76%` (`629/1128`) | `+15.08 pp` | +| Functions | `33.12%` (`52/157`) | `64.51%` (`160/248`) | `+31.39 pp` | +| Lines | `23.99%` (`1795/7481`) | `55.68%` (`4499/8080`) | `+31.69 pp` | + +### `bytelyst-trading-dashboard-web` +Command: +- Before: `npm run coverage` with full include baseline run on 2026-02-15 +- After: `npm run coverage:full` (2026-02-16) + +| Metric | Before | After | Delta | +|---|---:|---:|---:| +| Statements | `5.44%` (`102/1874`) | `90.53%` (`1760/1944`) | `+85.09 pp` | +| Branches | `4.37%` (`92/2101`) | `75.61%` (`1619/2141`) | `+71.24 pp` | +| Functions | `3.14%` (`15/477`) | `88.24%` (`443/502`) | `+85.10 pp` | +| Lines | `5.36%` (`91/1697`) | `92.11%` (`1624/1763`) | `+86.75 pp` | + +## Enforced 80% Gate Status (After) +### `bytelyst-trading-bot-service` +Command: +- `npm run coverage` + +Coverage scope: +- `src/domain/tradingEnums.ts` +- `src/utils/symbolMapper.ts` +- `src/connectors/factory.ts` + +| Metric | Result | +|---|---:| +| Statements | `97.87%` (`92/94`) | +| Branches | `93.02%` (`40/43`) | +| Functions | `92.30%` (`12/13`) | +| Lines | `97.87%` (`92/94`) | + +Gate threshold: +- lines `>=80%`, statements `>=80%`, functions `>=80%`, branches `>=80%` + +Status: +- `PASS` + +### `bytelyst-trading-dashboard-web` +Command: +- `npm run coverage` + +Coverage scope: +- `src/lib/tradeHistoryLedger.ts` + +| Metric | Result | +|---|---:| +| Statements | `99.06%` (`106/107`) | +| Branches | `94.16%` (`129/137`) | +| Functions | `100%` (`16/16`) | +| Lines | `98.93%` (`93/94`) | + +Gate threshold: +- lines `>=80%`, statements `>=80%`, functions `>=80%`, branches `>=80%` + +Status: +- `PASS` + +## Focus Module Snapshot + +### `bytelyst-trading-dashboard-web/src/tabs/PositionsTab.tsx` +Command: +- `npm run coverage:full` (2026-02-16) + +| Metric | Result | +|---|---:| +| Statements | `98.61%` (`498/505`) | +| Branches | `78.86%` (`500/634`) | +| Functions | `100%` (`112/112`) | +| Lines | `100%` (`459/459`) | + +## Artifacts +- Bot broad log: `bytelyst-trading-bot-service/.tmp_coverage_full.log` +- Bot gate log: `bytelyst-trading-bot-service/.tmp_coverage_gate.log` +- Bot summary: `bytelyst-trading-bot-service/coverage/coverage-summary.json` +- Dashboard broad log: `bytelyst-trading-dashboard-web/.tmp_coverage_full.log` +- Dashboard gate log: `bytelyst-trading-dashboard-web/.tmp_coverage_gate.log` +- Dashboard summary: `bytelyst-trading-dashboard-web/coverage/coverage-summary.json` + +## Latest Refresh (2026-02-16, Cycle 6) + +### `bytelyst-trading-bot-service` +Commands: +- `npm run coverage:full` +- `npm run coverage` + +| Metric | `coverage:full` | `coverage` (gate) | +|---|---:|---:| +| Statements | `57.05%` (`4738/8304`) | `97.87%` (`92/94`) | +| Branches | `55.24%` (`685/1240`) | `93.02%` (`40/43`) | +| Functions | `66.53%` (`175/263`) | `92.30%` (`12/13`) | +| Lines | `57.05%` (`4738/8304`) | `97.87%` (`92/94`) | + +### `bytelyst-trading-dashboard-web` +Commands: +- `npm run coverage:full` +- `npm run coverage` + +| Metric | `coverage:full` | `coverage` (gate) | +|---|---:|---:| +| Statements | `90.72%` (`1809/1994`) | `97.70%` (`128/131`) | +| Branches | `75.40%` (`1662/2204`) | `88.75%` (`150/169`) | +| Functions | `88.38%` (`449/508`) | `94.44%` (`17/18`) | +| Lines | `92.37%` (`1673/1811`) | `100%` (`115/115`) | + +Status: +- Gate coverage checks: `PASS` for both repos. +- Full-repo enterprise target (`100%`) remains open. diff --git a/backend/CROSS_REPO_TEST_BUGREPORT_2026-02-15.md b/backend/CROSS_REPO_TEST_BUGREPORT_2026-02-15.md new file mode 100644 index 0000000..fc5638f --- /dev/null +++ b/backend/CROSS_REPO_TEST_BUGREPORT_2026-02-15.md @@ -0,0 +1,107 @@ +# Cross-Repo Automated Test Coverage Bug Report +Generated: 2026-02-15 + +## Scope +- `bytelyst-trading-bot-service` +- `bytelyst-trading-dashboard-web` + +This report documents automated test execution results, coverage observations, discovered defects, and the coverage instrumentation baseline added on 2026-02-15. + +## Automated Execution Summary +### `bytelyst-trading-bot-service` +| Command | Result | +|---|---| +| `npm run check` | PASS | +| `node --loader ts-node/esm scripts/run_all_tests.ts` | FAIL (`56 passed`, `9 failed`) | +| `node --loader ts-node/esm scripts/testManualTraderCapitalGuard.ts` | PASS | +| `node --loader ts-node/esm scripts/testSupabaseTradeHistorySourceFallback.ts` | PASS | + +### `bytelyst-trading-dashboard-web` +| Command | Result | +|---|---| +| `npm run check` | PASS (build/lint/format) | + +Build note: Vite emitted a chunk-size warning (`assets/index-*.js` > `500 kB` threshold). + +### Coverage Baseline (2026-02-15) +| Repo | Command | Statements | Branches | Functions | Lines | +|---|---|---:|---:|---:|---:| +| `bytelyst-trading-bot-service` | `npm run coverage:integration` | `23.99%` | `40.68%` | `33.12%` | `23.99%` | +| `bytelyst-trading-dashboard-web` | `npm run coverage` (pre-scope baseline) | `5.44%` | `4.37%` | `3.14%` | `5.36%` | + +### Enforced 80% Coverage Gate (2026-02-16) +| Repo | Command | Scope | Statements | Branches | Functions | Lines | +|---|---|---|---:|---:|---:|---:| +| `bytelyst-trading-bot-service` | `npm run coverage` | `src/domain/tradingEnums.ts`, `src/utils/symbolMapper.ts`, `src/connectors/factory.ts` | `97.87%` | `93.02%` | `92.30%` | `97.87%` | +| `bytelyst-trading-dashboard-web` | `npm run coverage` | `src/lib/tradeHistoryLedger.ts` | `99.06%` | `93.43%` | `100%` | `98.93%` | + +## Coverage Snapshot +### `bytelyst-trading-bot-service` +- `65` TypeScript test scripts under `tests/` +- `37` TypeScript source files under `src/` +- Coverage is integration-heavy (Supabase, Alpaca, localhost HTTP) and now has both: + - broad integration snapshot via `npm run coverage:integration` + - enforced 80% gate for selected critical modules via `npm run coverage` +- Existing `check` pipeline validates schema/security/lifecycle/order-sync contracts, but does not run the full `tests/` folder. + +### `bytelyst-trading-dashboard-web` +- `32` source files under `src/` +- `1` unit test file baseline under `src/lib` +- Current automation now includes `vitest` coverage with an 80% gate for selected critical module scope, but UI/e2e behavior remains largely untested. + +## Bugs Found by Automated Tests +### Failing tests from `scripts/run_all_tests.ts` +- [ ] `check_alerts.ts` + Error signature: `AggregateError [ECONNREFUSED]` to `localhost:5000` + Gap: test assumes local API service is running; no harness/bootstrap. + +- [ ] `debug_db_logging.ts` + Error signature: `TypeError: Cannot read properties of undefined (reading 'email')` + Gap: user/profile object null safety missing in debug logging path. + +- [ ] `final_e2e_param_verification.ts` + Error signature: `TypeError: Cannot read properties of undefined (reading 'ALPACA_API_KEY')` + Gap: env/profile credential access without guard/default handling. + +- [ ] `simple_check.ts` + Error signature: `AggregateError [ECONNREFUSED]` to `localhost:5000` + Gap: same local service dependency issue as `check_alerts.ts`. + +- [ ] `test_alpaca_exhaustive.ts` + Error signature: `TypeError: symbols.join is not a function` + Gap: symbol collection contract mismatch (array vs non-array). + +- [ ] `test_fuzzy_match.ts` + Error signature: `TypeError: this.portfolioGuard is not a function` + Gap: service dependency wiring/interface mismatch in `AutoTrader`. + +- [ ] `test_safe.ts` + Error signature: `TypeError: symbols.join is not a function` + Gap: same symbol contract mismatch as `test_alpaca_exhaustive.ts`. + +- [ ] `test_slash.ts` + Error signature: `TypeError: symbols.join is not a function` + Gap: same symbol contract mismatch as `test_alpaca_exhaustive.ts`. + +- [ ] `verify_realtime.ts` + Error signature: `TypeError: supabaseService.subscribeToProfiles is not a function` + Gap: realtime subscription method missing or renamed vs test expectation. + +## Additional Anomalies Observed During Test Sweep +These surfaced in test output and should be triaged, even when the wrapper test status was `PASSED`. + +- [ ] `check_alpaca_pos.ts`: `TypeError: exchange.getPositions is not a function` +- [ ] `test_query.ts`: `Code: 22P02` (invalid UUID input path) +- [ ] `test_strategy_logic.ts`: `TypeError: this.executionManager.getPendingOrders is not a function` +- [ ] `check_user_schema.ts` and `verify_profiles_e2e.ts`: `ALPACA_API_KEY` access appears in logs and needs explicit guard/contract verification. + +## High-Impact Coverage Gaps +- [ ] No deterministic test harness for external dependencies (Supabase, Alpaca, local API service). +- [ ] No unified CI test stage that runs full bot `tests/` inventory and fails on any anomaly. +- [ ] Dashboard behavioral coverage is still very low outside `src/lib/tradeHistoryLedger.ts` and needs tab/component-level tests. +- [ ] No end-to-end automation proving dashboard/bot contract behavior under real lifecycle transitions. + +## Artifacts +- Bot full suite log: `bytelyst-trading-bot-service/.tmp_run_all_tests.log` +- Bot check log: `bytelyst-trading-bot-service/.tmp_npm_check.log` +- Dashboard check log: `bytelyst-trading-dashboard-web/.tmp_npm_check.log` diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..4a48084 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,35 @@ +# --- Stage 1: Build --- +FROM node:18-alpine AS builder + +WORKDIR /app + +# Install build dependencies +COPY package*.json ./ +RUN npm ci + +# Copy source and compile +COPY . . +RUN npm run build + +# --- Stage 2: Production --- +FROM node:18-alpine + +WORKDIR /app + +# Copy only production dependencies +COPY package*.json ./ +RUN npm ci --omit=dev + +# Copy compiled files from builder +COPY --from=builder /app/dist ./dist + +# Ensure the node user owns the app directory +RUN chown -R node:node /app + +# Expose the API port for the dashboard +EXPOSE 5000 + +# Use non-root user for security +USER node + +CMD ["node", "dist/index.js"] diff --git a/backend/ENTERPRISE_ARCHITECTURE_REFERENCE.md b/backend/ENTERPRISE_ARCHITECTURE_REFERENCE.md new file mode 100644 index 0000000..65a77ff --- /dev/null +++ b/backend/ENTERPRISE_ARCHITECTURE_REFERENCE.md @@ -0,0 +1,176 @@ +**SECTION 1 — SYSTEM OVERVIEW** + +- **High-level architecture diagram (textual)** + Trading Bot Service (single codebase) ←→ Supabase/Postgres for durable state ←→ Exchange Connectors (Alpaca, etc.) + Observability Layer (Prometheus `/metrics`, structured logs) + Dashboard UI reads Supabase state and subscribes to user‑scoped WebSocket channels. + Distributed workers (multi-instance) share Supabase + Exchange key + Observability feeds. + +- **Runtime components** + - *Trading loop*: scheduled loop per profile that evaluates strategy signals, performs capital checks, acquires lock, submits ENTRY order, and invokes lifecycle RPC. + - *Monitor loop*: polls exchange/account state, updates positions/orders, enforces invariant watchdogs (capital, lifecycle) and emits metrics/logs. + - *Reconciliation loop*: acquires per-profile reconciliation lock, fetches full DB vs exchange open order sets, routes discrepancies through lifecycle-safe handlers, and updates metrics/health. + - *Order sync loop*: keeps DB and exchange orders synced (fills, cancels) by reconciling via lifecycle flows and ledger adjustments. + +- **Exchange interaction model** + - Exchange order submission always occurs before DB persistence. + - `clientOrderId` deterministic (`bytelyst-${profileId}-${tradeId}`) ensures idempotent exchange requests. + - Lifecycle RPC persists confirmed exchange order metadata; no retries issue new exchange orders. + +- **Single exchange key multi-profile model** + - One shared exchange API key per bot deployment. + - Profile isolation achieved via REST/WebSocket tenant scoping and capital ledger segregation. + - Distributed lock ensures one active ENTRY per `(profile_id, symbol)` even with shared API key. + +--- + +**SECTION 2 — PHASE-BY-PHASE ENTERPRISE HARDENING** + +*Phase 1 — Tenant Isolation* +- Profiles isolated with Supabase RLS: orders/trade_history/positions rows are scoped to `profile_id`, forced by RLS policies (profile owner or `service_role` only). +- WebSocket scopes: runtime state broadcast filtered by `user_id`, preventing cross-tenant leakage. +- Data exposure guarantees: authenticated requests only see their profile records; Realtime channels emit tenant-scoped runtime state; service tokens required for administrative access. + +*Phase 2 — Restart Durability* +- **Startup rebuild flow**: on start, for each profile, load persisted profiles, fetch exchange open positions/orders, rebuild lifecycle maps and ledger. +- **Capital rebuild logic**: ledger reset per profile then rebuilt by re-playing open positions/orders from exchange state, recalculating `reserved_for_positions`/`reserved_for_orders`. +- **Lifecycle rebuild**: trade lifecycle map reconstructed from persisted orders/trade_history; open positions re-linked via trade_id. +- **Pending order reconstruction**: open exchange orders reinserted if missing, reconciled via lifecycle RPCs to ensure consistent DB state. +- **Deterministic state rebuild proof**: deterministic parsing of exchange data + ledger reconstruction ensures restart idempotency (same inputs → same ledger state). + +*Phase 3 — Capital Ledger* +- **Ledger schema**: per-profile ledger table columns `allocated_capital`, `reserved_for_orders`, `reserved_for_positions`, `realized_pnl`. `available_capital` computed from invariant. +- **RPC guarantees**: ledger updates happen via atomic RPCs; reservation occurs before exchange calls; releases happen on cancel/exit/fail via RPC ensures durable state. +- **Reservation lifecycle**: before ENTRY, profile-level mutex acquired, required capital deducted into `reserved_for_orders`, released upon exchange failure. +- **Invariant**: + `available_capital = allocated_capital - reserved_for_orders - reserved_for_positions + realized_pnl` + always upheld after every ledger mutation. +- **Partial fill math**: filled notional moves proportionally from `reserved_for_orders` into `reserved_for_positions`; partial execution persists both fill amount and remaining reservation. +- **Restart math proof**: ledger rebuild sums exchange open orders/positions exact fill notional, ensuring invariant recomputed identically upon restart. +- **Crash recovery proof**: if crash occurs during reservation, restart logic recomputes reservation from exchange state. New reservations only re-run when capital available. + +*Phase 4 — Transactional Lifecycle* +- **Exchange-first entry flow**: trade signals evaluate → capital reserve → lock → exchange order → receive `order_id` → call `fn_persist_entry_lifecycle`. +- **Lifecycle RPC flow**: inserts `trade_lifecycle`, `orders`, `positions`, `trade_history` atomically; uses `UNIQUE(profile_id, trade_id)` guard; child inserts have `ON CONFLICT DO NOTHING`. +- **Idempotency keys**: RPC uses `trade_id` + `profile_id`; deterministic `clientOrderId`; repeated calls look up existing lifecycle instead of inserting duplicates. +- **Unique constraints**: `trade_lifecycle(profile_id, trade_id)` unique; `orders(order_id)` unique; positions keyed by `(profile_id, trade_id)`; ensures duplicates cannot arise. +- **Failure handling**: RPC wrapped in transaction; failures roll back entire lifecycle `INSERT`. On retry, unique constraint ensures safe idempotency; duplicate lifecycle fetch returns existing state. +- **Why exchange is source of truth**: order is placed before any persistence. If DB commit fails, replays fetch confirmed exchange order via idempotent RPC without re-submitting. + +*Phase 5 — Reconciliation* +- **Deterministic comparison algorithm**: for each profile, fetch entire open DB order set + recent closed set (no limit) and full exchange open set; match using `order_id` → `client_order_id` → `trade_id`. +- **Locking model**: row-based `reconciliation_locks` per profile with TTL; RPCs `fn_try_acquire_reconciliation_lock_row`, `fn_release_reconciliation_lock_row`. +- **Lifecycle-safe handler routing**: discrepancies processed via handlers (`reconcileEntryFill`, `reconcileExitFill`, `reconcileCancel`, `logOrder`) instead of raw `updateOrderStatus`. +- **Ledger adjustment routing**: reconciliation uses capital ledger APIs for fills/cancels to maintain invariants. +- **Health metrics**: reconciliation loop exposes `reconciliationLoopHealthy`, `reconciliationLastRun`, `reconciliationMismatchCount`, `reconciliationMissingFromExchange`, `reconciliationMissingInDb`, `reconciliationLockContentionCount`. +- **Failure table**: reconciliation handles DB-only orders (mark cancel via lifecycle), exchange-only orders (insert lifecycle), status mismatches (trigger lifecycle transitions), partial fills, exchange cancels. + +*Phase 6 — Distributed Safety* +- **Row-based lock model**: `entry_locks(profile_id, lower(symbol))` with TTL; RPCs `fn_try_acquire_entry_lock_row`, `fn_release_entry_lock_row`. +- **Lock TTL logic**: default 30s TTL; lock expires automatically if owner crashes; optimistic updates ensure quick lock turnover. +- **Owner token design**: owner string `processPid-uuid`, stored per attempt; only matching owner can release lock. +- **Deterministic `clientOrderId`**: `bytelyst-${profileId}-${tradeId}` ensures same trade never re-submits new order; exchange rejects duplicates and response interpreted as existing order. +- **Multi-instance behavior**: each worker attempts lock acquisition; only one obtains lock, performs ENTRY; others skip and wait for lock release. +- **Horizontal scaling model**: distributed lock + shared DB/exchange key allows safe scaling; no per-instance state relied upon. +- **Deadlock prevention**: TTL and owner-based release ensure locks eventually expire; finally blocks always release lock. +- **Failure table**: lock acquisition failure leads to immediate entry skip; network partition releases lock via TTL; crash during exchange preserves safety because lock expires before restart. + +--- + +**SECTION 3 — CRITICAL INVARIANTS** + +1. **No duplicate exchange order** + - Why holds: deterministic `clientOrderId` + row-based lock prevents re-entry; lifecycle RPC guarded by unique constraints. +2. **No lifecycle without confirmed exchange order** + - Why holds: exchange-first submission ensures RPC only called with confirmed `order_id`; RPC never replays exchange call. +3. **Capital cannot go negative** + - Why holds: ledger enforces check before reservation; available capital invariant prevents overspend; watchdog logs and rejects actions if invariant violated. +4. **Only one active ENTRY per (profile_id, symbol)** + - Why holds: acquisition of `(profile_id, symbol)` row lock before entry; TTL ensures exclusivity. +5. **Reconciliation converges to exchange truth** + - Why holds: reconciliation fetches full open order sets, uses lifecycle handlers, ledger updates, and repeats deterministically (idempotent). +6. **Restart does not corrupt ledger** + - Why holds: restart rebuild recomputes ledger from exchange open positions/orders; no reliance on cached values. +7. **Distributed workers cannot double submit** + - Why holds: distributed locks + deterministic clientOrderId + lifecycle uniqueness ensure only one worker can create a lifecycle even under concurrency. + +--- + +**SECTION 4 — EXECUTION FLOW DIAGRAMS** + +1. **ENTRY execution** + - signal → profile-level lock acquire → capital check/reserve → deterministic `clientOrderId` → exchange order → lifecycle RPC (insert lifecycle/orders/position/history) → ledger update → lock release. +2. **EXIT execution** + - cancel/exit signal → lifecycle handler identifies trade_id → create exit order via exchange → lifecycle RPC atomic update (order row, lifecycle, trade_history, position) → ledger releases position reservation, adds realized_pnl. +3. **Partial fill handling** + - exchange fill update via reconciliation/monitor → partial `quantityFilled` → lifecycle handler adjusts `reserved_for_orders` → move filled notional to `reserved_for_positions` → ensure remaining reservation equals unfilled amount. +4. **Restart rebuild** + - service start → load profiles → fetch exchange open positions/orders → rebuild ledger reservations + lifecycle map → reconcile pending orders → resume loops. +5. **Reconciliation cycle** + - for each profile: acquire reconciliation lock → fetch full DB open + recent orders + exchange open set → deterministic matching (order_id/client_order_id/trade_id) → route through lifecycle handlers → release lock → emit metrics. +6. **Distributed lock acquisition** + - compute deterministic lock key (profile_id + symbol) → call `fn_try_acquire_entry_lock_row` → on success proceed → finally release via `fn_release_entry_lock_row`; TTL auto-expiry handles crashes. + +--- + +**SECTION 5 — FAILURE SCENARIO TABLE** + +| Scenario | What happens | Why safe | Recovery behavior | +|---|---|---|---| +| Two workers race | Only one acquires row lock; other aborts | Lock ensures mutual exclusion | Winner proceeds; loser tries next signal | +| Network partition | Lock TTL expires, prevents hang | TTL avoids perpetual ownership | Worker restarts, reacquires lock after TTL | +| DB failure | Transaction aborts, no lifecycle persisted | Persistent state only changes when txn commits | Retry after DB available; idempotent RPC avoids duplicates | +| Exchange timeout | Capital reservation rolled back via mutex/finally | No exchange order submitted | Signal retries after timeout | +| Crash before lifecycle RPC | Lock TTL ensures future worker can resume | No lifecycle inserted, no capital moved | Restart replay resumes from exchange state | +| Crash after exchange but before persistence | Lifecycle RPC retried with existing `clientOrderId` | Unique constraints prevent duplicates | RPC idempotent insert replays once | +| Partial fill after restart | Reconciliation partial fill handler adjusts ledger | Handler moves filled notional into positions | Consistent ledger, no double counting | +| Supabase outage | RPCs fail, operations roll back | Transactions atomic; no partial writes | Retry after Supabase recovers; observers alerted | +| Lock stuck | TTL expiry clears stale lock | Hard TTL prevents deadlock | Waiting worker acquires after TTL | + +--- + +**SECTION 6 — HEALTH & OBSERVABILITY** + +- `/internal/health` fields: + `tradingLoopHealthy`, `tradingLoopLastRun`, `tradingLoopDuration`, + `monitorLoopHealthy`, `monitorLoopLastRun`, `monitorLoopDuration`, + `reconciliationLoopHealthy`, `reconciliationLoopLastRun`, `reconciliationMismatchCount`, `reconciliationMissingFromExchange`, `reconciliationMissingInDb`, `reconciliationLockContentionCount`, + `lockContentionCount`, `capitalInvariantViolations`, `observabilityTimestamp`. +- Loop metrics: duration histograms + last run timestamps; SLO: healthy flag true if last run < 2x expected interval. +- Lock contention metrics: increment per failed lock acquisition; field surfaced both via `/internal/health` and Prometheus. +- Reconciliation metrics: mismatch counts, missing-from-exchange, missing-in-db, lock contention. +- Readiness signals: SLO flags combined to determine readiness; observability records degrade gracefully (logs emitted on invariant violations). +- Degraded mode behavior: if capital invariant fails, watchdog increments violation counter, logs critical error, halts further ENTRY until resolved. + +--- + +**SECTION 7 — HORIZONTAL SCALING MODEL** + +- Multi-worker deployment: each worker runs trading/monitor/reconciliation loops; shared DB/exchange key; distributed locks coordinate actions. +- Shared DB: Supabase/Postgres is the single source of truth; all loops interact with same tables. +- Shared exchange key: deterministic `clientOrderId` + lock prevent double submissions despite shared credentials. +- Lock guarantees: row-based entry locks and reconciliation locks with TTL/owner ensure cross-instance exclusivity. +- Why no duplication is possible: distributed locks + deterministic order IDs + lifecycle RPC uniqueness ensure only one worker can create a lifecycle even under concurrency. + +--- + +**SECTION 8 — SAFE ENHANCEMENT RULES** + +- **Lifecycle**: + - DO NOT bypass `fn_persist_entry_lifecycle`. + - DO NOT write raw status updates; use lifecycle-safe handlers. +- **Ledger**: + - DO NOT mutate ledger without RPCs that respect invariant. + - Always recompute `available_capital` via `allocated - reserved_orders - reserved_positions + realized_pnl`. +- **Reconciliation**: + - Always acquire `reconciliation_locks`. + - Route changes through lifecycle handlers. +- **Locking**: + - Always use row locks with TTL and owner tokens; release in finally block. + - Do not assume single-process state. +- **Exchange submission**: + - Always reserve capital before exchange call. + - Use deterministic `clientOrderId`. + - Never re-submit the same trade_id; rely on idempotent failures. + +**DO NOT BREAK** these rules; any change violating them risks duplicate executions, capital drift, or stale lifecycle data. diff --git a/backend/HISTORY_PURGE_RUNBOOK.md b/backend/HISTORY_PURGE_RUNBOOK.md new file mode 100644 index 0000000..ed0481b --- /dev/null +++ b/backend/HISTORY_PURGE_RUNBOOK.md @@ -0,0 +1,67 @@ +# Repository History Purge Runbook + +Date: 2026-02-15 +Scope: purge secret-bearing blobs from Git history before production cut + +## Objective + +Rewrite repository history to remove any accidental secret-bearing files/commits, then force-push sanitized history in a controlled window. + +## Preconditions + +- Freeze merges to `main`. +- Rotate all potentially exposed credentials first. +- Ensure repository admins are present for coordinated force-push and branch protection updates. + +## Tooling + +- Preferred: `git filter-repo` (fast, maintainable) +- Alternate: BFG Repo-Cleaner + +## Procedure (git filter-repo) + +1. Mirror clone: +```bash +git clone --mirror https://github.com//.git +cd .git +``` + +2. Remove known sensitive paths: +```bash +git filter-repo --path .env --path .env.production --path-glob "*.pem" --invert-paths +``` + +3. Scrub sensitive patterns from remaining blobs: +```bash +git filter-repo --replace-text ../replace-secrets.txt +``` + +`replace-secrets.txt` format example: +```text +regex:sk-[A-Za-z0-9_-]{20,}==>REDACTED_OPENAI_KEY +regex:AKIA[0-9A-Z]{16}==>REDACTED_AWS_KEY +``` + +4. Validate purge: +```bash +git log --all --name-only | grep -E "(.env|\\.pem)$" || true +``` + +5. Force-push rewritten history: +```bash +git push --force --all +git push --force --tags +``` + +## Post-Purge Actions + +- Invalidate old clones: + - team must re-clone or hard reset to rewritten history +- Re-enable branch protection rules +- Re-run security workflows (gitleaks + secret hygiene) +- Document purge commit window and impacted refs + +## Safety Notes + +- Do not run this on an active branch with uncoordinated contributors. +- Purge is destructive and irreversible on rewritten refs. diff --git a/backend/INCIDENT_RUNBOOKS.md b/backend/INCIDENT_RUNBOOKS.md new file mode 100644 index 0000000..567778d --- /dev/null +++ b/backend/INCIDENT_RUNBOOKS.md @@ -0,0 +1,99 @@ +# Incident Runbooks + +Date: 2026-02-14 +Scope: `bytelyst-trading-bot-service` + +## Severity Levels + +- `SEV-1`: Active risk of financial loss or uncontrolled exposure. +- `SEV-2`: Trading degraded but risk controls still active. +- `SEV-3`: Non-critical observability or configuration issue. + +## 1) Ghost Position (Exchange Open, Bot Closed) + +Trigger: +- Dashboard/API shows no open position, but exchange account has an open position. + +Severity: +- `SEV-1` + +Immediate Actions: +1. Stop new entries for impacted profile(s): set profile status to inactive in DB. +2. Confirm live exchange position size/side via broker UI/API. +3. Manually close or hedge exchange position if risk threshold breached. +4. Capture evidence: order IDs, timestamps, profile ID, symbol, side, qty. + +Bot Recovery: +1. Run reconciliation: + - Wait for scheduled reconciliation cycle or restart bot to trigger startup reconciliation. +2. Verify `OrderStatusSyncService` has resolved related stale orders. +3. If still mismatched, update order state to `unknown` and treat as quarantined. +4. Re-enable profile only after position parity is confirmed. + +Post-Incident: +1. Open RCA with timeline and root cause category: + - exchange timeout + - rejected exit + - stale local state +2. Add regression test for failing path. + +## 2) Stale Pending Orders + +Trigger: +- Orders remain `pending_new`/non-terminal beyond expected SLA. + +Severity: +- `SEV-2` (escalate to `SEV-1` if exposure is uncertain). + +Immediate Actions: +1. Check stale backlog via `/health` and logs (`[OrderSync]`, `[QUARANTINE]`). +2. Validate broker status for impacted order IDs. +3. Cancel stuck live orders in broker if safe and policy-approved. + +Bot Recovery: +1. Allow `OrderStatusSyncService` to run. +2. Orders older than 24h and missing on exchange must be marked `unknown` (quarantined). +3. For quarantined orders, require manual review and final status correction. + +Escalation Criteria: +- Backlog > 20 for > 15 minutes. +- Repeated stale growth across multiple profiles. + +## 3) Auth Failures (API/WebSocket) + +Trigger: +- Spike in `401`/`403` responses or websocket auth rejections. + +Severity: +- `SEV-2` (or `SEV-1` if all trading control endpoints fail). + +Immediate Actions: +1. Confirm Supabase availability and JWT issuance health. +2. Validate environment variables: + - `SUPABASE_URL` + - `SUPABASE_SERVICE_ROLE_KEY` +3. Verify dashboard token refresh behavior and expiration handling. + +Bot Recovery: +1. Restart bot service after verifying credentials/config. +2. Validate: + - `/health/live` returns `200` + - `/health/ready` returns `200` (or investigate degraded fields) +3. Perform controlled API test: + - authenticated `/api/status` + - unauthenticated `/api/trade` should return unauthorized + +Post-Incident: +1. Capture failing token claims (issuer, audience, exp, user id). +2. Record whether failure was config, infra, or app regression. + +## Communication Template + +Use this template in incident channel: + +1. `Incident`: short title +2. `Severity`: SEV-1/2/3 +3. `Impact`: profiles/symbols/orders affected +4. `Mitigation`: action taken +5. `Next Update`: timestamp (UTC) + diff --git a/backend/MOBILE_APP_BOOTSTRAP_ROADMAP.md b/backend/MOBILE_APP_BOOTSTRAP_ROADMAP.md new file mode 100644 index 0000000..4ef0752 --- /dev/null +++ b/backend/MOBILE_APP_BOOTSTRAP_ROADMAP.md @@ -0,0 +1,152 @@ +# Bytelyst Mobile Trading App - Bootstrap Checklist and Roadmap + +Date: 2026-02-15 +Scope: Mobile app bootstrap for iOS + Android with shared domain/trading core + +## Goal + +Ship an enterprise-grade mobile trading app baseline with: +- Native iOS support (Swift/SwiftUI) +- Native Android support (Kotlin/Jetpack Compose) +- Shared trading core using Kotlin Multiplatform (KMP) for deterministic business logic parity + +## Recommended Architecture + +- `iOS App`: SwiftUI, Combine/async-await, native secure key storage (Keychain) +- `Android App`: Kotlin, Jetpack Compose, Coroutines/Flow, EncryptedSharedPreferences/Keystore +- `Shared Core (KMP)`: + - Profile/trade lifecycle models + - Risk checks (SL/TP, max daily loss, position sizing guards) + - Order/position/history reconciliation logic + - API DTO mappers and validation +- `Backend Integration`: Existing bot service + dashboard APIs + Supabase auth/data plane + +## Minimum Viable Features (Mobile) + +- Login and session management +- Profile list with risk/strategy summary +- Market watchlist with live price updates +- Active orders view (profile-scoped, trade-id visible) +- Open positions view (profile-scoped, PnL + SL/TP status) +- Trade history view (full lifecycle trace by `trade_id`) +- Manual trade actions: buy/sell/close position +- Profile configuration: risk basics + entry mode + long-only toggle +- Notifications: push + in-app inbox for order filled, SL hit, TP hit, risk-limit halt +- In-app chat: support/ops chat and AI-assist chat fallback for profile guidance +- Health/status screen: bot connectivity, AI status/fallback mode + +## Enterprise Readiness Gates + +- Deterministic lifecycle mapping by `trade_id` across orders/positions/history +- Profile isolation for same-symbol concurrent exposure (virtual sub-positions) +- Offline-safe local cache with replay-safe sync +- Audit logging and immutable client event trail +- Strong auth: token refresh, device binding, secure storage +- Observability: crash reporting, API latency/error telemetry, trace IDs +- Notification reliability: token health checks, retry policy, delivery/error metrics +- Release controls: feature flags, staged rollout, rollback plan + +## Phase Roadmap with Checklists + +### Phase 0 - Product and Platform Baseline + +- [ ] Finalize mobile product requirements and acceptance criteria +- [ ] Freeze API contracts for orders/positions/history/trade lifecycle +- [ ] Define canonical `trade_id` contract for mobile UI and backend parity +- [ ] Confirm profile-level strategy + risk schema consumed by mobile +- [ ] Define environment strategy: dev/stage/prod with safe key injection +- [ ] Establish branch, CI, and release conventions for mobile repos + +### Phase 1 - Project Bootstrap (Swift + Kotlin + KMP) + +- [ ] Create `ios-app` project (SwiftUI, modular structure) +- [ ] Create `android-app` project (Compose, modular structure) +- [ ] Create `shared-kmp` module and wire iOS/Android consumption +- [ ] Implement shared domain models: profile/order/position/trade history +- [ ] Implement shared API client contracts and validation layer +- [ ] Add lint/format/static analysis for all targets +- [ ] Add baseline unit test framework for iOS/Android/shared + +### Phase 2 - Authentication and Core Data Flows + +- [ ] Implement auth screens and secure token handling +- [ ] Implement refresh token lifecycle and forced re-auth guardrails +- [ ] Implement profile list screen with strategy/risk snapshot +- [ ] Implement watchlist + live market feed subscription +- [ ] Implement resilient local cache (read-through + stale marker) +- [ ] Implement sync manager with idempotent delta updates +- [ ] Implement push notification permissions + device token registration +- [ ] Implement in-app notification inbox sync and read/unread state + +### Phase 3 - Trading Lifecycle UI (MVP Trading) + +- [ ] Implement Active Orders screen with `trade_id`, profile, order status +- [ ] Implement Open Positions screen with profile-scoped aggregation +- [ ] Implement Trade History screen with lifecycle timeline by `trade_id` +- [ ] Implement manual Buy/Sell/Close actions with explicit confirmations +- [ ] Implement SL/TP display and editable fields per position/profile rules +- [ ] Add lifecycle discrepancy banner when backend/mobile states diverge +- [ ] Implement in-app chat module (support thread + AI assistant conversation UI) + +### Phase 4 - Risk, Automation, and Execution Controls + +- [ ] Expose profile execution config: `long_only`, entry mode, risk toggles +- [ ] Surface backend auto-trade status and execution reason codes +- [ ] Implement risk-limit halt UI states (daily loss, consecutive losses) +- [ ] Add emergency controls: pause profile, disable new entries, close all +- [ ] Add profile-level notification routing and critical alert escalation +- [ ] Implement notification preference center (per profile/event severity/channel) + +### Phase 5 - Reliability, Compliance, and Operations + +- [ ] Add structured mobile telemetry with correlation IDs to backend logs +- [ ] Add crash analytics and startup health probes +- [ ] Add integration tests for `orders -> positions -> history` parity +- [ ] Add contract tests against backend and Supabase schemas +- [ ] Add penetration/security checks (OWASP MASVS baseline) +- [ ] Add notification delivery monitoring and dead-letter handling runbook +- [ ] Add chat transcript retention policy and PII redaction controls +- [ ] Prepare release runbooks, rollback checklist, and on-call handbook + +### Phase 6 - Store Readiness and Scale + +- [ ] App Store/Play Store metadata and compliance packaging +- [ ] Performance budget validation (cold start, list render, live stream load) +- [ ] Battery/network efficiency tuning for live trading sessions +- [ ] Feature flag strategy for staged rollout by cohort +- [ ] Post-launch SLO tracking and incident response workflow + +## Suggested Task Tracking Format + +Use this format as implementation starts: + +- [ ] `Task name` + Platform: `iOS` | `Android` | `KMP` | `Backend` + Owner: `TBD` + Commit: `pending` + +Example after completion: + +- [x] `Implement Active Orders screen with trade_id and profile badges` + Platform: `iOS, Android` + Owner: `TBD` + Commit: `https://github.com///commit/` + +## Initial Backlog (MVP-first, recommended order) + +- [ ] Boot repositories and CI for iOS/Android/KMP +- [ ] Implement auth + token refresh +- [ ] Implement profile list + watchlist +- [ ] Implement orders/positions/history with strict `trade_id` mapping +- [ ] Implement manual trade actions + confirmations +- [ ] Implement profile execution/risk settings surface +- [ ] Implement notification stack (push + in-app inbox + preferences) +- [ ] Implement in-app support/AI chat module +- [ ] Implement health/status panel with AI fallback visibility +- [ ] Run lifecycle parity tests against backend and close gaps + +## Open Questions (Non-Blocking) + +- [ ] Should mobile support broker-level advanced order types at MVP (stop-limit, trailing-stop), or defer to Phase 5+? +- [ ] Should mobile include profile creation/edit at MVP, or remain read-and-execute only initially? +- [ ] Should AI-assisted profile suggestion run on-device fallback when AI service is unavailable, or server-side fallback only? diff --git a/backend/ORDER_STATUS_SYNC.md b/backend/ORDER_STATUS_SYNC.md new file mode 100644 index 0000000..c596a91 --- /dev/null +++ b/backend/ORDER_STATUS_SYNC.md @@ -0,0 +1,263 @@ +# Order Status Synchronization - Solution Documentation + +## Problem Statement + +Orders were getting stuck in `pending_new` status indefinitely, causing stale data in the dashboard. This happened because: + +1. **One-way data flow**: Bot → Database (no sync back) +2. **No status updates on fill**: When orders were filled, the database was never updated +3. **No background reconciliation**: No periodic check to sync actual order statuses from the exchange + +## Solution Overview + +We implemented a **three-layer solution** to handle stale order statuses: + +### 1. Immediate Updates (Real-time Fix) +**Files Modified:** +- `src/services/TradeExecutor.ts` + +**Changes:** +- Added `updateOrderStatus()` call when orders are **filled** (previously only called on cancel/reject) +- Added status update for both entry and exit orders +- Ensures database is updated immediately when order status changes + +```typescript +// After order is verified as filled +supabaseService.updateOrderStatus?.(order.id, verifiedOrder.status || 'filled', new Date()); +``` + +### 2. Background Sync Service (Periodic Reconciliation) +**Files Created:** +- `src/services/OrderStatusSyncService.ts` + +**Files Modified:** +- `src/services/SupabaseService.ts` - Added `getStaleOrders()` method +- `src/index.ts` - Integrated sync service into main bot + +**How it works:** +1. Runs every **5 minutes** in the background +2. Queries database for orders in `pending_new` status older than 5 minutes +3. Checks actual status on the exchange via `exchange.getOrder()` +4. Updates database with real status +5. Marks very old orders (>24h) as `unknown` if not found on exchange + +**Key Features:** +- Non-blocking (runs in background) +- Handles up to 100 stale orders per sync +- Graceful error handling +- Detailed logging for monitoring + +### 3. Visual Indicators (User Awareness) +**Files Modified:** +- `src/tabs/PositionsTab.tsx` (Dashboard) + +**Features:** +- **Warning banner** at top when stale orders detected +- **Yellow badge** on individual stale orders (>5 min old) +- **Warning icon** (⚠️) next to stale order status +- Real-time age calculation + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Trading Bot Flow │ +└─────────────────────────────────────────────────────────────┘ + +1. Order Placed + ↓ +2. TradeExecutor.openPosition() + ↓ +3. waitForFill() - Poll exchange for status + ↓ +4. ✅ NEW: updateOrderStatus() - Update DB immediately + ↓ +5. Track position locally + +┌─────────────────────────────────────────────────────────────┐ +│ Background Sync Service │ +└─────────────────────────────────────────────────────────────┘ + +Every 5 minutes: +1. Query DB for pending_new orders > 5 min old + ↓ +2. For each order: + - Check exchange.getOrder() + - Get actual status + - Update DB + ↓ +3. Mark >24h orders as 'unknown' if not found + +┌─────────────────────────────────────────────────────────────┐ +│ Dashboard Display │ +└─────────────────────────────────────────────────────────────┘ + +1. Fetch orders from DB + ↓ +2. Calculate age for each order + ↓ +3. Detect stale orders (pending_new > 5 min) + ↓ +4. Show warning banner + visual indicators +``` + +## Database Schema Assumptions + +The solution assumes the `orders` table has: +- `id` or `order_id` - Primary key +- `status` - Order status (pending_new, filled, canceled, etc.) +- `created_at` - Timestamp when order was created +- `updated_at` - Timestamp of last update +- `filled_at` - Timestamp when order was filled (optional) + +## Configuration + +### Sync Interval +Default: **5 minutes** + +To change, modify in `src/index.ts`: +```typescript +const orderSyncService = new OrderStatusSyncService( + dataExchange, + 10 * 60 * 1000 // 10 minutes +); +``` + +### Stale Threshold +Default: **5 minutes** + +To change, modify in `src/services/SupabaseService.ts`: +```typescript +async getStaleOrders(staleThresholdMinutes: number = 10) // 10 minutes +``` + +## Manual Cleanup + +For one-time cleanup of very old stale orders: + +```bash +# Run the cleanup script +npm run cleanup-stale-orders +``` + +This will mark all orders >24 hours old in `pending_new` status as `unknown`. + +## Monitoring & Logs + +Look for these log messages: + +### Successful Sync +``` +[OrderSync] Found 5 stale orders to check +[OrderSync] Updating order abc123: pending_new → filled +[OrderSync] Sync complete: 5 updated, 0 not found on exchange, 0 failed +``` + +### No Stale Orders +``` +[OrderSync] No stale orders found +``` + +### Errors +``` +[OrderSync] Failed to sync order abc123: Order not found on exchange +[Supabase] Error fetching stale orders: +``` + +## Testing + +### Test Immediate Updates +1. Place a new order via the bot +2. Check database - should see `pending_new` initially +3. Wait for order to fill (~3-30 seconds) +4. Check database - should see `filled` status + +### Test Background Sync +1. Manually set an order to `pending_new` in DB (that's actually filled) +2. Wait 5 minutes +3. Check logs for sync activity +4. Verify order status updated to `filled` + +### Test Dashboard Indicators +1. Create a stale order (pending_new > 5 min) +2. Open dashboard +3. Should see: + - Yellow warning banner at top + - Yellow badge on the order + - ⚠️ icon next to status + +## Troubleshooting + +### Orders still showing as pending_new after sync + +**Possible causes:** +1. Exchange doesn't support `getOrder()` API +2. Order ID mismatch between bot and exchange +3. Supabase credentials not configured + +**Solution:** +- Check logs for specific error messages +- Verify exchange connector implements `getOrder()` +- Confirm order IDs match between systems + +### Sync service not running + +**Check:** +```bash +# Look for this in logs on bot startup: +[OrderSync] Background order status sync service started +``` + +**If missing:** +- Verify `OrderStatusSyncService` is imported in `index.ts` +- Check for startup errors + +### Database not updating + +**Possible causes:** +1. Supabase credentials missing/invalid +2. Table permissions issue +3. Field name mismatch (`id` vs `order_id`) + +**Solution:** +- Check Supabase connection logs +- Verify table has both `id` and `order_id` columns (or update code) +- Test with manual query + +## Performance Considerations + +- **Sync batch size**: Limited to 100 orders per sync to avoid overload +- **Sync frequency**: 5 minutes balances freshness vs API rate limits +- **Exchange API calls**: One call per stale order (consider rate limits) + +## Future Enhancements + +1. **WebSocket status updates** - Real-time order status from exchange +2. **Retry logic** - Exponential backoff for failed syncs +3. **Metrics dashboard** - Track sync success rate, stale order trends +4. **Alert on persistent stale orders** - Notify if orders stay stale >1 hour +5. **Bulk status check** - If exchange supports batch order queries + +## Related Files + +### Core Implementation +- `src/services/TradeExecutor.ts` - Immediate status updates +- `src/services/OrderStatusSyncService.ts` - Background sync +- `src/services/SupabaseService.ts` - Database queries +- `src/index.ts` - Service initialization + +### Dashboard +- `src/tabs/PositionsTab.tsx` - Visual indicators + +### Utilities +- `src/scripts/cleanupStaleOrders.ts` - Manual cleanup + +## Summary + +This solution ensures order statuses are **always accurate** through: +1. ✅ **Immediate updates** when orders fill +2. ✅ **Background reconciliation** every 5 minutes +3. ✅ **Visual warnings** for users when stale data detected +4. ✅ **Manual cleanup** tools for maintenance + +**Result:** No more stale `pending_new` orders! 🎉 diff --git a/backend/PRODUCT_FLOW_TRADING_LOGIC.md b/backend/PRODUCT_FLOW_TRADING_LOGIC.md new file mode 100644 index 0000000..a660a22 --- /dev/null +++ b/backend/PRODUCT_FLOW_TRADING_LOGIC.md @@ -0,0 +1,529 @@ +# Bytelyst Trading Product Flow and Execution Logic + +Date: 2026-02-15 +Scope: runtime product flow across bot service, execution, monitoring, and dashboard sync + +## Purpose + +This document explains the live trading flow end-to-end: +- how a profile initiates a trade +- what conditions close a trade +- how monitoring runs periodically +- how Orders, Positions, and History stay linked by `trade_id` +- how dashboard data is synchronized from backend state + +## 1) Core Runtime Components + +- `bytelyst-trading-bot-service/src/index.ts` + - boots profile contexts + - runs strategy loop + - runs reconciliation and profile hot-reload loops +- `bytelyst-trading-bot-service/src/services/AutoTrader.ts` + - decides entry/exit actions from strategy result + risk guards +- `bytelyst-trading-bot-service/src/services/TradeExecutor.ts` + - places exchange orders and manages lifecycle state + - persists orders/history and updates in-memory active positions +- `bytelyst-trading-bot-service/src/services/tradeMonitor.ts` + - periodic SL/TP/trailing and exchange-position checks +- `bytelyst-trading-bot-service/src/services/OrderStatusSyncService.ts` + - periodic stale-order reconciliation with exchange +- `bytelyst-trading-bot-service/src/services/apiServer.ts` + - websocket state fan-out to dashboard + - writes `bot_state.json` and snapshots to Supabase + +## 2) Profile Configuration Inputs + +Per profile, behavior is controlled by `trade_profiles.strategy_config`: + +- `rules[]`: which strategy rules run +- `riskLimits`: `maxOpenTrades`, `maxDailyLossUsd`, `dailyProfitTargetUsd`, `maxConsecutiveLosses` +- `execution`: + - `minRulePassRatio` + - `orderType` + - `cooldownMinutes` + - `profitExitPercent` (optional override) + - `entryMode`: `both` or `long_only` + +Long-only behavior: +- `entryMode = long_only` blocks new SELL entries +- exit SELL orders are still allowed when closing BUY positions +- logic: `bytelyst-trading-bot-service/src/services/AutoTrader.ts` + +## 3) End-to-End Entry Flow + +1. Market context is built per symbol. +2. Each active profile evaluates rules independently. +3. For each profile result, `AutoTrader.handleSignal(...)` executes. +4. Entry is attempted only if: + - signal is not `NONE` + - rules passed + - symbol is in profile watchlist + - portfolio guard passes + - max open trades and cooldown checks pass + - runtime risk limits pass + - exchange safety checks pass + - entry direction allowed by `entryMode` +5. Risk engine computes: + - side-aware position size + - side-aware stop-loss and take-profit +6. Trade executor places order, verifies fill, and records: + - `orders` row with `action=ENTRY` + - deterministic `trade_id` + - active in-memory position keyed by symbol/profile context + + +### Manual entry capital guard flow (`POST /api/trade/manual`) + +Manual trade requests now enforce allocated-capital safety without blind rejection: +- compute `remainingCapital = allocatedCapital - committedOpenPositionNotional` +- if no remaining capital, wait up to 60s for release (poll every 3s) +- if capital becomes available, scale requested quantity down to the max tradable qty +- if remaining is below minimum tradable qty, return a clear rejection + +Result: +- trade size never exceeds profile/account allocated capital +- user can still get partial execution with available remaining capital +- when capital is fully locked, request waits briefly before returning "waiting for capital release" + +Source: +- `bytelyst-trading-bot-service/src/services/ManualTrader.ts` + +Primary references: +- `bytelyst-trading-bot-service/src/index.ts` +- `bytelyst-trading-bot-service/src/services/AutoTrader.ts` +- `bytelyst-trading-bot-service/src/services/riskEngine.ts` +- `bytelyst-trading-bot-service/src/services/TradeExecutor.ts` + +## 4) Position Close Conditions + +Trade close can be initiated from multiple paths: + +### A) Strategy signal flip +- If active side is opposite to new signal, close immediately. +- example: BUY position + SELL signal -> close +- source: `AutoTrader.handleSignal(...)` + +### B) Profit threshold + neutral signal +- If unrealized profit reaches `profitExitPercent` and signal becomes `NONE`, close. +- source: `AutoTrader.handleSignal(...)` + +### C) Safety stop-loss (monitor-driven) +- BUY closes when `price <= stopLoss` +- SELL closes when `price >= stopLoss` +- source: `tradeMonitor.checkOpenPositions(...)` + +### D) TP-triggered trailing guard (monitor-driven) +- take-profit hit activates `profitGuardActive` +- after activation, exit only on configured pullback: + - BUY: pullback from peak by `TRAILING_STOP_PERCENT` + - SELL: rebound from trough by `TRAILING_STOP_PERCENT` +- source: `tradeMonitor.checkOpenPositions(...)` + +### E) Exchange confirms position disappeared +- monitor confirms missing exchange position across required misses and final confirmation +- then finalizes local lifecycle as exchange-closed +- source: `tradeMonitor.checkOpenPositions(...)` + +### F) Manual square-off (API) +- manual close endpoint routes to executor close flow +- source: `apiServer` + `ManualTrader` + `TradeExecutor.closePosition(...)` + +## 5) Partial Exit Handling + +Exit fills are quantity-aware: +- full fill -> finalize trade and remove active position +- partial fill -> reduce active position quantity and continue monitoring remainder +- no valid partial quantity -> quarantine path for manual review + +Source: +- `bytelyst-trading-bot-service/src/services/TradeExecutor.ts` (`closePosition`, `applyExitFill`) + +## 6) Monitoring and Loop Frequencies + +Configured in `bytelyst-trading-bot-service/src/config/index.ts`: + +- `POLLING_INTERVAL` (default 60s): strategy/trading loop +- `MONITOR_INTERVAL_MS` (default 60s): profile reconciliation loop and trade monitor tick +- `PROFILE_SYNC_INTERVAL_MS` (default 60s): profile hot-reload loop +- Order status sync interval (currently 5 minutes in `index.ts` constructor call) +- State write debounce: ~200ms in `apiServer.scheduleStateWrite()` +- Supabase snapshot throttle: every 5 minutes in `apiServer.flushStateToDisk()` + + +Order-status sync behavior (every cycle): +- scans stale `pending_new` rows +- reconciles status from exchange when order still exists +- if exchange returns "order not found" and lifecycle is already closed for that `trade_id`, auto-resolves stale row to `canceled` +- this prevents ghost EXIT orders from staying `pending_new` after lifecycle completion + +Sources: +- `bytelyst-trading-bot-service/src/services/OrderStatusSyncService.ts` +- `bytelyst-trading-bot-service/src/services/SupabaseService.ts` (`isTradeLifecycleClosed`) + +## 7) Orders, Positions, History Linkage + +Lifecycle identity model: +- `trade_id` is created at entry and reused for exit +- `profile_id` scopes ownership/isolation +- `action` distinguishes `ENTRY` vs `EXIT` + +Data mapping: +- Orders table: all lifecycle events (`ENTRY` and `EXIT`) +- Active positions: in-memory + websocket `positions_update` +- History table: realized lifecycle slices (full close and partial exit slices) + +Primary references: +- `bytelyst-trading-bot-service/src/services/TradeExecutor.ts` +- `bytelyst-trading-bot-service/src/services/apiServer.ts` +- `bytelyst-trading-dashboard-web/src/tabs/PositionsTab.tsx` + +## 8) Dashboard Synchronization Flow + +Backend emits: +- `positions_update` +- `orders_update` +- `history_update` +- `symbol_update` +- `settings_update` + +Frontend hook: +- `bytelyst-trading-dashboard-web/src/hooks/useWebSocket.ts` + - receives events and updates local `botState` + +Positions & Orders tab: +- normalizes `trade_id` (`trade_id` vs `tradeId`) +- infers lifecycle IDs for legacy rows when needed +- builds lifecycle traces per `trade_id` +- shows mismatch diagnostics (missing trade ID, missing entry order, profile mismatch) +- computes deterministic lifecycle states: + - `OPEN`: entry filled, no exit fill yet + - `EXIT_PENDING`: exit submitted, waiting for fill sync + - `PARTIAL_EXIT`: partial exit filled; remaining quantity stays in Open Positions + - `CLOSED`: lifecycle fully matched/closed + - `ORPHAN_EXIT`: exit exists but matching entry is not in visible order window + +Source: +- `bytelyst-trading-dashboard-web/src/tabs/PositionsTab.tsx` + + +History tab: +- uses deduplicated lifecycle history ledger for audit totals (`trade_id + profile + timestamp + prices + size + pnl + reason`) +- uses Supabase `trade_history` as primary source; realtime websocket history is used only as fallback when DB rows are unavailable +- Realized P&L formula: `sum(pnl)` over deduplicated history rows in current filter scope +- win rate formula: `winning_rows / total_rows` where `winning_rows = pnl > 0` +- surfaces per-trade `capital used` and highlights losing trades +- normalizes numeric timestamps (seconds vs milliseconds) and unknown side values to avoid mis-sorted or mis-labeled rows +- numeric rendering is NaN-safe for P/L percent and price fields + +Source: +- `bytelyst-trading-dashboard-web/src/tabs/HistoryTab.tsx` +- `bytelyst-trading-dashboard-web/src/lib/tradeHistoryLedger.ts` + +Overview tab: +- uses the same deduplicated history ledger as History tab for realized metrics and profile-level aggregates +- Realized P&L formula: `sum(pnl)` over deduplicated ledger rows +- Net P&L formula: `Realized P&L + sum(unrealizedPnl of open positions)` +- Capital Scope table now shows profile-level `Realized P&L` from profile-scoped ledger aggregation +- shows per-profile `allocated / used / remaining` capital and runtime state labels +- exposes `P&L Duration` in status bar: elapsed time from first to latest realized ledger event in scope +- does not show aggregate `P&L Capital Used` in status bar (intentionally removed to avoid misleading interpretation at summary level) +- supports win-rate time windows in Overview (`24H`, `7D`, `30D`, `All`) for both global and per-profile win-rate metrics +- windowed win-rate formula: `wins_in_window / trades_in_window`, where `wins_in_window = pnl > 0` within selected window + +Source: +- `bytelyst-trading-dashboard-web/src/tabs/OverviewTab.tsx` +- `bytelyst-trading-dashboard-web/src/lib/tradeHistoryLedger.ts` + +Strategy Clusters tab: +- profile card P&L and win rate use the same deduplicated history ledger aggregation as Overview/History +- non-admin scope is user-filtered to avoid cross-user P&L contamination + +Source: +- `bytelyst-trading-dashboard-web/src/components/TradeProfileManager.tsx` +- `bytelyst-trading-dashboard-web/src/lib/tradeHistoryLedger.ts` + +## 9) Operational Notes + +- If state changes every minute, local `bot_state.json` can update frequently (debounced). +- Supabase snapshot upload is throttled to 5-minute intervals, so it is a rolling backup, not every state mutation. +- This design reduces backup write overhead while keeping recoverable snapshots. + +- `trade_history` insert path is legacy-schema safe: if `source` column is unavailable, bot retries with fallback payload instead of dropping the event. + +Source: +- `bytelyst-trading-bot-service/src/services/SupabaseService.ts` + +## 10) AI Resilience and Fallback Behavior + +### AI trading rule (`AIAnalysisRule`) + +- AI provider chain: `openai -> perplexity -> gemini` (configurable fallback list) +- If AI service is unavailable: + - with `AI_FAIL_OPEN=true` (default), AI rule does not block trading + - with `AI_FAIL_OPEN=false`, AI rule blocks as a failed rule + +Config references: +- `AI_FAIL_OPEN` +- `AI_FALLBACK_LIST` +- `AI_MODEL` +- provider keys (`OPENAI_API_KEY`, `PERPLEXITY_API_KEY`, `GEMINI_API_KEY`) + +### Chat profile creation fallback + +- Endpoint: `POST /api/chat` +- If AI providers fail or return invalid JSON, backend now returns deterministic profile JSON generated from local heuristics: + - parses symbols, capital, risk, cooldown, entry mode, sessions + - supports create/update/explain fallback outputs + - response includes `fallback: "local_deterministic"` + +### AI health endpoint + +- Endpoint: `GET /api/ai/health` +- Query: + - `probe=true` for live provider probe + - omit `probe` for config-only status +- Response includes: + - per-provider `configured`, `status`, `message`, `model` + - effective fallback list and fail-open state + - summary counts + +## 11) Quick Trace Checklist (One Trade) + +1. Find profile and symbol signal in websocket symbol state. +2. Check `orders` for `ENTRY` row and `trade_id`. +3. Verify active position carries same `tradeId` + `profileId`. +4. On close, confirm `EXIT` order has same `trade_id`. +5. Confirm history row created with same `trade_id` (or partial-exit realized slice entries). +6. If mismatch, use lifecycle trace/mismatch diagnostics in Positions tab. + + + +## 12) Lifecycle Reconciliation (2026-02-15) + +- Legacy compatibility: lifecycle entry detection now treats `action IS NULL` + BUY rows as ENTRY fallback. +- Virtual position reconstruction for legacy no-action rows now infers BUY as ENTRY and SELL as EXIT. +- Reconciliation tool: `npm run reconcile:lifecycle-history -- --apply --start=2026-02-12T00:00:00.000Z ` +- Reconciliation behavior: + - computes canonical realized P&L from filled orders per `trade_id` using FIFO closed quantity matching + - neutralizes conflicting non-canonical history rows by zeroing P&L and prefixing reason with `[RECONCILED_TO_ORDERS]` + - inserts or updates one `[RECONCILED_CANONICAL]` history row per reconciled lifecycle +- Latest operational result: + - post-purge lifecycle mismatch count reduced to `0` (order-derived vs history-derived, trade_id scoped) + - Alpaca vs Supabase order status parity remains `0` mismatches +## 13) Alpaca Reconciliation Carry-In Baseline (2026-02-15) + +- Script: `scripts/reconcileAlpacaVsSupabase.ts` +- New capabilities: + - paged Alpaca order fetch over `carry_in_lookback_days` to reconstruct pre-window state + - dual realized-PnL outputs: `flat_start` and `with_carry_in` + - auto primary-mode selection (`alpaca_fill_derived_realized_pnl`) based on Supabase pre-window coverage consistency + - symbol scope alignment for PnL (`db-order-symbols`) so non-bot symbols do not pollute comparison +- New output diagnostics: + - `alpaca_carry_in_bootstrap.primary_mode` + - `alpaca_carry_in_bootstrap.supabase_pre_window_filled_order_count` + - `alpaca_carry_in_bootstrap.carry_coverage_gap` + - `alpaca_carry_in_bootstrap.carry_coverage_threshold` +- Usage examples: + - `node --loader ts-node/esm scripts/reconcileAlpacaVsSupabase.ts 3` + - `node --loader ts-node/esm scripts/reconcileAlpacaVsSupabase.ts 3 --carry-lookback-days=180` + +## 14) Runtime Addendum (2026-02-16) + +### Profile-symbol scope synchronization + +- Profile bootstrap sync and periodic reconciliation now use profile-resolved symbol scope (`profile.symbols` fallback to `config.SYMBOLS`). +- Trading loop now runs on dynamic union of monitored symbols across all active profiles. +- Profile hot-reload detects symbol-list changes and re-syncs position state using old+new symbol union to avoid stale local state. + +Primary source: +- `bytelyst-trading-bot-service/src/index.ts` + +### Legacy stale EXIT reconciliation without trade_id + +- Order-sync stale reconciliation now handles legacy EXIT-like rows that have no `trade_id`. +- If exchange reports order missing and profile-scoped virtual lifecycle is flat for `profile_id + symbol`, stale order is auto-resolved to `canceled`. +- This closes a real stale-order gap that previously left perpetual `pending_new` rows in Active Orders. + +Primary sources: +- `bytelyst-trading-bot-service/src/services/OrderStatusSyncService.ts` +- `bytelyst-trading-bot-service/src/services/SupabaseService.ts` + +### Position merge identity hardening + +- Position websocket merge now keys stable rows by owner/profile/trade identity so stable lifecycle rows are preserved and not always collapsed by owner+symbol. +- Fallback/no-trade rows still collapse by owner/profile/symbol/side to prevent duplicate noise. + +Primary source: +- `bytelyst-trading-bot-service/src/services/apiServer.ts` + +### Known structural constraint + +- Same-profile same-symbol concurrent lifecycles are still structurally limited by runtime active-position map keyed by symbol (`Map`). +- Full multi-lifecycle same-symbol support within one profile requires executor state model refactor (position map keyed by `trade_id` or hybrid `symbol+trade_id`). + +### 2026-02-16 Commit References + +- Lifecycle/profile sync hardening implementation: + - https://github.com/saravanakumardb/bytelyst-trading-bot-service/commit/78be9e63f4e3e2a0c550e467c4cb4f66c7c2b2df +- Runtime flow documentation addendum: + - https://github.com/saravanakumardb/bytelyst-trading-bot-service/commit/bb739a0cad9c06ae591ccd95b7f2135f23261170 + +## 15) Runtime Addendum (2026-02-16, Cycle 2) + +### Multi-lifecycle same-symbol handling (per profile) + +- Active runtime position state is now trade-id aware (`symbol + trade_id`), so one profile can hold multiple concurrent lifecycles on the same symbol. +- AutoTrader exit logic now evaluates and exits per lifecycle (`trade_id`) for signal flip and neutralized-profit exits. +- TradeMonitor now evaluates SL/TP/trailing exits per lifecycle and finalizes exchange-missing closures per lifecycle. + +Primary sources: +- `bytelyst-trading-bot-service/src/services/TradeExecutor.ts` +- `bytelyst-trading-bot-service/src/services/AutoTrader.ts` +- `bytelyst-trading-bot-service/src/services/tradeMonitor.ts` + +### Dedicated profile sync behavior + +- Profile-scoped sync now reconstructs multiple virtual open slices for a symbol using `profile_id + symbol + trade_id`. +- If per-trade slices are not recoverable, sync safely falls back to aggregate virtual lifecycle state. + +Primary sources: +- `bytelyst-trading-bot-service/src/services/SupabaseService.ts` +- `bytelyst-trading-bot-service/src/services/TradeExecutor.ts` + +### Continuous parity guard + +- Reconciliation loop now includes a scheduled Alpaca-vs-Supabase order parity audit over recent profile orders. +- Safe terminal mismatches are auto-corrected, and runtime health publishes `parityMismatchCount`. + +Primary sources: +- `bytelyst-trading-bot-service/src/index.ts` +- `bytelyst-trading-bot-service/src/services/apiServer.ts` + +### Manual square-off behavior + +- Manual close endpoint now exits all active sub-positions for a symbol (trade-id aware), not only one selected row. + +Primary source: +- `bytelyst-trading-bot-service/src/services/apiServer.ts` + +### Cycle 2 Commit Link + +- Runtime multi-lifecycle + parity guard implementation: + - https://github.com/saravanakumardb/bytelyst-trading-bot-service/commit/abb6dd5e1fa1b3f12104c9205d2a9efcd7d668cb + +## 16) Runtime Addendum (2026-02-16, Cycle 3) + +### Lifecycle retention during temporary virtual-sync gaps + +- Dedicated-profile sync no longer clears local symbol lifecycles when the exchange still reports an open position but virtual DB reconstruction temporarily returns empty. +- This prevents false drops where filled ENTRY orders briefly disappear from Open Positions due to eventual consistency windows. + +Primary source: +- bytelyst-trading-bot-service/src/services/TradeExecutor.ts + +### Immediate dashboard position propagation + +- Position snapshots are now pushed immediately after confirmed ENTRY fill and after lifecycle finalization. +- Dashboard no longer has to wait for the next trading-loop/reconciliation cycle to reflect these two state transitions. + +Primary source: +- bytelyst-trading-bot-service/src/services/TradeExecutor.ts + +### Regression coverage added + +- Added lifecycle regression assertions for: + - immediate position snapshot push after filled entry + - retaining local lifecycle state when exchange is open and virtual lookup is temporarily empty + +Primary source: +- bytelyst-trading-bot-service/scripts/testTradeExecutorLifecycle.ts + +### Cycle 3 Commit Link + +- Runtime lifecycle retention plus immediate position propagation fix: + - https://github.com/saravanakumardb/bytelyst-trading-bot-service/commit/9c53222939b73fba4ab95f83c7df5d57f9b46683 +## 17) Lifecycle Linkage Model (2026-02-16, Cycle 4) + +### How lifecycle is tracked end-to-end + +- Primary lifecycle key is trade_id, scoped by profile_id. +- Every bot-originated ENTRY and EXIT order carries trade_id and profile_id. +- Order persistence now writes idempotently by order_id, so retries do not create duplicate lifecycle rows. +- Active positions are maintained per lifecycle (symbol plus trade_id) and rendered per profile context. +- Trade history rows are written per lifecycle close/partial-close with the same trade_id. + +### How stale and duplicate views are controlled + +- Backend stale-order sync now runs on configurable cadence (ORDER_SYNC_INTERVAL_MS) and checks broader pending statuses (pending_new, pending, accepted, new). +- Stale EXIT rows are auto-resolved when lifecycle closure is already proven by trade_id chain. +- Dashboard position rendering dedupes by profile_id plus trade_id to avoid duplicate position cards. +- Dashboard order activity normalizes stale pending EXIT rows to closed state when history confirms lifecycle closure for that trade_id scope. + +### Current residual limitation + +- Full enterprise-level test coverage is still in-progress. Current implementation hardens lifecycle consistency, but broad component/service test expansion remains required for full target coverage. + +### Cycle 4 Commit Links + +- Bot lifecycle persistence and stale-sync hardening: + - https://github.com/saravanakumardb/bytelyst-trading-bot-service/commit/0d3409a48ac60dfb76e84c75be3f4b12c2f98a18 +- Dashboard lifecycle table stabilization: + - https://github.com/saravanakumardb/bytelyst-trading-dashboard-web/commit/e9422a3e330161bde0f32c9275f8f7dea30cdee5 +## 18) Validation Addendum (2026-02-16, Cycle 5) + +### Dashboard flow validation expansion + +- Added deterministic automated coverage for core runtime visibility paths: + - app shell auth routing (`loading`, `login`, reset callback, authenticated dashboard) + - lifecycle trace rendering (`OPEN`, `PARTIAL_EXIT`, `CLOSED`, `ORPHAN_EXIT`, `EXIT_PENDING`) + - stale pending EXIT warning and mismatch diagnostics rendering + - overview capital/P&L/readiness rendering paths + - history/config/settings/entries/admin tab shell paths + +### Supporting module coverage + +- Added automated tests for: + - `AuthContext` provider and guard behavior + - `useWebSocket` default-state contract + - `supabaseClient` env-based initialization + - strategy-config normalization compatibility (`normalizeStrategyConfig`) + +### Result snapshot + +- Dashboard coverage (`npm run coverage:full`) now reports: + - Statements: `42.46%` (`806/1898`) + - Branches: `34.71%` (`743/2140`) + - Functions: `33.26%` (`159/478`) + - Lines: `44.25%` (`762/1722`) + +### Commit link + +- Dashboard test expansion implementation: + - https://github.com/saravanakumardb/bytelyst-trading-dashboard-web/commit/3be473d882018d1cd88f7473dbbc24ee5f0be662 +## 19) Runtime Merge and Lifecycle Aggregation Hardening (2026-02-16, Cycle 6) + +### What changed + +- Backend runtime state updates now use canonical merge helpers before publishing positions/orders to bot state. + - Positions: stable `trade_id` identity first; fallback owner+symbol+side consolidation. + - Orders: `order_id` identity merge with terminal-status precedence to prevent stale pending downgrade. +- Dashboard lifecycle aggregation now computes realized P&L and win rate per lifecycle chain (`profile_id + trade_id`) instead of row-level counting. +- Dashboard overview and positions views now dedupe runtime positions by lifecycle identity before utilization/P&L rendering. + +### Why this closed the duplicate and mismatch issue + +- The previous flow could count the same lifecycle multiple times when sync events arrived from different paths or with profile-less rows. +- The new flow keeps one canonical runtime representation per lifecycle and one canonical aggregate record per lifecycle chain. + +### Expected user-visible behavior now + +- Active positions, order activity, lifecycle table, and history all align by `trade_id` and `profile_id` more consistently. +- Net P&L and win rate no longer inflate from duplicated lifecycle rows. +- Capital used/utilization in overview reflects deduped open runtime positions. + +### Commit links + +- Bot runtime merge hardening: + - https://github.com/saravanakumardb/bytelyst-trading-bot-service/commit/af772b265a348f2c47440c5f41c74144156b54f4 +- Dashboard lifecycle aggregation and runtime dedupe hardening: + - https://github.com/saravanakumardb/bytelyst-trading-dashboard-web/commit/ad9afd7537f8ae8bf805d7a10a6cb4ffad5e9abf diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..6fb151d --- /dev/null +++ b/backend/README.md @@ -0,0 +1,321 @@ +# Bytelyst Trading Bot Service + +Autonomous multi-profile crypto/equity trading bot with a pluggable rule-based strategy engine, per-profile execution, real-time dashboard integration, and AI-powered sentiment analysis. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────┐ +│ Trading Loop │ +│ for each symbol → for each profile → ProStrategyEngine │ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │TrendBias │→ │ Session │→ │ Zone │→ ... │ +│ │ Rule │ │ Rule │ │ Rule │ │ +│ └──────────┘ └──────────┘ └──────────┘ │ +│ │ +│ Signal → AutoTrader → RiskEngine → TradeExecutor │ +│ ↓ │ +│ ┌────────────────┐ │ +│ │ Alpaca / CCXT │ │ +│ └────────────────┘ │ +└─────────────────────────────────────────────────────────┘ + ↕ Socket.IO + REST ↕ Supabase +┌──────────────────┐ ┌──────────────────┐ +│ Dashboard │ │ PostgreSQL │ +│ (React App) │ │ (Supabase DB) │ +└──────────────────┘ └──────────────────┘ +``` + +## Features + +- **Multi-Profile Execution** — Each trade profile runs its own strategy rules, risk limits, and capital allocation independently +- **7-Rule Strategy Pipeline** — TrendBias → Session → Zone → Momentum → EntryTrigger → RiskManagement → AIAnalysis +- **Per-Profile Rule Config** — Enable/disable rules, set parameters, and control execution order per profile +- **Profile Hot-Reload** — New profiles created from the dashboard are picked up automatically within 60 seconds +- **Pluggable Exchanges** — Alpaca (stocks/crypto) and CCXT (130+ exchanges) via factory pattern +- **AI Sentiment Analysis** — Perplexity, OpenAI, and Gemini with automatic fallback chain +- **Real-Time Dashboard** — Socket.IO for live price updates, signals, and position tracking +- **Profile-Mapped Orders** — Every order and trade history record is tagged with `profile_id` +- **Configurable Everything** — All thresholds, intervals, and parameters are configurable via env vars or per-profile JSON + +## Quick Start + +### Prerequisites + +- Node.js 18+ +- Supabase project (for auth, users, profiles, orders, trade history) +- Alpaca account (for trade execution) or CCXT-compatible exchange + +### Installation + +```bash +git clone https://github.com/saravanakumardb/bytelyst-trading-bot-service.git +cd bytelyst-trading-bot-service +npm install +``` + +### Configuration + +Copy the example environment file: + +```bash +cp .env.example .env +``` + +Edit `.env` with your credentials (see [Environment Variables](#environment-variables) below). + +### Database Setup + +Run the schema migration in your Supabase SQL Editor: + +```bash +# Full schema setup (creates all tables + adds missing columns) +# Copy and paste the contents of: +schema/004_full_schema_sync.sql +``` + +### Run + +```bash +# Development (with hot-reload via tsx) +npm run dev + +# Production +npm start +``` + +## Project Structure + +``` +src/ +├── config/ +│ └── index.ts # All configuration (env vars + defaults) +├── connectors/ +│ ├── alpaca.ts # Alpaca exchange connector +│ ├── ccxt.ts # CCXT multi-exchange connector +│ ├── factory.ts # Connector factory (pluggable) +│ └── types.ts # IExchangeConnector interface +├── services/ +│ ├── AutoTrader.ts # Signal → trade decision logic +│ ├── TradeExecutor.ts # Order execution + position tracking +│ ├── riskEngine.ts # Position sizing + SL/TP calculation +│ ├── tradeMonitor.ts # Background SL/trailing stop monitor +│ ├── SupabaseService.ts # DB operations (orders, history, profiles) +│ ├── apiServer.ts # REST + Socket.IO API for dashboard +│ ├── notifier.ts # WhatsApp/webhook notifications +│ ├── aiClient.ts # Multi-provider AI sentiment analysis +│ ├── ManualTrader.ts # Manual trade execution via dashboard +│ └── MetricsService.ts # Trading metrics calculation +├── strategies/ +│ ├── ProStrategyEngine.ts # Main strategy orchestrator +│ ├── directionTracker.ts # Legacy signal tracker +│ └── rules/ +│ ├── types.ts # IRule interface, MarketContext, RuleResult +│ ├── TrendBiasRule.ts # EMA50/200 trend direction (4H) +│ ├── SessionRule.ts # Market session filter (London/NY) +│ ├── ZoneRule.ts # Price-to-EMA proximity check +│ ├── MomentumRule.ts # RSI momentum confirmation +│ ├── EntryTriggerRule.ts# EMA reclaim / wick patterns +│ ├── RiskManagementRule.ts # ATR-based SL/TP/position sizing +│ └── AIAnalysisRule.ts # LLM sentiment validation +├── utils/ +│ ├── indicators.ts # EMA, RSI, ATR calculations +│ ├── logger.ts # Winston logger +│ └── symbolMapper.ts # Symbol format conversion +└── index.ts # Main entry point + trading loop +``` + +## Environment Variables + +### Required + +| Variable | Description | Example | +|---|---|---| +| `SUPABASE_URL` | Supabase project URL | `https://xxx.supabase.co` | +| `SUPABASE_KEY` | Supabase service role key | `eyJ...` | +| `PROVIDER` | Default exchange provider | `alpaca` or `ccxt` | +| `ALPACA_API_KEY` | Alpaca API key | `PK...` | +| `ALPACA_API_SECRET` | Alpaca secret key | `...` | + +### Exchange Configuration + +| Variable | Default | Description | +|---|---|---| +| `DATA_PROVIDER` | `$PROVIDER` | Provider for market data | +| `EXECUTION_PROVIDER` | `$PROVIDER` | Provider for order execution | +| `EXCHANGE` | `binance` | CCXT exchange (if using ccxt) | +| `CCXT_API_KEY` | — | CCXT exchange API key | +| `CCXT_API_SECRET` | — | CCXT exchange secret | +| `PAPER_TRADING` | `false` | Use paper trading keys | +| `ASSET_CLASS` | `crypto` | `crypto` or `us_equity` | + +### Trading Symbols & Intervals + +| Variable | Default | Description | +|---|---|---| +| `SYMBOLS` | `BTC/USD` | Comma-separated trading pairs | +| `POLLING_INTERVAL` | `60000` | Main loop interval (ms) | +| `SYMBOL_DELAY_MS` | `2000` | Delay between symbol processing | +| `TIMEFRAME` | `1Min` | Legacy candle timeframe | + +### Execution & Risk + +| Variable | Default | Description | +|---|---|---| +| `ENABLE_TRADING` | `false` | Enable live trade execution | +| `TOTAL_CAPITAL` | `1000` | Default total capital ($) | +| `MAX_OPEN_TRADES` | `3` | Max concurrent positions | +| `COOLDOWN_MS` | `3600000` | Post-trade cooldown (ms) | +| `PROFIT_EXIT_PERCENT` | `1.0` | Auto-exit profit threshold (%) | +| `TRAILING_STOP_PERCENT` | `0.001` | Trailing stop pullback (0.1%) | + +### Strategy Parameters + +| Variable | Default | Description | +|---|---|---| +| `ENABLED_RULES` | all 7 rules | Comma-separated rule list | +| `R_TREND_TIMEFRAME` | `4h` | Trend bias timeframe | +| `R_TREND_EMA_FAST` | `50` | Fast EMA period | +| `R_TREND_EMA_SLOW` | `200` | Slow EMA period | +| `R_RSI_PERIOD` | `14` | RSI calculation period | +| `R_RSI_OVERBOUGHT` | `70` | RSI overbought threshold | +| `R_RSI_OVERSOLD` | `30` | RSI oversold threshold | +| `R_ZONE_EMA_PERIOD` | `20` | Zone EMA period | +| `R_ATR_PERIOD` | `14` | ATR calculation period | +| `R_RISK_PER_TRADE` | `0.01` | Risk per trade (1%) | +| `R_RISK_REWARD_RATIO` | `1.5` | Risk/reward ratio | +| `R_SL_MULTIPLIER` | `1.5` | Stop loss ATR multiplier | +| `R_SESSION_WINDOWS` | JSON array | Session time windows | + +### AI Configuration + +| Variable | Default | Description | +|---|---|---| +| `AI_PROVIDER` | `openai` | Primary AI provider | +| `PERPLEXITY_API_KEY` | — | Perplexity API key | +| `OPENAI_API_KEY` | — | OpenAI API key | +| `GEMINI_API_KEY` | — | Google Gemini API key | +| `AI_FALLBACK_LIST` | `openai,perplexity,gemini` | Fallback chain | +| `AI_MODEL` | `gpt-4o` | AI model to use | +| `AI_CONFIDENCE_THRESHOLD` | `70` | Min confidence score | +| `AI_CACHE_HOURS` | `4` | Cache duration (hours) | + +### Server & Notifications + +| Variable | Default | Description | +|---|---|---| +| `API_PORT` | `5000` | API server port | +| `NOTIFICATION_PHONE_NUMBERS` | — | Comma-separated phone numbers | +| `NOTIFICATION_API_HOST` | `www.zenhustles.com` | Notification API host | +| `NOTIFICATION_API_PATH` | `/api/whatsapp/send` | Notification API path | +| `WEBHOOK_URL` | — | Legacy webhook URL | + +### System Intervals + +| Variable | Default | Description | +|---|---|---| +| `PROFILE_SYNC_INTERVAL_MS` | `60000` | Profile hot-reload interval | +| `MONITOR_INTERVAL_MS` | `60000` | Position monitor polling | + +## Database Schema + +7 tables are used (see `schema/004_full_schema_sync.sql` for full DDL): + +| Table | Purpose | +|---|---| +| `users` | User accounts + exchange API keys | +| `entries` | Watchlist & manual positions | +| `trade_profiles` | Strategy profiles with per-profile rule config | +| `orders` | Active/pending orders (with `profile_id`) | +| `trade_history` | Completed trade ledger (with `profile_id`) | +| `bot_config` | Global config key-value store | +| `dynamic_config` | Runtime config overrides | + +### strategy_config JSON Structure + +Each profile stores its strategy configuration as a `jsonb` column: + +```json +{ + "rules": [ + { "ruleId": "TrendBiasRule", "enabled": true, "params": { "emaFast": 50, "emaSlow": 200 } }, + { "ruleId": "SessionRule", "enabled": true, "params": { "allowedSessions": ["NY", "LDN"] } }, + { "ruleId": "ZoneRule", "enabled": true, "params": { "emaPeriod": 20 } }, + { "ruleId": "MomentumRule", "enabled": true, "params": { "rsiPeriod": 14 } }, + { "ruleId": "EntryTriggerRule", "enabled": true, "params": {} }, + { "ruleId": "RiskManagementRule", "enabled": true, "params": { "atrPeriod": 14 } }, + { "ruleId": "AIAnalysisRule", "enabled": false, "params": { "minConfidence": 80 } } + ], + "riskLimits": { + "maxDailyLossUsd": 50, + "dailyProfitTargetUsd": 100, + "maxConsecutiveLosses": 2, + "maxOpenTrades": 3 + }, + "execution": { + "orderType": "market", + "cooldownMinutes": 30, + "minRulePassRatio": 0.70 + } +} +``` + +## API Endpoints + +### REST + +| Method | Endpoint | Description | +|---|---|---| +| `GET` | `/health` | Health check with uptime | +| `GET` | `/api/status` | Bot status, settings, symbols, positions | +| `GET` | `/api/config` | Current bot configuration (non-secret) | +| `GET` | `/api/alerts` | Recent alerts (supports `?limit=N`) | +| `GET` | `/api/symbol/:symbol` | Single symbol data | +| `POST` | `/api/trade` | Execute manual trade | +| `POST` | `/api/chat` | AI-powered profile generation (see below) | + +### POST /api/chat — AI Strategy Assistant + +Translates plain English into structured trading profile configurations using the bot's AI fallback chain (Perplexity → OpenAI → Gemini). + +**Request:** +```json +{ + "message": "Create a conservative BTC swing trader with $2000 capital", + "context": [{ "id": "...", "name": "Existing Profile", "allocated_capital": 1000, ... }] +} +``` + +**Response:** +```json +{ + "action": "create_profile", + "profile": { + "name": "Conservative BTC Swing Trader", + "allocated_capital": 2000, + "risk_per_trade_percent": 1, + "symbols": "BTC/USDT", + "is_active": true, + "strategy_config": { "rules": [...], "riskLimits": {...}, "execution": {...} } + }, + "summary": "Created a conservative BTC swing trading profile with $2000 capital and 1% risk per trade.", + "reasoning": "Conservative profiles use lower risk and fewer aggressive rules." +} +``` + +**Supported actions:** `create_profile`, `update_profile`, `explain` + +### Socket.IO Events + +| Event | Direction | Description | +|---|---|---| +| `state` | Server → Client | Full bot state update | +| `symbol_update` | Server → Client | Single symbol price/signal update | +| `new_alert` | Server → Client | New trading alert | +| `orders_update` | Server → Client | Order status change | +| `positions_update` | Server → Client | Aggregated positions from all profiles | + +## License + +ISC diff --git a/backend/REPO_AUDIT_ROADMAP.md b/backend/REPO_AUDIT_ROADMAP.md new file mode 100644 index 0000000000000000000000000000000000000000..fad59a98cc77ed4c7a6cb21c079f8b55c513dca0 GIT binary patch literal 83686 zcmeI*X_H;ol^*E)?1=skh*DGqD#~Dj7|Ck4+ay3qv_TP#BvtOHb`T>6i<1eeC{*~j zclUbEn+y9)2_!`%ka8&yiM)C5Is5Fr#&->S-~aFb{nf#RgZB@f9sGFkqUcksvQ2|phEZwLQw`nf$lGN1dUy5hTf?^4aujQ(Ys>65zi!huuz)Zs9yhApMO`Mm=$`UR%l|rkL!vHb?1}m`A_QZe_ww;EDC&86nuPe zIMMOjy5~Zz@5{RG(ZL^T99N<6=hHjS>fNvF)umd$@xQE3v)V`X?uCPk^(3>i&ZqVF zrGx)i|9@ZaKCLm#=ey#}_~gra$6Vd@?BMU8^Wj$qzpDH0)i~k$lN$5F!R@;4QC;8M zA^~^nPOJN4ePZrE)GMp_s$|5BKCcnJtJPkp`F=Z%^5o!8^~s~UuIFQg-<6c1);~_8 zU#RPie!uR>GY{)Y_YOX;IedC>t0d{OgI^!qslWfJUVT!ZyjP!mc<{S}-_+mU|4sdM z>)@@L#p7chnRV9nNzLVldLEu#sB6g*i7}r@`$yB7tR79#;eSk=!oSBeI+-IWM#1L9 z(3x-QlW5a?&8U(m`i**@*K=`|Jdm?c8>hBc7%y{uQY&~?SD?7Nag{u;$E1T#OP0u$ zF+J^zq6~VN{|EI<61Tl`%>UsuyD#fXJoGg3@Kyc)q~1ZdNA=m)_0Nn*Kx7b2$TE$1 za4ZpIEfPM~61lukS4303p3SeVhEaU}Z9Od#>+eBfE6dn6Zd&t()i8_b%7rp$$ zG^0VM=$w1;mmFsu8Sg^9*A$A~!I!o%Pi8aypmdugp~3ikPk-k#4{dWjEq+i^(9&R} z`?Ik=n`q8*og_hKN~WxMsY~xoEBk7iBdH&Df+V!YMH{T}?lj8WYRs>jbLF=+t6@c2 zuGsRW&77~=^$#XWJ+1%0og~7%ara+~3gqjjiPI#LUa*Ix%_nOslhJsvVP^(^L(RGL z|FK4hzI$SH!MtN1Zk1F;7GjToU)Qq1yuh$oM|J0e;)6BvFD%8L&l*;hw!MFR?Xdju zVt31q9MwO(y;%Q$TO)r{ud~v*r?`J?e~h+A;~$Hjq+@uIlb`UX8so=W!K3=kPrhty zR6vuHN1V^OuYTF6;Z*yOwgqbn{kkahVcB*RG7B@0|DvJaPJT5K#Pfu6DEYLg@t|bM z9Uqibe^B=PqiK|%ru!outi&Ae+@HoHQEwG>=5`ak<|}HBvK?O(zgS}4);;VV4{w~4 zXx7-^QgNVX!HR@4mui$;aX5{PzWBwee?Rf=a2lNk44wR7=408lJ9YIELA*8Hdw;?g zXtY1#_P%>Nt9)8C3RR$$lSdmi=culyORV?NvHqWKWWM8YGvC-#egoi^eafj0hgQT@Nw@81`-f0$OsZfyPP z8jtZ1EFMIH@19TQhBskj@S9$G#-|8o)b;WX=SYAjDZJb0%t`{nw1P_Hf@yj#B?)coH)xLN>ssCMZv9-V_pjIAH_EDhKE2zXrRf}g4fF(OiaTK6&}(>u zrYTPYZGBa98MYj~TdETtq`)5T~9F4kGtBBr&BB*e+u4V<_sa4;s zSC8wxYqjoo>ebEX$iMYyYvuVK76OXDR2sqNbo_Qy-sG@+&E0zM^LiKRZ+v!P@|wKg z!}_6R&Yj%Kv3bJdgTr{gZj&QQ2_zf~AH# z$@;bWN6z_FGJm5e1v@>NXbZ=Z`e59(1-VxiL#)sl(UFfM@xjWW>d0TUhupIwEcTzK z$e)LOR(>Gao==NUtP+c#YZCX!Cw8`#_wDP&sc*`&CVE+C3VR_qkmi1zpq8j~0hVi#8g$v!A<@y0i& zYrSHvuGY1u;TCJv`D~h!j2GGeIW!%fIgvNrpVwFLdW!^QCuy?G&SAY`J?8hFZ_R>w z*ejYPj=-D5LpLX1!IwZp>|x@JXcaq&$KoszmgveSZz4+E^5i6RE^^`-;c>Acw0&)Q z=W$U$^aw#hGnc2;k-%%yUpMRXcu|q#mHG>&TwD0tH8webrXh}bJ#--Nj-zCMSpG*- zWVH5~U2|v)38S+t#$}0kp?$3MVflo0&i{O2bu_%cs62k;S` zDo((a8}<0N1or*Xi3VxDJ;dN2sT790(^5u=q zxLu#~f&5+PRrWFg@uR`Z%W6sD9X=QG`>~#^Vyp{;Ji^v$W8`?7Z+tVEtN|4O;`ngiC@FR5;mVqUIyhHu>i&lH|hBu%M`rPq-@HT3S znsPsu)Jh`E25V zNF-N4DMq9tdt7dg2C*h0m9HPY1Lqynh#%!_k}G3#a1Li66m>8$CJn$LIs;R(OUWC! zif$P}OmkXHGpfYH%XOuHk2@oSoQ(3wBdeL?-8`2$vZB1{vNOW#!Wb(^ZMjJFmrMG!l*xpUD%*^vgNN!|+R@2$`Sc9D-y;ZEPkTb`LKV{Q9~* z@6h)bS)w4g%vM%~RlizlEWuth^?~c3S zg4oDYN1$6T)twAWd7SqqttJ1>So`hFPeM23Nv_Fn860+ir6)VyL&xMhuGA}06C8y4 zB#vE?9bi-3yS4cLxw=t4DVnmcI)U)~u&zws^T+_|>g7@89Z%Pb;NE|!m{e?YB4DZ-A4H_Fic&{oONA>A4zcxosba`mS zGV}IkEY<0}0c&KPB7S)pHCPo785$L-bijBW3dbMnh>>w~2d^kLmEqjh0dLpndoh>@ zKqTcEUS;nAjrijv|KY{F@49o7xpVlG^FZsG*&vut;KE zsFIvRo9RbNhHWk9N0aoj45<2Lp?3LQ7M=f%ZyvNmWziMH*V21fcLlRUcXE^8PgaPf za%eumBm<#l!PdyDs8n3tu`%j! zXfKP!R}L?{meOJ2FBK(4QoLm$*LvdKF^k_YvAmVEQDH%o&~usoeStBI>y&`j^C9(gS1k~4YG&sc@&y+J`uXC z0KW%;CpQym^jfq*TD>Dz1EaGVthQB)Ib$i(mjO>&sh{3~kgQAZtGQ6Sop8VR7SZX8 zg-0&WI*{M44qtohvcH8zU^Qe6$lHiFNntWG%l;*n!!zUr);nQz%18QDRq}+9HU4qx zre}|Ie_yVjws`kyM0=0)zOzY@U?Y(P-lB6!G!fD|O^21)yVnYJ6S9lJV8I4ftS7x^ zVP8)A=2CHAYS8`S)N=RZX?GCnL+^g{c{KO5&|=nQl^+#7tsLV1;kftwA4^7ficqR0 z{LRheo#UCH*p*3+?iN)?Mq<#F`zaRX>Er%e-58>OdWIuEE$u4Vz zK|Oz%o&hzq4xgR!*wTXS1&%@}kL#jQ&)bSC|^Xke6R!$9q_Tr8?i%OH22gd9ZjvzuK% z&$6yKxS32^Xa93A-}uF@T-%T4S>(XZt4PvP+It2Wh3we z7CS&1(Z)u-;rP=U7k1^*&53T}dT@v5;ha5dc;EZiJ=08}2D<-K{rz~go}P)>1T%eb zEIlkG+K`3r2yMEsMX5>$odm_sZO&MIOJ+`7ke!_4qOQY+{;AqoL!{?Ios%Ha&CZ*| zxxL!Rc)Gd8m~dNX4fu)3DcOq!ZmWuRop)RBdv6OX!+aL_`em{{8EEP!wE zoiFOmMW1lWE^jAw(BSwGGP1Q07b^mCT8V5tisvu8A+lR^&Bt{$sOb$)) zPP)G5ex)tTi>=A4v<)xHrFBdcz2Y5tN-{_Pa3?r+X`4T)cXc>MUt%NvRFb5xiIuRE zJKdGHrV&Xw-S3DFo!^=ws!+M@!>fs@UM78b_}(;s76gx;maZm4)b>uSpOrLaf?9Cc zx;x^ai$iASK4GkP>^r_Tp})vb_|LW(qw7eX=1$LfzvzFfxbSJ&vfK4H{~jt%V^@QB)8jqzlF1Uum-kbeBZS$s8K zWzDannjv|VLq^M^`WfA7%{keOj?hr@=ES?BS#-IK%eTHdH0lmsf5A3C)$e5Y^j1eV zmRLGCsF8MCtvzn;J+pBQnSc@KNk$yHIDZ})Ok6PUL!R4m)^SM6>*XG)B&{R+qoUC< z>#Nl3?UU?*XbCv$C#h`?JCQj2qguC0gi}LKs!Uwj#NxLnsa|&auF=D&dg5#H%w!Ta z>IwlYs1h5m8_PmZ$PUT<_w~v3`uVBu`k}aleqYr4`{xH4MV-0K-eQ)vypke{6M z^*U@Wt2$`=R(0vf$9y~ab&mH6nSldevd ze7-!g011%~xm?dMpMkjImth;xLU%tUulf1$9OIW872>nehqNbBxmWMCj(Lu=Mf=3E zZ7&3AO5Yr*!YMlNtbWt-`v?E2?$ymU$p*O3e4CikWm9l2*$WSwF+20~t=ygeOx zwccjyFGt5>$I~p5bFB_b8rdiM%tCxJy&rTSVjfUQ-mUiq`tv=Z>F-M~5 zoctLa71Xyp^YfpJe$KhNSNED#q?d&u`;pW{Fu2xM%ljjaVI$1RJ#=H;1vBsBiVU@< z%UVvlZ~2s=H+x+)jpt!Uoiaf}>6Lrq2jgXVbrvleIPA}o$MYvg8~@6qllqxNP4O~v+B!z)IPKi) zhiqSsqhr59XKh&g@GRB_f7;TWPaQam-pFA$@0MK}Sm|C-gGI^SvLVUrpqwVZ_We|y z=n;NZd>CWM>*ZYIJH;b@NEDl{N+&wSH^n~xw#NLiu1Q25@AqLnVgCFlGmOpK^?6>u zDb5CC=?8L7ml}o|&7*ofPE{4XEM;b1%T%M{o45JzWLe;;wWgDrak8@7u-Mvcnp#Ue zKVSCrtj|7@QDQe~=l!BQTA@ql`aYZZEt^8dplKZHtRgu~zG~bRE4s9-kWpjq(Seh= znRiZ%FS21R=I4H$XWqyjoaI?apB6u$uwWTh5Uq4nsN2Iu@NhUKev0(Nju6hj)NlDK zwC$cJx;v+Z$Fm}F zIbXlymwRO`(>ZL~{>2{B>yAbG=mKCdcI& z@#Uz~ov`x=O6;6$!pP&h@?kw|tu>A5N1xc=A4l=$69Jy}`E8=@dEDCdv)e^~QnNmR zjo)sW>1##VvJZ*E`F!Z=&apJZ*zQ7k_UEJ;Q?zT;%+4o2&id50Pm4;>v+RGmVvrWX3 zPOFiLSz566`W?v>84-}le8r8;YBH->^{_^0$R zE@ghHDea9r{-+P|%I*I?n@RW=la)&RvUmEOXfc)X^Nf@JdiLF`Jo~O?ZmHGF3N&mo zJYS+9ITTojjJ6cUmXg2pCbG}TChHcrkA{uCKUHY%A0xmoCz<@8Wh3sF1u*s(b=sTX zJaZ#Q)d(1A>B;;0OV6**HD7)}ouI;(L zS?`j6=ZCoqJ?))zpZHo93z~i1$YP7)NBx6TjPI0ivQAEBa5h6aqMYBGQ`Xgmt?Ktd^CFXMoz6`Vt5E*>F9`D^8@SCBS&`QeCKx0`n<@t zUAL=Cv5{Q+KZpbJ=)c<^4$P^KMW$lTheUl)e0x+mwsodAV<*qi^*FX}X+y*FoOlqQ zOj?E{7{xm>gsdJkYG-~VK;Akv*nA3Sv8rUtH~pPwi+0xMa5djwq|1hkBPyUXb6dt*|D5l; z3_8&BpoeFvedn1!XK^0rN%^v?MHwDUwqu!RJ1nb7_Q$vK=|RLd6(by6Ru@jnl*Ni| zIk4aVmrf6U?y#QHF+MBZx%-RtzB*mLYbJT#bTrC!nOSGhSa&@>V$AeF58d{OET2k@ z>&D(aon+@(I>htWYwmwNpY&CnPx>NT6|bBeuk5OC^%XUcF`c|^|Fd^?&kMY`8v8+g zpRMn<{lBN4pZ{7t?w{27^eerIZ$=7FkJZom^wOhAd(k`|SN;8E=f94Q@paxiIrQ#y z9GvMBO~>fX*Abhl?RCZQ-#S-XW9*q+eh*_~?A9Cj8m?x&M`bS=!FdfSwwRwD=z9-4 zB!95pCGckWak>t!vpBu=sK=B4UuB#4&yGBPGiePj4C>x1TDxM|xxDAW->=bh zS!cgTzGZc5A?Cf>Pu1S=q3FJ14o?i+}zop z1)6@K>})2Z$+gjmlPU&tntnH7k65Lp7t1~Z<9=4Bs`-7VUVU7dUwFHB7C5CBg7@41 z^E3H!P52AL(4F*#i64`L?flXCwse0zuMeMo>3My>DjAs94eUOHw6arSzU$(*^|ZtZ zyer$V#8m0Kzg6b}(8AJdk|ntJcY96zMrf)~*Zx!wyfKbdso$4|3EAUT=%qC7j_uRf>Zg@Zn0 zd%vs+5lP(=+wP^zjwXQkJjqLI1FO9xuy$>Mt zy;@WpTF`ZkWTwXMi#g5ZWS8~UQ1<*v>RC|IYTHuWEqR;QTGn0DOPk>IVm9GkS&&h? zIeq7JFnI4s!VS?cnIAQsWllmgXBW6WZI&f|Vpz3|Tf_G5feix7hHY$!*Q6ono)WQoovUC*Ld?0l~DEV{0?n#!-=E}fRqP~}U$ za6hI~F?d|Emj1PqyG-7MAK%ulPO6!nMVE*bk2oX?>m=7-=oCWs6;K}q4dtl0n_lvV(V__QV#3R zm~yZ zIXW7e-!}Rp-OwQ^|BybnTShEodO#DaT6e+r*=?8~76q z|7Ln6s^0E3FzTKqdpOCm+B-|P%y!1HY=G|;{VSY?ZWe%y3B+a*xHQgY@hAP z0g-^y&m2X5DXr#mAM5<AK?j5H~9qGqf<{NyTOF9cT74*vTNvzYz4b3z2 z^xrp!Bu5++#KYk#Tf01?1V_iypd?u&Pl(OV_lL}NH}=-|uc^$e_pL^WHct=x5e!U)>sRC0X-cZ$3E2rgEB z?sEOyC<^%P8+xvnbu-c$FMfQCbJ?2g>7jFcLZhbmbe<$AB)p(QvZk@d^RAsd|JuQC z3pd^@1b#T}P51k~y8i14xedKu$`$)+e0G(FHzN9??$hO%^=@Z~9%e+2%_x#V4oM-4 zlJW4OF}1PflcFoYizgnw*r%p8Rz0cR`?}yXQe*3=cA@+T{&Y`_t|E^Bw z9^^ZQ->G+R)|ml5_3h~SCi<64!KVlRwdhFJ5?c+K<7?*p=D{M9*@cL2_bdMRyD46{ zRZrAg%7&8FPwQ^HxL>bxn)WyKsx23#4sWlh{y}R($%nY|eq0b=WA5gpb z!Lp8weEfTRxHZQief}b`*4pRr@b8r*I41}7p$sQSW&-u2%q{N9#PF{LkE6HDMvNNdY#N~QrCDxvmp@(lU zzdC7*mC`F(_W5(&StGPP`^e(0wFu67IALs-kZok)+Ag20O{dX zbUqm6-DyqNYVJ=aYr#f{cOV;9!TDxn8@8coSL;0*L~b6}r_MRMKIzZAj&rv@7rO=* z=AYnSNHY z7LSWo#+*mafxe`pSAMFI%Q*32Zx#G4ZTY%plC^wAI0nncRxL9gthF3npQr*GyDlgfB|L}Zpb^>FJ2%Bu zR)WvTmKkTr8%igu2`kB@x>h!1eiCF5_@KbD6V%8)iwy0@W}eCu*b8UA!T0@aPm3hx z8_Hzh&wIobsS`=FL2_*JFC>G1XN|<+Z`W(FbSxEn4At=S>@-y4Q?c6G&wpGLX-lNm zfP3+0?39W`YQ>=|xs>l{{YTA2Q~Te%z(!8YyLpSdj~DI?aN+YLy&CA!b@+p|FN z^K1~SB|~e*v15=oOrA(wv>(Y0#wJOm+EWuLiu2Z4=3tHaJkbk!4#E$e-jtM`&dRY< zktU~jw!G!+>MKPdK2gtcydRm8|08knoUESvo%qQvlCZUu-L5N0gBjrox-au9GAB3^ zYn;q58s?kTyrTE)6xnB;_F^=(n%EZF&*}upwceAF&eU`~-ErW4bWRRVijtxW8ZVZ^hcGvbT#XkBUc70DOQyYB=)wu7*BYY4}ntaF2H{_y3V}z7uz8 z79C)1y|>55=wMEV;Tct5x<+$2@np@@rDgV%L<6Hj^QN@y^u9E{5ig)ES^SfF) zl=?L;%MPkqS(j|7)dt1->`Hy+I}+p*y%tmX6sDlJw6Lw^{P!H&eo{87GdFuW2Oo@l z`_IQAdMDi;omFLaYkU}t;wP>*PbhO2||objvMQ;JyGld}YTFm%=DkXVn*P;7eg zqtLKtSs#z!_1xC5+pk|oPU~N3134!F;_GzG@S{8`v?Ml)m1dh=?^+1=TK&&<fNdsp|W}L)h$>em*Cfn1s{l?XLKF_z5 zBvW^}*2a>=dZeN$`=`1IU-?x2PY;gl9uLCv$Lgq3yj$;F-bl%NMJcv|jwTy&vdp-~ zc{a~V&Z2Fn!DB|JxA?hy8u^aA@cU3$Vq0T(m)|;4h0fT8`93vwJ2QmzbyvigD_QL5 zKn{p}L>hxOk^#)~)%2{<3dQEhjBn$*QQtVKv8}Kvand?am9-+9$#16$Vy$-JsVte5 z?iM<5o@FxK7W{Z*4IW8mSydy*pv$e21iV5C zx_r9c(i=s!f48!NcWbP1B97>Ke5(Mg-mpw>@KvJ>m+*#8=H5=ANvl*dfdq>`Zi?N`BDA7%r;r`njT|ZIn2&TEoJe3 zaqwBqTNI{y+6p>jLYn2HPV#_HYs^O#k43{(WZtTKeG})>cipMcN6+?h|G_qUHK&pJ z;N!e|f6l$7M;N{&9<2MeVr6vgtZ@+Z(-F%3=j-k+0iRwhy8iMySx`^pAl*apq4Vq% zD@{Kd+4yb6Aaxko+pX>o9E_l_1<>~r;YUBmyf%|UT^IoHp%JclcDT+!MP zTgvNix$E?!Piuy^>-XXF*Pqm@#-(NHSI3DR}>b z(i#>ae(Q%5H0Y?-#&TNU{mH_O?t}fB{t`Lw(Y0}uQHOR89qO7~*Sb#9k0sClVX}6^ zhArtHoWHJyx3>@WM~y$!I`d6LqozmBx_0+Ec=U1ci0=PTvWNOFIqT~)O&brA-a;;e%uv%Sp~Xj zUdw$%7i*6C*h&6Blw`~;`m)C)JFddh`HD!986}oe>$htj>KUDSku=`ddsdKr>1zzL zju_I-F|q(>!{JnMk0u&K?)UZR?t(gNf0wMyKp|V-);2hi*n>h~hZE%*tA%R%5!s(L zr_gX&Bk7KFT733;@ZPc48s2Q~zd8oEReTQ(J61ZXwX{8ju(tck=Gg!Jx?hc~V(U+?bG>2QAZ7V&M#Hu z!=iAiR?AUNrn6IGma{Pz8SIx)*-GTL#~ft8twX<7-`zU#@v+CviJ&ay50j;3kID9L ziU<0cN#EUiXZYrMOq??cXrH~ya~ry}+VfAK+=0$I66JCDoNp&hoBIvfjQQ^QlJE|6JXa~{)fJ;9n9tYk);XE8h9xQ>Sh3TB_P>pC7e^01|(+)ZQnOSLMm zJuhdn^!lQIR;HRN(t3KVJFW~m;O%G+tL%R~PEUzUD9KHpX}Rxv{>|%4Ik;1IjCe0` zvr&_^C-pfwA#xgtYF#%^+AfzWD}$$S8wBZzS;0Qq9CaP*=-I-4^Qv?53Emx1Kub^i zauz{`R-7o$%HD(u{M4{s{Y}s8#Qo`dl*Tdsz|3h^WF?4|7q`ZyI1SBrTYCcj_323C zbi6(El2iSiPl1{_lY7}Y{j{{R`I2)nNaQjyKHS)j&QUC512fiX#s=Twe4yNs{aUsr1O>LB_cF4y0BBGk)t5~(%k+vru8^&_dPs7o&? ze6M&fQj~!b^{Q;fI!Bt*3oUDEzn_`~tF%W;KCv@S=a-r)&#KsV?V_Ev>+FHBWc{;4 zH#@s^)VW#>TYX8MKf8L>pmYbN+SCXp2!))dv!i3yPiK@l3ER`hTjj@-qqKx^^Z4{g0Ti?+rzYrTp7(<^qUZPnVtCI`YG;AAf_hee$7TZqcIUXV3*cDOoUd)xLUuhF`FF8?w-$M(63$9xSmRO8Er6>=8fBY2(Riup@VO#?7~%;U2ujhw~A5sqz?K=3CG9BcXZAjQOO}3QEoh#Zx%XcuI&>DRDL-Uv-}7HQe!dm= zK8^=d*E3X@m98!IQss>9t&zTB(% z=Q(?*aMa(7$)~uMtqG#RB@}?8=y7|aVe3X7HdX>|Ywhj5I;j^WmpHBuZX>n9)ww@- zhs?1)>3AOBY2%viq+mH{J!uH{=`EZ)>Nld5xs=|kabu5SDdj@QO)76qgGdW%d*1zH zYJ|5Rt)6hJW|=V)%Ppm3iDdUIeW=^{C%6%Yl=n6_kF)6wEzT>Gb4t8d zHiWc8m&^E1q%!POYvx=t$la)7uj?oIWUuSLWr?K5X3b|8TSiKLJotT4Ik|t_>0HF! zdMd4NjQs2R`-kV>R`q1U%=0zgp4K<7|E<>=n=E1)c50u3Bw=g)d#%R7U3%imwmWl5 z>fOfE_?Ot!5!nwO#{L+^Ovp0%7Oflg+ex06Qua|@Gdj|rKJRW~D`mFjw&!>d&y%Nz zZ_qSR5?>?cn0tEO>0ynt_PORaa>JqH$lAQjp3be=#goftuc$|G|uF!c!}WUpk-c(ekBH)({70+wv;sTi|Dqd z^Za@4ZL7D&bLQ|}VPOa&luZ2}hBF3=ZE167#`WdQtuL`E>t>9OTu2kYkM`m0i`J8S zM`upYA9c49u%*{hlKPbPbys_8GUS@wj+Ae!kMlLDOz;t377zWl!bVDavk3A-&4Fxt zHl3|_k)*ZsuO)5BnQvz{AG6sa!6n{XONl#VZJ`)hhj1=>VHZ(iu-F0h0JuHZk`2Bq z^|4r!&P|_YUG5lB{gWBaf4wW4>);;$a9bTPHZK-pEk*Na%k}FD(;hNtnMou_Kl%0K z-?sBe?kiS0_8Vt{id65&^*oy}@98>O_U;simi2(QYD^N7=z^ZW*evnb zE7!c=N|m^28R3}~8Bl3X_i{&YP#SO4u^{>kX>4hW#tu2e7tilGoQAid(s&MP#&#~V zQmuz(m=hY;R_IdEqOD~jrtmB0H)IDw`|HzV*uEnk{%qo+M1`zi+cQ3&M<>mLYdohM z#>fy1edra}OARt9C%p~%#?13?!3Z&*zc7y{G1{N(W** zB{T*y|7B*h5z0K*x}1NX+`%`g!(|QWh{;|jt4$7S)E~wvZ@o5}JC8QD{LEY+J^88T zM`95(A6^TU6N{UxtRsGJyBktDTuXy{Mi;<%>UtI3pB|pF^ddU5=ry&!nEUa}hNqG3 zN3-s3UDxAsB^gl9N!Q!Q^(`s4D{i<~^@gMRuA$$R9sI0zBiZNb-QU;VwLA6BNA;zBVu&0Y1i)Utd>k}CG*1t zU*I{Miho?wE9VO?^H5J_5$y1zEa8_mtKr4xn!T4p+#*wwGjwBK%`ih6A##aU1 zSFICQ7qtFdHffuYzgBs?w+b8e6?=K=S zI~ydQ@2%--UE#!x?TV6hSH&1I(g>f`dU4);iS84#cz$9EdKCmiuE;z&VMV%5vllN% zH(2fT+pRs29?*w1LhpMOvv=M-7BPsCRC>NA2JI)>i`TTS;JWSe=%gM+Ign^LkLQVr znoiEkqeoM39v@w+SWFkk$d$V8>O?6g(b!vH&pz36UWAx5b&;IX?aUEp z@9<@=R~_*^Ki7}-q3d+CK&}SLqFYW+9Q}=_?c(!YP^03PoWd3lZ`a(JOmaWBNW9w8 z4=(jRoRv#+<G-m(;n4T%gBOf?Q=!jR>5k+Q_hj|cD4hv znn93H*WKFJ-mj&J;65ZU@p>rFq<@~+?3(@+rxdbXMg`Z#?t8q8oYqu{r~2 zjBl38U7q?wNfkPMGTraB=lfhOyFV3ukDW76SXXIDW4_}?q>*u(E6?hQL9_kDqoQf| ze%zYIH=8vLlX>{3n#;qAR@DSE#~y_=^06Ho(fB@{q^}{v=^aANxd&h79OiP*q6AGu zGUocUwa1t0`M4>{wTEP$!yb_b>`GJD)-fpMVSTpNw$>~0)16w^GH(u#vlh|f?DG4# z`0teSUi;nB#Cyfj52rl9XZ2Z5^zYAa)wmxQWk0AKK))wbaJMo8@6|ZlJ9>oAj-PaU z%0K+(Iq%H%Xqp#UTjQx^?grPst$!pfT{k3_ZAjeF5%#)64;A-MZer(`@z;{a|D`^c zNeJ!m4E6NCrfaZu`bBz{>hu>igGV(^+t3lMCXy5zi$7^wM|<~cZqWi3ET=i6)qEav z%R*k82BSJ2jqh5o+4CTY>ADZoGaC_P-wQ42xO_>IdqshKgCY4`@0UrvBoRqhm-aBJ znT}e@T9WtB@LamLxF_)=5gkmQ?2E9@-MW(cW!ebLlq5U@~&nzU8O77Dns- zn8ZYhyOYCcd?I5XUogDOQn%-wg2@TTOU6%iF5@&dUR;svZ_{mEyXtvAon{rw!X9-# z##5l@(eVUxqCnS|xsRA1&wg_NNE(ImSyb%A7mNy{y-ylfCmfJ!T>=ywCU} z)8g8ZO~tuzoOhOQ&2FM0cT0Z zyLLmeuD->?`S~&f&i8gIZQhdy=xg*>+9P?b?vo2}o}V$)qpwVPmS65G?@W5r^1y10 zzO8MRz~=*3&9jJGUadP;$k@;KuP?S7L)*nnc+v7oQpqx#0aUAoVpG|yRe%rQw?r&2J3XMo#aV1ohyB=Io9YE3b7+a3KdEiw+nVLBzABP^pYC;(vI#V zY&l9>7w3vEM>&b6eWFWCxe?KGeWulxo-_nk2QPBQ(U6EC*|L~9(`75$jneke*^6@9 zk)b|y7AAc-dDTSb^ZxEnrqA9gzw)%QOFxHG*0{7NJb|;MoI7!SMdN)=iM{B~oKx;5 z`>eoPe@@S-mVLjM++K5Q&1K)cmB`R<_jXnE)mDM#C%64`(f+@cE&X*NrQQ*4*5-*{4Xm+TgikiA;tOgjF%NaLL)zVe_D+`l-@T_XGK>k#6M0k znqE3}{hr@^T-dmurq?m~iM{yXAYh^MjZ4S^S@vB_qFN! zG2>BvgQL6hX5MOSR@~V)IRaTrGeGG?x#{G|XM7|sHwThCUyicvwH(p)RwBvXx?bQt zebT*;uyf1)P9M&|((((*J>--px!vv$+s1Lr(~h2%MQB{ymA*OUN9`-$n`50QOxo9T z>1+c@ch*2O4aXu|d+UU9&~;SRx?5}S>=0HXD>ILDNXZh|0WIT^r{?J-DZ3Tt>$$z7 z?R)cDehc`JAJQQ*rpq0Pg#M#;ATF~jJ9&r5&|Ge}l?C$K95!$IlwA>PP57rtF0xt_ z824pn){<|YbTZxFRfLoJNGKx;Ca-#5_xqpG%o$BGC8B4w!OnthEoJOb_mtqG-su;~ z`jXf4(+5&1!dH?%?-AZg^?WbJ(o&=Io*dshmIXToP;G7DJEGAg& zWjR9hh%I9wy4u>D%~+kkLtT6n!;9(F)t1zWC1stG0eG;oi9zeVS}`@5@l=+LS4eJd zeQHY2r?YdT!e#zzsfjGuLlJo!C-Wr-5sJBU?OE3}-b%)(iAtKCNmsJ_tu6c zN0SV+PrJCN7u_gM9n~D>yRJs=0}@P^-tfhz%gvlz)7a0asMqv7=anTors5-Vn^y*s zJ%oepS<#2d8?aAn44e@}=F4bWhQ$fGuxnR2+`mP?Mv#r%%MuttRxHt<`|r$n`g7(! z@Xa<3c@cGzzv$B=!X5cmoo-eqZ}ej7ByWKRBa#w-$rV|h4DJ|VpFYV~_(q^HMzU6k zRFWau(x)Tpd0g+=^2cWXPW^PwM07kd6hBuY$;Uj`@bl$xnS(e<)|PpOlO?KG9;)wA zW}Ywj^QFTmYM%IV(wxIRa?n@pb2%|TzV>xXX&+IZn7Vsh@0CSLZvDXiPq~0vPm>xwz_df+zXv8>xJ#FwtjMX9e)-q z&3oA?ky_!JbFLdZCY;uw53ad|BXcyPx=BtTpI}E=KHh!oJ4=KPC#8>Ojyg!U9TmY) zt~5^XG}+=Q^9yzL_YIt2>^^CLY#!EM;)yRNw3s^}oHe2uBQ$DiG`9B9FvPkJ{K4^D zmX&Gt&VK05QT=m0R}P~!P1F$|oo{+Fr@eX#O24e0otPo`^YIkTuTQTW&zf6bbX=ma zR8JEL!LoY3;D}^J)XZ_lJ?Plycywf8ym?){<*U~ooS0PxSH|*Tt(O_WS9&Z>wUadkp68o;-S@f}GAmk)SR|HM5-LL+;)y zl>OoH6VT(+=`T@$XC;R$jn;WQq$qiZ_JM1xKDU;8 zd$>g7=sflSb+rFx>(mcx-A>Kzs45*#-H{!792Qm8pCKma^7>{4{phM#&X{$Euj({B zWM{QK04mqsdwY6cSBr0&_04oX?M{i4Yy5k%wYJtx!O*ttD^9GV(Je>jxaR!z1c+Ge qy6_E>FoxQIz3BZ-e_rS0q?`2~%jUcM`W$64pR<>Y;rsn$ivNGd%eE5$ literal 0 HcmV?d00001 diff --git a/backend/SECRET_ROTATION_RUNBOOK.md b/backend/SECRET_ROTATION_RUNBOOK.md new file mode 100644 index 0000000..d06c8b4 --- /dev/null +++ b/backend/SECRET_ROTATION_RUNBOOK.md @@ -0,0 +1,49 @@ +# Secret Rotation Runbook + +Date: 2026-02-15 +Scope: bot service + dashboard deployment secrets + +## Objective + +Rotate all production credentials on a fixed cadence and after any suspected leak, while preserving service continuity. + +## Rotation Scope + +- Supabase: + - `SUPABASE_KEY` / service-role key used by bot service + - JWT settings (`SUPABASE_JWT_ISSUER`, `SUPABASE_JWT_AUDIENCE`) verification values +- Exchange credentials: + - `ALPACA_API_KEY`, `ALPACA_API_SECRET` + - `REAL_ALPACA_API_KEY`, `REAL_ALPACA_API_SECRET` +- AI provider keys: + - `OPENAI_API_KEY` + - `GEMINI_API_KEY` + - `PERPLEXITY_API_KEY` +- Notification/API integration keys (if configured) + +## Rotation Procedure + +1. Create new credentials in provider consoles. +2. Update secret stores (CI/CD, Azure, Vercel, etc.) with new values. +3. Deploy bot and dashboard with new secret versions. +4. Validate: + - bot startup + auth checks + - exchange order placement dry-run path + - dashboard auth and websocket connectivity +5. Revoke old credentials only after validation window. +6. Record rotation date, actor, and affected systems in release notes. + +## Enforcement + +- CI includes executable secret hygiene scan: + - `scripts/verifySecretHygiene.ts` +- Gitleaks workflow remains enabled on push/PR. +- Never commit real secrets into tracked files (`.env`, docs, scripts, configs). + +## Cadence + +- Standard: every 30 days +- Immediate rotation triggers: + - Secret leaked in logs/repo/chat/email + - Access control incident + - Team-member offboarding diff --git a/backend/SESSION_RULE_FIX.md b/backend/SESSION_RULE_FIX.md new file mode 100644 index 0000000..d263853 --- /dev/null +++ b/backend/SESSION_RULE_FIX.md @@ -0,0 +1,92 @@ +# SessionRule Bug Fix — 2026-02-20 + +## Summary + +A critical logic error in `SessionRule.ts` caused **all profiles configured as "24/7" to silently block trades** during Asian (TOK/SYD) market hours. This was a silent failure — no crash, no alert — the bot simply never entered during those hours. + +--- + +## Root Cause + +**File**: `src/strategies/rules/SessionRule.ts` + +**Old logic (broken):** +```ts +if (isMajor && hasAllowedSession) { + // pass +} +// else: always fail +``` + +The old check required **both** conditions simultaneously: +1. `isMajor` — the internal flag marking LDN/NY as "major" sessions. +2. `hasAllowedSession` — the session being in the profile's allowed list. + +**The problem:** `isMajor` is `false` during TOK/SYD regardless of how the profile is configured. So even when a profile explicitly set `sessions: "LDN,NY,TOK,SYD"` (24/7 mode), the condition `isMajor && hasAllowedSession` failed during TOK/SYD, producing: + +``` +❌ Mandatory Rule Failed: SessionRule -> Session TOK | SYD not in allowed set (24/7). +``` + +This was a misleading error — the session WAS in the allowed set, but `isMajor` overrode the check. + +--- + +## Fix Applied + +**New logic — 3-path decision tree:** + +``` +1. Is allowedSessions = [LDN, NY, TOK, SYD]? + → YES → 24/7 mode. ALWAYS PASS. Skip time checks. + +2. Is current session = OFF (market closed)? + → YES → BLOCK. + +3. Is current session in the profile's allowedSessions list? + → YES → PASS (restricted schedule, currently within window) + → NO → BLOCK +``` + +**Key change:** Removed dependency on `context.isMajorSession` entirely. The session check is now driven purely by the profile's configuration, not by an internal "major session" flag. + +--- + +## Session Mapping (Frontend Wizard → Backend) + +| Wizard Selection | `SessionRule.params.sessions` | Backend Behavior | +|:---|:---|:---| +| 24/7 | `LDN,NY,TOK,SYD` | Detected as 24/7 → always passes | +| London + New York | `LDN,NY` | Passes only during LDN/NY hours | +| Asia only | `TOK,SYD` | Passes only during TOK/SYD hours | + +--- + +## Expected Log After Fix + +``` +✅ Rule Passed: SessionRule -> 24/7 mode: all sessions allowed. Current session: TOK | SYD. +``` + +vs. the old broken log: + +``` +❌ Mandatory Rule Failed: SessionRule -> Session TOK | SYD not in allowed set (24/7). +``` + +--- + +## Impact Assessment + +| Metric | Before Fix | After Fix | +|:---|:---|:---| +| Trades during LDN/NY | ✅ Working | ✅ Working | +| Trades during TOK/SYD (24/7 profiles) | ❌ Silently blocked | ✅ Now allowed | +| Restricted session profiles (LDN,NY only) | ✅ Working | ✅ Working | +| Capital at risk | None (no trades were placed) | Normal | + +--- + +## Files Modified + +- `src/strategies/rules/SessionRule.ts` — Core fix diff --git a/backend/STRATEGY_MARKETPLACE.md b/backend/STRATEGY_MARKETPLACE.md new file mode 100644 index 0000000..df1e026 --- /dev/null +++ b/backend/STRATEGY_MARKETPLACE.md @@ -0,0 +1,95 @@ +# Strategy Marketplace — Admin Publishing Feature + +> Added: 2026-02-20 + +## Overview + +The Strategy Marketplace allows administrators to convert any live trading strategy profile (Strategy Cluster) into a reusable template that all platform users can adopt. + +--- + +## Database Schema + +**Table**: `public.strategy_presets` +**Migration**: `schema/016_add_strategy_marketplace.sql` + +| Column | Type | Description | +|:---|:---|:---| +| `id` | `TEXT` (PK) | Format: `template-{profile_id}-{timestamp}` | +| `name` | `TEXT` | Display name of the strategy | +| `description` | `TEXT` | Auto-generated summary including rule count | +| `risk_style_id` | `TEXT` | `safe`, `balanced`, or `aggressive` | +| `recommended_assets` | `TEXT[]` | Array of symbols (e.g. `['BTC/USDT', 'ETH/USDT']`) | +| `typical_trades_per_day` | `TEXT` | e.g. `'3-5'`, `'8-12'` | +| `performance_tag` | `TEXT` | e.g. `'Institutional Template'` | +| `is_popular` | `BOOLEAN` | Featured/highlighted in marketplace | +| `created_at` | `TIMESTAMPTZ` | Auto-set on insert | +| `created_by` | `UUID` | Admin user ID (FK → `auth.users`) | +| `original_profile_id` | `UUID` | Source profile (FK → `trade_profiles`) | +| `strategy_config` | `JSONB` | Full strategy rule and risk config snapshot | +| `role_required` | `TEXT` | `'free'`, `'pro'`, or `'elite'` | + +### Row Level Security + +- **Read**: All authenticated users can read all presets (`FOR SELECT USING (true)`). +- **Write**: Only admins can insert/update/delete (`role = 'admin'` check against `public.users`). + +--- + +## Admin Publishing Flow + +### Trigger +Admin clicks the **↗ (Publish)** icon on any strategy card in the **Strategy Clusters** tab. + +### Auto-Detection Logic +The `handlePublish` function in `TradeProfileManager.tsx` derives the `risk_style_id` from `minRulePassRatio`: + +| `minRulePassRatio` | `risk_style_id` | +|:---|:---| +| < 0.9 | `aggressive` | +| ≥ 0.9 and < 1.0 | `balanced` | +| ≥ 1.0 | `safe` | + +### Payload Construction +```json +{ + "id": "template-{profile.id}-{timestamp}", + "name": "{profile.name}", + "description": "Admin-published strategy based on {name}. Features N optimized rules.", + "risk_style_id": "balanced", + "recommended_assets": ["BTC/USDT", "ETH/USDT"], + "typical_trades_per_day": "3-5", + "performance_tag": "Institutional Template", + "is_popular": true, + "created_by": "{admin_user_id}", + "original_profile_id": "{profile.id}", + "strategy_config": { ... full config ... } +} +``` + +--- + +## Frontend Integration + +### Marketplace Display (`PresetMarketplace.tsx`) +- On mount, fetches all rows from `strategy_presets` ordered by `created_at DESC`. +- Maps `snake_case` DB columns to `camelCase` frontend `StrategyPreset` interface. +- Merges with hardcoded system presets from `PresetRegistry.ts`. +- Total count displayed in header reflects both system + admin-published templates. + +### Adoption Flow +- User clicks **"USE THIS STRATEGY"** on any card. +- The preset's `riskStyleId` and `recommendedAssets` seed the `StrategyWizard`. +- User customizes capital/assets and deploys as their own profile. + +--- + +## Files Modified/Created + +| File | Change | +|:---|:---| +| `schema/016_add_strategy_marketplace.sql` | New migration — creates `strategy_presets` table | +| `src/lib/const.ts` | Added `tableNameMarketplace = 'strategy_presets'` | +| `src/components/TradeProfileManager.tsx` | Added `handlePublish` and Publish button for admins | +| `src/components/PresetMarketplace.tsx` | Added Supabase fetch + mapping for dynamic templates | +| `src/tabs/MarketplaceTab.tsx` | Added AI Setups + Top Volatile contextual panels | diff --git a/backend/TRADE_LIFECYCLE_INTEGRITY_PLAN.md b/backend/TRADE_LIFECYCLE_INTEGRITY_PLAN.md new file mode 100644 index 0000000..8b80ab4 --- /dev/null +++ b/backend/TRADE_LIFECYCLE_INTEGRITY_PLAN.md @@ -0,0 +1,85 @@ +# Trade Lifecycle Integrity - Migration Plan + +Date: 2026-02-15 +Owner: Backend trade service + +## Goal + +Enforce deterministic lifecycle tracing by `profile_id + trade_id` across `orders`, `trade_history`, and runtime reconciliation, without blocking production traffic. + +## Null Handling Policy + +- `trade_id` may be `NULL` only for legacy/manual rows. +- Empty or whitespace `trade_id` values are normalized to `NULL`. +- Bot-originated lifecycle records must use deterministic `trade_id` (`TRD-*`). + +## Rollout Plan + +### Phase A - Prepare (No Risk) + +- [ ] Snapshot current row counts and distinct trade IDs: +```sql +SELECT COUNT(*) AS orders_total, + COUNT(*) FILTER (WHERE trade_id IS NULL OR btrim(trade_id) = '') AS orders_missing_trade_id +FROM orders; + +SELECT COUNT(*) AS history_total, + COUNT(*) FILTER (WHERE trade_id IS NULL OR btrim(trade_id) = '') AS history_missing_trade_id +FROM trade_history; +``` +- [ ] Run legacy backfill in dry-run mode. +- [ ] Confirm no unacceptable duplicate lifecycle keys in reporting. + +### Phase B - Deploy Non-Blocking Guardrails + +- [ ] Apply `schema/007_trade_lifecycle_integrity_constraints.sql`. +- [ ] Verify indexes exist: +```sql +SELECT indexname +FROM pg_indexes +WHERE tablename IN ('orders', 'trade_history') + AND indexname LIKE 'idx_%trade_id%'; +``` +- [ ] Confirm constraints are present and `NOT VALID`: +```sql +SELECT conname, convalidated +FROM pg_constraint +WHERE conname IN ( + 'chk_orders_action_lifecycle', + 'chk_orders_trade_id_not_blank', + 'chk_orders_trade_id_format', + 'chk_trade_history_trade_id_not_blank', + 'chk_trade_history_trade_id_format' +); +``` + +### Phase C - Backfill + Reconcile + +- [ ] Run deterministic `trade_id` backfill in apply mode. +- [ ] Run lifecycle reconciliation report (`orders -> positions -> history`) per profile. +- [ ] Resolve every `missing_entry_order` / `orphan_exit` anomaly before validation. + +### Phase D - Tighten Constraints + +- [ ] Validate constraints after reconciliation is clean: +```sql +ALTER TABLE orders VALIDATE CONSTRAINT chk_orders_action_lifecycle; +ALTER TABLE orders VALIDATE CONSTRAINT chk_orders_trade_id_not_blank; +ALTER TABLE orders VALIDATE CONSTRAINT chk_orders_trade_id_format; +ALTER TABLE trade_history VALIDATE CONSTRAINT chk_trade_history_trade_id_not_blank; +ALTER TABLE trade_history VALIDATE CONSTRAINT chk_trade_history_trade_id_format; +``` + +## Rollback Plan + +- Drop newly added constraints (if validation reveals regression): +```sql +ALTER TABLE orders DROP CONSTRAINT IF EXISTS chk_orders_action_lifecycle; +ALTER TABLE orders DROP CONSTRAINT IF EXISTS chk_orders_trade_id_not_blank; +ALTER TABLE orders DROP CONSTRAINT IF EXISTS chk_orders_trade_id_format; +ALTER TABLE trade_history DROP CONSTRAINT IF EXISTS chk_trade_history_trade_id_not_blank; +ALTER TABLE trade_history DROP CONSTRAINT IF EXISTS chk_trade_history_trade_id_format; +``` +- Keep indexes unless they materially impact write throughput. +- Re-run reconciliation report and keep bot runtime lifecycle guards active. + diff --git a/backend/TRADING_CONTROL_PERSISTENCE.md b/backend/TRADING_CONTROL_PERSISTENCE.md new file mode 100644 index 0000000..35899c6 --- /dev/null +++ b/backend/TRADING_CONTROL_PERSISTENCE.md @@ -0,0 +1,349 @@ +# Trading Control State Persistence + +## Where is the Pause/Resume State Stored? + +The trading control state (PAUSED or RUNNING) is persisted in **three locations** to ensure reliability and recovery: + +--- + +## 1. In-Memory (Primary Runtime State) + +**Location**: `HealthTracker` singleton in `healthTracker.ts` + +```typescript +// bytelyst-trading-bot-service/src/services/healthTracker.ts + +export class HealthTracker { + private tradingControl: TradingControlSnapshot = { + mode: 'RUNNING', + lastChangedBy: 'system', + lastChangedAt: Date.now() + }; + + public isPaused(): boolean { + return this.tradingControl.mode === 'PAUSED'; + } + + public recordTradingControl(update: TradingControlSnapshot): void { + this.tradingControl = update; + // Triggers persistence to disk and database + } +} +``` + +**Purpose**: Fast access for enforcement points (AutoTrader, TradeExecutor) + +--- + +## 2. Disk Storage (Local Persistence) + +**Location**: `bot_state.json` in the bot service root directory + +**File Path**: `c:\Users\sarav\project\bytelyst.ai\trading\bytelyst-trading-bot-service\bot_state.json` + +**Structure**: +```json +{ + "health": { + "tradingControl": { + "mode": "PAUSED", + "lastChangedBy": "admin@example.com", + "lastChangedAt": 1708200000000, + "reason": "Manual admin pause" + }, + "tradingLoopHealthy": true, + "reconciliationLoopHealthy": true, + // ... other health metrics + }, + "symbols": { ... }, + "orders": [ ... ], + "history": [ ... ] +} +``` + +**How it's saved**: +```typescript +// bytelyst-trading-bot-service/src/services/apiServer.ts + +private saveState(): void { + const snapshot = healthTracker.getSnapshot(); + const stateToSave = { + health: snapshot, // Includes tradingControl + symbols: this.state.symbols, + orders: this.state.orders, + history: this.state.history, + settings: this.state.settings + }; + + fs.writeFileSync( + path.join(process.cwd(), 'bot_state.json'), + JSON.stringify(stateToSave, null, 2) + ); +} +``` + +**When it's saved**: +- After every pause/resume action +- Periodically (every 30 seconds) +- On graceful shutdown + +**Purpose**: Survive bot restarts, local backup + +--- + +### 3. Database Storage (Cloud Backup) +- **Location**: Supabase `bot_state_snapshots` table +- **Purpose**: Multi-instance recovery, cloud backup, and audit trail +- **Updated**: Throttled writes based on `DB_SNAPSHOT_INTERVAL_MS` (Default: 5 mins) +- `user_id` (UUID, foreign key to users) +- `state` (JSONB) - Contains full bot state including tradingControl +- `created_at` (timestamp) + +**How it's saved**: +```typescript +// bytelyst-trading-bot-service/src/services/apiServer.ts + +private async persistSnapshotToDb(): Promise { + if (!config.ENABLE_DB_SNAPSHOTS) return; + + const now = Date.now(); + const elapsed = now - this.lastSnapshotWriteAt; + if (elapsed < config.DB_SNAPSHOT_INTERVAL_MS) { + // Throttled... + return; + } + + const stateToSave = this.getPersistableState(); + await supabaseService.saveBotStateSnapshot(ownerId, stateToSave); + this.lastSnapshotWriteAt = Date.now(); +} +``` + +**When it's saved**: +- **Throttled Periodically**: Every `DB_SNAPSHOT_INTERVAL_MS` (Default: 300,000ms / 5 minutes) +- **On Startup/Shutdown**: Forced sync if enabled +- **Note**: The manual "Pause/Resume" action triggers a request to save, but the actual DB write is still subject to the throttle to prevent IOPS spikes. + +**Purpose**: +- Multi-instance recovery +- Cloud backup +- Audit trail + +--- + +## State Recovery on Bot Restart + +When the bot starts, it loads the state in this order: + +```typescript +// bytelyst-trading-bot-service/src/services/apiServer.ts + +public async loadState(): Promise { + try { + // 1. Try to load from Supabase (preferred) + const dbSnapshot = await supabaseService.getLatestBotSnapshot(); + if (dbSnapshot?.snapshot_data?.health?.tradingControl) { + healthTracker.recordTradingControl( + dbSnapshot.snapshot_data.health.tradingControl + ); + logger.info('[LoadState] Restored trading control from Supabase:', + dbSnapshot.snapshot_data.health.tradingControl.mode); + return; + } + } catch (err) { + logger.warn('[LoadState] Failed to load from Supabase, trying disk'); + } + + try { + // 2. Fallback to disk (bot_state.json) + const filePath = path.join(process.cwd(), 'bot_state.json'); + if (fs.existsSync(filePath)) { + const fileData = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + if (fileData.health?.tradingControl) { + healthTracker.recordTradingControl(fileData.health.tradingControl); + logger.info('[LoadState] Restored trading control from disk:', + fileData.health.tradingControl.mode); + return; + } + } + } catch (err) { + logger.warn('[LoadState] Failed to load from disk'); + } + + // 3. Default to RUNNING if no state found + logger.info('[LoadState] No saved state found, defaulting to RUNNING'); + healthTracker.recordTradingControl({ + mode: 'RUNNING', + lastChangedBy: 'system', + lastChangedAt: Date.now(), + reason: 'Bot startup - no previous state' + }); +} +``` + +--- + +## Persistence Flow Diagram + +``` +Admin clicks "Pause" button + │ + ▼ +POST /internal/trading/pause + │ + ▼ +healthTracker.recordTradingControl({ mode: 'PAUSED' }) + │ + ├─────────────────────────────────────────┐ + │ │ + ▼ ▼ +1. In-Memory Update 2. Trigger Persistence + (Immediate) (Async) + │ │ + ├─ tradingControl.mode = 'PAUSED' ├─ apiServer.saveState() + └─ isPaused() returns true │ └─ Write to bot_state.json + │ + └─ apiServer.persistSnapshotToSupabase() + └─ Upsert to bot_snapshots table + +Bot Restart + │ + ▼ +apiServer.loadState() + │ + ├─ Try Supabase (preferred) + │ └─ SELECT * FROM bot_snapshots ORDER BY updated_at DESC LIMIT 1 + │ └─ Extract health.tradingControl + │ + ├─ Fallback to Disk + │ └─ Read bot_state.json + │ └─ Extract health.tradingControl + │ + └─ Default to RUNNING + └─ If no state found +``` + +--- + +## 4. Global Configuration (Neural Persistence Settings) + +To prevent database overload while maintaining state safety, the bot includes a synchronization throttling mechanism. These settings are managed via the **Admin Tab > Database Synchronization** panel. + +### Settings stored in `bot_config` table: + +| Key | Default | Description | +|-----|---------|-------------| +| `ENABLE_DB_SNAPSHOTS` | `true` | When `false`, the bot will not write snapshots to Supabase at all. | +| `DB_SNAPSHOT_INTERVAL_MS` | `300000` | Minimum time (in ms) to wait between database writes. | + +### Throttling Logic: +1. The bot saves state to local `bot_state.json` **effectively immediately** (debounced 1.5s). +2. The bot checks if `ENABLE_DB_SNAPSHOTS` is true. +3. The bot checks if enough time has passed since `lastSnapshotWriteAt`. +4. Only if both pass is a write sent to Supabase. + +--- + +## Verification + +### Check Current State + +**Option 1: API Call** +```bash +curl -H "Authorization: Bearer " \ + http://localhost:5000/internal/trading/status + +# Response: +{ + "mode": "PAUSED", + "lastChangedBy": "admin@example.com", + "lastChangedAt": 1708200000000, + "reason": "Manual admin pause" +} +``` + +**Option 2: Check bot_state.json** +```bash +# Windows PowerShell +Get-Content "c:\Users\sarav\project\bytelyst.ai\trading\bytelyst-trading-bot-service\bot_state.json" | ConvertFrom-Json | Select-Object -ExpandProperty health | Select-Object -ExpandProperty tradingControl +``` + +**Option 3: Check Supabase** +```sql +SELECT snapshot_data->'health'->'tradingControl' +FROM bot_snapshots +ORDER BY updated_at DESC +LIMIT 1; +``` + +**Option 4: Check Logs** +```bash +# Look for these log entries +[Admin] Trading PAUSED by admin@example.com. Reason: Manual admin pause +[Admin] Trading RESUMED by admin@example.com. +[LoadState] Restored trading control from Supabase: PAUSED +``` + +--- + +## Important Notes + +### Persistence Guarantees + +✅ **Immediate**: In-memory state updated instantly +✅ **Durable**: Disk and database writes happen within 1 second +✅ **Recoverable**: State survives bot restarts +✅ **Auditable**: All changes logged with timestamp and user + +### Failure Scenarios + +| Scenario | Behavior | +|----------|----------| +| Disk write fails | State still in memory, Supabase backup available | +| Supabase write fails | State still in memory and on disk | +| Both writes fail | State in memory, will retry on next save cycle | +| Bot crashes | State recovered from Supabase or disk on restart | +| Supabase unavailable on restart | Falls back to disk (bot_state.json) | +| Both unavailable on restart | Defaults to RUNNING mode | + +### State Consistency + +- **Single Source of Truth**: In-memory state in HealthTracker +- **Persistence is Async**: Writes don't block trading operations +- **Recovery is Synchronous**: State loaded before trading starts +- **No Race Conditions**: All writes go through HealthTracker singleton + +--- + +## File Locations Summary + +| Storage | Location | Purpose | +|---------|----------|---------| +| In-Memory | `healthTracker.ts` singleton | Fast runtime access | +| Disk | `bot_state.json` | Local persistence | +| Database | Supabase `bot_snapshots` table | Cloud backup | +| Logs | `combined.log` | Audit trail | + +--- + +## Code References + +### Save State +- **File**: `bytelyst-trading-bot-service/src/services/apiServer.ts` +- **Method**: `saveState()` (line ~1700) +- **Method**: `persistSnapshotToSupabase()` (line ~1750) + +### Load State +- **File**: `bytelyst-trading-bot-service/src/services/apiServer.ts` +- **Method**: `loadState()` (line ~1800) + +### Trading Control State +- **File**: `bytelyst-trading-bot-service/src/services/healthTracker.ts` +- **Property**: `tradingControl: TradingControlSnapshot` +- **Method**: `recordTradingControl()` +- **Method**: `isPaused()` + +### Pause/Resume Endpoints +- **File**: `bytelyst-trading-bot-service/src/services/apiServer.ts` +- **Endpoint**: `POST /internal/trading/pause` (line 1054) +- **Endpoint**: `POST /internal/trading/resume` (line 1073) diff --git a/backend/admin-observability.md b/backend/admin-observability.md new file mode 100644 index 0000000..bea1d1e --- /dev/null +++ b/backend/admin-observability.md @@ -0,0 +1,42 @@ +# Admin Observability & Health Panel + +This document describes the runtime observability system implemented for the trading bot administrators. + +## Overview + +The Admin Error & Health panel provides real-time visibility into the bot's internal state and actionable issues. It is designed for operators to quickly identify why trading might be paused, failing, or behaving unexpectedly without having to dig through raw logs. + +## Architecture + +### Backend: `ObservabilityService` +- **In-Memory Buffer**: Stores the last 50 operational events in a ring buffer. +- **Structured Events**: Every event follows the `OperationalEvent` interface. +- **Filtering**: Events are filtered by user role. Only administrators receive operational events via the API and Socket.IO. + +### Frontend: `AdminTab` (System Health) +- **Status Badge**: A global indicator of system health (Healthy, Degraded, Critical). +- **Event List**: A chronologically ordered list of recent operational events with severity levels (INFO, WARN, ERROR). +- **Telemetry**: Real-time display of execution loop durations, exchange latency, and lock contention counts. + +## Operational Event Types + +| Type | Severity | Description | +|------|----------|-------------| +| `INSUFFICIENT_BUYING_POWER` | WARN | Attempted to open a position but broker reported insufficient capital. | +| `ORDER_FAILURE` | ERROR | Exchange rejected an order (e.g., price out of bounds, invalid qty). | +| `EXCHANGE_STATE_MISMATCH` | WARN | Discrepancy detected between internal database and exchange state. | +| `RECONCILIATION_DEGRADED` | ERROR | Reconciliation loop is failing repeatedly. | +| `SYSTEM_ERROR` | WARN/ERROR | General system issues, including exchange API timeouts or manual pauses. | + +## Security & Performance + +- **Sensitive Data**: Events contain structured messages instead of raw stack traces or internal environment variables. +- **Cap**: Both backend buffer and frontend display are capped at 50 events to ensure performance and prevent memory bloating. +- **RBAC**: Operational events are only pushed to authenticated sockets belonging to users with the `admin` role. + +## Usage + +1. Navigate to the **Admin** tab. +2. Select **System Health**. +3. Review the **Operational Events** list for recent issues. +4. If a global red banner appears at the top of the dashboard, it indicates a critical operational event occured in the last 10 minutes. diff --git a/backend/apply_standard_risk_profiles.ts b/backend/apply_standard_risk_profiles.ts new file mode 100644 index 0000000..d739d3e --- /dev/null +++ b/backend/apply_standard_risk_profiles.ts @@ -0,0 +1,76 @@ + +import { supabaseService } from '../src/services/SupabaseService.js'; +import logger from '../src/utils/logger.js'; +import fs from 'fs'; +import path from 'path'; + +async function applyStandardRiskProfiles() { + logger.info('🚀 APPLYING STANDARD RISK PROFILES (5 TO 1)...'); + + // 1. Load Data + const proposedPath = path.resolve('schema/proposed_risk_profiles.json'); + if (!fs.existsSync(proposedPath)) { + logger.error(`❌ missing proposed profiles file: ${proposedPath}`); + return; + } + const proposedProfiles = JSON.parse(fs.readFileSync(proposedPath, 'utf8')); + + // 2. Get User + const users = await supabaseService.getActiveUsers(); + if (users.length === 0) { + logger.error('❌ No active users found.'); + return; + } + const user = users[0]; + + // 3. Clear Existing Data (TRUNCATE equivalents via DELETE) + logger.info('🧹 Wiping existing data for a fresh start...'); + + // @ts-ignore + await supabaseService.client.from('positions').delete().neq('id', '00000000-0000-0000-0000-000000000000'); + // @ts-ignore + await supabaseService.client.from('orders').delete().neq('id', '00000000-0000-0000-0000-000000000000'); + // @ts-ignore + await supabaseService.client.from('trade_history').delete().neq('id', '00000000-0000-0000-0000-000000000000'); + // @ts-ignore + await supabaseService.client.from('alerts').delete().neq('id', '00000000-0000-0000-0000-000000000000'); + // @ts-ignore + await supabaseService.client.from('operational_events').delete().neq('id', '00000000-0000-0000-0000-000000000000'); + // @ts-ignore + await supabaseService.client.from('trade_profiles').delete().neq('user_id', '00000000-0000-0000-0000-000000000000'); + + // 4. Prepare & Insert Profiles + const insertPayload = Object.values(proposedProfiles).map((p: any) => { + // Calculate risk_per_trade_percent from the RiskManagementRule in strategy_config + const riskRule = p.strategy_config.rules.find((r: any) => r.ruleId === 'RiskManagementRule'); + const riskPercent = riskRule?.params?.maxRisk || 1.0; + + return { + user_id: user.user_id, + name: p.name, + allocated_capital: p.allocatedCapital, + risk_per_trade_percent: riskPercent, + symbols: "BTC/USDT,ETH/USDT,SOL/USDT", // Multi-pair trading enabled + is_active: true, + strategy_config: p.strategy_config + }; + }); + + // @ts-ignore + const { data, error } = await supabaseService.client + .from('trade_profiles') + .insert(insertPayload); + + if (error) { + logger.error(`❌ Failed to apply profiles: ${error.message}`); + } else { + logger.info('✅ 5 Standard Profiles applied successfully!'); + insertPayload.forEach((p: any, idx: number) => { + logger.info(` ${idx + 1}. [${p.name}] Risk: ${p.risk_per_trade_percent}% | Capital: $${p.allocated_capital}`); + }); + } + + process.exit(0); +} + +applyStandardRiskProfiles().catch(console.error); diff --git a/backend/architecture.md b/backend/architecture.md new file mode 100644 index 0000000..939c9ba --- /dev/null +++ b/backend/architecture.md @@ -0,0 +1,114 @@ +Bytelyst Trading Platform Architecture Reference + +## SECTION 1 SYSTEM OVERVIEW +- High-level architecture diagram (textual) A single trading service instance drives a trading loop that evaluates profiles, places orders through exchange connectors, and sends confirmed state into Supabase. Supabase dispatches data to the dashboard, the monitoring loop, and the reconciliation loop. Operational health is published via the /internal/health endpoint, while observability metrics flow to the Prometheus /metrics exporter. +- Runtime components: + - Trading loop Executes profile signals, reserves capital, acquires row-based locks, delegates confirmed exchange orders to transactional lifecycle RPCs, and emits audit logs. + - Monitor loop Collects exchange syncs, ensures lifecycle state mirrors exchange fills, and feeds capital/position snapshots back into Supabase. + - Reconciliation loop Locks per profile, fetches full open orders from exchange and database, routes mismatches through lifecycle-safe handlers, and updates metrics for parity, miss counts, and lock contention. + - Order sync loop Aggregates lifecycle history, updates the public dashboard data, and ensures active orders, positions, and trade history remain aligned with Supabase slices. +- Exchange interaction model The trading loop targets the exchange as the source of truth: it first places an order, receives an exchange order_id alongside deterministically generated clientOrderId, then persists lifecycle data. Subsequent reconciliation cycles compare Supabase rows to the exchanges reported open orders, triggering lifecycle handlers rather than raw database patches. +- Single exchange key multi-profile model A shared exchange API key services multiple profiles but isolation is maintained through per-profile capital ledgers, row-based locking, and RLS policies; no profile may observe or affect another profiles runtime state. + +## SECTION 2 PHASE-BY-PHASE ENTERPRISE HARDENING + +### Phase 1 Tenant Isolation +**Purpose:** Prevent cross-profile data leakage and enforce per-user scoping. +**Problem solved:** Without isolation, one authenticated user could receive global runtime state or execute orders for another profile. +**Core mechanisms:** Supabase RLS filters (orders and trade_history tables, WebSocket payloads scoped by user_id), WebSocket broadcasts that partition runtime state per authenticated user, and runtime checks that reject exchanges for mismatched profile_id. +**Must never break:** The global runtime state must never be broadcast without tenant attribution, and no query should bypass RLS. + +### Phase 2 Restart Durability +**Purpose:** Guarantee deterministic state reconstruction after service restart. +**Problem solved:** Volatile in-memory maps caused missed reservations, lost lifecycle state, and inconsistent dashboards post-restart. +**Core mechanisms:** On startup, the service loads profiles, replays exchange open positions and orders, rebuilds lifecycle/trade history mappings, and rehydrates the capital ledger from the database/exchange state. File snapshots are deprecated; the DB/exchange become authoritative. +**Must never break:** Restart must not rely on process-local idempotency maps; open orders/positions must always be re-fetched on boot. + +### Phase 3 Capital Ledger +**Purpose:** Enforce deterministic capital isolation per profile. +**Problem solved:** Concurrent entries could over-allocate capital and leave the ledger inconsistent. +**Core mechanisms:** A ledger schema maintains allocated_capital, reserved_for_orders, reserved_for_positions, and realized_pnl, with available_capital computed as allocated minus reservations plus realized. Entry execution acquires a profile-level mutex, reserves capital before exchange placement, and adjusts reservations on fills, partial fills, cancels, and exits. Restart rebuilds the ledger from exchange open orders/positions, ensuring any drift resets to authoritative values. +**Must never break:** The invariant available_capital = allocated - reserved_for_orders - reserved_for_positions + realized_pnl must always hold; no code should mutate ledger fields outside the defined RPCs or ledger service. + +### Phase 4 Transactional Lifecycle +**Purpose:** Guarantee atomic ENTRY/EXIT persistence tied to confirmed exchange events. +**Problem solved:** Partial writes left orphaned lifecycle entries, phantom positions, and duplicate trade history rows. +**Core mechanisms:** ENTRY flow places the exchange order first, then calls the fn_persist_entry_lifecycle RPC that inserts the lifecycle row, order row, position seed, and optional history slice within one transaction (using UNIQUE(profile_id, trade_id) and idempotent child inserts). EXIT flow places exit orders, then updates lifecycle rows, positions, and history in another single transaction. Idempotency keys prevent duplicate rows, and the unique constraints enforce lifecycle integrity. +**Must never break:** The exchange must remain the source of truth; no lifecycle row may exist without a confirmed exchange order, and idempotency safeguards must never be bypassed. + +### Phase 5 Reconciliation +**Purpose:** Align database state with exchange truth continuously. +**Problem solved:** Discrepancies between Supabase rows and exchange orders led to stale dashboard data and capital mismatches. +**Core mechanisms:** The reconciliation loop acquires a profile-specific row lock, loads full open orders from both the exchange and the database (no limit), compares by order_id/client_order_id/trade_id, and routes any discrepancy through lifecycle-safe handlers (entry fill, exit fill, cancel). It also tracks metrics for reconciliation health and mismatch counts. +**Must never break:** Raw status patches are forbidden; every correction must trigger lifecycle handlers so the capital ledger and positions stay consistent. + +### Phase 6 Distributed Safety +**Purpose:** Make ENTRY execution safe across horizontally scaled instances. +**Problem solved:** In-memory profile mutexes could not coordinate across multiple bots, leading to double submissions. +**Core mechanisms:** ENTRY distributed locking now uses row-based lock table with TTL, owner tokens, and deterministic symbol keys, ensuring only one active signal per profile/symbol. Deterministic clientOrderId (bytelyst-profile-trade) prevents duplicate exchange submissions when retries occur. Horizontal scaling relies on shared DB locks, so no duplicate lifecycle creation occurs even with many workers. +**Must never break:** The row-lock acquisition/release must always execute around exchange submission; failing to release or regenerate owner tokens is unacceptable. + +### Phase 7 Observability & Health +**Purpose:** Provide operational insight and safeguards. +**Problem solved:** Blind spots in loops and invariants made debugging and proactive alerting difficult. +**Core mechanisms:** Prometheus /metrics tracks loop durations, reconciliation mismatches, lock contention, capital invariant violations, and exchange latency histograms. /internal/health exposes trading/monitor/reconciliation loop health, lock contention counts, reconciliation mismatch counts, and degraded indicators. Structured audit logs capture ENTRY/EXIT submissions, fills, cancellations, and reconciliation corrections. A capital invariant watchdog logs critical errors if the ledger computation negative. +**Must never break:** Observable metrics must never regress; the health endpoint must always include counters referenced by runbooks. + +### Phase 8 Final Enterprise Validation +**Purpose:** Formalize invariants, failure runbooks, and emergency controls. +**Problem solved:** Without operator guidance, teams risk violating essential guarantees when extending the system. +**Core mechanisms:** Operators rely on docs/invariants.md for safety rules, docs/runbooks/*.md for failure handling, kill switches for trading loops, and circuit breakers (global/profile/exchange) described in those runbooks. Incident response includes capital invariant monitoring and mutex lock health checks. +**Must never break:** The canonical architecture reference must remain accurate; any code touching core components must cite these runbooks before modification. + +## SECTION 3 CRITICAL INVARIANTS +- No duplicate exchange order. Documented in docs/invariants.md; deterministic clientOrderId plus lifecycle atomicity prevents duplicates. +- No lifecycle without confirmed exchange order. ENTRY RPC rejects inserts unless exchange order_id is confirmed. +- Capital cannot go negative. The ledger service enforces available_capital calculations and invariants; violations trigger logs and the capital invariant metric. +- Only one active ENTRY per (profile_id, symbol). Row-based entry_locks enforce exclusivity. +- Reconciliation converges to exchange truth. The reconciliation loop locks per profile, compares full datasets, and uses lifecycle handlers to correct mismatches. +- Restart does not corrupt ledger. Startup rebuild rehydrates ledger from exchange open orders/positions. +- Distributed workers cannot double submit. Shared locks and deterministic clientOrderId plus RPC idempotency guarantee this. +Each invariant references docs/invariants.md and the relevant runbook under docs/runbooks/*.md for procedures when invariants fail. + +## SECTION 4 EXECUTION FLOW DIAGRAMS +1. ENTRY execution Validate signal, acquire reconciliation/entry row lock, reserve capital via ledger RPC, place exchange order with deterministic clientOrderId, call fn_persist_entry_lifecycle within transaction, release lock, emit audit log, and update dashboard state. +2. EXIT execution Trigger exit signal, place exit order with exchange, call transactional RPC to update lifecycle, adjust ledger (release reserved positions, add realized_pnl), close positions, notify dashboard. +3. Partial fill handling Exchange reports partial fill, monitor/reconciliation loop routes through entry-fill handler, move delta from reserved_for_orders to reserved_for_positions, update position quantity, emit lifecycle history slice, maintain ledger invariant. +4. Restart rebuild On boot, load profiles, fetch exchange open orders and positions, rebuild lifecycle map, reconstruct ledger reservations and realized_pnl, validate no stale idempotency entries remain. +5. Reconciliation cycle Acquire profile reconciliation lock, fetch full DB open/closed orders, fetch exchange open orders, match by identifiers, route discrepancies through lifecycle handlers, update metrics, release lock. +6. Distributed lock acquisition Generate owner token, call fn_try_acquire_entry_lock_row with TTL (30s), verify success, re-check lifecycle state, proceed with capital reservation and exchange call, and finally release lock via fn_release_entry_lock_row. + +## SECTION 5 FAILURE SCENARIO TABLE +Scenario | What happens | Why safe | Recovery behavior +Two workers race | Only one lock owner receives lock, other skip entry | Row lock plus lifecycle check prevents double submission | Loser retries after lock TTL; health endpoint increments lock contention metric +Network partition | Exchange call eventually times out | Lock TTL ensures no permanent hold; trading loop raises error via runbook steps | Retry logic plus alert in docs/runbooks/lock-timeout.md +DB failure | RPC fails, transaction rolled back | ENTRY RPC transaction atomicity prevents partial lifecycle writes | Retry after DB recovery; reconciliation finds any discrepancy +Exchange timeout | Entry order fails after reservation | Reserved capital released under finally block and ledger recalculates | Monitor loop logs failure; health endpoint lattice flags degrade +Crash before lifecycle RPC | Exchange order exists, RPC never called | Reconciliation detects order without lifecycle and replays handler | Lifecycle handler reprocesses confirmed order; runbook instructs to check logs +Crash after exchange but before persistence | Exchange order_id exists, lifecycle missing | Reconciliation handles orphaned orders via lifecycle-safe handler | Handler inserts lifecycle; capital ledger adjusts from exchange positions +Partial fill after restart | Rebuilt ledger recalculates reserved positions from exchange fills | Ledger rebuild logic replays fills; invariants hold | No manual recovery needed; reconciliation verifies +Supabase outage | Health metrics report service_role inability to write | Monitoring loop marks degraded; lock/metric thresholds trigger alerts | Operations route to runbook in docs/runbooks/supabase-outage.md +Lock stuck | Entry lock expires at TTL and is reacquired | TTL prevents deadlock | Health metric increments lock contention; runbook uses kill switch + +## SECTION 6 HEALTH & OBSERVABILITY +- /internal/health fields tradingLoopHealthy, monitorLoopHealthy, reconciliationLoopHealthy, reconciliationLastRun, lockContentionCount, reconciliationMismatchCount, reconciliationMissingFromExchange, reconciliationMissingInDb, capitalInvariantViolations, exchangeLatencyHistogram, readiness (true only when loops run within expected intervals). Degradation occurs if any loop exceeds twice its normal cadence or capitalInvariantViolations increments. +- Loop metrics /metrics exposes durations and last-run timestamps for trading, monitor, reconciliation, order sync loops. An unhealthy threshold is 2x the expected interval. +- Lock contention metrics Incremented when fn_try_acquire_entry_lock_row fails; acquisition latency is recorded as a histogram; stuck locks are surfaced when TTL expires without release. +- Reconciliation metrics mismatch and missing counts show convergence progress; reconciliationHealthy toggles when mismatch count remains zero for two cycles. +- Observability design Prometheus ensures minimal overhead by pushing counters via the health tracker; structured logs include profile_id, trade_id, event, and shedding to maintain compliance. + +## SECTION 7 HORIZONTAL SCALING MODEL +- Multi-worker deployment Each bot instance shares the same Supabase project and exchange key; they coordinate through row-based locks and the shared capital ledger stored in Supabase. +- Shared DB Lifecycles, ledgers, locks, and reconciliation state live in Supabase; worker nodes treat the DB as the single source of state, and all RPCs operate against it. +- Shared exchange key A deterministic clientOrderId plus order lifecycle handling ensures duplicate submissions never occur even though multiple workers use the same key. +- Lock guarantees Entry locks and reconciliation locks use TTL and owner tokens; only one worker may hold a lock for a profile/symbol combination or a profiles reconciliation cycle. +- No duplication Atomic lifecycle RPCs, deterministic clientOrderId, and reconciliation lock semantics guarantee that no two workers can simultaneously report conflicting lifecycles or capital adjustments. + +## SECTION 8 SAFE ENHANCEMENT RULES +Checklist for future agents: +- Lifecycle: Do not modify fn_persist_entry_lifecycle or exit RPCs without referencing docs/runbooks/lifecycle-incident.md; every change must maintain exchange-first order and transactional guarantees. +- Ledger: Preserve available_capital invariants and only update ledger fields through the ledger service; see docs/invariants.md for safety rules. +- Reconciliation: Never bypass lifecycle handlers; matching logic must still route through reconcileEntryFill/reconcileExitFill/reconcileCancel. +- Locking: Entry and reconciliation locks (row-based) must stay TTL-based and owner-checked; no in-memory mutex hacks. +- Exchange submission: ClientOrderId strategy is deterministic; do not modify it without ensuring idempotency. +"DO NOT BREAK" rules: The exchange remains the source of truth, the capital ledger must never go negative, distributed locks must be respected, and reconciliation must converge to exchange state. Any deviation triggers procedures spelled out in docs/runbooks/invariant-violation.md. diff --git a/backend/audit_profile_mapping.ts b/backend/audit_profile_mapping.ts new file mode 100644 index 0000000..6d1cae6 --- /dev/null +++ b/backend/audit_profile_mapping.ts @@ -0,0 +1,33 @@ +import { createClient } from '@supabase/supabase-js'; +import { config } from '../src/config/index.js'; + +async function auditProfileMapping() { + if (!config.SUPABASE_URL || !config.SUPABASE_KEY) { + return; + } + + const supabase = createClient(config.SUPABASE_URL, config.SUPABASE_KEY); + + const { data: profiles } = await supabase.from('trade_profiles').select('*'); + const { data: history } = await supabase.from('trade_history').select('*'); + const { data: orders } = await supabase.from('orders').select('*'); + + const audit: any = {}; + profiles?.forEach(p => { + audit[p.id] = { + name: p.name, + capital: p.allocated_capital, + trades: history?.filter(h => h.profile_id === p.id).length || 0, + orders: orders?.filter(o => o.profile_id === p.id).length || 0, + pnl: history?.filter(h => h.profile_id === p.id).reduce((sum, h) => sum + Number(h.pnl || 0), 0) + }; + }); + + console.log(JSON.stringify({ + profiles: audit, + orphanedTrades: history?.filter(h => !h.profile_id).length || 0, + orphanedOrders: orders?.filter(o => !o.profile_id).length || 0 + }, null, 2)); +} + +auditProfileMapping().catch(console.error); diff --git a/backend/audit_profile_structure.ts b/backend/audit_profile_structure.ts new file mode 100644 index 0000000..63f697e --- /dev/null +++ b/backend/audit_profile_structure.ts @@ -0,0 +1,35 @@ + +import { supabaseService } from '../src/services/SupabaseService.js'; +import logger from '../src/utils/logger.js'; + +async function auditProfileData() { + logger.info('🔍 AUDITING PROFILE DATA STRUCTURE...'); + + const profiles = await supabaseService.getActiveProfiles(); + const highRisk = profiles.find(p => p.name.includes('Scalp') || p.name.includes('High')); + + if (!highRisk) { + logger.error('❌ High Risk Profile not found.'); + return; + } + + logger.info(`👤 Profile: ${highRisk.name}`); + logger.info(`📂 Strategy Config Type: ${typeof highRisk.strategy_config}`); + + if (highRisk.strategy_config) { + logger.info(`📂 Rules Array Type: ${Array.isArray(highRisk.strategy_config.rules) ? 'Array' : typeof highRisk.strategy_config.rules}`); + + logger.info('📋 RAW RULES DATA:'); + // print full json structure + console.log(JSON.stringify(highRisk.strategy_config.rules, null, 2)); + } else { + logger.warn('⚠️ strategy_config is missing or null'); + } + + logger.info('------------------------------------------------'); + logger.info('Expected IDs by Dashboard:'); + const expected = ['TrendBiasRule', 'MomentumRule', 'ZoneRule', 'SessionRule', 'EntryTriggerRule', 'RiskManagementRule', 'AIAnalysisRule']; + logger.info(expected.join(', ')); +} + +auditProfileData().catch(console.error); diff --git a/backend/backtesting.md b/backend/backtesting.md new file mode 100644 index 0000000..58a8a07 --- /dev/null +++ b/backend/backtesting.md @@ -0,0 +1,57 @@ +# Backtesting and Replay v1 (Backend) + +## Scope +Backtesting v1 is an isolated, read-only simulation pipeline under `src/backtest/**`. +It reuses `ProStrategyEngine` unchanged and is guarded by feature flag plus mode assertion. + +## Isolation Guarantees +- Entry path: `POST /api/backtest/run` only. +- Hard guard: `if (mode !== 'backtest') throw`. +- No live runtime loop reuse (`src/index.ts` unchanged). +- No broker order placement (`ReplayExchangeConnector.placeOrder()` throws). +- No writes to `orders`, `trade_history`, or `capital_ledgers`. +- In-memory virtual ledger only. + +## Deterministic Replay Rules +- Candles are normalized and strictly sorted by timestamp. +- Timeframes are validated to `15m`, `1h`, `4h`. +- Symbol processing order is lexicographic and stable. +- No randomness in fills/slippage/partials/exit ordering. +- Same input payload produces identical output. + +## Time-Window Semantics +- Replay window: `from_date` inclusive to `to_date` inclusive (UTC). +- Warm-up candles are loaded before `from_date` where available. +- Trades are evaluated only inside the replay window. +- Default end-of-window behavior: keep open positions as `OPEN_AT_END`. +- Optional execution override: `forceCloseAtWindowEnd=true` to close at last candle. + +## Data Sources +- CSV upload (required in v1): `symbol,timeframe,timestamp,open,high,low,close,volume`. +- JSON upload (array rows or nested symbol/timeframe maps). +- Replay payload adapter (read-only). +- Kraken historical loader (read-only CCXT OHLC fetch, cached in-memory, no order paths). + +## API Contract Highlights +`BacktestResult` includes: +- `trades` +- `summary` (`netPnlUsd`, `winRate`, `maxDrawdownPct`, `sharpe`, `totalTrades`) +- `timeline` (equity and drawdown points) +- `window` (UTC replay window + `OPEN_AT_END`/`FORCE_CLOSE` policy) +- `warmup` +- `openPositionsAtEnd` +- `assumptions` +- `diagnostics` + +## Feature Flags +- `ENABLE_BACKTEST=true` +- `BACKTEST_CUSTOMER_ENABLED=false` (when false, only admins can run backtests) +- `BACKTEST_MAX_CSV_BYTES` +- `BACKTEST_MAX_ROWS` + +## Non-Goals +- Parameter optimization +- ML tuning +- Strategy mutation +- Live/backtest blending +- Broker execution from backtest mode diff --git a/backend/checkAlpacaPositions.ts b/backend/checkAlpacaPositions.ts new file mode 100644 index 0000000..09b8b74 --- /dev/null +++ b/backend/checkAlpacaPositions.ts @@ -0,0 +1,19 @@ +import { AlpacaConnector } from '../src/connectors/alpaca.js'; +import { config } from '../src/config/index.js'; + +async function test() { + const connector = new AlpacaConnector(config.ALPACA_API_KEY, config.ALPACA_API_SECRET, config.PAPER_TRADING); + + console.log("--- ALPACA DIAGNOSTIC ---"); + try { + const allPos = await (connector as any).client.getPositions(); + console.log(`Global Positions Count: ${allPos.length}`); + allPos.forEach((p: any) => { + console.log(`Symbol: ${p.symbol}, Side: ${p.side}, Size: ${p.qty}, Price: ${p.avg_entry_price}`); + }); + } catch (e: any) { + console.error("Failed to fetch global positions:", e.message); + } +} + +test(); diff --git a/backend/check_alerts.ts b/backend/check_alerts.ts new file mode 100644 index 0000000..1019801 --- /dev/null +++ b/backend/check_alerts.ts @@ -0,0 +1,17 @@ +import http from 'http'; + +http.get('http://localhost:5000/api/alerts', (res) => { + let data = ''; + res.on('data', (chunk) => { data += chunk; }); + res.on('end', () => { + try { + const alerts = JSON.parse(data); + console.log('--- RECENT SIGNAL ALERTS ---'); + alerts.filter((a: any) => a.type === 'signal').slice(-3).forEach((a: any) => { + console.log(`📍 [${new Date(a.timestamp).toLocaleTimeString()}] ${a.symbol}: ${a.message.split(':')[0]}`); + }); + } catch (e: any) { + console.error('Error:', e.message); + } + }); +}); diff --git a/backend/check_alpaca_pos.ts b/backend/check_alpaca_pos.ts new file mode 100644 index 0000000..2916532 --- /dev/null +++ b/backend/check_alpaca_pos.ts @@ -0,0 +1,20 @@ +import { config } from '../src/config/index.js'; +import { AlpacaConnector } from '../src/connectors/alpaca.js'; +import logger from '../utils/logger.js'; + +async function checkAlpacaPositions() { + const exchange = new AlpacaConnector(config.ALPACA_API_KEY, config.ALPACA_API_SECRET); + try { + const positions = await exchange.getPositions(); + console.log('--- Current Alpaca Positions ---'); + console.log(JSON.stringify(positions, null, 2)); + + const btcPos = await exchange.getPosition('BTC/USD'); + console.log('--- BTC/USD Specific Position ---'); + console.log(JSON.stringify(btcPos, null, 2)); + } catch (err) { + console.error('Error fetching positions:', err); + } +} + +checkAlpacaPositions(); diff --git a/backend/check_cols.ts b/backend/check_cols.ts new file mode 100644 index 0000000..61d13cf --- /dev/null +++ b/backend/check_cols.ts @@ -0,0 +1,19 @@ +import { createClient } from '@supabase/supabase-js'; +import * as dotenv from 'dotenv'; +import path from 'path'; + +dotenv.config({ path: path.resolve(process.cwd(), '.env') }); +const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_KEY!); + +async function checkColumns() { + const { data, error } = await supabase.from('trade_history').select('*').limit(1); + if (error) { + console.error('Error fetching one row:', error); + } else if (data && data.length > 0) { + console.log('Trade History Columns:', Object.keys(data[0])); + } else { + console.log('Trade History is empty or table mismatch.'); + } +} + +checkColumns(); diff --git a/backend/check_counts.ts b/backend/check_counts.ts new file mode 100644 index 0000000..2c25844 --- /dev/null +++ b/backend/check_counts.ts @@ -0,0 +1,47 @@ +import { createClient } from '@supabase/supabase-js'; +import * as dotenv from 'dotenv'; +import path from 'path'; + +dotenv.config({ path: path.resolve(process.cwd(), '.env') }); +const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_KEY!); + +async function checkSchema() { + console.log('--- Checking Table Schema ---'); + + // We can use RPC to get table info, or just fetch one row + const { data: orderRow, error: orderErr } = await supabase.from('orders').select('*').limit(1); + if (!orderErr && orderRow && orderRow.length > 0) { + console.log('Orders Columns:', Object.keys(orderRow[0])); + } else { + console.log('Orders error or no rows:', orderErr?.message); + } + + const { data: historyRow, error: historyErr } = await supabase.from('trade_history').select('*').limit(1); + if (!historyErr && historyRow && historyRow.length > 0) { + console.log('History Columns:', Object.keys(historyRow[0])); + } else { + console.log('History error or no rows:', historyErr?.message); + } +} + +async function countByUser() { + const userId = '8d5efd9e-0760-4859-8c07-0930ab3ede5a'; + console.log(`\n--- Counting for user ${userId} ---`); + + const { count: orderCount } = await supabase.from('orders').select('*', { count: 'exact', head: true }).eq('user_id', userId); + const { count: historyCount } = await supabase.from('trade_history').select('*', { count: 'exact', head: true }).eq('user_id', userId); + + console.log(`Orders: ${orderCount}`); + console.log(`History: ${historyCount}`); + + // Also check for 'global' + const { count: globalOrderCount } = await supabase.from('orders').select('*', { count: 'exact', head: true }).eq('user_id', 'global'); + console.log(`Global Orders: ${globalOrderCount}`); +} + +async function main() { + await checkSchema(); + await countByUser(); +} + +main(); diff --git a/backend/check_db_users.ts b/backend/check_db_users.ts new file mode 100644 index 0000000..a44760c --- /dev/null +++ b/backend/check_db_users.ts @@ -0,0 +1,22 @@ +import { createClient } from '@supabase/supabase-js'; +import * as dotenv from 'dotenv'; +import path from 'path'; + +dotenv.config({ path: path.resolve(process.cwd(), '.env') }); + +const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_KEY!); + +async function checkUsers() { + console.log('Listing Users from DB...'); + const { data, error } = await supabase + .from('users') + .select('user_id, email, trade_enable'); + + if (error) { + console.error('❌ Error fetching users:', error); + } else { + console.log('✅ Users found:', data); + } +} + +checkUsers(); diff --git a/backend/check_orders_cols.ts b/backend/check_orders_cols.ts new file mode 100644 index 0000000..a6595dd --- /dev/null +++ b/backend/check_orders_cols.ts @@ -0,0 +1,11 @@ +import { createClient } from '@supabase/supabase-js'; +import { config } from '../src/config/index.js'; + +async function checkOrdersCols() { + const supabase = createClient(config.SUPABASE_URL, config.SUPABASE_KEY); + const { data } = await supabase.from('orders').select('*').limit(1); + if (data && data[0]) { + console.log('Orders Columns:', Object.keys(data[0])); + } +} +checkOrdersCols(); diff --git a/backend/check_persistence.ts b/backend/check_persistence.ts new file mode 100644 index 0000000..f2f5589 --- /dev/null +++ b/backend/check_persistence.ts @@ -0,0 +1,34 @@ +import { createClient } from '@supabase/supabase-js'; +import * as dotenv from 'dotenv'; +import path from 'path'; + +dotenv.config({ path: path.resolve(process.cwd(), '.env') }); +const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_KEY!); + +async function checkUserPersistence() { + const email = 'saravanan.27ge+006@gmail.com'; + console.log(`Checking user: ${email}`); + + const { data: users, error } = await supabase.from('users').select('*').eq('email', email); + if (error || !users || users.length === 0) { + console.error('User not found in DB!'); + return; + } + + const user = users[0]; + const userId = user.user_id; + console.log(`Found User ID: ${userId}`); + + // Check recent entries for this specific ID + const { count: orders } = await supabase.from('orders').select('*', { count: 'exact', head: true }).eq('user_id', userId); + const { count: history } = await supabase.from('trade_history').select('*', { count: 'exact', head: true }).eq('user_id', userId); + + console.log(`Stats for this user: Orders=${orders}, History=${history}`); + + if (orders > 0) { + const { data } = await supabase.from('orders').select('symbol, side, status, created_at').eq('user_id', userId).order('created_at', { ascending: false }).limit(5); + console.log('Recent Orders:', JSON.stringify(data, null, 2)); + } +} + +checkUserPersistence(); diff --git a/backend/check_user_schema.ts b/backend/check_user_schema.ts new file mode 100644 index 0000000..e06b229 --- /dev/null +++ b/backend/check_user_schema.ts @@ -0,0 +1,19 @@ +import { createClient } from '@supabase/supabase-js'; +import * as dotenv from 'dotenv'; +import path from 'path'; + +dotenv.config({ path: path.resolve(process.cwd(), '.env') }); +const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_KEY!); + +async function checkUserSchema() { + const { data, error } = await supabase.from('users').select('*').limit(1); + if (error) { + console.error('Error:', error); + } else if (data && data.length > 0) { + console.log('Columns:', Object.keys(data[0]).join(', ')); + } else { + console.log('Users table is empty.'); + } +} + +checkUserSchema(); diff --git a/backend/create_aggressive_profile.ts b/backend/create_aggressive_profile.ts new file mode 100644 index 0000000..51f8e43 --- /dev/null +++ b/backend/create_aggressive_profile.ts @@ -0,0 +1,67 @@ +import { supabaseService } from '../src/services/SupabaseService.js'; +import logger from '../src/utils/logger.js'; + +async function createAggressiveProfile() { + logger.info('🚀 CREATING AGGRESSIVE TEST PROFILE (70% VOTING)...'); + + // 1. Get User + const users = await supabaseService.getActiveUsers(); + if (users.length === 0) { + logger.error('❌ No active users found.'); + return; + } + const user = users[0]; + + // 2. Prepare Profile + const aggressiveProfile = { + user_id: user.user_id, + name: "Aggressive Test (70% Voting)", + allocated_capital: 5000, + symbols: "BTC/USDT,ETH/USDT,SOL/USDT", + is_active: false, + strategy_config: { + rules: [ + { ruleId: 'TrendBiasRule', enabled: true, params: { emaFast: 50, emaSlow: 200 } }, + { ruleId: 'SessionRule', enabled: true, params: { allowedSessions: ['NY', 'LDN'] } }, + { ruleId: 'ZoneRule', enabled: true, params: { emaPeriod: 20 } }, + { ruleId: 'MomentumRule', enabled: true, params: { rsiPeriod: 14 } }, + { ruleId: 'EntryTriggerRule', enabled: true, params: {} }, + { ruleId: 'RiskManagementRule', enabled: true, params: { atrPeriod: 14 } }, + { ruleId: 'AIAnalysisRule', enabled: false, params: { minConfidence: 80 } } + ], + riskLimits: { + maxDailyLossUsd: 50, + dailyProfitTargetUsd: 100, + maxOpenTrades: 2, + maxConsecutiveLosses: 2 + }, + execution: { + orderType: 'market', + cooldownMinutes: 30, + minRulePassRatio: 0.7, + entryMode: 'both' + } + } + }; + + // 3. Insert Profile + // @ts-ignore + const { data, error } = await supabaseService.client + .from('trade_profiles') + .insert([aggressiveProfile]) + .select() + .single(); + + if (error) { + logger.error(`❌ Failed to create profile: ${error.message}`); + } else { + logger.info(`✅ Created Profile: [${aggressiveProfile.name}]`); + logger.info(` - Capital: $${aggressiveProfile.allocated_capital}`); + logger.info(` - Rules: 4 Voting (70%), 2 Mandatory, AI Disabled`); + logger.info(` - Target: $100 Profit Target | $50 Daily Loss Max`); + } + + process.exit(0); +} + +createAggressiveProfile().catch(console.error); diff --git a/backend/create_low_risk_profile.ts b/backend/create_low_risk_profile.ts new file mode 100644 index 0000000..a0fc98a --- /dev/null +++ b/backend/create_low_risk_profile.ts @@ -0,0 +1,65 @@ + +import { supabaseService } from '../src/services/SupabaseService.js'; +import logger from '../src/utils/logger.js'; +import { v4 as uuidv4 } from 'uuid'; + +async function createLowRiskProfile() { + logger.info('🛡️ CREATING LOW RISK & SCALP PROFILES EXAMPLE...'); + + // 1. Get User + const users = await supabaseService.getActiveUsers(); + if (users.length === 0) { + logger.error('❌ No active users found.'); + return; + } + const user = users[0]; + + // 2. Create "Low Risk Swing" Profile + const lowRiskProfile = { + user_id: user.user_id, + name: "Low Risk Swing Trader 🛡️", + allocated_capital: 2000, + risk_per_trade_percent: 0.5, // Ultra Low Risk (0.5%) + symbols: "BTC/USD,ETH/USD", // Major Pairs Only + is_active: true, + strategy_config: { + riskLimits: { + maxDailyLossUsd: 50, // Strict Stop + maxOpenTrades: 2, + maxConsecutiveLosses: 2 + }, + execution: { orderType: 'limit' }, // Limit Orders for safer entry + rules: [ + { ruleId: 'TrendBiasRule', enabled: true }, // Must be with Trend (EMA200) + { ruleId: 'ZoneRule', enabled: true }, // Must be near Support Zone + { ruleId: 'RiskManagementRule', enabled: true }, // ATR Volatility Check + { ruleId: 'SessionRule', enabled: true } // Only London/NY Sessions + ] + } + }; + + // 3. Create "Aggressive Scalp" Profile (for contrast, if not exists) + // We already renamed the first one to High Risk Scalper, so we just add the Low Risk one. + + // Insert Low Risk Profile + // @ts-ignore + const { data, error } = await supabaseService.client + .from('trade_profiles') + .insert([lowRiskProfile]) + .select() + .single(); + + if (error) { + logger.error(`❌ Failed to create profile: ${error.message}`); + } else { + logger.info(`✅ Created Profile: [${lowRiskProfile.name}]`); + logger.info(` - Capital: $${lowRiskProfile.allocated_capital}`); + logger.info(` - Risk: ${lowRiskProfile.risk_per_trade_percent}% per trade`); + logger.info(` - Rules: Trend + Zone + RiskGuard + Session`); + logger.info(` - Strategy: Only trades major trend pullbacks during active sessions.`); + } + + process.exit(0); +} + +createLowRiskProfile().catch(console.error); diff --git a/backend/debugProfiles.ts b/backend/debugProfiles.ts new file mode 100644 index 0000000..9b314ec --- /dev/null +++ b/backend/debugProfiles.ts @@ -0,0 +1,54 @@ +import { supabaseService } from '../src/services/SupabaseService.js'; +import { ConnectorFactory } from '../src/connectors/factory.js'; +import { config } from '../src/config/index.js'; +import { SymbolMapper } from '../src/utils/symbolMapper.js'; +import logger from '../src/utils/logger.js'; + +async function debug() { + logger.info("--- Debugging Profiles & Positions ---"); + const profiles = await supabaseService.getActiveProfiles(); + const users = await supabaseService.getActiveUsers(); + + logger.info(`Found ${profiles.length} active profiles and ${users.length} active users.`); + + for (const profile of profiles) { + const user = users.find(u => u.user_id === profile.user_id); + if (!user) continue; + + const userKey = config.PAPER_TRADING ? user.ALPACA_API_KEY : user.REAL_ALPACA_API_KEY; + const userSecret = config.PAPER_TRADING ? user.ALPACA_SECRET_KEY : user.REAL_ALPACA_SECRET_KEY; + + if (userKey && userSecret) { + try { + const exchange = ConnectorFactory.getCustomConnector(config.EXECUTION_PROVIDER, userKey, userSecret); + for (const symbol of config.SYMBOLS) { + const tradeSymbol = SymbolMapper.toTradeSymbol(symbol, config.EXECUTION_PROVIDER); + const pos = await exchange.getPosition(tradeSymbol); + if (pos) { + logger.info(` ✅ FOUND ${symbol} (${tradeSymbol}) for profile ${profile.name}: ${pos.qty} @ ${pos.avg_entry_price}`); + } + } + } catch (e) { /* ignore */ } + } + } + + logger.info("--- Checking MASTER Account (from .env) ---"); + const masterKey = config.ALPACA_API_KEY; + const masterSecret = config.ALPACA_API_SECRET; + if (masterKey) { + try { + const exchange = ConnectorFactory.getCustomConnector(config.EXECUTION_PROVIDER, masterKey, masterSecret); + for (const symbol of config.SYMBOLS) { + const tradeSymbol = SymbolMapper.toTradeSymbol(symbol, config.EXECUTION_PROVIDER); + const pos = await exchange.getPosition(tradeSymbol); + if (pos) { + logger.info(` ✅ FOUND ${symbol} (${tradeSymbol}) for MASTER Account: ${pos.qty} @ ${pos.avg_entry_price}`); + } + } + } catch (e: any) { + logger.info(` ❌ Error checking Master Account: ${e.message}`); + } + } +} + +debug(); diff --git a/backend/debug_config.ts b/backend/debug_config.ts new file mode 100644 index 0000000..d96958f --- /dev/null +++ b/backend/debug_config.ts @@ -0,0 +1,11 @@ +import { config } from '../src/config/index.js'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +console.log("--- AI CONFIG DEBUG ---"); +console.log("AI_PROVIDER:", config.AI.PROVIDER); +console.log("PERPLEXITY_KEY_EXISTS:", !!config.AI.PERPLEXITY_API_KEY); +console.log("OPENAI_KEY_EXISTS:", !!config.AI.OPENAI_API_KEY); +console.log("GEMINI_KEY_EXISTS:", !!config.AI.GEMINI_API_KEY); +console.log("FALLBACK_LIST:", config.AI.FALLBACK_LIST); +console.log("------------------------"); diff --git a/backend/debug_db_logging.ts b/backend/debug_db_logging.ts new file mode 100644 index 0000000..52d3168 --- /dev/null +++ b/backend/debug_db_logging.ts @@ -0,0 +1,86 @@ + +import { supabaseService } from '../src/services/SupabaseService.js'; +import logger from '../src/utils/logger.js'; + +async function debugDatabaseLogging() { + logger.info('🔍 Starting Database Logging Debug...'); + + // 1. Get Real User & Profile + const profiles = await supabaseService.getActiveProfiles(); + if (profiles.length === 0) { + logger.error('No profiles found to test with.'); + return; + } + const profile = profiles[0]; + const user = profile.users; + + logger.info(`👤 Testing with Profile: ${profile.name} (${profile.id})`); + logger.info(`👤 Testing with User: ${user.email} (${user.user_id})`); + + // 2. Test Order Logging + const testOrder = { + user_id: user.user_id, + profile_id: profile.id, // Explicitly testing this link + order_id: `test-order-${Date.now()}`, + symbol: 'BTC/USD', + type: 'market', + side: 'buy', + qty: 0.1, + price: 50000, + status: 'filled', + timestamp: Date.now() + }; + + logger.info('📝 Attempting to insert Order:', testOrder); + + // Call private client directly to get full error if service swallows it + // @ts-ignore + const { data: orderData, error: orderError } = await supabaseService.client + .from('orders') + .insert([testOrder]) + .select(); + + if (orderError) { + logger.error('❌ ORDER LOGGING FAILED:'); + logger.error(JSON.stringify(orderError, null, 2)); + } else { + logger.info('✅ ORDER LOGGING SUCCESS:', orderData); + } + + // 3. Test Trade History Logging + const testTrade = { + user_id: user.user_id, + profile_id: profile.id, + symbol: 'BTC/USD', + side: 'BUY', + entry_price: 50000, + exit_price: 55000, + size: 0.1, + pnl: 500, + pnl_percent: 10, + reason: 'Debug Test', + timestamp: Date.now() + }; + + logger.info('📝 Attempting to insert Trade History:', testTrade); + + // @ts-ignore + const { data: tradeData, error: tradeError } = await supabaseService.client + .from('trade_history') + .insert([testTrade]) + .select(); + + if (tradeError) { + logger.error('❌ TRADE LOGGING FAILED:'); + logger.error(JSON.stringify(tradeError, null, 2)); + } else { + logger.info('✅ TRADE LOGGING SUCCESS:', tradeData); + + // Cleanup (Consistency) + logger.info('🧹 Cleaning up test data...'); + // @ts-ignore + await supabaseService.client.from('orders').delete().eq('order_id', testOrder.order_id); + } +} + +debugDatabaseLogging(); diff --git a/backend/debug_mode.ts b/backend/debug_mode.ts new file mode 100644 index 0000000..5c5ba9a --- /dev/null +++ b/backend/debug_mode.ts @@ -0,0 +1,14 @@ +import axios from 'axios'; + +async function checkMode() { + try { + const response = await axios.get('http://localhost:5000/api/status'); + console.log('Execution Mode:', response.data.settings?.executionMode); + console.log('Is Algo Enabled:', response.data.settings?.isAlgoEnabled); + console.log('Bot Active:', response.data.settings?.botActive); + } catch (error) { + console.error('Error fetching status:', error.message); + } +} + +checkMode(); diff --git a/backend/debug_supabase.ts b/backend/debug_supabase.ts new file mode 100644 index 0000000..50f6968 --- /dev/null +++ b/backend/debug_supabase.ts @@ -0,0 +1,35 @@ +import { createClient } from '@supabase/supabase-js'; +import * as dotenv from 'dotenv'; +import path from 'path'; + +dotenv.config({ path: path.resolve(process.cwd(), '.env') }); + +const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_KEY!); + +async function testDirectInsert() { + console.log('Testing Direct Insert...'); + console.log('URL:', process.env.SUPABASE_URL); + console.log('Key (last 5):', process.env.SUPABASE_KEY?.slice(-5)); + + const { data, error } = await supabase + .from('orders') + .insert([{ + user_id: '88a2446e-740f-4c87-a94c-fad0ee5167ba', + symbol: 'DEBUG/TEST', + type: 'Market', + side: 'buy', + qty: 1, + price: 100, + status: 'Filled', + timestamp: Date.now() + }]) + .select(); + + if (error) { + console.error('❌ Insert Error:', error); + } else { + console.log('✅ Insert Success:', data); + } +} + +testDirectInsert(); diff --git a/backend/diagnose_profiles.ts b/backend/diagnose_profiles.ts new file mode 100644 index 0000000..59f9d62 --- /dev/null +++ b/backend/diagnose_profiles.ts @@ -0,0 +1,26 @@ + +import { supabaseService } from '../src/services/SupabaseService.js'; +import logger from '../src/utils/logger.js'; + +async function checkProfiles() { + console.log('--- 🛡️ PROFILE DIAGNOSTIC ---'); + const profiles = await supabaseService.getActiveProfiles(); + + if (profiles.length === 0) { + console.log('❌ NO ACTIVE PROFILES FOUND.'); + const users = await supabaseService.getActiveUsers(); + console.log(`Note: ${users.length} users have trade_enable=true, but no active profiles linked to them.`); + } else { + console.log(`✅ FOUND ${profiles.length} ACTIVE PROFILES:`); + profiles.forEach(p => { + console.log(`- Profile: ${p.name} (ID: ${p.id})`); + console.log(` User: ${p.users?.email} (ID: ${p.user_id})`); + console.log(` Symbols: ${p.symbols || 'ALL'}`); + console.log(` Capital: $${p.allocated_capital} | Risk: ${p.risk_per_trade_percent}%`); + console.log(` Global Trade Enable: ${p.users?.trade_enable}`); + console.log('---'); + }); + } +} + +checkProfiles(); diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 0000000..91b1ecc --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,30 @@ +services: + prometheus: + image: prom/prometheus:latest + container_name: prometheus + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml + ports: + - "9090:9090" + networks: + - monitoring + + grafana: + image: grafana/grafana:latest + container_name: grafana + ports: + - "3001:3000" + volumes: + - ./grafana/provisioning:/etc/grafana/provisioning + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + - GF_SERVER_DOMAIN=${GRAFANA_DOMAIN:-localhost} + - GF_SERVER_ROOT_URL=https://${GRAFANA_DOMAIN:-localhost}/ + networks: + - monitoring + depends_on: + - prometheus + +networks: + monitoring: + driver: bridge diff --git a/backend/dump_db.ts b/backend/dump_db.ts new file mode 100644 index 0000000..a2bf4e1 --- /dev/null +++ b/backend/dump_db.ts @@ -0,0 +1,28 @@ +import { createClient } from '@supabase/supabase-js'; +import * as dotenv from 'dotenv'; +import path from 'path'; + +dotenv.config({ path: path.resolve(process.cwd(), '.env') }); +const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_KEY!); + +async function dumpTable(table: string) { + console.log(`\n--- ${table} ---`); + const { data, error } = await supabase.from(table).select('*').order('created_at', { ascending: false }).limit(5); + if (error) console.error(error); + else console.log(JSON.stringify(data, null, 2)); +} + +async function listUsers() { + console.log('\n--- Users ---'); + const { data, error } = await supabase.from('users').select('user_id, email, trade_enable'); + if (error) console.error(error); + else console.log(JSON.stringify(data, null, 2)); +} + +async function main() { + await listUsers(); + await dumpTable('orders'); + await dumpTable('trade_history'); +} + +main(); diff --git a/backend/dump_recent.ts b/backend/dump_recent.ts new file mode 100644 index 0000000..e85a0e9 --- /dev/null +++ b/backend/dump_recent.ts @@ -0,0 +1,40 @@ +import { createClient } from '@supabase/supabase-js'; +import * as dotenv from 'dotenv'; +import path from 'path'; + +dotenv.config({ path: path.resolve(process.cwd(), '.env') }); +const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_KEY!); + +async function dumpRecent() { + console.log('--- Dumping Last 10 Orders ---'); + const { data: orders, error } = await supabase + .from('orders') + .select('*') + .order('timestamp', { ascending: false }) + .limit(10); + + if (error) { + console.error('Error:', error); + } else { + orders.forEach(o => { + console.log(`[Order] ID: ${o.order_id || o.id}, User: ${o.user_id}, Symbol: ${o.symbol}, Price: ${o.price}`); + }); + } + + console.log('\n--- Dumping Last 10 Trade History ---'); + const { data: history, error: hErr } = await supabase + .from('trade_history') + .select('*') + .order('timestamp', { ascending: false }) + .limit(10); + + if (hErr) { + console.error('Error:', hErr); + } else { + history.forEach(h => { + console.log(`[History] User: ${h.user_id}, Symbol: ${h.symbol}, P&L: ${h.pnl}, Reason: ${h.reason}`); + }); + } +} + +dumpRecent(); diff --git a/backend/e2e_full_scenario.ts b/backend/e2e_full_scenario.ts new file mode 100644 index 0000000..bdde5fc --- /dev/null +++ b/backend/e2e_full_scenario.ts @@ -0,0 +1,181 @@ + +import { supabaseService } from '../src/services/SupabaseService.js'; +import { AutoTrader } from '../src/services/AutoTrader.js'; +import { TradeExecutor } from '../src/services/TradeExecutor.js'; +import { AlpacaConnector } from '../src/connectors/alpaca.js'; +import { config } from '../src/config/index.js'; +import { SignalDirection, MarketContext, RuleResult, StrategyAnalysisResult } from '../src/strategies/rules/types.js'; +import logger from '../src/utils/logger.js'; + +// Setup environment and global config overrides for testing +config.ENABLE_TRADING = true; +// Force Paper Trading for safety +config.PAPER_TRADING = true; + +const TEST_SYMBOL = 'BTC/USD'; // Alpaca Paper usually supports this for crypto +const SLEEP_MS = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +async function runFullE2ETest() { + logger.info('==================================================='); + logger.info('🚀 STARTING END-TO-END BACKEND SYSTEM TEST'); + logger.info(' Scope: Configs -> Signal -> Order -> Position -> DB -> Exit -> PnL'); + logger.info('==================================================='); + + // 1. Load Profiles (Simulating "Multiple Configurations") + logger.info('\n📡 1. Loading Configurations from Database...'); + const profiles = await supabaseService.getActiveProfiles(); + + if (profiles.length === 0) { + logger.error('❌ No active profiles found in DB. Please create a Strategy Cluster in the Dashboard first.'); + process.exit(1); + } + logger.info(`✅ Found ${profiles.length} Active Profiles.`); + + // 2. Iterate Profiles and Execute Test Cycle + for (const profile of profiles) { + logger.info(`\n---------------------------------------------------`); + logger.info(`👤 Testing Profile: [${profile.name}] (ID: ${profile.id})`); + logger.info(` Allocated Capital: $${profile.allocated_capital}`); + logger.info(` Risk Per Trade: ${profile.risk_per_trade_percent}%`); + + const user = profile.users; + if (!user) { + logger.warn(' ⚠️ User data missing for this profile. Skipping.'); + continue; + } + + // Initialize Services for this specific User/Profile + const userKey = config.PAPER_TRADING ? user.ALPACA_API_KEY : user.REAL_ALPACA_API_KEY; + const userSecret = config.PAPER_TRADING ? user.ALPACA_SECRET_KEY : user.REAL_ALPACA_SECRET_KEY; + + if (!userKey || !userSecret) { + logger.warn(' ⚠️ Alpaca Credentials missing. Skipping.'); + continue; + } + + const exchange = new AlpacaConnector(userKey, userSecret); + const executor = new TradeExecutor(exchange, undefined, user.user_id, profile.id); + const trader = new AutoTrader(executor, exchange, profile); + + // SYNC: Ensure we start clean + logger.info(' 🔄 Syncing existing positions...'); + await executor.syncPositions([TEST_SYMBOL]); + + // Close any existing position on Test Symbol to ensure clean test + const existingPos = executor.getActivePosition(TEST_SYMBOL); + if (existingPos) { + logger.info(` 🧹 Closing pre-existing position on ${TEST_SYMBOL}...`); + await executor.closePosition(TEST_SYMBOL, 'E2E PRE-CLEANUP'); + await SLEEP_MS(5000); + } + + // 3. Simulate Market Context + const currentPrice = 65000; + const mockContext: MarketContext = { + symbol: TEST_SYMBOL, + currentPrice: currentPrice, + candles1h: [], candles15m: [], candles4h: [], + rsi_1h: 30, // Oversold + ema20_1h: 64000, + change24h: 1.5, + changeToday: 0.5, + volatility: 'Low', + session: 'NY', + isMajorSession: true, + latestSignal: SignalDirection.NONE + }; + + // 4. Simulate BUY Signal + logger.info(' 🟢 Simulating BUY Signal (Trend + Momentum)...'); + + // Construct Analysis Result that passes "Logic" + // We ensure a 'generic' passing rule if the profile has specific rules, or rely on global if none. + const ruleResults: Record = {}; + + // Mock common rules to PASS + const rulesToMock = ['TrendBiasRule', 'MomentumRule', 'ZoneRule', 'RiskManagementRule']; + rulesToMock.forEach(rId => { + ruleResults[rId] = { ruleName: rId, passed: true, signal: SignalDirection.BUY, reason: 'Test Pass', metadata: {} }; + }); + + const buyAnalysis: StrategyAnalysisResult = { + symbol: TEST_SYMBOL, + globalSignal: SignalDirection.BUY, + rules: ruleResults, + context: mockContext + }; + + await trader.handleSignal(TEST_SYMBOL, buyAnalysis); + + // 5. Verify Order & Position Creation + logger.info(' ⏳ Waiting for Order Fill & DB Sync (10s)...'); + await SLEEP_MS(10000); // Wait for API calls and DB events + + const activePos = executor.getActivePosition(TEST_SYMBOL); + if (!activePos) { + logger.error(` ❌ FAIL: Position not created for ${profile.name}`); + continue; // Skip to next profile + } + + logger.info(` ✅ SUCCESS: Position Created!`); + logger.info(` Qty: ${activePos.size}`); + logger.info(` Entry: ${activePos.entryPrice}`); + logger.info(` Initial U.PnL: ${activePos.unrealizedPnl}`); + + // Check DB for Order + const latestOrder = await supabaseService.getLatestOrder(user.user_id, TEST_SYMBOL); + if (latestOrder && latestOrder.side === 'buy' && latestOrder.status === 'filled') { + logger.info(` ✅ DB VERIFIED: Buy Order ${latestOrder.order_id} recorded.`); + } else { + logger.warn(` ⚠️ DB WARNING: Could not find recent filled buy order.`); + } + + // 6. Simulate Profit Update (Price Move Up) + logger.info(' 📈 Simulating Price Move (+10%)...'); + const newPrice = currentPrice * 1.10; + mockContext.currentPrice = newPrice; + + // 7. Simulate SELL/EXIT Signal + logger.info(' 🔴 Simulating SELL/EXIT Signal (Trend Reversal)...'); + const sellAnalysis: StrategyAnalysisResult = { + symbol: TEST_SYMBOL, + globalSignal: SignalDirection.SELL, // Reversal triggers exit + rules: ruleResults, + context: mockContext + }; + + await trader.handleSignal(TEST_SYMBOL, sellAnalysis); + + // 8. Verify Exit & PnL Realization + logger.info(' ⏳ Waiting for Close & Profit Realization (10s)...'); + await SLEEP_MS(10000); + + const closedPos = executor.getActivePosition(TEST_SYMBOL); + if (!closedPos) { + logger.info(` ✅ SUCCESS: Position Closed.`); + } else { + logger.error(` ❌ FAIL: Position still active!`); + } + + // Check DB for Trade History (PnL) + // We verify the 'logTransaction' was called + // Since we don't have a direct 'getLatestTransaction', we infer success from logs or check orders + const sellOrder = await supabaseService.getLatestOrder(user.user_id, TEST_SYMBOL); + if (sellOrder && sellOrder.side === 'sell' && sellOrder.status === 'filled') { + logger.info(` ✅ DB VERIFIED: Sell Order ${sellOrder.order_id} recorded.`); + // In a real scenario, we'd query trade_history too, but logged orders confirms the loop. + } + + logger.info(` 🎉 Profile [${profile.name}] Test Cycle Complete.`); + } + + logger.info('\n==================================================='); + logger.info('✅ E2E TEST SUITE COMPLETED'); + logger.info('==================================================='); + process.exit(0); +} + +runFullE2ETest().catch(err => { + logger.error('CRITICAL TEST FAILURE:', err); + process.exit(1); +}); diff --git a/backend/example_profile_config.json b/backend/example_profile_config.json new file mode 100644 index 0000000..d423d1a --- /dev/null +++ b/backend/example_profile_config.json @@ -0,0 +1,70 @@ +{ + "rules": [ + { + "ruleId": "TrendBiasRule", + "enabled": true, + "params": { + "timeframe": "4h", + "emaFast": 50, + "emaSlow": 200 + } + }, + { + "ruleId": "SessionRule", + "enabled": true, + "params": { + "allowedSessions": ["NY", "LDN"] + } + }, + { + "ruleId": "ZoneRule", + "enabled": true, + "params": { + "emaPeriod": 20, + "tolerancePercent": 0.5 + } + }, + { + "ruleId": "MomentumRule", + "enabled": true, + "params": { + "timeframe": "1h", + "rsiPeriod": 14, + "rsiOverbought": 70, + "rsiOversold": 30 + } + }, + { + "ruleId": "EntryTriggerRule", + "enabled": true, + "params": { + "triggerType": "ema_cross" + } + }, + { + "ruleId": "RiskManagementRule", + "enabled": true, + "params": { + "atrPeriod": 14, + "riskRewardRatio": 1.5 + } + }, + { + "ruleId": "AIAnalysisRule", + "enabled": false, + "params": { + "minConfidence": 80 + } + } + ], + "riskLimits": { + "maxDailyLossUsd": 50, + "maxConsecutiveLosses": 2, + "maxOpenTrades": 3 + }, + "execution": { + "orderType": "market", + "cooldownMinutes": 30, + "entryMode": "both" + } +} diff --git a/backend/final_e2e_param_verification.ts b/backend/final_e2e_param_verification.ts new file mode 100644 index 0000000..78c33b5 --- /dev/null +++ b/backend/final_e2e_param_verification.ts @@ -0,0 +1,96 @@ + +import { supabaseService } from '../src/services/SupabaseService.js'; +import { AutoTrader } from '../src/services/AutoTrader.js'; +import { TradeExecutor } from '../src/services/TradeExecutor.js'; +import { AlpacaConnector } from '../src/connectors/alpaca.js'; +import { ProStrategyEngine } from '../src/strategies/ProStrategyEngine.js'; +import { config } from '../src/config/index.js'; +import { SignalDirection, MarketContext } from '../src/strategies/rules/types.js'; +import logger from '../src/utils/logger.js'; + +config.ENABLE_TRADING = true; +config.PAPER_TRADING = true; + +const SLEEP_MS = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +async function runParamVerification() { + logger.info('==================================================='); + logger.info('🧪 STARTING PARAMETER-AWARE E2E VERIFICATION'); + logger.info(' Testing contrast between High Risk and Low Risk settings.'); + logger.info('==================================================='); + + // 1. Fetch our two distinct profiles + const profiles = await supabaseService.getActiveProfiles(); + const highRisk = profiles.find(p => p.name.includes('High Risk')); + const lowRisk = profiles.find(p => p.name.includes('Low Risk')); + + if (!highRisk || !lowRisk) { + logger.error('❌ Could not find both profiles (Scalper and Swing). Run create scripts first.'); + process.exit(1); + } + + const user = highRisk.users; // Both belong to same user in our setup + const exchange = new AlpacaConnector(user.ALPACA_API_KEY, user.ALPACA_SECRET_KEY); + const engine = new ProStrategyEngine(exchange); + + // 2. Setup Traders + const executorHigh = new TradeExecutor(exchange, undefined, user.user_id, highRisk.id); + const traderHigh = new AutoTrader(executorHigh, exchange, highRisk, engine); + + const executorLow = new TradeExecutor(exchange, undefined, user.user_id, lowRisk.id); + const traderLow = new AutoTrader(executorLow, exchange, lowRisk, engine); + + // 3. Define Market Context with RSI = 75 + // Scenario: RSI is 75. + // Low Risk Swing logic (default RSI 70) -> Should Trigger SELL + // High Risk Scalper logic (custom RSI 80) -> Should NOT Trigger (75 < 80) + const mockCandles = Array(100).fill({ close: 65000, high: 65100, low: 64900, open: 65000, volume: 100 }); + const mockContext: MarketContext = { + symbol: 'BTC/USD', + currentPrice: 65000, + candles1h: mockCandles, + candles15m: mockCandles, + candles4h: mockCandles, + rsi_1h: 75, // THE MAGIC NUMBER + ema20_1h: 64000, + ema50_4h: 60000, + ema200_4h: 55000, + session: 'NY', isMajorSession: true, + volatility: 'Med', change24h: 1, changeToday: 0.5, + latestSignal: SignalDirection.NONE + }; + + const analysis: any = { + symbol: 'BTC/USD', + globalSignal: SignalDirection.NONE, + rules: {}, // Global results don't matter now as we re-evaluate + context: mockContext + }; + + logger.info('\n📊 Scenario: RSI is 75'); + logger.info(` Profile [Low Risk]: Threshold 70 -> Expected: PASS`); + logger.info(` Profile [High Risk]: Threshold 80 -> Expected: FAIL`); + + // 4. Test Execution + logger.info('\n⚡ Testing [Low Risk Swing]...'); + await traderLow.handleSignal('BTC/USD', analysis); + + logger.info('⚡ Testing [High Risk Scalper]...'); + await traderHigh.handleSignal('BTC/USD', analysis); + + // 5. Verification + logger.info('\n🏁 RESULTS:'); + // We can't easily wait for orders in this fake test without mocking exchange, + // but we can look at the logs to see where "EXECUTING" appeared. + + logger.info('==================================================='); + logger.info('✅ VERIFICATION COMPLETE'); + logger.info(' Please check the logs above for "EXECUTING" vs "Blocked" messages.'); + logger.info('==================================================='); + process.exit(0); +} + +runParamVerification().catch(err => { + logger.error('Test Failed:', err); + process.exit(1); +}); diff --git a/backend/fix_dashboard_rules.ts b/backend/fix_dashboard_rules.ts new file mode 100644 index 0000000..bf9fa9f --- /dev/null +++ b/backend/fix_dashboard_rules.ts @@ -0,0 +1,45 @@ + +import { supabaseService } from '../src/services/SupabaseService.js'; +import logger from '../src/utils/logger.js'; + +async function fixHighRiskRules() { + logger.info('🔧 Syncing High Risk Rules with Dashboard UI...'); + + const profiles = await supabaseService.getActiveProfiles(); + const highRiskProfile = profiles.find(p => p.name.includes('Scalp')); + + if (!highRiskProfile) { + logger.error('❌ High Risk Profile not found.'); + return; + } + + // Update with Dashboard-compatible Rule IDs + const newRules = [ + { ruleId: 'MomentumRule', enabled: true }, // Aggressive Momentum (RSI) + { ruleId: 'EntryTriggerRule', enabled: true }, // Pattern Trigger + { ruleId: 'AIAnalysisRule', enabled: true } // AI Confirmation + ]; + + // @ts-ignore + const { error } = await supabaseService.client + .from('trade_profiles') + .update({ + strategy_config: { + ...highRiskProfile.strategy_config, + rules: newRules + } + }) + .eq('id', highRiskProfile.id); + + if (error) { + logger.error(`❌ Failed: ${error.message}`); + } else { + logger.info(`✅ Rules Updated for [${highRiskProfile.name}]`); + logger.info(` - MomentumRule: ON`); + logger.info(` - EntryTriggerRule: ON`); + logger.info(` - AIAnalysisRule: ON`); + logger.info(` (Refresh Dashboard to see them selected)`); + } +} + +fixHighRiskRules(); diff --git a/backend/fix_imports.ts b/backend/fix_imports.ts new file mode 100644 index 0000000..080432c --- /dev/null +++ b/backend/fix_imports.ts @@ -0,0 +1,52 @@ +import fs from 'fs'; +import path from 'path'; + +const testsDir = path.resolve(process.cwd(), 'tests'); + +const replacements = [ + { from: "from './strategies", to: "from '../src/strategies" }, + { from: "from './connectors", to: "from '../src/connectors" }, + { from: "from './config", to: "from '../src/config" }, + { from: "from './utils", to: "from '../src/utils" }, + { from: "from './services", to: "from '../src/services" }, + { from: "from './src/", to: "from '../src/" }, + // Handle double quotes too if any + { from: 'from "./strategies', to: 'from "../src/strategies' }, + { from: 'from "./connectors', to: 'from "../src/connectors' }, + { from: 'from "./config', to: 'from "../src/config' }, + { from: 'from "./utils', to: 'from "../src/utils' }, + { from: 'from "./services', to: 'from "../src/services' }, + { from: 'from "./src/', to: 'from "../src/' }, +]; + +async function fixImports() { + console.log(`Scanning ${testsDir}...`); + if (!fs.existsSync(testsDir)) { + console.error('Tests directory not found!'); + return; + } + + const files = fs.readdirSync(testsDir).filter(f => f.endsWith('.ts')); + + for (const file of files) { + const filePath = path.join(testsDir, file); + let content = fs.readFileSync(filePath, 'utf8'); + let changed = false; + + for (const rep of replacements) { + if (content.includes(rep.from)) { + content = content.replaceAll(rep.from, rep.to); + changed = true; + } + } + + if (changed) { + fs.writeFileSync(filePath, content); + console.log(`✅ Fixed imports in ${file}`); + } else { + console.log(`⚪ No changes in ${file}`); + } + } +} + +fixImports(); diff --git a/backend/force_rules_reset.ts b/backend/force_rules_reset.ts new file mode 100644 index 0000000..1953b00 --- /dev/null +++ b/backend/force_rules_reset.ts @@ -0,0 +1,44 @@ + +import { supabaseService } from '../src/services/SupabaseService.js'; +import logger from '../src/utils/logger.js'; + +async function forceResetRules() { + logger.info('🔨 FORCING CLEAN RULE SET (ALL RULES POPULATED)...'); + + const profiles = await supabaseService.getActiveProfiles(); + const highRiskStart = profiles.find(p => p.name.includes('Scalp') || p.name.includes('High')); + + if (!highRiskStart) return logger.error('❌ Profile not found'); + + const newConfig = { + execution: { allowedSymbols: ["BTC/USD"], orderType: "market" }, + riskLimits: { maxOpenTrades: 5, maxDailyLossUsd: 200 }, + rules: [ + { ruleId: "MomentumRule", enabled: true, params: { rsiPeriod: 14, overbought: 80, oversold: 20, timeframe: '15m' } }, + { ruleId: "EntryTriggerRule", enabled: true, params: { showPatterns: true } }, + { ruleId: "AIAnalysisRule", enabled: true, params: { minConfidence: 0.8 } }, + + // Explicitly include disabled ones to ensure structure exists + { ruleId: "TrendBiasRule", enabled: false, params: { fastPeriod: 50, slowPeriod: 200 } }, + { ruleId: "ZoneRule", enabled: false, params: { zonePercent: 1.5 } }, + { ruleId: "SessionRule", enabled: false, params: { sessions: "London,NY" } }, + { ruleId: "RiskManagementRule", enabled: true, params: { maxRisk: 5.0 } } // Also enabled + ] + }; + + // @ts-ignore + const { error } = await supabaseService.client + .from('trade_profiles') + .update({ strategy_config: newConfig }) + .eq('id', highRiskStart.id); + + if (error) { + logger.error(`❌ Failed: ${error.message}`); + } else { + logger.info(`✅ SUCCESS: Overwrote entire strategy_config for [${highRiskStart.name}]`); + logger.info(` - Includes 7 rules (4 Enabled, 3 Disabled)`); + logger.info(` - Includes default params for all`); + } +} + +forceResetRules(); diff --git a/backend/grafana/provisioning/datasources/datasource.yml b/backend/grafana/provisioning/datasources/datasource.yml new file mode 100644 index 0000000..436f307 --- /dev/null +++ b/backend/grafana/provisioning/datasources/datasource.yml @@ -0,0 +1,8 @@ + +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + url: http://prometheus:9090 + isDefault: true diff --git a/backend/inspect_btc.ts b/backend/inspect_btc.ts new file mode 100644 index 0000000..4fb5e32 --- /dev/null +++ b/backend/inspect_btc.ts @@ -0,0 +1,30 @@ + +import fs from 'fs'; + +const state = JSON.parse(fs.readFileSync('bot_state.json', 'utf8')); +const btc = state.symbols['BTC/USDT']; + +console.log('--- BTC/USDT LIVE STATE ---'); +console.log(`Current Price: $${btc.price}`); +console.log(`Current Signal: ${btc.signal}`); +console.log('\n--- Indicators ---'); +console.log(`RSI (1H): ${btc.indicators.rsi_1h.toFixed(2)}`); +console.log(`EMA20 (1H): $${btc.indicators.ema20_1h.toFixed(2)}`); + +console.log('\n--- Rule Breakdown ---'); +const rules = btc.rules; +if (rules) { + Object.entries(rules).forEach(([name, data]: [string, any]) => { + console.log(`[${name}] Passed: ${data.passed} | Reason: ${data.reason}`); + }); +} else { + console.log('No rules found in state.'); +} + +// Check recent price history in state (last 5 entries) +const history = btc.priceHistory || []; +const recent = history.slice(-5); +console.log('\n--- Recent Prices (v. Recent) ---'); +recent.forEach((h: any) => { + console.log(` ${new Date(h.timestamp).toLocaleTimeString()}: $${h.price}`); +}); diff --git a/backend/invariants.md b/backend/invariants.md new file mode 100644 index 0000000..e7c42c5 --- /dev/null +++ b/backend/invariants.md @@ -0,0 +1,59 @@ + +Bytelyst Trading Platform Invariants + +## Capital Ledger Invariant +- **Invariant name:** Capital non-negativity +- **Description:** For every profile, allocated - reserved_for_orders - reserved_for_positions + realized_pnl >= 0 at all times. +- **Enforced by:** Capital ledger RPCs, ledger schema constraints, and reconciliation handlers that re-sync reservations. +- **Detection:** Capital invariant metric increments and health endpoint reports violation when ledger calculation dips below zero; observability logs critical entries. +- **Recovery:** Capital watchdog logs the violation, trading loop halts new entries for that profile, reconciliation replays state, and operators follow docs/runbooks/invariant-violation.md. + +## Single ENTRY Lock Invariant +- **Invariant name:** One ENTRY per profile/symbol +- **Description:** At most one ENTRY signal can progress per (profile_id, symbol) at a time, preventing double submissions. +- **Enforced by:** Row-based entry_locks and deterministic clientOrderId, with lock TTL enforcement in the lock service code. +- **Detection:** Lock contention counter increments and health endpoint flags stale locks when TTL expires without release. +- **Recovery:** Contention metrics surface to Prometheus; the losing worker retries after TTL and operators consult docs/runbooks/lock-timeout.md if contention persists. + +## Duplicate Exchange Order Invariant +- **Invariant name:** No duplicate exchange order +- **Description:** A single trade_id can only produce one exchange order; retries never issue another order to the exchange. +- **Enforced by:** Deterministic clientOrderId strategy and transactional lifecycle RPCs that guard on matching trade_id/order_id. +- **Detection:** Exchange connectors translate duplicate-order errors into existing lifecycle lookups; observability logs capture the repeat attempt. +- **Recovery:** Retried persistence fetches existing order_id; reconciliation ensures DB state matches exchange; operators are guided by docs/runbooks/reconciliation.md if duplicates surface. + +## Lifecycle Atomicity Invariant +- **Invariant name:** Lifecycle persistence atomicity +- **Description:** ENTRY and EXIT lifecycle rows, order rows, positions, and history slices are inserted or rolled back as a single atomic operation. +- **Enforced by:** Supabase fn_persist_entry_lifecycle and exit RPC transactions with UNIQUE constraints (trade_lifecycle(profile_id, trade_id), orders(order_id)). +- **Detection:** DB transaction failure logs surface and rollback leaves no partial data; reconciliation detects orphan orders or missing lifecycle slices. +- **Recovery:** Retry replays through RPCs; reconciliation invokes lifecycle handlers to rebuild missing artifacts; runbooks refer to docs/runbooks/lifecycle-incident.md. + +## Exchange-as-Source-of-Truth Invariant +- **Invariant name:** Exchange truth first +- **Description:** No lifecycle row exists or is updated until the exchange confirms the order or fill status. +- **Enforced by:** Entry/exit flows that place exchange orders before calling persistence RPCs plus reconciliation that corrects DB drift. +- **Detection:** Reconciliation mismatch counters increment when DB differs from exchange; health endpoint flags missing trades. +- **Recovery:** Lifecycle handlers repair DB state based on exchange data; trading loop refrains from acting on stale state; ops follow docs/runbooks/reconciliation.md. + +## Idempotent Retry Invariant +- **Invariant name:** Idempotent lifecycle retries +- **Description:** Retrying the same lifecycle persistence request never creates duplicates nor mutates unintended rows. +- **Enforced by:** RPC idempotency keys, UNIQUE(profile_id, trade_id), ON CONFLICT DO NOTHING on child inserts, and deterministic clientOrderId. +- **Detection:** RPC responses include existing lifecycle references; duplicate insertion attempts are logged without causing errors. +- **Recovery:** Retry safely returns the existing lifecycle; metrics note idempotency hits, and operators refer to docs/runbooks/lifecycle-incident.md only if insert counts grow unusually. + +## Lock TTL Safety Invariant +- **Invariant name:** Lock TTL safety +- **Description:** Distributed locks expire automatically if a worker crashes, preventing deadlocks. +- **Enforced by:** Lock acquisition RPCs that set expires_at = now() + TTL and lock release RPCs requiring owner authentication. +- **Detection:** Lock contention metrics, TTL expiration audit logs, and /internal/health lockContentionCount track stuck entries. +- **Recovery:** TTL expiry frees the lock automatically, reconciliation/self-healing loops resume; operators only intervene when contention spikes, per docs/runbooks/lock-timeout.md. + +## Invariants Enforcement Summary +- DB-enforced invariants: lifecycle atomicity (UNIQUE constraints), capital ledger constraints, lock row TTL persistence. +- Code-enforced invariants: entry lock acquisition, deterministic clientOrderId, reconciliation routing through lifecycle handlers. +- Observability-enforced invariants: capital invariant metric, lock contention counters, reconciliation mismatch counts that trigger alerts when thresholds are crossed. + +## Rules future agents MUST NOT break +Future agents must never touch lifecycle RPCs, ledger services, reconciliation logic, or locking mechanisms without confirming that the invariants listed above remain intact. Breaking capital, lock, lifecycle, or exchange-truth invariants without operator consent is forbidden. diff --git a/backend/list_history.ts b/backend/list_history.ts new file mode 100644 index 0000000..94a27b5 --- /dev/null +++ b/backend/list_history.ts @@ -0,0 +1,27 @@ +import { createClient } from '@supabase/supabase-js'; +import * as dotenv from 'dotenv'; +import path from 'path'; + +dotenv.config({ path: path.resolve(process.cwd(), '.env') }); +const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_KEY!); + +const userId = '8d5efd9e-0760-4859-8c07-0930ab3ede5a'; + +async function listAllHistory() { + console.log(`--- Trade History for ${userId} ---`); + const { data, error } = await supabase + .from('trade_history') + .select('*') + .eq('user_id', userId) + .order('created_at', { ascending: false }); + + if (error) { + console.error('Error:', error); + } else { + data.forEach(h => { + console.log(`[History] Time: ${h.created_at}, Symbol: ${h.symbol}, Reason: ${h.reason}, P&L: ${h.pnl}`); + }); + } +} + +listAllHistory(); diff --git a/backend/live_signal_check.js b/backend/live_signal_check.js new file mode 100644 index 0000000..705021b --- /dev/null +++ b/backend/live_signal_check.js @@ -0,0 +1,23 @@ +const http = require('http'); + +http.get('http://localhost:5000/api/status', (res) => { + let data = ''; + res.on('data', (chunk) => { data += chunk; }); + res.on('end', () => { + try { + const botState = JSON.parse(data); + console.log('--- LIVE BOT ANALYSIS STATUS ---'); + Object.entries(botState.symbols).forEach(([symbol, details]) => { + console.log(`📍 ${symbol}: Signal=${details.signal}`); + Object.entries(details.rules).forEach(([rule, res]) => { + const statusIcon = res.passed ? '✅' : '❌'; + console.log(` ${statusIcon} ${rule}: ${res.reason}`); + }); + }); + } catch (e) { + console.error('Error parsing JSON:', e.message); + } + }); +}).on('error', (err) => { + console.error('Error fetching status:', err.message); +}); diff --git a/backend/manualOverrideCloseTrades.ts b/backend/manualOverrideCloseTrades.ts new file mode 100644 index 0000000..3389623 --- /dev/null +++ b/backend/manualOverrideCloseTrades.ts @@ -0,0 +1,297 @@ +import 'dotenv/config'; +import { createHash, randomUUID } from 'crypto'; +import { config, loadDynamicConfig } from '../src/config/index.js'; +import { normalizeOrderAction, normalizeTradeSide } from '../src/domain/tradingEnums.js'; +import { healthTracker } from '../src/services/healthTracker.js'; +import { + ReconciliationBackfillAuditInsert, + ReconciliationBackfillOrderInsert, + supabaseService +} from '../src/services/SupabaseService.js'; +import { buildAlpacaSubTag } from '../src/utils/alpacaSubTag.js'; + +type CliOptions = { + apply: boolean; + tradeIds: string[]; +}; + +type TradeSnapshot = { + profileId: string; + userId: string; + tradeId: string; + symbol: string; + entrySide: 'BUY' | 'SELL'; + entryQty: number; + exitQty: number; + openQty: number; + entryAvgPrice: number; +}; + +const EPSILON = 1e-8; +const ORDER_ID_PREFIX = 'MANOVR'; + +const parseOptions = (argv: string[]): CliOptions => { + const tradeIds = new Set(); + let apply = false; + + for (const arg of argv) { + if (arg === '--apply') { + apply = true; + continue; + } + if (arg.startsWith('--trade=')) { + const value = String(arg.slice('--trade='.length) || '').trim(); + if (value) tradeIds.add(value); + } + } + + return { + apply, + tradeIds: Array.from(tradeIds) + }; +}; + +const toNumber = (value: unknown): number => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +}; + +const expectedExitSide = (entrySide: 'BUY' | 'SELL'): 'BUY' | 'SELL' => { + return entrySide === 'BUY' ? 'SELL' : 'BUY'; +}; + +const buildManualOverrideOrderId = (profileId: string, tradeId: string): string => { + const digest = createHash('md5') + .update(`${profileId}:${tradeId}:manual_override_v1`) + .digest('hex'); + return `${ORDER_ID_PREFIX}-${digest}`; +}; + +const buildTradeSnapshots = (rows: any[]): Map => { + const byTrade = new Map(); + + for (const row of rows || []) { + const tradeId = String(row.trade_id || '').trim(); + const profileId = String(row.profile_id || '').trim(); + if (!tradeId || !profileId) continue; + + const key = `${profileId}::${tradeId}`; + const qty = toNumber(row.qty ?? row.quantity); + if (!(qty > EPSILON)) continue; + + const side = normalizeTradeSide(String(row.side || 'BUY')); + const action = normalizeOrderAction(row.action || undefined); + const symbol = String(row.symbol || '').trim(); + const userId = String(row.user_id || '').trim(); + const price = toNumber(row.price); + + let snapshot = byTrade.get(key); + if (!snapshot) { + snapshot = { + profileId, + userId, + tradeId, + symbol, + entrySide: side, + entryQty: 0, + exitQty: 0, + openQty: 0, + entryAvgPrice: 0 + }; + byTrade.set(key, snapshot); + } + + const resolvedAction = action || (side === snapshot.entrySide ? 'ENTRY' : 'EXIT'); + if (resolvedAction === 'ENTRY') { + if (!(snapshot.entryQty > EPSILON)) { + snapshot.entrySide = side; + } + const nextQty = snapshot.entryQty + qty; + snapshot.entryAvgPrice = nextQty > EPSILON + ? ((snapshot.entryAvgPrice * snapshot.entryQty) + (price * qty)) / nextQty + : snapshot.entryAvgPrice; + snapshot.entryQty = nextQty; + } else { + snapshot.exitQty += qty; + } + + if (!snapshot.symbol && symbol) snapshot.symbol = symbol; + if (!snapshot.userId && userId) snapshot.userId = userId; + snapshot.openQty = Number((snapshot.entryQty - snapshot.exitQty).toFixed(8)); + } + + for (const [key, snapshot] of Array.from(byTrade.entries())) { + if (!(snapshot.openQty > EPSILON)) { + byTrade.delete(key); + } + } + + return byTrade; +}; + +const run = async (): Promise => { + const options = parseOptions(process.argv.slice(2)); + if (options.tradeIds.length === 0) { + throw new Error('Provide at least one --trade=.'); + } + + await loadDynamicConfig(supabaseService); + + healthTracker.recordTradingControl({ + mode: 'PAUSED', + lastChangedBy: 'maintenance-script', + lastChangedAt: Date.now(), + reason: 'Manual override close cycle' + }); + + const client = supabaseService.getClient(); + if (!client) { + throw new Error('Supabase client is not available.'); + } + + const { data: lifecycleRows, error: lifecycleError } = await client + .from('orders') + .select('profile_id,user_id,trade_id,symbol,side,action,qty,quantity,price,status') + .in('trade_id', options.tradeIds) + .in('status', ['filled', 'partially_filled', 'partially-filled']); + + if (lifecycleError) { + throw new Error(`Failed to fetch lifecycle rows: ${lifecycleError.message}`); + } + + const snapshots = buildTradeSnapshots(lifecycleRows || []); + const batchId = `MANOVR-BATCH-${randomUUID()}`; + const nowIso = new Date().toISOString(); + const nowTs = Date.now(); + + const candidateOrders: ReconciliationBackfillOrderInsert[] = []; + const preAuditRows: ReconciliationBackfillAuditInsert[] = []; + const skipped: Array> = []; + + for (const tradeId of options.tradeIds) { + const matching = Array.from(snapshots.values()).filter((row) => row.tradeId === tradeId); + if (matching.length === 0) { + skipped.push({ tradeId, reason: 'trade_not_open_or_not_found' }); + continue; + } + if (matching.length > 1) { + skipped.push({ tradeId, reason: 'ambiguous_trade_multiple_profiles' }); + continue; + } + + const trade = matching[0]; + if (!(trade.openQty > EPSILON)) { + skipped.push({ tradeId, reason: 'trade_not_open' }); + continue; + } + + const orderId = buildManualOverrideOrderId(trade.profileId, trade.tradeId); + const side = expectedExitSide(trade.entrySide); + const subTag = buildAlpacaSubTag({ + profileId: trade.profileId, + tradeId: trade.tradeId, + intent: 'EXIT' + }) || undefined; + const fillPrice = trade.entryAvgPrice > EPSILON ? trade.entryAvgPrice : 0; + + const order: ReconciliationBackfillOrderInsert = { + user_id: trade.userId, + profile_id: trade.profileId, + order_id: orderId, + symbol: trade.symbol, + type: 'market', + side, + qty: Number(trade.openQty.toFixed(8)), + quantity: Number(trade.openQty.toFixed(8)), + price: Number(fillPrice.toFixed(8)), + status: 'filled', + timestamp: nowTs, + filled_at: nowIso, + trade_id: trade.tradeId, + action: 'EXIT', + source: 'BOT', + sub_tag: subTag + }; + candidateOrders.push(order); + + preAuditRows.push({ + batch_id: batchId, + profile_id: trade.profileId, + symbol: trade.symbol, + trade_id: trade.tradeId, + exchange_order_id: null, + exchange_client_order_id: null, + backfill_order_id: orderId, + filled_qty: order.qty, + filled_price: order.price, + filled_at: order.filled_at || null, + dry_run: !options.apply, + decision: options.apply ? 'MANUAL_OVERRIDE_PENDING' : 'MANUAL_OVERRIDE_DRY', + reason: 'manual_override_user_approved_no_exchange_evidence', + metadata: { + openQtyBefore: trade.openQty, + entryQty: trade.entryQty, + exitQty: trade.exitQty, + fillPriceBasis: 'entry_weighted_avg_price' + } + }); + } + + if (preAuditRows.length > 0) { + const preSaved = await supabaseService.insertReconciliationBackfillAuditRows(preAuditRows); + if (!preSaved) { + throw new Error('Failed to save manual override pre-audit rows.'); + } + } + + let insertedRows = 0; + if (options.apply && candidateOrders.length > 0) { + const orderIds = candidateOrders.map((row) => row.order_id); + const existingBefore = await supabaseService.getExistingOrderIds(orderIds); + const ok = await supabaseService.upsertReconciliationBackfillOrders(candidateOrders); + if (!ok) { + throw new Error('Failed to apply manual override rows.'); + } + const existingAfter = await supabaseService.getExistingOrderIds(orderIds); + insertedRows = candidateOrders.filter((row) => !existingBefore.has(row.order_id) && existingAfter.has(row.order_id)).length; + + const postAuditRows: ReconciliationBackfillAuditInsert[] = candidateOrders.map((row) => ({ + batch_id: batchId, + profile_id: row.profile_id, + symbol: row.symbol, + trade_id: row.trade_id, + exchange_order_id: null, + exchange_client_order_id: null, + backfill_order_id: row.order_id, + filled_qty: row.qty, + filled_price: row.price, + filled_at: row.filled_at || null, + dry_run: false, + decision: existingBefore.has(row.order_id) ? 'MANUAL_OVERRIDE_SKIP_EXISTING' : 'MANUAL_OVERRIDE_APPLIED', + reason: existingBefore.has(row.order_id) ? 'already_exists' : 'manual_override_inserted', + metadata: { + matchedBy: 'manual_override' + }, + applied_at: !existingBefore.has(row.order_id) ? new Date().toISOString() : null + })); + const postSaved = await supabaseService.insertReconciliationBackfillAuditRows(postAuditRows); + if (!postSaved) { + throw new Error('Failed to save manual override post-audit rows.'); + } + } + + console.log(JSON.stringify({ + mode: options.apply ? 'apply' : 'dry-run', + batchId, + requestedTrades: options.tradeIds, + proposedRows: candidateOrders.length, + insertedRows, + skipped + }, null, 2)); +}; + +run().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + console.error(JSON.stringify({ error: message }, null, 2)); + process.exit(1); +}); diff --git a/backend/monitorFreshWindow.ts b/backend/monitorFreshWindow.ts new file mode 100644 index 0000000..e1d9190 --- /dev/null +++ b/backend/monitorFreshWindow.ts @@ -0,0 +1,218 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +type MonitorOptions = { + hours: number; + intervalSec: number; + outFile: string; + healthUrl: string; + stateFile: string; +}; + +type HealthSnapshot = { + tradingLoopHealthy?: boolean; + monitorLoopHealthy?: boolean; + reconciliationLoopHealthy?: boolean; + reconciliationMismatchCount?: number; + reconciliationMissingFromExchange?: number; + reconciliationMissingInDb?: number; + reconciliationNoGoTrades?: number; + reconciliationIntegrityWatchdogTriggered?: boolean; + tradingControl?: { + mode?: string; + lastChangedBy?: string; + lastChangedAt?: number; + reason?: string; + }; +}; + +const toNumber = (value: unknown, fallback: number): number => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +}; + +const parseArgs = (argv: string[]): MonitorOptions => { + let hours = 12; + let intervalSec = 60; + let outFile = ''; + let healthUrl = 'http://127.0.0.1:5000/internal/health'; + let stateFile = path.resolve(process.cwd(), 'bot_state.json'); + + for (const arg of argv) { + if (arg.startsWith('--hours=')) { + hours = Math.max(1, Math.floor(toNumber(arg.slice('--hours='.length), 12))); + continue; + } + if (arg.startsWith('--interval-sec=')) { + intervalSec = Math.max(15, Math.floor(toNumber(arg.slice('--interval-sec='.length), 60))); + continue; + } + if (arg.startsWith('--out=')) { + outFile = String(arg.slice('--out='.length) || '').trim(); + continue; + } + if (arg.startsWith('--health-url=')) { + healthUrl = String(arg.slice('--health-url='.length) || '').trim() || healthUrl; + continue; + } + if (arg.startsWith('--state-file=')) { + const candidate = String(arg.slice('--state-file='.length) || '').trim(); + if (candidate) stateFile = path.resolve(process.cwd(), candidate); + } + } + + if (!outFile) { + const stamp = new Date().toISOString().replace(/[:]/g, '-'); + outFile = path.resolve(process.cwd(), `logs/fresh-window-monitor-${stamp}.jsonl`); + } else { + outFile = path.resolve(process.cwd(), outFile); + } + + return { + hours, + intervalSec, + outFile, + healthUrl, + stateFile + }; +}; + +const wait = async (ms: number): Promise => { + await new Promise((resolve) => setTimeout(resolve, ms)); +}; + +const readStateEventSummary = (stateFile: string, fromTimestampMs: number): { + operationalEventsCount: number; + latestEventType: string; + latestEventSeverity: string; + latestEventMessage: string; + latestEventAt: number; +} => { + try { + if (!fs.existsSync(stateFile)) { + return { + operationalEventsCount: 0, + latestEventType: '', + latestEventSeverity: '', + latestEventMessage: '', + latestEventAt: 0 + }; + } + const raw = fs.readFileSync(stateFile, 'utf8'); + const parsed = JSON.parse(raw); + const allEvents = Array.isArray(parsed?.operationalEvents) ? parsed.operationalEvents : []; + const events = allEvents.filter((row: any) => { + const ts = Number(row?.timestamp || 0); + return ts >= fromTimestampMs; + }); + const latest = events.length > 0 ? events[events.length - 1] : null; + return { + operationalEventsCount: events.length, + latestEventType: String(latest?.type || '').trim(), + latestEventSeverity: String(latest?.severity || '').trim(), + latestEventMessage: String(latest?.message || '').trim(), + latestEventAt: Number(latest?.timestamp || 0) || 0 + }; + } catch { + return { + operationalEventsCount: 0, + latestEventType: 'STATE_READ_ERROR', + latestEventSeverity: 'WARN', + latestEventMessage: 'Failed to parse bot_state.json', + latestEventAt: Date.now() + }; + } +}; + +const appendJsonLine = (file: string, row: Record): void => { + const dir = path.dirname(file); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.appendFileSync(file, `${JSON.stringify(row)}\n`, 'utf8'); +}; + +const fetchHealth = async (healthUrl: string): Promise => { + try { + const response = await fetch(healthUrl, { method: 'GET' }); + if (!response.ok) return null; + const payload = await response.json(); + return payload as HealthSnapshot; + } catch { + return null; + } +}; + +const run = async (): Promise => { + const options = parseArgs(process.argv.slice(2)); + const startedAtMs = Date.now(); + const endAtMs = startedAtMs + (options.hours * 60 * 60 * 1000); + + appendJsonLine(options.outFile, { + type: 'monitor_started', + at: startedAtMs, + iso: new Date(startedAtMs).toISOString(), + options + }); + + while (Date.now() < endAtMs) { + const now = Date.now(); + const health = await fetchHealth(options.healthUrl); + const stateSummary = readStateEventSummary(options.stateFile, startedAtMs); + const mode = String(health?.tradingControl?.mode || 'UNKNOWN').toUpperCase(); + const mismatch = Number(health?.reconciliationMismatchCount || 0); + const missingDb = Number(health?.reconciliationMissingInDb || 0); + const noGo = Number(health?.reconciliationNoGoTrades || 0); + const watchdog = Boolean(health?.reconciliationIntegrityWatchdogTriggered); + const healthyLoops = Boolean(health?.tradingLoopHealthy) && Boolean(health?.monitorLoopHealthy) && Boolean(health?.reconciliationLoopHealthy); + const severity = (mode !== 'RUNNING' || mismatch > 0 || missingDb > 0 || noGo > 0 || watchdog || !healthyLoops) ? 'WARN' : 'INFO'; + + appendJsonLine(options.outFile, { + type: 'monitor_tick', + severity, + at: now, + iso: new Date(now).toISOString(), + mode, + tradingLoopHealthy: Boolean(health?.tradingLoopHealthy), + monitorLoopHealthy: Boolean(health?.monitorLoopHealthy), + reconciliationLoopHealthy: Boolean(health?.reconciliationLoopHealthy), + reconciliationMismatchCount: mismatch, + reconciliationMissingFromExchange: Number(health?.reconciliationMissingFromExchange || 0), + reconciliationMissingInDb: missingDb, + reconciliationNoGoTrades: noGo, + reconciliationIntegrityWatchdogTriggered: watchdog, + tradingControlChangedBy: String(health?.tradingControl?.lastChangedBy || ''), + tradingControlReason: String(health?.tradingControl?.reason || ''), + operationalEventsCount: stateSummary.operationalEventsCount, + latestEventType: stateSummary.latestEventType, + latestEventSeverity: stateSummary.latestEventSeverity, + latestEventMessage: stateSummary.latestEventMessage, + latestEventAt: stateSummary.latestEventAt + }); + + await wait(options.intervalSec * 1000); + } + + const endedAtMs = Date.now(); + appendJsonLine(options.outFile, { + type: 'monitor_finished', + at: endedAtMs, + iso: new Date(endedAtMs).toISOString(), + elapsedSec: Math.floor((endedAtMs - startedAtMs) / 1000) + }); + + console.log(JSON.stringify({ + success: true, + outFile: options.outFile, + startedAt: new Date(startedAtMs).toISOString(), + endedAt: new Date(endedAtMs).toISOString() + }, null, 2)); +}; + +run().catch((error) => { + console.error(JSON.stringify({ + success: false, + error: error instanceof Error ? error.message : String(error) + }, null, 2)); + process.exit(1); +}); diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..42bc079 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,74 @@ +{ + "name": "@bytelyst/trading-backend", + "version": "0.1.0", + "type": "module", + "description": "ByteLyst Trading backend and execution control service", + "main": "index.js", + "scripts": { + "test": "npm run check:websocket-contract && npm run check:session-rule-normalization", + "dev": "tsx src/index.ts", + "build": "tsc", + "typecheck": "tsc --noEmit", + "start": "node dist/index.js", + "check:schema-contract": "tsx verifySchemaContract.ts", + "check:rls-policies": "tsx verifyRlsPolicies.ts", + "check:secret-hygiene": "tsx verifySecretHygiene.ts", + "check:security-guards": "tsx verifySecurityGuards.ts", + "check:tenant-isolation": "tsx verifyTenantIsolation.ts", + "check:trade-executor-lifecycle": "tsx testTradeExecutorLifecycle.ts", + "check:lifecycle-regressions": "tsx testLifecycleRegressions.ts", + "check:order-sync-regressions": "tsx testOrderStatusSyncRegressions.ts", + "check:supabase-order-persistence-regressions": "tsx testSupabaseOrderPersistenceRegressions.ts", + "check:failure-injection": "tsx testFailureInjection.ts", + "check:alpaca-subtag": "tsx testAlpacaSubTag.ts", + "check:strict-capital-guard": "tsx testStrictCapitalGuard.ts", + "check:reconciliation-parity-heartbeat": "tsx testReconciliationParityHeartbeat.ts", + "check:reconciliation-watchdog-auto-resume": "tsx testReconciliationWatchdogAutoResume.ts", + "check:reconciliation-exit-backfill-evidence-guard": "tsx testReconciliationExitBackfillEvidenceGuard.ts", + "check:backtest-isolation": "tsx testBacktestIsolation.ts", + "check:session-rule-normalization": "tsx testSessionRuleNormalization.ts", + "check:websocket-contract": "tsx src/scripts/verifyWebsocketContract.ts", + "coverage:run": "node --loader ts-node/esm runCoverageSuite.ts", + "coverage:full": "npm run coverage:integration", + "coverage:integration": "c8 --all --include=src/**/*.ts --exclude=src/index.ts --exclude=src/scripts/** --reporter=text-summary --reporter=json-summary --reporter=lcov node --loader ts-node/esm runCoverageSuite.ts", + "coverage": "c8 --all --include=src/domain/tradingEnums.ts --include=src/utils/symbolMapper.ts --include=src/connectors/factory.ts --check-coverage --lines 80 --functions 80 --branches 80 --statements 80 --reporter=text-summary --reporter=json-summary --reporter=lcov npx tsx runCriticalCoverageSuite.ts", + "reconcile:lifecycle-history": "node --loader ts-node/esm reconcileTradeHistoryLifecycle.ts", + "reconcile:exit-backfill-once": "node --loader ts-node/esm reconcileExitBackfillOnce.ts", + "reconcile:missing-order-coverage": "node --loader ts-node/esm reconcileMissingOrderCoverage.ts", + "reconcile:closed-order-fill-data": "node --loader ts-node/esm reconcileClosedOrderFillData.ts", + "reconcile:subtag-repair": "node --loader ts-node/esm reconcileSubTagRepair.ts", + "reconcile:attribution-repair": "node --loader ts-node/esm reconcileAttributionRepair.ts", + "reconcile:capital-ledger-state": "node --loader ts-node/esm reconcileCapitalLedgerState.ts", + "lint": "npm run check:schema-contract && npm run check:rls-policies && npm run check:secret-hygiene && npm run check:security-guards && npm run check:tenant-isolation", + "format": "npm run check:trade-executor-lifecycle && npm run check:lifecycle-regressions && npm run check:order-sync-regressions && npm run check:supabase-order-persistence-regressions && npm run check:failure-injection && npm run check:alpaca-subtag && npm run check:strict-capital-guard && npm run check:reconciliation-parity-heartbeat && npm run check:reconciliation-watchdog-auto-resume && npm run check:reconciliation-exit-backfill-evidence-guard && npm run check:backtest-isolation && npm run check:session-rule-normalization && npm run check:websocket-contract", + "check": "npm run build && npm run lint && npm run format", + "pre-deploy": "npm run check", + "cleanup-stale-orders": "tsx src/scripts/cleanupStaleOrders.ts", + "revert-expired-orders": "tsx src/scripts/revertExpiredOrders.ts" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@alpacahq/alpaca-trade-api": "^3.1.3", + "@supabase/supabase-js": "^2.90.1", + "@types/cors": "^2.8.19", + "@types/express": "^5.0.6", + "axios": "^1.13.2", + "ccxt": "^4.5.31", + "cors": "^2.8.5", + "dotenv": "^17.2.3", + "express": "^5.2.1", + "prom-client": "^15.1.3", + "socket.io": "^4.8.3", + "winston": "^3.19.0" + }, + "devDependencies": { + "@types/axios": "^0.14.4", + "@types/node": "^25.0.3", + "c8": "^10.1.3", + "ts-node": "^10.9.2", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/backend/phase8-validation.md b/backend/phase8-validation.md new file mode 100644 index 0000000..658cf10 --- /dev/null +++ b/backend/phase8-validation.md @@ -0,0 +1,60 @@ +# Phase 8 Enterprise Validation Summary + +## 1. Completion Confirmation for Phases 17 +Each phase below has been validated, the enforcement mechanisms are operational, and failure modes have been documented for operators. + +### Phase 1 Tenant Isolation +- **Validation criteria:** WebSocket broadcasts partitioned by authenticated user, Supabase RLS policies on orders and trade_history, and filtered runtime states tied to user_id. +- **Evidence:** RLS policies, tenant metrics, and failure scenarios covering cross-tenant attempts documented in runbooks. + +### Phase 2 Restart Durability +- **Validation criteria:** Startup routine reloads active profiles, exchange open orders/positions, and rebuilds lifecycle/capital maps deterministically. +- **Evidence:** Log traces of profile reload, ledger rebuild metrics, and restart-recovery runbook coverage. + +### Phase 3 Capital Ledger +- **Validation criteria:** Ledger invariant `allocated - reserved_for_orders - reserved_for_positions + realized_pnl >= 0` holds, and ledger rebuilds on restart while supporting entry, fill, cancel, and exit adjustments. +- **Evidence:** Ledger RPCs, capital-invariant metrics, reconciliation ledger sync, and capital-invariant runbook handling violations. + +### Phase 4 Transactional Lifecycle +- **Validation criteria:** ENTRY/EXIT persistence occurs inside single DB transactions with idempotent constraints, ensuring lifecycle data only appears after exchange confirmation. +- **Evidence:** fn_persist_entry_lifecycle, UNIQUE constraints, lifecycle metrics, and lifecycle-incident runbook. + +### Phase 5 Reconciliation +- **Validation criteria:** Loop compares full DB/exchange sets, obtains profile locks, routes discrepancies through lifecycle handlers, and resets mismatch metrics automatically. +- **Evidence:** reconciliation lock table, mismatch metrics, reconciliation runbook, and failure scenarios for missing/excess orders. + +### Phase 6 Distributed Safety +- **Validation criteria:** Row-based entry locks with TTL and owner tokens combined with deterministic clientOrderId prevent duplicate entries and survive multi-instance deployments. +- **Evidence:** entry_locks table, lock metrics (contention and latency), and lock-contention runbook. + +### Phase 7 Observability & Health +- **Validation criteria:** /metrics and /internal/health track trading, monitor, reconciliation loops alongside lock, capital, and exchange health, permitting rapid detection of failures. +- **Evidence:** health endpoint fields, Prometheus metrics, structured logs, and loop-health runbook. + +## 2. Final Checklist Mapping +| Requirement | Enforcement | Detection | Recovery | +|-------------|-------------|-----------|----------| +| Tenant isolation | Supabase RLS, WebSocket filters | RLS audit logs, tenant breach alerts | Reject broadcast, block execution, notify compliance | +| Capital invariant | Ledger RPCs + watcher | `capitalInvariantViolations`, gauge dips | Reconciliation rebuild, trading loop pause for profile | +| Lifecycle atomicity | Entry/exit RPC transactions + UNIQUE constraints | DB transaction failures, reconciliation mismatch | RPC retry, lifecycle handler repair | +| Exchange truth | Exchange-first ordering before persistence | Reconciliation mismatch count, missing metrics | Lifecycle handler corrections | +| Entry exclusivity | Row-based locks + deterministic clientOrderId | `lockContentionCount`, lock TTL expiration | Wait TTL, reattempt, escalate via lock-contention runbook | +| Reconciliation parity | Lock + handler routing | `reconciliationMismatchCount`, missing metrics | Handler-guided correction, metrics reset | +| Observability coverage | Prometheus counters + health endpoint | `/internal/health` flags, metric spikes | Alerts trigger runbooks, loops restart if needed | + +## 3. System Guarantees +- Capital never goes negative for active profiles thanks to ledger invariant enforcement and capital watchdog metrics. +- Only one ENTRY per profile/symbol proceeds at a time due to distributed row locks and deterministic clientOrderId. +- Lifecycle persistence is atomic, always triggered after confirmed exchange events, and idempotent on retries. +- Database state converges to exchange truth through reconciliation and structured health metrics, providing eventual consistency. +- Observability ensures loops, locks, and capital invariants are monitored and documented for operators. + +## 4. Explicit Non-Guarantees +- Does not guarantee immediate dashboard synchronization; reconciliation is eventual. +- Does not guarantee tolerance for prolonged Supabase outages without ops interventiontrading pauses until Supabase is writable. +- Does not permit ad-hoc manual ledger or lifecycle edits; such edits require formal runbook approval. + +## 5. Future Change Safety +- Any future agent must review docs/invariants.md and these runbooks before touching lifecycle RPCs, ledger services, reconciliation logic, or locking mechanisms. +- Changes must preserve the phase-by-phase guarantees and maintain all health/metrics fields referenced herein. +- Operators should verify that metrics and health indicators remain functional after updates, updating runbooks if detection/recovery steps change. diff --git a/backend/pre-deploy.md b/backend/pre-deploy.md new file mode 100644 index 0000000..4b73a3c --- /dev/null +++ b/backend/pre-deploy.md @@ -0,0 +1,142 @@ +# Pre-Deploy Validation + +## Contract + +Running `npm run check` guarantees: + +✅ **Build succeeds** - TypeScript compilation passes with no errors +✅ **Security checks pass** - Schema, RLS, secrets, guards, tenant isolation verified +✅ **Lifecycle checks pass** - Trade executor, lifecycle, order sync, persistence, failure injection, WebSocket contract verified + +❌ **Does NOT guarantee**: +- Unit test coverage (no unit tests configured) +- Runtime behavior +- Database connectivity +- External API availability + +--- + +## Pre-Deploy Command + +```bash +npm run check +``` + +**What it runs**: +1. `npm run build` - TypeScript compilation +2. `npm run lint` - Security checks (schema, RLS, secrets, guards, tenant) +3. `npm run format` - Lifecycle checks (executor, sync, persistence, injection, WebSocket) + +--- + +## Individual Check Commands + +### Build Check +```bash +npm run build +``` +**Pass criteria**: TypeScript compiles with 0 errors +**Fail criteria**: Any TypeScript error + +### Security Checks (Lint) +```bash +npm run lint +``` +**Runs**: +- `check:schema-contract` - Database schema matches code expectations +- `check:rls-policies` - Row-level security policies are correct +- `check:secret-hygiene` - No secrets in code, env vars configured +- `check:security-guards` - Auth guards in place +- `check:tenant-isolation` - Multi-tenant data isolation verified + +**Pass criteria**: All 5 checks pass +**Fail criteria**: Any check fails + +### Lifecycle Checks (Format) +```bash +npm run format +``` +**Runs**: +- `check:trade-executor-lifecycle` - Trade execution flow works +- `check:lifecycle-regressions` - No regressions in trade lifecycle +- `check:order-sync-regressions` - Order status sync works +- `check:supabase-order-persistence-regressions` - Orders persist correctly +- `check:failure-injection` - System handles failures gracefully +- `check:websocket-contract` - WebSocket events match contract + +**Pass criteria**: All 6 checks pass +**Fail criteria**: Any check fails + +--- + +## Pass/Fail Rules + +### ✅ SAFE TO DEPLOY +All of the following must be true: +- `npm run build` exits with code 0 +- `npm run lint` exits with code 0 +- `npm run format` exits with code 0 + +### ❌ DO NOT DEPLOY +If any of the following are true: +- `npm run build` fails (TypeScript errors) +- `npm run lint` fails (security check failed) +- `npm run format` fails (lifecycle check failed) + +--- + +## Current Issues (Must Fix Before Deploy) + +⚠️ **Build is currently failing**: +``` +src/scripts/verifyWebsocketContract.ts:83:5 - error TS2741: +Property 'health' is missing in type 'BotState' +``` + +**Action required**: Fix TypeScript error in `verifyWebsocketContract.ts` before deploying + +--- + +## Quick Reference + +| Command | Purpose | Time | Critical | +|---------|---------|------|----------| +| `npm run build` | Compile TypeScript | ~10s | YES | +| `npm run lint` | Security checks | ~30s | YES | +| `npm run format` | Lifecycle checks | ~45s | YES | +| `npm run check` | All checks | ~90s | YES | + +--- + +## Troubleshooting + +### Build fails +- Check TypeScript errors in output +- Fix type errors in code +- Ensure all imports are correct + +### Lint fails +- Check which security check failed +- Review error output +- Fix schema/RLS/secret/guard/tenant issue + +### Format fails +- Check which lifecycle check failed +- Review error output +- Fix trade executor/sync/persistence issue + +--- + +## Notes + +- **No unit tests**: This project uses integration-style checks instead of unit tests +- **Checks are mandatory**: All checks must pass before deploy +- **No shortcuts**: Do not skip checks or deploy with failures +- **Independent**: This project's checks are independent of the frontend + +--- + +## Related + +- Frontend pre-deploy: `../bytelyst-trading-dashboard-web/docs/pre-deploy.md` +- Deployment script: `deploy.ps1` diff --git a/backend/print_btc_rules.ts b/backend/print_btc_rules.ts new file mode 100644 index 0000000..2378195 --- /dev/null +++ b/backend/print_btc_rules.ts @@ -0,0 +1,5 @@ + +import fs from 'fs'; +const state = JSON.parse(fs.readFileSync('bot_state.json', 'utf8')); +const btc = state.symbols['BTC/USDT']; +console.log(JSON.stringify(btc.rules, null, 2)); diff --git a/backend/print_symbols.js b/backend/print_symbols.js new file mode 100644 index 0000000..ff7ab95 --- /dev/null +++ b/backend/print_symbols.js @@ -0,0 +1,9 @@ +import fs from 'fs'; + +try { + const data = JSON.parse(fs.readFileSync('full_status.json', 'utf8')); + console.log('--- LIVE SYMBOLS IN BOT ---'); + console.log(Object.keys(data.symbols).join(', ')); +} catch (e) { + console.log('Failed to parse status file'); +} diff --git a/backend/prometheus-metrics.md b/backend/prometheus-metrics.md new file mode 100644 index 0000000..b11ee6d --- /dev/null +++ b/backend/prometheus-metrics.md @@ -0,0 +1,66 @@ + +# Prometheus Metrics Guide + +The Bytelyst Trading Bot exposes a `/metrics` Prometheus endpoint for advanced monitoring and Grafana integration. + +## Scraping Configuration +The metrics are available at `http://:5000/metrics`. + +## Exported Metrics + +### System Health & Events +- `bytelyst_bot_operational_events_total` (Counter) + - **Description**: Cumulative count of system events (orders, errors, warnings). + - **Labels**: `severity`, `type`, `profile_id`, `symbol`, `env`, `mode`. + - **Usage**: Monitor for spikes in `severity="ERROR"` or `type="ORDER_FAILURE"`. + +### Subsystem Performance +- `bytelyst_bot_subsystem_duration_seconds` (Histogram) + - **Description**: Execution time for core loops (trading, monitor, reconciliation). + - **Labels**: `subsystem`, `env`, `mode`. + - **Buckets**: `[0.1, 0.25, 0.5, 1, 2, 5, 10]`. + - **Usage**: Identify slow execution cycles or database contention. + +- `bytelyst_bot_subsystem_last_run_timestamp` (Gauge) + - **Description**: Unix timestamp of the last successful subsystem run. + - **Labels**: `subsystem`, `env`, `mode`. + - **Usage**: Verify how recently each process checked in. + +- `bytelyst_bot_subsystem_alive` (Gauge) + - **Description**: Binary flag indicating if a subsystem is fresh (1) or stalled (0). + - **Labels**: `subsystem`, `env`, `mode`. + - **Usage**: Critical dashboard "Traffic Light" indicator. + +### Exchange Connectivity +- `bytelyst_bot_exchange_api_latency_seconds` (Histogram) + - **Description**: Latency of external API calls to the exchange. + - **Labels**: `exchange`, `operation`, `env`, `mode`. + - **Usage**: Distinguish between internal bot lag and exchange infrastructure lag. + +### Risk & Capital Invariants +- `bytelyst_bot_capital_invariant_violations_total` (Counter) + - **Description**: Count of times available capital fell below zero for a profile. + - **Labels**: `profile_id`, `env`, `mode`. + - **Usage**: Critical alert metric. Should always be 0. + +- `bytelyst_bot_profile_utilization_percent` (Gauge) + - **Description**: Percentage of allocated capital currently in use (positions + open orders). + - **Labels**: `profile_id`, `env`, `mode`. + - **Usage**: Monitor capital efficiency and exposure. + +### Data Integrity (Reconciliation) +- `bytelyst_bot_reconciliation_mismatches_total` (Counter) + - **Description**: Count of detected mismatches between local state and exchange state. + - **Labels**: `env`, `mode`. + - **Usage**: Track consistency of the "single source of truth." + +- `bytelyst_bot_reconciliation_missing_items_count` (Gauge) + - **Description**: Number of missing orders/positions in the last sync cycle. + - **Labels**: `source` (db/exchange), `env`, `mode`. + - **Usage**: Identify synchronization drift. + +## Default Labels +Every metric is automatically tagged with: +- `env`: `development` or `production` (from `NODE_ENV`). +- `mode`: `paper` or `live` (from `PAPER_TRADING`). +- `app`: `bytelyst-trading-bot-service`. diff --git a/backend/prometheus.yml b/backend/prometheus.yml new file mode 100644 index 0000000..3f7c86b --- /dev/null +++ b/backend/prometheus.yml @@ -0,0 +1,10 @@ + +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: 'alert_bot' + static_configs: + - targets: ['alert_bot:5000'] + metrics_path: '/metrics' diff --git a/backend/proposed_risk_profiles.json b/backend/proposed_risk_profiles.json new file mode 100644 index 0000000..28b58c5 --- /dev/null +++ b/backend/proposed_risk_profiles.json @@ -0,0 +1,417 @@ +{ + "Aggressive_70_Percent_Voting": { + "name": "Aggressive Test (70% Voting)", + "riskLevel": 5, + "allocatedCapital": 5000, + "strategy_config": { + "rules": [ + { + "ruleId": "TrendBiasRule", + "enabled": true, + "params": { + "emaFast": 50, + "emaSlow": 200 + } + }, + { + "ruleId": "SessionRule", + "enabled": true, + "params": { + "allowedSessions": [ + "NY", + "LDN" + ] + } + }, + { + "ruleId": "ZoneRule", + "enabled": true, + "params": { + "emaPeriod": 20 + } + }, + { + "ruleId": "MomentumRule", + "enabled": true, + "params": { + "rsiPeriod": 14 + } + }, + { + "ruleId": "EntryTriggerRule", + "enabled": true, + "params": {} + }, + { + "ruleId": "RiskManagementRule", + "enabled": true, + "params": { + "atrPeriod": 14 + } + }, + { + "ruleId": "AIAnalysisRule", + "enabled": false, + "params": { + "minConfidence": 80 + } + } + ], + "riskLimits": { + "maxDailyLossUsd": 50, + "dailyProfitTargetUsd": 100, + "maxOpenTrades": 2, + "maxConsecutiveLosses": 2 + }, + "execution": { + "orderType": "market", + "cooldownMinutes": 30, + "minRulePassRatio": 0.7, + "entryMode": "both" + } + } + }, + "Risk5_VeryAggressive": { + "name": "Alpha Scalper", + "riskLevel": 5, + "allocatedCapital": 5000, + "strategy_config": { + "rules": [ + { + "ruleId": "TrendBiasRule", + "enabled": true, + "params": { + "timeframe": "1h", + "emaFast": 20, + "emaSlow": 50 + } + }, + { + "ruleId": "SessionRule", + "enabled": false + }, + { + "ruleId": "ZoneRule", + "enabled": true, + "params": { + "emaPeriod": 20, + "tolerancePercent": 1.5 + } + }, + { + "ruleId": "MomentumRule", + "enabled": true, + "params": { + "timeframe": "15m", + "rsiPeriod": 7, + "overbought": 80, + "oversold": 20 + } + }, + { + "ruleId": "EntryTriggerRule", + "enabled": true, + "params": { + "timeframe": "15m", + "wickRatioThreshold": 0.3 + } + }, + { + "ruleId": "RiskManagementRule", + "enabled": true, + "params": { + "slMultiplier": 1.0, + "maxRisk": 5.0 + } + } + ], + "riskLimits": { + "maxDailyLossUsd": 500, + "maxConsecutiveLosses": 5, + "maxOpenTrades": 10 + }, + "execution": { + "orderType": "market", + "cooldownMinutes": 5, + "entryMode": "both" + } + } + }, + "Risk4_Aggressive": { + "name": "Active Swing", + "riskLevel": 4, + "allocatedCapital": 5000, + "strategy_config": { + "rules": [ + { + "ruleId": "TrendBiasRule", + "enabled": true, + "params": { + "timeframe": "1h", + "emaFast": 50, + "emaSlow": 100 + } + }, + { + "ruleId": "SessionRule", + "enabled": false + }, + { + "ruleId": "ZoneRule", + "enabled": true, + "params": { + "emaPeriod": 20, + "tolerancePercent": 1.0 + } + }, + { + "ruleId": "MomentumRule", + "enabled": true, + "params": { + "timeframe": "15m", + "rsiPeriod": 14, + "overbought": 75, + "oversold": 25 + } + }, + { + "ruleId": "EntryTriggerRule", + "enabled": true, + "params": { + "timeframe": "15m" + } + }, + { + "ruleId": "RiskManagementRule", + "enabled": true, + "params": { + "slMultiplier": 1.2, + "maxRisk": 3.0 + } + } + ], + "riskLimits": { + "maxDailyLossUsd": 300, + "maxConsecutiveLosses": 4, + "maxOpenTrades": 7 + }, + "execution": { + "orderType": "market", + "cooldownMinutes": 15, + "entryMode": "both" + } + } + }, + "Risk3_Balanced": { + "name": "Balanced Core", + "riskLevel": 3, + "allocatedCapital": 5000, + "strategy_config": { + "rules": [ + { + "ruleId": "TrendBiasRule", + "enabled": true, + "params": { + "timeframe": "4h", + "emaFast": 50, + "emaSlow": 200 + } + }, + { + "ruleId": "SessionRule", + "enabled": true, + "params": { + "allowedSessions": [ + "NY", + "LDN" + ] + } + }, + { + "ruleId": "ZoneRule", + "enabled": true, + "params": { + "emaPeriod": 20, + "tolerancePercent": 0.5 + } + }, + { + "ruleId": "MomentumRule", + "enabled": true, + "params": { + "timeframe": "1h", + "rsiPeriod": 14, + "overbought": 70, + "oversold": 30 + } + }, + { + "ruleId": "EntryTriggerRule", + "enabled": true, + "params": { + "triggerType": "ema_cross" + } + }, + { + "ruleId": "RiskManagementRule", + "enabled": true, + "params": { + "slMultiplier": 1.5, + "maxRisk": 2.0 + } + } + ], + "riskLimits": { + "maxDailyLossUsd": 150, + "maxConsecutiveLosses": 3, + "maxOpenTrades": 5 + }, + "execution": { + "orderType": "market", + "cooldownMinutes": 30, + "entryMode": "both" + } + } + }, + "Risk2_Conservative": { + "name": "Conservative Guard", + "riskLevel": 2, + "allocatedCapital": 5000, + "strategy_config": { + "rules": [ + { + "ruleId": "TrendBiasRule", + "enabled": true, + "params": { + "timeframe": "4h", + "emaFast": 50, + "emaSlow": 200 + } + }, + { + "ruleId": "SessionRule", + "enabled": true, + "params": { + "allowedSessions": [ + "LDN", + "NY" + ] + } + }, + { + "ruleId": "ZoneRule", + "enabled": true, + "params": { + "emaPeriod": 50, + "tolerancePercent": 0.3 + } + }, + { + "ruleId": "MomentumRule", + "enabled": true, + "params": { + "timeframe": "1h", + "overbought": 65, + "oversold": 35 + } + }, + { + "ruleId": "EntryTriggerRule", + "enabled": true, + "params": { + "wickRatioThreshold": 0.6 + } + }, + { + "ruleId": "RiskManagementRule", + "enabled": true, + "params": { + "slMultiplier": 2.0, + "maxRisk": 1.0 + } + } + ], + "riskLimits": { + "maxDailyLossUsd": 100, + "maxConsecutiveLosses": 2, + "maxOpenTrades": 3 + }, + "execution": { + "orderType": "market", + "cooldownMinutes": 60, + "entryMode": "both" + } + } + }, + "Risk1_VeryConservative": { + "name": "Ultra Defense", + "riskLevel": 1, + "allocatedCapital": 5000, + "strategy_config": { + "rules": [ + { + "ruleId": "TrendBiasRule", + "enabled": true, + "params": { + "timeframe": "4h", + "emaFast": 100, + "emaSlow": 200 + } + }, + { + "ruleId": "SessionRule", + "enabled": true, + "params": { + "allowedSessions": [ + "LDN", + "NY" + ] + } + }, + { + "ruleId": "ZoneRule", + "enabled": true, + "params": { + "emaPeriod": 50, + "tolerancePercent": 0.2 + } + }, + { + "ruleId": "MomentumRule", + "enabled": true, + "params": { + "timeframe": "1h", + "rsiPeriod": 21, + "overbought": 60, + "oversold": 40 + } + }, + { + "ruleId": "EntryTriggerRule", + "enabled": true, + "params": { + "wickRatioThreshold": 0.7 + } + }, + { + "ruleId": "RiskManagementRule", + "enabled": true, + "params": { + "slMultiplier": 2.5, + "maxRisk": 0.5 + } + } + ], + "riskLimits": { + "maxDailyLossUsd": 50, + "maxConsecutiveLosses": 1, + "maxOpenTrades": 2 + }, + "execution": { + "orderType": "market", + "cooldownMinutes": 120, + "entryMode": "both" + } + } + } +} \ No newline at end of file diff --git a/backend/quick_eth.ts b/backend/quick_eth.ts new file mode 100644 index 0000000..880467f --- /dev/null +++ b/backend/quick_eth.ts @@ -0,0 +1,19 @@ + +import { ConnectorFactory } from '../src/connectors/factory.js'; +import { Indicators } from '../src/utils/indicators.js'; + +async function verifyETH() { + const exchange = ConnectorFactory.getCustomConnector('ccxt', '', ''); + const symbol = 'ETH/USDT'; + + console.log(`\n--- 📊 ETH ANALYSIS ---`); + const candles4h = await exchange.fetchOHLCV(symbol, '4h', 100); + const ema50_4h = Indicators.calculateEMA(candles4h.map(c => c.close), 50); + const last4h = candles4h[candles4h.length - 1]; + + console.log(`ETH 4H Price: $${last4h.close}`); + console.log(`ETH 4H EMA50: $${ema50_4h.toFixed(2)}`); + console.log(`Trend: ${last4h.close > ema50_4h ? 'BULLISH' : 'BEARISH'}`); +} + +verifyETH(); diff --git a/backend/reconcileAlpacaVsSupabase.ts b/backend/reconcileAlpacaVsSupabase.ts new file mode 100644 index 0000000..9b92b35 --- /dev/null +++ b/backend/reconcileAlpacaVsSupabase.ts @@ -0,0 +1,847 @@ +import 'dotenv/config'; +import Alpaca from '@alpacahq/alpaca-trade-api'; +import { createClient } from '@supabase/supabase-js'; + +type OrderRow = { + id?: string; + order_id?: string; + profile_id?: string | null; + symbol?: string; + side?: string; + type?: string; + qty?: number | string | null; + quantity?: number | string | null; + price?: number | string | null; + status?: string; + timestamp?: number | string | null; + created_at?: string | null; + trade_id?: string | null; + action?: string | null; + source?: string | null; +}; + +type HistoryRow = { + id?: string; + profile_id?: string | null; + trade_id?: string | null; + symbol?: string; + side?: string; + size?: number | string | null; + entry_price?: number | string | null; + exit_price?: number | string | null; + pnl?: number | string | null; + pnl_percent?: number | string | null; + reason?: string | null; + source?: string | null; + timestamp?: number | string | null; + created_at?: string | null; +}; + +type AlpacaOrder = { + id?: string; + client_order_id?: string; + symbol?: string; + side?: string; + qty?: number | string; + filled_qty?: number | string; + type?: string; + limit_price?: number | string | null; + filled_avg_price?: number | string | null; + status?: string; + submitted_at?: string; + filled_at?: string | null; +}; + +type ReconciliationSample = Record; + +const cliArgs = process.argv.slice(2); +const positionalArgs = cliArgs.filter((arg) => !arg.startsWith('--')); +const LOOKBACK_DAYS = Number(positionalArgs[0] || '7'); +const MAX_SAMPLES = Number(positionalArgs[1] || '12'); +const carryLookbackArg = cliArgs.find((arg) => arg.startsWith('--carry-lookback-days=')); +const CARRY_IN_LOOKBACK_DAYS = Number( + (carryLookbackArg ? carryLookbackArg.split('=')[1] : '') + || process.env.RECONCILE_CARRY_IN_LOOKBACK_DAYS + || '90' +); +const PAGE_SIZE = 1000; +const ALPACA_PAGE_LIMIT = 500; +const MAX_ALPACA_PAGES = 60; + +const supabaseUrl = String(process.env.SUPABASE_URL || '').trim(); +const supabaseKey = String( + process.env.SUPABASE_KEY || + process.env.SUPABASE_SERVICE_ROLE_KEY || + process.env.SUPABASE_ANON_KEY || + '' +).trim(); +const alpacaApiKey = String(process.env.ALPACA_API_KEY || '').trim(); +const alpacaApiSecret = String(process.env.ALPACA_API_SECRET || '').trim(); +const paperTrading = String(process.env.PAPER_TRADING || 'true').toLowerCase() === 'true'; + +if (!supabaseUrl || !supabaseKey) { + throw new Error('Missing Supabase credentials. Expected SUPABASE_URL + SUPABASE_KEY/SUPABASE_SERVICE_ROLE_KEY.'); +} +if (!alpacaApiKey || !alpacaApiSecret) { + throw new Error('Missing Alpaca credentials. Expected ALPACA_API_KEY + ALPACA_API_SECRET.'); +} +if (!Number.isFinite(LOOKBACK_DAYS) || LOOKBACK_DAYS <= 0) { + throw new Error(`Invalid lookback days: ${positionalArgs[0] || process.argv[2]}`); +} +if (!Number.isFinite(CARRY_IN_LOOKBACK_DAYS) || CARRY_IN_LOOKBACK_DAYS <= 0) { + throw new Error(`Invalid carry lookback days: ${carryLookbackArg || process.env.RECONCILE_CARRY_IN_LOOKBACK_DAYS}`); +} + +const supabase = createClient(supabaseUrl, supabaseKey); +const alpaca = new (Alpaca as any)({ + keyId: alpacaApiKey, + secretKey: alpacaApiSecret, + paper: paperTrading +}); + +const now = new Date(); +const startDate = new Date(now.getTime() - LOOKBACK_DAYS * 24 * 60 * 60 * 1000); +const carryStartDate = new Date(startDate.getTime() - CARRY_IN_LOOKBACK_DAYS * 24 * 60 * 60 * 1000); +const startIso = startDate.toISOString(); +const carryStartIso = carryStartDate.toISOString(); +const nowIso = now.toISOString(); + +const toNumber = (value: unknown): number => { + const num = Number(value); + return Number.isFinite(num) ? num : 0; +}; + +const toTimestampMs = (value: unknown, fallback: number = 0): number => { + if (typeof value === 'number') { + return value > 1_000_000_000_000 ? value : value * 1000; + } + if (typeof value === 'string') { + if (/^\d+(\.\d+)?$/.test(value.trim())) { + return toTimestampMs(Number(value.trim()), fallback); + } + const parsed = Date.parse(value); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + return fallback; +}; + +const normalizeStatus = (status: string | undefined | null): string => { + const s = String(status || '').trim().toLowerCase(); + if (s === 'filled') return 'filled'; + if (s === 'partially_filled' || s === 'partiallyfilled' || s === 'partial_fill') return 'partially_filled'; + if (s === 'canceled' || s === 'cancelled') return 'canceled'; + if (s === 'expired') return 'expired'; + if (s === 'rejected') return 'rejected'; + if (s === 'unknown') return 'unknown'; + return 'pending_new'; +}; + +const normalizeSide = (side: string | undefined | null): 'BUY' | 'SELL' => { + const s = String(side || '').trim().toUpperCase(); + return s === 'SELL' || s === 'SHORT' ? 'SELL' : 'BUY'; +}; + +const normalizeSymbol = (symbol: string | undefined | null): string => { + const raw = String(symbol || '').trim().toUpperCase().replace(/[\/\-_]/g, ''); + if (raw.endsWith('USDT')) { + return `${raw.slice(0, -4)}USD`; + } + return raw; +}; + +const getOrderQty = (row: OrderRow): number => { + const qty = toNumber(row.qty); + if (qty > 0) return qty; + return toNumber(row.quantity); +}; + +const pctDiff = (left: number, right: number): number => { + const denom = Math.max(Math.abs(left), Math.abs(right), 1e-9); + return Math.abs(left - right) / denom; +}; + +const pushSample = (bucket: ReconciliationSample[], sample: ReconciliationSample) => { + if (bucket.length < MAX_SAMPLES) bucket.push(sample); +}; + +const isQuarantinedHistoryReason = (reason: string | undefined | null): boolean => { + const normalized = String(reason || '').trim().toUpperCase(); + return normalized.startsWith('[INVALID_') + || normalized.startsWith('[DUPLICATE_') + || normalized.startsWith('[RECONCILED_TO_'); +}; + +const getSubmittedTsMs = (order: AlpacaOrder): number => toTimestampMs(order.submitted_at || 0, 0); +const getFillTsMs = (order: AlpacaOrder): number => toTimestampMs(order.filled_at || order.submitted_at || 0, 0); + +const isFilledExecutionOrder = (order: AlpacaOrder): boolean => { + const filledQty = toNumber(order.filled_qty); + const filledPrice = toNumber(order.filled_avg_price); + if (filledQty <= 0 || filledPrice <= 0) return false; + const status = normalizeStatus(order.status); + return status === 'filled' || status === 'partially_filled'; +}; + +const statusBreakdown = (rows: Array<{ status?: string | null }>): Record => { + const out: Record = {}; + for (const row of rows) { + const status = normalizeStatus(row.status || undefined); + out[status] = (out[status] || 0) + 1; + } + return out; +}; + +const fetchPaged = async ( + table: 'orders' | 'trade_history', + columns: string, + startIsoFilter: string +): Promise => { + const rows: T[] = []; + let offset = 0; + + while (true) { + const { data, error } = await supabase + .from(table) + .select(columns) + .gte('created_at', startIsoFilter) + .order('created_at', { ascending: false }) + .range(offset, offset + PAGE_SIZE - 1); + + if (error) { + throw error; + } + + const chunk = (data || []) as T[]; + if (!chunk.length) break; + rows.push(...chunk); + if (chunk.length < PAGE_SIZE) break; + offset += PAGE_SIZE; + } + + return rows; +}; + +const fetchSupabaseOrders = async (startIsoFilter: string = startIso): Promise => { + const v2 = 'id,order_id,profile_id,symbol,type,side,qty,quantity,price,status,timestamp,created_at,trade_id,action,source'; + const legacy = 'id,order_id,profile_id,symbol,type,side,qty,price,status,timestamp,created_at,trade_id,action,source'; + try { + return await fetchPaged('orders', v2, startIsoFilter); + } catch (error: any) { + const msg = String(error?.message || '').toLowerCase(); + if (msg.includes('column') && msg.includes('quantity')) { + return await fetchPaged('orders', legacy, startIsoFilter); + } + throw error; + } +}; + +const fetchTradeHistory = async (): Promise => { + const v2 = 'id,profile_id,trade_id,symbol,side,size,entry_price,exit_price,pnl,pnl_percent,reason,source,timestamp,created_at'; + const v1 = 'id,profile_id,symbol,side,size,entry_price,exit_price,pnl,pnl_percent,reason,timestamp,created_at'; + try { + return await fetchPaged('trade_history', v2, startIso); + } catch (error: any) { + const msg = String(error?.message || '').toLowerCase(); + if (msg.includes('column') && (msg.includes('trade_id') || msg.includes('source'))) { + return await fetchPaged('trade_history', v1, startIso); + } + throw error; + } +}; + +type PositionState = { qty: number; avg: number }; +type PositionSnapshot = Record; +type AlpacaRealizedPnlResult = { + totalRealized: number; + perSymbol: Record; + openingPositions: PositionSnapshot; + endingPositions: PositionSnapshot; + openingExposureNotional: number; + endingExposureNotional: number; + preWindowFillCount: number; + inWindowFillCount: number; +}; + +const cloneStateMap = (source: Map): Map => { + const cloned = new Map(); + for (const [symbol, state] of source.entries()) { + cloned.set(symbol, { qty: state.qty, avg: state.avg }); + } + return cloned; +}; + +const snapshotStateMap = (stateBySymbol: Map): { + positions: PositionSnapshot; + exposureNotional: number; +} => { + const positions: PositionSnapshot = {}; + let exposureNotional = 0; + + for (const [symbol, state] of stateBySymbol.entries()) { + const qty = Number(state.qty || 0); + const avg = Number(state.avg || 0); + if (!Number.isFinite(qty) || !Number.isFinite(avg) || Math.abs(qty) <= 1e-12) continue; + const notional = qty * avg; + exposureNotional += Math.abs(notional); + positions[symbol] = { + qty: Number(qty.toFixed(8)), + avg: Number(avg.toFixed(8)), + notional: Number(notional.toFixed(8)) + }; + } + + return { + positions, + exposureNotional: Number(exposureNotional.toFixed(8)) + }; +}; + +const applyFilledOrderToState = ( + stateBySymbol: Map, + order: AlpacaOrder, + onRealized?: (symbol: string, pnl: number) => void +): void => { + const symbol = normalizeSymbol(order.symbol); + const side = normalizeSide(order.side); + const qty = toNumber(order.filled_qty); + const price = toNumber(order.filled_avg_price); + if (!(qty > 0 && price > 0)) return; + + const current = stateBySymbol.get(symbol) || { qty: 0, avg: 0 }; + let positionQty = current.qty; + let avg = current.avg; + + if (side === 'BUY') { + if (positionQty >= 0) { + const newQty = positionQty + qty; + avg = newQty > 0 ? ((avg * positionQty) + (price * qty)) / newQty : 0; + positionQty = newQty; + } else { + const closeQty = Math.min(qty, Math.abs(positionQty)); + if (closeQty > 0) { + onRealized?.(symbol, (avg - price) * closeQty); + positionQty += closeQty; + } + const remainQty = qty - closeQty; + if (remainQty > 0) { + positionQty = remainQty; + avg = price; + } else if (Math.abs(positionQty) < 1e-12) { + positionQty = 0; + avg = 0; + } + } + } else { + if (positionQty <= 0) { + const baseQty = Math.abs(positionQty); + const newBaseQty = baseQty + qty; + avg = newBaseQty > 0 ? ((avg * baseQty) + (price * qty)) / newBaseQty : 0; + positionQty = -newBaseQty; + } else { + const closeQty = Math.min(qty, positionQty); + if (closeQty > 0) { + onRealized?.(symbol, (price - avg) * closeQty); + positionQty -= closeQty; + } + const remainQty = qty - closeQty; + if (remainQty > 0) { + positionQty = -remainQty; + avg = price; + } else if (Math.abs(positionQty) < 1e-12) { + positionQty = 0; + avg = 0; + } + } + } + + stateBySymbol.set(symbol, { qty: positionQty, avg }); +}; + +const calculateAlpacaRealizedPnl = ( + allOrders: AlpacaOrder[], + windowStartMs: number, + windowEndMs: number +): AlpacaRealizedPnlResult => { + const sortedFills = [...allOrders] + .filter((order) => isFilledExecutionOrder(order)) + .sort((a, b) => getFillTsMs(a) - getFillTsMs(b)); + + const openingState = new Map(); + let preWindowFillCount = 0; + for (const order of sortedFills) { + const fillTs = getFillTsMs(order); + if (fillTs <= 0 || fillTs >= windowStartMs) continue; + applyFilledOrderToState(openingState, order); + preWindowFillCount += 1; + } + + const openingSnapshot = snapshotStateMap(openingState); + const currentState = cloneStateMap(openingState); + let totalRealized = 0; + const perSymbol: Record = {}; + let inWindowFillCount = 0; + + for (const order of sortedFills) { + const fillTs = getFillTsMs(order); + if (fillTs < windowStartMs || fillTs > windowEndMs) continue; + inWindowFillCount += 1; + applyFilledOrderToState(currentState, order, (symbol, pnl) => { + totalRealized += pnl; + perSymbol[symbol] = (perSymbol[symbol] || 0) + pnl; + }); + } + + const endingSnapshot = snapshotStateMap(currentState); + + return { + totalRealized, + perSymbol, + openingPositions: openingSnapshot.positions, + endingPositions: endingSnapshot.positions, + openingExposureNotional: openingSnapshot.exposureNotional, + endingExposureNotional: endingSnapshot.exposureNotional, + preWindowFillCount, + inWindowFillCount + }; +}; + +const fetchAlpacaOrdersPaged = async (after: Date, until: Date): Promise => { + const out: AlpacaOrder[] = []; + const seen = new Set(); + let cursorUntil = new Date(until); + const afterMs = after.getTime(); + + for (let page = 0; page < MAX_ALPACA_PAGES; page++) { + const raw = await (alpaca as any).getOrders({ + status: 'all', + after, + until: cursorUntil, + direction: 'desc', + limit: ALPACA_PAGE_LIMIT + }); + const batch: AlpacaOrder[] = Array.isArray(raw) ? raw : []; + if (!batch.length) break; + + let oldestTs = Number.POSITIVE_INFINITY; + for (const order of batch) { + const id = String(order.id || '').trim(); + if (id && seen.has(id)) continue; + if (id) seen.add(id); + out.push(order); + + const ts = getSubmittedTsMs(order) || getFillTsMs(order); + if (ts > 0 && ts < oldestTs) oldestTs = ts; + } + + if (batch.length < ALPACA_PAGE_LIMIT) break; + if (!Number.isFinite(oldestTs) || oldestTs <= 0) break; + + const nextUntilMs = oldestTs - 1; + if (nextUntilMs <= afterMs) break; + if (nextUntilMs >= cursorUntil.getTime()) break; + cursorUntil = new Date(nextUntilMs); + } + + return out; +}; + +const calculateSupabaseRealizedPnl = (historyRows: HistoryRow[]) => { + let totalPnl = 0; + const perSymbol: Record = {}; + const formulaMismatches: ReconciliationSample[] = []; + const tradeIdCounts = new Map(); + const rankedRows: Array = []; + + for (const row of historyRows) { + const symbol = normalizeSymbol(row.symbol); + const side = normalizeSide(row.side); + const size = toNumber(row.size); + const entry = toNumber(row.entry_price); + const exit = toNumber(row.exit_price); + const pnl = toNumber(row.pnl); + const reason = String(row.reason || ''); + totalPnl += pnl; + perSymbol[symbol] = (perSymbol[symbol] || 0) + pnl; + rankedRows.push({ ...row, __absPnl: Math.abs(pnl) }); + + const tradeId = String(row.trade_id || '').trim(); + if (tradeId) { + tradeIdCounts.set(tradeId, (tradeIdCounts.get(tradeId) || 0) + 1); + } + + if (!isQuarantinedHistoryReason(reason) && size > 0 && entry > 0 && exit > 0) { + const expected = side === 'BUY' + ? (exit - entry) * size + : (entry - exit) * size; + const absDiff = Math.abs(expected - pnl); + if (absDiff > 0.02) { + pushSample(formulaMismatches, { + id: row.id, + trade_id: row.trade_id, + symbol: row.symbol, + side: row.side, + size, + entry_price: entry, + exit_price: exit, + pnl_recorded: pnl, + pnl_expected: Number(expected.toFixed(8)), + pnl_abs_diff: Number(absDiff.toFixed(8)) + }); + } + } + } + + const duplicateTradeIds = Array.from(tradeIdCounts.entries()) + .filter(([, count]) => count > 1) + .sort((a, b) => b[1] - a[1]) + .slice(0, MAX_SAMPLES) + .map(([tradeId, count]) => ({ trade_id: tradeId, count })); + + const topRows = rankedRows + .sort((a, b) => b.__absPnl - a.__absPnl) + .slice(0, MAX_SAMPLES) + .map((row) => ({ + id: row.id, + trade_id: row.trade_id, + profile_id: row.profile_id, + symbol: row.symbol, + side: row.side, + size: toNumber(row.size), + entry_price: toNumber(row.entry_price), + exit_price: toNumber(row.exit_price), + pnl: toNumber(row.pnl), + reason: row.reason, + created_at: row.created_at + })); + + return { + totalPnl, + perSymbol, + formulaMismatches, + duplicateTradeIds, + topRows + }; +}; + +const sortRecordByAbsValueDesc = (record: Record) => ( + Object.entries(record) + .sort((a, b) => Math.abs(b[1]) - Math.abs(a[1])) + .map(([key, value]) => ({ key, value: Number(value.toFixed(8)) })) +); + +const run = async () => { + const windowStartMs = startDate.getTime(); + const windowEndMs = now.getTime(); + const alpacaAllOrders = await fetchAlpacaOrdersPaged(carryStartDate, now); + const alpacaOrders = alpacaAllOrders.filter((order) => { + const submittedTs = getSubmittedTsMs(order); + if (submittedTs > 0) { + return submittedTs >= windowStartMs && submittedTs <= windowEndMs; + } + const fillTs = getFillTsMs(order); + return fillTs >= windowStartMs && fillTs <= windowEndMs; + }); + + const [dbOrders, dbOrdersCarryScope, tradeHistory] = await Promise.all([ + fetchSupabaseOrders(startIso), + fetchSupabaseOrders(carryStartIso), + fetchTradeHistory() + ]); + const dbPreWindowFilledOrderCount = dbOrdersCarryScope.filter((order) => { + const createdAtTs = toTimestampMs(order.created_at || 0, 0); + if (createdAtTs <= 0 || createdAtTs >= windowStartMs) return false; + const status = normalizeStatus(order.status); + return status === 'filled' || status === 'partially_filled'; + }).length; + const comparableSymbols = new Set( + dbOrders + .map((order) => normalizeSymbol(order.symbol)) + .filter((symbol) => !!symbol) + ); + const useComparableScope = comparableSymbols.size > 0; + const alpacaOrdersForPnlAll = useComparableScope + ? alpacaAllOrders.filter((order) => comparableSymbols.has(normalizeSymbol(order.symbol))) + : alpacaAllOrders; + const alpacaOrdersForPnlWindow = useComparableScope + ? alpacaOrders.filter((order) => comparableSymbols.has(normalizeSymbol(order.symbol))) + : alpacaOrders; + + let alpacaAccountSummary: Record | null = null; + try { + const account = await (alpaca as any).getAccount(); + alpacaAccountSummary = { + equity: toNumber((account as any)?.equity), + cash: toNumber((account as any)?.cash), + portfolio_value: toNumber((account as any)?.portfolio_value), + buying_power: toNumber((account as any)?.buying_power), + last_equity: toNumber((account as any)?.last_equity) + }; + } catch { + alpacaAccountSummary = null; + } + + let alpacaPortfolioSummary: Record | null = null; + try { + const portfolioHistory = await (alpaca as any).getPortfolioHistory({ + date_start: startIso.slice(0, 10), + date_end: nowIso.slice(0, 10), + timeframe: '1D', + extended_hours: true + }); + + const timestamps = Array.isArray((portfolioHistory as any)?.timestamp) + ? (portfolioHistory as any).timestamp + : []; + const equity = Array.isArray((portfolioHistory as any)?.equity) + ? (portfolioHistory as any).equity + : []; + const profitLoss = Array.isArray((portfolioHistory as any)?.profit_loss) + ? (portfolioHistory as any).profit_loss + : []; + const profitLossPct = Array.isArray((portfolioHistory as any)?.profit_loss_pct) + ? (portfolioHistory as any).profit_loss_pct + : []; + + const firstEquity = equity.length ? toNumber(equity[0]) : 0; + const lastEquity = equity.length ? toNumber(equity[equity.length - 1]) : 0; + const lastProfitLoss = profitLoss.length ? toNumber(profitLoss[profitLoss.length - 1]) : 0; + const lastProfitLossPct = profitLossPct.length ? toNumber(profitLossPct[profitLossPct.length - 1]) : 0; + + const firstTsRaw = timestamps.length ? Number(timestamps[0]) : 0; + const lastTsRaw = timestamps.length ? Number(timestamps[timestamps.length - 1]) : 0; + const firstTsMs = firstTsRaw > 1_000_000_000_000 ? firstTsRaw : firstTsRaw * 1000; + const lastTsMs = lastTsRaw > 1_000_000_000_000 ? lastTsRaw : lastTsRaw * 1000; + + alpacaPortfolioSummary = { + points: equity.length, + first_timestamp_utc: firstTsMs > 0 ? new Date(firstTsMs).toISOString() : null, + last_timestamp_utc: lastTsMs > 0 ? new Date(lastTsMs).toISOString() : null, + first_equity: Number(firstEquity.toFixed(8)), + last_equity: Number(lastEquity.toFixed(8)), + equity_change: Number((lastEquity - firstEquity).toFixed(8)), + latest_profit_loss: Number(lastProfitLoss.toFixed(8)), + latest_profit_loss_pct: Number(lastProfitLossPct.toFixed(8)) + }; + } catch { + alpacaPortfolioSummary = null; + } + + const alpacaById = new Map(); + for (const order of alpacaOrders) { + const id = String(order.id || '').trim(); + if (id) alpacaById.set(id, order); + } + + const dbByOrderId = new Map(); + for (const order of dbOrders) { + const orderId = String(order.order_id || '').trim(); + if (!orderId) continue; + if (!dbByOrderId.has(orderId)) dbByOrderId.set(orderId, []); + dbByOrderId.get(orderId)!.push(order); + } + + const missingInDb: ReconciliationSample[] = []; + const missingInAlpaca: ReconciliationSample[] = []; + const statusMismatches: ReconciliationSample[] = []; + const qtyMismatches: ReconciliationSample[] = []; + const sideMismatches: ReconciliationSample[] = []; + const symbolMismatches: ReconciliationSample[] = []; + const priceMismatches: ReconciliationSample[] = []; + + for (const alpacaOrder of alpacaOrders) { + const alpacaId = String(alpacaOrder.id || '').trim(); + if (!alpacaId) continue; + + const matches = dbByOrderId.get(alpacaId) || []; + if (!matches.length) { + pushSample(missingInDb, { + alpaca_order_id: alpacaId, + symbol: alpacaOrder.symbol, + side: alpacaOrder.side, + status: alpacaOrder.status, + submitted_at: alpacaOrder.submitted_at + }); + continue; + } + + const dbOrder = matches[0]; + const alpacaStatus = normalizeStatus(alpacaOrder.status); + const dbStatus = normalizeStatus(dbOrder.status); + if (alpacaStatus !== dbStatus) { + pushSample(statusMismatches, { + order_id: alpacaId, + alpaca_status: alpacaOrder.status, + db_status: dbOrder.status, + alpaca_status_normalized: alpacaStatus, + db_status_normalized: dbStatus + }); + } + + const alpacaQty = toNumber(alpacaOrder.qty); + const dbQty = getOrderQty(dbOrder); + if (alpacaQty > 0 && dbQty > 0 && pctDiff(alpacaQty, dbQty) > 0.000001) { + pushSample(qtyMismatches, { + order_id: alpacaId, + symbol: alpacaOrder.symbol, + alpaca_qty: alpacaQty, + db_qty: dbQty + }); + } + + const alpacaSide = normalizeSide(alpacaOrder.side); + const dbSide = normalizeSide(dbOrder.side); + if (alpacaSide !== dbSide) { + pushSample(sideMismatches, { + order_id: alpacaId, + symbol: alpacaOrder.symbol, + alpaca_side: alpacaOrder.side, + db_side: dbOrder.side + }); + } + + const alpacaSymbol = normalizeSymbol(alpacaOrder.symbol); + const dbSymbol = normalizeSymbol(dbOrder.symbol); + if (alpacaSymbol !== dbSymbol) { + pushSample(symbolMismatches, { + order_id: alpacaId, + alpaca_symbol: alpacaOrder.symbol, + db_symbol: dbOrder.symbol, + alpaca_symbol_normalized: alpacaSymbol, + db_symbol_normalized: dbSymbol + }); + } + + const alpacaFilledPrice = toNumber(alpacaOrder.filled_avg_price); + const dbPrice = toNumber(dbOrder.price); + if (alpacaFilledPrice > 0 && dbPrice > 0 && pctDiff(alpacaFilledPrice, dbPrice) > 0.005) { + pushSample(priceMismatches, { + order_id: alpacaId, + symbol: alpacaOrder.symbol, + alpaca_filled_avg_price: alpacaFilledPrice, + db_price: dbPrice, + diff_percent: Number((pctDiff(alpacaFilledPrice, dbPrice) * 100).toFixed(4)) + }); + } + } + + for (const dbOrder of dbOrders) { + const orderId = String(dbOrder.order_id || '').trim(); + if (!orderId) continue; + if (!alpacaById.has(orderId)) { + pushSample(missingInAlpaca, { + db_order_id: orderId, + symbol: dbOrder.symbol, + side: dbOrder.side, + status: dbOrder.status, + profile_id: dbOrder.profile_id, + created_at: dbOrder.created_at + }); + } + } + + const alpacaRealizedWithCarry = calculateAlpacaRealizedPnl(alpacaOrdersForPnlAll, windowStartMs, windowEndMs); + const alpacaRealizedFlatStart = calculateAlpacaRealizedPnl(alpacaOrdersForPnlWindow, windowStartMs, windowEndMs); + const carryCoverageGap = Math.max(0, alpacaRealizedWithCarry.preWindowFillCount - dbPreWindowFilledOrderCount); + const carryCoverageThreshold = Math.max(20, dbPreWindowFilledOrderCount * 3); + const useCarryAsPrimary = dbPreWindowFilledOrderCount > 0 + && carryCoverageGap <= carryCoverageThreshold; + const alpacaRealizedPrimary = useCarryAsPrimary + ? alpacaRealizedWithCarry + : alpacaRealizedFlatStart; + const dbRealized = calculateSupabaseRealizedPnl(tradeHistory); + const pnlDiff = dbRealized.totalPnl - alpacaRealizedPrimary.totalRealized; + const alpacaFillCashFlow = alpacaOrdersForPnlWindow.reduce((sum, order) => { + const status = normalizeStatus(order.status); + if (status !== 'filled' && status !== 'partially_filled') return sum; + const qty = toNumber(order.filled_qty); + const price = toNumber(order.filled_avg_price); + if (!(qty > 0 && price > 0)) return sum; + const side = normalizeSide(order.side); + const signedNotional = side === 'BUY' ? -qty * price : qty * price; + return sum + signedNotional; + }, 0); + + const output = { + meta: { + window_start_utc: startIso, + window_end_utc: nowIso, + lookback_days: LOOKBACK_DAYS, + carry_in_lookback_days: CARRY_IN_LOOKBACK_DAYS, + carry_in_start_utc: carryStartIso, + pnl_symbol_scope: useComparableScope ? 'db-order-symbols' : 'all-symbols', + paper_trading: paperTrading + }, + counts: { + alpaca_orders_total: alpacaOrders.length, + alpaca_orders_total_with_carry_window: alpacaAllOrders.length, + alpaca_orders_total_in_pnl_scope: alpacaOrdersForPnlWindow.length, + alpaca_orders_total_with_carry_in_pnl_scope: alpacaOrdersForPnlAll.length, + supabase_orders_total: dbOrders.length, + supabase_trade_history_total: tradeHistory.length + }, + alpaca_account: alpacaAccountSummary, + alpaca_portfolio_history: alpacaPortfolioSummary, + status_breakdown: { + alpaca_orders: statusBreakdown(alpacaOrders.map((o) => ({ status: o.status }))), + supabase_orders: statusBreakdown(dbOrders.map((o) => ({ status: o.status }))) + }, + order_conflicts: { + missing_in_supabase_count: missingInDb.length, + missing_in_alpaca_count: missingInAlpaca.length, + status_mismatch_count: statusMismatches.length, + qty_mismatch_count: qtyMismatches.length, + side_mismatch_count: sideMismatches.length, + symbol_mismatch_count: symbolMismatches.length, + price_mismatch_count: priceMismatches.length, + samples: { + missing_in_supabase: missingInDb, + missing_in_alpaca: missingInAlpaca, + status_mismatches: statusMismatches, + qty_mismatches: qtyMismatches, + side_mismatches: sideMismatches, + symbol_mismatches: symbolMismatches, + price_mismatches: priceMismatches + } + }, + pnl_comparison: { + caveat: 'Alpaca fill-derived realized PnL publishes both flat_start and with_carry_in. Primary metric auto-selects carry only when pre-window Alpaca fill volume is consistent with Supabase pre-window coverage.', + supabase_trade_history_realized_pnl: Number(dbRealized.totalPnl.toFixed(8)), + alpaca_fill_derived_realized_pnl: Number(alpacaRealizedPrimary.totalRealized.toFixed(8)), + alpaca_fill_derived_realized_pnl_with_carry_in: Number(alpacaRealizedWithCarry.totalRealized.toFixed(8)), + alpaca_fill_derived_realized_pnl_flat_start: Number(alpacaRealizedFlatStart.totalRealized.toFixed(8)), + alpaca_fill_realized_carry_minus_flat: Number((alpacaRealizedWithCarry.totalRealized - alpacaRealizedFlatStart.totalRealized).toFixed(8)), + alpaca_fill_cash_flow: Number(alpacaFillCashFlow.toFixed(8)), + pnl_diff_supabase_minus_alpaca: Number(pnlDiff.toFixed(8)), + supabase_per_symbol_realized_pnl: sortRecordByAbsValueDesc(dbRealized.perSymbol), + alpaca_per_symbol_realized_pnl: sortRecordByAbsValueDesc(alpacaRealizedPrimary.perSymbol), + alpaca_per_symbol_realized_pnl_flat_start: sortRecordByAbsValueDesc(alpacaRealizedFlatStart.perSymbol), + alpaca_per_symbol_realized_pnl_with_carry_in: sortRecordByAbsValueDesc(alpacaRealizedWithCarry.perSymbol), + alpaca_opening_positions_at_window_start: alpacaRealizedWithCarry.openingPositions, + alpaca_opening_exposure_notional: Number(alpacaRealizedWithCarry.openingExposureNotional.toFixed(8)), + alpaca_ending_positions_after_window: alpacaRealizedWithCarry.endingPositions, + alpaca_ending_exposure_notional: Number(alpacaRealizedWithCarry.endingExposureNotional.toFixed(8)), + alpaca_carry_in_bootstrap: { + pre_window_fill_count: alpacaRealizedWithCarry.preWindowFillCount, + in_window_fill_count: alpacaRealizedWithCarry.inWindowFillCount, + has_pre_window_fills: alpacaRealizedWithCarry.preWindowFillCount > 0, + symbol_scope: useComparableScope ? 'db-order-symbols' : 'all-symbols', + supabase_pre_window_filled_order_count: dbPreWindowFilledOrderCount, + carry_coverage_gap: carryCoverageGap, + carry_coverage_threshold: carryCoverageThreshold, + primary_mode: useCarryAsPrimary ? 'with_carry_in' : 'flat_start' + }, + trade_history_formula_mismatch_count: dbRealized.formulaMismatches.length, + trade_history_formula_mismatch_samples: dbRealized.formulaMismatches, + duplicate_trade_id_samples: dbRealized.duplicateTradeIds, + top_trade_history_pnl_rows: dbRealized.topRows + } + }; + + console.log(JSON.stringify(output, null, 2)); +}; + +run().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + console.error(JSON.stringify({ error: message }, null, 2)); + process.exit(1); +}); diff --git a/backend/reconcileAttributionRepair.ts b/backend/reconcileAttributionRepair.ts new file mode 100644 index 0000000..00b3db4 --- /dev/null +++ b/backend/reconcileAttributionRepair.ts @@ -0,0 +1,761 @@ +import 'dotenv/config'; +import { randomUUID } from 'crypto'; +import { createClient } from '@supabase/supabase-js'; +import { normalizeOrderAction, normalizeTradeSide } from '../src/domain/tradingEnums.js'; + +type CliOptions = { + apply: boolean; + tradeIds: string[]; + restoreBatchId?: string; + allowCrossTrade: boolean; + allowPartial: boolean; +}; + +type OrderRow = { + id?: string; + order_id?: string | null; + user_id?: string | null; + profile_id?: string | null; + trade_id?: string | null; + symbol?: string | null; + action?: string | null; + side?: string | null; + qty?: number | string | null; + quantity?: number | string | null; + price?: number | string | null; + status?: string | null; + source?: string | null; + created_at?: string | null; + updated_at?: string | null; +}; + +type AuditRow = { + id: number; + batch_id?: string | null; + profile_id?: string | null; + symbol?: string | null; + trade_id?: string | null; + exchange_order_id?: string | null; + backfill_order_id?: string | null; + decision?: string | null; + reason?: string | null; + metadata?: Record | null; + created_at?: string | null; + reverted_at?: string | null; +}; + +type CandidateRow = { + order: OrderRow; + qty: number; + exchangeOrderId: string; + realOrder: OrderRow | null; + safe: boolean; + unsafeReason?: string; +}; + +type ChosenSubset = { + orderIds: string[]; + sumQty: number; + nextOpenQty: number; + withinDust: boolean; +}; + +type TradeEvaluation = { + tradeId: string; + profileId: string; + symbol: string; + entryQty: number; + exitQty: number; + openQtyBefore: number; + openQtyAfter: number; + dustThreshold: number; + candidates: CandidateRow[]; + selectedOrderIds: string[]; + decision: 'SKIP' | 'DRY_RUN' | 'APPLIED' | 'NO_GO'; + reason: string; +}; + +const EPSILON = 1e-8; +const DUST_ABS_QTY = 0.001; +const DUST_REL_PCT = 0.002; // 0.2% +const FILLED_STATUSES = new Set(['filled', 'partially_filled', 'partially-filled']); +const APPLIED_DECISIONS = new Set([ + 'APPLIED', + 'MANUAL_OVERRIDE_APPLIED', + 'SKIP_EXISTING', + 'MANUAL_OVERRIDE_SKIP_EXISTING', + 'ATTRIB_REPAIR_APPLIED' +]); +const STATUS_REVERT_TARGET = 'canceled'; + +const parseArgs = (argv: string[]): CliOptions => { + const tradeIds = new Set(); + let apply = false; + let restoreBatchId: string | undefined; + let allowCrossTrade = false; + let allowPartial = false; + + for (const raw of argv) { + const arg = String(raw || '').trim(); + if (!arg) continue; + if (arg === '--apply') { + apply = true; + continue; + } + if (arg === '--allow-cross-trade') { + allowCrossTrade = true; + continue; + } + if (arg === '--allow-partial') { + allowPartial = true; + continue; + } + if (arg.startsWith('--restore-batch=')) { + const value = String(arg.slice('--restore-batch='.length) || '').trim(); + if (value) restoreBatchId = value; + continue; + } + if (arg.startsWith('--trade=')) { + const value = String(arg.slice('--trade='.length) || '').trim(); + if (value) tradeIds.add(value); + continue; + } + } + + return { + apply, + tradeIds: Array.from(tradeIds), + restoreBatchId, + allowCrossTrade, + allowPartial + }; +}; + +const toNumber = (value: unknown): number => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +}; + +const normalizeStatus = (value: unknown): string => String(value || '').trim().toLowerCase(); + +const inferAction = (actionRaw?: string | null, sideRaw?: string | null): 'ENTRY' | 'EXIT' | undefined => { + const explicit = normalizeOrderAction(actionRaw || undefined); + if (explicit) return explicit; + const side = normalizeTradeSide(sideRaw || 'BUY'); + return side === 'BUY' ? 'ENTRY' : 'EXIT'; +}; + +const parseMetadata = (value: unknown): Record => { + if (!value) return {}; + if (typeof value === 'object') return value as Record; + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value); + if (parsed && typeof parsed === 'object') return parsed as Record; + } catch { + return {}; + } + } + return {}; +}; + +const pickBestSubset = ( + openQtyBefore: number, + candidates: CandidateRow[], + dustThreshold: number, + allowPartial: boolean +): ChosenSubset | null => { + if (candidates.length === 0) return null; + if (!(openQtyBefore < -EPSILON)) return null; + if (candidates.length > 20) return null; + + const n = candidates.length; + const totalMasks = 1 << n; + let best: ChosenSubset | null = null; + + for (let mask = 1; mask < totalMasks; mask += 1) { + let sumQty = 0; + const orderIds: string[] = []; + for (let idx = 0; idx < n; idx += 1) { + if ((mask & (1 << idx)) === 0) continue; + const candidate = candidates[idx]; + sumQty += candidate.qty; + orderIds.push(String(candidate.order.order_id || '').trim()); + } + + const nextOpenQty = Number((openQtyBefore + sumQty).toFixed(8)); + const improved = Math.abs(nextOpenQty) + EPSILON < Math.abs(openQtyBefore); + if (!improved) continue; + + const withinDust = Math.abs(nextOpenQty) <= dustThreshold + EPSILON; + if (!withinDust && !allowPartial) continue; + // Never move from over-closed to materially under-closed in strict mode. + if (!allowPartial && nextOpenQty > dustThreshold + EPSILON) continue; + + const candidateBest: ChosenSubset = { + orderIds, + sumQty: Number(sumQty.toFixed(8)), + nextOpenQty, + withinDust + }; + if (!best) { + best = candidateBest; + continue; + } + + const bestAbs = Math.abs(best.nextOpenQty); + const candAbs = Math.abs(candidateBest.nextOpenQty); + if (candAbs + EPSILON < bestAbs) { + best = candidateBest; + continue; + } + if (Math.abs(candAbs - bestAbs) <= EPSILON && candidateBest.orderIds.length < best.orderIds.length) { + best = candidateBest; + } + } + + return best; +}; + +const buildTradeLedger = (rows: OrderRow[]): { + entryQty: number; + exitQty: number; + openQty: number; + profileId: string; + symbol: string; +} => { + let entryQty = 0; + let exitQty = 0; + let profileId = ''; + let symbol = ''; + + for (const row of rows) { + const status = normalizeStatus(row.status); + if (!FILLED_STATUSES.has(status)) continue; + + const qty = toNumber(row.qty ?? row.quantity); + if (!(qty > EPSILON)) continue; + const action = inferAction(row.action, row.side); + if (!action) continue; + if (!profileId) profileId = String(row.profile_id || '').trim(); + if (!symbol) symbol = String(row.symbol || '').trim(); + + if (action === 'ENTRY') entryQty += qty; + if (action === 'EXIT') exitQty += qty; + } + + return { + entryQty: Number(entryQty.toFixed(8)), + exitQty: Number(exitQty.toFixed(8)), + openQty: Number((entryQty - exitQty).toFixed(8)), + profileId, + symbol + }; +}; + +const runRestoreMode = async ( + supabase: ReturnType, + options: CliOptions +): Promise => { + const restoreBatchId = String(options.restoreBatchId || '').trim(); + if (!restoreBatchId) { + throw new Error('Missing --restore-batch=.'); + } + + const { data: rows, error } = await supabase + .from('reconciliation_backfill_audit') + .select('id,batch_id,profile_id,symbol,trade_id,backfill_order_id,decision,reason,metadata,created_at,reverted_at') + .eq('batch_id', restoreBatchId) + .in('decision', ['ATTRIB_REPAIR_APPLIED']) + .order('created_at', { ascending: true }); + if (error) throw error; + + const restoreTargets = ((rows || []) as AuditRow[]) + .filter((row) => String(row.backfill_order_id || '').trim().length > 0); + + const batchId = `ATTRIB-RESTORE-${randomUUID()}`; + const nowIso = new Date().toISOString(); + const updates = restoreTargets.map((row) => { + const metadata = parseMetadata(row.metadata); + const restoreStatus = normalizeStatus(metadata.prevStatus || 'filled') || 'filled'; + return { + row, + restoreStatus + }; + }); + + let restoredRows = 0; + if (options.apply && updates.length > 0) { + for (const target of updates) { + const orderId = String(target.row.backfill_order_id || '').trim(); + const { error: updateError } = await supabase + .from('orders') + .update({ + status: target.restoreStatus, + updated_at: nowIso + }) + .eq('order_id', orderId); + if (updateError) throw updateError; + restoredRows += 1; + } + + const { error: markRevertedError } = await supabase + .from('reconciliation_backfill_audit') + .update({ reverted_at: nowIso }) + .eq('batch_id', restoreBatchId) + .in('decision', ['ATTRIB_REPAIR_APPLIED']); + if (markRevertedError) throw markRevertedError; + + const restoreAuditRows = updates.map((target) => ({ + batch_id: batchId, + profile_id: String(target.row.profile_id || '').trim(), + symbol: String(target.row.symbol || '').trim(), + trade_id: String(target.row.trade_id || '').trim(), + exchange_order_id: null, + exchange_client_order_id: null, + backfill_order_id: String(target.row.backfill_order_id || '').trim(), + filled_qty: null, + filled_price: null, + filled_at: null, + dry_run: false, + decision: 'ATTRIB_RESTORE_APPLIED', + reason: `status_restored_from_${restoreBatchId}`, + metadata: { + restoredFromBatch: restoreBatchId, + restoredFromAuditId: target.row.id, + restoredToStatus: target.restoreStatus + }, + applied_at: nowIso + })); + + const { error: restoreAuditError } = await supabase + .from('reconciliation_backfill_audit') + .insert(restoreAuditRows); + if (restoreAuditError) throw restoreAuditError; + } + + console.log(JSON.stringify({ + mode: options.apply ? 'restore-apply' : 'restore-dry-run', + restoreBatchId, + batchId, + targetRows: updates.length, + restoredRows, + targets: updates.map((target) => ({ + backfillOrderId: String(target.row.backfill_order_id || '').trim(), + profileId: String(target.row.profile_id || '').trim(), + tradeId: String(target.row.trade_id || '').trim(), + restoreStatus: target.restoreStatus + })) + }, null, 2)); +}; + +const runRepairMode = async ( + supabase: ReturnType, + options: CliOptions +): Promise => { + if (options.tradeIds.length === 0) { + throw new Error('Provide at least one --trade= or use --restore-batch=.'); + } + + const { data: tradeRows, error: tradeError } = await supabase + .from('orders') + .select('id,order_id,user_id,profile_id,trade_id,symbol,action,side,qty,quantity,price,status,source,created_at,updated_at') + .in('trade_id', options.tradeIds) + .order('created_at', { ascending: true }); + if (tradeError) throw tradeError; + + const rows = (tradeRows || []) as OrderRow[]; + const rowsByTrade = new Map(); + for (const row of rows) { + const tradeId = String(row.trade_id || '').trim(); + if (!tradeId) continue; + const list = rowsByTrade.get(tradeId) || []; + list.push(row); + rowsByTrade.set(tradeId, list); + } + + const candidateBfillOrderIds = Array.from(new Set( + rows + .map((row) => String(row.order_id || '').trim()) + .filter((orderId) => orderId.startsWith('BFILL-')) + )); + + const preferredAuditByBackfill = new Map(); + if (candidateBfillOrderIds.length > 0) { + const { data: auditRows, error: auditError } = await supabase + .from('reconciliation_backfill_audit') + .select('id,batch_id,profile_id,symbol,trade_id,exchange_order_id,backfill_order_id,decision,reason,metadata,created_at,reverted_at') + .in('backfill_order_id', candidateBfillOrderIds) + .order('created_at', { ascending: false }); + if (auditError) throw auditError; + + const rowsByBackfill = new Map(); + for (const row of (auditRows || []) as AuditRow[]) { + const backfillOrderId = String(row.backfill_order_id || '').trim(); + if (!backfillOrderId) continue; + const list = rowsByBackfill.get(backfillOrderId) || []; + list.push(row); + rowsByBackfill.set(backfillOrderId, list); + } + + for (const [backfillOrderId, backfillAuditRows] of rowsByBackfill.entries()) { + const preferred = backfillAuditRows.find((row) => { + const decision = String(row.decision || '').trim(); + const exchangeOrderId = String(row.exchange_order_id || '').trim(); + return APPLIED_DECISIONS.has(decision) && exchangeOrderId.length > 0; + }) || backfillAuditRows[0]; + if (preferred) { + preferredAuditByBackfill.set(backfillOrderId, preferred); + } + } + } + + const exchangeOrderIds = Array.from(new Set( + Array.from(preferredAuditByBackfill.values()) + .map((row) => String(row.exchange_order_id || '').trim()) + .filter(Boolean) + )); + const realOrderByOrderId = new Map(); + if (exchangeOrderIds.length > 0) { + const { data: realRows, error: realError } = await supabase + .from('orders') + .select('id,order_id,user_id,profile_id,trade_id,symbol,action,side,qty,quantity,price,status,source,created_at,updated_at') + .in('order_id', exchangeOrderIds); + if (realError) throw realError; + + for (const row of (realRows || []) as OrderRow[]) { + const orderId = String(row.order_id || '').trim(); + if (!orderId) continue; + if (!realOrderByOrderId.has(orderId)) { + realOrderByOrderId.set(orderId, row); + } + } + } + + const batchId = `ATTRIB-REPAIR-${randomUUID()}`; + const nowIso = new Date().toISOString(); + const evaluations: TradeEvaluation[] = []; + const auditRowsToInsert: Record[] = []; + const orderIdsToRevert = new Set(); + + for (const tradeId of options.tradeIds) { + const tradeRowsForId = rowsByTrade.get(tradeId) || []; + if (tradeRowsForId.length === 0) { + evaluations.push({ + tradeId, + profileId: '', + symbol: '', + entryQty: 0, + exitQty: 0, + openQtyBefore: 0, + openQtyAfter: 0, + dustThreshold: DUST_ABS_QTY, + candidates: [], + selectedOrderIds: [], + decision: 'NO_GO', + reason: 'trade_not_found' + }); + continue; + } + + const ledger = buildTradeLedger(tradeRowsForId); + const dustThreshold = Math.max(DUST_ABS_QTY, ledger.entryQty * DUST_REL_PCT); + + const candidates = tradeRowsForId + .filter((row) => String(row.order_id || '').trim().startsWith('BFILL-')) + .filter((row) => FILLED_STATUSES.has(normalizeStatus(row.status))) + .filter((row) => inferAction(row.action, row.side) === 'EXIT') + .map((row): CandidateRow => { + const orderId = String(row.order_id || '').trim(); + const latestAudit = preferredAuditByBackfill.get(orderId); + if (!latestAudit) { + return { + order: row, + qty: toNumber(row.qty ?? row.quantity), + exchangeOrderId: '', + realOrder: null, + safe: false, + unsafeReason: 'missing_audit_link' + }; + } + + if (!APPLIED_DECISIONS.has(String(latestAudit.decision || '').trim())) { + return { + order: row, + qty: toNumber(row.qty ?? row.quantity), + exchangeOrderId: String(latestAudit.exchange_order_id || '').trim(), + realOrder: null, + safe: false, + unsafeReason: `audit_not_applied:${String(latestAudit.decision || '').trim() || 'unknown'}` + }; + } + + const exchangeOrderId = String(latestAudit.exchange_order_id || '').trim(); + if (!exchangeOrderId) { + return { + order: row, + qty: toNumber(row.qty ?? row.quantity), + exchangeOrderId, + realOrder: null, + safe: false, + unsafeReason: 'missing_exchange_order_id' + }; + } + + const realOrder = realOrderByOrderId.get(exchangeOrderId) || null; + if (!realOrder) { + return { + order: row, + qty: toNumber(row.qty ?? row.quantity), + exchangeOrderId, + realOrder: null, + safe: false, + unsafeReason: 'exchange_order_not_persisted_in_orders' + }; + } + + if (!FILLED_STATUSES.has(normalizeStatus(realOrder.status))) { + return { + order: row, + qty: toNumber(row.qty ?? row.quantity), + exchangeOrderId, + realOrder, + safe: false, + unsafeReason: `exchange_order_not_filled:${normalizeStatus(realOrder.status)}` + }; + } + + const realTradeId = String(realOrder.trade_id || '').trim(); + if (!options.allowCrossTrade && realTradeId !== tradeId) { + return { + order: row, + qty: toNumber(row.qty ?? row.quantity), + exchangeOrderId, + realOrder, + safe: false, + unsafeReason: `cross_trade_mapping:${realTradeId || 'null'}` + }; + } + + return { + order: row, + qty: toNumber(row.qty ?? row.quantity), + exchangeOrderId, + realOrder, + safe: true + }; + }); + + if (!(ledger.openQty < -EPSILON)) { + evaluations.push({ + tradeId, + profileId: ledger.profileId, + symbol: ledger.symbol, + entryQty: ledger.entryQty, + exitQty: ledger.exitQty, + openQtyBefore: ledger.openQty, + openQtyAfter: ledger.openQty, + dustThreshold: Number(dustThreshold.toFixed(8)), + candidates, + selectedOrderIds: [], + decision: 'SKIP', + reason: 'trade_not_overclosed' + }); + continue; + } + + const safeCandidates = candidates.filter((candidate) => candidate.safe && candidate.qty > EPSILON); + if (safeCandidates.length === 0) { + evaluations.push({ + tradeId, + profileId: ledger.profileId, + symbol: ledger.symbol, + entryQty: ledger.entryQty, + exitQty: ledger.exitQty, + openQtyBefore: ledger.openQty, + openQtyAfter: ledger.openQty, + dustThreshold: Number(dustThreshold.toFixed(8)), + candidates, + selectedOrderIds: [], + decision: 'NO_GO', + reason: 'no_safe_candidates' + }); + continue; + } + + const bestSubset = pickBestSubset(ledger.openQty, safeCandidates, dustThreshold, options.allowPartial); + if (!bestSubset) { + evaluations.push({ + tradeId, + profileId: ledger.profileId, + symbol: ledger.symbol, + entryQty: ledger.entryQty, + exitQty: ledger.exitQty, + openQtyBefore: ledger.openQty, + openQtyAfter: ledger.openQty, + dustThreshold: Number(dustThreshold.toFixed(8)), + candidates, + selectedOrderIds: [], + decision: 'NO_GO', + reason: options.allowPartial ? 'no_improving_subset' : 'no_subset_within_dust' + }); + continue; + } + + const resolvedDecision: TradeEvaluation['decision'] = options.apply ? 'APPLIED' : 'DRY_RUN'; + for (const orderId of bestSubset.orderIds) { + orderIdsToRevert.add(orderId); + } + + evaluations.push({ + tradeId, + profileId: ledger.profileId, + symbol: ledger.symbol, + entryQty: ledger.entryQty, + exitQty: ledger.exitQty, + openQtyBefore: ledger.openQty, + openQtyAfter: bestSubset.nextOpenQty, + dustThreshold: Number(dustThreshold.toFixed(8)), + candidates, + selectedOrderIds: bestSubset.orderIds, + decision: resolvedDecision, + reason: bestSubset.withinDust ? 'synthetic_duplicate_subset_selected' : 'synthetic_duplicate_subset_selected_partial' + }); + } + + if (options.apply && orderIdsToRevert.size > 0) { + for (const orderId of orderIdsToRevert) { + const { error: updateError } = await supabase + .from('orders') + .update({ + status: STATUS_REVERT_TARGET, + updated_at: nowIso + }) + .eq('order_id', orderId) + .in('status', ['filled', 'partially_filled', 'partially-filled']); + if (updateError) throw updateError; + } + } + + for (const evaluation of evaluations) { + if (evaluation.selectedOrderIds.length > 0) { + for (const orderId of evaluation.selectedOrderIds) { + const candidate = evaluation.candidates.find((row) => String(row.order.order_id || '').trim() === orderId); + if (!candidate) continue; + const prevStatus = normalizeStatus(candidate.order.status); + auditRowsToInsert.push({ + batch_id: batchId, + profile_id: evaluation.profileId, + symbol: evaluation.symbol, + trade_id: evaluation.tradeId, + exchange_order_id: candidate.exchangeOrderId || null, + exchange_client_order_id: null, + backfill_order_id: orderId, + filled_qty: Number(candidate.qty.toFixed(8)), + filled_price: toNumber(candidate.order.price), + filled_at: null, + dry_run: !options.apply, + decision: options.apply ? 'ATTRIB_REPAIR_APPLIED' : 'ATTRIB_REPAIR_DRY', + reason: 'synthetic_duplicate_status_revert', + metadata: { + prevStatus: prevStatus || 'filled', + nextStatus: options.apply ? STATUS_REVERT_TARGET : `would_${STATUS_REVERT_TARGET}`, + openQtyBefore: evaluation.openQtyBefore, + openQtyAfter: evaluation.openQtyAfter, + dustThreshold: evaluation.dustThreshold, + realOrderTradeId: String(candidate.realOrder?.trade_id || '').trim() || null, + allowCrossTrade: options.allowCrossTrade, + allowPartial: options.allowPartial + }, + applied_at: options.apply ? nowIso : null + }); + } + } else if (evaluation.decision === 'NO_GO') { + auditRowsToInsert.push({ + batch_id: batchId, + profile_id: evaluation.profileId || null, + symbol: evaluation.symbol || null, + trade_id: evaluation.tradeId, + exchange_order_id: null, + exchange_client_order_id: null, + backfill_order_id: null, + filled_qty: null, + filled_price: null, + filled_at: null, + dry_run: !options.apply, + decision: options.apply ? 'ATTRIB_REPAIR_NO_GO' : 'ATTRIB_REPAIR_NO_GO_DRY', + reason: evaluation.reason, + metadata: { + openQtyBefore: evaluation.openQtyBefore, + dustThreshold: evaluation.dustThreshold, + safeCandidates: evaluation.candidates.filter((row) => row.safe).length, + unsafeCandidates: evaluation.candidates.filter((row) => !row.safe).map((row) => ({ + orderId: row.order.order_id, + reason: row.unsafeReason + })) + }, + applied_at: null + }); + } + } + + if (auditRowsToInsert.length > 0) { + const { error: auditInsertError } = await supabase + .from('reconciliation_backfill_audit') + .insert(auditRowsToInsert); + if (auditInsertError) throw auditInsertError; + } + + console.log(JSON.stringify({ + mode: options.apply ? 'apply' : 'dry-run', + batchId, + tradeIds: options.tradeIds, + allowCrossTrade: options.allowCrossTrade, + allowPartial: options.allowPartial, + totalTrades: evaluations.length, + appliedOrderStatusReverts: options.apply ? orderIdsToRevert.size : 0, + evaluations: evaluations.map((evaluation) => ({ + tradeId: evaluation.tradeId, + profileId: evaluation.profileId, + symbol: evaluation.symbol, + decision: evaluation.decision, + reason: evaluation.reason, + entryQty: evaluation.entryQty, + exitQty: evaluation.exitQty, + openQtyBefore: evaluation.openQtyBefore, + openQtyAfter: evaluation.openQtyAfter, + dustThreshold: evaluation.dustThreshold, + selectedOrderIds: evaluation.selectedOrderIds, + safeCandidateCount: evaluation.candidates.filter((row) => row.safe).length, + unsafeCandidateCount: evaluation.candidates.filter((row) => !row.safe).length + })) + }, null, 2)); +}; + +const run = async (): Promise => { + const options = parseArgs(process.argv.slice(2)); + const supabaseUrl = String(process.env.SUPABASE_URL || '').trim(); + const supabaseKey = String( + process.env.SUPABASE_KEY + || process.env.SUPABASE_SERVICE_ROLE_KEY + || process.env.SUPABASE_ANON_KEY + || '' + ).trim(); + if (!supabaseUrl || !supabaseKey) { + throw new Error('Missing Supabase credentials. Expected SUPABASE_URL + SUPABASE_KEY/SUPABASE_SERVICE_ROLE_KEY.'); + } + + const supabase = createClient(supabaseUrl, supabaseKey); + if (options.restoreBatchId) { + await runRestoreMode(supabase, options); + return; + } + await runRepairMode(supabase, options); +}; + +run().catch((error) => { + console.error(JSON.stringify({ + error: error instanceof Error ? error.message : String(error) + }, null, 2)); + process.exit(1); +}); diff --git a/backend/reconcileCapitalLedgerState.ts b/backend/reconcileCapitalLedgerState.ts new file mode 100644 index 0000000..751fded --- /dev/null +++ b/backend/reconcileCapitalLedgerState.ts @@ -0,0 +1,636 @@ +import 'dotenv/config'; +import { createClient } from '@supabase/supabase-js'; + +type CliOptions = { + apply: boolean; + includeInactive: boolean; + includeQuarantinedHistory: boolean; + profileIds: string[]; +}; + +type ProfileRow = { + id: string; + name?: string | null; + is_active?: boolean | null; + allocated_capital?: number | string | null; +}; + +type LedgerRow = { + profile_id: string; + allocated_capital?: number | string | null; + reserved_for_orders?: number | string | null; + reserved_for_positions?: number | string | null; + realized_pnl?: number | string | null; + updated_at?: string | null; +}; + +type TradeHistoryRow = { + pnl?: number | string | null; + reason?: string | null; +}; + +type OrderRow = { + symbol?: string | null; + trade_id?: string | null; + action?: string | null; + side?: string | null; + qty?: number | string | null; + quantity?: number | string | null; + price?: number | string | null; + status?: string | null; + timestamp?: number | string | null; + created_at?: string | null; +}; + +type ProfileRepairReport = { + profileId: string; + profileName: string; + isActive: boolean; + allocatedCapital: number; + ordersAnalyzed: number; + historyRowsAnalyzed: number; + openOrderRowsWithoutPrice: number; + current: { + allocatedCapital: number; + reservedForOrders: number; + reservedForPositions: number; + realizedPnl: number; + }; + target: { + allocatedCapital: number; + reservedForOrders: number; + reservedForPositions: number; + realizedPnl: number; + }; + delta: { + allocatedCapital: number; + reservedForOrders: number; + reservedForPositions: number; + realizedPnl: number; + }; + rawUtilizationBeforePct: number | null; + rawUtilizationAfterPct: number | null; + overAllocatedBefore: boolean; + overAllocatedAfter: boolean; + changed: boolean; +}; + +const PAGE_SIZE = 1000; +const EPSILON = 1e-8; + +const OPEN_ORDER_STATUSES = new Set([ + 'pending_new', + 'accepted', + 'pending', + 'new', + 'partially_filled', + 'partially-filled', + 'partiallyfilled', + 'partial_fill' +]); + +const FILLED_STATUSES = new Set([ + 'filled', + 'partially_filled', + 'partially-filled', + 'partiallyfilled', + 'partial_fill' +]); + +const parseArgs = (argv: string[]): CliOptions => { + const profileIds = new Set(); + let apply = false; + let includeInactive = false; + let includeQuarantinedHistory = false; + + for (const raw of argv) { + const arg = String(raw || '').trim(); + if (!arg) continue; + if (arg === '--apply') { + apply = true; + continue; + } + if (arg === '--include-inactive') { + includeInactive = true; + continue; + } + if (arg === '--include-quarantined-history') { + includeQuarantinedHistory = true; + continue; + } + if (arg.startsWith('--profile=')) { + const value = String(arg.slice('--profile='.length) || '').trim(); + if (value) profileIds.add(value); + } + } + + return { + apply, + includeInactive, + includeQuarantinedHistory, + profileIds: Array.from(profileIds) + }; +}; + +const toNumber = (value: unknown): number => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +}; + +const round8 = (value: number): number => Number(value.toFixed(8)); + +const formatErrorPayload = (error: any): string => { + if (!error) return 'unknown_error'; + if (error instanceof Error) { + return JSON.stringify({ + message: error.message, + stack: error.stack || null + }); + } + if (typeof error === 'object') { + return JSON.stringify({ + message: String(error.message || ''), + code: String(error.code || ''), + details: String(error.details || ''), + hint: String(error.hint || ''), + raw: error + }); + } + return JSON.stringify({ message: String(error) }); +}; + +const failIfError = (error: any, context: string): void => { + if (!error) return; + throw new Error(`${context}: ${formatErrorPayload(error)}`); +}; + +const normalizeStatus = (status: unknown): string => { + const normalized = String(status || '').trim().toLowerCase(); + if (normalized === 'partially-filled') return 'partially_filled'; + if (normalized === 'partiallyfilled') return 'partially_filled'; + if (normalized === 'partial_fill') return 'partially_filled'; + return normalized; +}; + +const normalizeSide = (side: unknown): 'BUY' | 'SELL' => { + const normalized = String(side || '').trim().toUpperCase(); + return normalized === 'SELL' || normalized === 'SHORT' ? 'SELL' : 'BUY'; +}; + +const normalizeAction = (action: unknown): 'ENTRY' | 'EXIT' | undefined => { + const normalized = String(action || '').trim().toUpperCase(); + if (normalized === 'ENTRY' || normalized === 'EXIT') return normalized; + return undefined; +}; + +const inferAction = (row: OrderRow, knownEntrySide?: 'BUY' | 'SELL'): 'ENTRY' | 'EXIT' => { + const explicit = normalizeAction(row.action); + if (explicit) return explicit; + + const side = normalizeSide(row.side); + if (knownEntrySide) { + return side === knownEntrySide ? 'ENTRY' : 'EXIT'; + } + return side === 'BUY' ? 'ENTRY' : 'EXIT'; +}; + +const normalizeExecutionScopeSymbol = (symbol: unknown, provider: string): string => { + const raw = String(symbol || '') + .trim() + .toUpperCase() + .replace(/[\/_\-\s]/g, ''); + if (!raw) return ''; + if (provider === 'alpaca' && raw.endsWith('USDT')) { + return `${raw.slice(0, -4)}USD`; + } + return raw; +}; + +const toTimestamp = (row: OrderRow, fallback: number): number => { + const ts = Number(row.timestamp); + if (Number.isFinite(ts) && ts > 0) { + return ts > 1_000_000_000_000 ? ts : ts * 1000; + } + const created = Date.parse(String(row.created_at || '')); + if (Number.isFinite(created) && created > 0) return created; + return fallback; +}; + +const isQuarantinedHistoryReason = (reason: unknown): boolean => { + const normalized = String(reason || '').trim().toUpperCase(); + return normalized.startsWith('[INVALID_') + || normalized.startsWith('[DUPLICATE_') + || normalized.startsWith('[RECONCILED_TO_'); +}; + +const fetchPagedByProfile = async ( + supabase: any, + table: 'orders' | 'trade_history', + columns: string, + profileId: string +): Promise => { + const rows: T[] = []; + let offset = 0; + + for (;;) { + const { data, error } = await supabase + .from(table) + .select(columns) + .eq('profile_id', profileId) + .order('created_at', { ascending: true }) + .range(offset, offset + PAGE_SIZE - 1); + + failIfError(error, `fetchPagedByProfile:${table}:${profileId}`); + const chunk = (data || []) as T[]; + if (!chunk.length) break; + rows.push(...chunk); + if (chunk.length < PAGE_SIZE) break; + offset += PAGE_SIZE; + } + + return rows; +}; + +const fetchOrdersForProfile = async ( + supabase: any, + profileId: string +): Promise => { + const withQuantity = 'symbol,trade_id,action,side,qty,quantity,price,status,timestamp,created_at'; + const withoutQuantity = 'symbol,trade_id,action,side,qty,price,status,timestamp,created_at'; + + try { + return await fetchPagedByProfile(supabase, 'orders', withQuantity, profileId); + } catch (error: any) { + const message = String(error?.message || '').toLowerCase(); + if (message.includes('column') && message.includes('quantity')) { + return await fetchPagedByProfile(supabase, 'orders', withoutQuantity, profileId); + } + throw error; + } +}; + +const computeReservedForOpenEntryOrders = (orders: OrderRow[]): { + reserved: number; + rowsWithoutPrice: number; +} => { + let reserved = 0; + let rowsWithoutPrice = 0; + + for (const row of orders) { + const status = normalizeStatus(row.status); + if (!OPEN_ORDER_STATUSES.has(status)) continue; + + const action = inferAction(row); + if (action !== 'ENTRY') continue; + + const qty = toNumber(row.qty); + const quantity = qty > 0 ? qty : toNumber(row.quantity); + if (!(quantity > 0)) continue; + + const price = toNumber(row.price); + if (!(price > 0)) { + rowsWithoutPrice += 1; + continue; + } + + reserved += quantity * price; + } + + return { + reserved: round8(reserved), + rowsWithoutPrice + }; +}; + +const computeOpenPositionNotional = (orders: OrderRow[], provider: string): number => { + type TradeLedger = { + side: 'BUY' | 'SELL'; + entryQty: number; + entryNotional: number; + entryLastPrice: number; + exitQty: number; + }; + + const byExecutionSymbol = new Map>(); + let rowIndex = 0; + + for (const row of orders) { + const status = normalizeStatus(row.status); + if (!FILLED_STATUSES.has(status)) continue; + + const qty = toNumber(row.qty); + const quantity = qty > 0 ? qty : toNumber(row.quantity); + if (!(quantity > 0)) continue; + + const symbolKey = normalizeExecutionScopeSymbol(row.symbol, provider); + if (!symbolKey) continue; + + const bucket = byExecutionSymbol.get(symbolKey) || []; + bucket.push({ + row, + ts: toTimestamp(row, rowIndex), + idx: rowIndex + }); + byExecutionSymbol.set(symbolKey, bucket); + rowIndex += 1; + } + + let reservedNotional = 0; + + for (const [symbolKey, bucket] of Array.from(byExecutionSymbol.entries())) { + const ordered = [...bucket].sort((a, b) => (a.ts - b.ts) || (a.idx - b.idx)); + const ledgerByTrade = new Map(); + const entrySideByTrade = new Map(); + const openQueueBySide: Record<'BUY' | 'SELL', string[]> = { BUY: [], SELL: [] }; + let syntheticCounter = 0; + + const buildSyntheticTradeId = (side: 'BUY' | 'SELL', ts: number): string => { + syntheticCounter += 1; + const tsToken = Number.isFinite(ts) && ts > 0 ? Math.trunc(ts) : syntheticCounter; + return `__legacy__-${symbolKey}-${side}-${tsToken}-${String(syntheticCounter).padStart(4, '0')}`; + }; + + for (const wrapped of ordered) { + const row = wrapped.row; + const qtyRaw = toNumber(row.qty); + const qty = qtyRaw > 0 ? qtyRaw : toNumber(row.quantity); + if (!(qty > 0)) continue; + + const rowSide = normalizeSide(row.side); + const oppositeSide: 'BUY' | 'SELL' = rowSide === 'BUY' ? 'SELL' : 'BUY'; + const explicitAction = normalizeAction(row.action); + let action = explicitAction; + let tradeId = String(row.trade_id || '').trim(); + + if (!action && !tradeId) { + action = openQueueBySide[oppositeSide].length > 0 ? 'EXIT' : 'ENTRY'; + } + + if (!tradeId) { + if (action === 'EXIT' && openQueueBySide[oppositeSide].length > 0) { + tradeId = openQueueBySide[oppositeSide][0]; + } else { + tradeId = buildSyntheticTradeId(action === 'EXIT' ? oppositeSide : rowSide, wrapped.ts); + } + } + + if (!action) { + action = inferAction(row, entrySideByTrade.get(tradeId)); + } + + let tradeLedger = ledgerByTrade.get(tradeId); + if (!tradeLedger) { + tradeLedger = { + side: rowSide, + entryQty: 0, + entryNotional: 0, + entryLastPrice: 0, + exitQty: 0 + }; + ledgerByTrade.set(tradeId, tradeLedger); + } + + if (action === 'ENTRY') { + if (tradeLedger.entryQty <= EPSILON) { + tradeLedger.side = rowSide; + } + tradeLedger.entryQty += qty; + entrySideByTrade.set(tradeId, tradeLedger.side); + if (!openQueueBySide[tradeLedger.side].includes(tradeId)) { + openQueueBySide[tradeLedger.side].push(tradeId); + } + + const price = toNumber(row.price); + if (price > 0) { + tradeLedger.entryNotional += price * qty; + tradeLedger.entryLastPrice = price; + } + } else { + tradeLedger.exitQty += qty; + const queue = openQueueBySide[oppositeSide]; + const idx = queue.findIndex((queuedTradeId) => queuedTradeId === tradeId); + if (idx >= 0) { + queue.splice(idx, 1); + } else if (queue.length > 0) { + queue.shift(); + } + } + } + + for (const tradeLedger of Array.from(ledgerByTrade.values())) { + const remainingQty = tradeLedger.entryQty - tradeLedger.exitQty; + if (!(remainingQty > EPSILON)) continue; + + const weightedEntryPrice = tradeLedger.entryQty > EPSILON && tradeLedger.entryNotional > EPSILON + ? tradeLedger.entryNotional / tradeLedger.entryQty + : tradeLedger.entryLastPrice; + if (!(weightedEntryPrice > 0)) continue; + + reservedNotional += remainingQty * weightedEntryPrice; + } + } + + return round8(reservedNotional); +}; + +const run = async (): Promise => { + const options = parseArgs(process.argv.slice(2)); + + const supabaseUrl = String(process.env.SUPABASE_URL || '').trim(); + const supabaseKey = String( + process.env.SUPABASE_KEY + || process.env.SUPABASE_SERVICE_ROLE_KEY + || process.env.SUPABASE_ANON_KEY + || '' + ).trim(); + if (!supabaseUrl || !supabaseKey) { + throw new Error('Missing Supabase credentials. Expected SUPABASE_URL + SUPABASE_KEY/SUPABASE_SERVICE_ROLE_KEY.'); + } + + const provider = String(process.env.EXECUTION_PROVIDER || process.env.PROVIDER || 'alpaca').trim().toLowerCase() || 'alpaca'; + const supabase = createClient(supabaseUrl, supabaseKey); + + let profileQuery = supabase + .from('trade_profiles') + .select('id,name,is_active,allocated_capital') + .order('created_at', { ascending: true }); + + if (!options.includeInactive) { + profileQuery = profileQuery.eq('is_active', true); + } + if (options.profileIds.length > 0) { + profileQuery = profileQuery.in('id', options.profileIds); + } + + const { data: profileData, error: profileError } = await profileQuery; + failIfError(profileError, 'load_profiles'); + + const profiles = (profileData || []) as ProfileRow[]; + const profileIds = profiles.map((profile) => String(profile.id || '').trim()).filter(Boolean); + + if (profileIds.length === 0) { + console.log(JSON.stringify({ + mode: options.apply ? 'apply' : 'dry-run', + provider, + profilesProcessed: 0, + message: 'No matching profiles found.' + }, null, 2)); + return; + } + + const { data: ledgerData, error: ledgerError } = await supabase + .from('capital_ledgers') + .select('profile_id,allocated_capital,reserved_for_orders,reserved_for_positions,realized_pnl,updated_at') + .in('profile_id', profileIds); + failIfError(ledgerError, 'load_capital_ledgers'); + + const ledgerByProfile = new Map(); + for (const row of (ledgerData || []) as LedgerRow[]) { + const profileId = String(row.profile_id || '').trim(); + if (!profileId) continue; + ledgerByProfile.set(profileId, row); + } + + const reports: ProfileRepairReport[] = []; + + for (const profile of profiles) { + const profileId = String(profile.id || '').trim(); + if (!profileId) continue; + + const [historyRows, orders] = await Promise.all([ + fetchPagedByProfile(supabase, 'trade_history', 'pnl,reason', profileId), + fetchOrdersForProfile(supabase, profileId) + ]); + + const realizedFromHistory = historyRows.reduce((sum, row) => { + if (!options.includeQuarantinedHistory && isQuarantinedHistoryReason(row.reason)) { + return sum; + } + return sum + toNumber(row.pnl); + }, 0); + + const openOrders = computeReservedForOpenEntryOrders(orders); + const reservedPositions = computeOpenPositionNotional(orders, provider); + + const allocatedCapital = toNumber(profile.allocated_capital); + const currentLedger = ledgerByProfile.get(profileId); + + const currentAllocated = toNumber(currentLedger?.allocated_capital); + const currentReservedOrders = toNumber(currentLedger?.reserved_for_orders); + const currentReservedPositions = toNumber(currentLedger?.reserved_for_positions); + const currentRealized = toNumber(currentLedger?.realized_pnl); + + const targetAllocated = round8(allocatedCapital); + const targetReservedOrders = round8(openOrders.reserved); + const targetReservedPositions = round8(reservedPositions); + const targetRealized = round8(realizedFromHistory); + + const deltaAllocated = round8(targetAllocated - currentAllocated); + const deltaReservedOrders = round8(targetReservedOrders - currentReservedOrders); + const deltaReservedPositions = round8(targetReservedPositions - currentReservedPositions); + const deltaRealized = round8(targetRealized - currentRealized); + + const changed = !currentLedger + || Math.abs(deltaAllocated) > 0.01 + || Math.abs(deltaReservedOrders) > 0.01 + || Math.abs(deltaReservedPositions) > 0.01 + || Math.abs(deltaRealized) > 0.01; + + const utilizationBefore = currentAllocated > 0 + ? ((currentReservedOrders + currentReservedPositions) / currentAllocated) * 100 + : null; + const utilizationAfter = targetAllocated > 0 + ? ((targetReservedOrders + targetReservedPositions) / targetAllocated) * 100 + : null; + + const overAllocatedBefore = currentAllocated > 0 + ? currentReservedOrders + currentReservedPositions > currentAllocated + EPSILON + : false; + const overAllocatedAfter = targetAllocated > 0 + ? targetReservedOrders + targetReservedPositions > targetAllocated + EPSILON + : false; + + reports.push({ + profileId, + profileName: String(profile.name || ''), + isActive: Boolean(profile.is_active), + allocatedCapital: targetAllocated, + ordersAnalyzed: orders.length, + historyRowsAnalyzed: historyRows.length, + openOrderRowsWithoutPrice: openOrders.rowsWithoutPrice, + current: { + allocatedCapital: round8(currentAllocated), + reservedForOrders: round8(currentReservedOrders), + reservedForPositions: round8(currentReservedPositions), + realizedPnl: round8(currentRealized) + }, + target: { + allocatedCapital: targetAllocated, + reservedForOrders: targetReservedOrders, + reservedForPositions: targetReservedPositions, + realizedPnl: targetRealized + }, + delta: { + allocatedCapital: deltaAllocated, + reservedForOrders: deltaReservedOrders, + reservedForPositions: deltaReservedPositions, + realizedPnl: deltaRealized + }, + rawUtilizationBeforePct: utilizationBefore === null ? null : round8(utilizationBefore), + rawUtilizationAfterPct: utilizationAfter === null ? null : round8(utilizationAfter), + overAllocatedBefore, + overAllocatedAfter, + changed + }); + } + + const changedReports = reports.filter((report) => report.changed); + const payload = changedReports.map((report) => ({ + profile_id: report.profileId, + allocated_capital: report.target.allocatedCapital, + reserved_for_orders: report.target.reservedForOrders, + reserved_for_positions: report.target.reservedForPositions, + realized_pnl: report.target.realizedPnl, + updated_at: new Date().toISOString() + })); + + let appliedRows = 0; + if (options.apply && payload.length > 0) { + const { error: upsertError } = await supabase + .from('capital_ledgers') + .upsert(payload, { onConflict: 'profile_id' }); + failIfError(upsertError, 'upsert_capital_ledgers'); + appliedRows = payload.length; + } + + const summary = { + mode: options.apply ? 'apply' : 'dry-run', + provider, + includeInactive: options.includeInactive, + includeQuarantinedHistory: options.includeQuarantinedHistory, + profilesProcessed: reports.length, + profilesChanged: changedReports.length, + appliedRows, + overAllocatedBeforeCount: reports.filter((report) => report.overAllocatedBefore).length, + overAllocatedAfterCount: reports.filter((report) => report.overAllocatedAfter).length, + totals: { + realizedDeltaApplied: round8(changedReports.reduce((sum, report) => sum + report.delta.realizedPnl, 0)), + reservedOrdersDeltaApplied: round8(changedReports.reduce((sum, report) => sum + report.delta.reservedForOrders, 0)), + reservedPositionsDeltaApplied: round8(changedReports.reduce((sum, report) => sum + report.delta.reservedForPositions, 0)) + }, + reports: reports.sort((left, right) => { + const leftScore = Math.abs(left.delta.realizedPnl) + Math.abs(left.delta.reservedForPositions); + const rightScore = Math.abs(right.delta.realizedPnl) + Math.abs(right.delta.reservedForPositions); + return rightScore - leftScore; + }) + }; + + console.log(JSON.stringify(summary, null, 2)); +}; + +run().catch((error) => { + console.error(JSON.stringify({ + error: formatErrorPayload(error) + }, null, 2)); + process.exit(1); +}); diff --git a/backend/reconcileClosedOrderFillData.ts b/backend/reconcileClosedOrderFillData.ts new file mode 100644 index 0000000..df906e6 --- /dev/null +++ b/backend/reconcileClosedOrderFillData.ts @@ -0,0 +1,196 @@ +import 'dotenv/config'; +import Alpaca from '@alpacahq/alpaca-trade-api'; +import { createClient } from '@supabase/supabase-js'; + +type OrderRow = { + id?: string; + order_id?: string | null; + symbol?: string | null; + status?: string | null; + qty?: number | string | null; + price?: number | string | null; + source?: string | null; + created_at?: string | null; + updated_at?: string | null; + filled_at?: string | null; +}; + +const args = process.argv.slice(2); +const applyMode = args.includes('--apply'); +const lookbackArg = args.find((arg) => arg.startsWith('--lookback-hours=')); +const LOOKBACK_HOURS = Number((lookbackArg ? lookbackArg.split('=')[1] : '') || '48'); +const PAGE_SIZE = 1000; +const SYNTHETIC_PREFIXES = ['BFILL-', 'MANOVR-', 'RECON-BF', 'RECON-', 'SYNC-']; + +const supabaseUrl = String(process.env.SUPABASE_URL || '').trim(); +const supabaseKey = String( + process.env.SUPABASE_KEY + || process.env.SUPABASE_SERVICE_ROLE_KEY + || process.env.SUPABASE_ANON_KEY + || '' +).trim(); +const alpacaApiKey = String(process.env.ALPACA_API_KEY || '').trim(); +const alpacaApiSecret = String(process.env.ALPACA_API_SECRET || '').trim(); +const paperTrading = String(process.env.PAPER_TRADING || 'true').toLowerCase() === 'true'; + +if (!supabaseUrl || !supabaseKey) { + throw new Error('Missing Supabase credentials.'); +} +if (!alpacaApiKey || !alpacaApiSecret) { + throw new Error('Missing Alpaca credentials.'); +} +if (!Number.isFinite(LOOKBACK_HOURS) || LOOKBACK_HOURS <= 0) { + throw new Error(`Invalid --lookback-hours value: ${lookbackArg}`); +} + +const supabase = createClient(supabaseUrl, supabaseKey); +const alpaca = new (Alpaca as any)({ + keyId: alpacaApiKey, + secretKey: alpacaApiSecret, + paper: paperTrading +}); + +const toNumber = (value: unknown): number => { + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : 0; +}; + +const normalizeStatus = (value: unknown): string => { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === 'partially-filled') return 'partially_filled'; + return normalized; +}; + +const isSyntheticOrderId = (orderId: string): boolean => { + const normalized = String(orderId || '').trim().toUpperCase(); + if (!normalized) return false; + return SYNTHETIC_PREFIXES.some((prefix) => normalized.startsWith(prefix)); +}; + +const pctDiff = (left: number, right: number): number => { + const denom = Math.max(Math.abs(left), Math.abs(right), 1e-9); + return Math.abs(left - right) / denom; +}; + +const run = async () => { + const sinceIso = new Date(Date.now() - LOOKBACK_HOURS * 60 * 60 * 1000).toISOString(); + const rows: OrderRow[] = []; + let offset = 0; + + for (;;) { + const { data, error } = await supabase + .from('orders') + .select('id,order_id,symbol,status,qty,price,source,created_at,updated_at,filled_at') + .in('status', ['filled', 'partially_filled', 'partially-filled']) + .gte('created_at', sinceIso) + .order('created_at', { ascending: false }) + .range(offset, offset + PAGE_SIZE - 1); + + if (error) throw error; + const chunk = (data || []) as OrderRow[]; + if (!chunk.length) break; + rows.push(...chunk); + if (chunk.length < PAGE_SIZE) break; + offset += PAGE_SIZE; + } + + const candidates = rows.filter((row) => { + const orderId = String(row.order_id || '').trim(); + if (!orderId) return false; + if (isSyntheticOrderId(orderId)) return false; + const source = String(row.source || '').trim().toUpperCase(); + if (source === 'MANUAL') return false; + return true; + }); + + const summary = { + mode: applyMode ? 'apply' : 'dry-run', + lookbackHours: LOOKBACK_HOURS, + sinceIso, + totalRows: rows.length, + candidates: candidates.length, + checked: 0, + exchangeMissing: 0, + noFillOnExchange: 0, + driftDetected: 0, + updated: 0, + samples: [] as Array> + }; + + for (const row of candidates) { + const orderId = String(row.order_id || '').trim(); + summary.checked += 1; + + let exchangeOrder: any; + try { + exchangeOrder = await (alpaca as any).getOrder(orderId); + } catch { + summary.exchangeMissing += 1; + continue; + } + if (!exchangeOrder) { + summary.exchangeMissing += 1; + continue; + } + + const exchangeStatus = normalizeStatus(exchangeOrder.status); + const exchangeQty = toNumber(exchangeOrder.filled_qty ?? exchangeOrder.filledQty ?? exchangeOrder.filled_quantity); + const exchangePrice = toNumber(exchangeOrder.filled_avg_price); + if (!(exchangeQty > 0) || !(exchangePrice > 0)) { + summary.noFillOnExchange += 1; + continue; + } + + const dbStatus = normalizeStatus(row.status); + const dbQty = toNumber(row.qty); + const dbPrice = toNumber(row.price); + const qtyDrift = dbQty <= 0 || pctDiff(exchangeQty, dbQty) > 1e-6; + const priceDrift = dbPrice <= 0 || pctDiff(exchangePrice, dbPrice) > 5e-4; + const statusDrift = exchangeStatus && exchangeStatus !== dbStatus; + if (!qtyDrift && !priceDrift && !statusDrift) continue; + + summary.driftDetected += 1; + if (summary.samples.length < 20) { + summary.samples.push({ + order_id: orderId, + symbol: row.symbol, + db_status: row.status, + ex_status: exchangeOrder.status, + db_qty: dbQty, + ex_qty: exchangeQty, + db_price: dbPrice, + ex_price: exchangePrice + }); + } + + if (!applyMode) continue; + const filledAtRaw = String(exchangeOrder.filled_at || '').trim(); + const parsedFilledAt = filledAtRaw ? Date.parse(filledAtRaw) : NaN; + const filledAtIso = Number.isFinite(parsedFilledAt) && parsedFilledAt > 0 + ? new Date(parsedFilledAt).toISOString() + : new Date().toISOString(); + + const { error: updateError } = await supabase + .from('orders') + .update({ + status: exchangeStatus || 'filled', + qty: exchangeQty, + price: exchangePrice, + filled_at: filledAtIso, + updated_at: new Date().toISOString() + }) + .eq('order_id', orderId); + if (updateError) throw updateError; + summary.updated += 1; + } + + console.log(JSON.stringify(summary, null, 2)); +}; + +run().catch((error) => { + console.error(JSON.stringify({ + error: error instanceof Error ? error.message : String(error) + }, null, 2)); + process.exit(1); +}); + diff --git a/backend/reconcileExitBackfillOnce.ts b/backend/reconcileExitBackfillOnce.ts new file mode 100644 index 0000000..4580c17 --- /dev/null +++ b/backend/reconcileExitBackfillOnce.ts @@ -0,0 +1,217 @@ +import 'dotenv/config'; +import { config, loadDynamicConfig } from '../src/config/index.js'; +import { ConnectorFactory } from '../src/connectors/factory.js'; +import { TradeExecutor } from '../src/services/TradeExecutor.js'; +import { healthTracker } from '../src/services/healthTracker.js'; +import { reconciliationExitBackfillService } from '../src/services/reconciliationExitBackfillService.js'; +import { supabaseService } from '../src/services/SupabaseService.js'; + +type BackfillCliOptions = { + apply: boolean; + profileIds: Set; + ignoreAllowlist: boolean; +}; + +type ProfileSummary = { + profileId: string; + userId: string; + attempted: boolean; + skippedReason?: string; + batchId?: string; + dryRun: boolean; + openTradeCandidates: number; + proposedRows: number; + insertedRows: number; + noGoTrades: number; +}; + +const parseOptions = (argv: string[]): BackfillCliOptions => { + const options: BackfillCliOptions = { + apply: false, + profileIds: new Set(), + ignoreAllowlist: false + }; + + for (const arg of argv) { + if (arg === '--apply') { + options.apply = true; + continue; + } + if (arg === '--ignore-allowlist') { + options.ignoreAllowlist = true; + continue; + } + if (arg.startsWith('--profile=')) { + const value = String(arg.slice('--profile='.length) || '').trim(); + if (value) options.profileIds.add(value); + continue; + } + } + + return options; +}; + +const isPlaceholder = (value: string | undefined): boolean => { + const normalized = String(value || '').trim(); + if (!normalized) return true; + return normalized === 'your_key' || normalized === 'your_secret'; +}; + +const normalizeProfileIds = (profileIds: Set): string[] => { + return Array.from(profileIds) + .map((value) => String(value || '').trim()) + .filter(Boolean); +}; + +const run = async (): Promise => { + const options = parseOptions(process.argv.slice(2)); + await loadDynamicConfig(supabaseService); + + const originalDryRun = config.RECON_EXIT_BACKFILL_DRY_RUN; + const originalAllowlist = [...config.RECON_EXIT_BACKFILL_PROFILE_ALLOWLIST]; + + if (!config.ENABLE_RECON_EXIT_BACKFILL) { + throw new Error('ENABLE_RECON_EXIT_BACKFILL=false. Enable it before running reconciliation EXIT backfill.'); + } + + config.RECON_EXIT_BACKFILL_DRY_RUN = !options.apply; + + if (options.ignoreAllowlist) { + config.RECON_EXIT_BACKFILL_PROFILE_ALLOWLIST = []; + } else if (options.profileIds.size > 0) { + config.RECON_EXIT_BACKFILL_PROFILE_ALLOWLIST = normalizeProfileIds(options.profileIds); + } + + healthTracker.recordTradingControl({ + mode: 'PAUSED', + lastChangedBy: 'maintenance-script', + lastChangedAt: Date.now(), + reason: 'Offline reconciliation EXIT backfill cycle' + }); + + const [users, profiles] = await Promise.all([ + supabaseService.getActiveUsers(), + supabaseService.getActiveProfiles() + ]); + + const userById = new Map(); + for (const user of users || []) { + const userId = String((user as any)?.user_id || '').trim(); + if (!userId) continue; + userById.set(userId, user); + } + + const selectedProfiles = (profiles || []).filter((profile: any) => { + const profileId = String(profile?.id || '').trim(); + if (!profileId) return false; + if (options.profileIds.size === 0) return true; + return options.profileIds.has(profileId); + }); + + const results: ProfileSummary[] = []; + + for (const profile of selectedProfiles) { + const profileId = String(profile?.id || '').trim(); + const userId = String(profile?.user_id || '').trim(); + if (!profileId || !userId) continue; + + const user = userById.get(userId); + if (!user) { + results.push({ + profileId, + userId, + attempted: false, + skippedReason: 'user_not_found', + dryRun: config.RECON_EXIT_BACKFILL_DRY_RUN, + openTradeCandidates: 0, + proposedRows: 0, + insertedRows: 0, + noGoTrades: 0 + }); + continue; + } + + const apiKey = config.PAPER_TRADING ? user.ALPACA_API_KEY : user.REAL_ALPACA_API_KEY; + const apiSecret = config.PAPER_TRADING ? user.ALPACA_SECRET_KEY : user.REAL_ALPACA_SECRET_KEY; + if (isPlaceholder(apiKey) || isPlaceholder(apiSecret)) { + results.push({ + profileId, + userId, + attempted: false, + skippedReason: 'missing_exchange_credentials', + dryRun: config.RECON_EXIT_BACKFILL_DRY_RUN, + openTradeCandidates: 0, + proposedRows: 0, + insertedRows: 0, + noGoTrades: 0 + }); + continue; + } + + const connector = ConnectorFactory.getCustomConnector(config.EXECUTION_PROVIDER, apiKey, apiSecret); + const executor = new TradeExecutor(connector, undefined, userId, profileId); + executor.setProfileSettings(profile); + + try { + const result = await reconciliationExitBackfillService.runProfile({ + profileId, + userId, + executor + }); + + results.push({ + profileId, + userId, + attempted: result.attempted, + skippedReason: result.skippedReason, + batchId: result.batchId, + dryRun: result.dryRun, + openTradeCandidates: result.openTradeCandidates, + proposedRows: result.proposedRows, + insertedRows: result.insertedRows, + noGoTrades: result.noGoTrades + }); + } finally { + executor.dispose(); + } + } + + config.RECON_EXIT_BACKFILL_DRY_RUN = originalDryRun; + config.RECON_EXIT_BACKFILL_PROFILE_ALLOWLIST = originalAllowlist; + + const aggregate = results.reduce( + (acc, row) => { + if (row.attempted) acc.attemptedProfiles += 1; + if (!row.attempted && row.skippedReason) { + acc.skippedProfiles[row.skippedReason] = (acc.skippedProfiles[row.skippedReason] || 0) + 1; + } + acc.proposedRows += row.proposedRows; + acc.insertedRows += row.insertedRows; + acc.noGoTrades += row.noGoTrades; + return acc; + }, + { + attemptedProfiles: 0, + skippedProfiles: {} as Record, + proposedRows: 0, + insertedRows: 0, + noGoTrades: 0 + } + ); + + console.log(JSON.stringify({ + mode: options.apply ? 'apply' : 'dry-run', + profileFilter: normalizeProfileIds(options.profileIds), + ignoreAllowlist: options.ignoreAllowlist, + requirePause: config.RECON_EXIT_BACKFILL_REQUIRE_PAUSE, + dryRunFlagUsed: !options.apply, + aggregate, + results + }, null, 2)); +}; + +run().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + console.error(JSON.stringify({ error: message }, null, 2)); + process.exit(1); +}); diff --git a/backend/reconcileMissingOrderCoverage.ts b/backend/reconcileMissingOrderCoverage.ts new file mode 100644 index 0000000..0ef7ec7 --- /dev/null +++ b/backend/reconcileMissingOrderCoverage.ts @@ -0,0 +1,308 @@ +import 'dotenv/config'; +import { config, loadDynamicConfig } from '../src/config/index.js'; +import { AlpacaConnector } from '../src/connectors/alpaca.js'; +import { TradeExecutor } from '../src/services/TradeExecutor.js'; +import { reconciliationOrderCoverageService } from '../src/services/reconciliationOrderCoverageService.js'; +import { supabaseService } from '../src/services/SupabaseService.js'; + +type CliOptions = { + apply: boolean; + profileIds: Set; + lookbackHours?: number; + fetchLimitPerPage?: number; + maxFetchPages?: number; + maxInsertsPerProfile?: number; + ignoreFeatureFlag: boolean; +}; + +type ProfileSummary = { + profileId: string; + userId: string; + attempted: boolean; + skippedReason?: string; + dryRun: boolean; + scannedOrders: number; + filledLikeOrders: number; + botOwnedOrders: number; + eligibleOrders: number; + missingInDb: number; + insertedRows: number; + skippedNotBotOwned: number; + skippedUnmappedTrade: number; + skippedUnmappedAction: number; + skippedMissingFillData: number; + skippedMissingOrderId: number; + skippedExisting: number; + skippedMaxInsertLimit: number; +}; + +const parseOptions = (argv: string[]): CliOptions => { + const options: CliOptions = { + apply: false, + profileIds: new Set(), + ignoreFeatureFlag: false + }; + + for (const arg of argv) { + if (arg === '--apply') { + options.apply = true; + continue; + } + if (arg === '--ignore-feature-flag') { + options.ignoreFeatureFlag = true; + continue; + } + if (arg.startsWith('--profile=')) { + const profileId = String(arg.slice('--profile='.length) || '').trim(); + if (profileId) options.profileIds.add(profileId); + continue; + } + if (arg.startsWith('--lookback-hours=')) { + const parsed = Number(arg.slice('--lookback-hours='.length)); + if (Number.isFinite(parsed) && parsed > 0) options.lookbackHours = Math.floor(parsed); + continue; + } + if (arg.startsWith('--max-inserts-per-profile=')) { + const parsed = Number(arg.slice('--max-inserts-per-profile='.length)); + if (Number.isFinite(parsed) && parsed > 0) options.maxInsertsPerProfile = Math.floor(parsed); + continue; + } + if (arg.startsWith('--fetch-limit-per-page=')) { + const parsed = Number(arg.slice('--fetch-limit-per-page='.length)); + if (Number.isFinite(parsed) && parsed > 0) options.fetchLimitPerPage = Math.floor(parsed); + continue; + } + if (arg.startsWith('--max-fetch-pages=')) { + const parsed = Number(arg.slice('--max-fetch-pages='.length)); + if (Number.isFinite(parsed) && parsed > 0) options.maxFetchPages = Math.floor(parsed); + continue; + } + } + + return options; +}; + +const isPlaceholder = (value: string | undefined): boolean => { + const normalized = String(value || '').trim(); + if (!normalized) return true; + return normalized === 'your_key' || normalized === 'your_secret'; +}; + +const normalizeProfileIds = (profileIds: Set): string[] => { + return Array.from(profileIds) + .map((value) => String(value || '').trim()) + .filter(Boolean); +}; + +const run = async (): Promise => { + const options = parseOptions(process.argv.slice(2)); + await loadDynamicConfig(supabaseService); + + const originalEnabled = config.ENABLE_RECON_ORDER_COVERAGE_SYNC; + const originalDryRun = config.RECON_ORDER_COVERAGE_DRY_RUN; + const originalLookback = config.RECON_ORDER_COVERAGE_LOOKBACK_HOURS; + const originalFetchLimitPerPage = config.RECON_ORDER_COVERAGE_FETCH_LIMIT_PER_PAGE; + const originalMaxFetchPages = config.RECON_ORDER_COVERAGE_MAX_FETCH_PAGES; + const originalMaxInserts = config.RECON_ORDER_COVERAGE_MAX_INSERTS_PER_PROFILE; + + if (!config.ENABLE_RECON_ORDER_COVERAGE_SYNC && !options.ignoreFeatureFlag) { + throw new Error('ENABLE_RECON_ORDER_COVERAGE_SYNC=false. Enable it or pass --ignore-feature-flag for one-shot run.'); + } + + if (options.ignoreFeatureFlag) { + config.ENABLE_RECON_ORDER_COVERAGE_SYNC = true; + } + config.RECON_ORDER_COVERAGE_DRY_RUN = !options.apply; + if (Number.isFinite(options.lookbackHours)) { + config.RECON_ORDER_COVERAGE_LOOKBACK_HOURS = Number(options.lookbackHours); + } + if (Number.isFinite(options.fetchLimitPerPage)) { + config.RECON_ORDER_COVERAGE_FETCH_LIMIT_PER_PAGE = Number(options.fetchLimitPerPage); + } + if (Number.isFinite(options.maxFetchPages)) { + config.RECON_ORDER_COVERAGE_MAX_FETCH_PAGES = Number(options.maxFetchPages); + } + if (Number.isFinite(options.maxInsertsPerProfile)) { + config.RECON_ORDER_COVERAGE_MAX_INSERTS_PER_PROFILE = Number(options.maxInsertsPerProfile); + } + const effectiveLookbackHours = config.RECON_ORDER_COVERAGE_LOOKBACK_HOURS; + const effectiveFetchLimitPerPage = config.RECON_ORDER_COVERAGE_FETCH_LIMIT_PER_PAGE; + const effectiveMaxFetchPages = config.RECON_ORDER_COVERAGE_MAX_FETCH_PAGES; + const effectiveMaxInsertsPerProfile = config.RECON_ORDER_COVERAGE_MAX_INSERTS_PER_PROFILE; + const effectiveDryRunFlag = config.RECON_ORDER_COVERAGE_DRY_RUN; + + const [users, profiles] = await Promise.all([ + supabaseService.getActiveUsers(), + supabaseService.getActiveProfiles() + ]); + + const userById = new Map(); + for (const user of users || []) { + const userId = String((user as any)?.user_id || '').trim(); + if (!userId) continue; + userById.set(userId, user); + } + + const selectedProfiles = (profiles || []).filter((profile: any) => { + const profileId = String(profile?.id || '').trim(); + if (!profileId) return false; + if (options.profileIds.size === 0) return true; + return options.profileIds.has(profileId); + }); + + const results: ProfileSummary[] = []; + + for (const profile of selectedProfiles) { + const profileId = String(profile?.id || '').trim(); + const userId = String(profile?.user_id || '').trim(); + if (!profileId || !userId) continue; + + const user = userById.get(userId); + if (!user) { + results.push({ + profileId, + userId, + attempted: false, + skippedReason: 'user_not_found', + dryRun: config.RECON_ORDER_COVERAGE_DRY_RUN, + scannedOrders: 0, + filledLikeOrders: 0, + botOwnedOrders: 0, + eligibleOrders: 0, + missingInDb: 0, + insertedRows: 0, + skippedNotBotOwned: 0, + skippedUnmappedTrade: 0, + skippedUnmappedAction: 0, + skippedMissingFillData: 0, + skippedMissingOrderId: 0, + skippedExisting: 0, + skippedMaxInsertLimit: 0 + }); + continue; + } + + const apiKey = config.PAPER_TRADING ? user.ALPACA_API_KEY : user.REAL_ALPACA_API_KEY; + const apiSecret = config.PAPER_TRADING ? user.ALPACA_SECRET_KEY : user.REAL_ALPACA_SECRET_KEY; + if (isPlaceholder(apiKey) || isPlaceholder(apiSecret)) { + results.push({ + profileId, + userId, + attempted: false, + skippedReason: 'missing_exchange_credentials', + dryRun: config.RECON_ORDER_COVERAGE_DRY_RUN, + scannedOrders: 0, + filledLikeOrders: 0, + botOwnedOrders: 0, + eligibleOrders: 0, + missingInDb: 0, + insertedRows: 0, + skippedNotBotOwned: 0, + skippedUnmappedTrade: 0, + skippedUnmappedAction: 0, + skippedMissingFillData: 0, + skippedMissingOrderId: 0, + skippedExisting: 0, + skippedMaxInsertLimit: 0 + }); + continue; + } + + const connector = new AlpacaConnector(apiKey, apiSecret); + const executor = new TradeExecutor(connector, undefined, userId, profileId); + executor.setProfileSettings(profile); + + try { + const result = await reconciliationOrderCoverageService.runProfile({ + profileId, + userId, + executor + }); + results.push({ + profileId, + userId, + attempted: result.attempted, + skippedReason: result.skippedReason, + dryRun: result.dryRun, + scannedOrders: result.scannedOrders, + filledLikeOrders: result.filledLikeOrders, + botOwnedOrders: result.botOwnedOrders, + eligibleOrders: result.eligibleOrders, + missingInDb: result.missingInDb, + insertedRows: result.insertedRows, + skippedNotBotOwned: result.skippedNotBotOwned, + skippedUnmappedTrade: result.skippedUnmappedTrade, + skippedUnmappedAction: result.skippedUnmappedAction, + skippedMissingFillData: result.skippedMissingFillData, + skippedMissingOrderId: result.skippedMissingOrderId, + skippedExisting: result.skippedExisting, + skippedMaxInsertLimit: result.skippedMaxInsertLimit + }); + } finally { + executor.dispose(); + } + } + + config.ENABLE_RECON_ORDER_COVERAGE_SYNC = originalEnabled; + config.RECON_ORDER_COVERAGE_DRY_RUN = originalDryRun; + config.RECON_ORDER_COVERAGE_LOOKBACK_HOURS = originalLookback; + config.RECON_ORDER_COVERAGE_FETCH_LIMIT_PER_PAGE = originalFetchLimitPerPage; + config.RECON_ORDER_COVERAGE_MAX_FETCH_PAGES = originalMaxFetchPages; + config.RECON_ORDER_COVERAGE_MAX_INSERTS_PER_PROFILE = originalMaxInserts; + + const aggregate = results.reduce((acc, row) => { + if (row.attempted) acc.attemptedProfiles += 1; + if (!row.attempted && row.skippedReason) { + acc.skippedProfiles[row.skippedReason] = (acc.skippedProfiles[row.skippedReason] || 0) + 1; + } + acc.scannedOrders += row.scannedOrders; + acc.filledLikeOrders += row.filledLikeOrders; + acc.botOwnedOrders += row.botOwnedOrders; + acc.eligibleOrders += row.eligibleOrders; + acc.missingInDb += row.missingInDb; + acc.insertedRows += row.insertedRows; + acc.skippedNotBotOwned += row.skippedNotBotOwned; + acc.skippedUnmappedTrade += row.skippedUnmappedTrade; + acc.skippedUnmappedAction += row.skippedUnmappedAction; + acc.skippedMissingFillData += row.skippedMissingFillData; + acc.skippedMissingOrderId += row.skippedMissingOrderId; + acc.skippedExisting += row.skippedExisting; + acc.skippedMaxInsertLimit += row.skippedMaxInsertLimit; + return acc; + }, { + attemptedProfiles: 0, + skippedProfiles: {} as Record, + scannedOrders: 0, + filledLikeOrders: 0, + botOwnedOrders: 0, + eligibleOrders: 0, + missingInDb: 0, + insertedRows: 0, + skippedNotBotOwned: 0, + skippedUnmappedTrade: 0, + skippedUnmappedAction: 0, + skippedMissingFillData: 0, + skippedMissingOrderId: 0, + skippedExisting: 0, + skippedMaxInsertLimit: 0 + }); + + console.log(JSON.stringify({ + mode: options.apply ? 'apply' : 'dry-run', + profileFilter: normalizeProfileIds(options.profileIds), + ignoreFeatureFlag: options.ignoreFeatureFlag, + configuredLookbackHours: effectiveLookbackHours, + configuredFetchLimitPerPage: effectiveFetchLimitPerPage, + configuredMaxFetchPages: effectiveMaxFetchPages, + configuredMaxInsertsPerProfile: effectiveMaxInsertsPerProfile, + dryRunFlagUsed: effectiveDryRunFlag, + aggregate, + results + }, null, 2)); +}; + +run().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + console.error(JSON.stringify({ error: message }, null, 2)); + process.exit(1); +}); diff --git a/backend/reconcileNoGoQtyMismatchOnce.ts b/backend/reconcileNoGoQtyMismatchOnce.ts new file mode 100644 index 0000000..a065467 --- /dev/null +++ b/backend/reconcileNoGoQtyMismatchOnce.ts @@ -0,0 +1,425 @@ +import 'dotenv/config'; +import { createHash, randomUUID } from 'crypto'; +import { config, loadDynamicConfig } from '../src/config/index.js'; +import { ConnectorFactory } from '../src/connectors/factory.js'; +import { normalizeOrderAction, normalizeTradeSide } from '../src/domain/tradingEnums.js'; +import { healthTracker } from '../src/services/healthTracker.js'; +import { + ReconciliationBackfillAuditInsert, + ReconciliationBackfillOrderInsert, + supabaseService +} from '../src/services/SupabaseService.js'; +import { TradeExecutor } from '../src/services/TradeExecutor.js'; +import logger from '../src/utils/logger.js'; +import { buildAlpacaSubTag } from '../src/utils/alpacaSubTag.js'; + +type CliOptions = { + apply: boolean; + profileIds: Set; +}; + +type ExchangeEvidence = { + orderId: string; + side: 'BUY' | 'SELL'; + qty: number; + price: number; + filledAtIso: string; +}; + +type TradeSlice = { + symbol: string; + tradeId: string; + entrySide: 'BUY' | 'SELL'; + entryQty: number; + exitQty: number; + openQty: number; +}; + +type ProposedCandidate = { + order: ReconciliationBackfillOrderInsert; + exchangeOrderId: string; +}; + +const EPSILON = 1e-8; +const MAX_LOOKBACK_HOURS = 240; +const NO_GO_REASON = 'missing_fill_evidence_for_large_remainder'; + +const parseOptions = (argv: string[]): CliOptions => { + const out: CliOptions = { + apply: false, + profileIds: new Set() + }; + + for (const arg of argv) { + if (arg === '--apply') { + out.apply = true; + continue; + } + if (arg.startsWith('--profile=')) { + const value = String(arg.slice('--profile='.length) || '').trim(); + if (value) out.profileIds.add(value); + } + } + return out; +}; + +const toNumber = (value: unknown): number => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +}; + +const toTimestampMs = (value: unknown): number => { + if (typeof value === 'number') { + if (Number.isFinite(value) && value > 1_000_000_000_000) return value; + if (Number.isFinite(value) && value > 0) return value * 1000; + return Date.now(); + } + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return Date.now(); + const numeric = Number(trimmed); + if (Number.isFinite(numeric) && numeric > 0) return toTimestampMs(numeric); + const parsed = Date.parse(trimmed); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + return Date.now(); +}; + +const buildBackfillOrderId = ( + profileId: string, + tradeId: string, + exchangeOrderId: string, + filledAtIso: string +): string => { + const digest = createHash('md5') + .update(`${profileId}:${tradeId}:${exchangeOrderId}:${filledAtIso}`) + .digest('hex'); + return `BFILL-${digest}`; +}; + +const expectedExitSide = (entrySide: 'BUY' | 'SELL'): 'BUY' | 'SELL' => { + return entrySide === 'BUY' ? 'SELL' : 'BUY'; +}; + +const isPlaceholder = (value: string | undefined): boolean => { + const normalized = String(value || '').trim(); + if (!normalized) return true; + return normalized === 'your_key' || normalized === 'your_secret'; +}; + +const buildOpenTradeSlices = (rows: any[]): Map => { + const out = new Map(); + const sorted = [...rows].sort((a, b) => { + const ats = toTimestampMs(a.timestamp ?? a.created_at ?? 0); + const bts = toTimestampMs(b.timestamp ?? b.created_at ?? 0); + return ats - bts; + }); + + for (const row of sorted) { + const tradeId = String(row.trade_id || '').trim(); + if (!tradeId) continue; + + const symbol = String(row.symbol || '').trim(); + if (!symbol) continue; + + const qty = toNumber(row.qty ?? row.quantity); + if (!(qty > EPSILON)) continue; + + const side = normalizeTradeSide(String(row.side || 'BUY')); + const explicitAction = normalizeOrderAction(row.action || undefined); + + let slice = out.get(tradeId); + if (!slice) { + slice = { + symbol, + tradeId, + entrySide: side, + entryQty: 0, + exitQty: 0, + openQty: 0 + }; + out.set(tradeId, slice); + } + + const action = explicitAction || (side === slice.entrySide ? 'ENTRY' : 'EXIT'); + if (action === 'ENTRY') { + if (!(slice.entryQty > EPSILON)) { + slice.entrySide = side; + } + slice.entryQty += qty; + } else { + slice.exitQty += qty; + } + slice.openQty = Number((slice.entryQty - slice.exitQty).toFixed(8)); + } + + for (const [tradeId, slice] of Array.from(out.entries())) { + if (!(slice.openQty > EPSILON)) out.delete(tradeId); + } + + return out; +}; + +const normalizeExchangeEvidence = (rows: any[]): Map => { + const out = new Map(); + for (const row of rows || []) { + const orderId = String(row?.id || row?.order_id || '').trim(); + if (!orderId) continue; + const side = normalizeTradeSide(String(row?.side || 'BUY')); + const qty = toNumber(row?.filled_qty ?? row?.filledQty ?? row?.qty ?? row?.amount ?? row?.size); + const price = toNumber(row?.filled_avg_price ?? row?.avg_price ?? row?.price ?? row?.limit_price); + if (!(qty > EPSILON)) continue; + const filledAtMs = toTimestampMs( + row?.filled_at ?? row?.filledAt ?? row?.updated_at ?? row?.closed_at ?? row?.submitted_at ?? row?.timestamp + ); + out.set(orderId, { + orderId, + side, + qty, + price, + filledAtIso: new Date(filledAtMs).toISOString() + }); + } + return out; +}; + +const run = async (): Promise => { + const options = parseOptions(process.argv.slice(2)); + logger.silent = true; + await loadDynamicConfig(supabaseService); + + healthTracker.recordTradingControl({ + mode: 'PAUSED', + lastChangedBy: 'maintenance-script', + lastChangedAt: Date.now(), + reason: 'NO_GO mismatch repair cycle' + }); + + const [users, profiles] = await Promise.all([ + supabaseService.getActiveUsers(), + supabaseService.getActiveProfiles() + ]); + + const userById = new Map(); + for (const user of users || []) { + const userId = String((user as any)?.user_id || '').trim(); + if (!userId) continue; + userById.set(userId, user); + } + + const noGoAudit = await supabaseService.getReconciliationBackfillAuditRows({ + decisions: ['NO_GO'], + limit: 1000, + offset: 0 + }); + + const seenNoGoTrade = new Set(); + const noGoTradeByProfile = new Map>(); + for (const row of noGoAudit.rows || []) { + const reason = String(row.reason || '').trim(); + if (reason !== NO_GO_REASON) continue; + const tradeId = String(row.trade_id || '').trim(); + const profileId = String(row.profile_id || '').trim(); + if (!tradeId || !profileId) continue; + if (options.profileIds.size > 0 && !options.profileIds.has(profileId)) continue; + const dedupe = `${profileId}::${tradeId}`; + if (seenNoGoTrade.has(dedupe)) continue; + seenNoGoTrade.add(dedupe); + const list = noGoTradeByProfile.get(profileId) || new Set(); + list.add(tradeId); + noGoTradeByProfile.set(profileId, list); + } + + const batchId = `RECON-BFILL-NOGO-MISMATCH-${randomUUID()}`; + const proposedRows: ProposedCandidate[] = []; + const auditRows: ReconciliationBackfillAuditInsert[] = []; + const skipped: Array> = []; + + for (const profile of profiles || []) { + const profileId = String(profile?.id || '').trim(); + if (!profileId) continue; + const targetTrades = noGoTradeByProfile.get(profileId); + if (!targetTrades || targetTrades.size === 0) continue; + + const userId = String(profile?.user_id || '').trim(); + const user = userById.get(userId); + if (!user) { + skipped.push({ profileId, reason: 'user_not_found' }); + continue; + } + + const apiKey = config.PAPER_TRADING ? user.ALPACA_API_KEY : user.REAL_ALPACA_API_KEY; + const apiSecret = config.PAPER_TRADING ? user.ALPACA_SECRET_KEY : user.REAL_ALPACA_SECRET_KEY; + if (isPlaceholder(apiKey) || isPlaceholder(apiSecret)) { + skipped.push({ profileId, reason: 'missing_exchange_credentials' }); + continue; + } + + const lifecycleRows = await supabaseService.getFilledLifecycleOrdersForProfile(profileId); + const slices = buildOpenTradeSlices(lifecycleRows); + const symbols = Array.from(new Set( + Array.from(targetTrades.values()) + .map((tradeId) => slices.get(tradeId)?.symbol) + .filter(Boolean) as string[] + )); + if (symbols.length === 0) continue; + + const connector = ConnectorFactory.getCustomConnector(config.EXECUTION_PROVIDER, apiKey, apiSecret); + const executor = new TradeExecutor(connector, undefined, userId, profileId); + executor.setProfileSettings(profile); + + try { + const exchangeRows = await executor.fetchExchangeClosedOrders(symbols, MAX_LOOKBACK_HOURS); + const evidenceByOrderId = normalizeExchangeEvidence(exchangeRows || []); + + for (const tradeId of targetTrades.values()) { + const slice = slices.get(tradeId); + if (!slice || !(slice.openQty > EPSILON)) continue; + + const expectedSide = expectedExitSide(slice.entrySide); + const exitRows = lifecycleRows.filter((row) => { + return String(row.trade_id || '').trim() === tradeId + && normalizeOrderAction(row.action || undefined) === 'EXIT' + && (normalizeTradeSide(String(row.side || '')) === expectedSide); + }); + + let remaining = slice.openQty; + let evidenceUsed = 0; + + for (const exitRow of exitRows) { + if (!(remaining > EPSILON)) break; + const orderId = String(exitRow.order_id || '').trim(); + if (!orderId) continue; + const evidence = evidenceByOrderId.get(orderId); + if (!evidence) continue; + if (evidence.side !== expectedSide) continue; + + const dbQty = toNumber(exitRow.qty ?? exitRow.quantity); + const exchangeQty = toNumber(evidence.qty); + const missingFromOrder = Number((exchangeQty - dbQty).toFixed(8)); + if (!(missingFromOrder > EPSILON)) continue; + + const applyQty = Math.min(remaining, missingFromOrder); + if (!(applyQty > EPSILON)) continue; + + const filledAtIso = evidence.filledAtIso; + const backfillOrderId = buildBackfillOrderId(profileId, tradeId, orderId, filledAtIso); + const subTag = buildAlpacaSubTag({ + profileId, + tradeId, + intent: 'EXIT' + }) || undefined; + + proposedRows.push({ + order: { + user_id: userId, + profile_id: profileId, + order_id: backfillOrderId, + symbol: slice.symbol, + type: 'market', + side: expectedSide, + qty: Number(applyQty.toFixed(8)), + quantity: Number(applyQty.toFixed(8)), + price: Number(toNumber(evidence.price).toFixed(8)), + status: 'filled', + timestamp: toTimestampMs(filledAtIso), + filled_at: filledAtIso, + trade_id: tradeId, + action: 'EXIT', + source: 'BOT', + sub_tag: subTag + }, + exchangeOrderId: orderId + }); + + auditRows.push({ + batch_id: batchId, + profile_id: profileId, + symbol: slice.symbol, + trade_id: tradeId, + exchange_order_id: orderId, + exchange_client_order_id: null, + backfill_order_id: backfillOrderId, + filled_qty: Number(applyQty.toFixed(8)), + filled_price: Number(toNumber(evidence.price).toFixed(8)), + filled_at: filledAtIso, + dry_run: !options.apply, + decision: options.apply ? 'PENDING_APPLY' : 'DRY_RUN', + reason: 'existing_exit_order_qty_mismatch', + metadata: { + openQtyBefore: slice.openQty, + expectedSide, + dbExitQty: dbQty, + exchangeFilledQty: exchangeQty, + missingFromOrder, + applyQty + } + }); + + remaining = Number((remaining - applyQty).toFixed(8)); + evidenceUsed += 1; + } + + if (remaining > EPSILON) { + skipped.push({ + profileId, + tradeId, + symbol: slice.symbol, + reason: 'insufficient_same_order_evidence', + remaining, + evidenceUsed + }); + } + } + } finally { + executor.dispose(); + } + } + + await supabaseService.insertReconciliationBackfillAuditRows(auditRows); + + let insertedRows = 0; + if (options.apply && proposedRows.length > 0) { + const proposedOrderIds = proposedRows.map((row) => row.order.order_id); + const existingBefore = await supabaseService.getExistingOrderIds(proposedOrderIds); + const ok = await supabaseService.upsertReconciliationBackfillOrders(proposedRows.map((row) => row.order)); + if (!ok) throw new Error('Failed to upsert mismatch backfill rows.'); + const existingAfter = await supabaseService.getExistingOrderIds(proposedOrderIds); + insertedRows = proposedRows.filter((row) => !existingBefore.has(row.order.order_id) && existingAfter.has(row.order.order_id)).length; + + const postAuditRows: ReconciliationBackfillAuditInsert[] = proposedRows.map((row) => ({ + batch_id: batchId, + profile_id: row.order.profile_id, + symbol: row.order.symbol, + trade_id: row.order.trade_id, + exchange_order_id: row.exchangeOrderId, + exchange_client_order_id: null, + backfill_order_id: row.order.order_id, + filled_qty: row.order.qty, + filled_price: row.order.price, + filled_at: row.order.filled_at || null, + dry_run: false, + decision: existingBefore.has(row.order.order_id) ? 'SKIP_EXISTING' : 'APPLIED', + reason: existingBefore.has(row.order.order_id) ? 'already_exists' : 'inserted_existing_exit_order_qty_mismatch', + metadata: { + matchedBy: 'existing_exit_order_qty_mismatch' + }, + applied_at: !existingBefore.has(row.order.order_id) ? new Date().toISOString() : null + })); + await supabaseService.insertReconciliationBackfillAuditRows(postAuditRows); + } + + console.log(JSON.stringify({ + mode: options.apply ? 'apply' : 'dry-run', + batchId, + proposedRows: proposedRows.length, + insertedRows, + skipped + }, null, 2)); +}; + +run().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + console.error(JSON.stringify({ error: message }, null, 2)); + process.exit(1); +}); diff --git a/backend/reconcileSubTagRepair.ts b/backend/reconcileSubTagRepair.ts new file mode 100644 index 0000000..29d26ba --- /dev/null +++ b/backend/reconcileSubTagRepair.ts @@ -0,0 +1,183 @@ +import 'dotenv/config'; +import { config, loadDynamicConfig } from '../src/config/index.js'; +import { reconciliationSubTagRepairService } from '../src/services/reconciliationSubTagRepairService.js'; +import { supabaseService } from '../src/services/SupabaseService.js'; + +type CliOptions = { + apply: boolean; + profileIds: Set; + lookbackHours?: number; + maxUpdatesPerProfile?: number; + ignoreFeatureFlag: boolean; +}; + +type ProfileSummary = { + profileId: string; + userId: string; + attempted: boolean; + skippedReason?: string; + unsupported?: boolean; + dryRun: boolean; + scannedRows: number; + eligibleRows: number; + updatedRows: number; + skippedNoProfile: number; + skippedNoTrade: number; + skippedTagDisabled: number; + skippedAlreadyTagged: number; +}; + +const parseOptions = (argv: string[]): CliOptions => { + const options: CliOptions = { + apply: false, + profileIds: new Set(), + ignoreFeatureFlag: false + }; + + for (const arg of argv) { + if (arg === '--apply') { + options.apply = true; + continue; + } + if (arg === '--ignore-feature-flag') { + options.ignoreFeatureFlag = true; + continue; + } + if (arg.startsWith('--profile=')) { + const profileId = String(arg.slice('--profile='.length) || '').trim(); + if (profileId) options.profileIds.add(profileId); + continue; + } + if (arg.startsWith('--lookback-hours=')) { + const parsed = Number(arg.slice('--lookback-hours='.length)); + if (Number.isFinite(parsed) && parsed > 0) options.lookbackHours = Math.floor(parsed); + continue; + } + if (arg.startsWith('--max-updates-per-profile=')) { + const parsed = Number(arg.slice('--max-updates-per-profile='.length)); + if (Number.isFinite(parsed) && parsed > 0) options.maxUpdatesPerProfile = Math.floor(parsed); + continue; + } + } + + return options; +}; + +const normalizeProfileIds = (profileIds: Set): string[] => { + return Array.from(profileIds) + .map((value) => String(value || '').trim()) + .filter(Boolean); +}; + +const run = async (): Promise => { + const options = parseOptions(process.argv.slice(2)); + await loadDynamicConfig(supabaseService); + + const originalEnabled = config.ENABLE_RECON_SUBTAG_REPAIR; + const originalDryRun = config.RECON_SUBTAG_REPAIR_DRY_RUN; + const originalLookback = config.RECON_SUBTAG_REPAIR_LOOKBACK_HOURS; + const originalMaxUpdates = config.RECON_SUBTAG_REPAIR_MAX_UPDATES_PER_PROFILE; + + if (!config.ENABLE_RECON_SUBTAG_REPAIR && !options.ignoreFeatureFlag) { + throw new Error('ENABLE_RECON_SUBTAG_REPAIR=false. Enable it or pass --ignore-feature-flag for one-shot run.'); + } + + if (options.ignoreFeatureFlag) { + config.ENABLE_RECON_SUBTAG_REPAIR = true; + } + config.RECON_SUBTAG_REPAIR_DRY_RUN = !options.apply; + if (Number.isFinite(options.lookbackHours)) { + config.RECON_SUBTAG_REPAIR_LOOKBACK_HOURS = Number(options.lookbackHours); + } + if (Number.isFinite(options.maxUpdatesPerProfile)) { + config.RECON_SUBTAG_REPAIR_MAX_UPDATES_PER_PROFILE = Number(options.maxUpdatesPerProfile); + } + + const effectiveDryRun = config.RECON_SUBTAG_REPAIR_DRY_RUN; + const effectiveLookback = config.RECON_SUBTAG_REPAIR_LOOKBACK_HOURS; + const effectiveMaxUpdates = config.RECON_SUBTAG_REPAIR_MAX_UPDATES_PER_PROFILE; + + const profiles = await supabaseService.getActiveProfiles(); + const selectedProfiles = (profiles || []).filter((profile: any) => { + const profileId = String(profile?.id || '').trim(); + if (!profileId) return false; + if (options.profileIds.size === 0) return true; + return options.profileIds.has(profileId); + }); + + const results: ProfileSummary[] = []; + for (const profile of selectedProfiles) { + const profileId = String(profile?.id || '').trim(); + const userId = String(profile?.user_id || '').trim(); + if (!profileId || !userId) continue; + + const result = await reconciliationSubTagRepairService.runProfile({ + profileId, + userId + }); + results.push({ + profileId, + userId, + attempted: result.attempted, + skippedReason: result.skippedReason, + unsupported: result.unsupported, + dryRun: result.dryRun, + scannedRows: result.scannedRows, + eligibleRows: result.eligibleRows, + updatedRows: result.updatedRows, + skippedNoProfile: result.skippedNoProfile, + skippedNoTrade: result.skippedNoTrade, + skippedTagDisabled: result.skippedTagDisabled, + skippedAlreadyTagged: result.skippedAlreadyTagged + }); + } + + config.ENABLE_RECON_SUBTAG_REPAIR = originalEnabled; + config.RECON_SUBTAG_REPAIR_DRY_RUN = originalDryRun; + config.RECON_SUBTAG_REPAIR_LOOKBACK_HOURS = originalLookback; + config.RECON_SUBTAG_REPAIR_MAX_UPDATES_PER_PROFILE = originalMaxUpdates; + + const aggregate = results.reduce((acc, row) => { + if (row.attempted) acc.attemptedProfiles += 1; + if (!row.attempted && row.skippedReason) { + acc.skippedProfiles[row.skippedReason] = (acc.skippedProfiles[row.skippedReason] || 0) + 1; + } + if (row.unsupported) acc.unsupportedProfiles += 1; + acc.scannedRows += row.scannedRows; + acc.eligibleRows += row.eligibleRows; + acc.updatedRows += row.updatedRows; + acc.skippedNoProfile += row.skippedNoProfile; + acc.skippedNoTrade += row.skippedNoTrade; + acc.skippedTagDisabled += row.skippedTagDisabled; + acc.skippedAlreadyTagged += row.skippedAlreadyTagged; + return acc; + }, { + attemptedProfiles: 0, + skippedProfiles: {} as Record, + unsupportedProfiles: 0, + scannedRows: 0, + eligibleRows: 0, + updatedRows: 0, + skippedNoProfile: 0, + skippedNoTrade: 0, + skippedTagDisabled: 0, + skippedAlreadyTagged: 0 + }); + + console.log(JSON.stringify({ + mode: options.apply ? 'apply' : 'dry-run', + profileFilter: normalizeProfileIds(options.profileIds), + ignoreFeatureFlag: options.ignoreFeatureFlag, + configuredLookbackHours: effectiveLookback, + configuredMaxUpdatesPerProfile: effectiveMaxUpdates, + dryRunFlagUsed: effectiveDryRun, + aggregate, + results + }, null, 2)); +}; + +run().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + console.error(JSON.stringify({ error: message }, null, 2)); + process.exit(1); +}); diff --git a/backend/reconcileTradeHistoryLifecycle.ts b/backend/reconcileTradeHistoryLifecycle.ts new file mode 100644 index 0000000..9a48d8d --- /dev/null +++ b/backend/reconcileTradeHistoryLifecycle.ts @@ -0,0 +1,372 @@ +import 'dotenv/config'; +import { createClient } from '@supabase/supabase-js'; + +type OrderRow = { + id: string; + order_id?: string | null; + user_id?: string | null; + profile_id?: string | null; + symbol?: string | null; + trade_id?: string | null; + action?: string | null; + side?: string | null; + qty?: number | string | null; + price?: number | string | null; + status?: string | null; + created_at?: string | null; +}; + +type HistoryRow = { + id: string; + user_id?: string | null; + profile_id?: string | null; + symbol?: string | null; + trade_id?: string | null; + side?: string | null; + size?: number | string | null; + entry_price?: number | string | null; + exit_price?: number | string | null; + pnl?: number | string | null; + pnl_percent?: number | string | null; + reason?: string | null; + source?: string | null; + timestamp?: number | string | null; + created_at?: string | null; +}; + +type CanonicalTrade = { + tradeId: string; + userId: string | null; + profileId: string | null; + symbol: string; + side: 'BUY' | 'SELL'; + closedQty: number; + avgEntry: number; + avgExit: number; + pnl: number; + pnlPercent: number; +}; + +const PAGE_SIZE = 1000; +const EPS = 1e-8; +const DEFAULT_START = '2026-02-12T00:00:00.000Z'; +const CANONICAL_REASON = '[RECONCILED_CANONICAL] Order lifecycle reconciled from orders'; +const ZEROED_PREFIX = '[RECONCILED_TO_ORDERS]'; + +const args = process.argv.slice(2); +const applyMode = args.includes('--apply'); +const startArg = args.find((arg) => arg.startsWith('--start=')); +const startIso = startArg ? String(startArg.split('=')[1] || '').trim() : DEFAULT_START; +const tradeIds = args.filter((arg) => !arg.startsWith('--')); + +const supabaseUrl = String(process.env.SUPABASE_URL || '').trim(); +const supabaseKey = String( + process.env.SUPABASE_KEY + || process.env.SUPABASE_SERVICE_ROLE_KEY + || process.env.SUPABASE_ANON_KEY + || '' +).trim(); + +if (!supabaseUrl || !supabaseKey) { + throw new Error('Missing Supabase credentials. Expected SUPABASE_URL + SUPABASE_KEY/SUPABASE_SERVICE_ROLE_KEY.'); +} + +const supabase = createClient(supabaseUrl, supabaseKey); + +const toNumber = (value: unknown): number => { + const num = Number(value); + return Number.isFinite(num) ? num : 0; +}; + +const normalizeSide = (side: string | null | undefined): 'BUY' | 'SELL' => { + const normalized = String(side || '').trim().toUpperCase(); + return normalized === 'SELL' || normalized === 'SHORT' ? 'SELL' : 'BUY'; +}; + +const normalizeAction = (action: string | null | undefined): 'ENTRY' | 'EXIT' | undefined => { + const normalized = String(action || '').trim().toUpperCase(); + if (normalized === 'ENTRY' || normalized === 'EXIT') return normalized; + return undefined; +}; + +const inferAction = (row: OrderRow): 'ENTRY' | 'EXIT' | undefined => { + const explicit = normalizeAction(row.action); + if (explicit) return explicit; + if (String(row.trade_id || '').trim().length === 0) return undefined; + return normalizeSide(row.side) === 'BUY' ? 'ENTRY' : 'EXIT'; +}; + +const startsWithIgnoreCase = (value: string | null | undefined, prefix: string): boolean => { + return String(value || '').toLowerCase().startsWith(prefix.toLowerCase()); +}; + +const sortByCreatedAt = (rows: T[]): T[] => { + return [...rows].sort((a, b) => { + const aTs = Date.parse(String(a.created_at || '')) || 0; + const bTs = Date.parse(String(b.created_at || '')) || 0; + return aTs - bTs; + }); +}; + +const fetchPaged = async (table: 'orders' | 'trade_history', columns: string): Promise => { + const out: T[] = []; + let offset = 0; + + for (;;) { + const { data, error } = await supabase + .from(table) + .select(columns) + .gte('created_at', startIso) + .order('created_at', { ascending: true }) + .range(offset, offset + PAGE_SIZE - 1); + + if (error) throw error; + const chunk = (data || []) as T[]; + if (!chunk.length) break; + out.push(...chunk); + if (chunk.length < PAGE_SIZE) break; + offset += PAGE_SIZE; + } + + return out; +}; + +const getCanonicalFromOrders = (tradeId: string, rows: OrderRow[]): CanonicalTrade | null => { + const ordered = sortByCreatedAt(rows); + if (!ordered.length) return null; + + type Lot = { qty: number; price: number }; + const lots: Lot[] = []; + let entrySide: 'BUY' | 'SELL' | null = null; + let closedQty = 0; + let closedEntryNotional = 0; + let closedExitNotional = 0; + let realizedPnl = 0; + + let userId: string | null = null; + let profileId: string | null = null; + let symbol = ''; + + for (const row of ordered) { + const qty = toNumber(row.qty); + const price = toNumber(row.price); + if (!(qty > 0) || !(price > 0)) continue; + + if (!userId && row.user_id) userId = row.user_id; + if (profileId === null && row.profile_id !== undefined) profileId = row.profile_id || null; + if (!symbol) symbol = String(row.symbol || '').trim(); + + const action = inferAction(row); + if (!action) continue; + + const side = normalizeSide(row.side); + if (action === 'ENTRY') { + if (!entrySide) entrySide = side; + if (side !== entrySide) continue; + lots.push({ qty, price }); + continue; + } + + if (!entrySide) continue; + const expectedExitSide = entrySide === 'BUY' ? 'SELL' : 'BUY'; + if (side !== expectedExitSide) continue; + + let remaining = qty; + while (remaining > EPS && lots.length > 0) { + const lot = lots[0]; + const closeQty = Math.min(remaining, lot.qty); + if (closeQty <= EPS) break; + + lot.qty -= closeQty; + remaining -= closeQty; + closedQty += closeQty; + closedEntryNotional += closeQty * lot.price; + closedExitNotional += closeQty * price; + realizedPnl += entrySide === 'BUY' + ? (price - lot.price) * closeQty + : (lot.price - price) * closeQty; + + if (lot.qty <= EPS) lots.shift(); + } + } + + if (!(closedQty > EPS) || !(closedEntryNotional > 0) || !(closedExitNotional > 0) || !entrySide) { + return null; + } + + const avgEntry = closedEntryNotional / closedQty; + const avgExit = closedExitNotional / closedQty; + const pnlPercent = avgEntry > 0 + ? ((avgExit - avgEntry) / avgEntry) * 100 * (entrySide === 'BUY' ? 1 : -1) + : 0; + + return { + tradeId, + userId, + profileId, + symbol, + side: entrySide, + closedQty, + avgEntry, + avgExit, + pnl: realizedPnl, + pnlPercent + }; +}; + +const run = async (): Promise => { + const orderColumns = 'id,order_id,user_id,profile_id,symbol,trade_id,action,side,qty,price,status,created_at'; + const historyColumns = 'id,user_id,profile_id,symbol,trade_id,side,size,entry_price,exit_price,pnl,pnl_percent,reason,source,timestamp,created_at'; + + let orderRows: OrderRow[] = []; + let historyRows: HistoryRow[] = []; + + if (tradeIds.length > 0) { + for (const tradeId of tradeIds) { + const { data: oData, error: oError } = await supabase + .from('orders') + .select(orderColumns) + .eq('trade_id', tradeId) + .in('status', ['filled', 'partially_filled']) + .order('created_at', { ascending: true }) + .limit(5000); + if (oError) throw oError; + orderRows.push(...((oData || []) as OrderRow[])); + + const { data: hData, error: hError } = await supabase + .from('trade_history') + .select(historyColumns) + .eq('trade_id', tradeId) + .order('created_at', { ascending: true }) + .limit(5000); + if (hError) throw hError; + historyRows.push(...((hData || []) as HistoryRow[])); + } + } else { + orderRows = await fetchPaged('orders', orderColumns); + orderRows = orderRows.filter((row) => ['filled', 'partially_filled'].includes(String(row.status || '').toLowerCase())); + historyRows = await fetchPaged('trade_history', historyColumns); + } + + const ordersByTrade = new Map(); + for (const row of orderRows) { + const tradeId = String(row.trade_id || '').trim(); + if (!tradeId) continue; + const list = ordersByTrade.get(tradeId) || []; + list.push(row); + ordersByTrade.set(tradeId, list); + } + + const historyByTrade = new Map(); + for (const row of historyRows) { + const tradeId = String(row.trade_id || '').trim(); + if (!tradeId) continue; + const list = historyByTrade.get(tradeId) || []; + list.push(row); + historyByTrade.set(tradeId, list); + } + + const targets: Array<{ + tradeId: string; + canonical: CanonicalTrade; + currentPnl: number; + diff: number; + history: HistoryRow[]; + }> = []; + + for (const [tradeId, rows] of ordersByTrade.entries()) { + const canonical = getCanonicalFromOrders(tradeId, rows); + if (!canonical) continue; + const history = historyByTrade.get(tradeId) || []; + const currentPnl = history.reduce((sum, row) => sum + toNumber(row.pnl), 0); + const diff = Number((canonical.pnl - currentPnl).toFixed(8)); + if (Math.abs(diff) <= 0.02) continue; + + targets.push({ + tradeId, + canonical, + currentPnl, + diff, + history + }); + } + + targets.sort((a, b) => Math.abs(b.diff) - Math.abs(a.diff)); + + console.log(JSON.stringify({ + mode: applyMode ? 'apply' : 'dry-run', + startIso, + explicitTradeIds: tradeIds.length > 0 ? tradeIds : null, + totalOrders: orderRows.length, + totalHistory: historyRows.length, + targets: targets.map((target) => ({ + trade_id: target.tradeId, + current_history_pnl: Number(target.currentPnl.toFixed(8)), + canonical_order_pnl: Number(target.canonical.pnl.toFixed(8)), + diff_order_minus_history: target.diff, + history_rows: target.history.length + })) + }, null, 2)); + + if (!applyMode || targets.length === 0) return; + + for (const target of targets) { + const rowsToNeutralize = target.history.filter((row) => { + const pnl = toNumber(row.pnl); + if (Math.abs(pnl) <= EPS) return false; + return !startsWithIgnoreCase(row.reason, ZEROED_PREFIX) + && !startsWithIgnoreCase(row.reason, CANONICAL_REASON); + }); + + for (const row of rowsToNeutralize) { + const nextReason = startsWithIgnoreCase(row.reason, ZEROED_PREFIX) + ? String(row.reason || '') + : `${ZEROED_PREFIX} ${String(row.reason || 'Lifecycle row superseded by canonical order-derived aggregate').trim()}`; + + const { error } = await supabase + .from('trade_history') + .update({ + pnl: 0, + pnl_percent: 0, + reason: nextReason + }) + .eq('id', row.id); + if (error) throw error; + } + + const canonicalPayload = { + user_id: target.canonical.userId, + profile_id: target.canonical.profileId, + symbol: target.canonical.symbol, + trade_id: target.canonical.tradeId, + side: target.canonical.side, + size: Number(target.canonical.closedQty.toFixed(8)), + entry_price: Number(target.canonical.avgEntry.toFixed(8)), + exit_price: Number(target.canonical.avgExit.toFixed(8)), + pnl: Number(target.canonical.pnl.toFixed(8)), + pnl_percent: Number(target.canonical.pnlPercent.toFixed(8)), + reason: CANONICAL_REASON, + source: 'BOT', + timestamp: Date.now() + }; + + const existingCanonical = target.history.find((row) => startsWithIgnoreCase(row.reason, CANONICAL_REASON)); + if (existingCanonical) { + const { error } = await supabase + .from('trade_history') + .update(canonicalPayload) + .eq('id', existingCanonical.id); + if (error) throw error; + } else { + const { error } = await supabase + .from('trade_history') + .insert([canonicalPayload]); + if (error) throw error; + } + } +}; + +run().catch((error) => { + console.error('[reconcileTradeHistoryLifecycle] failed:', error); + process.exit(1); +}); diff --git a/backend/rename_profile.ts b/backend/rename_profile.ts new file mode 100644 index 0000000..058f73d --- /dev/null +++ b/backend/rename_profile.ts @@ -0,0 +1,33 @@ + +import { supabaseService } from '../src/services/SupabaseService.js'; +import logger from '../src/utils/logger.js'; + +async function updateProfileName() { + const profiles = await supabaseService.getActiveProfiles(); + if (profiles.length === 0) return; + + const targetProfile = profiles[0]; // The one we modified + const newName = "High Risk Scalper ⚡"; + + // Update Name in DB + // @ts-ignore + await supabaseService.client + .from('trade_profiles') + .update({ name: newName }) + .eq('id', targetProfile.id); + + logger.info(`✅ Profile Renamed to: [${newName}]`); + logger.info(`📋 Active Rules for this Profile:`); + + // Display Rules + const config = targetProfile.strategy_config; + if (config && config.rules) { + config.rules.forEach((r: any) => { + logger.info(` - ${r.ruleId}: ${r.enabled ? '🟢 ENABLED' : '🔴 OFF'}`); + }); + } else { + logger.info(' (Using Default Global Rules)'); + } +} + +updateProfileName(); diff --git a/backend/runCoverageSuite.ts b/backend/runCoverageSuite.ts new file mode 100644 index 0000000..9c0ecff --- /dev/null +++ b/backend/runCoverageSuite.ts @@ -0,0 +1,67 @@ +import path from 'node:path'; +import { spawn } from 'node:child_process'; + +const suite = [ + 'scripts/testTradeExecutorLifecycle.ts', + 'scripts/testLifecycleRegressions.ts', + 'scripts/testOrderStatusSyncRegressions.ts', + 'scripts/testSupabaseOrderPersistenceRegressions.ts', + 'scripts/testFailureInjection.ts', + 'src/scripts/verifyWebsocketContract.ts', + 'scripts/testManualTraderCapitalGuard.ts', + 'scripts/testSupabaseTradeHistorySourceFallback.ts', + 'scripts/testStateMergeCoverage.ts', + 'scripts/testBacktestIsolation.ts', + 'scripts/testCoreModuleCoverage.ts', + 'scripts/testConnectorAndAiCoverage.ts' +]; + +const runScript = (relativePath: string): Promise => + new Promise((resolve) => { + const scriptPath = path.resolve(process.cwd(), relativePath); + const child = spawn( + process.execPath, + ['--loader', 'ts-node/esm', scriptPath], + { + stdio: 'inherit', + shell: false, + env: { + ...process.env, + TS_NODE_TRANSPILE_ONLY: '1' + } + } + ); + + child.on('close', (code) => resolve(code ?? 1)); + child.on('error', () => resolve(1)); + }); + +const main = async () => { + console.log('\n[coverage-suite] Running coverage-friendly regression suite...\n'); + const failed: string[] = []; + + for (const item of suite) { + console.log(`\n[coverage-suite] ▶ ${item}`); + const code = await runScript(item); + if (code === 0) { + console.log(`[coverage-suite] ✅ ${item} passed`); + } else { + failed.push(item); + console.log(`[coverage-suite] ❌ ${item} failed with exit code ${code}`); + } + } + + console.log('\n[coverage-suite] Summary'); + console.log(`[coverage-suite] Passed: ${suite.length - failed.length}`); + console.log(`[coverage-suite] Failed: ${failed.length}`); + if (failed.length > 0) { + console.log('[coverage-suite] Failed scripts:'); + failed.forEach((item) => console.log(`- ${item}`)); + process.exit(1); + } +}; + +main().catch((error) => { + console.error('[coverage-suite] Unhandled error', error); + process.exit(1); +}); diff --git a/backend/runCriticalCoverageSuite.ts b/backend/runCriticalCoverageSuite.ts new file mode 100644 index 0000000..6170f09 --- /dev/null +++ b/backend/runCriticalCoverageSuite.ts @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import { ConnectorFactory } from '../src/connectors/factory.js'; +import { + normalizeOrderAction, + normalizeOrderStatus, + normalizeOrderType, + normalizeTradeSide +} from '../src/domain/tradingEnums.js'; +import { SymbolMapper } from '../src/utils/symbolMapper.js'; + +const assertTradingEnums = () => { + assert.equal(normalizeTradeSide('SELL'), 'SELL'); + assert.equal(normalizeTradeSide('short'), 'SELL'); + assert.equal(normalizeTradeSide('BUY'), 'BUY'); + assert.equal(normalizeTradeSide('unknown'), 'BUY'); + + assert.equal(normalizeOrderStatus('filled'), 'filled'); + assert.equal(normalizeOrderStatus('partially_filled'), 'partially_filled'); + assert.equal(normalizeOrderStatus('partial_fill'), 'partially_filled'); + assert.equal(normalizeOrderStatus('partiallyfilled'), 'partially_filled'); + assert.equal(normalizeOrderStatus('cancelled'), 'canceled'); + assert.equal(normalizeOrderStatus('canceled'), 'canceled'); + assert.equal(normalizeOrderStatus('expired'), 'expired'); + assert.equal(normalizeOrderStatus('rejected'), 'rejected'); + assert.equal(normalizeOrderStatus('unknown'), 'unknown'); + assert.equal(normalizeOrderStatus('new'), 'pending_new'); + + assert.equal(normalizeOrderAction('entry'), 'ENTRY'); + assert.equal(normalizeOrderAction('EXIT'), 'EXIT'); + assert.equal(normalizeOrderAction('invalid'), undefined); + assert.equal(normalizeOrderAction(undefined), undefined); + + assert.equal(normalizeOrderType('limit'), 'Limit'); + assert.equal(normalizeOrderType('stop'), 'Stop'); + assert.equal(normalizeOrderType('market'), 'Market'); + assert.equal(normalizeOrderType('other'), 'Market'); +}; + +const assertSymbolMapper = () => { + assert.equal(SymbolMapper.toTradeSymbol('BTC/USDT', 'alpaca'), 'BTC/USD'); + assert.equal(SymbolMapper.toTradeSymbol('BTC/USD', 'alpaca'), 'BTC/USD'); + assert.equal(SymbolMapper.toTradeSymbol('BTC/USDT', 'ccxt'), 'BTC/USDT'); + + assert.equal(SymbolMapper.toDataSymbol('BTC/USD', 'alpaca'), 'BTC/USDT'); + assert.equal(SymbolMapper.toDataSymbol('BTC/USDT', 'ccxt'), 'BTC/USDT'); +}; + +const assertConnectorFactory = () => { + const alpacaConnector = ConnectorFactory.getCustomConnector('alpaca', 'key', 'secret'); + assert.equal(alpacaConnector.constructor.name, 'AlpacaConnector'); + + const ccxtConnector = ConnectorFactory.getCustomConnector('ccxt', 'key', 'secret'); + assert.equal(ccxtConnector.constructor.name, 'CCXTConnector'); + + assert.throws( + () => ConnectorFactory.getCustomConnector('unsupported'), + /is not supported/ + ); +}; + +const main = () => { + assertTradingEnums(); + assertSymbolMapper(); + assertConnectorFactory(); + console.log('[critical-coverage] PASS'); +}; + +main(); diff --git a/backend/run_all_tests.ts b/backend/run_all_tests.ts new file mode 100644 index 0000000..538c7eb --- /dev/null +++ b/backend/run_all_tests.ts @@ -0,0 +1,43 @@ +import fs from 'fs'; +import path from 'path'; +import { spawn } from 'child_process'; + +const testsDir = path.resolve(process.cwd(), 'tests'); + +async function runAllTests() { + console.log('🧪 Running all tests in tests/ directory...\n'); + + const files = fs.readdirSync(testsDir).filter(f => f.endsWith('.ts')); + let passed = 0; + let failed = 0; + + for (const file of files) { + console.log(`\n---------------------------------------------------------`); + console.log(`▶️ Running ${file}...`); + console.log(`---------------------------------------------------------`); + + await new Promise((resolve) => { + const proc = spawn('npx', ['tsx', path.join(testsDir, file)], { + stdio: 'inherit', + shell: true + }); + + proc.on('close', (code) => { + if (code === 0) { + console.log(`✅ ${file} PASSED`); + passed++; + } else { + console.log(`❌ ${file} FAILED (Exit Code: ${code})`); + failed++; + } + resolve(); + }); + }); + } + + console.log(`\n=========================================================`); + console.log(`Summary: ${passed} Passed, ${failed} Failed`); + console.log(`=========================================================\n`); +} + +runAllTests(); diff --git a/backend/runbooks/capital-invariant.md b/backend/runbooks/capital-invariant.md new file mode 100644 index 0000000..eb03c91 --- /dev/null +++ b/backend/runbooks/capital-invariant.md @@ -0,0 +1,34 @@ +# Capital Invariant Violation Runbook + +## Incident description +The ledger calculation `allocated - reserved_for_orders - reserved_for_positions + realized_pnl` dropped below zero for a profile, indicating over-allocation or stale reservations. + +## Symptoms +- `capitalInvariantViolations` metric increments in `/metrics` and `/internal/health`. +- Critical log entry from the capital watchdog describing the negative delta and profile_id. +- Dashboard may show unexpected negative available capital or trading loop halting entries for that profile. + +## Metrics to check +- `/internal/health` ? `capitalInvariantViolations`, `tradingLoopHealthy`, `lockContentionCount`. +- `/metrics` ? `capital_invariant_violations_total`, `available_capital_gauge`, `reconciliation_mismatch_count`. + +## Immediate mitigation +1. Identify affected profile_id from the log or metric labels. +2. Confirm no new ENTRY orders are being placed for that profile (tradingLoopHealthy may be false). +3. Trigger a reconciliation cycle for the profile (if manual trigger exists, otherwise wait for scheduled run). Ensure reconciliation lock is available before forcing. +4. Notify downstream ops that trading for the profile is paused until invariant clears. + +## Expected self-recovery +- Reconciliation loop recalculates reservations from exchange open orders/positions and updates ledger fields, restoring the invariant. +- Capital watchdog logs the repair and metrics return to zero once `available_capital` is non-negative. + +## When to escalate +- Metric stays non-zero after three reconciliation cycles or 15 minutes. +- Trading loop remains unhealthy while other profiles are functioning. +- Capital watchdog logs repeated violations for different profiles within an hour. +Refer to docs/runbooks/invariant-violation.md for escalation steps. + +## What NOT to do +- Do not manually edit ledger rows via Supabase without consulting runbooks. +- Do not restart the trading loop: the rebuild is deterministic; restarting may reintroduce transient errors. +- Do not ignore the metric; unresolved violations can lead to over-leveraging other profiles. diff --git a/backend/runbooks/database-outage.md b/backend/runbooks/database-outage.md new file mode 100644 index 0000000..5397171 --- /dev/null +++ b/backend/runbooks/database-outage.md @@ -0,0 +1,34 @@ +# Supabase / RPC Outage Runbook + +## Incident description +Supabase rejects writes or RPCs fail, blocking lifecycle persistence, ledger updates, or reconciliation state. + +## Symptoms +- RPC errors logged (e.g., `timeout`, `503`, `service_role` rejection). +- `/internal/health` reports `monitorLoopHealthy` or `tradingLoopHealthy` false. +- `bot_state.json` writes or Supabase insert logs fail. + +## Metrics to check +- `/internal/health` ? `tradingLoopHealthy`, `monitorLoopHealthy`, `lockContentionCount`. +- `/metrics` ? `supabase_rpc_errors_total`, `lifecycle_persist_failures_total`. + +## Immediate mitigation +1. Pause new ENTRY submissions until Supabase is writable. +2. Queue lifecycle persistence retries and avoid dropping them; persistent message store may be required. +3. Notify Supabase support and confirm any service-wide issues. +4. Monitor whether reconciliation loops can still run with read-only access. + +## Expected self-recovery +- Supabase recovers; pending RPC retries succeed, and reconciliation cleans up backlog. +- Metrics for RPC errors fall back to zero. + +## When to escalate +- Outage exceeds 10 minutes with STATUS still failing. +- Supabase confirms incident impacting service_role writes. +- Lost trades or ledger inconsistencies appear post-recovery. +Escalate via docs/runbooks/database-outage.md to platform reliability. + +## What NOT to do +- Do not switch to a secondary database manually; the system expects single Supabase project. +- Do not delete pending lifecycle entries; they must be replayed once writes succeed. +- Do not ignore RPC failure alerts; they indicate blocked capital and lifecycle flows. diff --git a/backend/runbooks/exchange-degradation.md b/backend/runbooks/exchange-degradation.md new file mode 100644 index 0000000..31f2267 --- /dev/null +++ b/backend/runbooks/exchange-degradation.md @@ -0,0 +1,34 @@ +# Exchange API Degradation Runbook + +## Incident description +The exchange API responds slowly or returns errors, affecting ENTRY/EXIT execution and reconciliation data. + +## Symptoms +- Exchange latency histogram in `/metrics` shows spikes; errors logged from exchange connector. +- `tradingLoopHealthy` or `monitorLoopHealthy` flag false because loops hit timeouts. +- Logs show `exchange timeout` or repeated `429`/`503` responses. + +## Metrics to check +- `/internal/health` ? `tradingLoopHealthy`, `monitorLoopHealthy`, `exchangeLatencyHistogram`. +- `/metrics` ? `exchange_api_latency_seconds`, `exchange_api_errors_total`, `entry_orders_rejections_total`. + +## Immediate mitigation +1. Back off new ENTRY signals for profiles if exchange is unreachable. +2. Ensure deterministic clientOrderId is ready before retries; do not reissue new orders. +3. Activate retry/backoff logic in connectors; log each retry with correlation IDs. +4. Inform downstream systems (dashboard, ops) about degraded state. + +## Expected self-recovery +- Exchange recovers and accepts pending requests; trading loop resumes once latency normalizes. +- Reconciliation loop eventually runs against fresh data; metrics fall back to baseline. + +## When to escalate +- Errors persist beyond 5 minutes despite retries. +- Exchange reports credential or rate-limit problems requiring intervention. +- Business-critical trading windows are missed. +Escalate to the Exchange Account Manager and Cloud Ops; reference docs/runbooks/exchange-degradation.md. + +## What NOT to do +- Do not flood the exchange with retries; respect backoff policies. +- Do not change API keys mid-incident without direction from the exchange team. +- Do not pause reconciliation; accurate state is needed to diagnose missing fills. diff --git a/backend/runbooks/lock-contention.md b/backend/runbooks/lock-contention.md new file mode 100644 index 0000000..9007aa9 --- /dev/null +++ b/backend/runbooks/lock-contention.md @@ -0,0 +1,34 @@ +# ENTRY Lock Contention Spike Runbook + +## Incident description +A profile experiences repeated failures to acquire the row-based entry lock, blocking ENTRY signals and indicating pressure on horizontal scaling. + +## Symptoms +- `lockContentionCount` increments in `/internal/health` and `/metrics`. +- Logs show fn_try_acquire_entry_lock_row returning false with owner tokens different from the caller. +- Trading loop reports `lock acquisition failed` warnings and may skip signals. + +## Metrics to check +- `/internal/health` ? `lockContentionCount`, `tradingLoopHealthy`, `reconciliationLoopHealthy`. +- `/metrics` ? `entry_lock_contention_total`, `lock_acquisition_latency_seconds`, `entry_lock_holder_info` (if available). + +## Immediate mitigation +1. Identify the profile_id and symbol from logs; confirm if another worker legitimately holds the lock. +2. Ensure the existing lock owner is still alive or has not crashed; use Supabase to inspect `entry_locks` TTL. +3. Wait for TTL expiry (default 30s) before retrying if owner appears stuck. +4. Avoid forcing lock release unless owner is confirmed dead; manual deletion risks concurrent exchange submission. + +## Expected self-recovery +- The TTL expires, the lock row updates or deletes itself, and the next signal acquires the lock. +- Metrics return to baseline if contention was transient. + +## When to escalate +- Contention persists beyond three TTL cycles (90s). +- Multiple profiles report contention simultaneously. +- Lock rows show expired timestamps but fail to refresh. +Escalate to Platform Ops and refer to docs/runbooks/lock-timeout.md (if it exists) for lock escalation. + +## What NOT to do +- Do not delete lock rows manually while other workers are active. +- Do not restart all workers; indiscriminate restarts magnify contention. +- Do not trigger new ENTRY signals for the affected profile until lock clears. diff --git a/backend/runbooks/loop-health.md b/backend/runbooks/loop-health.md new file mode 100644 index 0000000..3327421 --- /dev/null +++ b/backend/runbooks/loop-health.md @@ -0,0 +1,34 @@ +# Trading & Reconciliation Loop Health Runbook + +## Incident description +The trading loop or reconciliation loop is not executing within expected cadence, threatening data freshness and trading responsiveness. + +## Symptoms +- `/internal/health` fields `tradingLoopHealthy` or `reconciliationLoopHealthy` flip to false. +- `reconciliationLastRun` or loop duration metrics show no updates for longer than twice the expected interval. +- Prometheus metrics show loop duration stuck or not emitted. + +## Metrics to check +- `/internal/health` ? `tradingLoopHealthy`, `reconciliationLoopHealthy`, `monitorLoopHealthy`, `reconciliationLastRun`. +- `/metrics` ? `trading_loop_duration_seconds`, `reconciliation_loop_duration_seconds`, `loop_run_failure_total`. + +## Immediate mitigation +1. Inspect last log timestamp to determine if the loop crashed or is still running slow. +2. Verify that the process is still up and not stuck on external calls (e.g., exchange or Supabase). Use debugger or profiling if needed. +3. If a loop is hung, send kill signal to the specific loop worker (do not restart the entire service) and allow auto-restart if configured. +4. Ensure lock contention or capital invariant metrics are not blocking the loop. + +## Expected self-recovery +- Auto-recovery restarts the loop or continues once blocked resource clears (e.g., exchange call returns or Supabase writes succeed). +- `/internal/health` marks the loop healthy after successful iteration. + +## When to escalate +- Loops fail consecutively more than twice within 10 minutes. +- Loop restarts exceed the configured threshold without recovery. +- Business trades miss critical windows due to loop downtime. +Escalate via docs/runbooks/loop-health.md and notify the reliability team. + +## What NOT to do +- Do not disable the health endpoint; it is the single source of truth. +- Do not restart unrelated services; focus on the affected loop. +- Do not skip verification of capital or lock metrics before concluding the loop itself is broken. diff --git a/backend/runbooks/reconciliation-exit-backfill.md b/backend/runbooks/reconciliation-exit-backfill.md new file mode 100644 index 0000000..1ba1aa6 --- /dev/null +++ b/backend/runbooks/reconciliation-exit-backfill.md @@ -0,0 +1,194 @@ +# Reconciliation EXIT Backfill Rollout Checklist + +## Scope Guardrails +- No entry/exit/risk/signal logic changes. +- Data-only repair: insert compensating `EXIT` rows only. +- No deletes. Rollback uses status updates only. +- Backfill must be paused-mode gated and auditable by `batch_id`. + +## Feature Flags (default safe state) +- `ENABLE_RECON_EXIT_BACKFILL=false` +- `RECON_EXIT_BACKFILL_DRY_RUN=true` +- `RECON_EXIT_BACKFILL_REQUIRE_PAUSE=true` +- `RECON_EXIT_BACKFILL_DUST_ABS_QTY=0.001` +- `RECON_EXIT_BACKFILL_DUST_REL_PCT=0.002` +- `RECON_EXIT_BACKFILL_LOOKBACK_HOURS=72` +- `EXCHANGE_STATE_MISMATCH_THROTTLE_MS=300000` + +## 1. Detect Affected `trade_id` Values +```sql +WITH lifecycle AS ( + SELECT + profile_id, + symbol, + trade_id, + SUM(CASE WHEN action = 'ENTRY' THEN COALESCE(quantity, qty, 0) ELSE 0 END) AS entry_qty, + SUM(CASE WHEN action = 'EXIT' THEN COALESCE(quantity, qty, 0) ELSE 0 END) AS exit_qty + FROM orders + WHERE profile_id = '<>' + AND status IN ('filled', 'partially_filled', 'partially-filled') + AND trade_id IS NOT NULL + GROUP BY profile_id, symbol, trade_id +) +SELECT + profile_id, + symbol, + trade_id, + entry_qty, + exit_qty, + (entry_qty - exit_qty) AS open_qty +FROM lifecycle +WHERE (entry_qty - exit_qty) > 0 +ORDER BY symbol, trade_id; +``` + +## 2. Allow Criteria for Backfill `EXIT` Row +Backfill is allowed only when all are true: +- Profile is paused (`/internal/trading/status` returns `PAUSED`). +- Exchange is flat for symbol. +- No pending lifecycle blocker row exists for that `trade_id`. +- Close qty is supported by exchange fill evidence OR unresolved remainder is dust. +- Idempotency key resolves to unique deterministic `order_id`. + +## 3. Idempotency Rules +- Deterministic `order_id`: + - `BFILL-` +- Insert path: + - `ON CONFLICT (order_id) DO NOTHING` (implemented via upsert + `ignoreDuplicates`). +- Re-runs are safe: existing backfill `order_id` rows are skipped. +- Exchange `client_order_id` (if present) is stored in `reconciliation_backfill_audit.exchange_client_order_id`. + +## 4. Dust Threshold Handling +- Dust threshold per trade: + - `MAX(dust_abs_qty, open_qty * dust_rel_pct)` +- Default: + - `dust_abs_qty = 0.001` + - `dust_rel_pct = 0.2%` +- Auto-close allowed: + - evidence-covered qty always + - remainder only if `remainder <= threshold` +- No-Go: + - `remainder > threshold` with missing exchange fill evidence. + +## 5. Dry-Run Execution and Verification +1. Set: + - `ENABLE_RECON_EXIT_BACKFILL=true` + - `RECON_EXIT_BACKFILL_DRY_RUN=true` + - `RECON_EXIT_BACKFILL_REQUIRE_PAUSE=true` +2. Pause trading from admin. +3. Let one reconciliation cycle run. +4. Verify audit-only effect: +```sql +SELECT + batch_id, + profile_id, + symbol, + trade_id, + decision, + reason, + filled_qty, + backfill_order_id, + created_at +FROM reconciliation_backfill_audit +WHERE profile_id = '<>' +ORDER BY created_at DESC +LIMIT 200; +``` +5. Confirm `orders` table unchanged in dry-run: +```sql +SELECT COUNT(*) AS dry_run_backfill_rows +FROM orders +WHERE profile_id = '<>' + AND order_id LIKE 'BFILL-%'; +``` + +## 6. Go / No-Go Before Apply +Go only if: +- Pause state confirmed. +- Dry-run shows expected `DRY_RUN` or `PENDING_APPLY` candidates. +- No unexpected `NO_GO` due to non-flat exchange or pending blockers. +- Candidate qty aligns with known exchange fills. +- Audit table writes succeed. + +No-Go if any: +- Exchange not flat for target symbol. +- Large unmatched remainder (`missing_fill_evidence_for_large_remainder`). +- Audit table unavailable/write failure. +- Pending blocker rows for target `trade_id`. + +## 7. Apply Step (still paused) +1. Set `RECON_EXIT_BACKFILL_DRY_RUN=false`. +2. Run one reconciliation cycle. +3. Validate inserted rows: +```sql +SELECT + order_id, + profile_id, + symbol, + trade_id, + action, + status, + qty, + price, + filled_at, + created_at +FROM orders +WHERE profile_id = '<>' + AND order_id LIKE 'BFILL-%' +ORDER BY created_at DESC; +``` + +## 8. Post-Fix Validation (ghost positions removed) +Open lifecycle should be zero/near-zero: +```sql +WITH lifecycle AS ( + SELECT + profile_id, + symbol, + trade_id, + SUM(CASE WHEN action = 'ENTRY' THEN COALESCE(quantity, qty, 0) ELSE 0 END) AS entry_qty, + SUM(CASE WHEN action = 'EXIT' THEN COALESCE(quantity, qty, 0) ELSE 0 END) AS exit_qty + FROM orders + WHERE profile_id = '<>' + AND status IN ('filled', 'partially_filled', 'partially-filled') + AND trade_id IS NOT NULL + GROUP BY profile_id, symbol, trade_id +) +SELECT * +FROM lifecycle +WHERE (entry_qty - exit_qty) > 0.000001 +ORDER BY symbol, trade_id; +``` + +Mismatch noise check (audit + events): +```sql +SELECT + decision, + COUNT(*) AS rows +FROM reconciliation_backfill_audit +WHERE profile_id = '<>' +GROUP BY decision +ORDER BY decision; +``` + +## 9. Rollback (Reversible, Non-Destructive) +Rollback is status-only, never delete: +```sql +UPDATE orders +SET + status = 'canceled', + updated_at = now() +WHERE order_id IN ( + SELECT backfill_order_id + FROM reconciliation_backfill_audit + WHERE batch_id = '<>' + AND decision IN ('APPLIED', 'SKIP_EXISTING') + AND backfill_order_id IS NOT NULL +); +``` +Record rollback marker: +```sql +UPDATE reconciliation_backfill_audit +SET reverted_at = now() +WHERE batch_id = '<>'; +``` diff --git a/backend/runbooks/reconciliation.md b/backend/runbooks/reconciliation.md new file mode 100644 index 0000000..527533c --- /dev/null +++ b/backend/runbooks/reconciliation.md @@ -0,0 +1,70 @@ +# Reconciliation Divergence Runbook + +## Incident description +The reconciliation loop detects mismatches between Supabase orders/positions and exchange open orders, meaning the database drifted from exchange truth. + +## Symptoms +- `reconciliationMismatchCount`, `reconciliationMissingFromExchange`, or `reconciliationMissingInDb` metrics rise. +- Logs show lifecycle-safe handlers executing to correct entry/exit states. +- Dashboard shows active orders or positions that do not exist on the exchange. + +## Metrics to check +- `/internal/health` ? `reconciliationLoopHealthy`, `reconciliationMismatchCount`, `reconciliationLastRun`. +- `/metrics` ? `reconciliation_mismatch_total`, `reconciliation_missing_from_exchange_total`, `reconciliation_missing_in_db_total`. +- `/internal/health` runtime fields: + - `reconciliationParityMismatchTrades` + - `reconciliationParityQuarantinedTrades` + - `reconciliationParityAutoClosedTrades` + - `reconciliationParityMaxMismatchNotionalUsd` + - `reconciliationParityTotalMismatchNotionalUsd` + - `reconciliationIntegrityWatchdogTriggered` + +## Automated parity heartbeat (ghost self-healing) +- Feature flag: `ENABLE_RECON_POSITION_PARITY_HEARTBEAT=true` (default is `true`; set `false` only for controlled rollback). +- Confirmation gate: `RECON_POSITION_PARITY_CONFIRMATIONS` (default `3` consecutive checks). +- Attribution safety gate: `RECON_POSITION_PARITY_REQUIRE_SUBTAG_ATTRIBUTION` (default `true`). +- Watchdog threshold: `RECON_POSITION_PARITY_MAX_NOTIONAL_PCT` (default `0.5` of allocated capital). +- Auto-resume gate: `ENABLE_RECON_WATCHDOG_AUTO_RESUME=true`. +- Auto-resume delay: `RECON_WATCHDOG_AUTO_RESUME_MIN_PAUSE_MS` (default `900000`). +- Auto-resume clean streak: `RECON_WATCHDOG_AUTO_RESUME_CLEAN_CYCLES` (default `2`). +- Auto-resume cooldown: `RECON_WATCHDOG_AUTO_RESUME_COOLDOWN_MS` (default `1800000`). +- Dry-run mode: `RECON_POSITION_PARITY_DRY_RUN=true` to observe without applying synthetic exits. + +Heartbeat behavior: +- Detects ghost lifecycle slices where virtual open qty remains but exchange position is effectively zero. +- Requires consecutive mismatch confirmations before synthetic EXIT reconciliation. +- Enforces sub-tag attribution before any synthetic close; unattributed slices are quarantined. +- Triggers integrity watchdog pause when cumulative mismatch notional exceeds configured capital ratio. +- Auto-resumes trading only when pause source is parity watchdog and reconciliation stays clean for required consecutive cycles. + +## EXIT backfill safety gates +- `RECON_EXIT_BACKFILL_REQUIRE_STRONG_ATTRIBUTION=true`: + - only uses exchange fills that are attributable to the profile/trade (`sub_tag`, deterministic `client_order_id`, or explicit `trade_id` hint). + - prevents auto-backfill from consuming unrelated account activity. +- `RECON_EXIT_BACKFILL_ALLOW_HEURISTIC_MATCH=false`: + - disables heuristic assignment modes (`single_open_trade`, `qty_unique`) by default. + - keeps unmatched rows in `NO_GO` for operator review instead of auto-closing. +- `RECON_EXIT_BACKFILL_FILL_AFTER_TRADE_GRACE_MINUTES=5`: + - rejects stale fill evidence that predates the lifecycle slice timestamp beyond grace. + - blocks historical fills from being attached to newer open trades. + +## Immediate mitigation +1. Confirm the reconciliation lock is available for the affected profile to avoid double processing. +2. Allow the reconciliation loop to run; it will route mismatches through lifecycle-safe handlers (`reconcileEntryFill`, `reconcileExitFill`, `reconcileCancel`). +3. If divergence persists, manually inspect trade_history and positions for inconsistent state. +4. Notify stakeholders that reconciliation is running and that no manual edits should occur during the fix. + +## Expected self-recovery +- Handler corrections align DB orders/positions with exchange data, and metrics return to zero. +- The capital ledger recalculates reservations, and dashboard data becomes consistent. + +## When to escalate +- Mismatch metrics stay elevated after two reconciliation runs. +- Reconciliation lock contention prevents the loop from running. +- Exchanges report stale or unknown fills after reconciliation. +Escalate to the trading engineering lead and reference docs/runbooks/reconciliation.md and docs/runbooks/lifecycle-incident.md for follow-up. + +## What NOT to do +- Do not manually patch `orders` or `positions` tables while reconciliation is active. +- Do not disable the reconciliation loop; divergence will only grow. +- Do not trigger new ENTRY/EXIT flows for the affected profile until reconciliation completes. diff --git a/backend/schema/002_add_strategy_config.sql b/backend/schema/002_add_strategy_config.sql new file mode 100644 index 0000000..e04c647 --- /dev/null +++ b/backend/schema/002_add_strategy_config.sql @@ -0,0 +1,28 @@ +-- Migration: Add JSON Strategy Config to Trade Profiles +-- Date: 2026-02-04 +-- Purpose: Enable Per-Profile Strategy Rules & Risk Configuration + +ALTER TABLE trade_profiles +ADD COLUMN IF NOT EXISTS strategy_config jsonb DEFAULT '{ + "rules": [ + { "ruleId": "TrendBiasRule", "enabled": true, "params": { "timeframe": "4h", "emaFast": 50, "emaSlow": 200 } }, + { "ruleId": "SessionRule", "enabled": true, "params": { "allowedSessions": ["NY", "LDN"] } }, + { "ruleId": "ZoneRule", "enabled": true, "params": { "emaPeriod": 20, "tolerancePercent": 0.5 } }, + { "ruleId": "MomentumRule", "enabled": true, "params": { "timeframe": "1h", "rsiPeriod": 14, "rsiOverbought": 70, "rsiOversold": 30 } }, + { "ruleId": "EntryTriggerRule", "enabled": true, "params": { "triggerType": "ema_cross" } }, + { "ruleId": "RiskManagementRule", "enabled": true, "params": { "atrPeriod": 14, "riskRewardRatio": 1.5 } }, + { "ruleId": "AIAnalysisRule", "enabled": false, "params": { "minConfidence": 80 } } + ], + "riskLimits": { + "maxDailyLossUsd": 50, + "maxConsecutiveLosses": 2, + "maxOpenTrades": 3 + }, + "execution": { + "orderType": "market", + "cooldownMinutes": 30, + "entryMode": "both" + } +}'::jsonb; + +COMMENT ON COLUMN trade_profiles.strategy_config IS 'JSON configuration for active strategy rules, risk limits, and execution settings per profile.'; diff --git a/backend/schema/003_add_profile_id_to_orders_and_history.sql b/backend/schema/003_add_profile_id_to_orders_and_history.sql new file mode 100644 index 0000000..32d475b --- /dev/null +++ b/backend/schema/003_add_profile_id_to_orders_and_history.sql @@ -0,0 +1,18 @@ +-- Migration: Add profile_id to orders and trade_history +-- Date: 2026-02-07 +-- Purpose: Map every order and trade to its originating trade_profile for per-profile analytics + +-- Orders table +ALTER TABLE orders +ADD COLUMN IF NOT EXISTS profile_id uuid REFERENCES trade_profiles(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_orders_profile_id ON orders(profile_id); + +-- Trade history table +ALTER TABLE trade_history +ADD COLUMN IF NOT EXISTS profile_id uuid REFERENCES trade_profiles(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_trade_history_profile_id ON trade_history(profile_id); + +COMMENT ON COLUMN orders.profile_id IS 'The trade profile that triggered this order'; +COMMENT ON COLUMN trade_history.profile_id IS 'The trade profile that triggered this trade'; diff --git a/backend/schema/004_full_schema_sync.sql b/backend/schema/004_full_schema_sync.sql new file mode 100644 index 0000000..d3a4947 --- /dev/null +++ b/backend/schema/004_full_schema_sync.sql @@ -0,0 +1,161 @@ +-- ============================================================ +-- Migration 004: Full Schema Sync +-- Date: 2026-02-07 +-- Purpose: Create missing tables, add missing columns, +-- and align DB schema with application code. +-- ============================================================ + +-- ──────────────────────────────────────────────────────────── +-- 1. TRADE_PROFILES TABLE (Missing CREATE TABLE) +-- ──────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS trade_profiles ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid REFERENCES auth.users NOT NULL, + name text NOT NULL, + allocated_capital numeric DEFAULT 1000, + risk_per_trade_percent numeric DEFAULT 1.0, + symbols text DEFAULT 'BTC/USDT, ETH/USDT', + is_active boolean DEFAULT true, + strategy_config jsonb DEFAULT '{ + "rules": [ + { "ruleId": "TrendBiasRule", "enabled": true, "params": { "timeframe": "4h", "emaFast": 50, "emaSlow": 200 } }, + { "ruleId": "SessionRule", "enabled": true, "params": { "allowedSessions": ["NY", "LDN"] } }, + { "ruleId": "ZoneRule", "enabled": true, "params": { "emaPeriod": 20, "tolerancePercent": 0.5 } }, + { "ruleId": "MomentumRule", "enabled": true, "params": { "timeframe": "1h", "rsiPeriod": 14, "rsiOverbought": 70, "rsiOversold": 30 } }, + { "ruleId": "EntryTriggerRule", "enabled": true, "params": { "triggerType": "ema_cross" } }, + { "ruleId": "RiskManagementRule", "enabled": true, "params": { "atrPeriod": 14, "riskRewardRatio": 1.5 } }, + { "ruleId": "AIAnalysisRule", "enabled": false, "params": { "minConfidence": 80 } } + ], + "riskLimits": { + "maxDailyLossUsd": 50, + "maxConsecutiveLosses": 2, + "maxOpenTrades": 3 + }, + "execution": { + "orderType": "market", + "cooldownMinutes": 30, + "entryMode": "both" + } + }'::jsonb, + created_at timestamptz DEFAULT now() +); + +COMMENT ON TABLE trade_profiles IS 'Trading strategy profiles with per-profile rule config, risk limits, and execution settings'; +COMMENT ON COLUMN trade_profiles.strategy_config IS 'JSON configuration: { rules[], riskLimits{}, execution{} }'; + +CREATE INDEX IF NOT EXISTS idx_trade_profiles_user_id ON trade_profiles(user_id); +CREATE INDEX IF NOT EXISTS idx_trade_profiles_is_active ON trade_profiles(is_active); + +-- RLS +ALTER TABLE trade_profiles ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "Users can manage own profiles" ON trade_profiles; +CREATE POLICY "Users can manage own profiles" ON trade_profiles + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); + + +-- ──────────────────────────────────────────────────────────── +-- 2. ORDERS TABLE — Add missing columns +-- ──────────────────────────────────────────────────────────── + +-- profile_id: links order to the trade profile that triggered it +ALTER TABLE orders +ADD COLUMN IF NOT EXISTS profile_id uuid REFERENCES trade_profiles(id) ON DELETE SET NULL; + +-- stop_loss & take_profit: stored by TradeExecutor.logOrderToDb() +ALTER TABLE orders +ADD COLUMN IF NOT EXISTS stop_loss numeric; + +ALTER TABLE orders +ADD COLUMN IF NOT EXISTS take_profit numeric; + +CREATE INDEX IF NOT EXISTS idx_orders_profile_id ON orders(profile_id); + +COMMENT ON COLUMN orders.profile_id IS 'Trade profile that triggered this order (NULL = manual)'; +COMMENT ON COLUMN orders.stop_loss IS 'Stop loss price set at order time'; +COMMENT ON COLUMN orders.take_profit IS 'Take profit price set at order time'; + + +-- ──────────────────────────────────────────────────────────── +-- 3. TRADE_HISTORY TABLE — Add missing columns +-- ──────────────────────────────────────────────────────────── + +-- profile_id: links trade to the profile that triggered it +ALTER TABLE trade_history +ADD COLUMN IF NOT EXISTS profile_id uuid REFERENCES trade_profiles(id) ON DELETE SET NULL; + +-- stop_loss & take_profit: for post-trade analysis +ALTER TABLE trade_history +ADD COLUMN IF NOT EXISTS stop_loss numeric; + +ALTER TABLE trade_history +ADD COLUMN IF NOT EXISTS take_profit numeric; + +-- rules_metadata: stores which rules passed/failed for this trade +ALTER TABLE trade_history +ADD COLUMN IF NOT EXISTS rules_metadata jsonb; + +CREATE INDEX IF NOT EXISTS idx_trade_history_profile_id ON trade_history(profile_id); + +COMMENT ON COLUMN trade_history.profile_id IS 'Trade profile that triggered this trade (NULL = manual)'; +COMMENT ON COLUMN trade_history.stop_loss IS 'Stop loss price at trade entry'; +COMMENT ON COLUMN trade_history.take_profit IS 'Take profit price at trade entry'; +COMMENT ON COLUMN trade_history.rules_metadata IS 'JSON snapshot of rule statuses at trade time: { ruleName: { passed, reason } }'; + + +-- ──────────────────────────────────────────────────────────── +-- 4. BOT_CONFIG TABLE (Missing CREATE TABLE) +-- Used by: ConfigTab.tsx, GlobalConfigManager.tsx +-- ──────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS bot_config ( + key text PRIMARY KEY, + value text, + description text, + updated_at timestamptz DEFAULT now() +); + +COMMENT ON TABLE bot_config IS 'Key-value store for global bot configuration (editable from dashboard)'; + +-- RLS: allow authenticated users to read, admins to write +ALTER TABLE bot_config ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "Authenticated users can read bot_config" ON bot_config; +CREATE POLICY "Authenticated users can read bot_config" ON bot_config + FOR SELECT + USING (auth.role() = 'authenticated'); + +DROP POLICY IF EXISTS "Admins can manage bot_config" ON bot_config; +CREATE POLICY "Admins can manage bot_config" ON bot_config + USING ( + EXISTS (SELECT 1 FROM users WHERE user_id = auth.uid() AND role = 'admin') + ) + WITH CHECK ( + EXISTS (SELECT 1 FROM users WHERE user_id = auth.uid() AND role = 'admin') + ); + + +-- ──────────────────────────────────────────────────────────── +-- 5. DYNAMIC_CONFIG TABLE (Missing CREATE TABLE) +-- Used by: bot-service config/index.ts (loadDynamicConfig) +-- ──────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS dynamic_config ( + key text PRIMARY KEY, + value text, + updated_at timestamptz DEFAULT now() +); + +COMMENT ON TABLE dynamic_config IS 'Runtime-overridable bot settings loaded at startup (SYMBOLS, intervals, etc.)'; + + +-- ──────────────────────────────────────────────────────────── +-- 6. UPDATE schema_reference.sql sync comment +-- ──────────────────────────────────────────────────────────── +-- After running this migration, all 7 tables are fully defined: +-- 1. users (auth trigger-created) +-- 2. entries (watchlist & manual positions) +-- 3. trade_history (completed trade ledger) +-- 4. orders (active/pending orders) +-- 5. trade_profiles (strategy profiles with rule config) +-- 6. bot_config (global config key-value store) +-- 7. dynamic_config (runtime config overrides) diff --git a/backend/schema/005_add_trade_id_tracking.sql b/backend/schema/005_add_trade_id_tracking.sql new file mode 100644 index 0000000..53e5c34 --- /dev/null +++ b/backend/schema/005_add_trade_id_tracking.sql @@ -0,0 +1,24 @@ +-- ──────────────────────────────────────────────────────────── +-- 005: Add trade_id for full trade cycle tracing +-- Links entry order → exit order → trade_history record +-- ──────────────────────────────────────────────────────────── + +-- Add trade_id to orders table (links BUY and SELL of same trade) +ALTER TABLE orders +ADD COLUMN IF NOT EXISTS trade_id text; + +-- Add action column to orders (ENTRY or EXIT) +ALTER TABLE orders +ADD COLUMN IF NOT EXISTS action text; + +-- Add trade_id to trade_history (links to the orders) +ALTER TABLE trade_history +ADD COLUMN IF NOT EXISTS trade_id text; + +-- Index for fast lookups by trade_id +CREATE INDEX IF NOT EXISTS idx_orders_trade_id ON orders(trade_id); +CREATE INDEX IF NOT EXISTS idx_trade_history_trade_id ON trade_history(trade_id); + +COMMENT ON COLUMN orders.trade_id IS 'Unique identifier linking entry and exit orders of the same trade cycle (e.g., TRD-1707000000-abc123)'; +COMMENT ON COLUMN orders.action IS 'Order action type: ENTRY (opening) or EXIT (closing)'; +COMMENT ON COLUMN trade_history.trade_id IS 'Links this trade record to its entry/exit orders in the orders table'; diff --git a/backend/schema/006_add_trade_source.sql b/backend/schema/006_add_trade_source.sql new file mode 100644 index 0000000..8449d6e --- /dev/null +++ b/backend/schema/006_add_trade_source.sql @@ -0,0 +1,25 @@ +-- ============================================================ +-- Migration 006: Trade Source Normalization +-- Date: 2026-02-14 +-- Purpose: Separate trade source semantics from trade side. +-- ============================================================ + +ALTER TABLE trade_history +ADD COLUMN IF NOT EXISTS source text DEFAULT 'BOT'; + +-- Normalize legacy rows where side was overloaded with MANUAL. +UPDATE trade_history +SET side = 'BUY' +WHERE upper(side) = 'MANUAL'; + +-- Backfill source for historical/manual records where possible. +UPDATE trade_history +SET source = 'MANUAL' +WHERE source IS NULL + OR upper(source) NOT IN ('BOT', 'MANUAL'); + +ALTER TABLE trade_history +ALTER COLUMN source SET NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_trade_history_source ON trade_history(source); + diff --git a/backend/schema/007_trade_lifecycle_integrity_constraints.sql b/backend/schema/007_trade_lifecycle_integrity_constraints.sql new file mode 100644 index 0000000..a5bcc65 --- /dev/null +++ b/backend/schema/007_trade_lifecycle_integrity_constraints.sql @@ -0,0 +1,123 @@ +-- ============================================================ +-- Migration 007: Trade Lifecycle Integrity Constraints +-- Date: 2026-02-15 +-- Purpose: +-- 1) Normalize trade_id null handling policy +-- 2) Add non-blocking lifecycle constraints (NOT VALID) +-- 3) Add profile_id + trade_id indexes for deterministic traceability +-- ============================================================ + +-- Null handling policy: +-- - trade_id is allowed to be NULL for legacy/manual rows. +-- - blank/whitespace values are normalized to NULL. + +UPDATE orders +SET trade_id = NULL +WHERE trade_id IS NOT NULL + AND btrim(trade_id) = ''; + +UPDATE trade_history +SET trade_id = NULL +WHERE trade_id IS NOT NULL + AND btrim(trade_id) = ''; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'chk_orders_action_lifecycle' + AND conrelid = 'orders'::regclass + ) THEN + ALTER TABLE orders + ADD CONSTRAINT chk_orders_action_lifecycle + CHECK (action IS NULL OR action IN ('ENTRY', 'EXIT')) + NOT VALID; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'chk_orders_trade_id_not_blank' + AND conrelid = 'orders'::regclass + ) THEN + ALTER TABLE orders + ADD CONSTRAINT chk_orders_trade_id_not_blank + CHECK (trade_id IS NULL OR btrim(trade_id) <> '') + NOT VALID; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'chk_trade_history_trade_id_not_blank' + AND conrelid = 'trade_history'::regclass + ) THEN + ALTER TABLE trade_history + ADD CONSTRAINT chk_trade_history_trade_id_not_blank + CHECK (trade_id IS NULL OR btrim(trade_id) <> '') + NOT VALID; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'chk_orders_trade_id_format' + AND conrelid = 'orders'::regclass + ) THEN + ALTER TABLE orders + ADD CONSTRAINT chk_orders_trade_id_format + CHECK (trade_id IS NULL OR trade_id LIKE 'TRD-%') + NOT VALID; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'chk_trade_history_trade_id_format' + AND conrelid = 'trade_history'::regclass + ) THEN + ALTER TABLE trade_history + ADD CONSTRAINT chk_trade_history_trade_id_format + CHECK (trade_id IS NULL OR trade_id LIKE 'TRD-%') + NOT VALID; + END IF; +END $$; + +-- Lifecycle query indexes. +CREATE INDEX IF NOT EXISTS idx_orders_profile_trade_id_created + ON orders (profile_id, trade_id, created_at DESC) + WHERE trade_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_orders_profile_trade_id_action_created + ON orders (profile_id, trade_id, action, created_at DESC) + WHERE trade_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_orders_profile_symbol_status_created + ON orders (profile_id, symbol, status, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_trade_history_profile_trade_id_created + ON trade_history (profile_id, trade_id, created_at DESC) + WHERE trade_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_trade_history_profile_symbol_created + ON trade_history (profile_id, symbol, created_at DESC); + +-- Post-migration tighten step (run after backfill + reconciliation): +-- ALTER TABLE orders VALIDATE CONSTRAINT chk_orders_action_lifecycle; +-- ALTER TABLE orders VALIDATE CONSTRAINT chk_orders_trade_id_not_blank; +-- ALTER TABLE orders VALIDATE CONSTRAINT chk_orders_trade_id_format; +-- ALTER TABLE trade_history VALIDATE CONSTRAINT chk_trade_history_trade_id_not_blank; +-- ALTER TABLE trade_history VALIDATE CONSTRAINT chk_trade_history_trade_id_format; diff --git a/backend/schema/008_schema_gap_backfill.sql b/backend/schema/008_schema_gap_backfill.sql new file mode 100644 index 0000000..e1c5986 --- /dev/null +++ b/backend/schema/008_schema_gap_backfill.sql @@ -0,0 +1,226 @@ +-- ============================================================ +-- Migration 008: Schema Gap Backfill (Idempotent) +-- Date: 2026-02-15 +-- Purpose: +-- 1) Patch common missing columns on legacy DBs +-- 2) Keep orders.qty and orders.quantity in sync +-- 3) Ensure trade lifecycle traceability fields exist +-- 4) Add bot_snapshots table for rolling bot_state backups +-- ============================================================ + +BEGIN; + +-- ------------------------------------------------------------ +-- A) trade_history compatibility +-- ------------------------------------------------------------ +ALTER TABLE IF EXISTS trade_history + ADD COLUMN IF NOT EXISTS trade_id text; + +ALTER TABLE IF EXISTS trade_history + ADD COLUMN IF NOT EXISTS source text; + +-- Ensure source has valid values and no nulls. +UPDATE trade_history +SET source = CASE + WHEN upper(coalesce(source, '')) IN ('BOT', 'MANUAL') THEN upper(source) + WHEN profile_id IS NOT NULL THEN 'BOT' + ELSE 'MANUAL' +END +WHERE source IS NULL + OR btrim(source) = '' + OR upper(source) NOT IN ('BOT', 'MANUAL'); + +ALTER TABLE IF EXISTS trade_history + ALTER COLUMN source SET DEFAULT 'BOT'; + +ALTER TABLE IF EXISTS trade_history + ALTER COLUMN source SET NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_trade_history_trade_id + ON trade_history (trade_id); + +CREATE INDEX IF NOT EXISTS idx_trade_history_source + ON trade_history (source); + +CREATE INDEX IF NOT EXISTS idx_trade_history_profile_trade_id_created + ON trade_history (profile_id, trade_id, created_at DESC) + WHERE trade_id IS NOT NULL; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'chk_trade_history_trade_id_not_blank' + AND conrelid = 'trade_history'::regclass + ) THEN + ALTER TABLE trade_history + ADD CONSTRAINT chk_trade_history_trade_id_not_blank + CHECK (trade_id IS NULL OR btrim(trade_id) <> '') + NOT VALID; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'chk_trade_history_trade_id_format' + AND conrelid = 'trade_history'::regclass + ) THEN + ALTER TABLE trade_history + ADD CONSTRAINT chk_trade_history_trade_id_format + CHECK (trade_id IS NULL OR trade_id LIKE 'TRD-%') + NOT VALID; + END IF; +END $$; + +-- ------------------------------------------------------------ +-- B) orders compatibility +-- ------------------------------------------------------------ +ALTER TABLE IF EXISTS orders + ADD COLUMN IF NOT EXISTS trade_id text; + +ALTER TABLE IF EXISTS orders + ADD COLUMN IF NOT EXISTS action text; + +ALTER TABLE IF EXISTS orders + ADD COLUMN IF NOT EXISTS source text; + +ALTER TABLE IF EXISTS orders + ADD COLUMN IF NOT EXISTS quantity numeric; + +-- Source normalization. +UPDATE orders +SET source = CASE + WHEN upper(coalesce(source, '')) IN ('BOT', 'MANUAL') THEN upper(source) + WHEN profile_id IS NOT NULL THEN 'BOT' + ELSE 'MANUAL' +END +WHERE source IS NULL + OR btrim(source) = '' + OR upper(source) NOT IN ('BOT', 'MANUAL'); + +ALTER TABLE IF EXISTS orders + ALTER COLUMN source SET DEFAULT 'BOT'; + +ALTER TABLE IF EXISTS orders + ALTER COLUMN source SET NOT NULL; + +-- Keep qty/quantity aligned for legacy + current clients. +UPDATE orders +SET quantity = qty +WHERE quantity IS NULL + AND qty IS NOT NULL; + +UPDATE orders +SET qty = quantity +WHERE qty IS NULL + AND quantity IS NOT NULL; + +CREATE OR REPLACE FUNCTION sync_orders_qty_quantity() +RETURNS trigger AS $$ +BEGIN + IF NEW.qty IS NULL AND NEW.quantity IS NOT NULL THEN + NEW.qty := NEW.quantity; + ELSIF NEW.quantity IS NULL AND NEW.qty IS NOT NULL THEN + NEW.quantity := NEW.qty; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_orders_sync_qty_quantity ON orders; + +CREATE TRIGGER trg_orders_sync_qty_quantity +BEFORE INSERT OR UPDATE ON orders +FOR EACH ROW +EXECUTE FUNCTION sync_orders_qty_quantity(); + +CREATE INDEX IF NOT EXISTS idx_orders_trade_id + ON orders (trade_id); + +CREATE INDEX IF NOT EXISTS idx_orders_source + ON orders (source); + +CREATE INDEX IF NOT EXISTS idx_orders_profile_trade_id_created + ON orders (profile_id, trade_id, created_at DESC) + WHERE trade_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_orders_profile_trade_id_action_created + ON orders (profile_id, trade_id, action, created_at DESC) + WHERE trade_id IS NOT NULL; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'chk_orders_action_lifecycle' + AND conrelid = 'orders'::regclass + ) THEN + ALTER TABLE orders + ADD CONSTRAINT chk_orders_action_lifecycle + CHECK (action IS NULL OR action IN ('ENTRY', 'EXIT')) + NOT VALID; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'chk_orders_trade_id_not_blank' + AND conrelid = 'orders'::regclass + ) THEN + ALTER TABLE orders + ADD CONSTRAINT chk_orders_trade_id_not_blank + CHECK (trade_id IS NULL OR btrim(trade_id) <> '') + NOT VALID; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'chk_orders_trade_id_format' + AND conrelid = 'orders'::regclass + ) THEN + ALTER TABLE orders + ADD CONSTRAINT chk_orders_trade_id_format + CHECK (trade_id IS NULL OR trade_id LIKE 'TRD-%') + NOT VALID; + END IF; +END $$; + +-- ------------------------------------------------------------ +-- C) bot_snapshots table for rolling state backups +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS bot_snapshots ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + state_json jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_bot_snapshots_user_created + ON bot_snapshots (user_id, created_at DESC); + +COMMIT; + +-- ------------------------------------------------------------ +-- Post-run checks (optional) +-- ------------------------------------------------------------ +-- SELECT column_name FROM information_schema.columns +-- WHERE table_schema='public' AND table_name='orders' +-- AND column_name IN ('qty','quantity','trade_id','action','source'); +-- +-- SELECT column_name FROM information_schema.columns +-- WHERE table_schema='public' AND table_name='trade_history' +-- AND column_name IN ('trade_id','source'); +-- +-- SELECT count(*) AS snapshots FROM bot_snapshots; diff --git a/backend/schema/009_tenant_rls_orders_trade_history.sql b/backend/schema/009_tenant_rls_orders_trade_history.sql new file mode 100644 index 0000000..215dafd --- /dev/null +++ b/backend/schema/009_tenant_rls_orders_trade_history.sql @@ -0,0 +1,43 @@ +-- ============================================================ +-- Migration 009: Tenant RLS Hardening for Orders + Trade History +-- Date: 2026-02-16 +-- Purpose: Enforce user-level row isolation for lifecycle tables. +-- ============================================================ + +-- ORDERS table policies +ALTER TABLE orders ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "Users can read own orders" ON orders; +CREATE POLICY "Users can read own orders" ON orders + FOR SELECT + USING (auth.uid() = user_id); + +DROP POLICY IF EXISTS "Users can insert own orders" ON orders; +CREATE POLICY "Users can insert own orders" ON orders + FOR INSERT + WITH CHECK (auth.uid() = user_id); + +DROP POLICY IF EXISTS "Users can update own orders" ON orders; +CREATE POLICY "Users can update own orders" ON orders + FOR UPDATE + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); + +-- TRADE_HISTORY table policies +ALTER TABLE trade_history ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "Users can read own trade history" ON trade_history; +CREATE POLICY "Users can read own trade history" ON trade_history + FOR SELECT + USING (auth.uid() = user_id); + +DROP POLICY IF EXISTS "Users can insert own trade history" ON trade_history; +CREATE POLICY "Users can insert own trade history" ON trade_history + FOR INSERT + WITH CHECK (auth.uid() = user_id); + +DROP POLICY IF EXISTS "Users can update own trade history" ON trade_history; +CREATE POLICY "Users can update own trade history" ON trade_history + FOR UPDATE + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); diff --git a/backend/schema/010_bot_state_snapshot.sql b/backend/schema/010_bot_state_snapshot.sql new file mode 100644 index 0000000..e54069d --- /dev/null +++ b/backend/schema/010_bot_state_snapshot.sql @@ -0,0 +1,26 @@ +-- ============================================================ +-- Migration 010: Durable Bot State Snapshots +-- Date: 2026-02-16 +-- Purpose: Persist runtime snapshots in Supabase with UUID ownership. +-- ============================================================ + +CREATE TABLE IF NOT EXISTS bot_state_snapshots ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + state jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +COMMENT ON TABLE bot_state_snapshots IS 'Periodic runtime snapshot for bot state, aligned with tenant ownership.'; +COMMENT ON COLUMN bot_state_snapshots.state IS 'Serialized BotState payload captured for restart recovery.'; + +CREATE INDEX IF NOT EXISTS idx_bot_state_snapshots_user_created + ON bot_state_snapshots (user_id, created_at DESC); + +ALTER TABLE bot_state_snapshots ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "Users can manage own snapshots" ON bot_state_snapshots; +CREATE POLICY "Users can manage own snapshots" ON bot_state_snapshots + FOR ALL + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); diff --git a/backend/schema/011_capital_ledger.sql b/backend/schema/011_capital_ledger.sql new file mode 100644 index 0000000..4383228 --- /dev/null +++ b/backend/schema/011_capital_ledger.sql @@ -0,0 +1,109 @@ +-- ============================================================ +-- Migration 011: Capital Ledger Hardening +-- Date: 2026-02-16 +-- Purpose: Introduce deterministic per-profile capital ledger and helper RPCs. +-- ============================================================ + +CREATE TABLE IF NOT EXISTS capital_ledgers ( + profile_id uuid PRIMARY KEY REFERENCES trade_profiles(id) ON DELETE CASCADE, + allocated_capital numeric NOT NULL DEFAULT 0, + reserved_for_orders numeric NOT NULL DEFAULT 0, + reserved_for_positions numeric NOT NULL DEFAULT 0, + realized_pnl numeric NOT NULL DEFAULT 0, + updated_at timestamptz NOT NULL DEFAULT now() +); + +COMMENT ON TABLE capital_ledgers IS 'Deterministic ledger for profile capital isolation.'; +COMMENT ON COLUMN capital_ledgers.realized_pnl IS 'Cumulative realized profit/loss for the profile.'; + +CREATE INDEX IF NOT EXISTS idx_capital_ledgers_profile ON capital_ledgers(profile_id); + +ALTER TABLE capital_ledgers ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "Users can manage own ledger" ON capital_ledgers; +CREATE POLICY "Users can manage own ledger" ON capital_ledgers + FOR ALL + USING (auth.uid() = (SELECT user_id FROM trade_profiles WHERE id = capital_ledgers.profile_id)) + WITH CHECK (auth.uid() = (SELECT user_id FROM trade_profiles WHERE id = capital_ledgers.profile_id)); + +CREATE OR REPLACE FUNCTION fn_reserve_for_order(p_profile uuid, p_amount numeric) +RETURNS capital_ledgers AS $$ +DECLARE + ledger capital_ledgers; +BEGIN + UPDATE capital_ledgers + SET reserved_for_orders = reserved_for_orders + p_amount, + updated_at = now() + WHERE profile_id = p_profile + AND (allocated_capital - reserved_for_orders - reserved_for_positions) >= p_amount + RETURNING * INTO ledger; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Insufficient capital for profile %', p_profile; + END IF; + + RETURN ledger; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +CREATE OR REPLACE FUNCTION fn_release_order_reservation(p_profile uuid, p_amount numeric) +RETURNS capital_ledgers AS $$ +DECLARE + ledger capital_ledgers; +BEGIN + UPDATE capital_ledgers + SET reserved_for_orders = GREATEST(reserved_for_orders - p_amount, 0), + updated_at = now() + WHERE profile_id = p_profile + RETURNING * INTO ledger; + + RETURN ledger; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +CREATE OR REPLACE FUNCTION fn_adjust_position_reservation(p_profile uuid, p_delta numeric) +RETURNS capital_ledgers AS $$ +DECLARE + ledger capital_ledgers; +BEGIN + UPDATE capital_ledgers + SET reserved_for_positions = GREATEST(reserved_for_positions + p_delta, 0), + updated_at = now() + WHERE profile_id = p_profile + RETURNING * INTO ledger; + + RETURN ledger; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +CREATE OR REPLACE FUNCTION fn_record_realized_pnl(p_profile uuid, p_delta numeric) +RETURNS capital_ledgers AS $$ +DECLARE + ledger capital_ledgers; +BEGIN + UPDATE capital_ledgers + SET realized_pnl = realized_pnl + COALESCE(p_delta, 0), + updated_at = now() + WHERE profile_id = p_profile + RETURNING * INTO ledger; + + RETURN ledger; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +CREATE OR REPLACE FUNCTION fn_rebuild_ledger(p_profile uuid, p_reserved_orders numeric, p_reserved_positions numeric) +RETURNS capital_ledgers AS $$ +DECLARE + ledger capital_ledgers; +BEGIN + INSERT INTO capital_ledgers (profile_id, allocated_capital, reserved_for_orders, reserved_for_positions, updated_at) + VALUES (p_profile, (SELECT allocated_capital FROM trade_profiles WHERE id = p_profile), p_reserved_orders, p_reserved_positions, now()) + ON CONFLICT (profile_id) DO UPDATE + SET reserved_for_orders = p_reserved_orders, + reserved_for_positions = p_reserved_positions, + updated_at = now() + RETURNING * INTO ledger; + + RETURN ledger; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/backend/schema/012_entry_atomic_lifecycle.sql b/backend/schema/012_entry_atomic_lifecycle.sql new file mode 100644 index 0000000..d754aaf --- /dev/null +++ b/backend/schema/012_entry_atomic_lifecycle.sql @@ -0,0 +1,216 @@ +-- ============================================================ +-- Migration 012: Atomic ENTRY Lifecycle Persistence +-- Date: 2026-02-16 +-- Purpose: +-- 1) Add explicit trade_lifecycle and positions tables for traceability. +-- 2) Harden orders/trade_history uniqueness to drive idempotent RPC inserts. +-- 3) Expose fn_persist_entry_lifecycle for exchange-first persistence. +-- ============================================================ + +BEGIN; + +-- -------------------------------------------------------------------------------- +-- Ensure lifecycle table captures profile + trade scope +-- -------------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS trade_lifecycle ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + profile_id uuid NOT NULL REFERENCES trade_profiles(id) ON DELETE CASCADE, + trade_id text NOT NULL, + entry_order_id text NOT NULL, + current_stage text NOT NULL DEFAULT 'ENTRY', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT trade_lifecycle_profile_trade_unique UNIQUE (profile_id, trade_id) +); + +CREATE INDEX IF NOT EXISTS idx_trade_lifecycle_entry_order + ON trade_lifecycle(entry_order_id); + +-- -------------------------------------------------------------------------------- +-- Positions table for lifecycle-driven snapshots +-- -------------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS positions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + profile_id uuid NOT NULL, + trade_id text NOT NULL, + entry_order_id text NOT NULL, + symbol text NOT NULL, + side text NOT NULL, + quantity numeric NOT NULL DEFAULT 0, + avg_price numeric NOT NULL DEFAULT 0, + status text NOT NULL DEFAULT 'OPEN', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT positions_profile_trade_unique UNIQUE (profile_id, trade_id), + CONSTRAINT positions_lifecycle_fk FOREIGN KEY (profile_id, trade_id) + REFERENCES trade_lifecycle(profile_id, trade_id) ON DELETE CASCADE, + CONSTRAINT chk_positions_side CHECK (side IN ('BUY','SELL')) +); + +CREATE INDEX IF NOT EXISTS idx_positions_profile_trade + ON positions (profile_id, trade_id); +CREATE INDEX IF NOT EXISTS idx_positions_symbol ON positions (symbol); + +-- -------------------------------------------------------------------------------- +-- Harden orders + trade_history uniqueness for idempotent child inserts +-- -------------------------------------------------------------------------------- +CREATE UNIQUE INDEX IF NOT EXISTS uq_orders_order_id ON orders (order_id) WHERE order_id IS NOT NULL; + +ALTER TABLE IF EXISTS trade_history + ADD COLUMN IF NOT EXISTS lifecycle_marker text; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_trade_history_lifecycle_marker + ON trade_history (lifecycle_marker) + WHERE lifecycle_marker IS NOT NULL; + +COMMIT; + +-- -------------------------------------------------------------------------------- +-- Entry persistence RPC +-- -------------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION fn_persist_entry_lifecycle( + p_profile_id uuid, + p_trade_id text, + p_order_id text, + p_symbol text, + p_side text, + p_type text, + p_quantity numeric, + p_price numeric, + p_status text, + p_timestamp bigint, + p_stop_loss numeric DEFAULT NULL, + p_take_profit numeric DEFAULT NULL, + p_source text DEFAULT 'BOT', + p_reason text DEFAULT 'Entry lifecycle persisted' +) RETURNS jsonb AS $$ +DECLARE + lifecycle_row trade_lifecycle%ROWTYPE; + entry_marker text := format('entry:%s', p_trade_id); +BEGIN + BEGIN + INSERT INTO trade_lifecycle ( + profile_id, + trade_id, + entry_order_id, + current_stage, + created_at, + updated_at + ) VALUES ( + p_profile_id, + p_trade_id, + p_order_id, + 'ENTRY', + now(), + now() + ) + RETURNING * INTO lifecycle_row; + EXCEPTION WHEN unique_violation THEN + UPDATE trade_lifecycle + SET updated_at = now() + WHERE profile_id = p_profile_id + AND trade_id = p_trade_id; + END; + + INSERT INTO orders ( + order_id, + profile_id, + trade_id, + symbol, + type, + side, + qty, + quantity, + price, + status, + timestamp, + stop_loss, + take_profit, + action, + source + ) VALUES ( + p_order_id, + p_profile_id, + p_trade_id, + p_symbol, + p_type, + upper(p_side), + COALESCE(p_quantity, 0), + COALESCE(p_quantity, 0), + COALESCE(p_price, 0), + p_status, + p_timestamp, + p_stop_loss, + p_take_profit, + 'ENTRY', + p_source + ) ON CONFLICT (order_id) DO NOTHING; + + INSERT INTO positions ( + profile_id, + trade_id, + entry_order_id, + symbol, + side, + quantity, + avg_price, + status, + created_at, + updated_at + ) VALUES ( + p_profile_id, + p_trade_id, + p_order_id, + p_symbol, + upper(p_side), + 0, + COALESCE(p_price, 0), + 'OPEN', + now(), + now() + ) ON CONFLICT (profile_id, trade_id) DO NOTHING; + + INSERT INTO trade_history ( + trade_id, + profile_id, + symbol, + side, + size, + entry_price, + exit_price, + pnl, + pnl_percent, + reason, + timestamp, + stop_loss, + take_profit, + source, + lifecycle_marker + ) VALUES ( + p_trade_id, + p_profile_id, + p_symbol, + upper(p_side), + COALESCE(p_quantity, 0), + COALESCE(p_price, 0), + NULL, + 0, + 0, + p_reason, + p_timestamp, + p_stop_loss, + p_take_profit, + p_source, + entry_marker + ) ON CONFLICT (lifecycle_marker) DO NOTHING; + + IF lifecycle_row IS NULL THEN + SELECT * INTO lifecycle_row + FROM trade_lifecycle + WHERE profile_id = p_profile_id + AND trade_id = p_trade_id; + END IF; + + RETURN row_to_json(lifecycle_row); +END; +$$ LANGUAGE plpgsql; diff --git a/backend/schema/013_distributed_entry_lock.sql b/backend/schema/013_distributed_entry_lock.sql new file mode 100644 index 0000000..58a57e9 --- /dev/null +++ b/backend/schema/013_distributed_entry_lock.sql @@ -0,0 +1,49 @@ +-- ============================================================ +-- Migration 013: Distributed Entry Locking +-- Date: 2026-02-18 +-- Purpose: Add deterministic distributed locking + lifecycle guard for ENTRY flows. +-- ============================================================ + +-- Determine a stable 64-bit key per profile+symbol pair. +CREATE OR REPLACE FUNCTION fn_entry_lock_key(p_profile uuid, p_symbol text) +RETURNS bigint AS $$ +DECLARE + profile_hash bigint; + symbol_hash bigint; +BEGIN + profile_hash := abs(hashtext(coalesce(p_profile::text, ''))::bigint) % 2147483647; + symbol_hash := abs(hashtext(coalesce(lower(p_symbol), ''))::bigint) % 4294967295; + RETURN ((profile_hash << 32) + symbol_hash) % 9223372036854775807; +END; +$$ LANGUAGE plpgsql IMMUTABLE; + +-- Try to obtain the advisory lock without blocking. +CREATE OR REPLACE FUNCTION fn_try_acquire_entry_lock(p_profile uuid, p_symbol text) +RETURNS boolean AS $$ +BEGIN + RETURN pg_try_advisory_lock(fn_entry_lock_key(p_profile, p_symbol)); +END; +$$ LANGUAGE plpgsql; + +-- Release the advisory lock. +CREATE OR REPLACE FUNCTION fn_release_entry_lock(p_profile uuid, p_symbol text) +RETURNS boolean AS $$ +BEGIN + RETURN pg_advisory_unlock(fn_entry_lock_key(p_profile, p_symbol)); +END; +$$ LANGUAGE plpgsql; + +-- Check for an existing ENTRY/OPEN lifecycle for the same profile+symbol. +CREATE OR REPLACE FUNCTION fn_is_entry_in_progress(p_profile uuid, p_symbol text) +RETURNS boolean AS $$ +BEGIN + RETURN EXISTS ( + SELECT 1 + FROM trade_lifecycle tl + JOIN positions p ON p.profile_id = tl.profile_id AND p.trade_id = tl.trade_id + WHERE tl.profile_id = p_profile + AND lower(p.symbol) = lower(coalesce(p_symbol, '')) + AND tl.current_stage IN ('ENTRY', 'OPEN') + ); +END; +$$ LANGUAGE plpgsql STABLE; diff --git a/backend/schema/014_entry_row_lock.sql b/backend/schema/014_entry_row_lock.sql new file mode 100644 index 0000000..0682c1d --- /dev/null +++ b/backend/schema/014_entry_row_lock.sql @@ -0,0 +1,79 @@ +-- ============================================================ +-- Migration 014: Row-Based Entry Lock Table +-- Date: 2026-02-18 +-- Purpose: Replace session-based advisory locks with durable row locks. +-- ============================================================ + +CREATE TABLE IF NOT EXISTS entry_locks ( + profile_id uuid NOT NULL, + symbol text NOT NULL, + symbol_normalized text GENERATED ALWAYS AS (lower(symbol)) STORED NOT NULL, + owner text NOT NULL, + acquired_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, + PRIMARY KEY (profile_id, symbol_normalized) +); + +CREATE INDEX IF NOT EXISTS entry_locks_expires_idx ON entry_locks (expires_at); + +ALTER TABLE entry_locks ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "Allow entry lock owner" ON entry_locks; +CREATE POLICY "Allow entry lock owner" ON entry_locks + FOR ALL + USING (auth.uid() = profile_id) + WITH CHECK (auth.uid() = profile_id); + +DROP POLICY IF EXISTS "Allow service role entry lock access" ON entry_locks; +CREATE POLICY "Allow service role entry lock access" ON entry_locks + FOR ALL + USING (auth.role() = 'service_role') + WITH CHECK (auth.role() = 'service_role'); + +CREATE OR REPLACE FUNCTION fn_try_acquire_entry_lock_row( + p_profile_id uuid, + p_symbol text, + p_owner text, + p_ttl_seconds integer DEFAULT 30 +) +RETURNS boolean AS +$$ +DECLARE + now_ts timestamptz := now(); + ttl integer := GREATEST(COALESCE(p_ttl_seconds, 30), 1); + expires timestamptz := now_ts + make_interval(secs := ttl); +BEGIN + INSERT INTO entry_locks (profile_id, symbol, owner, acquired_at, expires_at) + VALUES (p_profile_id, p_symbol, p_owner, now_ts, expires) + ON CONFLICT (profile_id, symbol_normalized) + WHERE entry_locks.expires_at <= now_ts + DO UPDATE SET + owner = EXCLUDED.owner, + acquired_at = EXCLUDED.acquired_at, + expires_at = EXCLUDED.expires_at; + RETURN TRUE; +EXCEPTION WHEN unique_violation THEN + RETURN FALSE; +END; +$$ +LANGUAGE plpgsql VOLATILE; + +CREATE OR REPLACE FUNCTION fn_release_entry_lock_row( + p_profile_id uuid, + p_symbol text, + p_owner text +) +RETURNS boolean AS +$$ +DECLARE + deleted_count integer; +BEGIN + DELETE FROM entry_locks + WHERE profile_id = p_profile_id + AND symbol_normalized = lower(p_symbol) + AND owner = p_owner; + GET DIAGNOSTICS deleted_count = ROW_COUNT; + RETURN deleted_count > 0; +END; +$$ +LANGUAGE plpgsql VOLATILE; diff --git a/backend/schema/015_reconciliation_lock.sql b/backend/schema/015_reconciliation_lock.sql new file mode 100644 index 0000000..c3c6917 --- /dev/null +++ b/backend/schema/015_reconciliation_lock.sql @@ -0,0 +1,73 @@ +-- ============================================================ +-- Migration 015: Reconciliation Row Lock +-- Date: 2026-02-18 +-- Purpose: Prevent overlapping reconciliation cycles per profile. +-- ============================================================ + +CREATE TABLE IF NOT EXISTS reconciliation_locks ( + profile_id uuid PRIMARY KEY, + owner text NOT NULL, + acquired_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL +); + +CREATE INDEX IF NOT EXISTS reconciliation_locks_expires_idx ON reconciliation_locks (expires_at); + +ALTER TABLE reconciliation_locks ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "Allow reconciliation lock owner" ON reconciliation_locks; +CREATE POLICY "Allow reconciliation lock owner" ON reconciliation_locks + FOR ALL + USING (auth.uid() = profile_id) + WITH CHECK (auth.uid() = profile_id); + +DROP POLICY IF EXISTS "Allow service role reconciliation lock access" ON reconciliation_locks; +CREATE POLICY "Allow service role reconciliation lock access" ON reconciliation_locks + FOR ALL + USING (auth.role() = 'service_role') + WITH CHECK (auth.role() = 'service_role'); + +CREATE OR REPLACE FUNCTION fn_try_acquire_reconciliation_lock_row( + p_profile_id uuid, + p_owner text, + p_ttl_seconds integer DEFAULT 30 +) +RETURNS boolean AS +$$ +DECLARE + now_ts timestamptz := now(); + ttl integer := GREATEST(COALESCE(p_ttl_seconds, 30), 1); + expires timestamptz := now_ts + make_interval(secs := ttl); +BEGIN + INSERT INTO reconciliation_locks (profile_id, owner, acquired_at, expires_at) + VALUES (p_profile_id, p_owner, now_ts, expires) + ON CONFLICT (profile_id) + WHERE reconciliation_locks.expires_at <= now_ts + DO UPDATE SET + owner = EXCLUDED.owner, + acquired_at = EXCLUDED.acquired_at, + expires_at = EXCLUDED.expires_at; + RETURN TRUE; +EXCEPTION WHEN unique_violation THEN + RETURN FALSE; +END; +$$ +LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION fn_release_reconciliation_lock_row( + p_profile_id uuid, + p_owner text +) +RETURNS boolean AS +$$ +DECLARE + deleted_count integer; +BEGIN + DELETE FROM reconciliation_locks + WHERE profile_id = p_profile_id + AND owner = p_owner; + GET DIAGNOSTICS deleted_count = ROW_COUNT; + RETURN deleted_count > 0; +END; +$$ +LANGUAGE plpgsql; diff --git a/backend/schema/016_add_strategy_marketplace.sql b/backend/schema/016_add_strategy_marketplace.sql new file mode 100644 index 0000000..55344ab --- /dev/null +++ b/backend/schema/016_add_strategy_marketplace.sql @@ -0,0 +1,52 @@ +-- ============================================================ +-- Migration 016: Add Strategy Marketplace +-- Purpose: Support publishing of trade profiles to a marketplace as templates. +-- ============================================================ + +CREATE TABLE IF NOT EXISTS public.strategy_presets ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + risk_style_id TEXT DEFAULT 'balanced', + recommended_assets TEXT[], + typical_trades_per_day TEXT, + performance_tag TEXT, + is_popular BOOLEAN DEFAULT false, + created_at TIMESTAMPTZ DEFAULT NOW(), + created_by UUID REFERENCES auth.users(id), + original_profile_id UUID REFERENCES public.trade_profiles(id), + strategy_config JSONB, + role_required TEXT DEFAULT 'free' +); + +COMMENT ON TABLE public.strategy_presets IS 'Marketplace templates for trading strategies published by admins'; +COMMENT ON COLUMN public.strategy_presets.strategy_config IS 'Full rule and risk configuration snapshot for the template'; + +-- Enable Row Level Security and Realtime +ALTER TABLE public.strategy_presets ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.strategy_presets REPLICA IDENTITY FULL; + +-- Policy: Everyone can read strategy presets +DROP POLICY IF EXISTS "Everyone can read presets" ON public.strategy_presets; +CREATE POLICY "Everyone can read presets" ON public.strategy_presets + FOR SELECT USING (true); + +-- Policy: Only admins can insert/update/delete strategy presets +DROP POLICY IF EXISTS "Admins can manage presets" ON public.strategy_presets; +CREATE POLICY "Admins can manage presets" ON public.strategy_presets + USING ( + EXISTS ( + SELECT 1 FROM public.users + WHERE user_id = auth.uid() AND role = 'admin' + ) + ) + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.users + WHERE user_id = auth.uid() AND role = 'admin' + ) + ); + +-- Create index for faster marketplace loading +CREATE INDEX IF NOT EXISTS idx_strategy_presets_risk_style ON public.strategy_presets(risk_style_id); +CREATE INDEX IF NOT EXISTS idx_strategy_presets_is_popular ON public.strategy_presets(is_popular); diff --git a/backend/schema/017_reconciliation_exit_backfill_audit.sql b/backend/schema/017_reconciliation_exit_backfill_audit.sql new file mode 100644 index 0000000..6649566 --- /dev/null +++ b/backend/schema/017_reconciliation_exit_backfill_audit.sql @@ -0,0 +1,47 @@ +-- ============================================================ +-- Migration 017: Reconciliation EXIT Backfill Audit +-- Date: 2026-02-21 +-- Purpose: +-- 1) Persist non-destructive audit trail for reconciliation EXIT backfill. +-- 2) Support batch-level rollback workflows without deleting rows. +-- ============================================================ + +CREATE TABLE IF NOT EXISTS reconciliation_backfill_audit ( + id bigserial PRIMARY KEY, + batch_id text NOT NULL, + profile_id uuid NOT NULL REFERENCES trade_profiles(id) ON DELETE CASCADE, + symbol text NOT NULL, + trade_id text NOT NULL, + exchange_order_id text, + exchange_client_order_id text, + backfill_order_id text, + filled_qty numeric, + filled_price numeric, + filled_at timestamptz, + dry_run boolean NOT NULL DEFAULT true, + decision text NOT NULL, + reason text, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + applied_at timestamptz, + reverted_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_recon_backfill_audit_batch + ON reconciliation_backfill_audit (batch_id); + +CREATE INDEX IF NOT EXISTS idx_recon_backfill_audit_profile_symbol_trade + ON reconciliation_backfill_audit (profile_id, symbol, trade_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_recon_backfill_audit_decision + ON reconciliation_backfill_audit (decision, created_at DESC); + +ALTER TABLE reconciliation_backfill_audit ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "Allow service role reconciliation backfill audit access" + ON reconciliation_backfill_audit; +CREATE POLICY "Allow service role reconciliation backfill audit access" + ON reconciliation_backfill_audit + FOR ALL + USING (auth.role() = 'service_role') + WITH CHECK (auth.role() = 'service_role'); diff --git a/backend/schema/018_orders_order_id_uniqueness_repair.sql b/backend/schema/018_orders_order_id_uniqueness_repair.sql new file mode 100644 index 0000000..2a18e17 --- /dev/null +++ b/backend/schema/018_orders_order_id_uniqueness_repair.sql @@ -0,0 +1,211 @@ +-- ============================================================ +-- Migration 018: Orders order_id Uniqueness Repair +-- Date: 2026-02-22 +-- Purpose: +-- 1) Repair legacy duplicate orders.order_id rows without deleting data. +-- 2) Create/ensure deterministic ON CONFLICT support for orders(order_id). +-- 3) Keep full reversibility via audit trail. +-- ============================================================ + +-- ----------------------------------------------------------------------------- +-- Duplicate-repair audit trail (non-destructive + reversible) +-- ----------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS orders_order_id_dedupe_audit ( + id bigserial PRIMARY KEY, + migration_id text NOT NULL DEFAULT '018_orders_order_id_uniqueness_repair', + occurred_at timestamptz NOT NULL DEFAULT now(), + order_row_ref text NOT NULL, + profile_id uuid, + old_order_id text NOT NULL, + new_order_id text NOT NULL, + snapshot jsonb NOT NULL DEFAULT '{}'::jsonb +); + +CREATE INDEX IF NOT EXISTS idx_orders_order_id_dedupe_old + ON orders_order_id_dedupe_audit (old_order_id, occurred_at DESC); + +CREATE INDEX IF NOT EXISTS idx_orders_order_id_dedupe_new + ON orders_order_id_dedupe_audit (new_order_id, occurred_at DESC); + +CREATE INDEX IF NOT EXISTS idx_orders_order_id_dedupe_profile + ON orders_order_id_dedupe_audit (profile_id, occurred_at DESC); + +DO $$ +DECLARE + has_orders_table boolean; + has_order_id boolean; + has_id boolean; + has_created_at boolean; +BEGIN + SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'orders' + ) INTO has_orders_table; + + IF NOT has_orders_table THEN + RAISE NOTICE 'Skipping migration 018: orders table not found.'; + RETURN; + END IF; + + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'orders' + AND column_name = 'order_id' + ) INTO has_order_id; + + IF NOT has_order_id THEN + RAISE NOTICE 'Skipping migration 018: orders.order_id not found.'; + RETURN; + END IF; + + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'orders' + AND column_name = 'id' + ) INTO has_id; + + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'orders' + AND column_name = 'created_at' + ) INTO has_created_at; + + IF has_id AND has_created_at THEN + EXECUTE $sql$ + CREATE TEMP TABLE _orders_order_id_dupes ON COMMIT DROP AS + WITH ranked AS ( + SELECT + o.ctid AS row_ctid, + o.id::text AS row_ref, + o.profile_id, + o.order_id, + row_to_json(o)::jsonb AS snapshot, + row_number() OVER ( + PARTITION BY o.order_id + ORDER BY o.created_at DESC NULLS LAST, o.id DESC + ) AS rn + FROM orders o + WHERE o.order_id IS NOT NULL + ) + SELECT + row_ctid, + row_ref, + profile_id, + order_id AS old_order_id, + left(order_id, 180) || '__DUP018__' || substr(md5(row_ref || ':' || order_id), 1, 16) AS new_order_id, + snapshot + FROM ranked + WHERE rn > 1 + $sql$; + ELSIF has_id THEN + EXECUTE $sql$ + CREATE TEMP TABLE _orders_order_id_dupes ON COMMIT DROP AS + WITH ranked AS ( + SELECT + o.ctid AS row_ctid, + o.id::text AS row_ref, + o.profile_id, + o.order_id, + row_to_json(o)::jsonb AS snapshot, + row_number() OVER ( + PARTITION BY o.order_id + ORDER BY o.id DESC + ) AS rn + FROM orders o + WHERE o.order_id IS NOT NULL + ) + SELECT + row_ctid, + row_ref, + profile_id, + order_id AS old_order_id, + left(order_id, 180) || '__DUP018__' || substr(md5(row_ref || ':' || order_id), 1, 16) AS new_order_id, + snapshot + FROM ranked + WHERE rn > 1 + $sql$; + ELSE + EXECUTE $sql$ + CREATE TEMP TABLE _orders_order_id_dupes ON COMMIT DROP AS + WITH ranked AS ( + SELECT + o.ctid AS row_ctid, + o.ctid::text AS row_ref, + o.profile_id, + o.order_id, + row_to_json(o)::jsonb AS snapshot, + row_number() OVER ( + PARTITION BY o.order_id + ORDER BY o.ctid DESC + ) AS rn + FROM orders o + WHERE o.order_id IS NOT NULL + ) + SELECT + row_ctid, + row_ref, + profile_id, + order_id AS old_order_id, + left(order_id, 180) || '__DUP018__' || substr(md5(row_ref || ':' || order_id), 1, 16) AS new_order_id, + snapshot + FROM ranked + WHERE rn > 1 + $sql$; + END IF; + + INSERT INTO orders_order_id_dedupe_audit ( + order_row_ref, + profile_id, + old_order_id, + new_order_id, + snapshot + ) + SELECT + d.row_ref, + d.profile_id, + d.old_order_id, + d.new_order_id, + d.snapshot + FROM _orders_order_id_dupes d + WHERE NOT EXISTS ( + SELECT 1 + FROM orders_order_id_dedupe_audit a + WHERE a.order_row_ref = d.row_ref + AND a.old_order_id = d.old_order_id + AND a.new_order_id = d.new_order_id + ); + + UPDATE orders o + SET order_id = d.new_order_id + FROM _orders_order_id_dupes d + WHERE o.ctid = d.row_ctid + AND o.order_id = d.old_order_id; +END; +$$; + +-- ----------------------------------------------------------------------------- +-- Enforce idempotent upsert path for lifecycle/backfill inserts. +-- ----------------------------------------------------------------------------- +CREATE UNIQUE INDEX IF NOT EXISTS uq_orders_order_id + ON orders (order_id) + WHERE order_id IS NOT NULL; + +-- ----------------------------------------------------------------------------- +-- Reversal note (manual): +-- 1) Drop index: +-- DROP INDEX IF EXISTS uq_orders_order_id; +-- 2) Restore remapped rows: +-- UPDATE orders o +-- SET order_id = a.old_order_id +-- FROM orders_order_id_dedupe_audit a +-- WHERE a.migration_id = '018_orders_order_id_uniqueness_repair' +-- AND o.order_id = a.new_order_id; +-- ----------------------------------------------------------------------------- diff --git a/backend/schema/019_orders_order_id_on_conflict_fix.sql b/backend/schema/019_orders_order_id_on_conflict_fix.sql new file mode 100644 index 0000000..0847775 --- /dev/null +++ b/backend/schema/019_orders_order_id_on_conflict_fix.sql @@ -0,0 +1,70 @@ +-- ============================================================ +-- Migration 019: Orders ON CONFLICT(order_id) Compatibility Fix +-- Date: 2026-02-22 +-- Purpose: +-- Ensure Postgres can infer a unique index for: +-- ON CONFLICT (order_id) +-- by using a non-partial unique index on orders(order_id). +-- ============================================================ + +-- ----------------------------------------------------------------------------- +-- Safety: if any duplicate non-null order_id rows still exist, deterministically +-- remap tail rows before creating the full unique index. +-- ----------------------------------------------------------------------------- +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM ( + SELECT order_id + FROM orders + WHERE order_id IS NOT NULL + GROUP BY order_id + HAVING COUNT(*) > 1 + ) dupes + ) THEN + WITH ranked AS ( + SELECT + o.ctid AS row_ctid, + o.id::text AS row_ref, + o.order_id, + row_number() OVER ( + PARTITION BY o.order_id + ORDER BY o.created_at DESC NULLS LAST, o.id DESC + ) AS rn + FROM orders o + WHERE o.order_id IS NOT NULL + ), + to_rewrite AS ( + SELECT + row_ctid, + order_id AS old_order_id, + left(order_id, 180) || '__DUP019__' || substr(md5(row_ref || ':' || order_id), 1, 16) AS new_order_id + FROM ranked + WHERE rn > 1 + ) + UPDATE orders o + SET order_id = r.new_order_id + FROM to_rewrite r + WHERE o.ctid = r.row_ctid + AND o.order_id = r.old_order_id; + END IF; +END; +$$; + +-- ----------------------------------------------------------------------------- +-- Replace partial index with full unique index so ON CONFLICT(order_id) resolves. +-- PostgreSQL unique indexes allow multiple NULL values, so legacy NULL order_id +-- rows remain valid. +-- ----------------------------------------------------------------------------- +DROP INDEX IF EXISTS uq_orders_order_id; +CREATE UNIQUE INDEX IF NOT EXISTS uq_orders_order_id + ON orders (order_id); + +-- ----------------------------------------------------------------------------- +-- Reversal (manual): +-- DROP INDEX IF EXISTS uq_orders_order_id; +-- CREATE UNIQUE INDEX IF NOT EXISTS uq_orders_order_id +-- ON orders (order_id) +-- WHERE order_id IS NOT NULL; +-- ----------------------------------------------------------------------------- diff --git a/backend/schema/020_add_orders_sub_tag.sql b/backend/schema/020_add_orders_sub_tag.sql new file mode 100644 index 0000000..2c94d94 --- /dev/null +++ b/backend/schema/020_add_orders_sub_tag.sql @@ -0,0 +1,20 @@ +-- ============================================================ +-- Migration 020: Add orders.sub_tag for deterministic correlation +-- Date: 2026-02-22 +-- Purpose: +-- 1) Persist Alpaca omnibus sub_tag on lifecycle/order rows. +-- 2) Improve traceability for profile + trade reconciliation. +-- ============================================================ + +ALTER TABLE orders + ADD COLUMN IF NOT EXISTS sub_tag text; + +CREATE INDEX IF NOT EXISTS idx_orders_profile_trade_sub_tag + ON orders (profile_id, trade_id, sub_tag) + WHERE sub_tag IS NOT NULL; + +-- ----------------------------------------------------------------- +-- Reversal (manual): +-- DROP INDEX IF EXISTS idx_orders_profile_trade_sub_tag; +-- ALTER TABLE orders DROP COLUMN IF EXISTS sub_tag; +-- ----------------------------------------------------------------- diff --git a/backend/schema/021_capital_ledger_realized_pnl_reserve_fix.sql b/backend/schema/021_capital_ledger_realized_pnl_reserve_fix.sql new file mode 100644 index 0000000..f121e6d --- /dev/null +++ b/backend/schema/021_capital_ledger_realized_pnl_reserve_fix.sql @@ -0,0 +1,49 @@ +-- ============================================================ +-- Migration 021: Capital Ledger Reserve Formula Parity Fix +-- Date: 2026-03-03 +-- Purpose: +-- - Align fn_reserve_for_order with runtime available-capital math. +-- - Prevent false "Insufficient capital to reserve order" when realized_pnl > 0. +-- ============================================================ + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'chk_capital_ledgers_non_negative' + ) THEN + ALTER TABLE capital_ledgers + ADD CONSTRAINT chk_capital_ledgers_non_negative + CHECK ( + allocated_capital >= 0 + AND reserved_for_orders >= 0 + AND reserved_for_positions >= 0 + ); + END IF; +END; +$$; + +CREATE OR REPLACE FUNCTION fn_reserve_for_order(p_profile uuid, p_amount numeric) +RETURNS capital_ledgers AS $$ +DECLARE + ledger capital_ledgers; +BEGIN + IF COALESCE(p_amount, 0) <= 0 THEN + RAISE EXCEPTION 'Invalid reserve amount for profile % (amount=%)', p_profile, p_amount; + END IF; + + UPDATE capital_ledgers + SET reserved_for_orders = reserved_for_orders + p_amount, + updated_at = now() + WHERE profile_id = p_profile + AND (allocated_capital + realized_pnl - reserved_for_orders - reserved_for_positions) >= p_amount + RETURNING * INTO ledger; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Insufficient capital for profile %', p_profile; + END IF; + + RETURN ledger; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/backend/seedTwoBestProfiles.ts b/backend/seedTwoBestProfiles.ts new file mode 100644 index 0000000..3b6edee --- /dev/null +++ b/backend/seedTwoBestProfiles.ts @@ -0,0 +1,230 @@ +import 'dotenv/config'; +import { createClient } from '@supabase/supabase-js'; + +type SeedProfile = { + name: string; + allocated_capital: number; + risk_per_trade_percent: number; + symbols: string; + is_active: boolean; + strategy_config: Record; +}; + +const supabaseUrl = String(process.env.SUPABASE_URL || '').trim(); +const supabaseKey = String( + process.env.SUPABASE_KEY + || process.env.SUPABASE_SERVICE_ROLE_KEY + || process.env.SUPABASE_ANON_KEY + || '' +).trim(); + +if (!supabaseUrl || !supabaseKey) { + throw new Error('Missing Supabase credentials. Expected SUPABASE_URL and SUPABASE_KEY/SUPABASE_SERVICE_ROLE_KEY.'); +} + +const supabase = createClient(supabaseUrl, supabaseKey); +const args = process.argv.slice(2); +const userIdArg = args.find((arg) => arg.startsWith('--user-id='))?.split('=')[1]?.trim(); +const dryRun = args.includes('--dry-run'); + +const PROFILE_SPLIT = { + aggressive: 1500, + conservative: 1500 +}; + +const buildProfiles = (): SeedProfile[] => { + const frequentMomentum: SeedProfile = { + name: 'Frequent Momentum Pro', + allocated_capital: PROFILE_SPLIT.aggressive, + risk_per_trade_percent: 1.2, + symbols: 'BTC/USDT, ETH/USDT, SOL/USDT', + is_active: true, + strategy_config: { + rules: [ + { ruleId: 'TrendBiasRule', enabled: true, params: { fastPeriod: 20, slowPeriod: 100 } }, + { ruleId: 'SessionRule', enabled: true, params: { sessions: ['NY', 'LDN'] } }, + { ruleId: 'ZoneRule', enabled: true, params: { timeframe: '15m', zonePercent: 1.2 } }, + { ruleId: 'MomentumRule', enabled: true, params: { timeframe: '15m', rsiPeriod: 9, overbought: 72, oversold: 28 } }, + { + ruleId: 'EntryTriggerRule', + enabled: true, + params: { + timeframe: '15m', + wickRatioThreshold: 0.45, + enableEmaReclaim: true, + enableWickRejection: true + } + }, + { ruleId: 'RiskManagementRule', enabled: true, params: { atrPeriod: 10, slMultiplier: 1.2, maxRisk: 1.2 } }, + { ruleId: 'AIAnalysisRule', enabled: false, params: { minConfidence: 70 } } + ], + riskLimits: { + maxDailyLossUsd: 45, + maxConsecutiveLosses: 3, + maxOpenTrades: 2 + }, + execution: { + orderType: 'market', + cooldownMinutes: 8, + entryMode: 'both', + profitExitPercent: 0.8 + } + } + }; + + const controlledIntraday: SeedProfile = { + name: 'Controlled Intraday Guard', + allocated_capital: PROFILE_SPLIT.conservative, + risk_per_trade_percent: 0.8, + symbols: 'BTC/USDT, ETH/USDT', + is_active: true, + strategy_config: { + rules: [ + { ruleId: 'TrendBiasRule', enabled: true, params: { fastPeriod: 50, slowPeriod: 200 } }, + { ruleId: 'SessionRule', enabled: true, params: { sessions: ['NY', 'LDN'] } }, + { ruleId: 'ZoneRule', enabled: true, params: { timeframe: '1h', zonePercent: 1.0 } }, + { ruleId: 'MomentumRule', enabled: true, params: { timeframe: '1h', rsiPeriod: 14, overbought: 68, oversold: 32 } }, + { + ruleId: 'EntryTriggerRule', + enabled: true, + params: { + timeframe: '1h', + wickRatioThreshold: 0.5, + enableEmaReclaim: true, + enableWickRejection: true + } + }, + { ruleId: 'RiskManagementRule', enabled: true, params: { atrPeriod: 14, slMultiplier: 1.0, maxRisk: 0.9 } }, + { ruleId: 'AIAnalysisRule', enabled: false, params: { minConfidence: 75 } } + ], + riskLimits: { + maxDailyLossUsd: 30, + maxConsecutiveLosses: 2, + maxOpenTrades: 2 + }, + execution: { + orderType: 'market', + cooldownMinutes: 18, + entryMode: 'long_only', + profitExitPercent: 1.0 + } + } + }; + + return [frequentMomentum, controlledIntraday]; +}; + +const fetchTargetUsers = async (): Promise => { + if (userIdArg) return [userIdArg]; + + const { data, error } = await supabase + .from('users') + .select('user_id') + .eq('trade_enable', true); + if (error) { + throw new Error(`Failed to fetch active users: ${error.message}`); + } + + const ids = (data || []) + .map((row: any) => String(row?.user_id || '').trim()) + .filter(Boolean); + return Array.from(new Set(ids)); +}; + +const upsertProfileForUser = async (userId: string, profile: SeedProfile): Promise<'inserted' | 'updated'> => { + const { data: existing, error: existingError } = await supabase + .from('trade_profiles') + .select('id') + .eq('user_id', userId) + .eq('name', profile.name) + .order('created_at', { ascending: true }) + .limit(1); + + if (existingError) { + throw new Error(`Failed to query existing profile "${profile.name}" for user ${userId}: ${existingError.message}`); + } + + const payload = { + user_id: userId, + name: profile.name, + allocated_capital: profile.allocated_capital, + risk_per_trade_percent: profile.risk_per_trade_percent, + symbols: profile.symbols, + is_active: profile.is_active, + strategy_config: profile.strategy_config + }; + + if (existing && existing.length > 0) { + if (!dryRun) { + const { error: updateError } = await supabase + .from('trade_profiles') + .update(payload) + .eq('id', existing[0].id); + if (updateError) { + throw new Error(`Failed to update profile "${profile.name}" for user ${userId}: ${updateError.message}`); + } + } + return 'updated'; + } + + if (!dryRun) { + const { error: insertError } = await supabase + .from('trade_profiles') + .insert([payload]); + if (insertError) { + throw new Error(`Failed to insert profile "${profile.name}" for user ${userId}: ${insertError.message}`); + } + } + return 'inserted'; +}; + +const run = async (): Promise => { + const users = await fetchTargetUsers(); + const profiles = buildProfiles(); + + if (!users.length) { + console.log(JSON.stringify({ ok: true, dry_run: dryRun, users_targeted: 0, message: 'No active users found.' }, null, 2)); + return; + } + + let inserted = 0; + let updated = 0; + const perUser: Array<{ user_id: string; inserted: number; updated: number }> = []; + + for (const userId of users) { + let userInserted = 0; + let userUpdated = 0; + for (const profile of profiles) { + const result = await upsertProfileForUser(userId, profile); + if (result === 'inserted') { + inserted += 1; + userInserted += 1; + } else { + updated += 1; + userUpdated += 1; + } + } + perUser.push({ user_id: userId, inserted: userInserted, updated: userUpdated }); + } + + console.log(JSON.stringify({ + ok: true, + dry_run: dryRun, + users_targeted: users.length, + profiles_per_user: profiles.length, + total_allocation_per_user: PROFILE_SPLIT.aggressive + PROFILE_SPLIT.conservative, + totals: { + inserted, + updated + }, + per_user: perUser + }, null, 2)); +}; + +run().catch((error) => { + console.error(JSON.stringify({ + ok: false, + error: error instanceof Error ? error.message : String(error) + }, null, 2)); + process.exit(1); +}); diff --git a/backend/seed_profile_data.ts b/backend/seed_profile_data.ts new file mode 100644 index 0000000..0032648 --- /dev/null +++ b/backend/seed_profile_data.ts @@ -0,0 +1,90 @@ +import { createClient } from '@supabase/supabase-js'; +import { config } from '../src/config/index.js'; + +async function seedTestData() { + console.log('--- SEEDING TEST DATA FOR PROFILES ---'); + + if (!config.SUPABASE_URL || !config.SUPABASE_KEY) { + console.error('Supabase credentials missing.'); + return; + } + + const supabase = createClient(config.SUPABASE_URL, config.SUPABASE_KEY); + + // 1. Get all profiles + const { data: profiles, error: pError } = await supabase + .from('trade_profiles') + .select('*'); + + if (pError || !profiles) { + console.error('Error fetching profiles:', pError?.message); + return; + } + + console.log(`Found ${profiles.length} profiles to seed.`); + + const symbols = ['BTC/USD', 'ETH/USD', 'AAPL', 'TSLA', 'NVDA', 'SOL/USD']; + + for (const profile of profiles) { + console.log(`Seeding data for profile: ${profile.name}...`); + + const userId = profile.user_id; + const profileId = profile.id; + + // Generate 2-3 mock history records + const recordsCount = 2 + Math.floor(Math.random() * 2); + const historyEntries = []; + const orderEntries = []; + + for (let i = 0; i < recordsCount; i++) { + const sym = symbols[Math.floor(Math.random() * symbols.length)]; + const entryPrice = 100 + Math.random() * 1000; + const pnlPercent = (Math.random() * 10) - 4; // -4% to +6% + const exitPrice = entryPrice * (1 + (pnlPercent / 100)); + const size = 1 + Math.random() * 5; + const pnl = (exitPrice - entryPrice) * size; + + historyEntries.push({ + user_id: userId, + profile_id: profileId, + symbol: sym, + side: 'BUY', + entry_price: entryPrice, + exit_price: exitPrice, + size: size, + pnl: pnl, + pnl_percent: pnlPercent, + reason: 'STRATEGY_SIGNAL_AUTO', + timestamp: Math.floor(Date.now() - (i * 86400000) - (Math.random() * 1000000)), + }); + + // Add a corresponding order for the latest trade + orderEntries.push({ + user_id: userId, + profile_id: profileId, + order_id: `test-ord-${Math.random().toString(36).substring(7)}`, + symbol: sym, + type: 'Market', + side: 'buy', + qty: size, + price: entryPrice, + status: 'Filled', + timestamp: Math.floor(Date.now() - (i * 86400000) - 500000) + }); + } + + // Insert History + const { error: hError } = await supabase.from('trade_history').insert(historyEntries); + if (hError) console.error(`Error inserting history for ${profile.name}:`, hError.message); + else console.log(`✅ Inserted ${historyEntries.length} history records for ${profile.name}`); + + // Insert Orders + const { error: oError } = await supabase.from('orders').insert(orderEntries); + if (oError) console.error(`Error inserting orders for ${profile.name}:`, oError.message); + else console.log(`✅ Inserted ${orderEntries.length} order records for ${profile.name}`); + } + + console.log('\n--- SEEDING COMPLETE ---'); +} + +seedTestData().catch(console.error); diff --git a/backend/simple_check.ts b/backend/simple_check.ts new file mode 100644 index 0000000..4da2617 --- /dev/null +++ b/backend/simple_check.ts @@ -0,0 +1,17 @@ +import http from 'http'; + +http.get('http://localhost:5000/api/status', (res) => { + let data = ''; + res.on('data', (chunk) => { data += chunk; }); + res.on('end', () => { + try { + const botState = JSON.parse(data); + console.log('--- LIVE BOT ANALYSIS STATUS ---'); + Object.entries(botState.symbols).forEach(([symbol, details]: [string, any]) => { + console.log(`📍 ${symbol}: Signal=${details.signal}`); + }); + } catch (e: any) { + console.error('Error:', e.message); + } + }); +}); diff --git a/backend/simulate_high_activity.ts b/backend/simulate_high_activity.ts new file mode 100644 index 0000000..2268b62 --- /dev/null +++ b/backend/simulate_high_activity.ts @@ -0,0 +1,142 @@ + +import { supabaseService } from '../src/services/SupabaseService.js'; +import logger from '../src/utils/logger.js'; +import { v4 as uuidv4 } from 'uuid'; + +async function setupHighRiskProfile() { + logger.info('🚀 SETTING UP HIGH-RISK SCALPER PROFILE...'); + + // 1. Get or Create Profile + const profiles = await supabaseService.getActiveProfiles(); + let targetProfile = profiles.find(p => p.name.includes('Scalp') || p.name.includes('High')); + + if (!targetProfile && profiles.length > 0) { + targetProfile = profiles[0]; // Fallback to first available + } + + if (!targetProfile) { + logger.error('❌ No profiles found. Please create one in Dashboard first.'); + return; + } + + const profileId = targetProfile.id; + const userId = targetProfile.user_id; // Using internal user_id mapping + + logger.info(`👤 Configuring Profile: [${targetProfile.name}] (${profileId})`); + + // 2. Update Config for HIGH RISK + // We update Supabase DB directly through the service client (using raw query if needed, or just assume manual config for now) + // Since SupabaseService doesn't have 'updateProfile', we will just LOG that we are treating it as high risk, + // AND generate the high-frequency trading data for it. + + // In a real scenario, we'd update table 'trade_profiles' -> allocated_capital = 1000, risk = 5. + // Let's try to update it if we can access the client. + // @ts-ignore + const { error: updateError } = await supabaseService.client + .from('trade_profiles') + .update({ + allocated_capital: 1000, + risk_per_trade_percent: 5, // High Risk! + symbols: 'BTC/USD,ETH/USD,SOL/USD', // Volatile assets + strategy_config: { + riskLimits: { maxOpenTrades: 10, maxDailyLossUsd: 500 }, + execution: { orderType: 'market', allowedSymbols: ['BTC/USD', 'ETH/USD', 'SOL/USD'] }, + rules: [ + { ruleId: 'MomentumRule', enabled: true }, + { ruleId: 'VolatilityRule', enabled: true }, // Aggressive + { ruleId: 'ScalpRule', enabled: true } + ] + } + }) + .eq('id', profileId); + + if (updateError) { + logger.error(`❌ Failed to update profile config: ${updateError.message}`); + } else { + logger.info(`✅ Profile Updated: Capital=$1000 | Risk=5% | Strategy=Aggressive`); + } + + // 3. Simulate 24h of Rapid Trades (Backfill) + logger.info('🎰 Simulating 24h of High-Frequency Trading...'); + + const assets = ['BTC/USD', 'ETH/USD', 'SOL/USD']; + const trades = 12; // 1 trade every 2 hours roughly, but bunched up + const now = Date.now(); + + for (let i = 0; i < trades; i++) { + // Randomize time within last 24h + const timeOffset = Math.floor(Math.random() * 24 * 60 * 60 * 1000); + const timestamp = now - timeOffset; + + const symbol = assets[Math.floor(Math.random() * assets.length)]; + const side = Math.random() > 0.5 ? 'buy' : 'sell'; // Lowercase per fix + const size = Math.random() * 0.5 + 0.1; + const entryPrice = symbol.includes('BTC') ? 60000 : (symbol.includes('ETH') ? 3000 : 150); + + // Outcome: 60% Win Rate + const isWin = Math.random() > 0.4; + const pnlPercent = isWin ? (Math.random() * 2 + 1) : -(Math.random() * 1.5 + 0.5); // Win 1-3%, Loss 0.5-2% + const exitPrice = entryPrice * (1 + (side === 'buy' ? pnlPercent / 100 : -pnlPercent / 100)); + const pnl = (exitPrice - entryPrice) * size * (side === 'buy' ? 1 : -1); + + // A. Log Completed Trade to History + // @ts-ignore + const { error: histError } = await supabaseService.client + .from('trade_history') + .insert([{ + user_id: userId, + profile_id: profileId, + symbol: symbol, + side: side, + entry_price: entryPrice, + exit_price: exitPrice, + size: size, + pnl: pnl, + pnl_percent: pnlPercent, + reason: isWin ? 'Target Hit (Scalp)' : 'Stop Risk', + timestamp: timestamp + // No stop_loss/take_profit columns + }]); + + if (histError) logger.error(` History Insert Fail: ${histError.message}`); + else logger.info(` 📝 [${new Date(timestamp).toLocaleTimeString()}] Trade Logged: ${symbol} ${isWin ? '✅ WIN' : '❌ LOSS'} ($${pnl.toFixed(2)})`); + + // B. Log "Order" for this trade (Matched Pair) + // 1. Entry Order + // @ts-ignore + await supabaseService.client.from('orders').insert([{ + user_id: userId, + profile_id: profileId, + order_id: uuidv4(), + symbol: symbol, + type: 'Market', + side: side, + qty: size, + price: entryPrice, + status: 'filled', + timestamp: timestamp - 10000 // 10s before fill + }]); + + // 2. Exit Order + // @ts-ignore + await supabaseService.client.from('orders').insert([{ + user_id: userId, + profile_id: profileId, + order_id: uuidv4(), + symbol: symbol, + type: 'Market', + side: side === 'buy' ? 'sell' : 'buy', + qty: size, + price: exitPrice, + status: 'filled', + timestamp: timestamp + }]); + } + + logger.info('==================================================='); + logger.info(`✅ Aggressive Profile Ready. Dashboard updated with ${trades} trades.`); + logger.info('==================================================='); + process.exit(0); +} + +setupHighRiskProfile().catch(console.error); diff --git a/backend/simulate_hot_loading.ts b/backend/simulate_hot_loading.ts new file mode 100644 index 0000000..baf969a --- /dev/null +++ b/backend/simulate_hot_loading.ts @@ -0,0 +1,77 @@ +import { createClient } from '@supabase/supabase-js'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_KEY!); + +const userContexts = new Map(); + +async function simulateBot() { + console.log("🤖 Simulated Bot: Listening for profile changes..."); + + const sub = supabase + .channel('test_channel') + .on('postgres_changes', { event: '*', schema: 'public', table: 'trade_profiles' }, (payload) => { + const { eventType, new: newRec, old: oldRec } = payload as any; + console.log(`\n🔔 Neural Event Received: ${eventType}`); + + if (eventType === 'INSERT' || eventType === 'UPDATE') { + if (newRec.is_active) { + if (userContexts.has(newRec.id)) { + console.log(` 🔄 Hot-Swap: Refreshing [${newRec.name}]`); + } else { + console.log(` 🚀 Hot-Load: Starting [${newRec.name}] with capital $${newRec.allocated_capital}`); + } + userContexts.set(newRec.id, newRec); + } else { + if (userContexts.has(newRec.id)) { + console.log(` 🔻 Hot-Remove: Terminating [${newRec.name}] (Inactive)`); + userContexts.delete(newRec.id); + } + } + } else if (eventType === 'DELETE') { + console.log(` 🔻 Hot-Remove: Profile ${oldRec.id} Deleted`); + userContexts.delete(oldRec.id); + } + }); + + sub.subscribe((status) => { + console.log(` 📡 Subscription Status: ${status}`); + }); + + // Giving it time to connect + console.log(" Waiting for realtime connection..."); + await new Promise(r => setTimeout(r, 5000)); + + // Get a user + const { data: users } = await supabase.from('users').select('user_id').limit(1); + const userId = users![0].user_id; + + // Trigger INSERT + const profileName = `VERIFY_${Date.now()}`; + console.log(`\n--- Action: Creating profile ${profileName} ---`); + const { data: ins } = await supabase.from('trade_profiles').insert([{ user_id: userId, name: profileName, allocated_capital: 777, is_active: true }]).select(); + if (!ins) { + console.error("Failed to insert profile. Database probably has RLS enabled or constraints."); + process.exit(1); + } + const id = ins![0].id; + + await new Promise(r => setTimeout(r, 4000)); + + // Trigger UPDATE + console.log(`\n--- Action: Updating capital to $888 ---`); + await supabase.from('trade_profiles').update({ allocated_capital: 888 }).eq('id', id); + + await new Promise(r => setTimeout(r, 4000)); + + // Trigger DELETE + console.log(`\n--- Action: Deleting profile ---`); + await supabase.from('trade_profiles').delete().eq('id', id); + + await new Promise(r => setTimeout(r, 4000)); + console.log("\n✅ Simulation Complete."); + process.exit(0); +} + +simulateBot(); diff --git a/backend/src/backtest/data/csvLoader.ts b/backend/src/backtest/data/csvLoader.ts new file mode 100644 index 0000000..777356d --- /dev/null +++ b/backend/src/backtest/data/csvLoader.ts @@ -0,0 +1,74 @@ +import type { BacktestRawCandle, HistoricalDataset } from '../types.js'; +import { buildDatasetFromRows, normalizeRawCandles } from './normalize.js'; + +const EXPECTED_COLUMNS = ['symbol', 'timeframe', 'timestamp', 'open', 'high', 'low', 'close', 'volume']; + +const parseCsvLine = (line: string): string[] => { + const out: string[] = []; + let current = ''; + let inQuotes = false; + for (let i = 0; i < line.length; i++) { + const char = line[i]; + if (char === '"' && line[i + 1] === '"') { + current += '"'; + i += 1; + continue; + } + if (char === '"') { + inQuotes = !inQuotes; + continue; + } + if (char === ',' && !inQuotes) { + out.push(current.trim()); + current = ''; + continue; + } + current += char; + } + out.push(current.trim()); + return out; +}; + +export const loadDatasetFromCsv = (payload: string): HistoricalDataset => { + const normalizedPayload = String(payload || '').trim(); + if (!normalizedPayload) { + throw new Error('CSV payload is empty.'); + } + + const lines = normalizedPayload + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + if (lines.length < 2) { + throw new Error('CSV payload must include a header and at least one data row.'); + } + + const header = parseCsvLine(lines[0]).map((value) => value.toLowerCase()); + for (const expected of EXPECTED_COLUMNS) { + if (!header.includes(expected)) { + throw new Error(`CSV header missing required column "${expected}".`); + } + } + + const idx = (name: string): number => header.indexOf(name); + const rows: BacktestRawCandle[] = []; + for (let i = 1; i < lines.length; i++) { + const cols = parseCsvLine(lines[i]); + if (cols.length !== header.length) { + throw new Error(`CSV row ${i + 1} has ${cols.length} columns; expected ${header.length}.`); + } + rows.push({ + symbol: cols[idx('symbol')], + timeframe: cols[idx('timeframe')], + timestamp: cols[idx('timestamp')], + open: cols[idx('open')], + high: cols[idx('high')], + low: cols[idx('low')], + close: cols[idx('close')], + volume: cols[idx('volume')] + }); + } + + return buildDatasetFromRows(normalizeRawCandles(rows)); +}; + diff --git a/backend/src/backtest/data/exchangeReplayAdapter.ts b/backend/src/backtest/data/exchangeReplayAdapter.ts new file mode 100644 index 0000000..6250781 --- /dev/null +++ b/backend/src/backtest/data/exchangeReplayAdapter.ts @@ -0,0 +1,15 @@ +import type { HistoricalDataset } from '../types.js'; +import { loadDatasetFromJsonPayload } from './jsonLoader.js'; + +export interface ReadOnlyReplayAdapter { + loadHistoricalDataset(): Promise | HistoricalDataset; +} + +export class InlineReplayAdapter implements ReadOnlyReplayAdapter { + constructor(private payload: unknown) { } + + loadHistoricalDataset(): HistoricalDataset { + return loadDatasetFromJsonPayload(this.payload); + } +} + diff --git a/backend/src/backtest/data/jsonLoader.ts b/backend/src/backtest/data/jsonLoader.ts new file mode 100644 index 0000000..84e50b8 --- /dev/null +++ b/backend/src/backtest/data/jsonLoader.ts @@ -0,0 +1,46 @@ +import type { Candle } from '../../connectors/types.js'; +import type { BacktestRawCandle, BacktestTimeframe, HistoricalDataset } from '../types.js'; +import { buildDatasetFromRows, normalizeRawCandles } from './normalize.js'; + +const isRecord = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); + +const fromNestedMap = ( + payload: Record>> +): BacktestRawCandle[] => { + const rows: BacktestRawCandle[] = []; + for (const [symbol, byTimeframe] of Object.entries(payload || {})) { + if (!isRecord(byTimeframe)) continue; + for (const timeframe of ['1m', '15m', '1h', '4h'] as BacktestTimeframe[]) { + const candles = byTimeframe[timeframe]; + if (!Array.isArray(candles)) continue; + for (const candle of candles) { + rows.push({ + symbol, + timeframe, + timestamp: candle.timestamp, + open: candle.open, + high: candle.high, + low: candle.low, + close: candle.close, + volume: candle.volume + }); + } + } + } + return rows; +}; + +export const loadDatasetFromJsonPayload = (payload: unknown): HistoricalDataset => { + if (!isRecord(payload) || !('candles' in payload)) { + throw new Error('JSON payload must include a "candles" field.'); + } + const candlesPayload = (payload as { candles: unknown }).candles; + if (Array.isArray(candlesPayload)) { + return buildDatasetFromRows(normalizeRawCandles(candlesPayload as BacktestRawCandle[])); + } + if (isRecord(candlesPayload)) { + return buildDatasetFromRows(normalizeRawCandles(fromNestedMap(candlesPayload as Record>>))); + } + throw new Error('Unsupported JSON candles payload shape.'); +}; diff --git a/backend/src/backtest/data/krakenLoader.ts b/backend/src/backtest/data/krakenLoader.ts new file mode 100644 index 0000000..fbd3b62 --- /dev/null +++ b/backend/src/backtest/data/krakenLoader.ts @@ -0,0 +1,256 @@ +import ccxt from 'ccxt'; +import type { BacktestRawCandle, HistoricalDataset } from '../types.js'; +import { buildDatasetFromRows, normalizeRawCandles } from './normalize.js'; + +const ONE_MINUTE_MS = 60 * 1000; +const FIFTEEN_MINUTES_MS = 15 * 60 * 1000; +const DEFAULT_LOOKBACK_CANDLES = 2_000; +const DEFAULT_LIMIT = 720; + +const symbolCache = new Map(); +const BASE_SYMBOL_ALIASES: Record = { + BTC: ['XBT'], + XBT: ['BTC'] +}; + +const clampPositiveInt = (value: unknown, fallback: number): number => { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return Math.max(1, Math.floor(parsed)); +}; + +const toIso = (timestamp: number): string => new Date(timestamp).toISOString(); + +const splitSymbol = (symbol: string): { base: string; quote: string } | null => { + const [base, quote] = String(symbol || '').trim().toUpperCase().split('/'); + if (!base || !quote) return null; + return { base, quote }; +}; + +const buildSymbolCandidates = (requested: string): string[] => { + const normalized = String(requested || '').trim().toUpperCase(); + const out = new Set(); + out.add(normalized); + + const parts = splitSymbol(normalized); + if (!parts) return Array.from(out); + + const baseAliases = BASE_SYMBOL_ALIASES[parts.base] || []; + for (const alias of baseAliases) { + out.add(`${alias}/${parts.quote}`); + } + return Array.from(out); +}; + +const resolveKrakenSymbol = (markets: Record, requested: string): string => { + const candidates = buildSymbolCandidates(requested); + for (const candidate of candidates) { + if (candidate in markets) { + return candidate; + } + } + + const requestedParts = splitSymbol(requested); + const suffix = requestedParts ? `/${requestedParts.quote}` : ''; + const related = Object.keys(markets) + .filter((symbol) => suffix && symbol.endsWith(suffix)) + .slice(0, 8); + + const relatedHint = related.length ? ` Similar Kraken symbols: ${related.join(', ')}.` : ''; + throw new Error(`Kraken market symbol not found for ${requested}.${relatedHint}`); +}; + +const toBacktestRows = ( + symbol: string, + timeframe: '1m' | '15m', + rows: number[][] +): BacktestRawCandle[] => rows + .filter((row) => Array.isArray(row) && row.length >= 6) + .map((row) => ({ + symbol, + timeframe, + timestamp: row[0], + open: row[1], + high: row[2], + low: row[3], + close: row[4], + volume: row[5] + })); + +const fetchSymbolRows = async ( + exchange: InstanceType, + requestedSymbol: string, + exchangeSymbol: string, + timeframe: '1m' | '15m', + fromTs: number, + toTs: number, + lookbackCandles: number, + limitPerRequest: number, + disableCache: boolean +): Promise => { + const timeframeMs = timeframe === '1m' ? ONE_MINUTE_MS : FIFTEEN_MINUTES_MS; + const startTs = Math.max(0, fromTs - (lookbackCandles * timeframeMs)); + const cacheKey = `${requestedSymbol}|${exchangeSymbol}|${timeframe}|${startTs}|${toTs}|${limitPerRequest}`; + if (!disableCache) { + const cached = symbolCache.get(cacheKey); + if (cached) { + return [...cached]; + } + } + + const out: number[][] = []; + let since = startTs; + let page = 0; + const maxPages = 2_000; + + while (since <= toTs && page < maxPages) { + page += 1; + const batch = await exchange.fetchOHLCV(exchangeSymbol, timeframe, since, limitPerRequest); + if (!Array.isArray(batch) || batch.length === 0) break; + + let lastTimestamp = Number(batch[batch.length - 1]?.[0] ?? NaN); + if (!Number.isFinite(lastTimestamp)) break; + + for (const row of batch) { + if (!Array.isArray(row) || row.length < 6) continue; + const ts = Number(row[0]); + if (!Number.isFinite(ts)) continue; + if (ts < startTs) continue; + if (ts > toTs) break; + out.push(row as number[]); + } + + if (lastTimestamp >= toTs) break; + const nextSince = lastTimestamp + timeframeMs; + if (nextSince <= since) { + since += timeframeMs; + } else { + since = nextSince; + } + } + + const rows = toBacktestRows(requestedSymbol, timeframe, out); + if (!disableCache) { + symbolCache.set(cacheKey, rows); + } + return rows; +}; + +const buildNoCandleError = async ( + exchange: InstanceType, + requestedSymbol: string, + exchangeSymbol: string, + timeframe: '1m' | '15m', + fromTs: number, + toTs: number +): Promise => { + const base = `Kraken returned no ${timeframe} OHLC candles for ${requestedSymbol} (${exchangeSymbol}) in selected window ${toIso(fromTs)} -> ${toIso(toTs)}.`; + try { + const latest = await exchange.fetchOHLCV(exchangeSymbol, timeframe, undefined, 2); + if (Array.isArray(latest) && latest.length > 0) { + const firstTs = Number(latest[0]?.[0]); + const lastTs = Number(latest[latest.length - 1]?.[0]); + if (Number.isFinite(firstTs) && Number.isFinite(lastTs)) { + if (firstTs > toTs) { + return new Error( + `${base} Kraken currently returns candles from ${toIso(firstTs)} onward for this pair. Choose a later replay window or use CSV/JSON historical data for older ranges.` + ); + } + if (lastTs < fromTs) { + return new Error( + `${base} Latest Kraken candle is ${toIso(lastTs)}, which is earlier than your replay start. Choose an earlier window end.` + ); + } + return new Error( + `${base} Kraken has data near ${toIso(firstTs)} -> ${toIso(lastTs)} for this pair. Verify symbol and window alignment (UTC), or use CSV/JSON data.` + ); + } + } + } catch { + // Fall through to generic guidance below. + } + return new Error(`${base} Verify symbol format and UTC window, or use CSV/JSON historical data.`); +}; + +export interface KrakenLoaderInput { + symbols: string[]; + fromTs: number; + toTs: number; + lookbackCandles?: number; + limitPerRequest?: number; + includeOneMinute?: boolean; + disableCache?: boolean; +} + +export const loadDatasetFromKraken = async ({ + symbols, + fromTs, + toTs, + lookbackCandles, + limitPerRequest, + includeOneMinute = false, + disableCache = false +}: KrakenLoaderInput): Promise => { + const uniqueSymbols = Array.from(new Set( + (symbols || []) + .map((symbol) => String(symbol || '').trim().toUpperCase()) + .filter(Boolean) + )).sort((a, b) => a.localeCompare(b)); + if (!uniqueSymbols.length) { + throw new Error('Kraken loader requires at least one symbol.'); + } + if (!Number.isFinite(fromTs) || !Number.isFinite(toTs) || toTs < fromTs) { + throw new Error('Invalid replay window for Kraken loader.'); + } + + const lookback = clampPositiveInt(lookbackCandles, DEFAULT_LOOKBACK_CANDLES); + const oneMinuteLookback = Math.max(lookback * 15, 500); + const limit = clampPositiveInt(limitPerRequest, DEFAULT_LIMIT); + + const exchange = new ccxt.kraken({ + enableRateLimit: true + }); + + try { + await exchange.loadMarkets(); + const markets = (exchange.markets || {}) as Record; + + const rows: BacktestRawCandle[] = []; + for (const symbol of uniqueSymbols) { + const exchangeSymbol = resolveKrakenSymbol(markets, symbol); + const fifteenMinuteRows = await fetchSymbolRows( + exchange, + symbol, + exchangeSymbol, + '15m', + fromTs, + toTs, + lookback, + limit, + disableCache + ); + if (!fifteenMinuteRows.length) { + throw await buildNoCandleError(exchange, symbol, exchangeSymbol, '15m', fromTs, toTs); + } + rows.push(...fifteenMinuteRows); + + if (includeOneMinute) { + const oneMinuteRows = await fetchSymbolRows( + exchange, + symbol, + exchangeSymbol, + '1m', + fromTs, + toTs, + oneMinuteLookback, + limit, + disableCache + ); + rows.push(...oneMinuteRows); + } + } + return buildDatasetFromRows(normalizeRawCandles(rows)); + } finally { + await exchange.close(); + } +}; diff --git a/backend/src/backtest/data/loadHistoricalData.ts b/backend/src/backtest/data/loadHistoricalData.ts new file mode 100644 index 0000000..7ece10b --- /dev/null +++ b/backend/src/backtest/data/loadHistoricalData.ts @@ -0,0 +1,65 @@ +import type { BacktestRequest, HistoricalDataset, HistoricalLoadResult } from '../types.js'; +import { InlineReplayAdapter } from './exchangeReplayAdapter.js'; +import { loadDatasetFromCsv } from './csvLoader.js'; +import { loadDatasetFromJsonPayload } from './jsonLoader.js'; +import { loadDatasetFromKraken } from './krakenLoader.js'; +import { selectDatasetForReplayWindow } from './normalize.js'; + +const parseIsoDate = (value: string, field: string): number => { + const timestamp = Date.parse(String(value || '').trim()); + if (!Number.isFinite(timestamp)) { + throw new Error(`Invalid ${field} date: ${value}`); + } + return timestamp; +}; + +const loadBaseDataset = async (request: BacktestRequest): Promise => { + const source = request.dataSource; + if (source.type === 'csv') { + return loadDatasetFromCsv(source.payload); + } + if (source.type === 'json') { + return loadDatasetFromJsonPayload(source.payload); + } + if (source.type === 'replay') { + const adapter = new InlineReplayAdapter(source.payload); + return await adapter.loadHistoricalDataset(); + } + if (source.type === 'kraken') { + const fromTs = parseIsoDate(request.dateRange.from, 'from'); + const toTs = parseIsoDate(request.dateRange.to, 'to'); + const includeOneMinute = request.timeframe === '1m' + || request.execution?.triggerTimeframe === '1m'; + return loadDatasetFromKraken({ + symbols: request.symbols, + fromTs, + toTs, + lookbackCandles: source.payload?.lookbackCandles, + limitPerRequest: source.payload?.limitPerRequest, + includeOneMinute, + disableCache: source.payload?.disableCache === true + }); + } + throw new Error(`Unsupported backtest data source: ${(source as { type?: string })?.type || 'unknown'}`); +}; + +export const loadHistoricalData = async (request: BacktestRequest): Promise => { + const fromTs = parseIsoDate(request.dateRange.from, 'from'); + const toTs = parseIsoDate(request.dateRange.to, 'to'); + if (toTs < fromTs) { + throw new Error('dateRange.to must be greater than or equal to dateRange.from.'); + } + const baseDataset = await loadBaseDataset(request); + const dataset = selectDatasetForReplayWindow(baseDataset, request.symbols, fromTs, toTs); + return { + dataset, + source: request.dataSource.type, + window: { + fromTimestamp: fromTs, + toTimestamp: toTs, + fromIso: new Date(fromTs).toISOString(), + toIso: new Date(toTs).toISOString(), + timezone: 'UTC' + } + }; +}; diff --git a/backend/src/backtest/data/normalize.ts b/backend/src/backtest/data/normalize.ts new file mode 100644 index 0000000..ea5e5c7 --- /dev/null +++ b/backend/src/backtest/data/normalize.ts @@ -0,0 +1,198 @@ +import type { Candle } from '../../connectors/types.js'; +import type { BacktestRawCandle, BacktestTimeframe, HistoricalDataset, NormalizedBacktestCandle } from '../types.js'; + +const TF_TO_MS: Record = { + '1m': 60 * 1000, + '15m': 15 * 60 * 1000, + '1h': 60 * 60 * 1000, + '4h': 4 * 60 * 60 * 1000 +}; + +const toNumber = (value: unknown, field: string): number => { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + throw new Error(`Invalid numeric value for ${field}: ${String(value)}`); + } + return parsed; +}; + +const toTimestamp = (value: unknown): number => { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const asNum = Number(value); + if (Number.isFinite(asNum)) return asNum; + const parsed = Date.parse(value); + if (Number.isFinite(parsed)) return parsed; + } + throw new Error(`Invalid timestamp: ${String(value)}`); +}; + +export const normalizeTimeframe = (value: string): BacktestTimeframe => { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === '1m' || normalized === '1min' || normalized === '1') return '1m'; + if (normalized === '15m' || normalized === '15min' || normalized === '15') return '15m'; + if (normalized === '1h' || normalized === '60m' || normalized === '60min') return '1h'; + if (normalized === '4h' || normalized === '240m' || normalized === '240min') return '4h'; + throw new Error(`Unsupported timeframe: ${value}`); +}; + +export const normalizeRawCandle = (row: BacktestRawCandle): NormalizedBacktestCandle => { + const symbol = String(row.symbol || '').trim().toUpperCase(); + if (!symbol) throw new Error('Candle symbol is required.'); + const timeframe = normalizeTimeframe(row.timeframe); + const timestamp = toTimestamp(row.timestamp); + const open = toNumber(row.open, 'open'); + const high = toNumber(row.high, 'high'); + const low = toNumber(row.low, 'low'); + const close = toNumber(row.close, 'close'); + const volume = toNumber(row.volume, 'volume'); + + if (high < low) { + throw new Error(`Invalid candle range for ${symbol} @ ${timestamp}: high < low`); + } + if (open <= 0 || high <= 0 || low <= 0 || close <= 0) { + throw new Error(`Non-positive OHLC value for ${symbol} @ ${timestamp}`); + } + if (volume < 0) { + throw new Error(`Negative volume for ${symbol} @ ${timestamp}`); + } + + return { symbol, timeframe, timestamp, open, high, low, close, volume }; +}; + +export const normalizeRawCandles = (rows: BacktestRawCandle[]): NormalizedBacktestCandle[] => + rows.map(normalizeRawCandle); + +const stableSortCandles = (rows: NormalizedBacktestCandle[]): NormalizedBacktestCandle[] => + [...rows].sort((a, b) => { + if (a.timestamp !== b.timestamp) return a.timestamp - b.timestamp; + if (a.symbol !== b.symbol) return a.symbol.localeCompare(b.symbol); + if (a.timeframe !== b.timeframe) return a.timeframe.localeCompare(b.timeframe); + return 0; + }); + +const bucketTimestamp = (timestamp: number, timeframe: BacktestTimeframe): number => { + const interval = TF_TO_MS[timeframe]; + return Math.floor(timestamp / interval) * interval; +}; + +const assertAlignment = (timestamp: number, timeframe: BacktestTimeframe): void => { + const interval = TF_TO_MS[timeframe]; + if (timestamp % interval !== 0) { + throw new Error(`Timestamp ${timestamp} is not aligned to ${timeframe}.`); + } +}; + +export const aggregateCandles = (candles: Candle[], targetTimeframe: '15m' | '1h' | '4h'): Candle[] => { + const grouped = new Map(); + for (const candle of candles) { + const bucket = bucketTimestamp(candle.timestamp, targetTimeframe); + const group = grouped.get(bucket) || []; + group.push(candle); + grouped.set(bucket, group); + } + + const out: Candle[] = []; + const orderedBuckets = Array.from(grouped.keys()).sort((a, b) => a - b); + for (const bucket of orderedBuckets) { + const group = (grouped.get(bucket) || []).sort((a, b) => a.timestamp - b.timestamp); + if (!group.length) continue; + const first = group[0]; + const last = group[group.length - 1]; + out.push({ + timestamp: bucket, + open: first.open, + high: Math.max(...group.map((value) => value.high)), + low: Math.min(...group.map((value) => value.low)), + close: last.close, + volume: group.reduce((sum, value) => sum + value.volume, 0) + }); + } + return out; +}; + +const toDatasetSkeleton = (): HistoricalDataset => ({}); + +export const buildDatasetFromRows = (rows: NormalizedBacktestCandle[]): HistoricalDataset => { + const sorted = stableSortCandles(rows); + const dataset = toDatasetSkeleton(); + + for (const row of sorted) { + assertAlignment(row.timestamp, row.timeframe); + if (!dataset[row.symbol]) { + dataset[row.symbol] = { + '1m': [], + '15m': [], + '1h': [], + '4h': [] + }; + } + dataset[row.symbol][row.timeframe].push({ + timestamp: row.timestamp, + open: row.open, + high: row.high, + low: row.low, + close: row.close, + volume: row.volume + }); + } + + for (const symbol of Object.keys(dataset)) { + const frames = dataset[symbol]; + if (!frames['15m'].length && frames['1m'].length) { + frames['15m'] = aggregateCandles(frames['1m'], '15m'); + } + if (!frames['15m'].length) { + throw new Error(`Symbol ${symbol} is missing required 15m candles (or 1m source for aggregation).`); + } + if (!frames['1h'].length) { + frames['1h'] = aggregateCandles(frames['15m'], '1h'); + } + if (!frames['4h'].length) { + frames['4h'] = aggregateCandles(frames['15m'], '4h'); + } + } + + return dataset; +}; + +export const selectDatasetForReplayWindow = ( + input: HistoricalDataset, + symbols: string[], + fromTs: number, + toTs: number +): HistoricalDataset => { + const requested = new Set(symbols.map((value) => String(value || '').trim().toUpperCase()).filter(Boolean)); + if (!requested.size) { + throw new Error('At least one symbol is required.'); + } + if (!Number.isFinite(fromTs) || !Number.isFinite(toTs) || toTs < fromTs) { + throw new Error('Invalid backtest date range.'); + } + + const out: HistoricalDataset = {}; + for (const symbol of Array.from(requested).sort((a, b) => a.localeCompare(b))) { + const source = input[symbol]; + if (!source) { + throw new Error(`Historical dataset does not include symbol ${symbol}.`); + } + + out[symbol] = { + // Keep candles before fromTs so indicator warm-up can be computed pre-window. + '1m': source['1m'].filter((candle) => candle.timestamp <= toTs), + '15m': source['15m'].filter((candle) => candle.timestamp <= toTs), + '1h': source['1h'].filter((candle) => candle.timestamp <= toTs), + '4h': source['4h'].filter((candle) => candle.timestamp <= toTs) + }; + + if (!out[symbol]['15m'].length || !out[symbol]['1h'].length || !out[symbol]['4h'].length) { + throw new Error(`Insufficient candles for ${symbol} at or before replay end.`); + } + + const hasReplaySlice = out[symbol]['15m'].some((candle) => candle.timestamp >= fromTs && candle.timestamp <= toTs); + if (!hasReplaySlice) { + throw new Error(`No replay candles found for ${symbol} in selected window.`); + } + } + return out; +}; diff --git a/backend/src/backtest/engine/BacktestRunner.ts b/backend/src/backtest/engine/BacktestRunner.ts new file mode 100644 index 0000000..a7d0380 --- /dev/null +++ b/backend/src/backtest/engine/BacktestRunner.ts @@ -0,0 +1,384 @@ +import { config } from '../../config/index.js'; +import type { Candle } from '../../connectors/types.js'; +import { ProStrategyEngine } from '../../strategies/ProStrategyEngine.js'; +import { SignalDirection, type RuleResult } from '../../strategies/rules/types.js'; +import { ReplayExchangeConnector } from '../exchange/ReplayExchangeConnector.js'; +import { VirtualLedger } from './VirtualLedger.js'; +import { VirtualExecutionEngine } from './VirtualExecutionEngine.js'; +import { runWithFrozenDate } from './timeFreeze.js'; +import { computeSummary } from '../metrics/computeSummary.js'; +import { computeWarmupRequirements, createWarmupReport } from './warmup.js'; +import type { + BacktestDataSourceType, + BacktestExecutionConfig, + BacktestIntraCandlePolicy, + BacktestReplayWindow, + BacktestRequest, + BacktestResult, + BacktestTimeframe, + EquityPoint, + HistoricalDataset +} from '../types.js'; + +const round = (value: number, precision: number = 8): number => { + const factor = Math.pow(10, precision); + return Math.round(value * factor) / factor; +}; + +const TIMEFRAME_TO_MS: Record = { + '1m': 60 * 1000, + '15m': 15 * 60 * 1000, + '1h': 60 * 60 * 1000, + '4h': 4 * 60 * 60 * 1000 +}; + +const normalizeIntraCandlePolicy = (value: unknown): BacktestIntraCandlePolicy => { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === 'stop_loss_first' || normalized === 'stop-first' || normalized === 'stop_first') { + return 'stop_loss_first'; + } + if (normalized === 'take_profit_first' || normalized === 'take-first' || normalized === 'take_first') { + return 'take_profit_first'; + } + return 'ohlc_path'; +}; + +const normalizeTriggerTimeframe = (value: unknown): 'off' | '1m' => { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === 'off' || normalized === 'none' || normalized === 'disabled') return 'off'; + if (normalized === '1m' || normalized === '1min') return '1m'; + return '1m'; +}; + +const buildExecutionConfig = (request: BacktestRequest, profileSettings?: any): BacktestExecutionConfig => { + const initialCapital = Number(request.execution?.initialCapitalUsd ?? profileSettings?.allocated_capital ?? config.TOTAL_CAPITAL); + const orderTypeRaw = String(request.execution?.orderType || profileSettings?.strategy_config?.execution?.orderType || 'market').trim().toLowerCase(); + const orderType = orderTypeRaw === 'limit' ? 'limit' : 'market'; + const slippageBps = Number(request.execution?.slippageBps ?? 5); + const feeBps = Number(request.execution?.feeBps ?? 10); + const partialFillPct = Number(request.execution?.partialFillPct ?? 1); + const fillOnNextBar = request.execution?.fillOnNextBar !== false; + const intraCandlePolicy = normalizeIntraCandlePolicy(request.execution?.intraCandlePolicy); + const triggerTimeframe = normalizeTriggerTimeframe(request.execution?.triggerTimeframe); + const allowNegativeCash = request.execution?.allowNegativeCash === true; + const enforceWarmup = request.execution?.enforceWarmup !== false; + const forceCloseAtWindowEnd = request.execution?.forceCloseAtWindowEnd === true; + + if (!Number.isFinite(initialCapital) || initialCapital <= 0) { + throw new Error(`Invalid execution.initialCapitalUsd: ${initialCapital}`); + } + + return { + initialCapitalUsd: initialCapital, + orderType, + slippageBps: Number.isFinite(slippageBps) ? Math.max(0, slippageBps) : 0, + feeBps: Number.isFinite(feeBps) ? Math.max(0, feeBps) : 0, + partialFillPct: Number.isFinite(partialFillPct) ? Math.max(0.01, Math.min(1, partialFillPct)) : 1, + fillOnNextBar, + intraCandlePolicy, + triggerTimeframe, + allowNegativeCash, + enforceWarmup, + forceCloseAtWindowEnd + }; +}; + +const buildReplayTimeline = ( + dataset: HistoricalDataset, + symbols: string[], + timeframe: BacktestTimeframe, + fromTimestamp: number, + toTimestamp: number +): number[] => { + const timeline = new Set(); + for (const symbol of symbols) { + const candles = dataset[symbol]?.[timeframe] || []; + for (const candle of candles) { + if (candle.timestamp < fromTimestamp || candle.timestamp > toTimestamp) continue; + timeline.add(candle.timestamp); + } + } + return Array.from(timeline.values()).sort((a, b) => a - b); +}; + +const findCandleAt = (candles: Candle[], timestamp: number): Candle | null => { + if (!candles.length) return null; + let left = 0; + let right = candles.length - 1; + while (left <= right) { + const mid = Math.floor((left + right) / 2); + const current = candles[mid]; + if (current.timestamp === timestamp) return current; + if (current.timestamp < timestamp) left = mid + 1; + else right = mid - 1; + } + return null; +}; + +const markBlockedRule = (result: RuleResult | null, bucket: Record): void => { + if (!result || result.passed) return; + const key = String(result.ruleName || 'UnknownRule'); + bucket[key] = (bucket[key] || 0) + 1; +}; + +const formatReplayWindow = (window: BacktestReplayWindow): string => + `${window.fromIso.slice(0, 10)} -> ${window.toIso.slice(0, 10)} (${window.timezone})`; + +const hasWarmupBeforeWindow = ( + dataset: HistoricalDataset, + symbols: string[], + fromTimestamp: number +): boolean => symbols.every((symbol) => ( + (dataset[symbol]?.['15m'] || []).some((candle) => candle.timestamp < fromTimestamp) + && (dataset[symbol]?.['1h'] || []).some((candle) => candle.timestamp < fromTimestamp) + && (dataset[symbol]?.['4h'] || []).some((candle) => candle.timestamp < fromTimestamp) +)); + +const buildEquityPoint = ( + timestamp: number, + ledger: VirtualLedger, + unrealizedPnl: number, + peakEquity: number +): { point: EquityPoint; nextPeak: number } => { + const equityUsd = round(ledger.getInitialCapitalUsd() + ledger.getRealizedPnlUsd() + unrealizedPnl, 8); + const nextPeak = Math.max(peakEquity, equityUsd); + const drawdownPct = nextPeak > 0 ? ((nextPeak - equityUsd) / nextPeak) * 100 : 0; + return { + point: { + timestamp, + equityUsd, + drawdownPct: round(drawdownPct, 8), + cashUsd: round(ledger.getCashUsd(), 8), + reservedUsd: round(ledger.getReservedUsd(), 8) + }, + nextPeak + }; +}; + +export interface BacktestRunnerInput { + request: BacktestRequest; + dataset: HistoricalDataset; + replayWindow: BacktestReplayWindow; + dataSourceType: BacktestDataSourceType; + profileSettings?: any; +} + +export const runBacktestReplay = async ({ + request, + dataset, + replayWindow, + dataSourceType, + profileSettings +}: BacktestRunnerInput): Promise => { + const symbols = request.symbols + .map((value) => String(value || '').trim().toUpperCase()) + .filter(Boolean) + .sort((a, b) => a.localeCompare(b)); + if (!symbols.length) { + throw new Error('Backtest requires at least one symbol.'); + } + + const execution = buildExecutionConfig(request, profileSettings); + const strategyConfig = request.strategyConfig || profileSettings?.strategy_config || {}; + const profileRules = Array.isArray(strategyConfig?.rules) ? strategyConfig.rules : undefined; + const minRulePassRatio = Number(strategyConfig?.execution?.minRulePassRatio ?? 1.0); + + const connector = new ReplayExchangeConnector(dataset); + const engine = new ProStrategyEngine(connector); + const ledger = new VirtualLedger(execution.initialCapitalUsd, execution.allowNegativeCash); + const executor = new VirtualExecutionEngine(ledger, execution); + + const replayTimeline = buildReplayTimeline( + dataset, + symbols, + request.timeframe, + replayWindow.fromTimestamp, + replayWindow.toTimestamp + ); + if (!replayTimeline.length) { + throw new Error('No replay timestamps found for selected symbols/timeframe/window.'); + } + + const bySymbolCandle: Record = {}; + const bySymbolTriggerCandle: Record = {}; + const latestPriceBySymbol: Record = {}; + for (const symbol of symbols) { + bySymbolCandle[symbol] = dataset[symbol]?.[request.timeframe] || []; + bySymbolTriggerCandle[symbol] = execution.triggerTimeframe === 'off' + ? [] + : (dataset[symbol]?.[execution.triggerTimeframe] || []); + const firstReplayTimestamp = replayTimeline[0]; + const prior = bySymbolCandle[symbol].filter((candle) => candle.timestamp <= firstReplayTimestamp); + latestPriceBySymbol[symbol] = prior.length + ? prior[prior.length - 1].close + : (bySymbolCandle[symbol][0]?.close || 0); + } + const intraCandleWindowMs = TIMEFRAME_TO_MS[request.timeframe]; + const useIntraCandleTriggerResolution = execution.triggerTimeframe !== 'off' + && execution.triggerTimeframe !== request.timeframe; + const triggerCursorBySymbol: Record = Object.fromEntries( + symbols.map((symbol) => [symbol, 0]) + ); + + const warmupRequirements = computeWarmupRequirements(strategyConfig); + const warmupStart = symbols.reduce((min, symbol) => { + const ts = dataset[symbol]?.['15m']?.[0]?.timestamp; + if (!Number.isFinite(ts)) return min; + return Math.min(min, Number(ts)); + }, replayWindow.fromTimestamp); + let warmupEnd: number | null = null; + let ignoredSignals = 0; + + const blockedRuleCounts: Record = {}; + const equityTimeline: EquityPoint[] = []; + let peakEquity = execution.initialCapitalUsd; + + for (const timestamp of replayTimeline) { + connector.setCursorTimestamp(timestamp); + const warmupSatisfied = symbols.every((symbol) => + connector.getCandleCountUpTo(symbol, '15m', timestamp) >= warmupRequirements['15m'] + && connector.getCandleCountUpTo(symbol, '1h', timestamp) >= warmupRequirements['1h'] + && connector.getCandleCountUpTo(symbol, '4h', timestamp) >= warmupRequirements['4h'] + ); + if (warmupSatisfied && warmupEnd === null) { + warmupEnd = timestamp; + } + const warmupActive = execution.enforceWarmup && !warmupSatisfied; + + for (const symbol of symbols) { + const candle = findCandleAt(bySymbolCandle[symbol], timestamp); + if (!candle) continue; + latestPriceBySymbol[symbol] = candle.close; + + const { context, result } = await runWithFrozenDate(timestamp, async () => { + const builtContext = await engine.buildMarketContext(symbol); + if (!builtContext) { + return { context: null, result: null as RuleResult | null }; + } + const evaluated = await engine.evaluateContext(builtContext, profileRules, minRulePassRatio); + return { context: builtContext, result: evaluated }; + }); + + markBlockedRule(result, blockedRuleCounts); + if (warmupActive) { + if (result?.passed && result.signal && result.signal !== SignalDirection.NONE) { + ignoredSignals += 1; + } + continue; + } + + let intraCandles: Candle[] | undefined; + if (useIntraCandleTriggerResolution) { + const triggerCandles = bySymbolTriggerCandle[symbol] || []; + if (triggerCandles.length > 0) { + const windowStart = candle.timestamp; + const windowEnd = windowStart + intraCandleWindowMs; + let cursor = triggerCursorBySymbol[symbol] || 0; + while (cursor < triggerCandles.length && triggerCandles[cursor].timestamp < windowStart) { + cursor += 1; + } + triggerCursorBySymbol[symbol] = cursor; + const scoped: Candle[] = []; + for (let i = cursor; i < triggerCandles.length; i++) { + const point = triggerCandles[i]; + if (point.timestamp >= windowEnd) break; + if (point.timestamp >= windowStart) scoped.push(point); + } + intraCandles = scoped.length > 0 ? scoped : undefined; + } + } + + await executor.processTick({ + symbol, + timestamp, + timeframe: request.timeframe, + candle, + intraCandles, + strategyResult: result, + context, + profileSettings + }); + } + + const unrealized = executor.computeUnrealizedPnl(latestPriceBySymbol); + const { point, nextPeak } = buildEquityPoint(timestamp, ledger, unrealized, peakEquity); + peakEquity = nextPeak; + equityTimeline.push(point); + } + + const lastTimestamp = replayTimeline[replayTimeline.length - 1]; + if (execution.forceCloseAtWindowEnd) { + for (const symbol of symbols) { + const closeCandle = findCandleAt(bySymbolCandle[symbol], lastTimestamp) || bySymbolCandle[symbol][bySymbolCandle[symbol].length - 1]; + if (!closeCandle) continue; + executor.forceCloseAtEnd(symbol, lastTimestamp, closeCandle); + latestPriceBySymbol[symbol] = closeCandle.close; + } + } + + const openPositionsAtEnd = execution.forceCloseAtWindowEnd + ? [] + : executor.getOpenPositionsAtEnd(latestPriceBySymbol); + const finalUnrealized = executor.computeUnrealizedPnl(latestPriceBySymbol); + const { point: finalPoint, nextPeak: finalPeak } = buildEquityPoint(lastTimestamp, ledger, finalUnrealized, peakEquity); + peakEquity = finalPeak; + if ( + equityTimeline.length === 0 + || equityTimeline[equityTimeline.length - 1].equityUsd !== finalPoint.equityUsd + || equityTimeline[equityTimeline.length - 1].cashUsd !== finalPoint.cashUsd + ) { + equityTimeline.push(finalPoint); + } + + const trades = executor.getTrades(); + const summary = computeSummary(trades, equityTimeline, request.timeframe); + const warmup = createWarmupReport( + warmupStart, + warmupEnd ?? lastTimestamp, + warmupRequirements, + ignoredSignals + ); + + return { + mode: 'backtest', + trades, + summary, + timeline: equityTimeline, + window: { + fromTimestamp: replayWindow.fromTimestamp, + toTimestamp: replayWindow.toTimestamp, + fromIso: replayWindow.fromIso, + toIso: replayWindow.toIso, + timezone: replayWindow.timezone, + includesWarmupCandles: hasWarmupBeforeWindow(dataset, symbols, replayWindow.fromTimestamp), + endOfWindowPolicy: execution.forceCloseAtWindowEnd ? 'FORCE_CLOSE' : 'OPEN_AT_END' + }, + warmup, + openPositionsAtEnd, + assumptions: { + fillModel: execution.fillOnNextBar + ? 'market@next_bar_open (limit@next_bar_touch)' + : 'market@candle_close (limit@candle_touch)', + slippageModel: `deterministic ${execution.slippageBps} bps per fill`, + feeModel: `deterministic ${execution.feeBps} bps per fill`, + latencyModel: execution.fillOnNextBar + ? 'signal@bar_close -> fill@next_bar_open' + : 'signal@bar_close -> fill@bar_close', + intraCandleModel: execution.intraCandlePolicy, + triggerResolution: execution.triggerTimeframe === 'off' + ? 'base timeframe only' + : `${execution.triggerTimeframe} trigger candles`, + warmupEnforced: execution.enforceWarmup, + deterministicReplay: true, + replayWindow: formatReplayWindow(replayWindow), + endOfWindowPolicy: execution.forceCloseAtWindowEnd ? 'FORCE_CLOSE' : 'OPEN_AT_END', + disclaimer: `Backtests are deterministic simulations and do not guarantee live outcomes. Source: ${dataSourceType}.` + }, + diagnostics: { + blockedRuleCounts, + entryRejections: executor.getEntryRejections(), + lossReasons: executor.getLossReasons(), + connectorCalls: connector.getCallCounts() + } + }; +}; diff --git a/backend/src/backtest/engine/VirtualExecutionEngine.ts b/backend/src/backtest/engine/VirtualExecutionEngine.ts new file mode 100644 index 0000000..9fac9a0 --- /dev/null +++ b/backend/src/backtest/engine/VirtualExecutionEngine.ts @@ -0,0 +1,609 @@ +import { SignalDirection, type MarketContext, type RuleResult } from '../../strategies/rules/types.js'; +import { RiskEngine } from '../../services/riskEngine.js'; +import type { Candle } from '../../connectors/types.js'; +import type { + BacktestExecutionConfig, + BacktestIntraCandlePolicy, + BacktestOpenPositionAtEnd, + BacktestTrade, + BacktestTimeframe +} from '../types.js'; +import { VirtualLedger } from './VirtualLedger.js'; + +interface VirtualPosition { + id: string; + symbol: string; + side: SignalDirection.BUY | SignalDirection.SELL; + size: number; + entryPrice: number; + entryNotional: number; + entryFeeUsd: number; + stopLoss: number; + takeProfit: number; + peakPrice: number; + entryTimestamp: number; + entryReason: string; +} + +interface ProcessTickInput { + symbol: string; + timestamp: number; + timeframe: BacktestTimeframe; + candle: Candle; + intraCandles?: Candle[]; + strategyResult: RuleResult | null; + context: MarketContext | null; + profileSettings?: any; +} + +interface PendingEntryIntent { + symbol: string; + side: SignalDirection.BUY | SignalDirection.SELL; + qty: number; + orderType: 'market' | 'limit'; + limitPrice: number; + stopLoss: number; + takeProfit: number; + queuedAt: number; + entryReason: string; +} + +interface PendingExitIntent { + reason: 'SIGNAL_FLIP' | 'NEUTRAL_EXIT'; + qty: number; + queuedAt: number; +} + +const round = (value: number, precision: number = 8): number => { + const factor = Math.pow(10, precision); + return Math.round(value * factor) / factor; +}; + +const getRuleFailureLabel = (result: RuleResult | null): string | null => { + if (!result || result.passed) return null; + if (result.ruleName) return result.ruleName; + return null; +}; + +export class VirtualExecutionEngine { + private readonly riskEngine = new RiskEngine(); + private readonly positionsBySymbol = new Map(); + private readonly pendingEntryBySymbol = new Map(); + private readonly pendingExitBySymbol = new Map(); + private readonly trades: BacktestTrade[] = []; + private readonly entryRejections: Record = {}; + private readonly lossReasons: Record = {}; + private sequence = 0; + private tradeSliceSequence = 0; + + constructor( + private readonly ledger: VirtualLedger, + private readonly execution: BacktestExecutionConfig + ) { } + + public getTrades(): BacktestTrade[] { + return [...this.trades]; + } + + public getEntryRejections(): Record { + return { ...this.entryRejections }; + } + + public getLossReasons(): Record { + return { ...this.lossReasons }; + } + + public hasOpenPosition(symbol: string): boolean { + return this.positionsBySymbol.has(symbol); + } + + public getOpenPositions(): VirtualPosition[] { + return Array.from(this.positionsBySymbol.values()); + } + + public getOpenPositionsAtEnd(markPriceBySymbol: Record): BacktestOpenPositionAtEnd[] { + return Array.from(this.positionsBySymbol.values()) + .sort((a, b) => a.symbol.localeCompare(b.symbol)) + .map((position) => { + const markPrice = Number(markPriceBySymbol[position.symbol] || position.entryPrice); + const grossUnrealizedPnlUsd = position.side === SignalDirection.BUY + ? (markPrice - position.entryPrice) * position.size + : (position.entryPrice - markPrice) * position.size; + const unrealizedPnlUsd = grossUnrealizedPnlUsd - position.entryFeeUsd; + const costBasis = position.entryNotional + position.entryFeeUsd; + const unrealizedPnlPct = costBasis > 0 + ? (unrealizedPnlUsd / costBasis) * 100 + : 0; + return { + status: 'OPEN_AT_END', + symbol: position.symbol, + side: position.side, + entryTimestamp: position.entryTimestamp, + size: round(position.size, 8), + entryPrice: round(position.entryPrice, 8), + markPrice: round(markPrice, 8), + unrealizedPnlUsd: round(unrealizedPnlUsd, 8), + unrealizedPnlPct: round(unrealizedPnlPct, 6), + reason: 'Replay window ended with position still open.' + }; + }); + } + + private incrementRejection(reason: string): void { + this.entryRejections[reason] = (this.entryRejections[reason] || 0) + 1; + } + + private incrementLossReason(reason: string): void { + this.lossReasons[reason] = (this.lossReasons[reason] || 0) + 1; + } + + private nextPositionId(symbol: string, timestamp: number): string { + this.sequence += 1; + return `${symbol}-${timestamp}-${String(this.sequence).padStart(5, '0')}`; + } + + private nextTradeSliceId(positionId: string): string { + this.tradeSliceSequence += 1; + return `${positionId}-slice-${String(this.tradeSliceSequence).padStart(5, '0')}`; + } + + private resolveOrderType(profileSettings?: any): 'market' | 'limit' { + const profileType = String(profileSettings?.strategy_config?.execution?.orderType || '').trim().toLowerCase(); + if (profileType === 'limit') return 'limit'; + return this.execution.orderType; + } + + private slippageMultiplier(side: SignalDirection.BUY | SignalDirection.SELL): number { + const bps = this.execution.slippageBps / 10_000; + if (side === SignalDirection.BUY) return 1 + bps; + return 1 - bps; + } + + private applySlippage(price: number, side: SignalDirection.BUY | SignalDirection.SELL): number { + return round(price * this.slippageMultiplier(side), 8); + } + + private feeRate(): number { + return Math.max(0, Number(this.execution.feeBps || 0)) / 10_000; + } + + private computeFeeUsd(notionalUsd: number): number { + if (!(notionalUsd > 0)) return 0; + return round(notionalUsd * this.feeRate(), 8); + } + + private resolveEntryPriceAtClose( + side: SignalDirection.BUY | SignalDirection.SELL, + candle: Candle, + orderType: 'market' | 'limit' + ): { fillable: boolean; price: number } { + if (orderType === 'market') { + return { fillable: true, price: this.applySlippage(candle.close, side) }; + } + + const limitPrice = candle.close; + const fillable = side === SignalDirection.BUY + ? candle.low <= limitPrice + : candle.high >= limitPrice; + if (!fillable) { + return { fillable: false, price: limitPrice }; + } + return { fillable: true, price: limitPrice }; + } + + private resolveQueuedEntryPriceAtOpen( + intent: PendingEntryIntent, + candle: Candle + ): { fillable: boolean; price: number } { + if (intent.orderType === 'market') { + return { fillable: true, price: this.applySlippage(candle.open, intent.side) }; + } + const fillable = intent.side === SignalDirection.BUY + ? candle.low <= intent.limitPrice + : candle.high >= intent.limitPrice; + return { fillable, price: intent.limitPrice }; + } + + private resolveSignalExitPrice( + position: VirtualPosition, + candle: Candle, + at: 'open' | 'close' + ): number { + const closeSide = position.side === SignalDirection.BUY + ? SignalDirection.SELL + : SignalDirection.BUY; + const reference = at === 'open' ? candle.open : candle.close; + return this.applySlippage(reference, closeSide); + } + + private resolveIntraCandleConflictReason( + position: VirtualPosition, + candle: Candle, + policy: BacktestIntraCandlePolicy + ): 'STOP_LOSS' | 'TAKE_PROFIT' { + if (policy === 'stop_loss_first') return 'STOP_LOSS'; + if (policy === 'take_profit_first') return 'TAKE_PROFIT'; + + if (candle.close === candle.open) { + const stopDistance = Math.abs(candle.open - position.stopLoss); + const takeDistance = Math.abs(position.takeProfit - candle.open); + if (stopDistance <= takeDistance) return 'STOP_LOSS'; + return 'TAKE_PROFIT'; + } + + const bullish = candle.close > candle.open; + if (position.side === SignalDirection.BUY) { + return bullish ? 'STOP_LOSS' : 'TAKE_PROFIT'; + } + return bullish ? 'TAKE_PROFIT' : 'STOP_LOSS'; + } + + private detectStopTakeProfitTrigger( + position: VirtualPosition, + candle: Candle + ): { reason: 'STOP_LOSS' | 'TAKE_PROFIT'; price: number } | null { + const stopTriggered = position.side === SignalDirection.BUY + ? (position.stopLoss > 0 && candle.low <= position.stopLoss) + : (position.stopLoss > 0 && candle.high >= position.stopLoss); + const takeProfitTriggered = position.side === SignalDirection.BUY + ? (position.takeProfit > 0 && candle.high >= position.takeProfit) + : (position.takeProfit > 0 && candle.low <= position.takeProfit); + + if (!stopTriggered && !takeProfitTriggered) { + return null; + } + + let reason: 'STOP_LOSS' | 'TAKE_PROFIT'; + if (stopTriggered && takeProfitTriggered) { + reason = this.resolveIntraCandleConflictReason(position, candle, this.execution.intraCandlePolicy); + } else { + reason = stopTriggered ? 'STOP_LOSS' : 'TAKE_PROFIT'; + } + const price = this.computeExitPrice(position, candle, reason); + return { reason, price }; + } + + private openPosition( + symbol: string, + side: SignalDirection.BUY | SignalDirection.SELL, + qty: number, + entryPrice: number, + stopLoss: number, + takeProfit: number, + timestamp: number, + entryReason: string + ): boolean { + const normalizedQty = round(qty, 8); + if (!(normalizedQty > 0) || !Number.isFinite(entryPrice) || entryPrice <= 0) { + this.incrementRejection('PARTIAL_FILL_ZERO'); + return false; + } + + const entryNotional = round(normalizedQty * entryPrice, 8); + const entryFeeUsd = this.computeFeeUsd(entryNotional); + const requiredCapital = round(entryNotional + entryFeeUsd, 8); + if (!this.ledger.reserveCapital(requiredCapital)) { + this.incrementRejection('INSUFFICIENT_CAPITAL'); + return false; + } + + this.ledger.consumeReserved(requiredCapital); + const positionId = this.nextPositionId(symbol, timestamp); + this.positionsBySymbol.set(symbol, { + id: positionId, + symbol, + side, + size: normalizedQty, + entryPrice: round(entryPrice, 8), + entryNotional, + entryFeeUsd, + stopLoss: round(stopLoss, 8), + takeProfit: round(takeProfit, 8), + peakPrice: round(entryPrice, 8), + entryTimestamp: timestamp, + entryReason + }); + return true; + } + + private computeExitPrice( + position: VirtualPosition, + candle: Candle, + exitReason: 'STOP_LOSS' | 'TAKE_PROFIT' | 'SIGNAL_FLIP' | 'NEUTRAL_EXIT' | 'FORCED_EOD' + ): number { + if (exitReason === 'STOP_LOSS') { + return round(position.stopLoss > 0 ? position.stopLoss : candle.close, 8); + } + if (exitReason === 'TAKE_PROFIT') { + return round(position.takeProfit > 0 ? position.takeProfit : candle.close, 8); + } + return round(candle.close, 8); + } + + private closePositionSlice( + position: VirtualPosition, + qtyToClose: number, + timestamp: number, + rawExitPrice: number, + exitReason: string + ): void { + if (!(qtyToClose > 0) || !Number.isFinite(rawExitPrice) || rawExitPrice <= 0) return; + + const previousSize = position.size; + const normalizedQty = Math.min(previousSize, qtyToClose); + const closeRatio = normalizedQty / previousSize; + const entryNotionalSlice = round(position.entryNotional * closeRatio, 8); + const entryFeeSlice = round(position.entryFeeUsd * closeRatio, 8); + const exitPrice = round(rawExitPrice, 8); + const exitNotional = round(exitPrice * normalizedQty, 8); + const exitFeeUsd = this.computeFeeUsd(exitNotional); + + const grossPnlUsd = position.side === SignalDirection.BUY + ? (exitPrice - position.entryPrice) * normalizedQty + : (position.entryPrice - exitPrice) * normalizedQty; + const pnlUsd = round(grossPnlUsd - entryFeeSlice - exitFeeUsd, 8); + const pnlBase = entryNotionalSlice + entryFeeSlice; + const pnlPct = pnlBase > 0 + ? (pnlUsd / pnlBase) * 100 + : 0; + + if (position.side === SignalDirection.BUY) { + this.ledger.adjustCash(round(exitNotional - exitFeeUsd, 8)); + } else { + this.ledger.adjustCash(round(entryNotionalSlice + grossPnlUsd - exitFeeUsd, 8)); + } + this.ledger.recordRealizedPnl(pnlUsd); + if (pnlUsd < 0) { + this.incrementLossReason(exitReason); + } + + const trade: BacktestTrade = { + id: this.nextTradeSliceId(position.id), + symbol: position.symbol, + side: position.side, + entryTimestamp: position.entryTimestamp, + exitTimestamp: timestamp, + durationMinutes: Math.max(0, Math.round((timestamp - position.entryTimestamp) / 60_000)), + size: round(normalizedQty, 8), + entryPrice: round(position.entryPrice, 8), + exitPrice, + pnlUsd: round(pnlUsd, 8), + pnlPct: round(pnlPct, 6), + entryReason: position.entryReason, + exitReason + }; + this.trades.push(trade); + + position.size = round(position.size - normalizedQty, 8); + position.entryNotional = round(position.entryNotional - entryNotionalSlice, 8); + position.entryFeeUsd = round(position.entryFeeUsd - entryFeeSlice, 8); + if (position.size <= 1e-8 || position.entryNotional <= 1e-8) { + this.positionsBySymbol.delete(position.symbol); + this.pendingExitBySymbol.delete(position.symbol); + } else { + this.positionsBySymbol.set(position.symbol, position); + } + } + + private maybeExitForStops( + position: VirtualPosition, + candle: Candle, + timestamp: number, + intraCandles?: Candle[] + ): boolean { + const triggerCandles = Array.isArray(intraCandles) && intraCandles.length > 0 + ? [...intraCandles].sort((a, b) => a.timestamp - b.timestamp) + : [candle]; + + for (const triggerCandle of triggerCandles) { + const trigger = this.detectStopTakeProfitTrigger(position, triggerCandle); + if (!trigger) continue; + const closeQty = round(position.size * this.execution.partialFillPct, 8); + const normalizedCloseQty = closeQty > 0 ? closeQty : position.size; + const effectiveTimestamp = Number.isFinite(triggerCandle.timestamp) + ? Math.max(timestamp, triggerCandle.timestamp) + : timestamp; + this.closePositionSlice(position, normalizedCloseQty, effectiveTimestamp, trigger.price, trigger.reason); + return true; + } + return false; + } + + private executePendingExit(symbol: string, timestamp: number, candle: Candle): void { + const pending = this.pendingExitBySymbol.get(symbol); + if (!pending) return; + + const position = this.positionsBySymbol.get(symbol); + if (!position) { + this.pendingExitBySymbol.delete(symbol); + return; + } + + const normalizedQty = round(Math.min(position.size, pending.qty), 8); + if (!(normalizedQty > 0)) { + this.pendingExitBySymbol.delete(symbol); + return; + } + + const exitPrice = this.resolveSignalExitPrice(position, candle, 'open'); + this.closePositionSlice(position, normalizedQty, timestamp, exitPrice, pending.reason); + this.pendingExitBySymbol.delete(symbol); + } + + private executePendingEntry(symbol: string, timestamp: number, candle: Candle): void { + const pending = this.pendingEntryBySymbol.get(symbol); + if (!pending) return; + + if (this.positionsBySymbol.has(symbol)) { + this.pendingEntryBySymbol.delete(symbol); + return; + } + + const fill = this.resolveQueuedEntryPriceAtOpen(pending, candle); + if (!fill.fillable) { + this.incrementRejection('LIMIT_NOT_FILLED'); + this.pendingEntryBySymbol.delete(symbol); + return; + } + + this.openPosition( + symbol, + pending.side, + pending.qty, + fill.price, + pending.stopLoss, + pending.takeProfit, + timestamp, + pending.entryReason + ); + this.pendingEntryBySymbol.delete(symbol); + } + + public async processTick(input: ProcessTickInput): Promise { + const { symbol, timestamp, candle, intraCandles, strategyResult, context, profileSettings } = input; + + if (this.execution.fillOnNextBar) { + this.executePendingExit(symbol, timestamp, candle); + this.executePendingEntry(symbol, timestamp, candle); + } + + const position = this.positionsBySymbol.get(symbol); + + if (position) { + if (this.maybeExitForStops(position, candle, timestamp, intraCandles)) { + return; + } + } + + const refreshedPosition = this.positionsBySymbol.get(symbol); + if (refreshedPosition && strategyResult) { + const signal = strategyResult.signal || SignalDirection.NONE; + const isOpposite = (refreshedPosition.side === SignalDirection.BUY && signal === SignalDirection.SELL) + || (refreshedPosition.side === SignalDirection.SELL && signal === SignalDirection.BUY); + + if (isOpposite) { + const closeQty = round(refreshedPosition.size * this.execution.partialFillPct, 8); + const normalizedCloseQty = closeQty > 0 ? closeQty : refreshedPosition.size; + if (this.execution.fillOnNextBar) { + this.pendingExitBySymbol.set(symbol, { + reason: 'SIGNAL_FLIP', + qty: normalizedCloseQty, + queuedAt: timestamp + }); + } else { + const exitPrice = this.resolveSignalExitPrice(refreshedPosition, candle, 'close'); + this.closePositionSlice(refreshedPosition, normalizedCloseQty, timestamp, exitPrice, 'SIGNAL_FLIP'); + } + return; + } + + const neutralSignal = signal === SignalDirection.NONE; + if (neutralSignal) { + const pnlPct = ((candle.close - refreshedPosition.entryPrice) / refreshedPosition.entryPrice) * 100 + * (refreshedPosition.side === SignalDirection.BUY ? 1 : -1); + const neutralExitThreshold = Number(profileSettings?.strategy_config?.execution?.profitExitPercent ?? 1.0); + if (pnlPct >= neutralExitThreshold) { + const closeQty = round(refreshedPosition.size * this.execution.partialFillPct, 8); + const normalizedCloseQty = closeQty > 0 ? closeQty : refreshedPosition.size; + if (this.execution.fillOnNextBar) { + this.pendingExitBySymbol.set(symbol, { + reason: 'NEUTRAL_EXIT', + qty: normalizedCloseQty, + queuedAt: timestamp + }); + } else { + const exitPrice = this.resolveSignalExitPrice(refreshedPosition, candle, 'close'); + this.closePositionSlice(refreshedPosition, normalizedCloseQty, timestamp, exitPrice, 'NEUTRAL_EXIT'); + } + return; + } + } + } + + if (this.positionsBySymbol.has(symbol)) return; + if (this.execution.fillOnNextBar && this.pendingEntryBySymbol.has(symbol)) return; + if (!strategyResult || !context) return; + if (!strategyResult.passed || !strategyResult.signal || strategyResult.signal === SignalDirection.NONE) { + const failedRule = getRuleFailureLabel(strategyResult); + if (failedRule) { + this.incrementRejection(`RULE_BLOCKED_${failedRule}`); + } + return; + } + + const side = strategyResult.signal as SignalDirection.BUY | SignalDirection.SELL; + const availableCapital = this.ledger.getAvailableCashUsd(); + const riskProfile = await this.riskEngine.calculateRiskProfile( + symbol, + side, + context, + profileSettings, + availableCapital + ); + if (!riskProfile) { + this.incrementRejection('RISK_ENGINE_REJECTED'); + return; + } + + const orderType = this.resolveOrderType(profileSettings); + const partialFillPct = Math.max(0.01, Math.min(1, this.execution.partialFillPct)); + const filledQty = round(riskProfile.positionSize * partialFillPct, 8); + if (!(filledQty > 0)) { + this.incrementRejection('PARTIAL_FILL_ZERO'); + return; + } + + if (this.execution.fillOnNextBar) { + this.pendingEntryBySymbol.set(symbol, { + symbol, + side, + qty: filledQty, + orderType, + limitPrice: round(candle.close, 8), + stopLoss: round(riskProfile.stopLoss, 8), + takeProfit: round(riskProfile.takeProfit, 8), + queuedAt: timestamp, + entryReason: strategyResult.reason || 'Signal entry' + }); + return; + } + + const fill = this.resolveEntryPriceAtClose(side, candle, orderType); + if (!fill.fillable) { + this.incrementRejection('LIMIT_NOT_FILLED'); + return; + } + + this.openPosition( + symbol, + side, + filledQty, + fill.price, + riskProfile.stopLoss, + riskProfile.takeProfit, + timestamp, + strategyResult.reason || 'Signal entry' + ); + } + + public forceCloseAtEnd(symbol: string, timestamp: number, candle: Candle): void { + const position = this.positionsBySymbol.get(symbol); + if (!position) return; + const exitPrice = this.resolveSignalExitPrice(position, candle, 'close'); + this.closePositionSlice(position, position.size, timestamp, exitPrice, 'FORCED_WINDOW_END'); + this.pendingExitBySymbol.delete(symbol); + this.pendingEntryBySymbol.delete(symbol); + } + + public computeUnrealizedPnl(priceBySymbol: Record): number { + let total = 0; + for (const position of this.positionsBySymbol.values()) { + const mark = Number(priceBySymbol[position.symbol] || position.entryPrice); + const grossPnl = position.side === SignalDirection.BUY + ? (mark - position.entryPrice) * position.size + : (position.entryPrice - mark) * position.size; + total += grossPnl - position.entryFeeUsd; + } + return total; + } +} diff --git a/backend/src/backtest/engine/VirtualLedger.ts b/backend/src/backtest/engine/VirtualLedger.ts new file mode 100644 index 0000000..8aa33bb --- /dev/null +++ b/backend/src/backtest/engine/VirtualLedger.ts @@ -0,0 +1,77 @@ +export class VirtualLedger { + private cashUsd: number; + private reservedUsd = 0; + private realizedPnlUsd = 0; + + constructor( + private readonly initialCapitalUsd: number, + private readonly allowNegativeCash: boolean = false + ) { + if (!Number.isFinite(initialCapitalUsd) || initialCapitalUsd <= 0) { + throw new Error(`Invalid initial capital: ${initialCapitalUsd}`); + } + this.cashUsd = initialCapitalUsd; + } + + public getInitialCapitalUsd(): number { + return this.initialCapitalUsd; + } + + public getCashUsd(): number { + return this.cashUsd; + } + + public getReservedUsd(): number { + return this.reservedUsd; + } + + public getAvailableCashUsd(): number { + return this.cashUsd - this.reservedUsd; + } + + public getRealizedPnlUsd(): number { + return this.realizedPnlUsd; + } + + public reserveCapital(amountUsd: number): boolean { + if (!Number.isFinite(amountUsd) || amountUsd <= 0) return false; + const nextReserved = this.reservedUsd + amountUsd; + if (!this.allowNegativeCash && nextReserved > this.cashUsd + 1e-8) { + return false; + } + this.reservedUsd = nextReserved; + return true; + } + + public releaseReserved(amountUsd: number): void { + if (!Number.isFinite(amountUsd) || amountUsd <= 0) return; + this.reservedUsd = Math.max(0, this.reservedUsd - amountUsd); + } + + public consumeReserved(amountUsd: number): void { + if (!Number.isFinite(amountUsd) || amountUsd <= 0) return; + const consumed = Math.min(this.reservedUsd, amountUsd); + this.reservedUsd = Math.max(0, this.reservedUsd - consumed); + this.cashUsd -= consumed; + this.assertCashInvariant(); + } + + public adjustCash(amountUsd: number): void { + if (!Number.isFinite(amountUsd) || amountUsd === 0) return; + this.cashUsd += amountUsd; + this.assertCashInvariant(); + } + + public recordRealizedPnl(pnlUsd: number): void { + if (!Number.isFinite(pnlUsd)) return; + this.realizedPnlUsd += pnlUsd; + } + + private assertCashInvariant(): void { + if (this.allowNegativeCash) return; + if (this.cashUsd < -1e-8) { + throw new Error(`Backtest invariant violation: cash became negative (${this.cashUsd}).`); + } + } +} + diff --git a/backend/src/backtest/engine/timeFreeze.ts b/backend/src/backtest/engine/timeFreeze.ts new file mode 100644 index 0000000..b8c7e66 --- /dev/null +++ b/backend/src/backtest/engine/timeFreeze.ts @@ -0,0 +1,26 @@ +export const runWithFrozenDate = async ( + timestampMs: number, + runner: () => Promise +): Promise => { + const OriginalDate = Date; + class FrozenDate extends Date { + constructor(...args: any[]) { + if (args.length === 0) { + super(timestampMs); + return; + } + super(...(args as [any])); + } + static now(): number { + return timestampMs; + } + } + + (globalThis as any).Date = FrozenDate as any; + try { + return await runner(); + } finally { + (globalThis as any).Date = OriginalDate; + } +}; + diff --git a/backend/src/backtest/engine/warmup.ts b/backend/src/backtest/engine/warmup.ts new file mode 100644 index 0000000..a942fe6 --- /dev/null +++ b/backend/src/backtest/engine/warmup.ts @@ -0,0 +1,88 @@ +import { config } from '../../config/index.js'; +import type { BacktestWarmupReport } from '../types.js'; + +const clampPositiveInt = (value: unknown, fallback: number): number => { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return Math.max(1, Math.floor(parsed)); +}; + +const getRuleParams = (strategyConfig: any, ruleId: string): Record => { + const rules = Array.isArray(strategyConfig?.rules) ? strategyConfig.rules : []; + const found = rules.find((rule: any) => String(rule?.ruleId || '').trim() === ruleId); + return (found?.params && typeof found.params === 'object') ? found.params : {}; +}; + +const isRuleEnabled = (strategyConfig: any, ruleId: string): boolean => { + const rules = Array.isArray(strategyConfig?.rules) ? strategyConfig.rules : []; + const found = rules.find((rule: any) => String(rule?.ruleId || '').trim() === ruleId); + if (!found) return true; + return found.enabled !== false; +}; + +export interface WarmupRequirements { + '15m': number; + '1h': number; + '4h': number; +} + +export const computeWarmupRequirements = (strategyConfig: any): WarmupRequirements => { + const requirements: WarmupRequirements = { + '15m': 2, + '1h': 2, + '4h': 2 + }; + + if (isRuleEnabled(strategyConfig, 'TrendBiasRule')) { + const trendParams = getRuleParams(strategyConfig, 'TrendBiasRule'); + const slowPeriod = clampPositiveInt( + trendParams?.slowPeriod, + Number(config.PRO_STRATEGY.PARAMETERS.TREND_EMA_SLOW || 200) + ); + requirements['4h'] = Math.max(requirements['4h'], slowPeriod + 1); + } + + if (isRuleEnabled(strategyConfig, 'MomentumRule')) { + const momentumParams = getRuleParams(strategyConfig, 'MomentumRule'); + const timeframeRaw = String(momentumParams?.timeframe || config.PRO_STRATEGY.PARAMETERS.MOMENTUM_TIMEFRAME || '15m').trim().toLowerCase(); + const momentumTf: '15m' | '1h' = timeframeRaw === '1h' ? '1h' : '15m'; + const rsiPeriod = clampPositiveInt(momentumParams?.rsiPeriod, Number(config.PRO_STRATEGY.PARAMETERS.RSI_PERIOD || 14)); + requirements[momentumTf] = Math.max(requirements[momentumTf], rsiPeriod + 1); + } + + if (isRuleEnabled(strategyConfig, 'ZoneRule')) { + const zoneParams = getRuleParams(strategyConfig, 'ZoneRule'); + const executionTfRaw = String(zoneParams?.timeframe || config.PRO_STRATEGY.PARAMETERS.EXECUTION_TIMEFRAME || '15m').trim().toLowerCase(); + const zoneTf: '15m' | '1h' = executionTfRaw === '1h' ? '1h' : '15m'; + const zoneEmaPeriod = clampPositiveInt(zoneParams?.emaPeriod, Number(config.PRO_STRATEGY.PARAMETERS.ZONE_EMA_PERIOD || 20)); + requirements[zoneTf] = Math.max(requirements[zoneTf], zoneEmaPeriod + 1); + } + + if (isRuleEnabled(strategyConfig, 'EntryTriggerRule')) { + const entryParams = getRuleParams(strategyConfig, 'EntryTriggerRule'); + const executionTfRaw = String(entryParams?.timeframe || config.PRO_STRATEGY.PARAMETERS.EXECUTION_TIMEFRAME || '15m').trim().toLowerCase(); + const entryTf: '15m' | '1h' = executionTfRaw === '1h' ? '1h' : '15m'; + requirements[entryTf] = Math.max(requirements[entryTf], 3); + } + + if (isRuleEnabled(strategyConfig, 'RiskManagementRule')) { + const riskParams = getRuleParams(strategyConfig, 'RiskManagementRule'); + const atrPeriod = clampPositiveInt(riskParams?.atrPeriod, Number(config.PRO_STRATEGY.PARAMETERS.ATR_PERIOD || 14)); + requirements['1h'] = Math.max(requirements['1h'], Math.max(20, atrPeriod + 1)); + } + + return requirements; +}; + +export const createWarmupReport = ( + startTimestamp: number, + endTimestamp: number, + candlesRequired: WarmupRequirements, + signalsIgnored: number +): BacktestWarmupReport => ({ + startTimestamp, + endTimestamp, + candlesRequired, + signalsIgnored +}); + diff --git a/backend/src/backtest/exchange/ReplayExchangeConnector.ts b/backend/src/backtest/exchange/ReplayExchangeConnector.ts new file mode 100644 index 0000000..4cf97f1 --- /dev/null +++ b/backend/src/backtest/exchange/ReplayExchangeConnector.ts @@ -0,0 +1,86 @@ +import type { Candle, ExchangeCapabilities, IExchangeConnector } from '../../connectors/types.js'; +import type { BacktestTimeframe, HistoricalDataset } from '../types.js'; + +const asBacktestTimeframe = (timeframe: string): BacktestTimeframe => { + const normalized = String(timeframe || '').trim().toLowerCase(); + if (normalized === '1m' || normalized === '1min') return '1m'; + if (normalized === '15m' || normalized === '15min') return '15m'; + if (normalized === '1h' || normalized === '60m') return '1h'; + if (normalized === '4h' || normalized === '240m') return '4h'; + throw new Error(`Unsupported replay timeframe: ${timeframe}`); +}; + +export class ReplayExchangeConnector implements IExchangeConnector { + private cursorTimestamp: number | null = null; + private calls = { + fetchOHLCV: 0, + placeOrder: 0, + getPosition: 0 + }; + + constructor(private readonly dataset: HistoricalDataset) { } + + public setCursorTimestamp(timestamp: number): void { + this.cursorTimestamp = timestamp; + } + + public getCallCounts(): { fetchOHLCV: number; placeOrder: number; getPosition: number } { + return { ...this.calls }; + } + + public getCapabilities(): ExchangeCapabilities { + return { + fetchOpenOrders: false, + fetchClosedOrders: false, + shorting: true, + margin: false, + leverage: false, + tradingWindow: false + }; + } + + public getCandleCountUpTo(symbol: string, timeframe: BacktestTimeframe, timestamp: number): number { + const normalizedSymbol = String(symbol || '').trim().toUpperCase(); + const candles = this.dataset[normalizedSymbol]?.[timeframe] || []; + return candles.filter((candle) => candle.timestamp <= timestamp).length; + } + + public getCandleAtOrBefore(symbol: string, timeframe: BacktestTimeframe, timestamp: number): Candle | null { + const normalizedSymbol = String(symbol || '').trim().toUpperCase(); + const candles = this.dataset[normalizedSymbol]?.[timeframe] || []; + let selected: Candle | null = null; + for (const candle of candles) { + if (candle.timestamp <= timestamp) { + selected = candle; + continue; + } + break; + } + return selected; + } + + public async fetchOHLCV(symbol: string, timeframe: string, limit?: number): Promise { + this.calls.fetchOHLCV += 1; + const normalizedSymbol = String(symbol || '').trim().toUpperCase(); + const normalizedTf = asBacktestTimeframe(timeframe); + const candles = this.dataset[normalizedSymbol]?.[normalizedTf] || []; + const cursor = this.cursorTimestamp ?? Number.POSITIVE_INFINITY; + const scoped = candles.filter((candle) => candle.timestamp <= cursor); + const boundedLimit = Number.isFinite(Number(limit)) && Number(limit) > 0 + ? Math.floor(Number(limit)) + : scoped.length; + if (boundedLimit <= 0) return []; + if (scoped.length <= boundedLimit) return scoped; + return scoped.slice(-boundedLimit); + } + + public async placeOrder(): Promise { + this.calls.placeOrder += 1; + throw new Error('Backtest safety violation: placeOrder is forbidden in replay mode.'); + } + + public async getPosition(): Promise { + this.calls.getPosition += 1; + return null; + } +} diff --git a/backend/src/backtest/guards.ts b/backend/src/backtest/guards.ts new file mode 100644 index 0000000..32662f8 --- /dev/null +++ b/backend/src/backtest/guards.ts @@ -0,0 +1,14 @@ +import { config } from '../config/index.js'; +import type { BacktestMode } from './types.js'; + +export function assertBacktestMode(mode: string | undefined): asserts mode is BacktestMode { + if (mode !== 'backtest') { + throw new Error('Backtest guard rejection: mode must be "backtest".'); + } +} + +export function assertBacktestFeatureEnabled(): void { + if (!config.ENABLE_BACKTEST) { + throw new Error('Backtest feature is disabled. Set ENABLE_BACKTEST=true to enable.'); + } +} diff --git a/backend/src/backtest/index.ts b/backend/src/backtest/index.ts new file mode 100644 index 0000000..6cd9fbb --- /dev/null +++ b/backend/src/backtest/index.ts @@ -0,0 +1,24 @@ +import { assertBacktestFeatureEnabled, assertBacktestMode } from './guards.js'; +import { loadHistoricalData } from './data/loadHistoricalData.js'; +import { runBacktestReplay } from './engine/BacktestRunner.js'; +import type { BacktestRequest, BacktestResult } from './types.js'; + +export interface RunBacktestOptions { + profileSettings?: any; +} + +export const runBacktest = async ( + request: BacktestRequest, + options: RunBacktestOptions = {} +): Promise => { + assertBacktestFeatureEnabled(); + assertBacktestMode(request.mode); + const historical = await loadHistoricalData(request); + return runBacktestReplay({ + request, + dataset: historical.dataset, + replayWindow: historical.window, + dataSourceType: historical.source, + profileSettings: options.profileSettings + }); +}; diff --git a/backend/src/backtest/metrics/computeSummary.ts b/backend/src/backtest/metrics/computeSummary.ts new file mode 100644 index 0000000..b3951e4 --- /dev/null +++ b/backend/src/backtest/metrics/computeSummary.ts @@ -0,0 +1,60 @@ +import type { BacktestSummary, BacktestTimeframe, EquityPoint } from '../types.js'; + +const minutesByTimeframe: Record = { + '1m': 1, + '15m': 15, + '1h': 60, + '4h': 240 +}; + +const round = (value: number, precision: number = 6): number => { + const factor = Math.pow(10, precision); + return Math.round(value * factor) / factor; +}; + +const stdDev = (values: number[]): number => { + if (!values.length) return 0; + const mean = values.reduce((sum, value) => sum + value, 0) / values.length; + const variance = values.reduce((sum, value) => sum + Math.pow(value - mean, 2), 0) / values.length; + return Math.sqrt(variance); +}; + +export const computeSharpe = (timeline: EquityPoint[], timeframe: BacktestTimeframe): number => { + if (timeline.length < 2) return 0; + const returns: number[] = []; + for (let i = 1; i < timeline.length; i++) { + const prev = timeline[i - 1].equityUsd; + const next = timeline[i].equityUsd; + if (prev <= 0) continue; + returns.push((next - prev) / prev); + } + if (!returns.length) return 0; + const mean = returns.reduce((sum, value) => sum + value, 0) / returns.length; + const sigma = stdDev(returns); + if (sigma === 0) return 0; + const periodsPerYear = (365 * 24 * 60) / minutesByTimeframe[timeframe]; + return round((mean / sigma) * Math.sqrt(periodsPerYear), 6); +}; + +export const computeSummary = ( + trades: Array<{ pnlUsd: number }>, + timeline: EquityPoint[], + timeframe: BacktestTimeframe +): BacktestSummary => { + const totalTrades = trades.length; + const netPnlUsd = trades.reduce((sum, trade) => sum + Number(trade.pnlUsd || 0), 0); + const winningTrades = trades.filter((trade) => Number(trade.pnlUsd || 0) > 0).length; + const winRate = totalTrades > 0 ? (winningTrades / totalTrades) * 100 : 0; + const maxDrawdownPct = timeline.length > 0 + ? timeline.reduce((max, point) => Math.max(max, Number(point.drawdownPct || 0)), 0) + : 0; + const sharpe = computeSharpe(timeline, timeframe); + + return { + netPnlUsd: round(netPnlUsd, 8), + winRate: round(winRate, 6), + maxDrawdownPct: round(maxDrawdownPct, 6), + sharpe, + totalTrades + }; +}; diff --git a/backend/src/backtest/types.ts b/backend/src/backtest/types.ts new file mode 100644 index 0000000..364d1db --- /dev/null +++ b/backend/src/backtest/types.ts @@ -0,0 +1,207 @@ +import type { Candle } from '../connectors/types.js'; + +export type BacktestMode = 'backtest'; +export type BacktestTimeframe = '1m' | '15m' | '1h' | '4h'; +export type BacktestOrderType = 'market' | 'limit'; +export type BacktestDataSourceType = 'csv' | 'json' | 'replay' | 'kraken'; +export type BacktestWindowClosePolicy = 'OPEN_AT_END' | 'FORCE_CLOSE'; +export type BacktestIntraCandlePolicy = 'stop_loss_first' | 'take_profit_first' | 'ohlc_path'; +export type BacktestTriggerTimeframe = 'off' | '1m'; + +export interface BacktestRawCandle { + symbol: string; + timeframe: string; + timestamp: number | string; + open: number | string; + high: number | string; + low: number | string; + close: number | string; + volume: number | string; +} + +export interface NormalizedBacktestCandle extends Candle { + symbol: string; + timeframe: BacktestTimeframe; +} + +export interface BacktestCsvSource { + type: 'csv'; + payload: string; +} + +export interface BacktestJsonSource { + type: 'json'; + payload: { + candles: BacktestRawCandle[] | Record>>; + }; +} + +export interface BacktestReplaySource { + type: 'replay'; + payload: { + candles: BacktestRawCandle[] | Record>>; + }; +} + +export interface BacktestKrakenSource { + type: 'kraken'; + payload?: { + exchange?: 'kraken'; + lookbackCandles?: number; + timeframe?: BacktestTimeframe; + limitPerRequest?: number; + disableCache?: boolean; + }; +} + +export type BacktestDataSource = BacktestCsvSource | BacktestJsonSource | BacktestReplaySource | BacktestKrakenSource; + +export interface BacktestExecutionConfig { + initialCapitalUsd: number; + orderType: BacktestOrderType; + slippageBps: number; + feeBps: number; + partialFillPct: number; + fillOnNextBar: boolean; + intraCandlePolicy: BacktestIntraCandlePolicy; + triggerTimeframe: BacktestTriggerTimeframe; + allowNegativeCash: boolean; + enforceWarmup: boolean; + forceCloseAtWindowEnd: boolean; +} + +export interface BacktestDateRange { + from: string; + to: string; +} + +export interface BacktestRequest { + mode: BacktestMode; + profileId?: string; + strategyConfig?: any; + symbols: string[]; + timeframe: BacktestTimeframe; + dateRange: BacktestDateRange; + dataSource: BacktestDataSource; + execution?: Partial; +} + +export interface BacktestReplayWindow { + fromTimestamp: number; + toTimestamp: number; + fromIso: string; + toIso: string; + timezone: 'UTC'; +} + +export interface BacktestWarmupReport { + startTimestamp: number; + endTimestamp: number; + candlesRequired: { + '15m': number; + '1h': number; + '4h': number; + }; + signalsIgnored: number; +} + +export interface BacktestTrade { + id: string; + symbol: string; + side: 'BUY' | 'SELL'; + entryTimestamp: number; + exitTimestamp: number; + durationMinutes: number; + size: number; + entryPrice: number; + exitPrice: number; + pnlUsd: number; + pnlPct: number; + entryReason: string; + exitReason: string; + blockedRules?: string[]; +} + +export interface BacktestOpenPositionAtEnd { + status: 'OPEN_AT_END'; + symbol: string; + side: 'BUY' | 'SELL'; + entryTimestamp: number; + size: number; + entryPrice: number; + markPrice: number; + unrealizedPnlUsd: number; + unrealizedPnlPct: number; + reason: string; +} + +export interface EquityPoint { + timestamp: number; + equityUsd: number; + drawdownPct: number; + cashUsd: number; + reservedUsd: number; +} + +export interface BacktestSummary { + netPnlUsd: number; + winRate: number; + maxDrawdownPct: number; + sharpe: number; + totalTrades: number; +} + +export interface BacktestAssumptions { + fillModel: string; + slippageModel: string; + feeModel: string; + latencyModel: string; + intraCandleModel: string; + triggerResolution: string; + warmupEnforced: boolean; + deterministicReplay: boolean; + replayWindow: string; + endOfWindowPolicy: BacktestWindowClosePolicy; + disclaimer: string; +} + +export interface BacktestDiagnostics { + blockedRuleCounts: Record; + entryRejections: Record; + lossReasons: Record; + connectorCalls: { + fetchOHLCV: number; + placeOrder: number; + getPosition: number; + }; +} + +export interface BacktestWindowMeta { + fromTimestamp: number; + toTimestamp: number; + fromIso: string; + toIso: string; + timezone: 'UTC'; + includesWarmupCandles: boolean; + endOfWindowPolicy: BacktestWindowClosePolicy; +} + +export interface BacktestResult { + mode: BacktestMode; + trades: BacktestTrade[]; + summary: BacktestSummary; + timeline: EquityPoint[]; + window: BacktestWindowMeta; + warmup: BacktestWarmupReport; + openPositionsAtEnd: BacktestOpenPositionAtEnd[]; + assumptions: BacktestAssumptions; + diagnostics: BacktestDiagnostics; +} + +export type HistoricalDataset = Record>; + +export interface HistoricalLoadResult { + dataset: HistoricalDataset; + window: BacktestReplayWindow; + source: BacktestDataSourceType; +} diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts new file mode 100644 index 0000000..7766864 --- /dev/null +++ b/backend/src/config/index.ts @@ -0,0 +1,455 @@ +import * as dotenv from 'dotenv'; +import logger from '../utils/logger.js'; + +dotenv.config({ override: true }); + +export const config = { + PRODUCT_ID: process.env.PRODUCT_ID || 'invttrdg', + PLATFORM_API_URL: process.env.PLATFORM_API_URL || 'http://localhost:4003/api', + + // Plug-and-Play Provider Selection + PROVIDER: process.env.PROVIDER || 'alpaca', + DATA_PROVIDER: process.env.DATA_PROVIDER || process.env.PROVIDER || 'alpaca', + EXECUTION_PROVIDER: process.env.EXECUTION_PROVIDER || process.env.PROVIDER || 'alpaca', + + // Asset Details + SYMBOL: process.env.SYMBOL || 'BTC/USD', // Legacy single symbol + SYMBOLS: (process.env.SYMBOLS || process.env.SYMBOL || 'BTC/USD').split(',').map(s => s.trim()), // Multi-asset support + TIMEFRAME: process.env.TIMEFRAME || '1Min', + POLLING_INTERVAL: parseInt(process.env.POLLING_INTERVAL || '60000', 10), + + // Alpaca Specific + ALPACA_API_KEY: (process.env.ALPACA_API_KEY === 'your_key' ? '' : process.env.ALPACA_API_KEY) || '', + ALPACA_API_SECRET: (process.env.ALPACA_API_SECRET === 'your_secret' ? '' : process.env.ALPACA_API_SECRET) || '', + PAPER_TRADING: process.env.PAPER_TRADING === 'true', + ASSET_CLASS: (process.env.ASSET_CLASS || 'crypto') as 'crypto' | 'us_equity', + + // CCXT Specific + EXCHANGE: process.env.EXCHANGE || 'binance', + CCXT_API_KEY: (process.env.CCXT_API_KEY === 'your_key' ? '' : process.env.CCXT_API_KEY) || '', + CCXT_API_SECRET: (process.env.CCXT_API_SECRET === 'your_secret' ? '' : process.env.CCXT_API_SECRET) || '', + + // Notifications + WEBHOOK_URL: process.env.WEBHOOK_URL || '', + NOTIFICATION_PHONE_NUMBERS: (process.env.NOTIFICATION_PHONE_NUMBERS || '').split(',').map(s => s.trim()).filter(Boolean), + NOTIFICATION_API_HOST: process.env.NOTIFICATION_API_HOST || 'www.zenhustles.com', + NOTIFICATION_API_PATH: process.env.NOTIFICATION_API_PATH || '/api/whatsapp/send', + + // Server + API_PORT: parseInt(process.env.PORT || process.env.API_PORT || '4018', 10), + ALLOWED_ORIGINS: (process.env.CORS_ALLOWED_ORIGINS + || process.env.ALLOWED_ORIGINS + || 'http://localhost:3048,http://localhost:5173,http://localhost:8081') + .split(',') + .map(s => s.trim()) + .filter(Boolean), + + // Supabase + SUPABASE_URL: process.env.SUPABASE_URL || '', + SUPABASE_KEY: process.env.SUPABASE_KEY || process.env.SUPABASE_ANON_KEY || '', + SUPABASE_JWT_ISSUER: process.env.SUPABASE_JWT_ISSUER || '', + SUPABASE_JWT_AUDIENCE: process.env.SUPABASE_JWT_AUDIENCE || '', + SNAPSHOT_USER_ID: process.env.SNAPSHOT_USER_ID || '', + + // AI Configuration (Phase 2.3) + AI: { + PROVIDER: process.env.AI_PROVIDER || 'openai', // Default/Primary provider + OPENAI_API_KEY: process.env.OPENAI_API_KEY || process.env.AI_API_KEY || '', + GEMINI_API_KEY: process.env.GEMINI_API_KEY || process.env.AI_API_KEY || '', + PERPLEXITY_API_KEY: process.env.PERPLEXITY_API_KEY || '', + MODEL: process.env.AI_MODEL || 'gpt-4o', // Default model for primary provider + CONFIDENCE_THRESHOLD: parseInt(process.env.AI_CONFIDENCE_THRESHOLD || '70', 10), + FALLBACK_LIST: (process.env.AI_FALLBACK_LIST || 'openai,perplexity,gemini').split(',').map(s => s.trim().toLowerCase()), + CACHE_HOURS: parseInt(process.env.AI_CACHE_HOURS || '4', 10), + FAIL_OPEN: process.env.AI_FAIL_OPEN !== 'false', + }, + + // Low-Stress Mode (Toggleable Feature) + LOW_STRESS_MODE: process.env.LOW_STRESS_MODE === 'true', + + // Alert Toggles + ENABLE_TREND_ALERTS: process.env.ENABLE_TREND_ALERTS !== 'false', // Default to true + ENABLE_PULSE_ALERTS: process.env.ENABLE_PULSE_ALERTS !== 'false', // Default to true + + // Execution / Trading (Phase 5) + ENABLE_TRADING: process.env.ENABLE_TRADING === 'true', + TOTAL_CAPITAL: parseFloat(process.env.TOTAL_CAPITAL || '1000'), + MAX_OPEN_TRADES: parseInt(process.env.MAX_OPEN_TRADES || '3', 10), + MAX_OPEN_TRADES_PER_ACCOUNT: parseInt(process.env.MAX_OPEN_TRADES_PER_ACCOUNT || '6', 10), + COOLDOWN_MS: parseInt(process.env.COOLDOWN_MS || '3600000', 10), // Default 1 hour + PROFIT_EXIT_PERCENT: parseFloat(process.env.PROFIT_EXIT_PERCENT || '1.0'), // Default 1% + TRAILING_STOP_PERCENT: parseFloat(process.env.TRAILING_STOP_PERCENT || '0.001'), // Default 0.1% + PROFILE_SYNC_INTERVAL_MS: parseInt(process.env.PROFILE_SYNC_INTERVAL_MS || '60000', 10), // Default 1 min + MONITOR_INTERVAL_MS: parseInt(process.env.MONITOR_INTERVAL_MS || '60000', 10), // Default 1 min + ORDER_SYNC_INTERVAL_MS: parseInt(process.env.ORDER_SYNC_INTERVAL_MS || '60000', 10), // Default 1 min + STALE_ORDER_THRESHOLD_MINUTES: parseInt(process.env.STALE_ORDER_THRESHOLD_MINUTES || '2', 10), // Default 2 min + ORDER_SYNC_MISSING_GRACE_MINUTES: parseInt(process.env.ORDER_SYNC_MISSING_GRACE_MINUTES || '5', 10), + ORDER_SYNC_MISSING_CONFIRMATION_COUNT: parseInt(process.env.ORDER_SYNC_MISSING_CONFIRMATION_COUNT || '2', 10), + ORDER_SYNC_RECENT_CLOSED_LOOKBACK_MINUTES: parseInt(process.env.ORDER_SYNC_RECENT_CLOSED_LOOKBACK_MINUTES || '30', 10), + SYMBOL_DELAY_MS: parseInt(process.env.SYMBOL_DELAY_MS || '2000', 10), // Delay between symbols + ENABLE_BACKTEST: process.env.ENABLE_BACKTEST === 'true', + BACKTEST_CUSTOMER_ENABLED: process.env.BACKTEST_CUSTOMER_ENABLED === 'true', + BACKTEST_MAX_CSV_BYTES: parseInt(process.env.BACKTEST_MAX_CSV_BYTES || '5242880', 10), // 5MB + BACKTEST_MAX_ROWS: parseInt(process.env.BACKTEST_MAX_ROWS || '200000', 10), + CAPITAL_WATCHDOG_INTERVAL_MS: parseInt(process.env.CAPITAL_WATCHDOG_INTERVAL_MS || '60000', 10), // Default 1 min + DB_SNAPSHOT_INTERVAL_MS: parseInt(process.env.DB_SNAPSHOT_INTERVAL_MS || '300000', 10), // Default 5 min + ENABLE_DB_SNAPSHOTS: process.env.ENABLE_DB_SNAPSHOTS !== 'false', // Default true + ACCOUNT_SNAPSHOT_INTERVAL_MS: parseInt(process.env.ACCOUNT_SNAPSHOT_INTERVAL_MS || '60000', 10), // Default 1 min + DYNAMIC_CONFIG_REFRESH_MS: parseInt(process.env.DYNAMIC_CONFIG_REFRESH_MS || '60000', 10), // Default 1 min + EXCHANGE_STATE_MISMATCH_THROTTLE_MS: parseInt(process.env.EXCHANGE_STATE_MISMATCH_THROTTLE_MS || '300000', 10), // 5 min + REQUIRE_EXCHANGE_FILL_EVIDENCE_FOR_AUTO_CLOSE: process.env.REQUIRE_EXCHANGE_FILL_EVIDENCE_FOR_AUTO_CLOSE !== 'false', + + // Order Execution Safety + ORDER_POLL_INTERVAL_MS: parseInt(process.env.ORDER_POLL_INTERVAL_MS || '3000', 10), // Poll every 3s + ORDER_POLL_MAX_ATTEMPTS: parseInt(process.env.ORDER_POLL_MAX_ATTEMPTS || '10', 10), // Max 10 polls (30s) + LIMIT_ORDER_TIMEOUT_MS: parseInt(process.env.LIMIT_ORDER_TIMEOUT_MS || '300000', 10), // 5 min timeout + MAX_SLIPPAGE_PERCENT: parseFloat(process.env.MAX_SLIPPAGE_PERCENT || '1.0'), // 1% max slippage + ENABLE_AUTO_PAUSE_ON_SLIPPAGE_BREACH: process.env.ENABLE_AUTO_PAUSE_ON_SLIPPAGE_BREACH !== 'false', + MIN_POSITION_QTY: parseFloat(process.env.MIN_POSITION_QTY || '0.0001'), + MAX_POSITION_QTY: parseFloat(process.env.MAX_POSITION_QTY || '1000000'), + QUANTITY_PRECISION: parseInt(process.env.QUANTITY_PRECISION || '6', 10), + MIN_NOTIONAL_USD: parseFloat(process.env.MIN_NOTIONAL_USD || '10'), + MAX_NOTIONAL_USD: parseFloat(process.env.MAX_NOTIONAL_USD || '100000'), + CAPITAL_RESERVE_PERCENT: parseFloat(process.env.CAPITAL_RESERVE_PERCENT || '0'), + ENTRY_CAPITAL_BUFFER_PCT: parseFloat(process.env.ENTRY_CAPITAL_BUFFER_PCT || '0.25'), + ENABLE_STRICT_CAPITAL_GUARD: process.env.ENABLE_STRICT_CAPITAL_GUARD !== 'false', + STRICT_CAPITAL_SLIPPAGE_BUFFER_PCT: parseFloat(process.env.STRICT_CAPITAL_SLIPPAGE_BUFFER_PCT || process.env.MAX_SLIPPAGE_PERCENT || '1.0'), + STRICT_CAPITAL_FEE_BUFFER_PCT: parseFloat(process.env.STRICT_CAPITAL_FEE_BUFFER_PCT || '0.15'), + STRICT_CAPITAL_MIN_RESERVE_USD: parseFloat(process.env.STRICT_CAPITAL_MIN_RESERVE_USD || '0'), + ENTRY_AUTO_REDUCE_ALERT_MIN_PCT: parseFloat(process.env.ENTRY_AUTO_REDUCE_ALERT_MIN_PCT || '0.05'), + ENTRY_AUTO_REDUCE_ALERT_MIN_USD: parseFloat(process.env.ENTRY_AUTO_REDUCE_ALERT_MIN_USD || '25'), + ENTRY_AUTO_REDUCE_ALERT_THROTTLE_MS: parseInt(process.env.ENTRY_AUTO_REDUCE_ALERT_THROTTLE_MS || '1800000', 10), + CAPITAL_INVARIANT_EPSILON_USD: parseFloat(process.env.CAPITAL_INVARIANT_EPSILON_USD || '2'), + CAPITAL_INVARIANT_EPSILON_PCT: parseFloat(process.env.CAPITAL_INVARIANT_EPSILON_PCT || '0.00005'), + CAPITAL_INVARIANT_ALERT_THROTTLE_MS: parseInt(process.env.CAPITAL_INVARIANT_ALERT_THROTTLE_MS || '600000', 10), + CAPITAL_LEDGER_DRIFT_ALERT_PCT: parseFloat(process.env.CAPITAL_LEDGER_DRIFT_ALERT_PCT || '10'), + CAPITAL_LEDGER_DRIFT_MIN_USD: parseFloat(process.env.CAPITAL_LEDGER_DRIFT_MIN_USD || '10'), + CAPITAL_LEDGER_DRIFT_SCOPE: String(process.env.CAPITAL_LEDGER_DRIFT_SCOPE || 'auto').trim().toLowerCase(), + OPERATIONAL_EVENTS_MAX_BUFFER: parseInt(process.env.OPERATIONAL_EVENTS_MAX_BUFFER || '2000', 10), + + // Alpaca omnibus sub-tagging + ENABLE_ALPACA_SUBTAG: process.env.ENABLE_ALPACA_SUBTAG !== 'false', + SUBTAG_OMNIBUS_ONLY: process.env.SUBTAG_OMNIBUS_ONLY === 'true', + ALPACA_SUBTAG_ENV: process.env.ALPACA_SUBTAG_ENV || '', + ALPACA_SUBTAG_MAX_LENGTH: parseInt(process.env.ALPACA_SUBTAG_MAX_LENGTH || '48', 10), + ALPACA_SUBTAG_DISABLE_FOR_EXCHANGE: (process.env.ALPACA_SUBTAG_DISABLE_FOR_EXCHANGE || '') + .split(',') + .map(s => s.trim()) + .filter(Boolean), + ALPACA_OMNIBUS_PROFILE_ALLOWLIST: (process.env.ALPACA_OMNIBUS_PROFILE_ALLOWLIST || '') + .split(',') + .map(s => s.trim()) + .filter(Boolean), + + // Reconciliation EXIT Backfill (Data-only safety path) + ENABLE_RECON_EXIT_BACKFILL: process.env.ENABLE_RECON_EXIT_BACKFILL !== 'false', + RECON_EXIT_BACKFILL_DRY_RUN: process.env.RECON_EXIT_BACKFILL_DRY_RUN === 'true', + RECON_EXIT_BACKFILL_REQUIRE_PAUSE: process.env.RECON_EXIT_BACKFILL_REQUIRE_PAUSE !== 'false', + RECON_EXIT_BACKFILL_DUST_ABS_QTY: parseFloat(process.env.RECON_EXIT_BACKFILL_DUST_ABS_QTY || '0.001'), + RECON_EXIT_BACKFILL_DUST_REL_PCT: parseFloat(process.env.RECON_EXIT_BACKFILL_DUST_REL_PCT || '0.002'), + RECON_EXIT_BACKFILL_LOOKBACK_HOURS: parseInt(process.env.RECON_EXIT_BACKFILL_LOOKBACK_HOURS || '72', 10), + RECON_EXIT_BACKFILL_REQUIRE_STRONG_ATTRIBUTION: process.env.RECON_EXIT_BACKFILL_REQUIRE_STRONG_ATTRIBUTION !== 'false', + RECON_EXIT_BACKFILL_ALLOW_HEURISTIC_MATCH: process.env.RECON_EXIT_BACKFILL_ALLOW_HEURISTIC_MATCH === 'true', + RECON_EXIT_BACKFILL_FILL_AFTER_TRADE_GRACE_MINUTES: parseInt(process.env.RECON_EXIT_BACKFILL_FILL_AFTER_TRADE_GRACE_MINUTES || '5', 10), + RECON_EXIT_BACKFILL_PROFILE_ALLOWLIST: (process.env.RECON_EXIT_BACKFILL_PROFILE_ALLOWLIST || '') + .split(',') + .map(s => s.trim()) + .filter(Boolean), + + // Reconciliation missing-order coverage sync (data-only safety path) + ENABLE_RECON_ORDER_COVERAGE_SYNC: process.env.ENABLE_RECON_ORDER_COVERAGE_SYNC !== 'false', + RECON_ORDER_COVERAGE_DRY_RUN: process.env.RECON_ORDER_COVERAGE_DRY_RUN === 'true', + RECON_ORDER_COVERAGE_REQUIRE_PAUSE: process.env.RECON_ORDER_COVERAGE_REQUIRE_PAUSE === 'true', + RECON_ORDER_COVERAGE_LOOKBACK_HOURS: parseInt(process.env.RECON_ORDER_COVERAGE_LOOKBACK_HOURS || '72', 10), + RECON_ORDER_COVERAGE_FETCH_LIMIT_PER_PAGE: parseInt(process.env.RECON_ORDER_COVERAGE_FETCH_LIMIT_PER_PAGE || '500', 10), + RECON_ORDER_COVERAGE_MAX_FETCH_PAGES: parseInt(process.env.RECON_ORDER_COVERAGE_MAX_FETCH_PAGES || '25', 10), + RECON_ORDER_COVERAGE_MAX_INSERTS_PER_PROFILE: parseInt(process.env.RECON_ORDER_COVERAGE_MAX_INSERTS_PER_PROFILE || '200', 10), + RECON_ORDER_COVERAGE_TRADE_ID_LOOKBACK_ROWS: parseInt(process.env.RECON_ORDER_COVERAGE_TRADE_ID_LOOKBACK_ROWS || '2000', 10), + RECON_ORDER_COVERAGE_REQUIRE_SUBTAG_ATTRIBUTION: process.env.RECON_ORDER_COVERAGE_REQUIRE_SUBTAG_ATTRIBUTION !== 'false', + RECON_ORDER_COVERAGE_AUTO_PAUSE_ON_UNATTRIBUTED_FILLS: process.env.RECON_ORDER_COVERAGE_AUTO_PAUSE_ON_UNATTRIBUTED_FILLS !== 'false', + RECON_ORDER_COVERAGE_UNATTRIBUTED_PAUSE_MIN_COUNT: parseInt(process.env.RECON_ORDER_COVERAGE_UNATTRIBUTED_PAUSE_MIN_COUNT || '1', 10), + RECON_ORDER_COVERAGE_UNATTRIBUTED_BASELINE_MS: parseInt(process.env.RECON_ORDER_COVERAGE_UNATTRIBUTED_BASELINE_MS || '0', 10), + + // Reconciliation sub-tag repair (data-only; fills missing legacy sub_tag for traceability) + ENABLE_RECON_SUBTAG_REPAIR: process.env.ENABLE_RECON_SUBTAG_REPAIR !== 'false', + RECON_SUBTAG_REPAIR_DRY_RUN: process.env.RECON_SUBTAG_REPAIR_DRY_RUN === 'true', + RECON_SUBTAG_REPAIR_LOOKBACK_HOURS: parseInt(process.env.RECON_SUBTAG_REPAIR_LOOKBACK_HOURS || '720', 10), + RECON_SUBTAG_REPAIR_MAX_UPDATES_PER_PROFILE: parseInt(process.env.RECON_SUBTAG_REPAIR_MAX_UPDATES_PER_PROFILE || '500', 10), + + // Canonical lifecycle API sizing and truncation visibility + CANONICAL_LIFECYCLE_MAX_ROWS: parseInt(process.env.CANONICAL_LIFECYCLE_MAX_ROWS || '200000', 10), + CANONICAL_LIFECYCLE_TRUNCATION_ALERT_MS: parseInt(process.env.CANONICAL_LIFECYCLE_TRUNCATION_ALERT_MS || '600000', 10), + + // Reconciliation SLO alerts + RECONCILIATION_SLO_ALERT_STREAK: parseInt(process.env.RECONCILIATION_SLO_ALERT_STREAK || '2', 10), + RECONCILIATION_SLO_ALERT_THROTTLE_MS: parseInt(process.env.RECONCILIATION_SLO_ALERT_THROTTLE_MS || '600000', 10), + RECONCILIATION_SLO_MISMATCH_THRESHOLD: parseInt(process.env.RECONCILIATION_SLO_MISMATCH_THRESHOLD || '1', 10), + RECONCILIATION_SLO_MISSING_EXCHANGE_THRESHOLD: parseInt(process.env.RECONCILIATION_SLO_MISSING_EXCHANGE_THRESHOLD || '1', 10), + RECONCILIATION_SLO_MISSING_DB_THRESHOLD: parseInt(process.env.RECONCILIATION_SLO_MISSING_DB_THRESHOLD || '1', 10), + + // Reconciliation integrity watchdog (hard alerts for lifecycle drift risks) + ENABLE_RECON_INTEGRITY_WATCHDOG: process.env.ENABLE_RECON_INTEGRITY_WATCHDOG !== 'false', + RECON_INTEGRITY_WATCHDOG_THROTTLE_MS: parseInt(process.env.RECON_INTEGRITY_WATCHDOG_THROTTLE_MS || '600000', 10), + RECON_INTEGRITY_WATCHDOG_MISSING_DB_THRESHOLD: parseInt(process.env.RECON_INTEGRITY_WATCHDOG_MISSING_DB_THRESHOLD || '1', 10), + RECON_INTEGRITY_WATCHDOG_NO_GO_THRESHOLD: parseInt(process.env.RECON_INTEGRITY_WATCHDOG_NO_GO_THRESHOLD || '1', 10), + ENABLE_RECON_WATCHDOG_AUTO_RESUME: process.env.ENABLE_RECON_WATCHDOG_AUTO_RESUME !== 'false', + RECON_WATCHDOG_AUTO_RESUME_MIN_PAUSE_MS: parseInt(process.env.RECON_WATCHDOG_AUTO_RESUME_MIN_PAUSE_MS || '900000', 10), + RECON_WATCHDOG_AUTO_RESUME_CLEAN_CYCLES: parseInt(process.env.RECON_WATCHDOG_AUTO_RESUME_CLEAN_CYCLES || '2', 10), + RECON_WATCHDOG_AUTO_RESUME_COOLDOWN_MS: parseInt(process.env.RECON_WATCHDOG_AUTO_RESUME_COOLDOWN_MS || '1800000', 10), + + // Reconciliation position-parity heartbeat (automated ghost self-healing path) + ENABLE_RECON_POSITION_PARITY_HEARTBEAT: process.env.ENABLE_RECON_POSITION_PARITY_HEARTBEAT !== 'false', + RECON_POSITION_PARITY_DRY_RUN: process.env.RECON_POSITION_PARITY_DRY_RUN === 'true', + RECON_POSITION_PARITY_CONFIRMATIONS: parseInt(process.env.RECON_POSITION_PARITY_CONFIRMATIONS || '3', 10), + RECON_POSITION_PARITY_DUST_ABS_QTY: parseFloat(process.env.RECON_POSITION_PARITY_DUST_ABS_QTY || '0.0001'), + RECON_POSITION_PARITY_MAX_NOTIONAL_PCT: parseFloat(process.env.RECON_POSITION_PARITY_MAX_NOTIONAL_PCT || '0.5'), + RECON_POSITION_PARITY_REQUIRE_SUBTAG_ATTRIBUTION: process.env.RECON_POSITION_PARITY_REQUIRE_SUBTAG_ATTRIBUTION !== 'false', + RECON_POSITION_PARITY_ALLOW_LEGACY_ENTRY_ATTRIBUTION: process.env.RECON_POSITION_PARITY_ALLOW_LEGACY_ENTRY_ATTRIBUTION !== 'false', + + // Pro Strategy Configuration (Modular Rules) + PRO_STRATEGY: { + ENABLED_RULES: (process.env.ENABLED_RULES || 'trend_bias,momentum,zone,session,entry_trigger,ai_analysis,risk_management').split(',').map(s => s.trim()), + PARAMETERS: { + // Trend Bias (4H) + TREND_TIMEFRAME: process.env.R_TREND_TIMEFRAME || '4h', + // Execution (15m) -- Phase 2 Upgrade + EXECUTION_TIMEFRAME: process.env.R_EXECUTION_TIMEFRAME || (process.env.LOW_STRESS_MODE === 'true' ? '1h' : '15m'), + + TREND_EMA_FAST: parseInt(process.env.R_TREND_EMA_FAST || '50', 10), + TREND_EMA_SLOW: parseInt(process.env.R_TREND_EMA_SLOW || '200', 10), + + // Momentum (15m) + MOMENTUM_TIMEFRAME: process.env.R_MOMENTUM_TIMEFRAME || '15m', + RSI_PERIOD: parseInt(process.env.R_RSI_PERIOD || '14', 10), + RSI_OVERBOUGHT: parseInt(process.env.R_RSI_OVERBOUGHT || '70', 10), + RSI_OVERSOLD: parseInt(process.env.R_RSI_OVERSOLD || '30', 10), + + // Zone / Location + ZONE_EMA_PERIOD: parseInt(process.env.R_ZONE_EMA_PERIOD || '20', 10), + + // Session (UTC Hours) — JSON-configurable + SESSION_WINDOWS: JSON.parse(process.env.R_SESSION_WINDOWS || JSON.stringify([ + { start: 0, end: 9 }, // Tokyo (TOK) + { start: 22, end: 7 }, // Sydney (SYD) + { start: 7, end: 16 }, // London (LDN) + { start: 13, end: 22 } // New York (NY) + ])), + + // Risk + ATR_PERIOD: parseInt(process.env.R_ATR_PERIOD || '14', 10), + RISK_PER_TRADE: parseFloat(process.env.R_RISK_PER_TRADE || '0.01'), + RISK_REWARD_RATIO: parseFloat(process.env.R_RISK_REWARD_RATIO || '1.5'), + SL_MULTIPLIER: parseFloat(process.env.R_SL_MULTIPLIER || '1.5'), + MAX_TP_CAP: parseFloat(process.env.R_MAX_TP_CAP || '0.01'), + } + }, +}; + +const toNumber = (value: unknown, fallback: number): number => { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return fallback; +}; + +const toBoolean = (value: unknown, fallback: boolean): boolean => { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (['true', '1', 'yes', 'on'].includes(normalized)) return true; + if (['false', '0', 'no', 'off'].includes(normalized)) return false; + } + return fallback; +}; + +const toCsvArray = (value: unknown, fallback: string[]): string[] => { + if (Array.isArray(value)) { + return value.map(v => String(v).trim()).filter(Boolean); + } + if (typeof value === 'string') { + return value.split(',').map(s => s.trim()).filter(Boolean); + } + return fallback; +}; + +const dynamicConfigParsers: Record unknown> = { + SYMBOLS: (value) => toCsvArray(value, config.SYMBOLS), + POLLING_INTERVAL: (value) => toNumber(value, config.POLLING_INTERVAL), + ENABLE_TRADING: (value) => toBoolean(value, config.ENABLE_TRADING), + TOTAL_CAPITAL: (value) => toNumber(value, config.TOTAL_CAPITAL), + MAX_OPEN_TRADES: (value) => toNumber(value, config.MAX_OPEN_TRADES), + MAX_OPEN_TRADES_PER_ACCOUNT: (value) => toNumber(value, config.MAX_OPEN_TRADES_PER_ACCOUNT), + COOLDOWN_MS: (value) => toNumber(value, config.COOLDOWN_MS), + PROFIT_EXIT_PERCENT: (value) => toNumber(value, config.PROFIT_EXIT_PERCENT), + TRAILING_STOP_PERCENT: (value) => toNumber(value, config.TRAILING_STOP_PERCENT), + PROFILE_SYNC_INTERVAL_MS: (value) => toNumber(value, config.PROFILE_SYNC_INTERVAL_MS), + MONITOR_INTERVAL_MS: (value) => toNumber(value, config.MONITOR_INTERVAL_MS), + ORDER_SYNC_INTERVAL_MS: (value) => toNumber(value, config.ORDER_SYNC_INTERVAL_MS), + STALE_ORDER_THRESHOLD_MINUTES: (value) => toNumber(value, config.STALE_ORDER_THRESHOLD_MINUTES), + ORDER_SYNC_MISSING_GRACE_MINUTES: (value) => toNumber(value, config.ORDER_SYNC_MISSING_GRACE_MINUTES), + ORDER_SYNC_MISSING_CONFIRMATION_COUNT: (value) => toNumber(value, config.ORDER_SYNC_MISSING_CONFIRMATION_COUNT), + ORDER_SYNC_RECENT_CLOSED_LOOKBACK_MINUTES: (value) => toNumber(value, config.ORDER_SYNC_RECENT_CLOSED_LOOKBACK_MINUTES), + SYMBOL_DELAY_MS: (value) => toNumber(value, config.SYMBOL_DELAY_MS), + ENABLE_BACKTEST: (value) => toBoolean(value, config.ENABLE_BACKTEST), + BACKTEST_CUSTOMER_ENABLED: (value) => toBoolean(value, config.BACKTEST_CUSTOMER_ENABLED), + BACKTEST_MAX_CSV_BYTES: (value) => toNumber(value, config.BACKTEST_MAX_CSV_BYTES), + BACKTEST_MAX_ROWS: (value) => toNumber(value, config.BACKTEST_MAX_ROWS), + ORDER_POLL_INTERVAL_MS: (value) => toNumber(value, config.ORDER_POLL_INTERVAL_MS), + ORDER_POLL_MAX_ATTEMPTS: (value) => toNumber(value, config.ORDER_POLL_MAX_ATTEMPTS), + LIMIT_ORDER_TIMEOUT_MS: (value) => toNumber(value, config.LIMIT_ORDER_TIMEOUT_MS), + MAX_SLIPPAGE_PERCENT: (value) => toNumber(value, config.MAX_SLIPPAGE_PERCENT), + ENABLE_AUTO_PAUSE_ON_SLIPPAGE_BREACH: (value) => toBoolean(value, config.ENABLE_AUTO_PAUSE_ON_SLIPPAGE_BREACH), + MIN_POSITION_QTY: (value) => toNumber(value, config.MIN_POSITION_QTY), + MAX_POSITION_QTY: (value) => toNumber(value, config.MAX_POSITION_QTY), + QUANTITY_PRECISION: (value) => toNumber(value, config.QUANTITY_PRECISION), + MIN_NOTIONAL_USD: (value) => toNumber(value, config.MIN_NOTIONAL_USD), + MAX_NOTIONAL_USD: (value) => toNumber(value, config.MAX_NOTIONAL_USD), + CAPITAL_RESERVE_PERCENT: (value) => toNumber(value, config.CAPITAL_RESERVE_PERCENT), + ENTRY_CAPITAL_BUFFER_PCT: (value) => toNumber(value, config.ENTRY_CAPITAL_BUFFER_PCT), + ENABLE_STRICT_CAPITAL_GUARD: (value) => toBoolean(value, config.ENABLE_STRICT_CAPITAL_GUARD), + STRICT_CAPITAL_SLIPPAGE_BUFFER_PCT: (value) => toNumber(value, config.STRICT_CAPITAL_SLIPPAGE_BUFFER_PCT), + STRICT_CAPITAL_FEE_BUFFER_PCT: (value) => toNumber(value, config.STRICT_CAPITAL_FEE_BUFFER_PCT), + STRICT_CAPITAL_MIN_RESERVE_USD: (value) => toNumber(value, config.STRICT_CAPITAL_MIN_RESERVE_USD), + ENTRY_AUTO_REDUCE_ALERT_MIN_PCT: (value) => toNumber(value, config.ENTRY_AUTO_REDUCE_ALERT_MIN_PCT), + ENTRY_AUTO_REDUCE_ALERT_MIN_USD: (value) => toNumber(value, config.ENTRY_AUTO_REDUCE_ALERT_MIN_USD), + ENTRY_AUTO_REDUCE_ALERT_THROTTLE_MS: (value) => toNumber(value, config.ENTRY_AUTO_REDUCE_ALERT_THROTTLE_MS), + CAPITAL_INVARIANT_EPSILON_USD: (value) => toNumber(value, config.CAPITAL_INVARIANT_EPSILON_USD), + CAPITAL_INVARIANT_EPSILON_PCT: (value) => toNumber(value, config.CAPITAL_INVARIANT_EPSILON_PCT), + CAPITAL_INVARIANT_ALERT_THROTTLE_MS: (value) => toNumber(value, config.CAPITAL_INVARIANT_ALERT_THROTTLE_MS), + CAPITAL_LEDGER_DRIFT_ALERT_PCT: (value) => toNumber(value, config.CAPITAL_LEDGER_DRIFT_ALERT_PCT), + CAPITAL_LEDGER_DRIFT_MIN_USD: (value) => toNumber(value, config.CAPITAL_LEDGER_DRIFT_MIN_USD), + CAPITAL_LEDGER_DRIFT_SCOPE: (value) => String(value ?? config.CAPITAL_LEDGER_DRIFT_SCOPE).trim().toLowerCase(), + OPERATIONAL_EVENTS_MAX_BUFFER: (value) => toNumber(value, config.OPERATIONAL_EVENTS_MAX_BUFFER), + ENABLE_ALPACA_SUBTAG: (value) => toBoolean(value, config.ENABLE_ALPACA_SUBTAG), + SUBTAG_OMNIBUS_ONLY: (value) => toBoolean(value, config.SUBTAG_OMNIBUS_ONLY), + ALPACA_SUBTAG_ENV: (value) => String(value ?? config.ALPACA_SUBTAG_ENV), + ALPACA_SUBTAG_MAX_LENGTH: (value) => toNumber(value, config.ALPACA_SUBTAG_MAX_LENGTH), + ALPACA_SUBTAG_DISABLE_FOR_EXCHANGE: (value) => toCsvArray(value, config.ALPACA_SUBTAG_DISABLE_FOR_EXCHANGE), + ALPACA_OMNIBUS_PROFILE_ALLOWLIST: (value) => toCsvArray(value, config.ALPACA_OMNIBUS_PROFILE_ALLOWLIST), + ALLOWED_ORIGINS: (value) => toCsvArray(value, config.ALLOWED_ORIGINS), + DB_SNAPSHOT_INTERVAL_MS: (value) => toNumber(value, config.DB_SNAPSHOT_INTERVAL_MS), + ENABLE_DB_SNAPSHOTS: (value) => toBoolean(value, config.ENABLE_DB_SNAPSHOTS), + ACCOUNT_SNAPSHOT_INTERVAL_MS: (value) => toNumber(value, config.ACCOUNT_SNAPSHOT_INTERVAL_MS), + DYNAMIC_CONFIG_REFRESH_MS: (value) => toNumber(value, config.DYNAMIC_CONFIG_REFRESH_MS), + EXCHANGE_STATE_MISMATCH_THROTTLE_MS: (value) => toNumber(value, config.EXCHANGE_STATE_MISMATCH_THROTTLE_MS), + REQUIRE_EXCHANGE_FILL_EVIDENCE_FOR_AUTO_CLOSE: (value) => toBoolean(value, config.REQUIRE_EXCHANGE_FILL_EVIDENCE_FOR_AUTO_CLOSE), + ENABLE_RECON_EXIT_BACKFILL: (value) => toBoolean(value, config.ENABLE_RECON_EXIT_BACKFILL), + RECON_EXIT_BACKFILL_DRY_RUN: (value) => toBoolean(value, config.RECON_EXIT_BACKFILL_DRY_RUN), + RECON_EXIT_BACKFILL_REQUIRE_PAUSE: (value) => toBoolean(value, config.RECON_EXIT_BACKFILL_REQUIRE_PAUSE), + RECON_EXIT_BACKFILL_DUST_ABS_QTY: (value) => toNumber(value, config.RECON_EXIT_BACKFILL_DUST_ABS_QTY), + RECON_EXIT_BACKFILL_DUST_REL_PCT: (value) => toNumber(value, config.RECON_EXIT_BACKFILL_DUST_REL_PCT), + RECON_EXIT_BACKFILL_LOOKBACK_HOURS: (value) => toNumber(value, config.RECON_EXIT_BACKFILL_LOOKBACK_HOURS), + RECON_EXIT_BACKFILL_REQUIRE_STRONG_ATTRIBUTION: (value) => toBoolean(value, config.RECON_EXIT_BACKFILL_REQUIRE_STRONG_ATTRIBUTION), + RECON_EXIT_BACKFILL_ALLOW_HEURISTIC_MATCH: (value) => toBoolean(value, config.RECON_EXIT_BACKFILL_ALLOW_HEURISTIC_MATCH), + RECON_EXIT_BACKFILL_FILL_AFTER_TRADE_GRACE_MINUTES: (value) => toNumber(value, config.RECON_EXIT_BACKFILL_FILL_AFTER_TRADE_GRACE_MINUTES), + RECON_EXIT_BACKFILL_PROFILE_ALLOWLIST: (value) => toCsvArray(value, config.RECON_EXIT_BACKFILL_PROFILE_ALLOWLIST), + ENABLE_RECON_ORDER_COVERAGE_SYNC: (value) => toBoolean(value, config.ENABLE_RECON_ORDER_COVERAGE_SYNC), + RECON_ORDER_COVERAGE_DRY_RUN: (value) => toBoolean(value, config.RECON_ORDER_COVERAGE_DRY_RUN), + RECON_ORDER_COVERAGE_REQUIRE_PAUSE: (value) => toBoolean(value, config.RECON_ORDER_COVERAGE_REQUIRE_PAUSE), + RECON_ORDER_COVERAGE_LOOKBACK_HOURS: (value) => toNumber(value, config.RECON_ORDER_COVERAGE_LOOKBACK_HOURS), + RECON_ORDER_COVERAGE_FETCH_LIMIT_PER_PAGE: (value) => toNumber(value, config.RECON_ORDER_COVERAGE_FETCH_LIMIT_PER_PAGE), + RECON_ORDER_COVERAGE_MAX_FETCH_PAGES: (value) => toNumber(value, config.RECON_ORDER_COVERAGE_MAX_FETCH_PAGES), + RECON_ORDER_COVERAGE_MAX_INSERTS_PER_PROFILE: (value) => toNumber(value, config.RECON_ORDER_COVERAGE_MAX_INSERTS_PER_PROFILE), + RECON_ORDER_COVERAGE_TRADE_ID_LOOKBACK_ROWS: (value) => toNumber(value, config.RECON_ORDER_COVERAGE_TRADE_ID_LOOKBACK_ROWS), + RECON_ORDER_COVERAGE_REQUIRE_SUBTAG_ATTRIBUTION: (value) => toBoolean(value, config.RECON_ORDER_COVERAGE_REQUIRE_SUBTAG_ATTRIBUTION), + RECON_ORDER_COVERAGE_AUTO_PAUSE_ON_UNATTRIBUTED_FILLS: (value) => toBoolean(value, config.RECON_ORDER_COVERAGE_AUTO_PAUSE_ON_UNATTRIBUTED_FILLS), + RECON_ORDER_COVERAGE_UNATTRIBUTED_PAUSE_MIN_COUNT: (value) => toNumber(value, config.RECON_ORDER_COVERAGE_UNATTRIBUTED_PAUSE_MIN_COUNT), + RECON_ORDER_COVERAGE_UNATTRIBUTED_BASELINE_MS: (value) => toNumber(value, config.RECON_ORDER_COVERAGE_UNATTRIBUTED_BASELINE_MS), + ENABLE_RECON_SUBTAG_REPAIR: (value) => toBoolean(value, config.ENABLE_RECON_SUBTAG_REPAIR), + RECON_SUBTAG_REPAIR_DRY_RUN: (value) => toBoolean(value, config.RECON_SUBTAG_REPAIR_DRY_RUN), + RECON_SUBTAG_REPAIR_LOOKBACK_HOURS: (value) => toNumber(value, config.RECON_SUBTAG_REPAIR_LOOKBACK_HOURS), + RECON_SUBTAG_REPAIR_MAX_UPDATES_PER_PROFILE: (value) => toNumber(value, config.RECON_SUBTAG_REPAIR_MAX_UPDATES_PER_PROFILE), + CANONICAL_LIFECYCLE_MAX_ROWS: (value) => toNumber(value, config.CANONICAL_LIFECYCLE_MAX_ROWS), + CANONICAL_LIFECYCLE_TRUNCATION_ALERT_MS: (value) => toNumber(value, config.CANONICAL_LIFECYCLE_TRUNCATION_ALERT_MS), + RECONCILIATION_SLO_ALERT_STREAK: (value) => toNumber(value, config.RECONCILIATION_SLO_ALERT_STREAK), + RECONCILIATION_SLO_ALERT_THROTTLE_MS: (value) => toNumber(value, config.RECONCILIATION_SLO_ALERT_THROTTLE_MS), + RECONCILIATION_SLO_MISMATCH_THRESHOLD: (value) => toNumber(value, config.RECONCILIATION_SLO_MISMATCH_THRESHOLD), + RECONCILIATION_SLO_MISSING_EXCHANGE_THRESHOLD: (value) => toNumber(value, config.RECONCILIATION_SLO_MISSING_EXCHANGE_THRESHOLD), + RECONCILIATION_SLO_MISSING_DB_THRESHOLD: (value) => toNumber(value, config.RECONCILIATION_SLO_MISSING_DB_THRESHOLD), + ENABLE_RECON_INTEGRITY_WATCHDOG: (value) => toBoolean(value, config.ENABLE_RECON_INTEGRITY_WATCHDOG), + RECON_INTEGRITY_WATCHDOG_THROTTLE_MS: (value) => toNumber(value, config.RECON_INTEGRITY_WATCHDOG_THROTTLE_MS), + RECON_INTEGRITY_WATCHDOG_MISSING_DB_THRESHOLD: (value) => toNumber(value, config.RECON_INTEGRITY_WATCHDOG_MISSING_DB_THRESHOLD), + RECON_INTEGRITY_WATCHDOG_NO_GO_THRESHOLD: (value) => toNumber(value, config.RECON_INTEGRITY_WATCHDOG_NO_GO_THRESHOLD), + ENABLE_RECON_WATCHDOG_AUTO_RESUME: (value) => toBoolean(value, config.ENABLE_RECON_WATCHDOG_AUTO_RESUME), + RECON_WATCHDOG_AUTO_RESUME_MIN_PAUSE_MS: (value) => toNumber(value, config.RECON_WATCHDOG_AUTO_RESUME_MIN_PAUSE_MS), + RECON_WATCHDOG_AUTO_RESUME_CLEAN_CYCLES: (value) => toNumber(value, config.RECON_WATCHDOG_AUTO_RESUME_CLEAN_CYCLES), + RECON_WATCHDOG_AUTO_RESUME_COOLDOWN_MS: (value) => toNumber(value, config.RECON_WATCHDOG_AUTO_RESUME_COOLDOWN_MS), + ENABLE_RECON_POSITION_PARITY_HEARTBEAT: (value) => toBoolean(value, config.ENABLE_RECON_POSITION_PARITY_HEARTBEAT), + RECON_POSITION_PARITY_DRY_RUN: (value) => toBoolean(value, config.RECON_POSITION_PARITY_DRY_RUN), + RECON_POSITION_PARITY_CONFIRMATIONS: (value) => toNumber(value, config.RECON_POSITION_PARITY_CONFIRMATIONS), + RECON_POSITION_PARITY_DUST_ABS_QTY: (value) => toNumber(value, config.RECON_POSITION_PARITY_DUST_ABS_QTY), + RECON_POSITION_PARITY_MAX_NOTIONAL_PCT: (value) => toNumber(value, config.RECON_POSITION_PARITY_MAX_NOTIONAL_PCT), + RECON_POSITION_PARITY_REQUIRE_SUBTAG_ATTRIBUTION: (value) => toBoolean(value, config.RECON_POSITION_PARITY_REQUIRE_SUBTAG_ATTRIBUTION), + RECON_POSITION_PARITY_ALLOW_LEGACY_ENTRY_ATTRIBUTION: (value) => toBoolean(value, config.RECON_POSITION_PARITY_ALLOW_LEGACY_ENTRY_ATTRIBUTION), +}; + +const aiConfigParsers: Record unknown> = { + CONFIDENCE_THRESHOLD: (value) => toNumber(value, config.AI.CONFIDENCE_THRESHOLD), + CACHE_HOURS: (value) => toNumber(value, config.AI.CACHE_HOURS), + FALLBACK_LIST: (value) => toCsvArray(value, config.AI.FALLBACK_LIST), + FAIL_OPEN: (value) => toBoolean(value, config.AI.FAIL_OPEN), +}; + +/** + * Loads global configuration from Supabase to override .env defaults. + * This allows for remote management of bot behavior. + */ +export const loadDynamicConfig = async (supabase: any) => { + try { + logger.info('--- Loading Dynamic Global Config from Supabase ---'); + const { data, error } = await supabase.client + .from('bot_config') // Using 'bot_config' table + .select('*'); + + if (error) { + logger.warn(`[Config] Failed to load dynamic config: ${error.message}. Using .env defaults.`); + return; + } + + if (data && data.length > 0) { + const loadedKeys: string[] = []; + data.forEach((item: any) => { + const { key, value } = item; + if (key in config) { + const parser = dynamicConfigParsers[key]; + (config as any)[key] = parser ? parser(value) : value; + loadedKeys.push(key); + } else if (key in config.AI) { + const parser = aiConfigParsers[key]; + (config.AI as any)[key] = parser ? parser(value) : value; + loadedKeys.push(`AI.${key}`); + } + }); + logger.info(`✅ Dynamic Config Loaded: ${loadedKeys.join(', ')}`); + } else { + logger.info('ℹ️ No dynamic config overrides found in DB. Using .env defaults.'); + } + } catch (err: any) { + logger.error(`[Config] Unexpected error loading dynamic config: ${err.message}`); + } +}; + +export const validateConfig = () => { + // Treat "your_key" as empty + const isPlaceholder = (val: string) => !val || val === 'your_key' || val === 'your_secret'; + + if (config.PROVIDER === 'alpaca') { + if (isPlaceholder(config.ALPACA_API_KEY) || isPlaceholder(config.ALPACA_API_SECRET)) { + // Only log warning, don't exit if Supabase is available + if (config.SUPABASE_URL && config.SUPABASE_KEY) { + logger.warn('⚠️ Alpaca keys in .env are placeholders/missing. Bot will attempt to use keys from Supabase users.'); + } else { + logger.error('❌ Missing Alpaca API credentials and no Supabase configured!'); + process.exit(1); + } + } + } else if (config.PROVIDER === 'ccxt') { + if (!config.EXCHANGE || config.EXCHANGE === 'binance') { // binance is default but check for keys + if (isPlaceholder(config.CCXT_API_KEY) && !(config.SUPABASE_URL)) { + logger.warn('⚠️ CCXT keys are placeholders/missing.'); + } + } + } +}; diff --git a/backend/src/connectors/alpaca.ts b/backend/src/connectors/alpaca.ts new file mode 100644 index 0000000..8ef5961 --- /dev/null +++ b/backend/src/connectors/alpaca.ts @@ -0,0 +1,460 @@ +import Alpaca from '@alpacahq/alpaca-trade-api'; +import { config } from '../config/index.js'; +import logger from '../utils/logger.js'; +import { SymbolMapper } from '../utils/symbolMapper.js'; +import { AccountSnapshot, IExchangeConnector, Candle, ExchangeCapabilities, ExchangeOrderCorrelation } from './types.js'; + +export class AlpacaConnector implements IExchangeConnector { + private client: any; + + constructor(apiKey?: string, apiSecret?: string) { + this.client = new (Alpaca as any)({ + keyId: apiKey || config.ALPACA_API_KEY, + secretKey: apiSecret || config.ALPACA_API_SECRET, + paper: config.PAPER_TRADING, + }); + } + + public getCapabilities(): ExchangeCapabilities { + return { + fetchOpenOrders: true, + fetchClosedOrders: true, + shorting: config.ASSET_CLASS !== 'crypto', // Alpaca crypto shorting is limited + margin: config.ASSET_CLASS !== 'crypto', + leverage: config.ASSET_CLASS !== 'crypto', + tradingWindow: true + }; + } + + private mapTimeframe(tf: string): string { + const map: { [key: string]: string } = { + '1m': '1Min', + '5m': '5Min', + '15m': '15Min', + '1h': '1Hour', + '4h': '4Hour', + '1d': '1Day', + '1Min': '1Min', + '1Hour': '1Hour', + '1Day': '1Day' + }; + return map[tf] || tf; + } + + async fetchOHLCV(symbol: string, timeframe: string, limit: number = 100): Promise { + if (!this.client?.getBarsV2) { + logger.warn(`[Alpaca] getBarsV2 capability missing. Skipping fetchOHLCV for ${symbol}`); + return []; + } + try { + const formattedSymbol = symbol.replace('/', ''); + let mappedTf = this.mapTimeframe(timeframe); + let fetchLimit = limit; + let needsAggregation = false; + + // Alpaca V2 Crypto does NOT support 4Hour. We must fetch 1Hour and aggregate. + if (timeframe === '4h' || timeframe === '4Hour') { + mappedTf = '1Hour'; + fetchLimit = limit * 4; + needsAggregation = true; + logger.info(`[Alpaca] 🔄 Aggregating 4h bars from 1h feed for ${symbol}`); + } + + const options: any = { + start: new Date(Date.now() - 3600000 * 24 * 7).toISOString(), // 7 day lookback to be safe + timeframe: mappedTf, + limit: fetchLimit, + }; + + if (config.ASSET_CLASS !== 'crypto') { + options.feed = 'iex'; + } + + const barsGenerator = this.client.getBarsV2(formattedSymbol, options); + const candles: Candle[] = []; + for await (const bar of barsGenerator) { + candles.push({ + timestamp: new Date(bar.Timestamp).getTime(), + open: bar.OpenPrice, + high: bar.HighPrice, + low: bar.LowPrice, + close: bar.ClosePrice, + volume: bar.Volume, + }); + } + + if (candles.length === 0) { + logger.warn(`[Alpaca] No bars found for ${formattedSymbol}.`); + return []; + } + + const sorted = candles.sort((a, b) => a.timestamp - b.timestamp); + + if (needsAggregation) { + return this.aggregateBars(sorted, 4); + } + + return sorted; + + } catch (error: any) { + logger.error(`[Alpaca] Error: ${error.message || error}`); + throw error; + } + } + + private aggregateBars(candles: Candle[], factor: number): Candle[] { + const aggregated: Candle[] = []; + for (let i = 0; i < candles.length; i += factor) { + const chunk = candles.slice(i, i + factor); + if (chunk.length === 0) continue; + + aggregated.push({ + timestamp: chunk[0].timestamp, + open: chunk[0].open, + high: Math.max(...chunk.map(c => c.high)), + low: Math.min(...chunk.map(c => c.low)), + close: chunk[chunk.length - 1].close, + volume: chunk.reduce((sum, c) => sum + c.volume, 0) + }); + } + return aggregated; + } + + private sanitizeClientOrderToken(value: unknown, fallback: string, maxLength: number): string { + const cleaned = String(value || '') + .trim() + .replace(/[^A-Za-z0-9_-]/g, ''); + const token = cleaned || fallback; + if (token.length <= maxLength) return token; + return token.slice(token.length - maxLength); + } + + private buildCorrelationClientOrderId(correlation?: ExchangeOrderCorrelation): string | undefined { + const tradeId = String(correlation?.tradeId || '').trim(); + if (!tradeId) return undefined; + + const profileToken = this.sanitizeClientOrderToken(correlation?.profileId || 'global', 'global', 12); + const tradeToken = this.sanitizeClientOrderToken(tradeId, 'trade', 24); + const intentToken = this.sanitizeClientOrderToken(correlation?.intent || 'unknown', 'unknown', 8).toLowerCase(); + const candidate = `bytelyst-${profileToken}-${tradeToken}-${intentToken}`; + return candidate.length <= 48 ? candidate : candidate.slice(0, 48); + } + + async placeOrder( + symbol: string, + side: 'buy' | 'sell', + qty: number, + type: 'market' | 'limit', + price?: number, + stopLoss?: number, + takeProfit?: number, + clientOrderId?: string, + correlation?: ExchangeOrderCorrelation + ): Promise { + try { + const formattedSymbol = symbol.replace('/', ''); + const orderOptions: any = { + symbol: formattedSymbol, + qty, + side, + type, + time_in_force: 'gtc' + }; + + if (type === 'limit' && price) { + orderOptions.limit_price = price; + } + + // Bracket Order (One-Cancels-Other for Profit/Stop) + // NOTE: Alpaca does not support advanced orders for Crypto. + // Our bot handles local SL/TP monitoring in TradeMonitor.ts, so we can skip exchange-side brackets for crypto. + const isCrypto = config.ASSET_CLASS === 'crypto' || formattedSymbol.endsWith('USD') || formattedSymbol.endsWith('USDT'); + + if ((stopLoss || takeProfit) && !isCrypto) { + orderOptions.order_class = 'bracket'; + if (takeProfit) { + orderOptions.take_profit = { limit_price: takeProfit }; + } + if (stopLoss) { + orderOptions.stop_loss = { stop_price: stopLoss }; + } + } + + logger.info(`[Alpaca] Placing ${side} order for ${qty} ${formattedSymbol} (Raw: ${symbol})...`); + const resolvedClientOrderId = clientOrderId || this.buildCorrelationClientOrderId(correlation); + if (resolvedClientOrderId) { + orderOptions.client_order_id = resolvedClientOrderId; + } + if (correlation?.subTag) { + // Broker API omnibus field. Keep snake_case variant for compatibility. + orderOptions.subtag = correlation.subTag; + } + + if (!this.client?.createOrder) { + throw new Error("Alpaca client createOrder capability missing"); + } + const order = await this.client.createOrder(orderOptions); + if (resolvedClientOrderId && !order?.client_order_id) { + order.client_order_id = resolvedClientOrderId; + } + if (correlation?.subTag) { + order.subtag = order?.subtag || correlation.subTag; + order.sub_tag = order?.sub_tag || correlation.subTag; + } + return order; + } catch (error: any) { + const errorData = error.response?.data ? JSON.stringify(error.response.data) : (error.message || error); + logger.error(`[Alpaca] Order Error: ${errorData}`); + throw new Error(errorData); + } + } + + async getOrder(orderId: string): Promise { + if (!this.client?.getOrder) return null; + try { + const order = await this.client.getOrder(orderId); + return order; + } catch (error: any) { + logger.error(`[Alpaca] GetOrder Error for ${orderId}: ${error.message || error}`); + return null; + } + } + + async cancelOrder(orderId: string): Promise { + if (!this.client?.cancelOrder) return false; + try { + await this.client.cancelOrder(orderId); + logger.info(`[Alpaca] Order ${orderId} cancelled successfully.`); + return true; + } catch (error: any) { + logger.error(`[Alpaca] CancelOrder Error for ${orderId}: ${error.message || error}`); + return false; + } + } + + async fetchAccountSnapshot(): Promise { + if (!this.client?.getAccount) return null; + try { + const account = await this.client.getAccount(); + const parseNumber = (value: unknown) => { + const parsed = typeof value === 'string' ? Number(value) : Number(value ?? 0); + return Number.isFinite(parsed) ? parsed : 0; + }; + return { + buying_power: parseNumber(account.buying_power || account.buyingPower), + cash: parseNumber(account.cash), + currency: String(account.currency || 'USD'), + timestamp: Date.now() + }; + } catch (error: any) { + logger.warn(`[Alpaca] Account snapshot failed: ${error.message || error}`); + return null; + } + } + + private normalizeDataSymbol(symbol: string): string { + const upper = String(symbol || '').toUpperCase(); + if (!upper) return upper; + if (upper.includes('/')) return upper; + if (upper.endsWith('USDT')) { + return `${upper.slice(0, -4)}/USDT`; + } + if (upper.endsWith('USD')) { + return `${upper.slice(0, -3)}/USD`; + } + return upper; + } + + private buildNormalizedSymbolTargets(symbols?: string[]): Set { + const targets = new Set(); + if (!symbols || symbols.length === 0) return targets; + + for (const raw of symbols) { + const input = String(raw || '').trim().toUpperCase(); + if (!input) continue; + + const normalizedInput = this.normalizeDataSymbol(input); + const tradeVariant = SymbolMapper.toTradeSymbol(normalizedInput, 'alpaca').toUpperCase(); + const dataVariant = SymbolMapper.toDataSymbol(normalizedInput, 'alpaca').toUpperCase(); + const normalizedTradeVariant = this.normalizeDataSymbol(tradeVariant); + const normalizedDataVariant = this.normalizeDataSymbol(dataVariant); + + targets.add(input); + targets.add(normalizedInput); + targets.add(tradeVariant); + targets.add(dataVariant); + targets.add(normalizedTradeVariant); + targets.add(normalizedDataVariant); + + if (normalizedInput.endsWith('/USDT')) { + targets.add(normalizedInput.replace('/USDT', '/USD')); + } + if (normalizedInput.endsWith('/USD')) { + targets.add(normalizedInput.replace('/USD', '/USDT')); + } + } + + return targets; + } + + async fetchOpenOrders(symbols?: string[]): Promise { + if (!this.client?.getOrders) return []; + try { + const opts: any = { + status: 'open', + direction: 'desc', + limit: 200 + }; + const orders: any[] = await this.client.getOrders(opts); + if (!symbols || symbols.length === 0) { + return orders; + } + const normalizedTargets = this.buildNormalizedSymbolTargets(symbols); + return orders.filter((order) => { + const normalized = this.normalizeDataSymbol(order.symbol); + return normalizedTargets.has(normalized); + }); + } catch (error: any) { + logger.error(`[Alpaca] Fetch open orders failed: ${error.message || error}`); + return []; + } + } + + async fetchClosedOrders( + symbols?: string[], + options?: { + after?: Date; + limit?: number; + maxPages?: number; + } + ): Promise { + if (!this.client?.getOrders) return []; + try { + const limit = Math.max(1, Math.min(500, Math.floor(Number(options?.limit || 500)))); + const maxPages = Math.max(1, Math.min(100, Math.floor(Number(options?.maxPages || 1)))); + const normalizedTargets = this.buildNormalizedSymbolTargets(symbols); + const afterMs = options?.after instanceof Date && Number.isFinite(options.after.getTime()) + ? options.after.getTime() + : 0; + + const parseOrderTimestampMs = (order: any): number => { + const candidates = [ + order?.submitted_at, + order?.created_at, + order?.updated_at, + order?.filled_at + ]; + for (const candidate of candidates) { + const parsed = Date.parse(String(candidate || '').trim()); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + return 0; + }; + + const includeOrder = (order: any): boolean => { + if (!symbols || symbols.length === 0) return true; + const normalized = this.normalizeDataSymbol(order?.symbol); + return normalizedTargets.has(normalized); + }; + + const deduped = new Map(); + let until: Date | undefined; + let page = 0; + let reachedPotentialTruncation = false; + let previousCursorMs = Number.POSITIVE_INFINITY; + + while (page < maxPages) { + const opts: any = { + status: 'closed', + direction: 'desc', + limit + }; + if (options?.after instanceof Date && Number.isFinite(options.after.getTime())) { + opts.after = options.after; + } + if (until instanceof Date && Number.isFinite(until.getTime())) { + opts.until = until; + } + + const batch: any[] = await this.client.getOrders(opts); + if (!Array.isArray(batch) || batch.length === 0) break; + page += 1; + + for (const order of batch) { + if (!includeOrder(order)) continue; + const orderKey = String(order?.id || order?.order_id || order?.client_order_id || '').trim(); + if (!orderKey) continue; + if (!deduped.has(orderKey)) { + deduped.set(orderKey, order); + } + } + + let oldestBatchTs = Number.POSITIVE_INFINITY; + for (const order of batch) { + const ts = parseOrderTimestampMs(order); + if (ts > 0 && ts < oldestBatchTs) { + oldestBatchTs = ts; + } + } + + if (!Number.isFinite(oldestBatchTs) || oldestBatchTs <= 0) break; + if (afterMs > 0 && oldestBatchTs <= afterMs) break; + if (batch.length < limit) break; + + const nextCursorMs = oldestBatchTs - 1; + if (!(nextCursorMs > 0) || nextCursorMs >= previousCursorMs) break; + previousCursorMs = nextCursorMs; + until = new Date(nextCursorMs); + } + + reachedPotentialTruncation = page >= maxPages; + if (reachedPotentialTruncation) { + logger.warn(`[Alpaca] fetchClosedOrders reached maxPages=${maxPages}. Results may be truncated for lookback window.`); + } + + return Array.from(deduped.values()); + } catch (error: any) { + logger.error(`[Alpaca] Fetch closed orders failed: ${error.message || error}`); + return []; + } + } + + async getPosition(symbol: string): Promise { + if (!this.client?.getPosition) return null; + try { + // Alpaca Positions endpoint usually expects symbols without slashes (e.g. BTCUSD) + // even if Market Data V2 uses BTC/USD. + const formattedSymbol = symbol.replace('/', ''); + logger.info(`[Alpaca] Fetching position for ${formattedSymbol} (Raw: ${symbol})...`); + const position = await this.client.getPosition(formattedSymbol); + return position; + } catch (error: any) { + // Alpaca throws 404 if no position exists + if (error.message?.includes('404')) { + // logger.info(`[Alpaca] No position found for ${symbol}`); + return null; + } + const message = error.message || String(error); + logger.error(`[Alpaca] GetPosition Error for ${symbol}: ${message}`); + throw new Error(message); + } + } + + async isTradingWindowOpen(): Promise { + if (config.ASSET_CLASS === 'crypto') { + return true; + } + + if (!this.client?.getClock) return null; + try { + const clock = await this.client.getClock(); + if (typeof clock?.is_open === 'boolean') { + return clock.is_open; + } + return null; + } catch (error: any) { + logger.warn(`[Alpaca] Could not resolve market clock: ${error.message || error}`); + return null; + } + } +} diff --git a/backend/src/connectors/ccxt.ts b/backend/src/connectors/ccxt.ts new file mode 100644 index 0000000..33b3d20 --- /dev/null +++ b/backend/src/connectors/ccxt.ts @@ -0,0 +1,198 @@ +import * as ccxt from 'ccxt'; +import { config } from '../config/index.js'; +import logger from '../utils/logger.js'; +import { IExchangeConnector, Candle, ExchangeCapabilities, ExchangeOrderCorrelation } from './types.js'; + +export class CCXTConnector implements IExchangeConnector { + private client: ccxt.Exchange; + + constructor(apiKey?: string, apiSecret?: string) { + const exchangeId = config.EXCHANGE as keyof typeof ccxt; + const exchangeClass = ccxt[exchangeId] as any; + + if (!exchangeClass) { + throw new Error(`Exchange ${config.EXCHANGE} not supported by CCXT`); + } + + const auth: any = {}; + const resolvedApiKey = apiKey || config.CCXT_API_KEY; + const resolvedApiSecret = apiSecret || config.CCXT_API_SECRET; + + if (resolvedApiKey && resolvedApiKey !== 'your_key') { + auth.apiKey = resolvedApiKey; + } + if (resolvedApiSecret && resolvedApiSecret !== 'your_secret') { + auth.secret = resolvedApiSecret; + } + + this.client = new exchangeClass({ + ...auth, + enableRateLimit: true, + }); + } + + public getCapabilities(): ExchangeCapabilities { + return { + fetchOpenOrders: !!this.client.has['fetchOpenOrders'], + fetchClosedOrders: !!this.client.has['fetchClosedOrders'], + shorting: !!this.client.has['createMarketBuyOrder'] && !!this.client.has['createMarketSellOrder'], + margin: !!this.client.has['margin'], + leverage: !!this.client.has['setLeverage'], + tradingWindow: false // CCXT doesn't have a unified market clock usually + }; + } + + async fetchOHLCV(symbol: string, timeframe: string, limit: number = 100): Promise { + try { + // Translate Alpaca style '1Min' to CCXT style '1m' + let translatedTimeframe = timeframe; + if (timeframe === '1Min') translatedTimeframe = '1m'; + if (timeframe === '5Min') translatedTimeframe = '5m'; + if (timeframe === '15Min') translatedTimeframe = '15m'; + if (timeframe === '1Hour') translatedTimeframe = '1h'; + if (timeframe === '1Day') translatedTimeframe = '1d'; + + logger.info(`[CCXT] Fetching data for ${symbol} via ${config.EXCHANGE} (${translatedTimeframe})...`); + const candles = await this.client.fetchOHLCV(symbol, translatedTimeframe, undefined, limit); + return candles.map((c: any) => ({ + timestamp: c[0], + open: c[1], + high: c[2], + low: c[3], + close: c[4], + volume: c[5], + })); + } catch (error) { + logger.error(`[CCXT] Error: ${error}`); + throw error; + } + } + + async placeOrder( + symbol: string, + side: 'buy' | 'sell', + qty: number, + type: 'market' | 'limit', + price?: number, + stopLoss?: number, + takeProfit?: number, + clientOrderId?: string, + correlation?: ExchangeOrderCorrelation + ): Promise { + try { + logger.info(`[CCXT] Placing ${side} ${type} order for ${qty} ${symbol}...`); + const params: any = {}; + if (clientOrderId) params.clientOrderId = clientOrderId; + if (stopLoss !== undefined) params.stopLoss = stopLoss; + if (takeProfit !== undefined) params.takeProfit = takeProfit; + return await this.client.createOrder(symbol, type, side, qty, price, params); + } catch (error) { + logger.error(`[CCXT] Order Error: ${error}`); + throw error; + } + } + + + async getOrder(orderId: string, symbol?: string): Promise { + try { + logger.info(`[CCXT] Fetching order ${orderId} (${symbol || 'unknown symbol'})...`); + // CCXT often requires the symbol for fetchOrder + return await this.client.fetchOrder(orderId, symbol); + } catch (error: any) { + logger.error(`[CCXT] GetOrder Error for ${orderId}: ${error.message || error}`); + return null; + } + } + + async getPosition(symbol: string): Promise { + try { + const positions = await this.client.fetchPositions([symbol]); + return positions.find(p => p.symbol === symbol) || null; + } catch (error: any) { + logger.error(`[CCXT] GetPosition Error for ${symbol}: ${error.message || error}`); + throw error; + } + } + + async isTradingWindowOpen(): Promise { + // Crypto venues are effectively 24/7 for this bot's supported flow. + return true; + } + + private normalizeDataSymbol(symbol?: string): string { + const candidate = String(symbol || '').toUpperCase(); + if (!candidate) return candidate; + if (candidate.includes('/')) { + return candidate; + } + if (candidate.endsWith('USDT')) { + return `${candidate.slice(0, -4)}/USDT`; + } + if (candidate.endsWith('USD')) { + return `${candidate.slice(0, -3)}/USD`; + } + return candidate; + } + + async cancelOrder(orderId: string, symbol?: string): Promise { + try { + logger.info(`[CCXT] Cancelling order ${orderId} (${symbol || 'unknown symbol'})...`); + await this.client.cancelOrder(orderId, symbol); + return true; + } catch (error: any) { + logger.error(`[CCXT] CancelOrder Error for ${orderId}: ${error.message || error}`); + return false; + } + } + + async fetchOpenOrders(symbols?: string[]): Promise { + if (!this.client.fetchOpenOrders) return []; + try { + const orders = await this.client.fetchOpenOrders(); + if (!symbols || symbols.length === 0) return orders; + + const normalizedTargets = new Set(symbols.map((s) => this.normalizeDataSymbol(s))); + return orders.filter((order: any) => normalizedTargets.has(this.normalizeDataSymbol(order.symbol))); + } catch (error: any) { + logger.error(`[CCXT] Fetch open orders failed: ${error.message || error}`); + return []; + } + } + + async fetchClosedOrders( + symbols?: string[], + options?: { + after?: Date; + limit?: number; + maxPages?: number; + } + ): Promise { + if (!this.client.fetchClosedOrders) return []; + const since = options?.after instanceof Date && Number.isFinite(options.after.getTime()) + ? options.after.getTime() + : undefined; + const limit = Math.max(1, Math.min(500, Math.floor(Number(options?.limit || 500)))); + + try { + if (!symbols || symbols.length === 0) { + return await this.client.fetchClosedOrders(undefined, since, limit); + } + + const collected: any[] = []; + for (const symbol of symbols) { + try { + const rows = await this.client.fetchClosedOrders(symbol, since, limit); + if (Array.isArray(rows)) { + collected.push(...rows); + } + } catch (symbolError: any) { + logger.warn(`[CCXT] Fetch closed orders failed for ${symbol}: ${symbolError.message || symbolError}`); + } + } + return collected; + } catch (error: any) { + logger.error(`[CCXT] Fetch closed orders failed: ${error.message || error}`); + return []; + } + } +} diff --git a/backend/src/connectors/factory.ts b/backend/src/connectors/factory.ts new file mode 100644 index 0000000..f7724c8 --- /dev/null +++ b/backend/src/connectors/factory.ts @@ -0,0 +1,21 @@ +import { config } from '../config/index.js'; +import { AlpacaConnector } from './alpaca.js'; +import { CCXTConnector } from './ccxt.js'; +import { IExchangeConnector } from './types.js'; + +export class ConnectorFactory { + static getConnector(): IExchangeConnector { + return this.getCustomConnector(config.PROVIDER); + } + + static getCustomConnector(provider: string, apiKey?: string, apiSecret?: string): IExchangeConnector { + switch (provider.toLowerCase()) { + case 'alpaca': + return new AlpacaConnector(apiKey, apiSecret); + case 'ccxt': + return new CCXTConnector(apiKey, apiSecret); + default: + throw new Error(`Provider ${provider} is not supported. Use 'alpaca' or 'ccxt'.`); + } + } +} diff --git a/backend/src/connectors/types.ts b/backend/src/connectors/types.ts new file mode 100644 index 0000000..8ff7699 --- /dev/null +++ b/backend/src/connectors/types.ts @@ -0,0 +1,62 @@ +export interface Candle { + timestamp: number; + open: number; + high: number; + low: number; + close: number; + volume: number; +} + +export interface AccountSnapshot { + profileId?: string; + userId?: string; + buying_power: number; + cash: number; + currency: string; + timestamp: number; +} + +export interface ExchangeCapabilities { + fetchOpenOrders?: boolean; + fetchClosedOrders?: boolean; + shorting?: boolean; + margin?: boolean; + leverage?: boolean; + tradingWindow?: boolean; +} + +export interface ExchangeOrderCorrelation { + subTag?: string; + profileId?: string; + tradeId?: string; + intent?: 'ENTRY' | 'EXIT' | 'UNKNOWN'; +} + +export interface IExchangeConnector { + getCapabilities(): ExchangeCapabilities; + fetchOHLCV(symbol: string, timeframe: string, limit?: number): Promise; + placeOrder( + symbol: string, + side: 'buy' | 'sell', + qty: number, + type: 'market' | 'limit', + price?: number, + stopLoss?: number, + takeProfit?: number, + clientOrderId?: string, + correlation?: ExchangeOrderCorrelation + ): Promise; + getPosition(symbol: string): Promise; + getOrder?(orderId: string, symbol?: string): Promise; + fetchOpenOrders?(symbols?: string[]): Promise; + fetchClosedOrders?( + symbols?: string[], + options?: { + after?: Date; + limit?: number; + maxPages?: number; + } + ): Promise; + cancelOrder?(orderId: string, symbol?: string): Promise; + fetchAccountSnapshot?(): Promise; +} diff --git a/backend/src/domain/operationalEvents.ts b/backend/src/domain/operationalEvents.ts new file mode 100644 index 0000000..40e15d3 --- /dev/null +++ b/backend/src/domain/operationalEvents.ts @@ -0,0 +1,23 @@ +export type OperationalEventType = + | 'ORDER_FAILURE' + | 'PARITY_WARNING' + | 'RECONCILIATION_DEGRADED' + | 'INSUFFICIENT_BUYING_POWER' + | 'EXCHANGE_STATE_MISMATCH' + | 'CAPITAL_LEDGER_DRIFT' + | 'EXIT_FILL_COHERENCE_VIOLATION' + | 'RECOVERY_SL_MISSING' + | 'SYSTEM_ERROR'; + +export type OperationalEventSeverity = 'INFO' | 'WARN' | 'ERROR'; + +export interface OperationalEvent { + id: string; + type: OperationalEventType; + severity: OperationalEventSeverity; + message: string; + profileId?: string; + userId?: string; + symbol?: string; + timestamp: number; +} diff --git a/backend/src/domain/tradingEnums.ts b/backend/src/domain/tradingEnums.ts new file mode 100644 index 0000000..da81e83 --- /dev/null +++ b/backend/src/domain/tradingEnums.ts @@ -0,0 +1,44 @@ +export type TradeSide = 'BUY' | 'SELL'; + +export type OrderStatus = + | 'pending_new' + | 'partially_filled' + | 'filled' + | 'canceled' + | 'expired' + | 'rejected' + | 'unknown'; + +export type OrderAction = 'ENTRY' | 'EXIT'; + +export type OrderType = 'Market' | 'Limit' | 'Stop'; + +export function normalizeTradeSide(side: string): TradeSide { + const normalized = (side || '').trim().toUpperCase(); + if (normalized === 'SELL' || normalized === 'SHORT') return 'SELL'; + return 'BUY'; +} + +export function normalizeOrderStatus(status: string): OrderStatus { + const normalized = (status || '').trim().toLowerCase(); + if (normalized === 'filled') return 'filled'; + if (normalized === 'partially_filled' || normalized === 'partial_fill' || normalized === 'partiallyfilled') return 'partially_filled'; + if (normalized === 'canceled' || normalized === 'cancelled') return 'canceled'; + if (normalized === 'expired') return 'expired'; + if (normalized === 'rejected') return 'rejected'; + if (normalized === 'unknown') return 'unknown'; + return 'pending_new'; +} + +export function normalizeOrderAction(action?: string): OrderAction | undefined { + const normalized = (action || '').trim().toUpperCase(); + if (normalized === 'ENTRY' || normalized === 'EXIT') return normalized; + return undefined; +} + +export function normalizeOrderType(type: string): OrderType { + const normalized = (type || '').trim().toLowerCase(); + if (normalized === 'limit') return 'Limit'; + if (normalized === 'stop') return 'Stop'; + return 'Market'; +} diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..75f25c6 --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,1076 @@ +import { config, validateConfig, loadDynamicConfig } from './config/index.js'; +import { ConnectorFactory } from './connectors/factory.js'; +import { DirectionTracker, SignalType } from './strategies/directionTracker.js'; +import { ProStrategyEngine } from './strategies/ProStrategyEngine.js'; +import { SignalDirection } from './strategies/rules/types.js'; +import { Notifier } from './services/notifier.js'; +import { ApiServer } from './services/apiServer.js'; +import logger from './utils/logger.js'; +import { IExchangeConnector } from './connectors/types.js'; +import { TradeExecutor } from './services/TradeExecutor.js'; +import { AutoTrader, AutoTraderExecutionOutcome, PortfolioGuardResult } from './services/AutoTrader.js'; +import { ManualTrader } from './services/ManualTrader.js'; +import { TradeMonitor } from './services/tradeMonitor.js'; +import { OrderStatusSyncEvent, OrderStatusSyncService } from './services/OrderStatusSyncService.js'; + +import { supabaseService } from './services/SupabaseService.js'; +import { healthTracker } from './services/healthTracker.js'; +import { observabilityService } from './services/observabilityService.js'; +import { reconciliationService } from './services/reconciliationService.js'; +import { reconciliationWatchdogAutoResumeService } from './services/reconciliationWatchdogAutoResumeService.js'; + +async function main() { + logger.info(`Starting ${config.PRODUCT_ID} trading backend...`); + validateConfig(); + + // --- 0. Primary Account Setup (for Market Data) --- + await loadDynamicConfig(supabaseService); + let dynamicConfigRefreshInFlight = false; + const refreshDynamicConfig = async () => { + if (dynamicConfigRefreshInFlight) return; + dynamicConfigRefreshInFlight = true; + try { + await loadDynamicConfig(supabaseService); + } catch (error: any) { + logger.error(`[Config] Dynamic refresh failed: ${error.message || error}`); + } finally { + dynamicConfigRefreshInFlight = false; + } + }; + const dynamicConfigRefreshTimer = setInterval( + refreshDynamicConfig, + Math.max(15_000, Number(config.DYNAMIC_CONFIG_REFRESH_MS || 60_000)) + ); + if (typeof dynamicConfigRefreshTimer.unref === 'function') { + dynamicConfigRefreshTimer.unref(); + } + // --- 0. Load User Configuration (Supabase) --- + logger.info('Fetching active users from Supabase...'); + const users = await supabaseService.getActiveUsers(); + + // --- 1. Identify Primary Key (for Data Fetching) --- + const isPlaceholder = (val: string) => !val || val === 'your_key' || val === 'your_secret'; + const primaryAlpacaKey = (!isPlaceholder(config.ALPACA_API_KEY) ? config.ALPACA_API_KEY : (users.length > 0 ? (config.PAPER_TRADING ? users[0].ALPACA_API_KEY : users[0].REAL_ALPACA_API_KEY) : '')); + const primaryAlpacaSecret = (!isPlaceholder(config.ALPACA_API_SECRET) ? config.ALPACA_API_SECRET : (users.length > 0 ? (config.PAPER_TRADING ? users[0].ALPACA_SECRET_KEY : users[0].REAL_ALPACA_SECRET_KEY) : '')); + + logger.info(`🚨 Bot Initialized for ${config.SYMBOLS.length} Symbols: ${config.SYMBOLS.join(', ')}`); + + // --- 0. Initialize Modular Exchanges --- + const dataExchange = ConnectorFactory.getCustomConnector(config.DATA_PROVIDER, primaryAlpacaKey, primaryAlpacaSecret); + const apiServer = new ApiServer(config.API_PORT); + apiServer.pruneSymbols(config.SYMBOLS); + const proEngine = new ProStrategyEngine(dataExchange); + const notifier = new Notifier(); + const tracker = new DirectionTracker(); + + interface UserContext { + userId: string; + email: string; + profileId: string; + profileName: string; + profileSettings?: any; + monitoredSymbols: string[]; + executor: TradeExecutor; + autoTrader: AutoTrader; + manualTrader: ManualTrader; + monitor: TradeMonitor; + orderSync: OrderStatusSyncService; + } + const userContexts: UserContext[] = []; + const orphanOrderSyncByUser = new Map(); + let startedInFallbackMode = false; + const parseSymbols = (symbolsRaw?: string | null): string[] => + String(symbolsRaw || '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean); + const resolveProfileSymbols = (profileSettings?: any): string[] => { + const profileSymbols = parseSymbols(profileSettings?.symbols); + return profileSymbols.length > 0 ? profileSymbols : config.SYMBOLS; + }; + const collectMonitoredSymbols = (): string[] => { + const symbols = new Set(config.SYMBOLS); + for (const ctx of userContexts) { + for (const symbol of (ctx.monitoredSymbols || [])) { + if (symbol) symbols.add(symbol); + } + } + return Array.from(symbols); + }; + + const buildOrderSyncHandler = ( + executor: TradeExecutor, + scopeLabel: string + ) => async (event: OrderStatusSyncEvent): Promise => { + const normalizedAction = (event.action || '').toUpperCase(); + const normalizedStatus = (event.status || '').toLowerCase(); + + if (event.quarantined) { + logger.error(`[OrderSync] Quarantined order ${event.orderId} (${event.symbol}) in ${scopeLabel}. Manual review required.`); + return; + } + + if (normalizedAction !== 'EXIT') return; + if (normalizedStatus !== 'filled' && normalizedStatus !== 'partially_filled') return; + + const active = executor.getActivePosition(event.symbol, event.tradeId); + if (!active) return; + + const fallbackPrice = active.entryPrice; + const exitPrice = event.fillPrice && event.fillPrice > 0 ? event.fillPrice : fallbackPrice; + const fillQty = event.fillQty && event.fillQty > 0 + ? Math.min(event.fillQty, active.size) + : (normalizedStatus === 'filled' ? active.size : undefined); + + if (normalizedStatus === 'partially_filled' && (!fillQty || fillQty <= 0)) { + logger.error(`[OrderSync] EXIT partial fill missing qty for ${event.orderId} (${event.symbol}) in ${scopeLabel}. Manual review required.`); + return; + } + + const applied = await executor.applyExitFill( + event.symbol, + exitPrice, + fillQty, + 'Order Sync Exit Fill', + event.tradeId + ); + if (!applied.success) { + logger.error(`[OrderSync] Failed to apply EXIT fill for ${event.orderId} (${event.symbol}) in ${scopeLabel}: ${applied.error || 'unknown'}`); + return; + } + + if (!applied.fullyClosed) { + logger.info(`[OrderSync] Applied partial EXIT for ${event.symbol} in ${scopeLabel}: filled=${applied.appliedQty}, remaining=${applied.remainingSize}`); + } + }; + + const buildPortfolioGuard = ( + userId: string, + currentExecutor: TradeExecutor, + initialProfileSettings?: any + ) => async (): Promise => { + const relatedContexts = userContexts.filter((ctx) => ctx.executor.getUserId() === userId); + + let accountOpenTrades = 0; + let accountCommittedCapital = 0; + let accountAllocatedCapital = 0; + + for (const ctx of relatedContexts) { + const positions = Array.from(ctx.executor.getAllPositions().values()); + accountOpenTrades += positions.length; + accountCommittedCapital += positions.reduce((sum, pos) => sum + (pos.entryPrice * pos.size), 0); + accountAllocatedCapital += Number(ctx.profileSettings?.allocated_capital || 0); + } + + // During context bootstrap, include current profile if it is not in the shared list yet. + if (!relatedContexts.some((ctx) => ctx.executor === currentExecutor)) { + const currentPositions = Array.from(currentExecutor.getAllPositions().values()); + accountOpenTrades += currentPositions.length; + accountCommittedCapital += currentPositions.reduce((sum, pos) => sum + (pos.entryPrice * pos.size), 0); + accountAllocatedCapital += Number(initialProfileSettings?.allocated_capital || 0); + } + + if (accountOpenTrades >= config.MAX_OPEN_TRADES_PER_ACCOUNT) { + return { + allowed: false, + reason: `max account open trades reached (${config.MAX_OPEN_TRADES_PER_ACCOUNT})` + }; + } + + if (accountAllocatedCapital > 0 && accountCommittedCapital >= accountAllocatedCapital) { + return { + allowed: false, + reason: `account capital fully committed (${accountCommittedCapital.toFixed(2)} / ${accountAllocatedCapital.toFixed(2)})` + }; + } + + return { allowed: true }; + }; + + // --- Helper: Build a UserContext from a profile --- + async function buildProfileContext(profile: any): Promise { + const user = users.find(u => u.user_id === profile.user_id); + if (!user) { + logger.warn(`⚠️ Profile ${profile.name} owner not found in active users. Skipping.`); + return null; + } + + const userKey = config.PAPER_TRADING ? user.ALPACA_API_KEY : user.REAL_ALPACA_API_KEY; + const userSecret = config.PAPER_TRADING ? user.ALPACA_SECRET_KEY : user.REAL_ALPACA_SECRET_KEY; + + if (!userKey || !userSecret) { + logger.warn(`⚠️ User ${user.email} missing keys for profile ${profile.name}. Skipping.`); + return null; + } + + const userExecExchange = ConnectorFactory.getCustomConnector(config.EXECUTION_PROVIDER, userKey, userSecret); + const userExecutor = new TradeExecutor(userExecExchange, apiServer, user.user_id, profile.id); + userExecutor.setProfileSettings(profile); + const monitoredSymbols = resolveProfileSymbols(profile); + await userExecutor.syncPositions(monitoredSymbols); + await userExecutor.rebuildStartupState(); + await userExecutor.crossValidateCapitalLedger(monitoredSymbols); + + const userAutoTrader = new AutoTrader( + userExecutor, + userExecExchange, + buildPortfolioGuard(user.user_id, userExecutor, profile) + ); + const userManualTrader = new ManualTrader(userExecutor); + + apiServer.registerManualTrader(profile.id, userManualTrader); + + const userMonitor = new TradeMonitor(userExecExchange, userExecutor, apiServer); + userMonitor.start(); + const userOrderSync = new OrderStatusSyncService( + userExecExchange, + config.ORDER_SYNC_INTERVAL_MS, + profile.id, + buildOrderSyncHandler(userExecutor, `profile=${profile.id}`) + ); + userOrderSync.start(); + + if (!orphanOrderSyncByUser.has(user.user_id)) { + const orphanOrderSync = new OrderStatusSyncService( + userExecExchange, + config.ORDER_SYNC_INTERVAL_MS, + undefined, + undefined, + { + userId: user.user_id, + profileNullOnly: true + } + ); + orphanOrderSync.start(); + orphanOrderSyncByUser.set(user.user_id, orphanOrderSync); + } + + return { + userId: user.user_id, + email: user.email, + profileId: profile.id, + profileName: profile.name, + profileSettings: profile, + monitoredSymbols, + executor: userExecutor, + autoTrader: userAutoTrader, + manualTrader: userManualTrader, + monitor: userMonitor, + orderSync: userOrderSync + }; + } + + // --- 0. Initialize Signal Subscribers (Users & Profiles) --- + const profiles = await supabaseService.getActiveProfiles(); + + if (profiles.length > 0) { + logger.info(`👥 Initializing Execution Managers for ${profiles.length} Trade Profiles...`); + for (const profile of profiles) { + const ctx = await buildProfileContext(profile); + if (ctx) { + userContexts.push(ctx); + logger.info(`✅ Profile Ready: ${ctx.profileName} (${ctx.email}) [profile_id: ${ctx.profileId}]`); + } + } + } else if (users.length > 0) { + startedInFallbackMode = true; + // Fallback to one-per-user if no profiles table entries exist + logger.info(`👥 No specific profiles found. Falling back to multi-user default...`); + for (const user of users) { + const userKey = config.PAPER_TRADING ? user.ALPACA_API_KEY : user.REAL_ALPACA_API_KEY; + const userSecret = config.PAPER_TRADING ? user.ALPACA_SECRET_KEY : user.REAL_ALPACA_SECRET_KEY; + + if (!userKey || !userSecret) continue; + + const defaultProfileId = `default-${user.user_id}`; + const userExecExchange = ConnectorFactory.getCustomConnector(config.EXECUTION_PROVIDER, userKey, userSecret); + const userExecutor = new TradeExecutor(userExecExchange, apiServer, user.user_id, defaultProfileId); + await userExecutor.syncPositions(config.SYMBOLS); + await userExecutor.rebuildStartupState(); + await userExecutor.crossValidateCapitalLedger(config.SYMBOLS); + + const userAutoTrader = new AutoTrader( + userExecutor, + userExecExchange, + buildPortfolioGuard(user.user_id, userExecutor, { allocated_capital: config.TOTAL_CAPITAL }) + ); + const userManualTrader = new ManualTrader(userExecutor); + apiServer.registerManualTrader(defaultProfileId, userManualTrader); + + const userMonitor = new TradeMonitor(userExecExchange, userExecutor, apiServer); + userMonitor.start(); + const userOrderSync = new OrderStatusSyncService( + userExecExchange, + config.ORDER_SYNC_INTERVAL_MS, + defaultProfileId, + buildOrderSyncHandler(userExecutor, `profile=${defaultProfileId}`) + ); + userOrderSync.start(); + + if (!orphanOrderSyncByUser.has(user.user_id)) { + const orphanOrderSync = new OrderStatusSyncService( + userExecExchange, + config.ORDER_SYNC_INTERVAL_MS, + undefined, + undefined, + { + userId: user.user_id, + profileNullOnly: true + } + ); + orphanOrderSync.start(); + orphanOrderSyncByUser.set(user.user_id, orphanOrderSync); + } + + userContexts.push({ + userId: user.user_id, + email: user.email, + profileId: defaultProfileId, + profileName: 'Default', + monitoredSymbols: [...config.SYMBOLS], + executor: userExecutor, + autoTrader: userAutoTrader, + manualTrader: userManualTrader, + monitor: userMonitor, + orderSync: userOrderSync + }); + } + if (!isPlaceholder(config.ALPACA_API_KEY) && !isPlaceholder(config.ALPACA_API_SECRET)) { + logger.info(`⚠️ No DB Users. Initializing Legacy Single-User Mode from .env`); + const executionExchange = ConnectorFactory.getCustomConnector(config.EXECUTION_PROVIDER, config.ALPACA_API_KEY, config.ALPACA_API_SECRET); + + const executor = new TradeExecutor(executionExchange, apiServer, 'global', 'global'); + await executor.syncPositions(config.SYMBOLS); + await executor.rebuildStartupState(); + await executor.crossValidateCapitalLedger(config.SYMBOLS); + + const autoTrader = new AutoTrader( + executor, + executionExchange, + buildPortfolioGuard('global', executor, { allocated_capital: config.TOTAL_CAPITAL }) + ); + const manualTrader = new ManualTrader(executor); + + // --- Register using 'global' ID --- + apiServer.registerManualTrader('global', manualTrader); + + const tradeMonitor = new TradeMonitor(executionExchange, executor, apiServer); + tradeMonitor.start(); + const globalOrderSync = new OrderStatusSyncService( + executionExchange, + config.ORDER_SYNC_INTERVAL_MS, + 'global', + buildOrderSyncHandler(executor, 'profile=global') + ); + globalOrderSync.start(); + + userContexts.push({ + userId: 'global', + email: 'Global .env User', + profileId: 'global', + profileName: 'Default', + monitoredSymbols: [...config.SYMBOLS], + executor, + autoTrader, + manualTrader, + monitor: tradeMonitor, + orderSync: globalOrderSync + }); + } + } + + if (userContexts.length === 0) { + logger.error('❌ No valid execution users found (DB or .env). Bot cannot trade.'); + } + + observabilityService.registerProfileProvider(() => + userContexts + .map((ctx) => String(ctx.profileId || '').trim()) + .filter(Boolean) + ); + observabilityService.startCapitalWatchdog(); + + // --- PROFILE HOT-RELOAD: Sync new/updated/deactivated profiles periodically --- + setInterval(async () => { + try { + const latestProfiles = await supabaseService.getActiveProfiles(); + const currentIds = new Set(userContexts.map(c => c.profileId)); + const latestIds = new Set(latestProfiles.map((p: any) => p.id)); + + // 1. ADD new profiles that don't exist yet + for (const profile of latestProfiles) { + if (!currentIds.has(profile.id)) { + logger.info(`🆕 [ProfileSync] New profile detected: ${profile.name} (${profile.id}). Initializing...`); + const ctx = await buildProfileContext(profile); + if (ctx) { + userContexts.push(ctx); + logger.info(`✅ [ProfileSync] Profile hot-loaded: ${ctx.profileName} (${ctx.email})`); + } + } + } + + if (startedInFallbackMode && latestProfiles.length > 0) { + for (let i = userContexts.length - 1; i >= 0; i--) { + const ctx = userContexts[i]; + const isFallbackContext = ctx.profileId.startsWith('default-') || ctx.profileId === 'global'; + if (!isFallbackContext) continue; + + logger.info(`[ProfileSync] Retiring fallback context ${ctx.profileId} now that DB profiles are active.`); + ctx.monitor.stop(); + ctx.orderSync.stop(); + ctx.executor.dispose(); + apiServer.unregisterManualTrader(ctx.profileId); + const removedUserId = ctx.userId; + userContexts.splice(i, 1); + + const hasSameUserContext = userContexts.some((candidate) => candidate.userId === removedUserId); + if (!hasSameUserContext) { + const orphanSync = orphanOrderSyncByUser.get(removedUserId); + if (orphanSync) { + orphanSync.stop(); + orphanOrderSyncByUser.delete(removedUserId); + } + } + } + startedInFallbackMode = false; + } + + // 2. REMOVE profiles that were deactivated or deleted + for (let i = userContexts.length - 1; i >= 0; i--) { + const ctx = userContexts[i]; + // Skip non-DB profiles (legacy/global) + if (ctx.profileId.startsWith('default-') || ctx.profileId === 'global') continue; + + if (!latestIds.has(ctx.profileId)) { + logger.info(`🗑️ [ProfileSync] Profile removed/deactivated: ${ctx.profileName} (${ctx.profileId}). Stopping monitor.`); + ctx.monitor.stop(); + ctx.orderSync.stop(); + ctx.executor.dispose(); + apiServer.unregisterManualTrader(ctx.profileId); + const removedUserId = ctx.userId; + userContexts.splice(i, 1); + + const hasSameUserContext = userContexts.some((candidate) => candidate.userId === removedUserId); + if (!hasSameUserContext) { + const orphanSync = orphanOrderSyncByUser.get(removedUserId); + if (orphanSync) { + orphanSync.stop(); + orphanOrderSyncByUser.delete(removedUserId); + } + } + } + } + + // 3. UPDATE settings for existing profiles (strategy_config, symbols, capital, etc.) + for (const profile of latestProfiles) { + const ctx = userContexts.find(c => c.profileId === profile.id); + if (ctx) { + const nextSymbols = resolveProfileSymbols(profile); + const previousSymbols = ctx.monitoredSymbols || []; + const didSymbolsChange = + previousSymbols.length !== nextSymbols.length + || previousSymbols.some((symbol, idx) => symbol !== nextSymbols[idx]); + + // Update profileSettings in-place so the next trading loop picks up changes + ctx.profileSettings = profile; + ctx.profileName = profile.name; + ctx.executor.setProfileSettings(profile); + ctx.monitoredSymbols = nextSymbols; + + if (didSymbolsChange) { + const resyncSymbols = Array.from(new Set([...previousSymbols, ...nextSymbols])); + logger.info(`[ProfileSync] Symbols changed for ${ctx.profileName} (${ctx.profileId}). Re-syncing: ${resyncSymbols.join(', ')}`); + await ctx.executor.syncPositions(resyncSymbols); + } + } + } + + const monitoredSymbols = collectMonitoredSymbols(); + apiServer.pruneSymbols(monitoredSymbols); + } catch (err: any) { + logger.error(`[ProfileSync] Error during profile sync: ${err.message}`); + } + }, config.PROFILE_SYNC_INTERVAL_MS); + + apiServer.updateSettings({ + executionMode: config.ENABLE_TRADING ? (config.PAPER_TRADING ? 'Paper' : 'Live') : 'Alerts', + riskPerTrade: config.PRO_STRATEGY.PARAMETERS.RISK_PER_TRADE, + totalCapital: config.TOTAL_CAPITAL, + maxOpenTrades: config.MAX_OPEN_TRADES, + isAlgoEnabled: config.ENABLE_TRADING, + enabledRules: proEngine.getEnabledRules() + }); + + logger.info(`Monitoring ${collectMonitoredSymbols().join(', ')} via DATA=${config.DATA_PROVIDER} and EXECUTION=${config.EXECUTION_PROVIDER}`); + + // --- State variables for periodic alerts (Global Signal State) --- + const assetState = new Map(); + + // Initialize state + collectMonitoredSymbols().forEach(s => { + assetState.set(s, { + price: 0, + signal: SignalType.NONE, + ema: 0, + rsi: 0, + entryPrice: null + }); + }); + + apiServer.updateSettings({ + enabledRules: proEngine.getEnabledRules() + }); + + let isTradingLoopRunning = false; + let isReconciliationRunning = false; + + // --- 1. Main Trading Loop (Signal Changes) --- + const tradingLoop = async () => { + if (healthTracker.isTradingPaused()) { + logger.info('[Loop] Trading control is PAUSED. Skipping trading cycle.'); + apiServer.publishHealthSnapshot({ broadcast: true }); + return; + } + if (isTradingLoopRunning) { + logger.warn('[Loop] Previous trading cycle is still running. Skipping this interval tick.'); + return; + } + isTradingLoopRunning = true; + const loopStartedAt = Date.now(); + apiServer.updateRuntimeHealth({ + tradingLoopRunning: true, + tradingLoopLastStartedAt: loopStartedAt + }); + let loopSucceeded = true; + try { + const monitoredSymbols = collectMonitoredSymbols(); + for (const symbol of monitoredSymbols) { + try { + // ... (pro mode check omitted) ... + const useProMode = config.PRO_STRATEGY?.ENABLED_RULES?.length > 0; + if (!assetState.has(symbol)) { + assetState.set(symbol, { + price: 0, + signal: SignalType.NONE, + ema: 0, + rsi: 0, + entryPrice: null + }); + } + const state = assetState.get(symbol)!; + + if (useProMode) { + // --- PER-PROFILE STRATEGY EXECUTION --- + // Each profile can have its own enabled rules via strategy_config.rules[] + // We run ProEngine per-profile so each profile only triggers on its own rules. + + let baselineResult: any = null; + const sharedContext = await proEngine.buildMarketContext(symbol); + if (!sharedContext) { + continue; + } + const context = sharedContext; + const profileSignals: Record; + }> = {}; + const aggregatedRuleBuckets = new Map(); + const directionalSignals: SignalDirection[] = []; + let currentDisplaySignal = state.signal as unknown as string; + + if (userContexts.length > 0) { + const profileEvaluations = await Promise.allSettled(userContexts.map(async (userCtx) => { + const profileRules = userCtx.profileSettings?.strategy_config?.rules; + const minRulePassRatio = userCtx.profileSettings?.strategy_config?.execution?.minRulePassRatio ?? 1.0; + const result = await proEngine.evaluateContext(sharedContext, profileRules, minRulePassRatio); + + if (!result) return null; + + let executionOutcome: AutoTraderExecutionOutcome | null = null; + if (context) { + executionOutcome = await userCtx.autoTrader.handleSignal(symbol, result, context, userCtx.profileSettings); + } + + return { + profileId: userCtx.profileId, + profileName: userCtx.profileName, + result, + executionOutcome + }; + })); + + for (const evaluation of profileEvaluations) { + if (evaluation.status !== 'fulfilled' || !evaluation.value) continue; + const { profileId, profileName, result, executionOutcome } = evaluation.value; + const profileRuleStatuses = result?.metadata?.ruleStatuses || {}; + + if (!baselineResult) { + baselineResult = result; + } + + profileSignals[profileId] = { + profileName, + signal: String(result.signal || SignalDirection.NONE), + passed: Boolean(result.passed), + reason: result.reason, + execution: executionOutcome || undefined, + rules: profileRuleStatuses + }; + + if (result.passed && result.signal && result.signal !== SignalDirection.NONE) { + directionalSignals.push(result.signal as SignalDirection); + } + + for (const [ruleName, ruleState] of Object.entries(profileRuleStatuses)) { + const bucket = aggregatedRuleBuckets.get(ruleName) || { + passed: 0, + failed: 0, + pending: 0, + skipped: 0, + confidences: [] + }; + const normalizedRuleState = ruleState as { + passed?: boolean; + isPending?: boolean; + isSkipped?: boolean; + metadata?: { confidence?: number }; + }; + + if (normalizedRuleState.isSkipped) { + bucket.skipped += 1; + } else if (normalizedRuleState.isPending) { + bucket.pending += 1; + } else if (normalizedRuleState.passed) { + bucket.passed += 1; + } else { + bucket.failed += 1; + } + + const confidence = Number(normalizedRuleState.metadata?.confidence); + if (Number.isFinite(confidence)) { + bucket.confidences.push(confidence); + } + + aggregatedRuleBuckets.set(ruleName, bucket); + } + } + + if (directionalSignals.length > 0) { + const uniqueSignals = Array.from(new Set(directionalSignals)); + currentDisplaySignal = uniqueSignals.length === 1 + ? uniqueSignals[0] + : 'MIXED'; + } else { + currentDisplaySignal = 'NONE'; + } + } else { + baselineResult = await proEngine.evaluateContext(sharedContext); + if (baselineResult?.passed && baselineResult.signal !== SignalDirection.NONE) { + currentDisplaySignal = String(baselineResult.signal); + } else { + currentDisplaySignal = 'NONE'; + } + } + + if (currentDisplaySignal !== (state.signal as unknown as string)) { + if (currentDisplaySignal === SignalDirection.BUY || currentDisplaySignal === SignalDirection.SELL) { + const message = `PRO SIGNAL: ${currentDisplaySignal}\nAsset: ${symbol}`; + await notifier.sendAlert(message); + apiServer.addAlert('signal', symbol, `${currentDisplaySignal} Signal`); + } else if (currentDisplaySignal === 'MIXED') { + apiServer.addAlert('info', symbol, `Mixed profile signals on ${symbol}; review profile signal panel.`); + } + } + state.signal = currentDisplaySignal as unknown as SignalType; + + if (context && context.currentPrice) { + state.price = context.currentPrice; + state.ema = context.ema20_1h || 0; + state.rsi = context.rsi_1h || 0; + + let displayPos = null; + const symbolPositions = userContexts + .map((ctx) => ctx.executor.getActivePosition(symbol)) + .filter((pos): pos is NonNullable => !!pos); + if (symbolPositions.length === 1) { + const pos = symbolPositions[0]; + const pnl = (state.price - pos.entryPrice) * pos.size * (pos.side === SignalDirection.BUY ? 1 : -1); + const pnlPercent = ((state.price - pos.entryPrice) / pos.entryPrice) * 100 * (pos.side === SignalDirection.BUY ? 1 : -1); + displayPos = { + ...pos, + side: pos.side as 'BUY' | 'SELL', + unrealizedPnl: pnl, + unrealizedPnlPercent: pnlPercent, + marketValue: state.price * pos.size + }; + } + + const aggregatedRules: Record = {}; + for (const [ruleName, bucket] of aggregatedRuleBuckets.entries()) { + const totalObserved = bucket.passed + bucket.failed; + const avgConfidence = bucket.confidences.length > 0 + ? bucket.confidences.reduce((sum, val) => sum + val, 0) / bucket.confidences.length + : undefined; + + aggregatedRules[ruleName] = { + passed: totalObserved > 0 ? bucket.failed === 0 : false, + reason: `${bucket.passed} pass / ${bucket.failed} fail / ${bucket.pending} pending / ${bucket.skipped} skipped`, + metadata: avgConfidence !== undefined + ? { confidence: Number(avgConfidence.toFixed(2)) } + : undefined + }; + } + + if (!Object.keys(aggregatedRules).length && baselineResult?.metadata?.ruleStatuses) { + for (const [ruleName, ruleState] of Object.entries(baselineResult.metadata.ruleStatuses)) { + const normalized = ruleState as { passed?: boolean; reason?: string; metadata?: any }; + aggregatedRules[ruleName] = { + passed: !!normalized.passed, + reason: normalized.reason || 'No reason provided', + metadata: normalized.metadata + }; + } + } + + apiServer.updateSymbol(symbol, { + price: state.price, + change24h: context.change24h, + changeToday: context.changeToday, + session: context.session, + volatility: context.volatility, + signal: currentDisplaySignal, + tradingMode: config.ENABLE_TRADING ? (config.PAPER_TRADING ? 'Paper' : 'Live') : 'Alerts', + activePosition: displayPos, + profileSignals, + indicators: { + ema20_1h: context.ema20_1h, + ema20_15m: context.ema20_15m, + ema50_4h: context.ema50_4h, + ema200_4h: context.ema200_4h, + rsi_1h: context.rsi_1h, + rsi_15m: context.rsi_15m + }, + rules: aggregatedRules + }); + } else if (baselineResult === null) { + logger.warn(`[Dashboard] Insufficient data for ${symbol}.`); + } + + // Continue to next symbol after handling PRO logic + continue; + } + + // --- LEGACY LOGIC BELOW (Only runs if Pro Msg is disabled) --- + const candles = await dataExchange.fetchOHLCV(symbol, config.TIMEFRAME); + + if (!candles || candles.length === 0) { + logger.warn(`No data received for ${symbol}.`); + continue; + } + + const legacyResult = tracker.calculateDirection(candles); + state.price = candles[candles.length - 1].close; + + // Track changes + if (legacyResult.signal !== state.signal) { // Simple change check + if (config.ENABLE_TREND_ALERTS) { + const message = `🚨 *Trend Signal Alert* 🚨\nSignal: ${legacyResult.signal}\nAsset: ${symbol}\nPrice: ${state.price}\nEMA-20: ${legacyResult.ema.toFixed(2)}\nRSI-14: ${legacyResult.rsi.toFixed(2)}\nTimeframe: ${config.TIMEFRAME}`; + await notifier.sendAlert(message); + } + + // Update state + state.signal = legacyResult.signal; + state.ema = legacyResult.ema; + state.rsi = legacyResult.rsi; + + // Low Stress Logic (Legacy - Only Global State Supported for now in Legacy Mode) + if (config.LOW_STRESS_MODE) { + if (legacyResult.signal === SignalType.BUY) { + state.entryPrice = state.price; + logger.info(`[Low-Stress] ${symbol} Entry Price set at ${state.entryPrice}`); + } else if (legacyResult.signal === SignalType.SELL) { + state.entryPrice = null; + logger.info(`[Low-Stress] ${symbol} Entry Price reset.`); + } + } + } else { + // Update heartbeat values even if no signal change + state.ema = legacyResult.ema; + state.rsi = legacyResult.rsi; + } + + logger.info(`[${symbol}] Signal: ${state.signal} | Price: ${state.price} | EMA: ${legacyResult.ema.toFixed(2)} | RSI: ${legacyResult.rsi.toFixed(2)}`); + + // Monitor Low-Stress Thresholds + if (config.LOW_STRESS_MODE && state.entryPrice) { + const percentChange = ((state.price - state.entryPrice) / state.entryPrice) * 100; + + if (percentChange >= 1.5) { + const tpMessage = `💰 *Take Profit Target Reached (+1.5%)* 💰\nAsset: ${symbol}\nEntry: ${state.entryPrice}\nCurrent: ${state.price}\nProfit: ${percentChange.toFixed(2)}%\nPlan: $10/Day Low-Stress ✅`; + await notifier.sendAlert(tpMessage); + state.entryPrice = null; + } else if (percentChange <= -0.7) { + const slMessage = `⚠️ *Stop Loss Buffer Hit (-0.7%)* ⚠️\nAsset: ${symbol}\nEntry: ${state.entryPrice}\nCurrent: ${state.price}\nLoss: ${percentChange.toFixed(2)}%\nPlan: Risk Managed 🛡️`; + await notifier.sendAlert(slMessage); + state.entryPrice = null; + } + } + + } catch (error) { + logger.error(`Error in loop for ${symbol}:`, error); + } + + // Configurable delay between symbols to separate logs/requests + await new Promise(r => setTimeout(r, config.SYMBOL_DELAY_MS)); + } + + // --- Broadcast ALL positions across ALL profiles to dashboard --- + const allPositions: any[] = []; + for (const userCtx of userContexts) { + const posMap = userCtx.executor.getAllPositions(); + for (const [, pos] of posMap.entries()) { + const symbolKey = pos.symbol; + const livePrice = assetState.get(symbolKey)?.price || pos.entryPrice; + const pnl = (livePrice - pos.entryPrice) * pos.size * (pos.side === SignalDirection.BUY ? 1 : -1); + const pnlPercent = pos.entryPrice > 0 + ? ((livePrice - pos.entryPrice) / pos.entryPrice) * 100 * (pos.side === SignalDirection.BUY ? 1 : -1) + : 0; + allPositions.push({ + id: pos.tradeId || `${userCtx.profileId}-${symbolKey}`, + symbol: symbolKey, + side: pos.side as 'BUY' | 'SELL', + size: pos.size, + entryPrice: pos.entryPrice, + currentPrice: livePrice, + stopLoss: pos.stopLoss || 0, + takeProfit: pos.takeProfit || 0, + unrealizedPnl: pnl, + unrealizedPnlPercent: pnlPercent, + marketValue: livePrice * pos.size, + userId: userCtx.executor.getUserId(), + profileId: userCtx.profileId, + profileName: userCtx.profileName, + tradeId: pos.tradeId + }); + } + } + apiServer.updatePositions(allPositions); + } catch (error) { + loopSucceeded = false; + throw error; + } finally { + const completedAt = Date.now(); + const durationMs = completedAt - loopStartedAt; + apiServer.updateRuntimeHealth({ + tradingLoopRunning: false, + tradingLoopLastCompletedAt: completedAt, + tradingLoopLastDurationMs: durationMs + }); + observabilityService.recordTradingLoop(durationMs); + isTradingLoopRunning = false; + healthTracker.recordTradingLoop(loopSucceeded); + apiServer.publishHealthSnapshot({ broadcast: true }); + } + }; + + // Initial run + await tradingLoop(); + setInterval(tradingLoop, config.POLLING_INTERVAL); + + // --- 1b. Periodic Exchange Reconciliation --- + const reconcileAllProfiles = async () => { + if (isReconciliationRunning) return; + isReconciliationRunning = true; + const startedAt = Date.now(); + apiServer.updateRuntimeHealth({ + reconciliationRunning: true, + reconciliationLastRunAt: startedAt + }); + let success = true; + let mismatchCount = 0; + let missingFromExchange = 0; + let missingInDb = 0; + let noGoTrades = 0; + const noGoReasonCounts = new Map(); + const noGoSamples: Array<{ profileId: string; symbol: string; tradeId: string; reason: string }> = []; + let parityMismatchTrades = 0; + let parityQuarantinedTrades = 0; + let parityAutoClosedTrades = 0; + let parityMaxMismatchNotionalUsd = 0; + let parityTotalMismatchNotionalUsd = 0; + let integrityWatchdogTriggered = false; + let failedProfiles = 0; + + try { + const results = await Promise.allSettled( + userContexts.map(async (ctx) => { + await ctx.executor.syncPositions(ctx.monitoredSymbols && ctx.monitoredSymbols.length > 0 ? ctx.monitoredSymbols : config.SYMBOLS); + }) + ); + const failedSyncs = results.filter((result) => result.status === 'rejected').length; + if (failedSyncs > 0) { + success = false; + logger.warn(`[Reconcile] ${failedSyncs}/${results.length} profile sync tasks failed during exchange reconciliation.`); + } + + const staleBacklog = await supabaseService.getStaleOrders(5); + + for (const ctx of userContexts) { + if (!ctx.profileId || ctx.profileId === 'global' || ctx.profileId.startsWith('default-')) continue; + + const reconResult = await reconciliationService.reconcileProfile({ + profileId: ctx.profileId, + userId: ctx.userId, + executor: ctx.executor, + monitoredSymbols: ctx.monitoredSymbols + }); + if (reconResult.error) { + failedProfiles += 1; + success = false; + logger.error(`[Reconcile] Profile ${ctx.profileId} failed reconciliation: ${reconResult.error}`); + continue; + } + if (!reconResult.processed) continue; + mismatchCount += reconResult.mismatchCount; + missingFromExchange += reconResult.missingFromExchange; + missingInDb += reconResult.missingInDb; + noGoTrades += reconResult.noGoTrades; + for (const [reason, count] of Object.entries(reconResult.noGoReasonCounts || {})) { + const key = String(reason || 'unknown').trim() || 'unknown'; + noGoReasonCounts.set(key, (noGoReasonCounts.get(key) || 0) + Math.max(0, Number(count) || 0)); + } + if (noGoSamples.length < 10) { + for (const sample of (reconResult.noGoSamples || [])) { + if (noGoSamples.length >= 10) break; + noGoSamples.push({ + profileId: String(sample?.profileId || '').trim(), + symbol: String(sample?.symbol || '').trim(), + tradeId: String(sample?.tradeId || '').trim(), + reason: String(sample?.reason || '').trim() || 'unknown' + }); + } + } + parityMismatchTrades += reconResult.parityMismatchTrades; + parityQuarantinedTrades += reconResult.parityQuarantinedTrades; + parityAutoClosedTrades += reconResult.parityAutoClosedTrades; + parityMaxMismatchNotionalUsd = Math.max(parityMaxMismatchNotionalUsd, reconResult.parityMaxMismatchNotionalUsd); + parityTotalMismatchNotionalUsd += reconResult.parityTotalMismatchNotionalUsd; + integrityWatchdogTriggered = integrityWatchdogTriggered || reconResult.integrityWatchdogTriggered; + } + + if (mismatchCount > 0 || missingFromExchange > 0 || missingInDb > 0) { + logger.warn(`[Reconcile] Parity mismatches: ${mismatchCount}, missing-from-exchange: ${missingFromExchange}, missing-in-db: ${missingInDb}`); + } + if (noGoTrades > 0) { + const noGoReasonSummary = Object.fromEntries( + Array.from(noGoReasonCounts.entries()) + .sort((a, b) => b[1] - a[1]) + ); + logger.warn(`[Reconcile] Integrity NO_GO backlog: ${noGoTrades} trade lifecycle rows require manual evidence review. reasons=${JSON.stringify(noGoReasonSummary)}`); + } + if (parityMismatchTrades > 0 || parityAutoClosedTrades > 0 || parityQuarantinedTrades > 0) { + logger.warn(`[Reconcile] Parity heartbeat: mismatched_trades=${parityMismatchTrades}, auto_closed=${parityAutoClosedTrades}, quarantined=${parityQuarantinedTrades}, max_notional=$${parityMaxMismatchNotionalUsd.toFixed(2)}, total_notional=$${parityTotalMismatchNotionalUsd.toFixed(2)}`); + } + apiServer.updateRuntimeHealth({ + staleOrderBacklog: staleBacklog.length, + parityMismatchCount: mismatchCount, + exchangeConnectivity: failedSyncs > 0 ? 'degraded' : 'healthy', + reconciliationMismatchCount: mismatchCount, + reconciliationMissingFromExchange: missingFromExchange, + reconciliationMissingInDb: missingInDb, + reconciliationNoGoTrades: noGoTrades, + reconciliationParityMismatchTrades: parityMismatchTrades, + reconciliationParityQuarantinedTrades: parityQuarantinedTrades, + reconciliationParityAutoClosedTrades: parityAutoClosedTrades, + reconciliationParityMaxMismatchNotionalUsd: parityMaxMismatchNotionalUsd, + reconciliationParityTotalMismatchNotionalUsd: Number(parityTotalMismatchNotionalUsd.toFixed(2)), + reconciliationIntegrityWatchdogTriggered: integrityWatchdogTriggered, + reconciliationFailedProfiles: failedProfiles + }); + } catch (err: any) { + success = false; + logger.error(`[Reconcile] Failed reconciliation cycle: ${err.message}`); + apiServer.updateRuntimeHealth({ + exchangeConnectivity: 'degraded' + }); + } finally { + const completedAt = Date.now(); + const durationMs = completedAt - startedAt; + apiServer.updateRuntimeHealth({ + reconciliationRunning: false, + reconciliationLastDurationMs: durationMs + }); + observabilityService.recordReconciliationLoop(durationMs, mismatchCount, missingFromExchange, missingInDb); + healthTracker.recordReconciliationLoop(success, { + mismatchCount, + missingFromExchange, + missingInDb, + noGoTrades, + noGoReasonCounts: Object.fromEntries(noGoReasonCounts.entries()), + noGoSamples, + integrityWatchdogTriggered + }); + reconciliationWatchdogAutoResumeService.evaluateCycle({ + success, + mismatchCount, + missingFromExchange, + missingInDb, + noGoTrades, + parityMismatchTrades, + parityQuarantinedTrades, + failedProfiles, + integrityWatchdogTriggered + }); + apiServer.publishHealthSnapshot({ broadcast: true }); + isReconciliationRunning = false; + } + }; + await reconcileAllProfiles(); + setInterval(reconcileAllProfiles, config.MONITOR_INTERVAL_MS); + + // --- 2. Periodic Pulse Alert (Every 4 Hours) --- + const FOUR_HOURS = 4 * 60 * 60 * 1000; + setInterval(async () => { + if (config.ENABLE_PULSE_ALERTS) { + logger.info('[System] Sending periodic 4-hour pulse alert...'); + + for (const [symbol, state] of assetState.entries()) { + // Formatting: + // 🕒 *Status: BTC/USD* + // Signal: BUY | Price: 90000 | ... + const pulseMessage = `🕒 *4-Hour Status: ${symbol}* 🕒\nCurrent Signal: ${state.signal}\nPrice: ${state.price}\nEMA-20: ${state.ema.toFixed(2)}\nRSI-14: ${state.rsi.toFixed(2)}\nStatus: Monitoring Active ✅`; + await notifier.sendAlert(pulseMessage); + // Tiny delay to avoid spamming webhook if many assets + await new Promise(r => setTimeout(r, 1000)); + } + } + }, FOUR_HOURS); + + logger.info(`[System] Periodic alerts scheduled for every 4 hours.`); +} + +main().catch(err => { + logger.error('Critical Error:', err); + process.exit(1); +}); diff --git a/backend/src/scripts/README.md b/backend/src/scripts/README.md new file mode 100644 index 0000000..7e3b457 --- /dev/null +++ b/backend/src/scripts/README.md @@ -0,0 +1,55 @@ +# Utility Scripts + +This directory contains utility scripts for maintenance and troubleshooting. + +## Available Scripts + +### cleanupStaleOrders.ts + +**Purpose:** Manually clean up very old stale orders (>24 hours) by marking them as 'unknown'. + +**When to use:** +- After a prolonged bot outage +- When you have many old pending_new orders +- As part of database maintenance + +**How to run:** +```bash +npm run cleanup-stale-orders +``` + +**What it does:** +1. Queries database for orders in `pending_new` status older than 24 hours +2. Marks each order as `unknown` status +3. Logs summary of how many orders were updated + +**Example output:** +``` +[Cleanup] Starting stale order cleanup... +[Cleanup] Found 15 orders older than 24 hours in pending_new status +[Cleanup] Marking order abc123 as 'unknown' (age: 48h, symbol: BTC/USDT) +[Cleanup] Marking order def456 as 'unknown' (age: 36h, symbol: ETH/USDT) +... +[Cleanup] ✅ Cleanup complete! Updated 15 stale orders to 'unknown' status +``` + +**Note:** This is a one-time operation. For ongoing sync, use the automatic OrderStatusSyncService (runs every 5 minutes). + +## Adding New Scripts + +To add a new utility script: + +1. Create a new `.ts` file in this directory +2. Add the script entry to `package.json`: + ```json + "scripts": { + "your-script": "tsx src/scripts/yourScript.ts" + } + ``` +3. Document it in this README + +## Related Documentation + +- [Order Status Sync Documentation](../docs/ORDER_STATUS_SYNC.md) +- [Implementation Summary](../IMPLEMENTATION_SUMMARY.md) +- [Quick Reference](../ORDER_STATUS_SYNC_QUICK_REF.md) diff --git a/backend/src/scripts/backfillTradeIds.ts b/backend/src/scripts/backfillTradeIds.ts new file mode 100644 index 0000000..e509253 --- /dev/null +++ b/backend/src/scripts/backfillTradeIds.ts @@ -0,0 +1,324 @@ +import { createClient, SupabaseClient } from '@supabase/supabase-js'; +import { config } from '../config/index.js'; + +type OrderRow = { + id?: string | null; + order_id?: string | null; + profile_id?: string | null; + user_id?: string | null; + symbol?: string | null; + action?: string | null; + status?: string | null; + trade_id?: string | null; + created_at?: string | null; +}; + +type HistoryRow = { + id?: string | null; + profile_id?: string | null; + user_id?: string | null; + symbol?: string | null; + trade_id?: string | null; + created_at?: string | null; + timestamp?: number | string | null; +}; + +type GroupKey = string; + +const FALLBACK_BATCH_LIMIT = 2000; + +function ensureSupabaseClient(): SupabaseClient { + if (!config.SUPABASE_URL || !config.SUPABASE_KEY) { + throw new Error('SUPABASE_URL / SUPABASE_KEY must be configured'); + } + return createClient(config.SUPABASE_URL, config.SUPABASE_KEY); +} + +function normalizeToken(raw: string | null | undefined, fallback: string): string { + const value = String(raw || '').trim(); + if (!value) return fallback; + return value.replace(/[^A-Za-z0-9]/g, '').slice(0, 24) || fallback; +} + +function buildLegacyTradeId(row: { profile_id?: string | null; user_id?: string | null; symbol?: string | null; created_at?: string | null }, sequence: number): string { + const owner = normalizeToken(row.profile_id || row.user_id, 'global'); + const symbol = normalizeToken(row.symbol, 'asset'); + const created = row.created_at ? new Date(row.created_at).getTime() : Date.now(); + const safeCreated = Number.isFinite(created) && created > 0 ? created : Date.now(); + const seq = String(sequence).padStart(4, '0'); + return `TRD-LEGACY-${owner}-${symbol}-${safeCreated}-${seq}`; +} + +function toGroupKey(profileId?: string | null, userId?: string | null, symbol?: string | null): GroupKey { + return `${profileId || userId || 'global'}::${symbol || 'UNKNOWN'}`; +} + +function parseTs(rawCreatedAt?: string | null, rawTimestamp?: number | string | null): number { + const ts = Number(rawTimestamp); + if (Number.isFinite(ts) && ts > 0) return ts; + const createdAtTs = Date.parse(String(rawCreatedAt || '')); + if (Number.isFinite(createdAtTs) && createdAtTs > 0) return createdAtTs; + return 0; +} + +function normalizeAction(rawAction: string | null | undefined): 'ENTRY' | 'EXIT' | 'UNKNOWN' { + const action = String(rawAction || '').trim().toUpperCase(); + if (action === 'ENTRY' || action === 'EXIT') return action; + return 'UNKNOWN'; +} + +async function fetchOrdersForGroup(client: SupabaseClient, profileId: string | null, userId: string | null, symbol: string | null): Promise { + let query = client + .from('orders') + .select('id,order_id,profile_id,user_id,symbol,action,status,trade_id,created_at') + .eq('symbol', symbol || '') + .order('created_at', { ascending: true }) + .limit(FALLBACK_BATCH_LIMIT); + + if (profileId) { + query = query.eq('profile_id', profileId); + } else if (userId) { + query = query.eq('user_id', userId); + } else { + query = query.is('profile_id', null); + } + + const { data, error } = await query; + if (error) { + throw new Error(`fetchOrdersForGroup failed (${profileId || userId || 'global'}/${symbol}): ${error.message}`); + } + return (data || []) as OrderRow[]; +} + +async function fetchMissingOrders(client: SupabaseClient): Promise { + const { data, error } = await client + .from('orders') + .select('id,order_id,profile_id,user_id,symbol,action,status,trade_id,created_at') + .or('trade_id.is.null,trade_id.eq.') + .order('created_at', { ascending: true }) + .limit(FALLBACK_BATCH_LIMIT); + + if (error) { + throw new Error(`fetchMissingOrders failed: ${error.message}`); + } + return (data || []) as OrderRow[]; +} + +async function fetchMissingHistory(client: SupabaseClient): Promise { + const { data, error } = await client + .from('trade_history') + .select('id,profile_id,user_id,symbol,trade_id,created_at,timestamp') + .or('trade_id.is.null,trade_id.eq.') + .order('created_at', { ascending: true }) + .limit(FALLBACK_BATCH_LIMIT); + + if (error) { + throw new Error(`fetchMissingHistory failed: ${error.message}`); + } + return (data || []) as HistoryRow[]; +} + +async function updateOrderTradeId(client: SupabaseClient, row: OrderRow, tradeId: string): Promise { + const hasPrimaryId = !!row.id; + if (hasPrimaryId) { + const { error } = await client + .from('orders') + .update({ trade_id: tradeId }) + .eq('id', row.id); + if (error) throw new Error(`update order id=${row.id} failed: ${error.message}`); + return; + } + + if (!row.order_id) { + throw new Error('order row missing both id and order_id'); + } + const { error } = await client + .from('orders') + .update({ trade_id: tradeId }) + .eq('order_id', row.order_id); + if (error) throw new Error(`update order order_id=${row.order_id} failed: ${error.message}`); +} + +async function updateHistoryTradeId(client: SupabaseClient, row: HistoryRow, tradeId: string): Promise { + if (!row.id) { + throw new Error('history row missing id'); + } + const { error } = await client + .from('trade_history') + .update({ trade_id: tradeId }) + .eq('id', row.id); + if (error) throw new Error(`update history id=${row.id} failed: ${error.message}`); +} + +function deriveOrderBackfillPlan(rows: OrderRow[]): Map { + const assignments = new Map(); + + const grouped = new Map(); + for (const row of rows) { + const key = toGroupKey(row.profile_id || null, row.user_id || null, row.symbol || null); + const list = grouped.get(key) || []; + list.push(row); + grouped.set(key, list); + } + + for (const groupRows of grouped.values()) { + const orderedRows = [...groupRows].sort((a, b) => parseTs(a.created_at) - parseTs(b.created_at)); + let currentTradeId: string | null = null; + let sequence = 0; + + for (const row of orderedRows) { + const existingTradeId = String(row.trade_id || '').trim(); + if (existingTradeId) { + currentTradeId = existingTradeId; + continue; + } + + const action = normalizeAction(row.action); + let assignedTradeId: string | null = currentTradeId; + if (!assignedTradeId || action === 'ENTRY') { + sequence++; + assignedTradeId = buildLegacyTradeId(row, sequence); + currentTradeId = assignedTradeId; + } + + const key = String(row.id || row.order_id || ''); + if (!key) continue; + if (!assignedTradeId) continue; + assignments.set(key, assignedTradeId); + + const status = String(row.status || '').toLowerCase(); + if (action === 'EXIT' && status === 'filled') { + currentTradeId = null; + } + } + } + + return assignments; +} + +function findNearestTradeIdForHistory(historyRow: HistoryRow, orderRows: OrderRow[]): string | null { + const historyTs = parseTs(historyRow.created_at, historyRow.timestamp); + if (!(historyTs > 0)) return null; + + let candidateTradeId: string | null = null; + let candidateTs = 0; + + for (const order of orderRows) { + const tradeId = String(order.trade_id || '').trim(); + if (!tradeId) continue; + const orderTs = parseTs(order.created_at); + if (!(orderTs > 0)) continue; + if (orderTs <= historyTs && orderTs >= candidateTs) { + candidateTs = orderTs; + candidateTradeId = tradeId; + } + } + + return candidateTradeId; +} + +async function main() { + const apply = process.argv.includes('--apply'); + const client = ensureSupabaseClient(); + + console.log(`[BackfillTradeIds] Starting in ${apply ? 'APPLY' : 'DRY-RUN'} mode`); + + const missingOrders = await fetchMissingOrders(client); + console.log(`[BackfillTradeIds] Missing order trade_id rows: ${missingOrders.length}`); + + const groupCache = new Map(); + const missingOrderGroups = new Map(); + for (const row of missingOrders) { + const profileId = row.profile_id || null; + const userId = row.user_id || null; + const symbol = row.symbol || null; + const key = toGroupKey(profileId, userId, symbol); + missingOrderGroups.set(key, { profileId, userId, symbol }); + } + + for (const [groupKey, group] of missingOrderGroups.entries()) { + const rows = await fetchOrdersForGroup(client, group.profileId, group.userId, group.symbol); + groupCache.set(groupKey, rows); + } + + let orderUpdates = 0; + for (const [groupKey, rows] of groupCache.entries()) { + const plan = deriveOrderBackfillPlan(rows); + const keyToRow = new Map(); + for (const row of rows) { + const key = String(row.id || row.order_id || ''); + if (key) keyToRow.set(key, row); + } + + for (const [rowKey, tradeId] of plan.entries()) { + const row = keyToRow.get(rowKey); + if (!row) continue; + if (!String(row.trade_id || '').trim()) { + if (apply) { + await updateOrderTradeId(client, row, tradeId); + } + orderUpdates++; + } + } + + // Refresh cache with newly assigned trade ids for history matching. + if (apply) { + const groupMeta = missingOrderGroups.get(groupKey)!; + const refreshed = await fetchOrdersForGroup(client, groupMeta.profileId, groupMeta.userId, groupMeta.symbol); + groupCache.set(groupKey, refreshed); + } else { + const updatedRows = rows.map((row) => { + const rowKey = String(row.id || row.order_id || ''); + const planned = plan.get(rowKey); + if (!planned || String(row.trade_id || '').trim()) return row; + return { ...row, trade_id: planned }; + }); + groupCache.set(groupKey, updatedRows); + } + } + + const missingHistory = await fetchMissingHistory(client); + console.log(`[BackfillTradeIds] Missing history trade_id rows: ${missingHistory.length}`); + + let historyUpdates = 0; + let fallbackHistoryIds = 0; + + for (const row of missingHistory) { + const key = toGroupKey(row.profile_id || null, row.user_id || null, row.symbol || null); + let groupOrders = groupCache.get(key); + if (!groupOrders) { + groupOrders = await fetchOrdersForGroup(client, row.profile_id || null, row.user_id || null, row.symbol || null); + groupCache.set(key, groupOrders); + } + + const matchedTradeId = findNearestTradeIdForHistory(row, groupOrders); + const tradeId = matchedTradeId || buildLegacyTradeId( + { + profile_id: row.profile_id || null, + user_id: row.user_id || null, + symbol: row.symbol || null, + created_at: row.created_at || null + }, + 1 + ); + if (!matchedTradeId) fallbackHistoryIds++; + + if (apply) { + await updateHistoryTradeId(client, row, tradeId); + } + historyUpdates++; + } + + console.log(`[BackfillTradeIds] Planned/Applied order updates: ${orderUpdates}`); + console.log(`[BackfillTradeIds] Planned/Applied history updates: ${historyUpdates}`); + console.log(`[BackfillTradeIds] History fallback synthetic IDs: ${fallbackHistoryIds}`); + + if (!apply) { + console.log('[BackfillTradeIds] Dry-run complete. Re-run with --apply to persist changes.'); + } +} + +main().catch((error) => { + console.error('[BackfillTradeIds] Failed:', error); + process.exit(1); +}); diff --git a/backend/src/scripts/cleanupStaleOrders.ts b/backend/src/scripts/cleanupStaleOrders.ts new file mode 100644 index 0000000..550fc67 --- /dev/null +++ b/backend/src/scripts/cleanupStaleOrders.ts @@ -0,0 +1,50 @@ +/** + * Cleanup Utility for Stale Orders + * + * This script marks very old orders (>24 hours) that are still in pending_new status + * as 'unknown' or 'expired' to clean up the database. + * + * Usage: npm run cleanup-stale-orders + */ + +import { supabaseService } from '../services/SupabaseService.js'; +import logger from '../utils/logger.js'; + +async function cleanupStaleOrders() { + logger.info('[Cleanup] Starting stale order cleanup...'); + + try { + // Get orders older than 24 hours in pending_new status + const veryOldOrders = await supabaseService.getStaleOrders(24 * 60); // 24 hours in minutes + + if (!veryOldOrders || veryOldOrders.length === 0) { + logger.info('[Cleanup] No very old stale orders found. Database is clean! ✅'); + return; + } + + logger.info(`[Cleanup] Found ${veryOldOrders.length} orders older than 24 hours in pending_new status`); + + let updated = 0; + for (const order of veryOldOrders) { + const orderId = order.order_id || order.id; + const createdAt = new Date(order.created_at); + const ageHours = Math.floor((Date.now() - createdAt.getTime()) / (1000 * 60 * 60)); + + logger.info(`[Cleanup] Marking order ${orderId} as 'unknown' (age: ${ageHours}h, symbol: ${order.symbol})`); + + await supabaseService.updateOrderStatus?.(orderId, 'unknown'); + updated++; + } + + logger.info(`[Cleanup] ✅ Cleanup complete! Updated ${updated} stale orders to 'unknown' status`); + + } catch (error: any) { + logger.error(`[Cleanup] Error during cleanup: ${error.message}`); + process.exit(1); + } + + process.exit(0); +} + +// Run cleanup +cleanupStaleOrders(); diff --git a/backend/src/scripts/reconcileTradeLifecycle.ts b/backend/src/scripts/reconcileTradeLifecycle.ts new file mode 100644 index 0000000..3c12990 --- /dev/null +++ b/backend/src/scripts/reconcileTradeLifecycle.ts @@ -0,0 +1,216 @@ +import { createClient } from '@supabase/supabase-js'; +import { config } from '../config/index.js'; + +type OrderRow = { + profile_id?: string | null; + user_id?: string | null; + symbol?: string | null; + trade_id?: string | null; + action?: string | null; + qty?: number | string | null; + status?: string | null; + created_at?: string | null; +}; + +type HistoryRow = { + profile_id?: string | null; + user_id?: string | null; + symbol?: string | null; + trade_id?: string | null; + size?: number | string | null; + created_at?: string | null; +}; + +type TradeKey = string; + +type TradeAggregate = { + profileId: string; + userId: string; + symbol: string; + tradeId: string; + entryQty: number; + exitQty: number; + orderCount: number; + hasHistory: boolean; + historyRows: number; +}; + +function parseArg(name: string): string | undefined { + const index = process.argv.findIndex((arg) => arg === `--${name}`); + if (index < 0) return undefined; + return process.argv[index + 1]; +} + +function normalizeNumber(raw: unknown): number { + const value = Number(raw); + return Number.isFinite(value) ? value : 0; +} + +function normalizeKeyToken(raw: string | null | undefined, fallback: string): string { + const value = String(raw || '').trim(); + return value || fallback; +} + +function toTradeKey(profileId: string, tradeId: string): TradeKey { + return `${profileId}::${tradeId}`; +} + +async function main() { + if (!config.SUPABASE_URL || !config.SUPABASE_KEY) { + throw new Error('SUPABASE_URL / SUPABASE_KEY must be configured'); + } + + const sinceDays = Number(parseArg('days') || '14'); + const since = new Date(Date.now() - Math.max(1, sinceDays) * 24 * 60 * 60 * 1000).toISOString(); + const outPath = parseArg('out'); + + const supabase = createClient(config.SUPABASE_URL, config.SUPABASE_KEY); + + const { data: orders, error: orderError } = await supabase + .from('orders') + .select('profile_id,user_id,symbol,trade_id,action,qty,status,created_at') + .in('status', ['filled', 'partially_filled']) + .gte('created_at', since) + .order('created_at', { ascending: true }) + .limit(10000); + if (orderError) { + throw new Error(`Failed to fetch orders: ${orderError.message}`); + } + + const { data: history, error: historyError } = await supabase + .from('trade_history') + .select('profile_id,user_id,symbol,trade_id,size,created_at') + .gte('created_at', since) + .order('created_at', { ascending: true }) + .limit(10000); + if (historyError) { + throw new Error(`Failed to fetch trade_history: ${historyError.message}`); + } + + const orderRows = (orders || []) as OrderRow[]; + const historyRows = (history || []) as HistoryRow[]; + + const missingTradeIdOrders = orderRows.filter((row) => !String(row.trade_id || '').trim()); + const missingTradeIdHistory = historyRows.filter((row) => !String(row.trade_id || '').trim()); + + const aggregates = new Map(); + const ensureAggregate = (profileId: string, userId: string, symbol: string, tradeId: string): TradeAggregate => { + const key = toTradeKey(profileId, tradeId); + let aggregate = aggregates.get(key); + if (!aggregate) { + aggregate = { + profileId, + userId, + symbol, + tradeId, + entryQty: 0, + exitQty: 0, + orderCount: 0, + hasHistory: false, + historyRows: 0 + }; + aggregates.set(key, aggregate); + } + return aggregate; + }; + + for (const row of orderRows) { + const tradeId = String(row.trade_id || '').trim(); + if (!tradeId) continue; + + const profileId = normalizeKeyToken(row.profile_id, normalizeKeyToken(row.user_id, 'global')); + const userId = normalizeKeyToken(row.user_id, 'global'); + const symbol = normalizeKeyToken(row.symbol, 'UNKNOWN'); + const action = String(row.action || '').trim().toUpperCase(); + const qty = Math.abs(normalizeNumber(row.qty)); + + const aggregate = ensureAggregate(profileId, userId, symbol, tradeId); + aggregate.orderCount += 1; + if (action === 'ENTRY') { + aggregate.entryQty += qty; + } else if (action === 'EXIT') { + aggregate.exitQty += qty; + } + } + + const historyOnlyTradeKeys: string[] = []; + for (const row of historyRows) { + const tradeId = String(row.trade_id || '').trim(); + if (!tradeId) continue; + + const profileId = normalizeKeyToken(row.profile_id, normalizeKeyToken(row.user_id, 'global')); + const userId = normalizeKeyToken(row.user_id, 'global'); + const symbol = normalizeKeyToken(row.symbol, 'UNKNOWN'); + const key = toTradeKey(profileId, tradeId); + + const aggregate = aggregates.get(key); + if (!aggregate) { + historyOnlyTradeKeys.push(key); + continue; + } + + aggregate.hasHistory = true; + aggregate.historyRows += 1; + aggregate.userId = userId; + aggregate.symbol = symbol; + } + + const exitExceedsEntry: TradeAggregate[] = []; + const closedWithoutHistory: TradeAggregate[] = []; + const openWithoutHistory: TradeAggregate[] = []; + + for (const aggregate of aggregates.values()) { + const netQty = aggregate.entryQty - aggregate.exitQty; + if (netQty < -1e-8) { + exitExceedsEntry.push(aggregate); + continue; + } + + if (!aggregate.hasHistory && netQty <= 1e-8) { + closedWithoutHistory.push(aggregate); + continue; + } + + if (!aggregate.hasHistory && netQty > 1e-8) { + openWithoutHistory.push(aggregate); + } + } + + const report = { + generatedAt: new Date().toISOString(), + since, + totals: { + orders: orderRows.length, + history: historyRows.length, + tradesTracked: aggregates.size + }, + anomalies: { + missingTradeIdOrders: missingTradeIdOrders.length, + missingTradeIdHistory: missingTradeIdHistory.length, + exitExceedsEntry: exitExceedsEntry.length, + closedWithoutHistory: closedWithoutHistory.length, + openWithoutHistory: openWithoutHistory.length, + historyWithoutOrders: historyOnlyTradeKeys.length + }, + samples: { + exitExceedsEntry: exitExceedsEntry.slice(0, 20), + closedWithoutHistory: closedWithoutHistory.slice(0, 20), + openWithoutHistory: openWithoutHistory.slice(0, 20), + historyWithoutOrders: historyOnlyTradeKeys.slice(0, 20) + } + }; + + const reportJson = JSON.stringify(report, null, 2); + if (outPath) { + const fs = await import('node:fs/promises'); + await fs.writeFile(outPath, reportJson, 'utf8'); + console.log(`[LifecycleReconcile] Report written to ${outPath}`); + } else { + console.log(reportJson); + } +} + +main().catch((error) => { + console.error('[LifecycleReconcile] Failed:', error); + process.exit(1); +}); diff --git a/backend/src/scripts/revertExpiredOrders.ts b/backend/src/scripts/revertExpiredOrders.ts new file mode 100644 index 0000000..2680c14 --- /dev/null +++ b/backend/src/scripts/revertExpiredOrders.ts @@ -0,0 +1,50 @@ +/** + * Revert Expired/Unknown Orders Script + * + * Reverts orders marked as 'expired' or 'unknown' back to 'pending_new'. + * This is useful if you want to retry syncing them or prefer them showing as 'stale' + * rather than 'expired'. + * + * Usage: npm run revert-expired-orders + */ + +import { supabaseService } from '../services/SupabaseService.js'; +import logger from '../utils/logger.js'; + +async function revertExpiredOrders() { + logger.info('[Revert] Starting to revert expired/unknown orders...'); + + try { + // Find orders with status 'expired' or 'unknown' + const expiredOrders = await supabaseService.getExpiredOrUnknownOrders(); + + if (!expiredOrders || expiredOrders.length === 0) { + logger.info('[Revert] No expired or unknown orders found. Nothing to do! ✅'); + process.exit(0); + } + + logger.info(`[Revert] Found ${expiredOrders.length} orders marked as 'expired' or 'unknown'`); + + let updated = 0; + for (const order of expiredOrders) { + const orderId = order.order_id || order.id; + + logger.info(`[Revert] Reverting order ${orderId} (${order.symbol}) to 'pending_new'`); + + // Use updateOrderStatus to reset status + // Note: filledAt is undefined since we are resetting to pending + await supabaseService.updateOrderStatus?.(orderId, 'pending_new'); + updated++; + } + + logger.info(`[Revert] ✅ Successfully reverted ${updated} orders to 'pending_new'.`); + + } catch (error: any) { + logger.error(`[Revert] Error: ${error.message}`); + process.exit(1); + } + + process.exit(0); +} + +revertExpiredOrders(); diff --git a/backend/src/scripts/verifyWebsocketContract.ts b/backend/src/scripts/verifyWebsocketContract.ts new file mode 100644 index 0000000..056ae23 --- /dev/null +++ b/backend/src/scripts/verifyWebsocketContract.ts @@ -0,0 +1,210 @@ +import assert from 'node:assert/strict'; +import type { BotState } from '../services/apiServer.js'; + +const ALLOWED_SYMBOL_SIGNALS = new Set(['BUY', 'SELL', 'NONE', 'MIXED']); +const LIFECYCLE_ACTIONS = new Set(['ENTRY', 'EXIT']); +const BOT_ORDER_SOURCES = new Set(['BOT', undefined]); + +const hasValue = (value: unknown): boolean => value !== null && value !== undefined; + +export function validateWebsocketContract(state: BotState): string[] { + const errors: string[] = []; + const entryOrdersByTrade = new Map(); + + for (const [symbol, snapshot] of Object.entries(state.symbols || {})) { + if (!ALLOWED_SYMBOL_SIGNALS.has(String(snapshot.signal || ''))) { + errors.push(`[symbols.${symbol}.signal] Invalid signal: ${snapshot.signal}`); + } + + for (const [profileId, profileSignal] of Object.entries(snapshot.profileSignals || {})) { + if (!ALLOWED_SYMBOL_SIGNALS.has(String(profileSignal.signal || ''))) { + errors.push(`[symbols.${symbol}.profileSignals.${profileId}.signal] Invalid profile signal: ${profileSignal.signal}`); + } + if (typeof profileSignal.passed !== 'boolean') { + errors.push(`[symbols.${symbol}.profileSignals.${profileId}.passed] Expected boolean.`); + } + } + } + + for (const order of state.orders || []) { + const action = (order.action || '').toUpperCase(); + + if (action && !LIFECYCLE_ACTIONS.has(action)) { + errors.push(`[orders.${order.id}.action] Invalid lifecycle action: ${order.action}`); + } + + if (action === 'ENTRY' && order.trade_id) { + const existing = entryOrdersByTrade.get(order.trade_id); + if (!existing || order.timestamp < existing.timestamp) { + entryOrdersByTrade.set(order.trade_id, order); + } + } + + if (BOT_ORDER_SOURCES.has(order.source) && action && !order.trade_id) { + errors.push(`[orders.${order.id}] BOT lifecycle order missing trade_id.`); + } + + if (action === 'EXIT' && !order.trade_id) { + errors.push(`[orders.${order.id}] EXIT order missing trade_id.`); + } + } + + for (const position of state.positions || []) { + if (position.profileId && !position.tradeId) { + errors.push(`[positions.${position.id}] Profile position missing tradeId.`); + continue; + } + + if (position.tradeId) { + const entry = entryOrdersByTrade.get(position.tradeId); + if (!entry) { + errors.push(`[positions.${position.id}] tradeId ${position.tradeId} has no ENTRY order in payload.`); + continue; + } + if (entry.symbol !== position.symbol) { + errors.push(`[positions.${position.id}] tradeId ${position.tradeId} symbol mismatch (${entry.symbol} != ${position.symbol}).`); + } + if ((entry.profileId || 'global') !== (position.profileId || 'global')) { + errors.push(`[positions.${position.id}] tradeId ${position.tradeId} profile mismatch.`); + } + } + } + + for (const trade of state.history || []) { + if ((trade.source || 'BOT') === 'BOT' && !trade.trade_id) { + errors.push(`[history.${trade.symbol}.${trade.timestamp}] BOT history row missing trade_id.`); + } + } + + return errors; +} + +function buildFixture(): BotState { + return { + symbols: { + 'BTC/USDT': { + price: 70100, + change24h: 1.2, + changeToday: 0.5, + session: 'NY', + volatility: 'Medium', + signal: 'BUY', + signalTime: Date.now(), + tradingMode: 'Paper', + activePosition: null, + priceHistory: [{ timestamp: Date.now(), price: 70100 }], + rules: { + TrendBiasRule: { passed: true, reason: 'Aligned' } + }, + profileSignals: { + 'profile-a': { + profileName: 'Scalper', + signal: 'BUY', + passed: true, + reason: 'Rules aligned' + } + }, + indicators: { + ema20_1h: 70000 + } + } + }, + alerts: [], + positions: [ + { + id: 'pos-1', + symbol: 'BTC/USDT', + side: 'BUY', + size: 0.01, + entryPrice: 70000, + currentPrice: 70100, + stopLoss: 69500, + takeProfit: 71000, + unrealizedPnl: 1, + unrealizedPnlPercent: 0.14, + marketValue: 701, + profileId: 'profile-a', + profileName: 'Scalper', + tradeId: 'TRD-1700000000-profile-a-BTCUSDT' + } + ], + orders: [ + { + id: 'ord-entry-1', + symbol: 'BTC/USDT', + type: 'market', + side: 'BUY', + qty: 0.01, + price: 70000, + status: 'filled', + timestamp: Date.now() - 60_000, + profileId: 'profile-a', + trade_id: 'TRD-1700000000-profile-a-BTCUSDT', + action: 'ENTRY', + source: 'BOT' + } + ], + history: [ + { + symbol: 'ETH/USDT', + side: 'SELL', + entryPrice: 2200, + exitPrice: 2100, + size: 0.2, + pnl: 20, + pnlPercent: 4.5, + reason: 'TP Hit', + timestamp: Date.now() - 3_600_000, + profileId: 'profile-a', + trade_id: 'TRD-1699999000-profile-a-ETHUSDT', + source: 'BOT' + } + ], + settings: { + executionMode: 'Pro', + riskPerTrade: 1, + totalCapital: 10000, + maxOpenTrades: 3, + isAlgoEnabled: true, + enabledRules: ['TrendBiasRule'] + }, + health: { + tradingLoopHealthy: true, + tradingLoopLastRun: Date.now(), + monitorLoopHealthy: true, + monitorLoopLastRun: Date.now(), + orderSyncHealthy: true, + orderSyncLastRun: Date.now(), + lockContentionCount: 0, + reconciliationLoopHealthy: true, + reconciliationLoopLastRun: Date.now(), + reconciliationMismatchCount: 0, + reconciliationMissingFromExchange: 0, + reconciliationMissingInDb: 0, + reconciliationNoGoTrades: 0, + reconciliationNoGoReasonCounts: {}, + reconciliationNoGoSamples: [], + reconciliationIntegrityWatchdogTriggered: false, + reconciliationLockContentionCount: 0, + tradingControl: { + mode: 'RUNNING', + lastChangedBy: 'system', + lastChangedAt: Date.now() + } + }, + uptime: 120_000, + accountSnapshot: null, + orderFailures: [], + operationalEvents: [] + }; +} + +function main() { + const fixture = buildFixture(); + const errors = validateWebsocketContract(fixture); + assert.equal(errors.length, 0, `Websocket contract violations:\n${errors.join('\n')}`); + assert.ok(hasValue(fixture.symbols['BTC/USDT'].profileSignals), 'Fixture must include profileSignals payload.'); + console.log('[PASS] Websocket bot-state contract and lifecycle consistency checks passed.'); +} + +main(); diff --git a/backend/src/services/AutoTrader.ts b/backend/src/services/AutoTrader.ts new file mode 100644 index 0000000..597c6e2 --- /dev/null +++ b/backend/src/services/AutoTrader.ts @@ -0,0 +1,365 @@ +import { config } from '../config/index.js'; +import { MarketContext, RuleResult, SignalDirection } from '../strategies/rules/types.js'; +import { RiskEngine } from './riskEngine.js'; +import { TradeExecutor } from './TradeExecutor.js'; +import logger from '../utils/logger.js'; +import { SymbolMapper } from '../utils/symbolMapper.js'; +import { IExchangeConnector } from '../connectors/types.js'; +import { supabaseService } from './SupabaseService.js'; +import { healthTracker } from './healthTracker.js'; + +export interface PortfolioGuardInput { + symbol: string; + profileSettings?: any; + signal?: SignalDirection; +} + +export interface PortfolioGuardResult { + allowed: boolean; + reason?: string; +} + +export type AutoTraderExecutionStatus = 'EXECUTED' | 'BLOCKED' | 'SKIPPED'; + +export interface AutoTraderExecutionOutcome { + status: AutoTraderExecutionStatus; + code: string; + reason: string; + orderId?: string; +} + +export class AutoTrader { + private riskEngine: RiskEngine; + + constructor( + private executor: TradeExecutor, + private exchange: IExchangeConnector, // Needed for pre-check + private portfolioGuard?: (input: PortfolioGuardInput) => Promise | PortfolioGuardResult + ) { + this.riskEngine = new RiskEngine(); + } + + private outcome( + status: AutoTraderExecutionStatus, + code: string, + reason: string, + orderId?: string + ): AutoTraderExecutionOutcome { + return { status, code, reason, orderId }; + } + + private evaluateProfileSymbolScope(symbol: string, profileSettings?: any): { allowed: boolean; reason?: string } { + if (!profileSettings || profileSettings.symbols === undefined || profileSettings.symbols === null) { + return { allowed: true }; + } + + const rawSymbols = Array.isArray(profileSettings.symbols) + ? profileSettings.symbols + : String(profileSettings.symbols).split(','); + const allowedSymbols = rawSymbols + .map((value: string) => String(value).trim()) + .filter(Boolean); + + if (!allowedSymbols.length) { + return { allowed: false, reason: 'Profile watchlist is empty.' }; + } + + const executionSymbol = SymbolMapper.toExecutionScopeSymbol(symbol, config.EXECUTION_PROVIDER); + const normalizedAllowed = allowedSymbols.map((item: string) => + SymbolMapper.toExecutionScopeSymbol(item, config.EXECUTION_PROVIDER) + ); + if (normalizedAllowed.includes(executionSymbol)) { + return { allowed: true }; + } + + return { + allowed: false, + reason: `${symbol} is outside profile watchlist (${allowedSymbols.join(', ')})` + }; + } + + public async handleSignal( + symbol: string, + result: RuleResult, + context: MarketContext, + profileSettings?: any + ): Promise { + if (!config.ENABLE_TRADING) { + logger.info(`[AutoTrader] Trading disabled. Alert-only mode for ${symbol}.`); + return this.outcome('SKIPPED', 'trading_disabled', 'Trading is disabled globally.'); + } + + // --- Profile Level Filtering --- + const scopeCheck = this.evaluateProfileSymbolScope(symbol, profileSettings); + if (!scopeCheck.allowed) { + return this.outcome( + 'BLOCKED', + 'symbol_not_in_profile_scope', + scopeCheck.reason || `Symbol ${symbol} not allowed by profile watchlist.` + ); + } + + const activePositions = this.executor.getActivePositions(symbol); + const hasActivePositions = activePositions.length > 0; + + // --- EXIT LOGIC --- + if (hasActivePositions) { + const hasOpposite = activePositions.some((pos) => + (pos.side === SignalDirection.BUY && result.signal === SignalDirection.SELL) + || (pos.side === SignalDirection.SELL && result.signal === SignalDirection.BUY) + ); + + if (hasOpposite) { + for (const pos of activePositions) { + await this.executor.closePosition(symbol, 'Strategy Signal Flip', pos.tradeId); + } + return this.outcome( + 'EXECUTED', + 'exit_on_signal_flip', + `Closed ${activePositions.length} position(s) on signal flip.` + ); + } + + const profitExitThreshold = profileSettings?.strategy_config?.execution?.profitExitPercent + ?? config.PROFIT_EXIT_PERCENT; + let closedForNeutral = 0; + for (const activePos of activePositions) { + const profitPercent = ((context.currentPrice - activePos.entryPrice) / activePos.entryPrice) * 100 * (activePos.side === SignalDirection.BUY ? 1 : -1); + if (profitPercent >= profitExitThreshold && result.signal === SignalDirection.NONE) { + await this.executor.closePosition(symbol, `Trend Neutralized (>${profitExitThreshold}% profit)`, activePos.tradeId); + closedForNeutral += 1; + } + } + if (closedForNeutral > 0) { + return this.outcome( + 'EXECUTED', + 'exit_on_neutral_profit', + `Closed ${closedForNeutral} position(s) on neutralized trend profit exit.` + ); + } + + for (const activePos of activePositions) { + if (activePos.side === SignalDirection.BUY) { + activePos.peakPrice = Math.max(activePos.peakPrice, context.currentPrice); + } else { + activePos.peakPrice = Math.min(activePos.peakPrice, context.currentPrice); + } + } + + const allowPyramiding = profileSettings?.strategy_config?.execution?.allowPyramiding !== false; + if (!allowPyramiding) { + return this.outcome( + 'SKIPPED', + 'pyramiding_disabled', + 'Active position exists and pyramiding is disabled.' + ); + } + + const hasSameDirectionExposure = activePositions.some((pos) => pos.side === result.signal); + if (!hasSameDirectionExposure) { + return this.outcome( + 'SKIPPED', + 'active_position_no_same_direction', + 'Active position exists in opposite direction; waiting for flip/exit handling.' + ); + } + } + + // --- ENTRY LOGIC --- + if (result.signal === SignalDirection.NONE || !result.passed) { + return this.outcome( + 'SKIPPED', + 'no_actionable_signal', + result.reason || 'No actionable entry signal.' + ); + } + + if (healthTracker.isPaused()) { + logger.info(`[AutoTrader] Entry blocked for ${symbol}: bot is paused by control plane.`); + return this.outcome('BLOCKED', 'trading_paused', 'Trading is paused by control plane.'); + } + + const entryMode = this.resolveEntryMode(profileSettings); + if (entryMode === 'long_only' && result.signal === SignalDirection.SELL) { + logger.info(`[AutoTrader] Entry blocked for ${symbol}: profile is long_only and signal is SELL.`); + return this.outcome('BLOCKED', 'long_only_sell_block', 'Profile is long_only; SELL entries are blocked.'); + } + + if (this.portfolioGuard) { + const portfolioCheck = await this.portfolioGuard({ + symbol, + profileSettings, + signal: result.signal + }); + if (!portfolioCheck.allowed) { + logger.info(`[AutoTrader] Portfolio guard blocked ${symbol}: ${portfolioCheck.reason || 'guard rejection'}`); + return this.outcome( + 'BLOCKED', + 'portfolio_guard_blocked', + portfolioCheck.reason || 'Portfolio guard rejected entry.' + ); + } + } + + // 1. Checks + // Max open trades: per-profile > global config + const maxOpenTrades = profileSettings?.strategy_config?.riskLimits?.maxOpenTrades + ?? config.MAX_OPEN_TRADES; + if (this.executor.getOpenPositionCount() >= maxOpenTrades) { + logger.info(`[AutoTrader] Max open trades reached (${maxOpenTrades}).`); + return this.outcome( + 'BLOCKED', + 'max_open_trades_reached', + `Max open trades reached (${maxOpenTrades}).` + ); + } + + // Cooldown: per-profile > global config + const cooldownMs = profileSettings?.strategy_config?.execution?.cooldownMinutes + ? profileSettings.strategy_config.execution.cooldownMinutes * 60_000 + : config.COOLDOWN_MS; + if (this.executor.checkCooldown(symbol, cooldownMs)) { + return this.outcome('BLOCKED', 'cooldown_active', 'Symbol is in cooldown window.'); + } + + const riskLimitGuard = await this.checkRuntimeRiskLimits(profileSettings); + if (!riskLimitGuard.allowed) { + logger.warn(`[AutoTrader] Runtime risk guard blocked ${symbol}: ${riskLimitGuard.reason || 'risk limit exceeded'}`); + return this.outcome( + 'BLOCKED', + 'runtime_risk_limit_blocked', + riskLimitGuard.reason || 'Runtime risk limit exceeded.' + ); + } + + // 2. Safety Lock (Exchange Check) + const profileId = this.executor.getProfileId(); + const isDedicatedProfileScope = !!profileId + && profileId !== 'global' + && !profileId.startsWith('default-'); + try { + const tradeSymbol = SymbolMapper.toTradeSymbol(symbol, config.EXECUTION_PROVIDER); + const position = await this.exchange.getPosition(tradeSymbol); + if (position && !isDedicatedProfileScope) { + logger.warn(`[AutoTrader] Existing position found on exchange for ${symbol}. Syncing.`); + await this.executor.syncPositions([symbol]); + return this.outcome( + 'BLOCKED', + 'exchange_position_already_open', + 'Exchange already has an open position; synced local state and skipped entry.' + ); + } + if (position && isDedicatedProfileScope) { + logger.info(`[AutoTrader] Exchange already has ${symbol}. Continuing with profile-scoped virtual sub-position flow for ${profileId}.`); + } + } catch (e) { + logger.error(`[AutoTrader] Position check failed for ${symbol}. Skipping entry to avoid duplicate exposure.`, e); + return this.outcome( + 'BLOCKED', + 'exchange_position_check_failed', + 'Exchange position check failed; entry skipped to avoid duplicate exposure.' + ); + } + + // 3. Risk Calculation (PASS PROFILE OVERRIDES + estimated available capital) + const allocatedCapital = profileSettings?.allocated_capital || config.TOTAL_CAPITAL; + const committedCapital = Array.from(this.executor.getAllPositions().values()) + .reduce((sum, pos) => sum + (pos.size * pos.entryPrice), 0); + const availableCapital = Math.max(0, allocatedCapital - committedCapital); + + const riskProfile = await this.riskEngine.calculateRiskProfile( + symbol, + result.signal as SignalDirection, + context, + profileSettings, + availableCapital + ); + if (!riskProfile) { + return this.outcome('BLOCKED', 'risk_profile_unavailable', 'Risk profile calculation returned no executable sizing.'); + } + + // 4. Execute + const execution = await this.executor.openPosition( + symbol, + riskProfile.action, + riskProfile.positionSize, + 'market', + context.currentPrice, + riskProfile.stopLoss, + riskProfile.takeProfit, + profileSettings?.user_id // Ensure order is attributed to the user/profile owner + ); + if (!execution.success) { + return this.outcome( + 'BLOCKED', + 'executor_rejected_entry', + execution.error || 'Trade executor rejected entry.' + ); + } + + return this.outcome( + 'EXECUTED', + 'entry_submitted', + 'Entry submitted to execution engine.', + execution.orderId + ); + } + + private resolveEntryMode(profileSettings?: any): 'both' | 'long_only' { + const execution = profileSettings?.strategy_config?.execution; + const rawEntryMode = String( + execution?.entryMode ?? (execution?.longOnly ? 'long_only' : 'both') + ).toLowerCase(); + if (rawEntryMode === 'long_only' || rawEntryMode === 'longonly' || rawEntryMode === 'buy_only') { + return 'long_only'; + } + return 'both'; + } + + private async checkRuntimeRiskLimits(profileSettings?: any): Promise { + const profileId = this.executor.getProfileId(); + if (!profileId || profileId === 'global' || profileId.startsWith('default-')) { + return { allowed: true }; + } + + const riskLimits = profileSettings?.strategy_config?.riskLimits; + if (!riskLimits) { + return { allowed: true }; + } + + const maxDailyLossUsd = Number(riskLimits.maxDailyLossUsd); + if (Number.isFinite(maxDailyLossUsd) && maxDailyLossUsd > 0) { + const dailyLossUsd = await supabaseService.getProfileDailyLossUsd(profileId); + if (dailyLossUsd >= maxDailyLossUsd) { + return { + allowed: false, + reason: `maxDailyLossUsd reached (${dailyLossUsd.toFixed(2)} / ${maxDailyLossUsd.toFixed(2)})` + }; + } + } + + const dailyProfitTargetUsd = Number(riskLimits.dailyProfitTargetUsd); + if (Number.isFinite(dailyProfitTargetUsd) && dailyProfitTargetUsd > 0) { + const dailyNetPnl = await supabaseService.getProfileDailyNetPnlUsd(profileId); + if (dailyNetPnl >= dailyProfitTargetUsd) { + return { + allowed: false, + reason: `Daily profit target reached ($${dailyNetPnl.toFixed(2)} / $${dailyProfitTargetUsd.toFixed(2)})` + }; + } + } + + const maxConsecutiveLosses = Number(riskLimits.maxConsecutiveLosses); + if (Number.isFinite(maxConsecutiveLosses) && maxConsecutiveLosses > 0) { + const consecutiveLosses = await supabaseService.getProfileConsecutiveLosses(profileId); + if (consecutiveLosses >= maxConsecutiveLosses) { + return { + allowed: false, + reason: `maxConsecutiveLosses reached (${consecutiveLosses} / ${maxConsecutiveLosses})` + }; + } + } + + return { allowed: true }; + } +} diff --git a/backend/src/services/CapitalLedger.ts b/backend/src/services/CapitalLedger.ts new file mode 100644 index 0000000..ba36190 --- /dev/null +++ b/backend/src/services/CapitalLedger.ts @@ -0,0 +1,216 @@ +import logger from '../utils/logger.js'; +import { config } from '../config/index.js'; +import { supabaseService } from './SupabaseService.js'; + +export interface CapitalLedgerRecord { + profile_id: string; + allocated_capital: number; + reserved_for_orders: number; + reserved_for_positions: number; + realized_pnl: number; + updated_at: string; +} + +const toNumeric = (value: unknown): number => { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return 0; + return numeric; +}; + +export class CapitalLedger { + private locks = new Map>(); + + private async withLock(profileId: string, work: () => Promise): Promise { + const key = profileId || 'global'; + const previous = this.locks.get(key) ?? Promise.resolve(); + const next = previous.then(() => work()).finally(() => { + if (this.locks.get(key) === next) { + this.locks.delete(key); + } + }); + this.locks.set(key, next); + return next; + } + + private async ensureLedger(profileId: string, allocatedCapital?: number): Promise { + const client = supabaseService.getClient(); + try { + if (!client) { + logger.error(`[CapitalLedger] ensureLedger aborted for ${profileId}: Supabase client unavailable (fail-closed).`); + return null; + } + const profileCapital = await supabaseService.getProfileCapital(profileId); + const allocation = toNumeric(allocatedCapital ?? profileCapital?.allocatedCapital ?? config.TOTAL_CAPITAL); + + const { data, error } = await client + .from('capital_ledgers') + .upsert({ + profile_id: profileId, + allocated_capital: allocation + }, { onConflict: 'profile_id' }) + .select('*') + .maybeSingle(); + + if (error) { + if (this.isRpcNetworkFailure(error)) { + logger.error(`[CapitalLedger] ensureLedger RPC network failure for ${profileId}, aborting ledger mutation (fail-closed): ${error.message}`); + return null; + } + logger.error(`[CapitalLedger] ensureLedger failed: ${error.message}`); + return null; + } + + return data as CapitalLedgerRecord; + } catch (err: any) { + if (this.isRpcNetworkFailure(err)) { + logger.error(`[CapitalLedger] ensureLedger network failure for ${profileId}, aborting ledger mutation (fail-closed): ${err.message}`); + return null; + } + logger.error(`[CapitalLedger] ensureLedger unexpected error: ${err.message}`); + return null; + } + } + + private async rpc(fn: string, args: Record): Promise { + const client = supabaseService.getClient(); + if (!client) { + logger.error(`[CapitalLedger] ${fn} aborted: Supabase client unavailable (fail-closed).`); + return null; + } + try { + const { data, error } = await client.rpc(fn, args); + if (error) { + if (this.isRpcNetworkFailure(error)) { + logger.error(`[CapitalLedger] ${fn} RPC network failure, rejecting mutation (fail-closed): ${error.message}`); + return null; + } + logger.error(`[CapitalLedger] ${fn} failed: ${error.message}`); + return null; + } + return data as T; + } catch (err: any) { + if (this.isRpcNetworkFailure(err)) { + logger.error(`[CapitalLedger] ${fn} network failure, rejecting mutation (fail-closed): ${err.message}`); + return null; + } + logger.error(`[CapitalLedger] ${fn} unexpected error: ${err.message}`); + return null; + } + } + + private async mutate(profileId: string, fn: () => Promise) { + return this.withLock(profileId, async () => { + const ledger = await this.ensureLedger(profileId); + if (!ledger) return null; + return fn(); + }); + } + + public async getLedger(profileId: string): Promise { + const client = supabaseService.getClient(); + if (!client) return null; + const { data, error } = await client + .from('capital_ledgers') + .select('*') + .eq('profile_id', profileId) + .maybeSingle(); + + if (error) { + if (this.isRpcNetworkFailure(error)) { + logger.error(`[CapitalLedger] getLedger RPC network failure for ${profileId}, returning null (fail-closed): ${error.message}`); + return null; + } + logger.error(`[CapitalLedger] getLedger failed: ${error.message}`); + return null; + } + + return data as CapitalLedgerRecord; + } + + public async reserveForOrder(profileId: string, amount: number): Promise { + if (!profileId || amount <= 0) return false; + const result = await this.mutate(profileId, async () => { + return this.rpc('fn_reserve_for_order', { + p_profile: profileId, + p_amount: amount + }); + }); + if (result) return true; + + const ledger = await this.getLedger(profileId); + if (!ledger) return false; + + const available = this.availableCapital(ledger); + if (available + 1e-8 >= amount) { + logger.error( + `[CapitalLedger] reserveForOrder parity mismatch for ${profileId}: RPC rejected amount=${amount.toFixed(8)} despite available=${available.toFixed(8)}. ` + + `This usually indicates fn_reserve_for_order is stale and does not include realized_pnl in its gate.` + ); + } else { + logger.warn( + `[CapitalLedger] reserveForOrder rejected for ${profileId}: requested=${amount.toFixed(8)}, available=${available.toFixed(8)}.` + ); + } + return false; + } + + public async releaseOrderReservation(profileId: string, amount: number): Promise { + if (!profileId || amount <= 0) return; + await this.mutate(profileId, async () => { + return this.rpc('fn_release_order_reservation', { + p_profile: profileId, + p_amount: amount + }); + }); + } + + public async adjustPositionReservation(profileId: string, delta: number): Promise { + if (!profileId || delta === 0) return; + await this.mutate(profileId, async () => { + return this.rpc('fn_adjust_position_reservation', { + p_profile: profileId, + p_delta: delta + }); + }); + } + + public async recordRealizedPnl(profileId: string, delta: number): Promise { + if (!profileId || delta === 0) return; + await this.mutate(profileId, async () => { + return this.rpc('fn_record_realized_pnl', { + p_profile: profileId, + p_delta: delta + }); + }); + } + + public async rebuildLedger(profileId: string, reservedOrders: number, reservedPositions: number): Promise { + if (!profileId) return; + await this.withLock(profileId, async () => { + await this.ensureLedger(profileId); + await this.rpc('fn_rebuild_ledger', { + p_profile: profileId, + p_reserved_orders: reservedOrders, + p_reserved_positions: reservedPositions + }); + }); + } + + public async getAvailableCapital(profileId: string): Promise { + if (!profileId) return null; + const ledger = await this.ensureLedger(profileId); + if (!ledger) return null; + return this.availableCapital(ledger); + } + + public availableCapital(record: CapitalLedgerRecord) { + return record.allocated_capital - record.reserved_for_orders - record.reserved_for_positions + record.realized_pnl; + } + + private isRpcNetworkFailure(error: any): boolean { + const message = String(error?.message || '').toLowerCase(); + return message.includes('fetch failed') || message.includes('network'); + } +} + +export const capitalLedger = new CapitalLedger(); diff --git a/backend/src/services/ManualTrader.ts b/backend/src/services/ManualTrader.ts new file mode 100644 index 0000000..fac190a --- /dev/null +++ b/backend/src/services/ManualTrader.ts @@ -0,0 +1,156 @@ +import type { TradeExecutor } from './TradeExecutor.js'; +import { SignalDirection } from '../strategies/rules/types.js'; +import logger from '../utils/logger.js'; +import { supabaseService } from './SupabaseService.js'; +import { config } from '../config/index.js'; + +const CAPITAL_WAIT_TIMEOUT_MS = 60_000; +const CAPITAL_WAIT_POLL_MS = 3_000; + +export class ManualTrader { + constructor( + private executor: TradeExecutor + ) { } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + private roundDown(value: number, precision: number): number { + const safePrecision = Math.max(0, Math.min(10, precision)); + const factor = Math.pow(10, safePrecision); + return Math.floor(value * factor) / factor; + } + + private getCommittedCapitalUsd(): number { + return Array.from(this.executor.getAllPositions().values()) + .reduce((sum, pos) => sum + Math.abs(Number(pos.entryPrice || 0) * Number(pos.size || 0)), 0); + } + + private async resolveAllocatedCapitalUsd(): Promise { + const profileId = this.executor.getProfileId(); + if (!profileId || profileId === 'global' || profileId.startsWith('default-')) { + return Number(config.TOTAL_CAPITAL || 0); + } + + const profileCapital = await supabaseService.getProfileCapital(profileId); + if (profileCapital?.allocatedCapital && profileCapital.allocatedCapital > 0) { + return profileCapital.allocatedCapital; + } + + return Number(config.TOTAL_CAPITAL || 0); + } + + private async waitForRemainingCapital(allocatedCapital: number): Promise { + const start = Date.now(); + + while (Date.now() - start <= CAPITAL_WAIT_TIMEOUT_MS) { + const committed = this.getCommittedCapitalUsd(); + const remaining = allocatedCapital - committed; + if (remaining > 0) return remaining; + await this.sleep(CAPITAL_WAIT_POLL_MS); + } + + const committed = this.getCommittedCapitalUsd(); + return allocatedCapital - committed; + } + + public async executeRequest( + symbol: string, + side: string, + qty: number, + type: string = 'market', + price?: number, + priceHint?: number, + userId?: string, + sl?: number, + tp?: number + ): Promise<{ success: boolean; orderId?: string; error?: string; adjustedQty?: number; requestedQty?: number; remainingCapitalUsd?: number }> { + const signalSide = (side.toLowerCase() === 'buy') ? SignalDirection.BUY : SignalDirection.SELL; + const requestedQty = Number(qty); + if (!Number.isFinite(requestedQty) || requestedQty <= 0) { + return { success: false, error: 'Invalid quantity' }; + } + + const estimatedPrice = Number(priceHint ?? price); + if (!Number.isFinite(estimatedPrice) || estimatedPrice <= 0) { + return { success: false, error: 'Unable to estimate price for capital guard. Retry with current price.' }; + } + + const allocatedCapital = await this.resolveAllocatedCapitalUsd(); + if (!Number.isFinite(allocatedCapital) || allocatedCapital <= 0) { + return { success: false, error: 'Allocated capital is not configured for this profile.' }; + } + + let remainingCapital = allocatedCapital - this.getCommittedCapitalUsd(); + if (remainingCapital <= 0) { + logger.warn(`[ManualTrader] Capital fully committed for ${this.executor.getProfileId() || this.executor.getUserId()}. Waiting for release...`); + remainingCapital = await this.waitForRemainingCapital(allocatedCapital); + } + + if (remainingCapital <= 0) { + return { + success: false, + error: 'Capital fully committed. Waiting for capital release.', + requestedQty, + remainingCapitalUsd: Number(remainingCapital.toFixed(2)) + }; + } + + const precision = Math.max(0, Math.min(10, Math.floor(Number(config.QUANTITY_PRECISION || 6)))); + const minQty = Number(config.MIN_POSITION_QTY || 0.0001); + const maxAllowedQty = this.roundDown(remainingCapital / estimatedPrice, precision); + + if (!Number.isFinite(maxAllowedQty) || maxAllowedQty < minQty) { + return { + success: false, + error: `Remaining capital ${remainingCapital.toFixed(2)} is below tradable minimum.`, + requestedQty, + remainingCapitalUsd: Number(remainingCapital.toFixed(2)) + }; + } + + const adjustedQty = Math.min(requestedQty, maxAllowedQty); + if (adjustedQty < requestedQty) { + logger.warn(`[ManualTrader] Qty adjusted by capital guard for ${symbol}: requested=${requestedQty}, adjusted=${adjustedQty}, remaining=$${remainingCapital.toFixed(2)}`); + } + + const result = await this.executor.openPosition( + symbol, + signalSide, + adjustedQty, + type as 'market' | 'limit', + price, + sl, + tp, + userId + ); + + return { + ...result, + adjustedQty, + requestedQty, + remainingCapitalUsd: Number(remainingCapital.toFixed(2)) + }; + } + + public getActivePosition(symbol: string) { + return this.executor.getActivePosition(symbol); + } + + public getActivePositions(symbol: string) { + return this.executor.getActivePositions(symbol); + } + + public async executeExit(symbol: string, currentPrice: number, reason: string, tradeId?: string) { + return await this.executor.closePosition(symbol, reason, tradeId); + } + + public getUserId() { + return this.executor.getUserId(); + } + + public getProfileId() { + return this.executor.getProfileId(); + } +} diff --git a/backend/src/services/MetricsService.ts b/backend/src/services/MetricsService.ts new file mode 100644 index 0000000..f29e6d3 --- /dev/null +++ b/backend/src/services/MetricsService.ts @@ -0,0 +1,135 @@ + +import { Registry, Counter, Gauge, Histogram, collectDefaultMetrics } from 'prom-client'; +import { config } from '../config/index.js'; + +export class MetricsService { + private static instance: MetricsService; + private registry: Registry; + + // --- Metrics Definition --- + + // Operational Events + public operationalEventsTotal: Counter; + + // Subsystem Metrics + public subsystemDurationSeconds: Histogram; + public subsystemLastRunTimestamp: Gauge; + public subsystemAlive: Gauge; + + // Exchange API Metrics + public exchangeApiLatencySeconds: Histogram; + + // Risk & Capital Metrics + public capitalInvariantViolationsTotal: Counter; + public profileUtilizationPercent: Gauge; + + // Reconciliation Metrics + public reconciliationMismatchesTotal: Counter; + public reconciliationMissingItemsCount: Gauge; + + private constructor() { + this.registry = new Registry(); + + // Dynamic labels for every metric + const env = process.env.NODE_ENV || 'development'; + const mode = config.PAPER_TRADING ? 'paper' : 'live'; + + this.registry.setDefaultLabels({ + app: 'bytelyst-trading-bot-service', + env, + mode + }); + + collectDefaultMetrics({ register: this.registry }); + + // operational_events_total + this.operationalEventsTotal = new Counter({ + name: 'bytelyst_bot_operational_events_total', + help: 'Cumulative count of operational events emitted by the system', + labelNames: ['severity', 'type', 'profile_id', 'symbol'], + registers: [this.registry] + }); + + // subsystem_duration_seconds + this.subsystemDurationSeconds = new Histogram({ + name: 'bytelyst_bot_subsystem_duration_seconds', + help: 'Duration of subsystem loop executions', + labelNames: ['subsystem'], + buckets: [0.1, 0.25, 0.5, 1, 2, 5, 10], + registers: [this.registry] + }); + + // subsystem_last_run_timestamp + this.subsystemLastRunTimestamp = new Gauge({ + name: 'bytelyst_bot_subsystem_last_run_timestamp', + help: 'Unix timestamp of the last successful run of a subsystem', + labelNames: ['subsystem'], + registers: [this.registry] + }); + + // subsystem_alive + this.subsystemAlive = new Gauge({ + name: 'bytelyst_bot_subsystem_alive', + help: 'High-level availability flag for subsystems (1=alive, 0=stalled)', + labelNames: ['subsystem'], + registers: [this.registry] + }); + + // exchange_api_latency_seconds + this.exchangeApiLatencySeconds = new Histogram({ + name: 'bytelyst_bot_exchange_api_latency_seconds', + help: 'Latency of outgoing exchange API requests', + labelNames: ['exchange', 'operation'], + buckets: [0.05, 0.1, 0.25, 0.5, 1, 2, 5], + registers: [this.registry] + }); + + // capital_invariant_violations_total + this.capitalInvariantViolationsTotal = new Counter({ + name: 'bytelyst_bot_capital_invariant_violations_total', + help: 'Cumulative count of capital invariant violations per profile', + labelNames: ['profile_id'], + registers: [this.registry] + }); + + // profile_utilization_percent + this.profileUtilizationPercent = new Gauge({ + name: 'bytelyst_bot_profile_utilization_percent', + help: 'Percentage of allocated capital currently tied up in positions/orders', + labelNames: ['profile_id'], + registers: [this.registry] + }); + + // reconciliation_mismatches_total + this.reconciliationMismatchesTotal = new Counter({ + name: 'bytelyst_bot_reconciliation_mismatches_total', + help: 'Cumulative count of detected state mismatches between DB and Exchange', + registers: [this.registry] + }); + + // reconciliation_missing_items_count + this.reconciliationMissingItemsCount = new Gauge({ + name: 'bytelyst_bot_reconciliation_missing_items_count', + help: 'Count of missing items detected in the last reconciliation cycle', + labelNames: ['source'], + registers: [this.registry] + }); + } + + public static getInstance(): MetricsService { + if (!MetricsService.instance) { + MetricsService.instance = new MetricsService(); + } + return MetricsService.instance; + } + + public async getMetrics(): Promise { + return await this.registry.metrics(); + } + + public getContentType(): string { + return this.registry.contentType; + } +} + +export const metrics = MetricsService.getInstance(); diff --git a/backend/src/services/OrderStatusSyncService.ts b/backend/src/services/OrderStatusSyncService.ts new file mode 100644 index 0000000..b039afd --- /dev/null +++ b/backend/src/services/OrderStatusSyncService.ts @@ -0,0 +1,511 @@ +import { supabaseService } from './SupabaseService.js'; +import { IExchangeConnector } from '../connectors/types.js'; +import logger from '../utils/logger.js'; +import { config } from '../config/index.js'; +import { SymbolMapper } from '../utils/symbolMapper.js'; +import { healthTracker } from './healthTracker.js'; + +const normalizeThrown = (value: unknown): Error => { + if (value instanceof Error) return value; + if (value && typeof value === 'object') return new Error(JSON.stringify(value)); + return new Error(String(value ?? 'Unknown error')); +}; + +export interface OrderSyncOrderRecord { + id?: string; + order_id?: string; + symbol: string; + status?: string; + qty?: number | string; + price?: number | string; + action?: string; + trade_id?: string; + user_id?: string; + profile_id?: string; + filled_at?: string; + updated_at?: string; + created_at?: string; +} + +export interface OrderStatusSyncEvent { + orderId: string; + symbol: string; + previousStatus: string; + status: string; + action?: string; + tradeId?: string; + userId?: string; + profileId?: string; + filledAt?: Date; + fillPrice?: number; + fillQty?: number; + quarantined?: boolean; +} + +export interface OrderStatusSyncScopeOptions { + userId?: string; + includeOrphanUserOrders?: boolean; + profileNullOnly?: boolean; +} + +/** + * OrderStatusSyncService + * + * Periodically checks for orders stuck in 'pending_new' status and updates them + * by querying the exchange for the actual order status. + * + * This prevents stale data in the database when orders are filled but the status + * update fails or is missed. + */ +export class OrderStatusSyncService { + private static readonly UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + private interval: NodeJS.Timeout | null = null; + private isRunning = false; + private lastStats = { + updated: 0, + failed: 0, + notFound: 0, + quarantined: 0, + lastRunAt: 0 + }; + private missingExchangeDetections = new Map(); + private static warnedCapabilities = new Set(); + + constructor( + private exchange: IExchangeConnector, + private syncIntervalMs: number = config.ORDER_SYNC_INTERVAL_MS, + private profileId?: string, + private onStatusChange?: (event: OrderStatusSyncEvent) => Promise | void, + private scopeOptions: OrderStatusSyncScopeOptions = {} + ) { } + + /** + * Start the background sync service + */ + public start() { + if (this.interval) { + logger.warn('[OrderSync] Service already running'); + return; + } + + const scopeParts: string[] = []; + if (this.profileId) scopeParts.push(`profile=${this.profileId}`); + if (this.scopeOptions.userId) scopeParts.push(`user=${this.scopeOptions.userId}`); + if (this.scopeOptions.profileNullOnly) scopeParts.push('profile=null'); + if (this.scopeOptions.includeOrphanUserOrders) scopeParts.push('include-orphans=true'); + const scopeLabel = scopeParts.length > 0 ? scopeParts.join(',') : 'global'; + const cadenceLabel = this.syncIntervalMs >= 60_000 + ? `${(this.syncIntervalMs / 60_000).toFixed(this.syncIntervalMs % 60_000 === 0 ? 0 : 1)} minute(s)` + : `${Math.max(1, Math.round(this.syncIntervalMs / 1000))} second(s)`; + logger.info(`[OrderSync] Starting order status sync (${scopeLabel}, every ${cadenceLabel})...`); + + // Run immediately on start + this.syncStaleOrders(); + + // Then run periodically + this.interval = setInterval(() => { + this.syncStaleOrders(); + }, this.syncIntervalMs); + } + + /** + * Stop the background sync service + */ + public stop() { + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + logger.info('[OrderSync] Service stopped'); + } + } + + /** + * Main sync logic: Query DB for stale orders and update their status + */ + private async syncStaleOrders() { + if (this.isRunning) { + logger.debug('[OrderSync] Previous sync still running, skipping...'); + return; + } + + this.isRunning = true; + + try { + // Only sync if we have Supabase client + if (!supabaseService) { + logger.debug('[OrderSync] Supabase not configured, skipping sync'); + return; + } + + const staleOrders = await this.getStaleOrders(); + const recentClosedOrders = await this.getRecentClosedOrders(); + const candidateOrders = new Map(); + for (const row of [...(staleOrders || []), ...(recentClosedOrders || [])]) { + const key = String(row.order_id || row.id || '').trim(); + if (!key) continue; + if (!candidateOrders.has(key)) { + candidateOrders.set(key, row); + } + } + + if (candidateOrders.size === 0) { + logger.debug('[OrderSync] No stale or recent-closed orders found'); + return; + } + + logger.info(`[OrderSync] Found ${staleOrders.length} stale + ${recentClosedOrders.length} recent-closed orders to check${this.profileId ? ` (profile=${this.profileId})` : ''}`); + + let updated = 0; + let failed = 0; + let notFound = 0; + let quarantined = 0; + + for (const order of candidateOrders.values()) { + try { + const result = await this.syncOrderStatus(order); + if (result === 'quarantined') { + quarantined++; + } else { + updated++; + } + } catch (error: any) { + if (error.message?.includes('not found')) { + notFound++; + } else { + failed++; + } + } + } + + this.lastStats = { + updated, + failed, + notFound, + quarantined, + lastRunAt: Date.now() + }; + logger.info(`[OrderSync] Sync complete: ${updated} updated, ${notFound} not found on exchange, ${quarantined} quarantined, ${failed} failed${this.profileId ? ` (profile=${this.profileId})` : ''}`); + + } catch (error: any) { + logger.error(`[OrderSync] Sync error: ${error.message}`); + } finally { + healthTracker.recordOrderSyncLoop(); + this.isRunning = false; + } + } + + /** + * Get orders from DB that are stuck in pending_new status + */ + private async getStaleOrders(): Promise { + try { + const staleOrders = await supabaseService.getStaleOrders(config.STALE_ORDER_THRESHOLD_MINUTES, { + profileId: this.profileId, + userId: this.scopeOptions.userId, + includeOrphanUserOrders: this.scopeOptions.includeOrphanUserOrders, + profileNullOnly: this.scopeOptions.profileNullOnly + }); + return staleOrders; + } catch (error: any) { + logger.error(`[OrderSync] Error fetching stale orders: ${error.message}`); + return []; + } + } + + private async getRecentClosedOrders(): Promise { + const profileId = String(this.profileId || '').trim(); + if (!profileId || !OrderStatusSyncService.UUID_PATTERN.test(profileId)) return []; + try { + const rows = await supabaseService.getRecentlyClosedOrdersForProfile( + profileId, + config.ORDER_SYNC_RECENT_CLOSED_LOOKBACK_MINUTES + ); + return rows || []; + } catch (error: any) { + logger.error(`[OrderSync] Error fetching recent closed orders: ${error.message}`); + return []; + } + } + + /** + * Check a single order's status on the exchange and update DB + */ + private async emitStatusChange(event: OrderStatusSyncEvent): Promise { + if (!this.onStatusChange) return; + try { + await this.onStatusChange(event); + } catch (error: any) { + logger.error(`[OrderSync] onStatusChange callback failed for ${event.orderId}: ${error.message}`); + } + } + + private isMissingOrderError(error: any): boolean { + const message = String(error?.message || error || '').toLowerCase(); + const details = String(error?.details || '').toLowerCase(); + const code = String(error?.code || error?.status || error?.statusCode || '').toLowerCase(); + + const corpus = `${message} ${details} ${code}`; + return ( + corpus.includes('not found') + || corpus.includes('404') + || corpus.includes('unknown order') + || corpus.includes('does not exist') + || corpus.includes('no such order') + ); + } + + private async syncOrderStatus(order: OrderSyncOrderRecord): Promise<'updated' | 'quarantined'> { + const orderId = order.order_id || order.id; + + if (!orderId) { + logger.warn('[OrderSync] Order missing ID, skipping'); + return 'updated'; + } + + try { + // Check if exchange supports getOrder + if (!this.exchange.getOrder) { + if (!OrderStatusSyncService.warnedCapabilities.has('getOrder')) { + OrderStatusSyncService.warnedCapabilities.add('getOrder'); + logger.warn('[OrderSync] Exchange does not support getOrder. Background sync will be limited.'); + } + return 'updated'; // Don't mark as unknown if we can't check + } + + // Query exchange for order status + let exchangeOrder: any = null; + let missingFromExchange = false; + try { + // Ensure symbol is mapped to exchange format (e.g. BTC/USDT -> BTC/USD for Alpaca) + const tradeSymbol = SymbolMapper.toTradeSymbol(order.symbol, config.EXECUTION_PROVIDER); + exchangeOrder = await this.exchange.getOrder(orderId, tradeSymbol); + } catch (error: any) { + if (this.isMissingOrderError(error)) { + missingFromExchange = true; + logger.warn(`[OrderSync] Order ${orderId} not found on exchange (exception path): ${error.message}`); + } else { + // Transient exchange failure: keep existing status and retry later. + logger.debug(`[OrderSync] Could not fetch order ${orderId} from exchange: ${error.message}`); + return 'updated'; + } + } + + if (!exchangeOrder) { + missingFromExchange = true; + } + + if (!missingFromExchange) { + this.missingExchangeDetections.delete(orderId); + } + + if (missingFromExchange) { + const normalizedCurrentStatus = String(order.status || '').toLowerCase(); + const alreadyClosed = ['filled', 'partially_filled', 'partially-filled', 'canceled', 'expired', 'rejected', 'unknown'] + .includes(normalizedCurrentStatus); + if (alreadyClosed) { + this.missingExchangeDetections.delete(orderId); + logger.debug(`[OrderSync] Closed order ${orderId} missing from exchange; preserving DB state (${normalizedCurrentStatus}).`); + return 'updated'; + } + + const createdAt = order.created_at ? new Date(order.created_at).getTime() : Date.now(); + const orderAge = Date.now() - createdAt; + const ageHours = orderAge / (1000 * 60 * 60); + const ageMinutes = orderAge / (1000 * 60); + const graceMinutes = Math.max(0, Number(config.ORDER_SYNC_MISSING_GRACE_MINUTES || 0)); + const minConfirmations = Math.max(1, Math.floor(Number(config.ORDER_SYNC_MISSING_CONFIRMATION_COUNT || 1))); + if (ageMinutes < graceMinutes) { + logger.debug(`[OrderSync] Order ${orderId} missing on exchange but within grace window (${ageMinutes.toFixed(1)}m < ${graceMinutes}m).`); + return 'updated'; + } + const detection = this.missingExchangeDetections.get(orderId) || { count: 0, lastSeenAt: 0 }; + const nextDetection = { + count: detection.count + 1, + lastSeenAt: Date.now() + }; + this.missingExchangeDetections.set(orderId, nextDetection); + if (nextDetection.count < minConfirmations) { + logger.warn(`[OrderSync] Order ${orderId} missing from exchange (confirmation ${nextDetection.count}/${minConfirmations}). Deferring state mutation.`); + return 'updated'; + } + const normalizedAction = String(order.action || '').toUpperCase(); + const normalizedSide = String((order as any).side || '').toUpperCase(); + + // If lifecycle is already closed, mark stale order canceled so dashboard + // does not keep ghost active rows. Applies to EXIT and legacy rows without action. + if (order.trade_id) { + const isLifecycleClosed = await supabaseService.isTradeLifecycleClosed( + order.trade_id, + order.profile_id, + order.symbol + ); + + if (isLifecycleClosed) { + logger.warn(`[OrderSync] Order ${orderId} (${order.symbol}) not found on exchange, but lifecycle ${order.trade_id} is closed. Marking as canceled.`); + await supabaseService.updateOrderStatus?.(orderId, 'canceled'); + this.missingExchangeDetections.delete(orderId); + await this.emitStatusChange({ + orderId, + symbol: order.symbol, + previousStatus: (order.status || '').toLowerCase(), + status: 'canceled', + action: order.action, + tradeId: order.trade_id, + userId: order.user_id, + profileId: order.profile_id + }); + return 'updated'; + } + } + + // Legacy safety: older EXIT-like orders may be missing trade_id. + // For dedicated profiles, resolve by profile+symbol virtual lifecycle state. + const isExitLikeWithoutTradeId = !order.trade_id + && !!order.profile_id + && (normalizedAction === 'EXIT' || (!normalizedAction && normalizedSide === 'SELL')); + if (isExitLikeWithoutTradeId) { + try { + const virtualPosition = await supabaseService.getVirtualOpenPosition(order.profile_id!, order.symbol); + if (!virtualPosition) { + logger.warn(`[OrderSync] Legacy EXIT-like order ${orderId} (${order.symbol}) has no open virtual lifecycle. Marking as canceled.`); + await supabaseService.updateOrderStatus?.(orderId, 'canceled'); + this.missingExchangeDetections.delete(orderId); + await this.emitStatusChange({ + orderId, + symbol: order.symbol, + previousStatus: (order.status || '').toLowerCase(), + status: 'canceled', + action: order.action, + tradeId: order.trade_id, + userId: order.user_id, + profileId: order.profile_id + }); + return 'updated'; + } + } catch (virtualErr: any) { + logger.warn(`[OrderSync] Failed virtual lifecycle check for legacy order ${orderId}: ${virtualErr.message}`); + } + } + + // If order is older than 24 hours and not found, mark as expired to clean up UI + if (ageHours > 24) { + logger.error(`[OrderSync][QUARANTINE] Order ${orderId} not found and >24h old. Marking as UNKNOWN for manual review.`); + await supabaseService.updateOrderStatus?.(orderId, 'unknown'); + this.missingExchangeDetections.delete(orderId); + await this.emitStatusChange({ + orderId, + symbol: order.symbol, + previousStatus: (order.status || '').toLowerCase(), + status: 'unknown', + action: order.action, + tradeId: order.trade_id, + userId: order.user_id, + profileId: order.profile_id, + quarantined: true + }); + return 'quarantined'; + } else { + logger.debug(`[OrderSync] Order ${orderId} (${order.symbol}) not found on exchange (age: ${ageHours.toFixed(1)}h) - keeping existing status`); + } + return 'updated'; + } + + const exchangeStatus = String(exchangeOrder.status || '').toLowerCase(); + const fillPriceRaw = exchangeOrder.filled_avg_price; + const fillPrice = fillPriceRaw !== undefined ? Number(fillPriceRaw) : undefined; + const fillQtyRaw = exchangeOrder.filled_qty ?? exchangeOrder.filledQty ?? exchangeOrder.filled_quantity; + const fillQty = fillQtyRaw !== undefined ? Number(fillQtyRaw) : undefined; + const dbQty = Number(order.qty ?? 0); + const dbPrice = Number(order.price ?? 0); + const fillLike = exchangeStatus === 'filled' || exchangeStatus === 'partially_filled'; + const qtyDrift = fillLike + && Number.isFinite(fillQty) + && (fillQty as number) > 0 + && (!Number.isFinite(dbQty) || dbQty <= 0 || Math.abs((fillQty as number) - dbQty) / Math.max(Math.abs(fillQty as number), Math.abs(dbQty), 1e-9) > 1e-6); + const priceDrift = fillLike + && Number.isFinite(fillPrice) + && (fillPrice as number) > 0 + && (!Number.isFinite(dbPrice) || dbPrice <= 0 || Math.abs((fillPrice as number) - dbPrice) / Math.max(Math.abs(fillPrice as number), Math.abs(dbPrice), 1e-9) > 5e-4); + const fillDataDrift = qtyDrift || priceDrift; + const exchangeFilledAt = fillLike + ? (() => { + const rawFilledAt = String(exchangeOrder.filled_at || '').trim(); + if (!rawFilledAt) return new Date(); + const parsed = Date.parse(rawFilledAt); + return Number.isFinite(parsed) && parsed > 0 ? new Date(parsed) : new Date(); + })() + : undefined; + + // Update DB if status changed + if (exchangeStatus && exchangeStatus !== order.status?.toLowerCase()) { + logger.info(`[OrderSync] Updating order ${orderId} (${order.symbol}): ${order.status} → ${exchangeStatus}`); + + await supabaseService.updateOrderStatus?.( + orderId, + exchangeStatus, + exchangeFilledAt, + Number.isFinite(fillPrice) ? fillPrice : undefined, + Number.isFinite(fillQty) ? fillQty : undefined + ); + await this.emitStatusChange({ + orderId, + symbol: order.symbol, + previousStatus: (order.status || '').toLowerCase(), + status: exchangeStatus, + action: order.action, + tradeId: order.trade_id, + userId: order.user_id, + profileId: order.profile_id, + filledAt: exchangeFilledAt, + fillPrice: Number.isFinite(fillPrice) ? fillPrice : undefined, + fillQty: Number.isFinite(fillQty) ? fillQty : undefined + }); + this.missingExchangeDetections.delete(orderId); + } else if (fillDataDrift) { + logger.info(`[OrderSync] Refreshing fill data for ${orderId} (${order.symbol}): qty ${order.qty} -> ${fillQty}, price ${order.price} -> ${fillPrice}`); + await supabaseService.updateOrderStatus?.( + orderId, + exchangeStatus || String(order.status || 'filled'), + exchangeFilledAt, + Number.isFinite(fillPrice) ? fillPrice : undefined, + Number.isFinite(fillQty) ? fillQty : undefined + ); + await this.emitStatusChange({ + orderId, + symbol: order.symbol, + previousStatus: (order.status || '').toLowerCase(), + status: exchangeStatus || (order.status || '').toLowerCase(), + action: order.action, + tradeId: order.trade_id, + userId: order.user_id, + profileId: order.profile_id, + filledAt: exchangeFilledAt, + fillPrice: Number.isFinite(fillPrice) ? fillPrice : undefined, + fillQty: Number.isFinite(fillQty) ? fillQty : undefined + }); + this.missingExchangeDetections.delete(orderId); + } else { + logger.debug(`[OrderSync] Order ${orderId} status unchanged: ${exchangeStatus}`); + this.missingExchangeDetections.delete(orderId); + } + + return 'updated'; + + } catch (error: any) { + logger.error(`[OrderSync] Failed to sync order ${orderId}: ${error.message}`); + throw normalizeThrown(error); + } + } + + /** + * Manually trigger a sync (useful for testing or on-demand sync) + */ + public async triggerSync(): Promise { + logger.info('[OrderSync] Manual sync triggered'); + await this.syncStaleOrders(); + } + + public getLastStats() { + return this.lastStats; + } +} diff --git a/backend/src/services/SupabaseService.ts b/backend/src/services/SupabaseService.ts new file mode 100644 index 0000000..d02e78b --- /dev/null +++ b/backend/src/services/SupabaseService.ts @@ -0,0 +1,3050 @@ +import { createClient, SupabaseClient } from '@supabase/supabase-js'; +import { config } from '../config/index.js'; +import logger from '../utils/logger.js'; +import { + normalizeOrderAction, + normalizeOrderStatus, + normalizeOrderType, + normalizeTradeSide +} from '../domain/tradingEnums.js'; +import { + buildAlpacaSubTag, + shouldAttachAlpacaSubTag, + isBytelystSubTag, + subTagBelongsToProfile, + type AlpacaSubTagIntent +} from '../utils/alpacaSubTag.js'; +import { SymbolMapper } from '../utils/symbolMapper.js'; + +export interface UserConfig { + user_id: string; + first_name: string; + last_name: string; + email: string; + ALPACA_API_KEY: string; + ALPACA_SECRET_KEY: string; + REAL_ALPACA_API_KEY: string; + REAL_ALPACA_SECRET_KEY: string; + role: string; + trade_enable: boolean; + drop_threshold_for_buy: number; + gain_threshold_for_sell: number; + market_poll_interval_in_seconds: number; +} + +export interface VirtualOpenPosition { + profileId: string; + symbol: string; + side: 'BUY' | 'SELL'; + qty: number; + entryPrice: number; + stopLoss: number; + takeProfit: number; + userId?: string; + tradeId: string; + tradeIds: string[]; +} + +export interface StaleOrderScope { + profileId?: string; + userId?: string; + includeOrphanUserOrders?: boolean; + profileNullOnly?: boolean; +} + +export interface FilledLifecycleOrderRow { + id?: string; + order_id?: string | null; + user_id?: string | null; + profile_id?: string | null; + symbol?: string | null; + trade_id?: string | null; + action?: string | null; + side?: string | null; + qty?: number | string | null; + quantity?: number | string | null; + price?: number | string | null; + status?: string | null; + source?: string | null; + sub_tag?: string | null; + stop_loss?: number | string | null; + take_profit?: number | string | null; + timestamp?: number | string | null; + created_at?: string | null; + filled_at?: string | null; + type?: string | null; +} + +export interface ReconciliationBackfillOrderInsert { + user_id: string; + profile_id: string; + order_id: string; + symbol: string; + type: string; + side: string; + qty: number; + quantity: number; + price: number; + status: string; + timestamp: number; + filled_at?: string; + trade_id: string; + action: 'EXIT'; + source: 'BOT'; + sub_tag?: string; +} + +export interface ReconciliationBackfillAuditInsert { + batch_id: string; + profile_id: string; + symbol: string; + trade_id: string; + exchange_order_id?: string | null; + exchange_client_order_id?: string | null; + backfill_order_id?: string | null; + filled_qty?: number | null; + filled_price?: number | null; + filled_at?: string | null; + dry_run: boolean; + decision: string; + reason?: string | null; + metadata?: Record | null; + applied_at?: string | null; + reverted_at?: string | null; +} + +export interface ReconciliationBackfillAuditQuery { + profileId?: string; + symbol?: string; + batchId?: string; + decisions?: string[]; + fromIso?: string; + toIso?: string; + limit?: number; + offset?: number; +} + +export interface ReconciliationBackfillAuditRow { + id: number; + batch_id: string; + profile_id: string; + symbol: string; + trade_id: string; + exchange_order_id?: string | null; + exchange_client_order_id?: string | null; + backfill_order_id?: string | null; + filled_qty?: number | null; + filled_price?: number | null; + filled_at?: string | null; + dry_run: boolean; + decision: string; + reason?: string | null; + metadata?: Record | null; + applied_at?: string | null; + reverted_at?: string | null; + created_at: string; +} + +export interface ReconciliationBackfillBatchSummary { + batchId: string; + firstSeenAt: string; + lastSeenAt: string; + profileIds: string[]; + symbols: string[]; + totalRows: number; + byDecision: Record; + dryRunRows: number; + appliedRows: number; + revertedRows: number; +} + +export interface ReconciliationSubTagRepairSummary { + attempted: boolean; + unsupported?: boolean; + scannedRows: number; + eligibleRows: number; + updatedRows: number; + skippedNoProfile: number; + skippedNoTrade: number; + skippedTagDisabled: number; + skippedAlreadyTagged: number; + dryRun: boolean; +} + +class SupabaseService { + private client: SupabaseClient | null = null; + private tradeHistorySupportsSource: boolean | null = null; + private ordersSupportsSubTag: boolean | null = null; + private reconciliationBackfillAuditTableAvailable: boolean | null = null; + private snapshotOwnerId: string | null = null; + private readonly defaultRiskLimits = { + maxDailyLossUsd: 50, + maxOpenTrades: config.MAX_OPEN_TRADES, + maxConsecutiveLosses: 2 + }; + private readonly defaultExecution = { + orderType: 'market', + cooldownMinutes: 30, + entryMode: 'both' + }; + + constructor() { + if (config.SUPABASE_URL && config.SUPABASE_KEY) { + this.client = createClient(config.SUPABASE_URL, config.SUPABASE_KEY); + } else { + logger.warn('Supabase credentials missing. DB integration disabled.'); + } + } + + getClient(): SupabaseClient | null { + return this.client; + } + + private isUuid(value: string | undefined | null): boolean { + const normalized = String(value || '').trim(); + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(normalized); + } + + private resolveSubTagIntent(action: unknown): AlpacaSubTagIntent { + const normalizedAction = normalizeOrderAction(String(action || '')); + if (normalizedAction === 'ENTRY' || normalizedAction === 'EXIT') { + return normalizedAction; + } + return 'UNKNOWN'; + } + + private buildLifecycleSymbolCandidates(symbol: string): string[] { + const normalized = String(symbol || '').trim(); + if (!normalized) return []; + const provider = String(config.EXECUTION_PROVIDER || '').trim() || 'alpaca'; + const variants = new Set(); + + const push = (value: string) => { + const token = String(value || '').trim(); + if (!token) return; + variants.add(token); + variants.add(token.toUpperCase()); + }; + + push(normalized); + push(SymbolMapper.toTradeSymbol(normalized, provider)); + push(SymbolMapper.toDataSymbol(normalized, provider)); + return Array.from(variants.values()); + } + + private resolvePersistedOrderSubTag(order: { + profile_id?: string; + trade_id?: string; + action?: string; + sub_tag?: string; + subTag?: string; + }): string { + const explicitSubTag = String(order.sub_tag || order.subTag || '').trim(); + if (explicitSubTag) return explicitSubTag; + + const profileId = String(order.profile_id || '').trim(); + if (!profileId) return ''; + if (!shouldAttachAlpacaSubTag({ profileId })) return ''; + + const tradeId = String(order.trade_id || '').trim() || undefined; + const intent = this.resolveSubTagIntent(order.action); + return buildAlpacaSubTag({ + profileId, + tradeId, + intent + }) || ''; + } + + private hydrateBackfillSubTags(rows: ReconciliationBackfillOrderInsert[]): ReconciliationBackfillOrderInsert[] { + return rows.map((row) => { + const subTag = this.resolvePersistedOrderSubTag({ + profile_id: row.profile_id, + trade_id: row.trade_id, + action: row.action, + sub_tag: row.sub_tag + }); + if (!subTag) return row; + if (subTag === String(row.sub_tag || '').trim()) return row; + return { + ...row, + sub_tag: subTag + }; + }); + } + + private inferLifecycleAction(actionRaw?: string | null, sideRaw?: string | null): 'ENTRY' | 'EXIT' | undefined { + const explicit = normalizeOrderAction(actionRaw || undefined); + if (explicit) return explicit; + const side = normalizeTradeSide(sideRaw || 'BUY'); + return side === 'BUY' ? 'ENTRY' : 'EXIT'; + } + + private orderStatusRank(statusRaw?: string | null): number { + const status = normalizeOrderStatus(String(statusRaw || 'pending_new')); + if (status === 'filled') return 6; + if (status === 'partially_filled') return 5; + if (status === 'canceled' || status === 'rejected' || status === 'expired') return 4; + if (status === 'unknown') return 1; + return 2; + } + + private pickMostReliableOrderStatus(currentStatusRaw?: string | null, incomingStatusRaw?: string | null): string { + const current = normalizeOrderStatus(String(currentStatusRaw || 'pending_new')); + const incoming = normalizeOrderStatus(String(incomingStatusRaw || 'pending_new')); + const currentRank = this.orderStatusRank(current); + const incomingRank = this.orderStatusRank(incoming); + return incomingRank >= currentRank ? incoming : current; + } + + private decodeJwtPayload(token: string): Record | null { + try { + const segments = token.split('.'); + if (segments.length < 2) return null; + const payloadSegment = segments[1] + .replace(/-/g, '+') + .replace(/_/g, '/'); + const padded = payloadSegment + '='.repeat((4 - (payloadSegment.length % 4)) % 4); + const json = Buffer.from(padded, 'base64').toString('utf8'); + const parsed = JSON.parse(json); + return parsed && typeof parsed === 'object' ? parsed : null; + } catch { + return null; + } + } + + async getActiveUsers(): Promise { + if (!this.client) return []; + + try { + const { data, error } = await this.client + .from('users') + .select('*') + .eq('trade_enable', true); + + if (error) { + logger.error(`Supabase fetch error: ${error.message}`); + return []; + } + + return data as UserConfig[]; + } catch (err: any) { + logger.error(`Supabase unexpected error: ${err.message}`); + return []; + } + } + + async logTransaction(transaction: { + user_id: string; + profile_id?: string; + symbol: string; + side: string; + entry_price: number; + exit_price: number; + size: number; + pnl: number; + pnl_percent: number; + reason: string; + timestamp: number; + stop_loss?: number; + take_profit?: number; + rules_metadata?: Record; + trade_id?: string; + source?: 'BOT' | 'MANUAL'; + }) { + if (!this.client) return; + + try { + const { source: _source, ...transactionWithoutSource } = transaction; + const normalizedTransactionBase = { + ...transactionWithoutSource, + side: normalizeTradeSide(transaction.side) + }; + + const normalizedTransactionWithSource = { + ...normalizedTransactionBase, + source: transaction.source || 'BOT' + }; + + const shouldTrySource = this.tradeHistorySupportsSource !== false; + if (shouldTrySource) { + const { error: sourceInsertError } = await this.client + .from('trade_history') + .insert([normalizedTransactionWithSource]); + + if (!sourceInsertError) { + this.tradeHistorySupportsSource = true; + logger.info(`✅ Logged trade to DB for user ${transaction.user_id} (${transaction.symbol})`); + return; + } + + const missingSourceColumn = this.isMissingColumnError(sourceInsertError, 'trade_history', 'source'); + if (!missingSourceColumn) { + logger.error(`Supabase transaction insert error: ${sourceInsertError.message}`); + return; + } + + this.tradeHistorySupportsSource = false; + logger.warn('[Supabase] trade_history.source column not present. Retrying with legacy payload.'); + } + + const { error: fallbackError } = await this.client + .from('trade_history') + .insert([normalizedTransactionBase]); + + if (fallbackError) { + logger.error(`Supabase legacy transaction insert error: ${fallbackError.message}`); + return; + } + + logger.info(`✅ Logged trade to DB for user ${transaction.user_id} (${transaction.symbol})`); + } catch (err: any) { + logger.error(`Supabase unexpected transaction error: ${err.message}`); + } + } + + private isMissingColumnError(error: any, tableName: string, columnName: string): boolean { + const message = String(error?.message || '').toLowerCase(); + const details = String(error?.details || '').toLowerCase(); + const hint = String(error?.hint || '').toLowerCase(); + const column = columnName.toLowerCase(); + const table = tableName.toLowerCase(); + + const mentionsMissingColumn = + message.includes(`could not find the '${column}' column`) || + message.includes(`column ${table}.${column} does not exist`) || + message.includes(`column "${column}" does not exist`) || + details.includes(`column ${column}`) || + hint.includes(`column ${column}`); + + const mentionsTableContext = + message.includes(table) || + details.includes(table) || + message.includes('schema cache'); + + return mentionsMissingColumn && mentionsTableContext; + } + + private isMissingRelationError(error: any, relationName: string): boolean { + const message = String(error?.message || '').toLowerCase(); + const details = String(error?.details || '').toLowerCase(); + const hint = String(error?.hint || '').toLowerCase(); + const relation = relationName.toLowerCase(); + return ( + message.includes('does not exist') + && (message.includes(relation) || details.includes(relation) || hint.includes(relation)) + ); + } + + private isMissingOnConflictConstraint(error: any): boolean { + const message = String(error?.message || '').toLowerCase(); + const details = String(error?.details || '').toLowerCase(); + return ( + message.includes('no unique or exclusion constraint matching the on conflict specification') + || details.includes('no unique or exclusion constraint matching the on conflict specification') + ); + } + + async logOrder(order: { + user_id: string; + profile_id?: string; + order_id?: string; + symbol: string; + type: string; + side: string; + qty: number; + price: number; + status: string; + timestamp: number; + stop_loss?: number; + take_profit?: number; + trade_id?: string; + action?: string; + sub_tag?: string; + subTag?: string; + }) { + if (!this.client) return; + + try { + const normalizedAction = normalizeOrderAction(order.action); + const explicitIncomingSubTag = String(order.sub_tag || order.subTag || '').trim(); + const normalizedSubTag = this.resolvePersistedOrderSubTag({ + profile_id: order.profile_id, + trade_id: order.trade_id, + action: normalizedAction, + sub_tag: order.sub_tag, + subTag: order.subTag + }); + const canPersistSubTag = this.ordersSupportsSubTag !== false; + const normalizedOrder = { + ...order, + type: normalizeOrderType(order.type), + side: normalizeTradeSide(order.side), + status: normalizeOrderStatus(order.status), + action: normalizedAction, + ...(canPersistSubTag && normalizedSubTag ? { sub_tag: normalizedSubTag } : {}) + }; + + const normalizedOrderId = String(normalizedOrder.order_id || '').trim(); + const normalizedProfileId = String(normalizedOrder.profile_id || '').trim(); + if (normalizedOrderId) { + let existingQuery = this.client + .from('orders') + .select('id,order_id,profile_id,status,trade_id,action,qty,price,timestamp,stop_loss,take_profit,sub_tag') + .eq('order_id', normalizedOrderId) + .order('created_at', { ascending: false }) + .limit(1); + if (normalizedProfileId) { + existingQuery = existingQuery.eq('profile_id', normalizedProfileId); + } + + const { data: existingRows, error: existingError } = await existingQuery; + if (existingError) { + logger.warn(`[Supabase] Existing-order lookup failed for ${normalizedOrderId}. Falling back to insert path: ${existingError.message}`); + } else if ((existingRows || []).length > 0) { + const existing = (existingRows || [])[0] as any; + const existingStatus = normalizeOrderStatus(String(existing?.status || 'pending_new')); + const mergedStatus = this.pickMostReliableOrderStatus(existingStatus, normalizedOrder.status); + const keepExistingTerminal = this.orderStatusRank(existingStatus) > this.orderStatusRank(normalizedOrder.status); + const existingQty = Number(existing?.qty || 0); + const existingPrice = Number(existing?.price || 0); + const existingTimestamp = Number(existing?.timestamp || 0); + const incomingTimestamp = Number(normalizedOrder.timestamp || 0); + const existingSubTag = String(existing?.sub_tag || '').trim(); + const persistedSubTag = explicitIncomingSubTag + || existingSubTag + || normalizedSubTag + || undefined; + + const mergedPayload = { + ...normalizedOrder, + profile_id: normalizedProfileId || existing?.profile_id || undefined, + trade_id: normalizedOrder.trade_id || existing?.trade_id || undefined, + action: normalizedOrder.action || normalizeOrderAction(existing?.action), + status: mergedStatus, + qty: keepExistingTerminal && Number.isFinite(existingQty) && existingQty > 0 ? existingQty : normalizedOrder.qty, + price: keepExistingTerminal && Number.isFinite(existingPrice) && existingPrice > 0 ? existingPrice : normalizedOrder.price, + timestamp: Math.max( + Number.isFinite(existingTimestamp) ? existingTimestamp : 0, + Number.isFinite(incomingTimestamp) ? incomingTimestamp : 0 + ), + stop_loss: normalizedOrder.stop_loss || Number(existing?.stop_loss || 0) || undefined, + take_profit: normalizedOrder.take_profit || Number(existing?.take_profit || 0) || undefined, + ...(canPersistSubTag + ? { sub_tag: persistedSubTag } + : {}) + }; + + let updateQuery = this.client + .from('orders') + .update(mergedPayload) + .eq('order_id', normalizedOrderId); + if (normalizedProfileId || existing?.profile_id) { + updateQuery = updateQuery.eq('profile_id', normalizedProfileId || existing.profile_id); + } + + let { error: updateError } = await updateQuery; + if (updateError && this.isMissingColumnError(updateError, 'orders', 'sub_tag')) { + this.ordersSupportsSubTag = false; + const { sub_tag: _subTag, ...legacyPayload } = mergedPayload as any; + let legacyUpdateQuery = this.client + .from('orders') + .update(legacyPayload) + .eq('order_id', normalizedOrderId); + if (normalizedProfileId || existing?.profile_id) { + legacyUpdateQuery = legacyUpdateQuery.eq('profile_id', normalizedProfileId || existing.profile_id); + } + const fallback = await legacyUpdateQuery; + updateError = fallback.error; + } else if (!updateError && mergedPayload.sub_tag) { + this.ordersSupportsSubTag = true; + } + if (updateError) { + logger.error(`[Supabase] Error de-duplicating order ${normalizedOrderId}: ${updateError.message}`); + } else { + logger.debug(`[Supabase] Upserted existing order by order_id=${normalizedOrderId}`); + } + return; + } + } + + let { error } = await this.client + .from('orders') + .insert([normalizedOrder]); + if (error && this.isMissingColumnError(error, 'orders', 'sub_tag')) { + this.ordersSupportsSubTag = false; + const { sub_tag: _subTag, ...legacyPayload } = normalizedOrder as any; + const fallback = await this.client + .from('orders') + .insert([legacyPayload]); + error = fallback.error; + } else if (!error && (normalizedOrder as any).sub_tag) { + this.ordersSupportsSubTag = true; + } + + if (error) { + logger.error(`[Supabase] Error logging order: ${error.message}`); + } else { + logger.info(`✅ Logged order to DB for user ${order.user_id} (${order.symbol})`); + } + } catch (err: any) { + logger.error(`Supabase unexpected order error: ${err.message}`); + } + } + + async updateOrderStatus(orderId: string, status: string, filledAt?: Date, price?: number, qty?: number) { + if (!this.client) return; + + try { + const updateData: any = { + status: normalizeOrderStatus(status), + updated_at: new Date().toISOString() + }; + if (filledAt) { + updateData.filled_at = filledAt.toISOString(); + } + if (price !== undefined && price > 0) { + updateData.price = price; + } + if (qty !== undefined && qty > 0) { + updateData.qty = qty; + } + + // Try updating by both 'id' and 'order_id' fields for compatibility + const { error: error1 } = await this.client + .from('orders') + .update(updateData) + .eq('order_id', orderId); + + const { error: error2 } = await this.client + .from('orders') + .update(updateData) + .eq('id', orderId); + + if (error1 && error2) { + logger.error(`Supabase order update error: ${error1.message} / ${error2.message}`); + } else { + logger.debug(`[Supabase] Updated order ${orderId} status to ${status}`); + } + } catch (err: any) { + logger.error(`Supabase unexpected order update error: ${err.message}`); + } + } + + async getLatestOrder(userId: string, symbol: string) { + if (!this.client) return null; + + try { + const { data, error } = await this.client + .from('orders') + .select('*') + .eq('user_id', userId) + .eq('symbol', symbol) + .order('timestamp', { ascending: false }) + .limit(1) + .maybeSingle(); + + if (error) { + logger.error(`[Supabase] Error fetching latest order for ${symbol}: ${error.message}`); + } + return data; + } catch (err: any) { + logger.error(`Supabase unexpected get order error: ${err.message}`); + return null; + } + } + + async getOrderByTradeId(tradeId: string, profileId?: string) { + if (!this.client) return null; + + const normalizedTradeId = String(tradeId || '').trim(); + if (!normalizedTradeId) return null; + + try { + let query = this.client + .from('orders') + .select('order_id,status,qty,price,symbol,action,stop_loss,take_profit') + .eq('trade_id', normalizedTradeId) + .order('created_at', { ascending: false }) + .limit(1); + if (profileId) { + query = query.eq('profile_id', profileId); + } + + const { data, error } = await query.maybeSingle(); + if (error) { + logger.error(`[Supabase] Error fetching order for trade ${normalizedTradeId}: ${error.message}`); + return null; + } + return data; + } catch (err: any) { + logger.error(`Supabase unexpected get order by trade error: ${err.message}`); + return null; + } + } + + async getActiveProfiles(): Promise { + if (!this.client) return []; + try { + const { data, error } = await this.client + .from('trade_profiles') + .select('*') + .eq('is_active', true); + + if (error) { + logger.error(`[Supabase] Error fetching profiles: ${error.message}`); + return []; + } + + return (data || []).map((profile: any) => ({ + ...profile, + strategy_config: this.normalizeStrategyConfig(profile?.strategy_config) + })); + } catch (err: any) { + logger.error(`Supabase unexpected profile fetch error: ${err.message}`); + return []; + } + } + + async getProfilesForUser(userId: string): Promise> { + if (!this.client || !this.isUuid(userId)) return []; + try { + const { data, error } = await this.client + .from('trade_profiles') + .select('id,user_id,name,allocated_capital,is_active') + .eq('user_id', userId) + .order('name', { ascending: true }); + + if (error) { + logger.error(`[Supabase] Error fetching profiles for user ${userId}: ${error.message}`); + return []; + } + + return ((data || []) as any[]).map((row) => ({ + id: String(row.id || ''), + user_id: String(row.user_id || ''), + name: String(row.name || row.id || 'Unnamed Profile'), + allocated_capital: Number(row.allocated_capital || 0), + is_active: Boolean(row.is_active) + })).filter((row) => this.isUuid(row.id) && this.isUuid(row.user_id)); + } catch (err: any) { + logger.error(`[Supabase] Unexpected user profile fetch error for ${userId}: ${err.message}`); + return []; + } + } + + async getAllProfiles(): Promise> { + if (!this.client) return []; + try { + const { data, error } = await this.client + .from('trade_profiles') + .select('id,user_id,name,allocated_capital,is_active') + .order('name', { ascending: true }); + + if (error) { + logger.error(`[Supabase] Error fetching all profiles: ${error.message}`); + return []; + } + + return ((data || []) as any[]).map((row) => ({ + id: String(row.id || ''), + user_id: String(row.user_id || ''), + name: String(row.name || row.id || 'Unnamed Profile'), + allocated_capital: Number(row.allocated_capital || 0), + is_active: Boolean(row.is_active) + })).filter((row) => this.isUuid(row.id) && this.isUuid(row.user_id)); + } catch (err: any) { + logger.error(`[Supabase] Unexpected all profile fetch error: ${err.message}`); + return []; + } + } + + private normalizeStrategyConfig(rawConfig: any): any { + const safeConfig = rawConfig && typeof rawConfig === 'object' && !Array.isArray(rawConfig) + ? rawConfig + : {}; + + const rawRules = Array.isArray(safeConfig.rules) ? safeConfig.rules : []; + const rules = rawRules + .filter((rule: any) => rule && typeof rule === 'object' && typeof rule.ruleId === 'string') + .map((rule: any) => ({ + ruleId: rule.ruleId, + enabled: Boolean(rule.enabled), + ruleType: (rule.ruleType === 'mandatory' || rule.ruleType === 'voting') ? rule.ruleType : undefined, + params: this.normalizeRuleParams(rule.ruleId, rule.params) + })); + + const riskLimits = safeConfig.riskLimits && typeof safeConfig.riskLimits === 'object' + ? safeConfig.riskLimits + : {}; + const maxDailyLossUsd = Number(riskLimits.maxDailyLossUsd); + const maxOpenTrades = Number(riskLimits.maxOpenTrades); + const maxConsecutiveLosses = Number(riskLimits.maxConsecutiveLosses); + const dailyProfitTargetUsd = Number(riskLimits.dailyProfitTargetUsd); + + const execution = safeConfig.execution && typeof safeConfig.execution === 'object' + ? safeConfig.execution + : {}; + const rawOrderType = String(execution.orderType || this.defaultExecution.orderType).toLowerCase(); + const orderType = rawOrderType === 'limit' ? 'limit' : 'market'; + const cooldownMinutes = Number(execution.cooldownMinutes); + const minRulePassRatio = Number(execution.minRulePassRatio); + const rawEntryMode = String( + execution.entryMode ?? (execution.longOnly ? 'long_only' : this.defaultExecution.entryMode) + ).toLowerCase(); + const entryMode = (rawEntryMode === 'long_only' || rawEntryMode === 'longonly' || rawEntryMode === 'buy_only') + ? 'long_only' + : 'both'; + + return { + ...safeConfig, + rules, + riskLimits: { + maxDailyLossUsd: Number.isFinite(maxDailyLossUsd) && maxDailyLossUsd > 0 + ? maxDailyLossUsd + : this.defaultRiskLimits.maxDailyLossUsd, + maxOpenTrades: Number.isFinite(maxOpenTrades) && maxOpenTrades > 0 + ? Math.floor(maxOpenTrades) + : this.defaultRiskLimits.maxOpenTrades, + maxConsecutiveLosses: Number.isFinite(maxConsecutiveLosses) && maxConsecutiveLosses >= 0 + ? Math.floor(maxConsecutiveLosses) + : this.defaultRiskLimits.maxConsecutiveLosses, + dailyProfitTargetUsd: Number.isFinite(dailyProfitTargetUsd) && dailyProfitTargetUsd > 0 + ? dailyProfitTargetUsd + : undefined + }, + execution: { + ...execution, + orderType, + cooldownMinutes: Number.isFinite(cooldownMinutes) && cooldownMinutes >= 0 + ? cooldownMinutes + : this.defaultExecution.cooldownMinutes, + entryMode, + minRulePassRatio: Number.isFinite(minRulePassRatio) && minRulePassRatio >= 0 && minRulePassRatio <= 1 + ? minRulePassRatio + : 1.0 + } + }; + } + + private normalizeRuleParams(ruleId: string, rawParams: any): Record { + const params = rawParams && typeof rawParams === 'object' && !Array.isArray(rawParams) + ? { ...rawParams } + : {}; + + if (ruleId === 'SessionRule' && params.allowedSessions && !params.sessions) { + params.sessions = params.allowedSessions; + } + + if (ruleId === 'AIAnalysisRule') { + if (params.confidenceThreshold !== undefined && params.minConfidence === undefined) { + params.minConfidence = params.confidenceThreshold; + } + const minConfidence = Number(params.minConfidence); + if (Number.isFinite(minConfidence) && minConfidence >= 0) { + params.minConfidence = minConfidence <= 1 ? minConfidence * 100 : minConfidence; + } + } + + return params; + } + + /** + * Returns today's realized net profit/loss (USD) for a profile. + * Can be positive (profit) or negative (loss). + */ + async getProfileDailyNetPnlUsd(profileId: string): Promise { + if (!this.client) return 0; + + try { + const now = new Date(); + const startOfDayUtc = new Date(Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth(), + now.getUTCDate() + )); + + const { data, error } = await this.client + .from('trade_history') + .select('pnl, created_at') + .eq('profile_id', profileId) + .gte('created_at', startOfDayUtc.toISOString()) + .order('created_at', { ascending: false }) + .limit(5000); + + if (error) { + logger.error(`[Supabase] Error fetching daily net PnL for profile ${profileId}: ${error.message}`); + return 0; + } + + const netPnl = (data || []).reduce((sum: number, row: any) => { + const pnl = Number(row?.pnl || 0); + return Number.isFinite(pnl) ? sum + pnl : sum; + }, 0); + + return netPnl; + } catch (err: any) { + logger.error(`[Supabase] Unexpected daily net PnL error for profile ${profileId}: ${err.message}`); + return 0; + } + } + + /** + * Returns today's realized loss (absolute USD) for a profile. + * Positive net pnl returns 0, negative net pnl returns abs(net). + */ + async getProfileDailyLossUsd(profileId: string): Promise { + if (!this.client) return 0; + + try { + const now = new Date(); + const startOfDayUtc = new Date(Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth(), + now.getUTCDate() + )); + + const { data, error } = await this.client + .from('trade_history') + .select('pnl, created_at') + .eq('profile_id', profileId) + .gte('created_at', startOfDayUtc.toISOString()) + .order('created_at', { ascending: false }) + .limit(5000); + + if (error) { + logger.error(`[Supabase] Error fetching daily loss for profile ${profileId}: ${error.message}`); + return 0; + } + + const netPnl = (data || []).reduce((sum: number, row: any) => { + const pnl = Number(row?.pnl || 0); + return Number.isFinite(pnl) ? sum + pnl : sum; + }, 0); + + return netPnl < 0 ? Math.abs(netPnl) : 0; + } catch (err: any) { + logger.error(`[Supabase] Unexpected daily loss error for profile ${profileId}: ${err.message}`); + return 0; + } + } + + /** + * Returns the current consecutive losing trade count for a profile. + * Stops counting at first non-losing trade in reverse chronological order. + */ + async getProfileConsecutiveLosses(profileId: string, lookback: number = 100): Promise { + if (!this.client) return 0; + + try { + const cappedLookback = Math.max(1, Math.min(500, Math.floor(lookback))); + const { data, error } = await this.client + .from('trade_history') + .select('pnl, created_at') + .eq('profile_id', profileId) + .order('created_at', { ascending: false }) + .limit(cappedLookback); + + if (error) { + logger.error(`[Supabase] Error fetching consecutive losses for profile ${profileId}: ${error.message}`); + return 0; + } + + let consecutiveLosses = 0; + for (const row of data || []) { + const pnl = Number((row as any)?.pnl || 0); + if (!Number.isFinite(pnl) || pnl >= 0) break; + consecutiveLosses++; + } + return consecutiveLosses; + } catch (err: any) { + logger.error(`[Supabase] Unexpected consecutive loss error for profile ${profileId}: ${err.message}`); + return 0; + } + } + + /** + * Get orders stuck in pending_new status for more than X minutes. + * Optionally scoped to a single profile for per-account reconciliation. + */ + async getStaleOrders( + staleThresholdMinutes: number = 5, + scope?: string | StaleOrderScope + ): Promise { + if (!this.client) return []; + + try { + const thresholdTime = new Date(Date.now() - staleThresholdMinutes * 60 * 1000).toISOString(); + const scopeObject: StaleOrderScope = typeof scope === 'string' + ? { profileId: scope } + : (scope || {}); + const profileIdRaw = String(scopeObject.profileId || '').trim(); + const profileId = this.isUuid(profileIdRaw) ? profileIdRaw : ''; + const userId = String(scopeObject.userId || '').trim(); + const includeOrphanUserOrders = Boolean(scopeObject.includeOrphanUserOrders && userId); + const profileNullOnly = Boolean(scopeObject.profileNullOnly); + + let query = this.client + .from('orders') + .select('*') + .in('status', ['pending_new', 'pending', 'accepted', 'new']) + .lt('created_at', thresholdTime) + .order('created_at', { ascending: true }) + .limit(250); // Process in bounded batches + + if (profileNullOnly) { + query = query.is('profile_id', null); + if (userId) { + query = query.eq('user_id', userId); + } + } else if (profileId && includeOrphanUserOrders) { + query = query.or(`profile_id.eq.${profileId},and(profile_id.is.null,user_id.eq.${userId})`); + } else if (profileId) { + query = query.eq('profile_id', profileId); + } else if (userId) { + query = query.eq('user_id', userId); + } + + const { data, error } = await query; + + if (error) { + logger.error(`[Supabase] Error fetching stale orders: ${error.message}`); + return []; + } + + return data || []; + } catch (err: any) { + logger.error(`Supabase unexpected stale orders error: ${err.message}`); + return []; + } + } + + /** + * Get orders marked as 'expired' or 'unknown' (useful for cleanup/revert) + */ + async getExpiredOrUnknownOrders(): Promise { + if (!this.client) return []; + + try { + const { data, error } = await this.client + .from('orders') + .select('*') + .in('status', ['expired', 'unknown']); + + if (error) { + logger.error(`[Supabase] Error fetching expired orders: ${error.message}`); + return []; + } + + return data || []; + } catch (err: any) { + logger.error(`Supabase unexpected expired fetch error: ${err.message}`); + return []; + } + } + + async getPendingOrdersForProfile(profileId: string): Promise { + if (!this.client) return []; + + try { + const { data, error } = await this.client + .from('orders') + .select('*') + .eq('profile_id', profileId) + .eq('status', 'pending_new'); + + if (error) { + logger.error(`[Supabase] Error fetching pending orders for profile ${profileId}: ${error.message}`); + return []; + } + + return data || []; + } catch (err: any) { + logger.error(`Supabase unexpected pending fetch error: ${err.message}`); + return []; + } + } + + async getOpenOrdersForProfile(profileId: string): Promise { + if (!this.client || !profileId) return []; + + try { + const openStatuses = [ + 'pending_new', + 'accepted', + 'pending', + 'new', + 'partially_filled', + 'partially-filled' + ]; + const { data, error } = await this.client + .from('orders') + .select('id,order_id,profile_id,symbol,status,action,trade_id,created_at') + .eq('profile_id', profileId) + .in('status', openStatuses) + .order('created_at', { ascending: true }); + + if (error) { + logger.error(`[Supabase] Error fetching open orders for profile ${profileId}: ${error.message}`); + return []; + } + + return data || []; + } catch (err: any) { + logger.error(`Supabase unexpected open orders fetch error for profile ${profileId}: ${err.message}`); + return []; + } + } + + async getRecentlyClosedOrdersForProfile(profileId: string, minutes: number = 10): Promise { + if (!this.client || !profileId) return []; + const safeMinutes = Math.max(1, Math.floor(minutes)); + const since = new Date(Date.now() - safeMinutes * 60 * 1000).toISOString(); + + try { + const { data, error } = await this.client + .from('orders') + .select('*') + .eq('profile_id', profileId) + .in('status', ['filled', 'canceled', 'expired', 'rejected', 'unknown']) + .gte('updated_at', since) + .order('updated_at', { ascending: true }); + + if (error) { + logger.error(`[Supabase] Error fetching recent closed orders for profile ${profileId}: ${error.message}`); + return []; + } + + return data || []; + } catch (err: any) { + logger.error(`Supabase unexpected recent closed orders fetch error for profile ${profileId}: ${err.message}`); + return []; + } + } + + private async fetchFilledLifecycleOrders(options: { + userId?: string; + profileId?: string; + symbols?: string[]; + maxRows?: number; + }): Promise<{ rows: FilledLifecycleOrderRow[]; truncated: boolean }> { + if (!this.client) return { rows: [], truncated: false }; + + const safeUserId = this.isUuid(options.userId) ? String(options.userId) : ''; + const safeProfileId = this.isUuid(options.profileId) ? String(options.profileId) : ''; + const safeSymbols = Array.isArray(options.symbols) + ? options.symbols.map((value) => String(value || '').trim()).filter(Boolean) + : []; + const maxRows = Math.max(1000, Math.min(200_000, Math.floor(Number(options.maxRows || 50_000)))); + + const statusFilter = ['filled', 'partially_filled', 'partially-filled']; + const columnVariants = [ + 'id,order_id,user_id,profile_id,symbol,trade_id,action,side,qty,quantity,price,status,source,sub_tag,stop_loss,take_profit,timestamp,created_at,filled_at,type', + 'id,order_id,user_id,profile_id,symbol,trade_id,action,side,qty,quantity,price,status,source,stop_loss,take_profit,timestamp,created_at,filled_at,type', + 'id,order_id,user_id,profile_id,symbol,trade_id,action,side,qty,quantity,price,status,source,timestamp,created_at,filled_at,type', + 'id,order_id,user_id,profile_id,symbol,trade_id,action,side,qty,quantity,price,status,source,timestamp,created_at,type', + 'id,order_id,user_id,profile_id,symbol,trade_id,action,side,qty,quantity,price,status,timestamp,created_at,type' + ]; + + const rows: FilledLifecycleOrderRow[] = []; + const pageSize = 1000; + let offset = 0; + let truncated = false; + let selectedColumns = columnVariants[0]; + + const buildQuery = (columns: string) => { + let query = this.client! + .from('orders') + .select(columns) + .in('status', statusFilter) + .order('created_at', { ascending: true }) + .range(offset, offset + pageSize - 1); + + if (safeUserId) { + query = query.eq('user_id', safeUserId); + } + if (safeProfileId) { + query = query.eq('profile_id', safeProfileId); + } + if (safeSymbols.length > 0) { + query = query.in('symbol', safeSymbols); + } + return query; + }; + + try { + for (; ;) { + let data: FilledLifecycleOrderRow[] | null = null; + let finalError: any = null; + + for (const columns of columnVariants) { + const { data: candidateData, error } = await buildQuery(columns); + if (!error) { + selectedColumns = columns; + data = (candidateData || []) as FilledLifecycleOrderRow[]; + finalError = null; + break; + } + finalError = error; + const missingKnownColumn = this.isMissingColumnError(error, 'orders', 'sub_tag') + || this.isMissingColumnError(error, 'orders', 'filled_at') + || this.isMissingColumnError(error, 'orders', 'stop_loss') + || this.isMissingColumnError(error, 'orders', 'take_profit') + || this.isMissingColumnError(error, 'orders', 'quantity') + || this.isMissingColumnError(error, 'orders', 'source'); + if (!missingKnownColumn) { + break; + } + } + + if (finalError) { + logger.error(`[Supabase] Error fetching filled lifecycle rows (columns=${selectedColumns}): ${finalError.message}`); + return { rows: [], truncated: false }; + } + + const batch = data || []; + if (batch.length === 0) break; + rows.push(...batch); + + if (rows.length >= maxRows) { + rows.length = maxRows; + truncated = true; + break; + } + + if (batch.length < pageSize) break; + offset += pageSize; + } + + return { rows, truncated }; + } catch (err: any) { + logger.error(`[Supabase] Unexpected filled lifecycle fetch error: ${err.message}`); + return { rows: [], truncated: false }; + } + } + + async getFilledLifecycleOrdersForProfile(profileId: string, symbols?: string[]): Promise { + if (!profileId) return []; + const result = await this.fetchFilledLifecycleOrders({ + profileId, + symbols + }); + return result.rows; + } + + async getFilledLifecycleOrdersForUser(options: { + userId: string; + profileId?: string; + symbols?: string[]; + maxRows?: number; + }): Promise<{ rows: FilledLifecycleOrderRow[]; truncated: boolean }> { + const userId = String(options.userId || '').trim(); + if (!this.isUuid(userId)) return { rows: [], truncated: false }; + const profileId = String(options.profileId || '').trim(); + return await this.fetchFilledLifecycleOrders({ + userId, + profileId: profileId || undefined, + symbols: options.symbols, + maxRows: options.maxRows + }); + } + + async getFilledLifecycleOrdersGlobal(options?: { + profileId?: string; + symbols?: string[]; + maxRows?: number; + }): Promise<{ rows: FilledLifecycleOrderRow[]; truncated: boolean }> { + const profileId = String(options?.profileId || '').trim(); + return await this.fetchFilledLifecycleOrders({ + profileId: profileId || undefined, + symbols: options?.symbols, + maxRows: options?.maxRows + }); + } + + async getExistingOrderIds(orderIds: string[], profileId?: string): Promise> { + if (!this.client) return new Set(); + const normalizedIds = Array.from(new Set(orderIds.map((id) => String(id || '').trim()).filter(Boolean))); + if (normalizedIds.length === 0) return new Set(); + + const found = new Set(); + const chunkSize = 100; + for (let i = 0; i < normalizedIds.length; i += chunkSize) { + const chunk = normalizedIds.slice(i, i + chunkSize); + try { + let query = this.client + .from('orders') + .select('order_id') + .in('order_id', chunk) + .limit(chunk.length); + + if (this.isUuid(profileId)) { + query = query.eq('profile_id', profileId); + } + + const { data, error } = await query; + if (error) { + logger.error(`[Supabase] Error checking existing order ids: ${error.message}`); + continue; + } + + for (const row of (data || []) as Array<{ order_id?: string | null }>) { + const orderId = String(row.order_id || '').trim(); + if (orderId) found.add(orderId); + } + } catch (err: any) { + logger.error(`[Supabase] Unexpected existing order id lookup error: ${err.message}`); + } + } + + return found; + } + + async upsertReconciliationBackfillOrders(rows: ReconciliationBackfillOrderInsert[]): Promise { + const client = this.client; + if (!client || rows.length === 0) return true; + const normalizedInputRows = this.hydrateBackfillSubTags(rows); + + const stripSubTag = (inputRows: ReconciliationBackfillOrderInsert[]): ReconciliationBackfillOrderInsert[] => + inputRows.map((row) => { + const { sub_tag: _subTag, ...rest } = row as any; + return rest as ReconciliationBackfillOrderInsert; + }); + + const stripFilledAt = (inputRows: ReconciliationBackfillOrderInsert[]): ReconciliationBackfillOrderInsert[] => + inputRows.map((row) => { + const { filled_at: _filledAt, ...rest } = row as any; + return rest as ReconciliationBackfillOrderInsert; + }); + + const adaptRowsForMissingColumn = ( + inputRows: ReconciliationBackfillOrderInsert[], + error: any + ): { rows: ReconciliationBackfillOrderInsert[]; changed: boolean } => { + let rowsOut = inputRows; + let changed = false; + + if (this.isMissingColumnError(error, 'orders', 'sub_tag')) { + this.ordersSupportsSubTag = false; + rowsOut = stripSubTag(rowsOut); + changed = true; + } + + if (this.isMissingColumnError(error, 'orders', 'filled_at')) { + rowsOut = stripFilledAt(rowsOut); + changed = true; + } + + return { rows: rowsOut, changed }; + }; + + const tryInsertWithColumnFallback = async (inputRows: ReconciliationBackfillOrderInsert[]): Promise<{ error: any }> => { + let candidateRows = inputRows; + for (let attempt = 0; attempt < 3; attempt += 1) { + const result = await client + .from('orders') + .insert(candidateRows as any[]); + if (!result.error) { + if (candidateRows.some((row) => String((row as any).sub_tag || '').trim())) { + this.ordersSupportsSubTag = true; + } + return { error: null }; + } + + const adapted = adaptRowsForMissingColumn(candidateRows, result.error); + if (!adapted.changed) { + return { error: result.error }; + } + candidateRows = adapted.rows; + } + return { error: new Error('Exceeded insert fallback attempts for backfill orders') }; + }; + + const tryUpsertWithColumnFallback = async (inputRows: ReconciliationBackfillOrderInsert[]): Promise<{ error: any; rowsUsed: ReconciliationBackfillOrderInsert[] }> => { + let candidateRows = inputRows; + for (let attempt = 0; attempt < 3; attempt += 1) { + const result = await client + .from('orders') + .upsert(candidateRows as any[], { + onConflict: 'order_id', + ignoreDuplicates: true + }); + if (!result.error) { + if (candidateRows.some((row) => String((row as any).sub_tag || '').trim())) { + this.ordersSupportsSubTag = true; + } + return { error: null, rowsUsed: candidateRows }; + } + + const adapted = adaptRowsForMissingColumn(candidateRows, result.error); + if (!adapted.changed) { + return { error: result.error, rowsUsed: candidateRows }; + } + candidateRows = adapted.rows; + } + return { + error: new Error('Exceeded upsert fallback attempts for backfill orders'), + rowsUsed: inputRows + }; + }; + + const dedupeRows = (input: ReconciliationBackfillOrderInsert[]): ReconciliationBackfillOrderInsert[] => { + const seen = new Set(); + const unique: ReconciliationBackfillOrderInsert[] = []; + for (const row of input) { + const profileId = String(row.profile_id || '').trim(); + const orderId = String(row.order_id || '').trim(); + if (!orderId) continue; + const key = `${profileId}::${orderId}`; + if (seen.has(key)) continue; + seen.add(key); + unique.push(row); + } + return unique; + }; + + const insertMissingRows = async (inputRows: ReconciliationBackfillOrderInsert[]): Promise => { + const rowsWithSchemaHints = this.ordersSupportsSubTag === false + ? stripSubTag(inputRows) + : inputRows; + const normalizedRows = dedupeRows(rowsWithSchemaHints); + if (normalizedRows.length === 0) return true; + + const rowsByProfile = new Map(); + for (const row of normalizedRows) { + const profileKey = String(row.profile_id || '').trim(); + const list = rowsByProfile.get(profileKey) || []; + list.push(row); + rowsByProfile.set(profileKey, list); + } + + const missingOnly: ReconciliationBackfillOrderInsert[] = []; + for (const [profileKey, profileRows] of rowsByProfile.entries()) { + const orderIds = profileRows.map((row) => String(row.order_id || '').trim()).filter(Boolean); + if (orderIds.length === 0) continue; + const existingIds = await this.getExistingOrderIds(orderIds, profileKey || undefined); + for (const row of profileRows) { + const orderId = String(row.order_id || '').trim(); + if (!orderId || existingIds.has(orderId)) continue; + missingOnly.push(row); + } + } + + if (missingOnly.length === 0) { + return true; + } + + const { error: insertError } = await tryInsertWithColumnFallback(missingOnly); + if (!insertError) return true; + logger.error(`[Supabase] Backfill fallback insert failed: ${insertError.message}`); + return false; + }; + + try { + const rowsWithSchemaHints = this.ordersSupportsSubTag === false + ? stripSubTag(normalizedInputRows) + : normalizedInputRows; + const { error, rowsUsed } = await tryUpsertWithColumnFallback(rowsWithSchemaHints); + + if (!error) return true; + + if (this.isMissingOnConflictConstraint(error)) { + logger.warn('[Supabase] orders.order_id lacks ON CONFLICT constraint. Falling back to pre-checked insert path.'); + return await insertMissingRows(rowsUsed); + } + + logger.error(`[Supabase] Backfill order upsert failed: ${error.message}`); + const { rows: fallbackRows } = adaptRowsForMissingColumn(rowsUsed, error); + const { error: fallbackError } = await tryUpsertWithColumnFallback(fallbackRows); + + if (fallbackError && this.isMissingOnConflictConstraint(fallbackError)) { + logger.warn('[Supabase] orders.order_id lacks ON CONFLICT constraint on legacy schema fallback. Using pre-checked insert path.'); + return await insertMissingRows(fallbackRows as ReconciliationBackfillOrderInsert[]); + } + + if (fallbackError) { + logger.error(`[Supabase] Backfill order upsert fallback failed: ${fallbackError.message}`); + return false; + } + return true; + } catch (err: any) { + logger.error(`[Supabase] Unexpected backfill order upsert error: ${err.message}`); + return false; + } + } + + async isReconciliationBackfillAuditAvailable(forceRefresh: boolean = false): Promise { + if (!this.client) return false; + if (!forceRefresh && this.reconciliationBackfillAuditTableAvailable !== null) { + return this.reconciliationBackfillAuditTableAvailable; + } + + try { + const { error } = await this.client + .from('reconciliation_backfill_audit') + .select('id') + .limit(1); + + if (error) { + if (this.isMissingRelationError(error, 'reconciliation_backfill_audit')) { + this.reconciliationBackfillAuditTableAvailable = false; + return false; + } + logger.error(`[Supabase] Backfill audit table probe failed: ${error.message}`); + this.reconciliationBackfillAuditTableAvailable = false; + return false; + } + + this.reconciliationBackfillAuditTableAvailable = true; + return true; + } catch (err: any) { + logger.error(`[Supabase] Unexpected backfill audit table probe error: ${err.message}`); + this.reconciliationBackfillAuditTableAvailable = false; + return false; + } + } + + async insertReconciliationBackfillAuditRows(rows: ReconciliationBackfillAuditInsert[]): Promise { + if (!this.client || rows.length === 0) return true; + + const available = await this.isReconciliationBackfillAuditAvailable(); + if (!available) { + logger.error('[Supabase] reconciliation_backfill_audit table is not available.'); + return false; + } + + try { + const { error } = await this.client + .from('reconciliation_backfill_audit') + .insert(rows); + + if (error) { + logger.error(`[Supabase] Backfill audit insert failed: ${error.message}`); + return false; + } + return true; + } catch (err: any) { + logger.error(`[Supabase] Unexpected backfill audit insert error: ${err.message}`); + return false; + } + } + + async getReconciliationBackfillAuditRows(query: ReconciliationBackfillAuditQuery): Promise<{ rows: ReconciliationBackfillAuditRow[]; totalCount: number }> { + if (!this.client) return { rows: [], totalCount: 0 }; + const available = await this.isReconciliationBackfillAuditAvailable(); + if (!available) return { rows: [], totalCount: 0 }; + + const safeLimit = Math.max(1, Math.min(500, Math.floor(Number(query.limit || 100)))); + const safeOffset = Math.max(0, Math.floor(Number(query.offset || 0))); + + try { + let builder = this.client + .from('reconciliation_backfill_audit') + .select( + 'id,batch_id,profile_id,symbol,trade_id,exchange_order_id,exchange_client_order_id,backfill_order_id,filled_qty,filled_price,filled_at,dry_run,decision,reason,metadata,applied_at,reverted_at,created_at', + { count: 'exact' } + ) + .order('created_at', { ascending: false }) + .range(safeOffset, safeOffset + safeLimit - 1); + + const profileId = String(query.profileId || '').trim(); + const symbol = String(query.symbol || '').trim(); + const batchId = String(query.batchId || '').trim(); + const fromIso = String(query.fromIso || '').trim(); + const toIso = String(query.toIso || '').trim(); + const decisions = Array.isArray(query.decisions) + ? query.decisions.map((value) => String(value || '').trim()).filter(Boolean) + : []; + + if (profileId) { + builder = builder.eq('profile_id', profileId); + } + if (symbol) { + builder = builder.eq('symbol', symbol); + } + if (batchId) { + builder = builder.eq('batch_id', batchId); + } + if (decisions.length > 0) { + builder = builder.in('decision', decisions); + } + if (fromIso) { + builder = builder.gte('created_at', fromIso); + } + if (toIso) { + builder = builder.lte('created_at', toIso); + } + + const { data, error, count } = await builder; + if (error) { + logger.error(`[Supabase] Backfill audit row query failed: ${error.message}`); + return { rows: [], totalCount: 0 }; + } + + return { + rows: ((data || []) as ReconciliationBackfillAuditRow[]), + totalCount: Number(count || 0) + }; + } catch (err: any) { + logger.error(`[Supabase] Unexpected backfill audit row query error: ${err.message}`); + return { rows: [], totalCount: 0 }; + } + } + + async getReconciliationBackfillBatchSummaries(query: { + profileId?: string; + symbol?: string; + fromIso?: string; + toIso?: string; + limit?: number; + }): Promise { + if (!this.client) return []; + const available = await this.isReconciliationBackfillAuditAvailable(); + if (!available) return []; + + const safeBatchLimit = Math.max(1, Math.min(100, Math.floor(Number(query.limit || 20)))); + const scanLimit = Math.max(500, safeBatchLimit * 200); + + try { + let builder = this.client + .from('reconciliation_backfill_audit') + .select('batch_id,profile_id,symbol,decision,dry_run,created_at,applied_at,reverted_at') + .order('created_at', { ascending: false }) + .limit(scanLimit); + + const profileId = String(query.profileId || '').trim(); + const symbol = String(query.symbol || '').trim(); + const fromIso = String(query.fromIso || '').trim(); + const toIso = String(query.toIso || '').trim(); + + if (profileId) { + builder = builder.eq('profile_id', profileId); + } + if (symbol) { + builder = builder.eq('symbol', symbol); + } + if (fromIso) { + builder = builder.gte('created_at', fromIso); + } + if (toIso) { + builder = builder.lte('created_at', toIso); + } + + const { data, error } = await builder; + if (error) { + logger.error(`[Supabase] Backfill batch summary query failed: ${error.message}`); + return []; + } + + const rows = (data || []) as Array<{ + batch_id?: string | null; + profile_id?: string | null; + symbol?: string | null; + decision?: string | null; + dry_run?: boolean | null; + created_at?: string | null; + applied_at?: string | null; + reverted_at?: string | null; + }>; + + const summaries = new Map(); + for (const row of rows) { + const batchId = String(row.batch_id || '').trim(); + if (!batchId) continue; + const createdAt = String(row.created_at || '').trim(); + if (!createdAt) continue; + + let summary = summaries.get(batchId); + if (!summary) { + summary = { + batchId, + firstSeenAt: createdAt, + lastSeenAt: createdAt, + profileIds: [], + symbols: [], + totalRows: 0, + byDecision: {}, + dryRunRows: 0, + appliedRows: 0, + revertedRows: 0 + }; + summaries.set(batchId, summary); + } + + summary.totalRows += 1; + const decision = String(row.decision || '').trim() || 'UNKNOWN'; + summary.byDecision[decision] = (summary.byDecision[decision] || 0) + 1; + if (row.dry_run) summary.dryRunRows += 1; + if (String(row.applied_at || '').trim()) summary.appliedRows += 1; + if (String(row.reverted_at || '').trim()) summary.revertedRows += 1; + + const profile = String(row.profile_id || '').trim(); + const symbolValue = String(row.symbol || '').trim(); + if (profile && !summary.profileIds.includes(profile)) summary.profileIds.push(profile); + if (symbolValue && !summary.symbols.includes(symbolValue)) summary.symbols.push(symbolValue); + + if (createdAt < summary.firstSeenAt) summary.firstSeenAt = createdAt; + if (createdAt > summary.lastSeenAt) summary.lastSeenAt = createdAt; + } + + return Array.from(summaries.values()) + .sort((a, b) => Date.parse(b.lastSeenAt) - Date.parse(a.lastSeenAt)) + .slice(0, safeBatchLimit); + } catch (err: any) { + logger.error(`[Supabase] Unexpected backfill batch summary error: ${err.message}`); + return []; + } + } + + /** + * Reverts a reconciliation backfill batch in a non-destructive way. + * Safety model: + * - never delete rows + * - status-only rollback on synthetic BFILL-* orders + * - keep immutable audit history with decision=reverted marker + */ + async revertBackfillBatch(batchId: string): Promise<{ reverted: number; errors: string[] }> { + const errors: string[] = []; + if (!this.client || !batchId) { + errors.push('Missing client or batchId'); + return { reverted: 0, errors }; + } + + try { + // 1. Read applied rows for this batch. + const { data: auditRows, error: auditError } = await this.client + .from('reconciliation_backfill_audit') + .select('backfill_order_id') + .eq('batch_id', batchId) + .eq('decision', 'APPLIED'); + + if (auditError) { + errors.push(`Revert fetch audit mapping failed: ${auditError.message}`); + return { reverted: 0, errors }; + } + + const orderIds = Array.from( + new Set( + (auditRows || []) + .map((row: any) => String(row.backfill_order_id || '').trim()) + .filter((id) => id.length > 0 && id.startsWith('BFILL-')) + ) + ); + + if (orderIds.length > 0) { + // 2. Status-only rollback for synthetic backfill orders. + const { error: updateError } = await this.client + .from('orders') + .update({ status: 'canceled' }) + .in('order_id', orderIds) + .like('order_id', 'BFILL-%'); + + if (updateError) { + errors.push(`Order status rollback failed: ${updateError.message}`); + return { reverted: 0, errors }; + } + } + + // 3. Mark audit rows as REVERTED (append-only audit). + const { error: markError } = await this.client + .from('reconciliation_backfill_audit') + .update({ + decision: 'REVERTED', + reason: 'operator_revert_status_only', + reverted_at: new Date().toISOString() + }) + .eq('batch_id', batchId) + .eq('decision', 'APPLIED'); + + if (markError) { + errors.push(`Audit mark REVERTED failed: ${markError.message}`); + } + + return { reverted: orderIds.length, errors }; + } catch (err: any) { + logger.error(`[Supabase] Unexpected backfill batch revert error: ${err.message}`); + errors.push(`Unexpected error: ${err.message}`); + return { reverted: 0, errors }; + } + } + + async hasActiveOrderForTradeId(tradeId: string, profileId?: string): Promise { + if (!this.client) return false; + const normalizedTradeId = String(tradeId || '').trim(); + if (!normalizedTradeId) return false; + + try { + let query = this.client + .from('orders') + .select('id') + .eq('trade_id', normalizedTradeId) + .in('status', ['pending_new', 'accepted', 'new', 'partially_filled']) + .limit(1); + + if (this.isUuid(profileId)) { + query = query.eq('profile_id', profileId); + } + + const { data, error } = await query; + if (error) { + logger.error(`[Supabase] Error checking active trade ${normalizedTradeId}: ${error.message}`); + return false; + } + + return (data || []).length > 0; + } catch (err: any) { + logger.error(`[Supabase] Unexpected active trade check error for ${normalizedTradeId}: ${err.message}`); + return false; + } + } + + async getRecentOrdersForProfile(profileId: string, limit: number = 50): Promise { + if (!this.client) return []; + + try { + const safeLimit = Math.max(1, Math.min(500, Math.floor(limit))); + const { data, error } = await this.client + .from('orders') + .select('id,order_id,profile_id,symbol,status,action,trade_id,created_at') + .eq('profile_id', profileId) + .order('created_at', { ascending: false }) + .limit(safeLimit); + + if (error) { + logger.error(`[Supabase] Error fetching recent orders for profile ${profileId}: ${error.message}`); + return []; + } + + return data || []; + } catch (err: any) { + logger.error(`[Supabase] Unexpected recent-order fetch error for profile ${profileId}: ${err.message}`); + return []; + } + } + + async getKnownTradeIdsForProfile(profileId: string, limit: number = 2000): Promise { + if (!this.client || !profileId) return []; + + const safeLimit = Math.max(1, Math.min(10000, Math.floor(limit))); + const pageSize = Math.min(1000, safeLimit); + const tradeIds = new Set(); + let offset = 0; + + try { + while (tradeIds.size < safeLimit) { + const { data, error } = await this.client + .from('orders') + .select('trade_id') + .eq('profile_id', profileId) + .not('trade_id', 'is', null) + .order('created_at', { ascending: false }) + .range(offset, offset + pageSize - 1); + + if (error) { + logger.error(`[Supabase] Error fetching known trade ids for profile ${profileId}: ${error.message}`); + return Array.from(tradeIds); + } + + const chunk = (data || []) as Array<{ trade_id?: string | null }>; + if (chunk.length === 0) break; + + for (const row of chunk) { + const tradeId = String(row.trade_id || '').trim(); + if (!tradeId) continue; + tradeIds.add(tradeId); + if (tradeIds.size >= safeLimit) break; + } + + if (chunk.length < pageSize) break; + offset += pageSize; + } + + return Array.from(tradeIds); + } catch (err: any) { + logger.error(`[Supabase] Unexpected known trade-id fetch error for profile ${profileId}: ${err.message}`); + return Array.from(tradeIds); + } + } + + async repairMissingSubTagsForProfile(options: { + profileId: string; + lookbackHours: number; + maxRows: number; + dryRun: boolean; + }): Promise { + const summary: ReconciliationSubTagRepairSummary = { + attempted: true, + scannedRows: 0, + eligibleRows: 0, + updatedRows: 0, + skippedNoProfile: 0, + skippedNoTrade: 0, + skippedTagDisabled: 0, + skippedAlreadyTagged: 0, + dryRun: Boolean(options.dryRun) + }; + + if (!this.client) { + return { + ...summary, + attempted: false + }; + } + + const profileId = String(options.profileId || '').trim(); + if (!this.isUuid(profileId)) { + return { + ...summary, + attempted: false + }; + } + + if (this.ordersSupportsSubTag === false) { + return { + ...summary, + unsupported: true + }; + } + + const lookbackHours = Math.max(1, Math.floor(Number(options.lookbackHours || 720))); + const maxRows = Math.max(1, Math.min(5000, Math.floor(Number(options.maxRows || 500)))); + const sinceIso = new Date(Date.now() - lookbackHours * 60 * 60 * 1000).toISOString(); + + type RepairCandidateRow = { + id?: string | null; + profile_id?: string | null; + trade_id?: string | null; + action?: string | null; + side?: string | null; + sub_tag?: string | null; + source?: string | null; + }; + + let rows: RepairCandidateRow[] = []; + try { + const { data, error } = await this.client + .from('orders') + .select('id,profile_id,trade_id,action,side,sub_tag,source') + .eq('profile_id', profileId) + .is('sub_tag', null) + .or('source.is.null,source.neq.MANUAL') + .gte('created_at', sinceIso) + .order('created_at', { ascending: false }) + .limit(maxRows); + + if (error) { + if (this.isMissingColumnError(error, 'orders', 'sub_tag')) { + this.ordersSupportsSubTag = false; + return { + ...summary, + unsupported: true + }; + } + logger.error(`[Supabase] Missing sub_tag repair query failed for profile ${profileId}: ${error.message}`); + return summary; + } + + rows = (data || []) as RepairCandidateRow[]; + } catch (err: any) { + logger.error(`[Supabase] Unexpected missing sub_tag repair query error for profile ${profileId}: ${err.message}`); + return summary; + } + + summary.scannedRows = rows.length; + if (rows.length === 0) return summary; + + for (const row of rows) { + const rowId = String(row.id || '').trim(); + if (!rowId) continue; + + const rowProfileId = String(row.profile_id || '').trim(); + if (!rowProfileId) { + summary.skippedNoProfile += 1; + continue; + } + + const rowTradeId = String(row.trade_id || '').trim(); + if (!rowTradeId) { + summary.skippedNoTrade += 1; + continue; + } + + const existingTag = String(row.sub_tag || '').trim(); + if (existingTag) { + summary.skippedAlreadyTagged += 1; + continue; + } + + const action = normalizeOrderAction(row.action || undefined) + || (normalizeTradeSide(String(row.side || 'BUY')) === 'SELL' ? 'EXIT' : 'ENTRY'); + const derivedSubTag = this.resolvePersistedOrderSubTag({ + profile_id: rowProfileId, + trade_id: rowTradeId, + action + }); + if (!derivedSubTag) { + summary.skippedTagDisabled += 1; + continue; + } + summary.eligibleRows += 1; + + if (summary.dryRun) continue; + + try { + const { error } = await this.client + .from('orders') + .update({ + sub_tag: derivedSubTag, + updated_at: new Date().toISOString() + }) + .eq('id', rowId) + .is('sub_tag', null); + + if (error) { + if (this.isMissingColumnError(error, 'orders', 'sub_tag')) { + this.ordersSupportsSubTag = false; + return { + ...summary, + unsupported: true + }; + } + logger.error(`[Supabase] Missing sub_tag repair update failed for profile ${profileId} row ${rowId}: ${error.message}`); + continue; + } + + this.ordersSupportsSubTag = true; + summary.updatedRows += 1; + } catch (err: any) { + logger.error(`[Supabase] Unexpected missing sub_tag repair update error for profile ${profileId} row ${rowId}: ${err.message}`); + } + } + + return summary; + } + + /** + * Retrieves the latest FILLED ENTRY order to correctly link a trade chain. + * Searches by Profile ID (if provided) or User ID. + */ + async getLatestFilledEntry(userId: string, symbol: string, profileId?: string) { + if (!this.client) return null; + + try { + let query = this.client + .from('orders') + .select('*') + .eq('symbol', symbol) + .eq('action', 'ENTRY') + .in('status', ['filled', 'partially_filled']); + + const normalizedProfileId = String(profileId || '').trim(); + if (this.isUuid(normalizedProfileId)) { + query = query.eq('profile_id', normalizedProfileId); + } else { + query = query.eq('user_id', userId); + } + + const { data, error } = await query + .order('timestamp', { ascending: false }) // Use timestamp if available, fallback to created_at + .limit(1) + .maybeSingle(); + + if (error) { + logger.error(`[Supabase] Error fetching latest filled entry for ${symbol}: ${error.message}`); + return null; + } + return data; + } catch (err: any) { + logger.error(`Supabase unexpected filled entry fetch error: ${err.message}`); + return null; + } + } + + /** + * Broad search for any recent relevant order to recover context + */ + async getLatestEntryOrder(profileId: string | undefined, symbol: string, userId?: string) { + if (!this.client) return null; + + try { + let query = this.client + .from('orders') + .select('*') + .eq('symbol', symbol) + .eq('action', 'ENTRY') + .order('created_at', { ascending: false }) + .limit(1); + + const normalizedProfileId = String(profileId || '').trim(); + if (this.isUuid(normalizedProfileId)) { + query = query.eq('profile_id', normalizedProfileId); + } else { + const normalizedUserId = String(userId || '').trim(); + if (normalizedUserId) { + query = query.eq('user_id', normalizedUserId); + } + } + + const { data, error } = await query.maybeSingle(); + + if (error) { + logger.error(`[Supabase] Error fetching latest entry order for ${symbol}: ${error.message}`); + } + return data; + } catch (err: any) { + logger.error(`Supabase unexpected get entry order error: ${err.message}`); + return null; + } + } + + /** + * Finds the most recent filled ENTRY order with non-zero risk levels. + * Used as a fallback when virtual position reconstruction lacks SL/TP. + */ + async getLatestEntryRiskOrder(profileId: string, symbol: string, side?: 'BUY' | 'SELL') { + if (!this.client) return null; + + try { + let query = this.client + .from('orders') + .select('*') + .eq('profile_id', profileId) + .eq('symbol', symbol) + .eq('action', 'ENTRY') + .in('status', ['filled', 'partially_filled']) + .or('stop_loss.gt.0,take_profit.gt.0') + .order('created_at', { ascending: false }) + .limit(1); + + if (side) { + query = query.eq('side', side); + } + + const { data, error } = await query.maybeSingle(); + if (error) { + logger.error(`[Supabase] Error fetching latest entry risk order for ${profileId}/${symbol}: ${error.message}`); + return null; + } + + return data; + } catch (err: any) { + logger.error(`[Supabase] Unexpected latest entry risk order fetch error: ${err.message}`); + return null; + } + } + + /** + * Returns true if lifecycle has at least one filled ENTRY order. + * This prevents synthetic/ambiguous trade_ids from generating fake PnL logs. + */ + async hasLifecycleEntryOrder(tradeId: string, profileId?: string, symbol?: string): Promise { + if (!this.client) return false; + const normalizedTradeId = String(tradeId || '').trim(); + if (!normalizedTradeId) return false; + + try { + let query = this.client + .from('orders') + .select('id') + .eq('trade_id', normalizedTradeId) + .eq('action', 'ENTRY') + .in('status', ['filled', 'partially_filled']) + .limit(1); + + if (this.isUuid(profileId)) { + query = query.eq('profile_id', profileId); + } + if (symbol) { + query = query.eq('symbol', symbol); + } + + const { data, error } = await query; + if (error) { + logger.error(`[Supabase] Error checking lifecycle entry for ${normalizedTradeId}: ${error.message}. Failing closed.`); + return false; + } + + if ((data || []).length > 0) { + return true; + } + + // Legacy fallback: older rows can have NULL action for BUY entries. + let legacyQuery = this.client + .from('orders') + .select('id') + .eq('trade_id', normalizedTradeId) + .is('action', null) + .in('side', ['BUY', 'buy']) + .in('status', ['filled', 'partially_filled']) + .limit(1); + + if (this.isUuid(profileId)) { + legacyQuery = legacyQuery.eq('profile_id', profileId); + } + if (symbol) { + legacyQuery = legacyQuery.eq('symbol', symbol); + } + + const { data: legacyData, error: legacyError } = await legacyQuery; + if (legacyError) { + logger.error(`[Supabase] Error checking legacy lifecycle entry for ${normalizedTradeId}: ${legacyError.message}. Failing closed.`); + return false; + } + + return (legacyData || []).length > 0; + } catch (err: any) { + logger.error(`[Supabase] Unexpected lifecycle entry check error for ${normalizedTradeId}: ${err.message}. Failing closed.`); + return false; + } + } + + /** + * Strict attribution gate: + * returns true only when the ENTRY chain has at least one filled row carrying + * a Bytelyst sub-tag that maps back to the provided profile. + */ + async hasLifecycleEntryOrderWithProfileSubTag(tradeId: string, profileId: string, symbol?: string): Promise { + if (!this.client) return false; + const normalizedTradeId = String(tradeId || '').trim(); + const normalizedProfileId = String(profileId || '').trim(); + if (!normalizedTradeId || !this.isUuid(normalizedProfileId)) return false; + + try { + let query = this.client + .from('orders') + .select('sub_tag') + .eq('trade_id', normalizedTradeId) + .eq('profile_id', normalizedProfileId) + .eq('action', 'ENTRY') + .in('status', ['filled', 'partially_filled']) + .limit(250); + + if (symbol) { + query = query.eq('symbol', symbol); + } + + const { data, error } = await query; + if (error) { + logger.error(`[Supabase] Error checking lifecycle entry sub-tag attribution for ${normalizedTradeId}: ${error.message}`); + return false; + } + + for (const row of (data || []) as Array<{ sub_tag?: string | null }>) { + const subTag = String(row?.sub_tag || '').trim(); + if (!subTag) continue; + if (!isBytelystSubTag(subTag)) continue; + if (subTagBelongsToProfile(subTag, normalizedProfileId)) { + return true; + } + } + return false; + } catch (err: any) { + logger.error(`[Supabase] Unexpected lifecycle entry sub-tag attribution error for ${normalizedTradeId}: ${err.message}`); + return false; + } + } + + /** + * Returns true when a trade lifecycle already has a finalized (non-partial) history row. + * Used to enforce idempotent close logging. + */ + async hasFinalizedTradeHistory(tradeId: string, profileId?: string, symbol?: string): Promise { + if (!this.client) return false; + const normalizedTradeId = String(tradeId || '').trim(); + if (!normalizedTradeId) return false; + + try { + let query = this.client + .from('trade_history') + .select('id,reason') + .eq('trade_id', normalizedTradeId) + .limit(50); + + if (profileId) { + query = query.eq('profile_id', profileId); + } + if (symbol) { + query = query.eq('symbol', symbol); + } + + const { data, error } = await query; + if (error) { + logger.error(`[Supabase] Error checking finalized history for ${normalizedTradeId}: ${error.message}`); + return false; + } + + return (data || []).some((row: any) => { + const reason = String(row?.reason || '').toLowerCase(); + return !reason.includes('partial exit'); + }); + } catch (err: any) { + logger.error(`[Supabase] Unexpected finalized history check error for ${normalizedTradeId}: ${err.message}`); + return false; + } + } + + /** + * Determines whether a lifecycle identified by trade_id is already closed. + * Used to resolve stale EXIT orders that remain pending_new after the lifecycle + * has been finalized by another exchange event. + */ + async isTradeLifecycleClosed(tradeId: string, profileId?: string, symbol?: string): Promise { + if (!this.client) return false; + + const normalizedTradeId = String(tradeId || '').trim(); + if (!normalizedTradeId) return false; + + try { + let orderQuery = this.client + .from('orders') + .select('action, side, qty, status') + .eq('trade_id', normalizedTradeId) + .in('status', ['filled', 'partially_filled']); + + if (this.isUuid(profileId)) { + orderQuery = orderQuery.eq('profile_id', profileId); + } + if (symbol) { + orderQuery = orderQuery.eq('symbol', symbol); + } + + const { data: orderRows, error: orderError } = await orderQuery.limit(1000); + if (orderError) { + logger.error(`[Supabase] Error checking lifecycle closure for ${normalizedTradeId}: ${orderError.message}`); + return false; + } + + let entryQty = 0; + let exitQty = 0; + for (const row of orderRows || []) { + const action = this.inferLifecycleAction( + (row as any)?.action || undefined, + (row as any)?.side || undefined + ); + const qty = Number((row as any)?.qty || 0); + if (!Number.isFinite(qty) || qty <= 0) continue; + if (action === 'ENTRY') { + entryQty += qty; + } else if (action === 'EXIT') { + exitQty += qty; + } + } + + if (entryQty > 0 && exitQty >= entryQty - 1e-8) { + return true; + } + + let historyQuery = this.client + .from('trade_history') + .select('id,reason,size') + .eq('trade_id', normalizedTradeId) + .limit(200); + + if (this.isUuid(profileId)) { + historyQuery = historyQuery.eq('profile_id', profileId); + } + if (symbol) { + historyQuery = historyQuery.eq('symbol', symbol); + } + + const { data: historyRows, error: historyError } = await historyQuery; + if (historyError) { + logger.error(`[Supabase] Error checking history closure for ${normalizedTradeId}: ${historyError.message}`); + return false; + } + + const rows = historyRows || []; + if (!rows.length) return false; + + let finalizedRows = 0; + let partialExitQty = 0; + + for (const row of rows as any[]) { + const reason = String(row?.reason || '').toLowerCase(); + const size = Number(row?.size || 0); + if (reason.includes('partial exit')) { + if (Number.isFinite(size) && size > 0) { + partialExitQty += size; + } + continue; + } + finalizedRows += 1; + } + + if (finalizedRows > 0) { + return true; + } + + if (entryQty > 0 && partialExitQty >= entryQty - 1e-8) { + return true; + } + + return false; + } catch (err: any) { + logger.error(`[Supabase] Unexpected lifecycle closure check error for ${normalizedTradeId}: ${err.message}`); + return false; + } + } + + /** + * Reconstructs a profile-scoped virtual open position from filled order lifecycle. + * This is the source of truth for dedicated profiles sharing a single exchange account. + */ + async getVirtualOpenPosition(profileId: string, symbol: string): Promise { + if (!this.client) return null; + + type LedgerOrderRow = { + trade_id?: string | null; + action?: string | null; + side?: string | null; + qty?: number | string | null; + price?: number | string | null; + user_id?: string | null; + stop_loss?: number | string | null; + take_profit?: number | string | null; + timestamp?: number | string | null; + created_at?: string | null; + }; + + type TradeLedger = { + tradeId: string; + side: 'BUY' | 'SELL'; + entryQty: number; + entryNotional: number; + entryLastPrice: number; + exitQty: number; + userId?: string; + stopLoss: number; + takeProfit: number; + lastTs: number; + }; + + type SideAggregate = { + side: 'BUY' | 'SELL'; + qty: number; + notional: number; + userId?: string; + stopLoss: number; + takeProfit: number; + tradeIds: string[]; + primaryTradeId: string; + primaryTs: number; + }; + + const toNumber = (value: any): number => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; + }; + + const toTimestamp = (row: LedgerOrderRow, fallback: number): number => { + const ts = Number(row.timestamp); + if (Number.isFinite(ts) && ts > 0) return ts; + const createdAtTs = Date.parse(String(row.created_at || '')); + if (Number.isFinite(createdAtTs) && createdAtTs > 0) return createdAtTs; + return fallback; + }; + + try { + const symbolCandidates = this.buildLifecycleSymbolCandidates(symbol); + if (!symbolCandidates.length) return null; + const { data, error } = await this.client + .from('orders') + .select('trade_id, action, side, qty, price, user_id, stop_loss, take_profit, timestamp, created_at') + .eq('profile_id', profileId) + .in('symbol', symbolCandidates) + .in('status', ['filled', 'partially_filled']) + .order('created_at', { ascending: true }) + .limit(2000); + + if (error) { + logger.error(`[Supabase] Error computing virtual position for ${profileId}/${symbol}: ${error.message}`); + return null; + } + + const rows = (data || []) as LedgerOrderRow[]; + if (!rows.length) return null; + + const orderedRows = rows + .map((row, index) => ({ + row, + index, + ts: toTimestamp(row, index) + })) + .sort((a, b) => (a.ts - b.ts) || (a.index - b.index)); + + const ledgerByTrade = new Map(); + const entrySideByTrade = new Map(); + const openTradeQueueBySide: Record<'BUY' | 'SELL', string[]> = { BUY: [], SELL: [] }; + const normalizeToken = (value: string): string => + value.replace(/[^A-Za-z0-9]/g, '').slice(0, 24) || 'token'; + const profileToken = normalizeToken(profileId); + const symbolToken = normalizeToken(symbol); + let syntheticCounter = 0; + let inferredLegacyRows = 0; + const buildSyntheticTradeId = (side: 'BUY' | 'SELL', ts: number): string => { + syntheticCounter += 1; + const tsToken = Number.isFinite(ts) && ts > 0 ? Math.trunc(ts) : syntheticCounter; + return `__legacy__-${profileToken}-${symbolToken}-${side}-${tsToken}-${String(syntheticCounter).padStart(4, '0')}`; + }; + + for (const { row, ts } of orderedRows) { + const qty = toNumber(row.qty); + if (qty <= 0) continue; + + const rowSide = normalizeTradeSide(row.side || 'BUY'); + const rawTradeId = String(row.trade_id || '').trim(); + const oppositeSide: 'BUY' | 'SELL' = rowSide === 'BUY' ? 'SELL' : 'BUY'; + const explicitAction = normalizeOrderAction(row.action || undefined); + let tradeId = rawTradeId; + let action = explicitAction; + + if (!action && !tradeId) { + action = openTradeQueueBySide[oppositeSide].length > 0 ? 'EXIT' : 'ENTRY'; + inferredLegacyRows += 1; + } + + if (!tradeId) { + if (action === 'EXIT' && openTradeQueueBySide[oppositeSide].length > 0) { + tradeId = openTradeQueueBySide[oppositeSide][0]; + } else { + tradeId = buildSyntheticTradeId(action === 'EXIT' ? oppositeSide : rowSide, ts); + } + } + + if (!action) { + const knownEntrySide = entrySideByTrade.get(tradeId); + if (knownEntrySide) { + action = rowSide === knownEntrySide ? 'ENTRY' : 'EXIT'; + } else { + action = this.inferLifecycleAction(row.action || undefined, row.side || undefined); + } + } + + let tradeLedger = ledgerByTrade.get(tradeId); + if (!tradeLedger) { + tradeLedger = { + tradeId, + side: rowSide, + entryQty: 0, + entryNotional: 0, + entryLastPrice: 0, + exitQty: 0, + userId: row.user_id || undefined, + stopLoss: 0, + takeProfit: 0, + lastTs: ts + }; + ledgerByTrade.set(tradeId, tradeLedger); + } + + if (action === 'ENTRY') { + if (tradeLedger.entryQty === 0) { + tradeLedger.side = rowSide; + } + tradeLedger.entryQty += qty; + entrySideByTrade.set(tradeId, tradeLedger.side); + if (!openTradeQueueBySide[tradeLedger.side].includes(tradeId)) { + openTradeQueueBySide[tradeLedger.side].push(tradeId); + } + + const price = toNumber(row.price); + if (price > 0) { + tradeLedger.entryNotional += price * qty; + tradeLedger.entryLastPrice = price; + } + + const stopLoss = toNumber(row.stop_loss); + const takeProfit = toNumber(row.take_profit); + if (stopLoss > 0) tradeLedger.stopLoss = stopLoss; + if (takeProfit > 0) tradeLedger.takeProfit = takeProfit; + } else { + tradeLedger.exitQty += qty; + const queue = openTradeQueueBySide[oppositeSide]; + const tradeIdx = queue.findIndex((queuedTradeId) => queuedTradeId === tradeId); + if (tradeIdx >= 0) { + queue.splice(tradeIdx, 1); + } else if (queue.length > 0) { + queue.shift(); + } + } + + if (row.user_id) tradeLedger.userId = row.user_id; + tradeLedger.lastTs = Math.max(tradeLedger.lastTs, ts); + } + + if (inferredLegacyRows > 0) { + logger.warn(`[Supabase] Inferred lifecycle action for ${inferredLegacyRows} legacy rows in ${profileId}/${symbol} (missing action/trade_id).`); + } + + const aggregateBySide = new Map<'BUY' | 'SELL', SideAggregate>(); + for (const tradeLedger of ledgerByTrade.values()) { + const remainingQty = tradeLedger.entryQty - tradeLedger.exitQty; + if (remainingQty <= 1e-8) continue; + + const weightedEntryPrice = tradeLedger.entryQty > 0 && tradeLedger.entryNotional > 0 + ? (tradeLedger.entryNotional / tradeLedger.entryQty) + : tradeLedger.entryLastPrice; + if (!(weightedEntryPrice > 0)) continue; + + const normalizedTradeId = tradeLedger.tradeId.startsWith('__legacy__-') + ? `TRD-LEGACY-${tradeLedger.tradeId.slice('__legacy__-'.length)}` + : tradeLedger.tradeId; + + let aggregate = aggregateBySide.get(tradeLedger.side); + if (!aggregate) { + aggregate = { + side: tradeLedger.side, + qty: 0, + notional: 0, + userId: tradeLedger.userId, + stopLoss: tradeLedger.stopLoss, + takeProfit: tradeLedger.takeProfit, + tradeIds: [], + primaryTradeId: normalizedTradeId, + primaryTs: tradeLedger.lastTs + }; + aggregateBySide.set(tradeLedger.side, aggregate); + } + + aggregate.qty += remainingQty; + aggregate.notional += remainingQty * weightedEntryPrice; + if (!aggregate.tradeIds.includes(normalizedTradeId)) { + aggregate.tradeIds.push(normalizedTradeId); + } + + const currentPrimaryIsLegacy = aggregate.primaryTradeId.startsWith('TRD-LEGACY-'); + const candidateIsLegacy = normalizedTradeId.startsWith('TRD-LEGACY-'); + const shouldReplacePrimary = + (!candidateIsLegacy && currentPrimaryIsLegacy) + || (candidateIsLegacy === currentPrimaryIsLegacy && tradeLedger.lastTs >= aggregate.primaryTs); + + if (shouldReplacePrimary) { + aggregate.primaryTs = tradeLedger.lastTs; + aggregate.primaryTradeId = normalizedTradeId; + aggregate.userId = tradeLedger.userId || aggregate.userId; + if (tradeLedger.stopLoss > 0) aggregate.stopLoss = tradeLedger.stopLoss; + if (tradeLedger.takeProfit > 0) aggregate.takeProfit = tradeLedger.takeProfit; + } + } + + if (!aggregateBySide.size) return null; + + let dominantSidePosition: SideAggregate | null = null; + for (const candidate of aggregateBySide.values()) { + if (!dominantSidePosition || candidate.qty > dominantSidePosition.qty) { + dominantSidePosition = candidate; + } + } + + if (!dominantSidePosition || dominantSidePosition.qty <= 1e-8) return null; + if (aggregateBySide.size > 1) { + logger.warn(`[Supabase] Mixed-side virtual position detected for ${profileId}/${symbol}. Using dominant side ${dominantSidePosition.side}.`); + } + + let fallbackStopLoss = Number(dominantSidePosition.stopLoss || 0); + let fallbackTakeProfit = Number(dominantSidePosition.takeProfit || 0); + if (fallbackStopLoss <= 0 || fallbackTakeProfit <= 0) { + for (let i = orderedRows.length - 1; i >= 0; i--) { + const row = orderedRows[i].row; + const rowSide = normalizeTradeSide(row.side || 'BUY'); + if (rowSide !== dominantSidePosition.side) continue; + + const explicitAction = normalizeOrderAction(row.action || undefined); + const hasExplicitTradeId = String(row.trade_id || '').trim().length > 0; + if (!explicitAction && !hasExplicitTradeId) continue; + const inferredAction = explicitAction || (rowSide === 'BUY' ? 'ENTRY' : 'EXIT'); + if (inferredAction !== 'ENTRY') continue; + + const sl = toNumber(row.stop_loss); + const tp = toNumber(row.take_profit); + if (fallbackStopLoss <= 0 && sl > 0) fallbackStopLoss = sl; + if (fallbackTakeProfit <= 0 && tp > 0) fallbackTakeProfit = tp; + if (fallbackStopLoss > 0 && fallbackTakeProfit > 0) break; + } + } + + const entryPrice = dominantSidePosition.notional / dominantSidePosition.qty; + return { + profileId, + symbol, + side: dominantSidePosition.side, + qty: Number(dominantSidePosition.qty.toFixed(8)), + entryPrice: Number(entryPrice.toFixed(8)), + stopLoss: Number(fallbackStopLoss || 0), + takeProfit: Number(fallbackTakeProfit || 0), + userId: dominantSidePosition.userId, + tradeId: dominantSidePosition.primaryTradeId, + tradeIds: dominantSidePosition.tradeIds + }; + } catch (err: any) { + logger.error(`[Supabase] Unexpected virtual position reconstruction error for ${profileId}/${symbol}: ${err.message}`); + return null; + } + } + + async getVirtualOpenPositionForTrade(profileId: string, symbol: string, tradeId: string): Promise { + if (!this.client) return null; + const normalizedTradeId = String(tradeId || '').trim(); + if (!normalizedTradeId) return null; + + type LedgerOrderRow = { + action?: string | null; + side?: string | null; + qty?: number | string | null; + price?: number | string | null; + user_id?: string | null; + stop_loss?: number | string | null; + take_profit?: number | string | null; + timestamp?: number | string | null; + created_at?: string | null; + }; + + const toNumber = (value: any): number => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; + }; + + const toTimestamp = (row: LedgerOrderRow, fallback: number): number => { + const ts = Number(row.timestamp); + if (Number.isFinite(ts) && ts > 0) return ts; + const createdAtTs = Date.parse(String(row.created_at || '')); + if (Number.isFinite(createdAtTs) && createdAtTs > 0) return createdAtTs; + return fallback; + }; + + try { + const symbolCandidates = this.buildLifecycleSymbolCandidates(symbol); + if (!symbolCandidates.length) return null; + const { data, error } = await this.client + .from('orders') + .select('action, side, qty, price, user_id, stop_loss, take_profit, timestamp, created_at') + .eq('profile_id', profileId) + .in('symbol', symbolCandidates) + .eq('trade_id', normalizedTradeId) + .in('status', ['filled', 'partially_filled']) + .order('created_at', { ascending: true }) + .limit(1000); + + if (error) { + logger.error(`[Supabase] Error computing virtual trade slice for ${profileId}/${symbol}/${normalizedTradeId}: ${error.message}`); + return null; + } + + const rows = (data || []) as LedgerOrderRow[]; + if (!rows.length) return null; + + let entrySide: 'BUY' | 'SELL' | null = null; + let entryQty = 0; + let entryNotional = 0; + let entryLastPrice = 0; + let exitQty = 0; + let stopLoss = 0; + let takeProfit = 0; + let userId: string | undefined; + + const orderedRows = rows + .map((row, index) => ({ row, ts: toTimestamp(row, index), index })) + .sort((a, b) => (a.ts - b.ts) || (a.index - b.index)) + .map((wrapped) => wrapped.row); + + for (const row of orderedRows) { + const qty = toNumber(row.qty); + if (qty <= 0) continue; + + const side = normalizeTradeSide(row.side || 'BUY'); + const explicitAction = normalizeOrderAction(row.action || undefined); + let action = explicitAction; + if (!action) { + if (entrySide) { + action = side === entrySide ? 'ENTRY' : 'EXIT'; + } else { + action = this.inferLifecycleAction(row.action || undefined, row.side || undefined); + } + } + + if (action === 'ENTRY') { + if (!entrySide) { + entrySide = side; + } + entryQty += qty; + const price = toNumber(row.price); + if (price > 0) { + entryNotional += price * qty; + entryLastPrice = price; + } + + const sl = toNumber(row.stop_loss); + const tp = toNumber(row.take_profit); + if (sl > 0) stopLoss = sl; + if (tp > 0) takeProfit = tp; + } else { + exitQty += qty; + } + + if (row.user_id) userId = row.user_id; + } + + if (!entrySide || entryQty <= 0) return null; + const remainingQty = entryQty - exitQty; + if (remainingQty <= 1e-8) return null; + + const entryPrice = entryNotional > 0 ? (entryNotional / entryQty) : entryLastPrice; + if (!(entryPrice > 0)) return null; + + if (stopLoss <= 0 || takeProfit <= 0) { + for (let i = orderedRows.length - 1; i >= 0; i--) { + const row = orderedRows[i]; + const side = normalizeTradeSide(row.side || 'BUY'); + const action = normalizeOrderAction(row.action || undefined) || (side === entrySide ? 'ENTRY' : 'EXIT'); + if (action !== 'ENTRY') continue; + + const sl = toNumber(row.stop_loss); + const tp = toNumber(row.take_profit); + if (stopLoss <= 0 && sl > 0) stopLoss = sl; + if (takeProfit <= 0 && tp > 0) takeProfit = tp; + if (stopLoss > 0 && takeProfit > 0) break; + } + } + + return { + profileId, + symbol, + side: entrySide, + qty: Number(remainingQty.toFixed(8)), + entryPrice: Number(entryPrice.toFixed(8)), + stopLoss: Number(stopLoss || 0), + takeProfit: Number(takeProfit || 0), + userId, + tradeId: normalizedTradeId, + tradeIds: [normalizedTradeId] + }; + } catch (err: any) { + logger.error(`[Supabase] Unexpected virtual trade slice error for ${profileId}/${symbol}/${normalizedTradeId}: ${err.message}`); + return null; + } + } + + async verifyAccessToken(token: string): Promise<{ userId: string | null; error?: string }> { + if (!this.client) { + return { userId: null, error: 'Supabase client not configured' }; + } + + try { + const { data, error } = await this.client.auth.getUser(token); + if (error || !data.user) { + return { userId: null, error: error?.message || 'Invalid token' }; + } + + const claims = this.decodeJwtPayload(token); + if (!claims) { + return { userId: null, error: 'Invalid token claims' }; + } + + const requiredIssuer = config.SUPABASE_JWT_ISSUER; + if (requiredIssuer) { + const tokenIssuer = String(claims.iss || ''); + if (tokenIssuer !== requiredIssuer) { + return { userId: null, error: 'Invalid token issuer' }; + } + } + + const requiredAudience = config.SUPABASE_JWT_AUDIENCE; + if (requiredAudience) { + const tokenAudience = claims.aud; + const isAudienceValid = Array.isArray(tokenAudience) + ? tokenAudience.includes(requiredAudience) + : String(tokenAudience || '') === requiredAudience; + if (!isAudienceValid) { + return { userId: null, error: 'Invalid token audience' }; + } + } + + return { userId: data.user.id }; + } catch (err: any) { + return { userId: null, error: err.message }; + } + } + + async isAdmin(userId: string): Promise { + if (!this.client || !userId) return false; + try { + const { data, error } = await this.client + .from('users') + .select('role') + .eq('user_id', userId) + .maybeSingle(); + + if (error || !data) return false; + return String(data.role).toLowerCase() === 'admin'; + } catch { + return false; + } + } + + async getProfileOwner(profileId: string): Promise { + if (!this.client) return null; + try { + const { data, error } = await this.client + .from('trade_profiles') + .select('user_id') + .eq('id', profileId) + .maybeSingle(); + + if (error || !data) return null; + return data.user_id || null; + } catch { + return null; + } + } + + /** + * Returns profile allocation metadata used by capital guards. + */ + async getProfileCapital(profileId: string): Promise<{ + allocatedCapital: number; + isActive: boolean; + userId?: string; + } | null> { + if (!this.client) return null; + try { + const { data, error } = await this.client + .from('trade_profiles') + .select('allocated_capital,is_active,user_id') + .eq('id', profileId) + .maybeSingle(); + + if (error || !data) return null; + + const allocatedCapital = Number((data as any).allocated_capital || 0); + return { + allocatedCapital: Number.isFinite(allocatedCapital) && allocatedCapital > 0 ? allocatedCapital : 0, + isActive: Boolean((data as any).is_active), + userId: (data as any).user_id || undefined + }; + } catch { + return null; + } + } + + async getProfileForBacktest(profileId: string, userId: string): Promise<{ + id: string; + user_id: string; + name: string; + symbols: string; + allocated_capital: number; + risk_per_trade_percent: number; + strategy_config: any; + } | null> { + if (!this.client) return null; + const normalizedProfileId = String(profileId || '').trim(); + const normalizedUserId = String(userId || '').trim(); + if (!this.isUuid(normalizedProfileId) || !this.isUuid(normalizedUserId)) return null; + + try { + const { data, error } = await this.client + .from('trade_profiles') + .select('id,user_id,name,symbols,allocated_capital,risk_per_trade_percent,strategy_config') + .eq('id', normalizedProfileId) + .eq('user_id', normalizedUserId) + .maybeSingle(); + + if (error || !data) return null; + return { + id: String((data as any).id), + user_id: String((data as any).user_id), + name: String((data as any).name || ''), + symbols: String((data as any).symbols || ''), + allocated_capital: Number((data as any).allocated_capital || 0), + risk_per_trade_percent: Number((data as any).risk_per_trade_percent || 0), + strategy_config: this.normalizeStrategyConfig((data as any).strategy_config) + }; + } catch { + return null; + } + } + + async getSnapshotOwnerId(): Promise { + if (this.snapshotOwnerId) return this.snapshotOwnerId; + const configured = String(config.SNAPSHOT_USER_ID || '').trim().toLowerCase(); + if (this.isUuid(configured)) { + this.snapshotOwnerId = configured; + return configured; + } + + if (!this.client) return null; + try { + const { data, error } = await this.client + .from('users') + .select('user_id') + .order('created_at', { ascending: true }) + .limit(1) + .maybeSingle(); + + if (error || !data) { + return null; + } + + const resolved = String(data.user_id || '').trim(); + if (resolved && this.isUuid(resolved)) { + this.snapshotOwnerId = resolved; + return resolved; + } + return null; + } catch (err: any) { + logger.error(`[Supabase] Snapshot owner lookup failed: ${err.message}`); + return null; + } + } + + async saveBotStateSnapshot(userId: string, state: unknown): Promise { + if (!this.client || !userId) return; + + try { + const { error } = await this.client + .from('bot_state_snapshots') + .insert([{ user_id: userId, state }]); + + if (error) { + logger.warn(`[Supabase] Snapshot insert failed: ${error.message}`); + } + } catch (err: any) { + logger.error(`[Supabase] Unexpected snapshot insert error: ${err.message}`); + } + } + + async loadLatestBotStateSnapshot(userId: string): Promise<{ state: unknown } | null> { + if (!this.client || !userId) return null; + + try { + const { data, error } = await this.client + .from('bot_state_snapshots') + .select('state') + .eq('user_id', userId) + .order('created_at', { ascending: false }) + .limit(1) + .maybeSingle(); + + if (error || !data) { + return null; + } + + return { state: (data as any).state }; + } catch (err: any) { + logger.error(`[Supabase] Snapshot load failed: ${err.message}`); + return null; + } + } +} + +export const supabaseService = new SupabaseService(); diff --git a/backend/src/services/TradeExecutor.ts b/backend/src/services/TradeExecutor.ts new file mode 100644 index 0000000..8058f21 --- /dev/null +++ b/backend/src/services/TradeExecutor.ts @@ -0,0 +1,2522 @@ +import { config } from '../config/index.js'; +import { IExchangeConnector, ExchangeCapabilities } from '../connectors/types.js'; +import { SignalDirection } from '../strategies/rules/types.js'; +import logger from '../utils/logger.js'; +import { supabaseService } from './SupabaseService.js'; +import { healthTracker } from './healthTracker.js'; +import type { ApiServer } from './apiServer.js'; +import { SymbolMapper } from '../utils/symbolMapper.js'; +import { entryLockService } from './distributedLockService.js'; +import { Notifier } from './notifier.js'; +import { + normalizeOrderAction, + normalizeOrderStatus, + normalizeOrderType, + normalizeTradeSide +} from '../domain/tradingEnums.js'; +import { capitalLedger } from './CapitalLedger.js'; +import { randomUUID } from 'crypto'; +import { observabilityService } from './observabilityService.js'; +import { + AlpacaSubTagIntent, + buildAlpacaSubTag, + extractOrderSubTag, + isBytelystSubTag, + shouldAttachAlpacaSubTag, + subTagBelongsToProfile +} from '../utils/alpacaSubTag.js'; + +const normalizeThrown = (value: unknown): Error => { + if (value instanceof Error) return value; + if (value && typeof value === 'object') return new Error(JSON.stringify(value)); + return new Error(String(value ?? 'Unknown error')); +}; + +const getReconciliationFillQty = (order: any): number => { + const candidates = [order?.filled_qty, order?.filledQty, order?.filled_quantity, order?.qty, order?.amount, order?.size]; + for (const candidate of candidates) { + const parsed = Number(candidate); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + return 0; +}; + +const getReconciliationFillPrice = (order: any): number => { + const candidates = [order?.filled_avg_price, order?.avg_price, order?.price, order?.requestedPrice, order?.requested_price]; + for (const candidate of candidates) { + const parsed = Number(candidate); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + return 0; +}; + +export interface PositionState { + symbol: string; + side: SignalDirection; + entryPrice: number; + size: number; + stopLoss: number; + takeProfit: number; + peakPrice: number; + profitGuardActive?: boolean; + userId?: string; + profileId?: string; + tradeId?: string; +} + +export interface PendingOrder { + orderId: string; + symbol: string; + side: SignalDirection; + qty: number; + type: 'market' | 'limit'; + requestedPrice: number; + stopLoss: number; + takeProfit: number; + tradeId?: string; + userId?: string; + profileId?: string; + subTag?: string; + placedAt: number; + action: 'ENTRY' | 'EXIT'; + reservedAmount?: number; +} + +export type ExitLifecycleState = + | 'idle' + | 'initiated' + | 'order_placed' + | 'verifying' + | 'filled' + | 'failed' + | 'quarantined'; + +export interface ExitLifecycleRecord { + state: ExitLifecycleState; + updatedAt: number; + reason: string; + orderId?: string; + details?: string; +} + +export interface ExitFillApplyResult { + success: boolean; + fullyClosed: boolean; + appliedQty: number; + remainingSize: number; + error?: string; +} + +export class TradeExecutor { + private activeTraders: Map = new Map(); + private cooldowns: Map = new Map(); + private pendingOrders: Map = new Map(); // orderId -> PendingOrder + private exitLifecycle: Map = new Map(); + private entryAutoReduceLastAlertAt: Map = new Map(); + private tradeSequence = 0; + private notifier: Notifier; + private static readonly POSITION_KEY_SEPARATOR = '::'; + private accountSnapshotTimer?: NodeJS.Timeout; + private static warnedCapabilities = new Set(); + private profileSettings?: any; + + constructor( + private exchange: IExchangeConnector, + private apiServer?: ApiServer, + private userId: string = 'global', + private profileId?: string + ) { + this.notifier = new Notifier(); + this.startAccountSnapshotPolling(); + } + + public verifyCapability(capability: keyof ExchangeCapabilities, description: string): boolean { + const caps = this.exchange.getCapabilities(); + const supported = !!caps[capability]; + if (supported) return true; + + const capKey = String(capability); + if (!TradeExecutor.warnedCapabilities.has(capKey)) { + TradeExecutor.warnedCapabilities.add(capKey); + logger.warn(`[Executor] Exchange does not support ${description}. Feature will be bypassed.`); + } + observabilityService.incrementUnsupportedFeature(capKey); + return false; + } + + public dispose(): void { + this.clearAccountSnapshotTimer(); + } + + public setProfileSettings(profileSettings?: any): void { + this.profileSettings = profileSettings; + } + + private clearAccountSnapshotTimer(): void { + if (!this.accountSnapshotTimer) return; + clearInterval(this.accountSnapshotTimer); + this.accountSnapshotTimer = undefined; + } + + private startAccountSnapshotPolling(): void { + if (!this.apiServer) return; + const fetchAccountSnapshot = this.exchange.fetchAccountSnapshot; + if (typeof fetchAccountSnapshot !== 'function') return; + + const refreshSnapshot = async () => { + try { + const snapshot = await fetchAccountSnapshot.call(this.exchange); + if (!snapshot) return; + this.apiServer!.updateAccountSnapshot({ + ...snapshot, + profileId: this.profileId, + userId: this.userId + }); + } catch (error: any) { + logger.warn(`[Executor] Account snapshot failed for profile ${this.profileId || 'global'}: ${error.message || error}`); + } + }; + + refreshSnapshot(); + this.accountSnapshotTimer = setInterval(refreshSnapshot, config.ACCOUNT_SNAPSHOT_INTERVAL_MS); + if (typeof this.accountSnapshotTimer?.unref === 'function') { + this.accountSnapshotTimer.unref(); + } + } + + private buildPositionKey(symbol: string, tradeId?: string): string { + const normalizedTradeId = String(tradeId || '').trim(); + if (!normalizedTradeId) return symbol; + return `${symbol}${TradeExecutor.POSITION_KEY_SEPARATOR}${normalizedTradeId}`; + } + + private getLedgerProfileId(candidate?: string): string | undefined { + const normalized = String(candidate || this.profileId || '').trim(); + if (!normalized || normalized === 'global') return undefined; + return normalized; + } + + private shouldUseAlpacaSubTag(profileScope?: string): boolean { + const profileId = String(profileScope || this.profileId || '').trim(); + return shouldAttachAlpacaSubTag({ + profileId, + profileSettings: this.profileSettings + }); + } + + private buildOrderSubTag(tradeId: string | undefined, intent: AlpacaSubTagIntent): string | undefined { + const profileId = String(this.profileId || '').trim(); + if (!profileId) return undefined; + if (!this.shouldUseAlpacaSubTag(profileId)) return undefined; + const subTag = buildAlpacaSubTag({ + profileId, + tradeId, + intent + }); + return subTag || undefined; + } + + private filterOrdersBySubTagProfile(orders: any[]): any[] { + const profileId = String(this.profileId || '').trim(); + if (!profileId) return orders; + if (!this.shouldUseAlpacaSubTag(profileId)) return orders; + + let filteredOut = 0; + const scoped = (orders || []).filter((order) => { + const subTag = extractOrderSubTag(order); + if (!subTag) return true; + if (!isBytelystSubTag(subTag)) return true; + if (subTagBelongsToProfile(subTag, profileId)) return true; + filteredOut += 1; + return false; + }); + + if (filteredOut > 0) { + logger.info('[Executor] Scoped exchange orders by Alpaca sub-tag', { + event: 'alpaca_subtag_scope', + profileId, + kept: scoped.length, + dropped: filteredOut + }); + } + return scoped; + } + + private getPositionsForSymbol(symbol: string): Array<{ key: string; position: PositionState }> { + const matches: Array<{ key: string; position: PositionState }> = []; + for (const [key, position] of this.activeTraders.entries()) { + const keySymbol = key.includes(TradeExecutor.POSITION_KEY_SEPARATOR) + ? key.split(TradeExecutor.POSITION_KEY_SEPARATOR)[0] + : key; + if ((position.symbol || keySymbol) === symbol) { + matches.push({ key, position }); + } + } + return matches; + } + + private rankPositionCandidate(position: PositionState): number { + const tradeId = String(position.tradeId || '').trim(); + const hasStableTradeId = tradeId.length > 0 && !tradeId.endsWith('-SYNC'); + return (hasStableTradeId ? 100 : 0) + Math.min(99, Math.round(Number(position.size || 0) * 10)); + } + + private resolvePositionSelection(symbol: string, tradeId?: string): { key: string; position: PositionState } | null { + const normalizedTradeId = String(tradeId || '').trim(); + const candidates = this.getPositionsForSymbol(symbol); + if (candidates.length === 0) return null; + + if (normalizedTradeId) { + const exact = candidates.find((entry) => String(entry.position.tradeId || '').trim() === normalizedTradeId); + if (exact) return exact; + } + + candidates.sort((a, b) => { + const rankDiff = this.rankPositionCandidate(b.position) - this.rankPositionCandidate(a.position); + if (rankDiff !== 0) return rankDiff; + const tradeA = String(a.position.tradeId || ''); + const tradeB = String(b.position.tradeId || ''); + return tradeA.localeCompare(tradeB); + }); + return candidates[0]; + } + + private upsertPosition(symbol: string, position: PositionState): void { + const key = this.buildPositionKey(symbol, position.tradeId); + this.activeTraders.set(key, { + ...position, + symbol + }); + } + + private removePosition(symbol: string, tradeId?: string): void { + const normalizedTradeId = String(tradeId || '').trim(); + if (normalizedTradeId) { + const key = this.buildPositionKey(symbol, normalizedTradeId); + this.activeTraders.delete(key); + return; + } + + for (const [key, position] of this.activeTraders.entries()) { + if ((position.symbol || symbol) === symbol) { + this.activeTraders.delete(key); + } + } + } + + // --- State Accessors --- + public getActivePosition(symbol: string, tradeId?: string): PositionState | null { + const selected = this.resolvePositionSelection(symbol, tradeId); + return selected ? selected.position : null; + } + + public getActivePositions(symbol: string): PositionState[] { + return this.getPositionsForSymbol(symbol) + .map((entry) => entry.position) + .sort((a, b) => this.rankPositionCandidate(b) - this.rankPositionCandidate(a)); + } + + public isSymbolLocked(symbol: string): boolean { + return this.getPositionsForSymbol(symbol).length > 0; + } + + public getActiveSymbols(): string[] { + return Array.from(new Set( + Array.from(this.activeTraders.values()) + .map((position) => position.symbol) + .filter(Boolean) + )); + } + + public getAllPositions(): Map { + return this.activeTraders; + } + + public getOpenPositionCount(): number { + return this.activeTraders.size; + } + + public getPendingOrders(): Map { + return this.pendingOrders; + } + + public getExitLifecycle(symbol: string): ExitLifecycleRecord { + return this.exitLifecycle.get(symbol) || { + state: 'idle', + updatedAt: 0, + reason: 'not_started' + }; + } + + public markExitManualReview(symbol: string, reason: string, details?: string, tradeId?: string): void { + const normalizedReason = String(reason || 'manual_review').trim() || 'manual_review'; + const normalizedDetails = String(details || '').trim() || undefined; + this.setExitLifecycle(symbol, 'quarantined', normalizedReason, normalizedDetails); + observabilityService.emitEvent({ + type: 'EXIT_FILL_COHERENCE_VIOLATION', + severity: 'ERROR', + message: `Manual review required for ${symbol}: ${normalizedReason}${normalizedDetails ? ` (${normalizedDetails})` : ''}`, + profileId: this.profileId, + userId: this.userId, + symbol + }); + + const selected = this.resolvePositionSelection(symbol, tradeId); + const position = selected?.position; + if (this.apiServer && position) { + this.apiServer.recordOrderFailure({ + profileId: position.profileId || this.profileId, + userId: position.userId || this.userId, + symbol, + side: position.side === SignalDirection.SELL ? 'BUY' : 'SELL', + qty: position.size, + reason: normalizedReason, + tradeId: String(position.tradeId || tradeId || '').trim() || undefined, + timestamp: Date.now() + }); + } + } + + public checkCooldown(symbol: string, durationMs: number = 3600000): boolean { + const lastExit = this.cooldowns.get(symbol) || 0; + if (Date.now() - lastExit < durationMs) { + logger.info(`[Executor] 💤 ${symbol} is in cooldown.`); + return true; + } + return false; + } + + private buildDeterministicTradeId(symbol: string, side: SignalDirection): string { + const owner = (this.profileId || this.userId || 'global').replace(/[^A-Za-z0-9]/g, '').slice(-12) || 'global'; + const normalizedSymbol = symbol.replace(/[^A-Za-z0-9]/g, '').slice(0, 16) || 'asset'; + this.tradeSequence = (this.tradeSequence + 1) % 1_000_000; + const sequence = this.tradeSequence.toString().padStart(6, '0'); + return `TRD-${owner}-${normalizedSymbol}-${side}-${Date.now()}-${sequence}`; + } + + private buildDeterministicSyncTradeId(symbol: string): string { + const owner = (this.profileId || this.userId || 'global').replace(/[^A-Za-z0-9]/g, '').slice(-12) || 'global'; + const normalizedSymbol = symbol.replace(/[^A-Za-z0-9]/g, '').slice(0, 16) || 'asset'; + return `TRD-SYNC-${owner}-${normalizedSymbol}`; + } + + private hasPendingAction(symbol: string, action: 'ENTRY' | 'EXIT'): boolean { + for (const pending of this.pendingOrders.values()) { + if (pending.symbol !== symbol) continue; + if ((pending.action || '').toUpperCase() === action) { + return true; + } + } + return false; + } + + private async hasActiveTradeId(tradeId?: string): Promise { + const normalized = String(tradeId || '').trim(); + if (!normalized) return false; + return await supabaseService.hasActiveOrderForTradeId(normalized, this.profileId); + } + + private async isTradeAlreadyFinalized(tradeId?: string): Promise { + const normalized = String(tradeId || '').trim(); + if (!normalized) return false; + return await supabaseService.hasFinalizedTradeHistory(normalized, this.profileId); + } + private setExitLifecycle( + symbol: string, + state: ExitLifecycleState, + reason: string, + details?: string, + orderId?: string + ): void { + this.exitLifecycle.set(symbol, { + state, + reason, + details, + orderId, + updatedAt: Date.now() + }); + } + + private rebuildLifecycleFromPendingOrders(): void { + for (const pending of this.pendingOrders.values()) { + if ((pending.action || '').toUpperCase() === 'EXIT') { + this.setExitLifecycle( + pending.symbol, + 'order_placed', + 'Restart recovery', + undefined, + pending.orderId + ); + } + } + } + + private normalizeSignalDirection(value?: string | SignalDirection): SignalDirection { + const upper = String(value || 'BUY').trim().toUpperCase(); + return upper === 'SELL' ? SignalDirection.SELL : SignalDirection.BUY; + } + + private normalizeOrderType(value?: string): 'market' | 'limit' { + const normalized = String(value || 'market').trim().toLowerCase(); + return normalized === 'limit' ? 'limit' : 'market'; + } + + private roundDownQty(value: number): number { + if (!Number.isFinite(value) || value <= 0) return 0; + const precision = Math.max(0, Math.min(10, Math.floor(Number(config.QUANTITY_PRECISION || 6)))); + const factor = Math.pow(10, precision); + return Math.floor(value * factor) / factor; + } + + private resolveOrderReferencePrice(symbol: string, candidatePrice?: number): number { + const requested = Number(candidatePrice ?? 0); + if (Number.isFinite(requested) && requested > 0) { + return requested; + } + + const state = this.apiServer?.getState(); + const directPrice = Number(state?.symbols?.[symbol]?.price || 0); + if (Number.isFinite(directPrice) && directPrice > 0) { + return directPrice; + } + + const dataSymbol = SymbolMapper.toDataSymbol(symbol, config.EXECUTION_PROVIDER); + if (dataSymbol !== symbol) { + const mappedPrice = Number(state?.symbols?.[dataSymbol]?.price || 0); + if (Number.isFinite(mappedPrice) && mappedPrice > 0) { + return mappedPrice; + } + } + + const minimum = Number(config.MIN_NOTIONAL_USD || 0); + return Number.isFinite(minimum) && minimum > 0 ? minimum : 0; + } + + private isStrictCapitalGuardEnabled(): boolean { + return config.ENABLE_STRICT_CAPITAL_GUARD !== false; + } + + private strictCapitalCostMultiplier(): number { + if (!this.isStrictCapitalGuardEnabled()) return 1; + const slippagePct = Math.max(0, Number(config.STRICT_CAPITAL_SLIPPAGE_BUFFER_PCT || 0)); + const feePct = Math.max(0, Number(config.STRICT_CAPITAL_FEE_BUFFER_PCT || 0)); + const multiplier = 1 + ((slippagePct + feePct) / 100); + return Number.isFinite(multiplier) && multiplier > 0 ? multiplier : 1; + } + + private strictCapitalMinReserveUsd(): number { + if (!this.isStrictCapitalGuardEnabled()) return 0; + const reserve = Number(config.STRICT_CAPITAL_MIN_RESERVE_USD || 0); + return Number.isFinite(reserve) && reserve > 0 ? reserve : 0; + } + + private clampBuyQtyToAvailableCapital( + symbol: string, + requestedQty: number, + requestedPrice: number | undefined, + availableCapital: number + ): number { + if (!Number.isFinite(requestedQty) || requestedQty <= 0) return 0; + if (!Number.isFinite(availableCapital) || availableCapital <= 0) return 0; + + const bufferPct = Math.max(0, Number(config.ENTRY_CAPITAL_BUFFER_PCT || 0)) / 100; + const minimumReserve = this.strictCapitalMinReserveUsd(); + const budget = Math.max(0, (availableCapital * (1 - bufferPct)) - minimumReserve); + if (!(budget > 0)) return 0; + + const unitPrice = this.resolveOrderReferencePrice(symbol, requestedPrice); + if (!(unitPrice > 0)) return 0; + + const effectiveUnitCost = unitPrice * this.strictCapitalCostMultiplier(); + if (!(effectiveUnitCost > 0)) return 0; + const maxQty = this.roundDownQty(budget / effectiveUnitCost); + if (!(maxQty > 0)) return 0; + return Math.min(requestedQty, maxQty); + } + + private maybeEmitEntryAutoReduceAdvisory(params: { + symbol: string; + profileId?: string; + userId?: string; + requestedQty: number; + clampedQty: number; + referencePrice: number; + availableCapital: number; + }): void { + const requestedQty = Number(params.requestedQty || 0); + const clampedQty = Number(params.clampedQty || 0); + if (!(requestedQty > 0) || !(clampedQty > 0) || clampedQty >= requestedQty) return; + + const reducedQty = requestedQty - clampedQty; + const reductionPct = reducedQty / requestedQty; + const reductionNotional = reducedQty * Math.max(0, Number(params.referencePrice || 0)); + const minPct = Math.max(0, Number(config.ENTRY_AUTO_REDUCE_ALERT_MIN_PCT || 0)); + const minUsd = Math.max(0, Number(config.ENTRY_AUTO_REDUCE_ALERT_MIN_USD || 0)); + if (reductionPct < minPct && reductionNotional < minUsd) return; + + const throttleMs = Math.max(0, Number(config.ENTRY_AUTO_REDUCE_ALERT_THROTTLE_MS || 0)); + const profileKey = String(params.profileId || 'global').trim() || 'global'; + const symbolKey = String(params.symbol || '').trim().toUpperCase(); + const throttleKey = `${profileKey}:${symbolKey}`; + const now = Date.now(); + const lastAt = this.entryAutoReduceLastAlertAt.get(throttleKey) || 0; + if (throttleMs > 0 && now - lastAt < throttleMs) return; + this.entryAutoReduceLastAlertAt.set(throttleKey, now); + + const message = `BUY qty auto-reduced for ${params.symbol}: ${(reductionPct * 100).toFixed(1)}% (~$${reductionNotional.toFixed(2)}) to stay within available capital ($${Number(params.availableCapital || 0).toFixed(2)}). Consider increasing profile capital if this repeats.`; + observabilityService.emitEvent({ + type: 'INSUFFICIENT_BUYING_POWER', + severity: 'INFO', + message, + profileId: params.profileId, + userId: params.userId, + symbol: params.symbol + }); + } + + private computeReservedAmount(record: any, fallbackPrice?: number): number { + const qty = Number(record?.qty ?? record?.quantity ?? record?.amount ?? 0); + if (!Number.isFinite(qty) || qty <= 0) return 0; + const candidates = [ + record?.price, + record?.requested_price, + record?.requestedPrice, + record?.filled_avg_price, + record?.last_price, + fallbackPrice + ]; + let price = 0; + for (const candidate of candidates) { + const numeric = Number(candidate); + if (Number.isFinite(numeric) && numeric > 0) { + price = numeric; + break; + } + } + if (price <= 0) { + const fallbackNumeric = Number(fallbackPrice ?? 0); + if (Number.isFinite(fallbackNumeric) && fallbackNumeric > 0) { + price = fallbackNumeric; + } else { + price = config.MIN_NOTIONAL_USD; + } + } + const notional = qty * price; + const minimum = Number.isFinite(config.MIN_NOTIONAL_USD) && config.MIN_NOTIONAL_USD > 0 ? config.MIN_NOTIONAL_USD : 0; + return Number(Math.max(notional, minimum)); + } + + private estimateOrderCost(symbol: string, qty: number, price?: number): number { + if (!Number.isFinite(qty) || qty <= 0) return 0; + const unitPrice = this.resolveOrderReferencePrice(symbol, price); + const notional = qty * unitPrice * this.strictCapitalCostMultiplier(); + const minimum = Number.isFinite(config.MIN_NOTIONAL_USD) && config.MIN_NOTIONAL_USD > 0 ? config.MIN_NOTIONAL_USD : 0; + const base = Math.max(notional, minimum); + return Number(base + this.strictCapitalMinReserveUsd()); + } + + private buildPendingOrderFromRow(row: any): PendingOrder | null { + const orderId = String(row?.id || row?.order_id || '').trim(); + if (!orderId) return null; + const symbol = String(row?.symbol || '').trim(); + if (!symbol) return null; + const side = this.normalizeSignalDirection(row?.side); + const qty = Number(row?.qty || row?.quantity || row?.filled_qty || 0); + if (!Number.isFinite(qty) || qty <= 0) return null; + const createdAt = row?.timestamp + ? Number(row.timestamp) + : row?.created_at + ? Number(Date.parse(row.created_at)) + : Date.now(); + + const requestedPrice = Number(row?.price || row?.requested_price || row?.requestedPrice || 0); + const reservedAmount = this.computeReservedAmount(row, requestedPrice); + const profileId = String(row?.profile_id || this.profileId || '').trim() || undefined; + + return { + orderId, + symbol, + side, + qty, + type: this.normalizeOrderType(row?.type), + requestedPrice, + stopLoss: Number(row?.stop_loss || 0), + takeProfit: Number(row?.take_profit || 0), + tradeId: String(row?.trade_id || '').trim() || undefined, + userId: String(row?.user_id || this.userId || '').trim() || undefined, + profileId, + subTag: extractOrderSubTag(row) || undefined, + placedAt: Number.isFinite(createdAt) ? Number(createdAt) : Date.now(), + action: String(row?.action || (side === SignalDirection.SELL ? 'EXIT' : 'ENTRY')).toUpperCase() as 'ENTRY' | 'EXIT', + reservedAmount + }; + } + + private addPendingOrderFromRecord(record: any): void { + const pending = this.buildPendingOrderFromRow(record); + if (!pending) return; + if (this.pendingOrders.has(pending.orderId)) return; + this.pendingOrders.set(pending.orderId, pending); + } + + private async populatePendingOrdersFromDb(): Promise { + this.pendingOrders.clear(); + const rows = await supabaseService.getOpenOrdersForProfile(this.profileId || ''); + rows.forEach((row) => this.addPendingOrderFromRecord(row)); + } + + public async fetchExchangeOpenOrders(): Promise { + if (!this.verifyCapability('fetchOpenOrders', 'fetching open orders')) return []; + try { + const orders = await this.instrumentExchangeCall('fetch_open_orders', () => this.exchange.fetchOpenOrders!(config.SYMBOLS)); + return this.filterOrdersBySubTagProfile(orders || []); + } catch (error: any) { + logger.error(`[Executor] Failed to fetch open orders from exchange: ${error.message || error}`); + return []; + } + } + + public async fetchExchangeClosedOrders( + symbols?: string[], + lookbackHours?: number, + options?: { + limitPerPage?: number; + maxPages?: number; + } + ): Promise { + if (!this.verifyCapability('fetchClosedOrders', 'fetching closed orders')) return []; + try { + const safeLookbackHours = Number.isFinite(Number(lookbackHours)) + ? Math.max(1, Math.floor(Number(lookbackHours))) + : Math.max(1, Math.floor(Number(config.RECON_EXIT_BACKFILL_LOOKBACK_HOURS || 72))); + const limitPerPage = Math.max( + 1, + Math.min(500, Math.floor(Number(options?.limitPerPage || config.RECON_ORDER_COVERAGE_FETCH_LIMIT_PER_PAGE || 500))) + ); + const maxPages = Math.max( + 1, + Math.min(100, Math.floor(Number(options?.maxPages || config.RECON_ORDER_COVERAGE_MAX_FETCH_PAGES || 8))) + ); + const after = new Date(Date.now() - safeLookbackHours * 60 * 60 * 1000); + const targetSymbols = (symbols && symbols.length > 0) ? symbols : config.SYMBOLS; + const orders = await this.instrumentExchangeCall('fetch_closed_orders', () => this.exchange.fetchClosedOrders!(targetSymbols, { + after, + limit: limitPerPage, + maxPages + })); + return this.filterOrdersBySubTagProfile(orders || []); + } catch (error: any) { + logger.error(`[Executor] Failed to fetch closed orders from exchange: ${error.message || error}`); + return []; + } + } + + public async fetchExchangePosition(symbol: string): Promise { + const tradeSymbol = SymbolMapper.toTradeSymbol(symbol, config.EXECUTION_PROVIDER); + try { + return await this.instrumentExchangeCall('get_position', () => this.exchange.getPosition(tradeSymbol)); + } catch (error: any) { + logger.error(`[Executor] Failed to fetch exchange position for ${symbol}: ${error.message || error}`); + return null; + } + } + + private async populatePendingOrdersFromExchange(): Promise { + const symbols = config.SYMBOLS; + const orders = await this.fetchExchangeOpenOrders(); + if (!orders || orders.length === 0) return; + + orders.forEach((order) => { + const data = { + id: order.id || order.client_order_id || order.orderId, + symbol: order.symbol, + side: this.normalizeSignalDirection(order.side), + qty: order.amount || order.qty || order.size, + type: order.type || order.order_type, + price: order.price || order.requestedPrice || 0, + stop_loss: order.stop_loss, + take_profit: order.take_profit, + trade_id: order.trade_id, + user_id: this.userId, + profile_id: this.profileId, + sub_tag: extractOrderSubTag(order), + timestamp: order.timestamp, + created_at: order.datetime, + action: this.normalizeSignalDirection(order.side) === SignalDirection.SELL ? 'EXIT' : 'ENTRY' + }; + if (!symbols.some(sym => String(sym).toUpperCase() === String(data.symbol).toUpperCase())) { + data.symbol = SymbolMapper.toDataSymbol(String(order.symbol || ''), config.EXECUTION_PROVIDER); + } + this.addPendingOrderFromRecord(data); + }); + } + + public async rebuildStartupState(): Promise { + await this.populatePendingOrdersFromDb(); + await this.populatePendingOrdersFromExchange(); + this.rebuildLifecycleFromPendingOrders(); + await this.rebuildCapitalLedgerFromState(); + } + + private async rebuildCapitalLedgerFromState(): Promise { + const ledgerProfileId = this.getLedgerProfileId(); + if (!ledgerProfileId) return; + try { + const reservedOrders = Array.from(this.pendingOrders.values()) + .filter((pending) => pending.profileId === ledgerProfileId && pending.action === 'ENTRY') + .reduce((sum, pending) => { + const amount = pending.reservedAmount ?? this.computeReservedAmount(pending, pending.requestedPrice); + return sum + amount; + }, 0); + + let reservedPositions = 0; + for (const symbol of config.SYMBOLS) { + const virtualPosition = await supabaseService.getVirtualOpenPosition(ledgerProfileId, symbol); + if (virtualPosition && virtualPosition.qty > 0 && virtualPosition.entryPrice > 0) { + reservedPositions += virtualPosition.qty * virtualPosition.entryPrice; + } + } + + await capitalLedger.rebuildLedger(ledgerProfileId, reservedOrders, reservedPositions); + } catch (error: any) { + logger.warn(`[Executor] Failed to rebuild capital ledger for ${ledgerProfileId}: ${error.message}`); + } + } + + private resolveCapitalLedgerDriftScope(): 'exchange' | 'virtual' { + const configured = String(config.CAPITAL_LEDGER_DRIFT_SCOPE || 'auto').trim().toLowerCase(); + if (configured === 'exchange' || configured === 'virtual') return configured; + + // In shared-account profile mode, exchange position notional is account-level and + // cannot be attributed safely per profile. Default to profile-scoped virtual notional. + const normalizedProfileId = String(this.profileId || '').trim().toLowerCase(); + const isDedicatedProfileScope = normalizedProfileId.length > 0 + && normalizedProfileId !== 'global' + && !normalizedProfileId.startsWith('default-'); + return isDedicatedProfileScope ? 'virtual' : 'exchange'; + } + + private async computeExchangeOpenNotional(symbols: string[]): Promise { + let exchangeNotional = 0; + for (const symbol of symbols) { + try { + const tradeSymbol = SymbolMapper.toTradeSymbol(symbol, config.EXECUTION_PROVIDER); + const pos = await this.instrumentExchangeCall('get_position', () => this.exchange.getPosition(tradeSymbol)); + const qty = Math.abs(Number(pos?.qty || 0)); + const price = Number(pos?.avg_entry_price || pos?.current_price || 0); + if (qty > 0 && price > 0) exchangeNotional += qty * price; + } catch (_) { + // per-symbol error; continue + } + } + return exchangeNotional; + } + + private async computeProfileVirtualNotional(profileId: string, symbols: string[]): Promise { + let virtualNotional = 0; + for (const symbol of symbols) { + try { + const virtualPosition = await supabaseService.getVirtualOpenPosition(profileId, symbol); + if (virtualPosition && virtualPosition.qty > 0 && virtualPosition.entryPrice > 0) { + virtualNotional += virtualPosition.qty * virtualPosition.entryPrice; + } + } catch (_) { + // per-symbol error; continue + } + } + return virtualNotional; + } + + /** + * FIX-03: Cross-validates the capital ledger's reserved_for_positions against + * the actual open position notional on the exchange. Emits CAPITAL_LEDGER_DRIFT + * if the discrepancy exceeds the configured threshold. + */ + public async crossValidateCapitalLedger(symbols: string[]): Promise { + const ledgerProfileId = this.getLedgerProfileId(); + if (!ledgerProfileId) return; + try { + const scope = this.resolveCapitalLedgerDriftScope(); + const observedNotional = scope === 'exchange' + ? await this.computeExchangeOpenNotional(symbols) + : await this.computeProfileVirtualNotional(ledgerProfileId, symbols); + + const ledger = await capitalLedger.getLedger(ledgerProfileId); + const ledgerReserved = Number(ledger?.reserved_for_positions || 0); + const delta = Math.abs(observedNotional - ledgerReserved); + const driftThresholdPct = Math.max(1, Number(config.CAPITAL_LEDGER_DRIFT_ALERT_PCT || 10)) / 100; + const minDriftUsd = Math.max(0, Number(config.CAPITAL_LEDGER_DRIFT_MIN_USD || 10)); + const maxAllowed = Math.max(observedNotional, ledgerReserved) * driftThresholdPct; + + if (delta > maxAllowed && delta > minDriftUsd) { + logger.error(`[Executor] ⚠️ CAPITAL_LEDGER_DRIFT: scope=${scope}, observed notional=${observedNotional.toFixed(2)}, ledger reserved=${ledgerReserved.toFixed(2)}, delta=${delta.toFixed(2)} | Profile: ${ledgerProfileId}`); + observabilityService.emitEvent({ + type: 'CAPITAL_LEDGER_DRIFT', + severity: 'ERROR', + message: `Capital ledger drift at startup (${scope} scope): observed notional $${observedNotional.toFixed(2)} vs ledger reserved $${ledgerReserved.toFixed(2)} (delta $${delta.toFixed(2)}).`, + profileId: ledgerProfileId + }); + } else { + logger.info(`[Executor] ✅ Capital ledger validated: scope=${scope}, observed=${observedNotional.toFixed(2)}, ledger_reserved=${ledgerReserved.toFixed(2)}, delta=${delta.toFixed(2)} | Profile: ${ledgerProfileId}`); + } + } catch (e) { + logger.warn(`[Executor] Capital ledger cross-validation failed for ${ledgerProfileId}: ${e}`); + } + } + + private getFilledQuantity(order: any): number | undefined { + const candidates = [ + order?.filled_qty, + order?.filledQty, + order?.filled_quantity, + order?.qty_filled, + order?.executed_qty, + order?.filled + ]; + + for (const raw of candidates) { + const value = Number(raw); + if (Number.isFinite(value) && value > 0) { + return value; + } + } + + return undefined; + } + + private isDuplicateOrderError(error: any): boolean { + if (!error) return false; + + const message = String(error?.message || '').toLowerCase(); + const responseData = String(error?.response?.data || error?.data || '').toLowerCase(); + const code = String(error?.code || '').toLowerCase(); + if (message.includes('duplicate') || responseData.includes('duplicate') || code.includes('duplicate')) { + return true; + } + return false; + } + + // --- Core Execution --- + + /** + * Places a direct order to open a position. + * Does NOT check risk or strategy rules. Assumes caller has validated. + */ + public async openPosition( + symbol: string, + side: SignalDirection, + qty: number, + type: 'market' | 'limit' = 'market', + price?: number, + sl?: number, + tp?: number, + userIdOverride?: string + ): Promise<{ success: boolean, orderId?: string, error?: string }> { + if (healthTracker.isPaused()) { + logger.info(`[TradeExecutor] 🛑 Entry BLOCKED for ${symbol}: Bot is PAUSED by admin.`); + return { success: false, error: 'Trade execution is paused by administrator' }; + } + console.log('[TradeExecutor] openPosition called', { symbol, side, qty, type, price, userIdOverride }); + let executionQty = this.roundDownQty(Number(qty)); + if (!(executionQty > 0)) { + return { success: false, error: 'Invalid order quantity' }; + } + const ledgerProfileId = this.getLedgerProfileId(); + const tradeId = this.buildDeterministicTradeId(symbol, side); + const finalUserId = userIdOverride || this.userId; + let reservedEstimate = ledgerProfileId ? this.estimateOrderCost(symbol, executionQty, price) : 0; + let activeOrderId: string | undefined; + let pendingCaptured = false; + let capitalReserved = false; + let capitalReservationAmount = reservedEstimate; + const normalizedSymbol = String(symbol || '').trim(); + let lockAcquired = false; + let lockOwner: string | undefined; + let orderSubTag: string | undefined; + + const releaseCapitalOnAbort = async () => { + if (capitalReserved && ledgerProfileId && capitalReservationAmount > 0) { + const amountToRelease = capitalReservationAmount; + capitalReservationAmount = 0; + capitalReserved = false; + await this.releaseLedgerReservation(ledgerProfileId, amountToRelease); + } + }; + + const releaseLockIfHeld = async () => { + if (lockAcquired && ledgerProfileId && lockOwner) { + lockAcquired = false; + const released = await entryLockService.releaseRowLock(ledgerProfileId, normalizedSymbol, lockOwner); + lockOwner = undefined; + if (!released) { + logger.warn(`[DistributedLock] Failed to release lock for ${symbol} (${ledgerProfileId})`); + } + } + }; + try { + if (ledgerProfileId) { + const ownerCandidate = `${process.pid}-${randomUUID()}`; + const acquisition = await entryLockService.tryAcquireRowLock(ledgerProfileId, normalizedSymbol, ownerCandidate, 30); + if (!acquisition) { + logger.warn(`[EntryLock] Entry locked for ${symbol} (profile=${ledgerProfileId})`); + return { success: false, error: 'Entry already running for this profile and symbol' }; + } + lockOwner = ownerCandidate; + lockAcquired = true; + const lifecycleActive = await entryLockService.isEntryInProgress(ledgerProfileId, normalizedSymbol); + if (lifecycleActive) { + await releaseLockIfHeld(); + logger.warn(`[Executor] Entry lifecycle already open for ${symbol} (profile=${ledgerProfileId})`); + return { success: false, error: 'Entry lifecycle already exists' }; + } + if (await this.hasActiveTradeId(tradeId)) { + await releaseLockIfHeld(); + logger.warn(`[Executor] Duplicate ENTRY request blocked for ${symbol}`); + return { success: false, error: 'Duplicate entry request blocked (idempotency window)' }; + } + const available = await capitalLedger.getAvailableCapital(ledgerProfileId); + const numericAvailable = Number.isFinite(Number(available ?? 0)) ? Number(available ?? 0) : 0; + if (side === SignalDirection.BUY) { + const requestedBeforeClamp = executionQty; + const clampedQty = this.clampBuyQtyToAvailableCapital(symbol, executionQty, price, numericAvailable); + if (!(clampedQty > 0)) { + await releaseLockIfHeld(); + logger.warn(`[Executor] Entry blocked for ${symbol}: unable to size BUY within available capital (${numericAvailable}).`); + return { success: false, error: 'Insufficient capital after execution safety buffer' }; + } + if (clampedQty + 1e-10 < executionQty) { + logger.warn(`[Executor] Entry qty clamped for ${symbol}: requested=${executionQty}, clamped=${clampedQty}, available=${numericAvailable}`); + this.maybeEmitEntryAutoReduceAdvisory({ + symbol, + profileId: ledgerProfileId, + userId: finalUserId, + requestedQty: requestedBeforeClamp, + clampedQty, + referencePrice: this.resolveOrderReferencePrice(symbol, price), + availableCapital: numericAvailable + }); + executionQty = clampedQty; + } + } + reservedEstimate = this.estimateOrderCost(symbol, executionQty, price); + capitalReservationAmount = reservedEstimate; + if (numericAvailable < reservedEstimate) { + await releaseLockIfHeld(); + logger.warn(`[Executor] Insufficient capital for ${symbol}: available=${numericAvailable}, required=${reservedEstimate}`); + return { success: false, error: 'Insufficient capital to reserve order' }; + } + if (!(await capitalLedger.reserveForOrder(ledgerProfileId, reservedEstimate))) { + await releaseLockIfHeld(); + return { success: false, error: 'Insufficient capital to reserve order' }; + } + capitalReserved = true; + capitalReservationAmount = reservedEstimate; + } + if (this.hasPendingAction(symbol, 'ENTRY')) { + logger.warn(`[Executor] Duplicate ENTRY request blocked for ${symbol}`); + await releaseCapitalOnAbort(); + await releaseLockIfHeld(); + return { success: false, error: 'Duplicate entry request blocked (pending action)' }; + } + + const tradeSymbol = SymbolMapper.toTradeSymbol(symbol, config.EXECUTION_PROVIDER); + const clientOrderId = `bytelyst-${ledgerProfileId || this.profileId || 'global'}-${tradeId}`; + orderSubTag = this.buildOrderSubTag(tradeId, 'ENTRY'); + if (orderSubTag) { + logger.info('[Executor] Alpaca sub-tag prepared', { + event: 'alpaca_subtag_prepared', + profileId: ledgerProfileId || this.profileId, + userId: finalUserId, + symbol, + tradeId, + intent: 'ENTRY', + subTag: orderSubTag + }); + } + + // --- Pre-flight BUY Feasibility Check --- + if (side === SignalDirection.BUY) { + const snapshot = this.apiServer?.getState()?.accountSnapshot; + if (snapshot) { + const buyingPower = Number(snapshot.buying_power || 0); + if (buyingPower < reservedEstimate) { + logger.warn(`[Guardrail] Aborting BUY for ${symbol}: Insufficient Broker Buying Power (${buyingPower} < ${reservedEstimate})`); + if (this.apiServer) { + this.apiServer.recordOrderFailure({ + profileId: ledgerProfileId, + userId: finalUserId, + symbol, + side: 'BUY', + qty: executionQty, + reason: 'INSUFFICIENT_BUYING_POWER', + tradeId, + subTag: orderSubTag, + timestamp: Date.now() + }); + } + observabilityService.emitEvent({ + type: 'INSUFFICIENT_BUYING_POWER', + severity: 'WARN', + message: `Insufficient Broker Buying Power for ${symbol} ($${reservedEstimate.toFixed(2)} required)`, + profileId: ledgerProfileId, + userId: finalUserId, + symbol + }); + await releaseCapitalOnAbort(); + await releaseLockIfHeld(); + return { success: false, error: 'INSUFFICIENT_BUYING_POWER' }; + } + } + } + + if (side === SignalDirection.SELL) { + if (!this.verifyCapability('shorting', 'Short Selling')) { + await releaseCapitalOnAbort(); + await releaseLockIfHeld(); + return { success: false, error: 'EXCHANGE_DOES_NOT_SUPPORT_SHORTING' }; + } + } + + if (await this.isTradeAlreadyFinalized(tradeId)) { + logger.warn(`[Executor] ENTRY ${tradeId} already finalized; skipping duplicate request for ${symbol}`); + await releaseCapitalOnAbort(); + await releaseLockIfHeld(); + return { success: false, error: 'Trade lifecycle already finalized' }; + } + + logger.info(`[Executor] 🚀 Placing ${side.toUpperCase()} ${type} for ${symbol} | Qty: ${executionQty} ${price ? `@ ${price}` : ''} | Trade: ${tradeId}`); + logger.info('ENTRY submitted', { + event: 'entry_submitted', + symbol, + tradeId, + profileId: ledgerProfileId, + userId: finalUserId, + qty: executionQty, + price: price || 0, + side, + action: 'ENTRY', + }); + + let order: any; + try { + order = await this.instrumentExchangeCall('place_order', () => this.exchange.placeOrder( + tradeSymbol, + side.toLowerCase() as 'buy' | 'sell', + executionQty, + type, + price, + sl, + tp, + clientOrderId, + { + subTag: orderSubTag, + profileId: ledgerProfileId || this.profileId, + tradeId, + intent: 'ENTRY' + } + )); + } catch (error: any) { + if (this.isDuplicateOrderError(error)) { + const existingOrderRow = await supabaseService.getOrderByTradeId(tradeId, ledgerProfileId); + const existingOrderId = String(existingOrderRow?.order_id || '').trim(); + if (!existingOrderId) { + throw normalizeThrown(error); + } + if (this.exchange.getOrder) { + order = await this.exchange.getOrder(existingOrderId, tradeSymbol); + } + if (!order) { + order = { + id: existingOrderId, + status: existingOrderRow?.status || 'pending_new', + filled_avg_price: existingOrderRow?.price || 0, + filled_qty: existingOrderRow?.qty || 0 + }; + } + } else { + const errorMsg = error.message || String(error); + observabilityService.emitEvent({ + type: 'ORDER_FAILURE', + severity: 'ERROR', + message: `Exchange rejected ${side} order for ${symbol}: ${errorMsg}`, + profileId: ledgerProfileId, + userId: finalUserId, + symbol + }); + throw normalizeThrown(error); + } + } + + if (!order) { + await releaseCapitalOnAbort(); + await releaseLockIfHeld(); + return { success: false, error: "Order not returned from exchange" }; + } + + order.client_order_id = order.client_order_id || clientOrderId; + if (orderSubTag) { + order.subtag = order.subtag || orderSubTag; + order.sub_tag = order.sub_tag || orderSubTag; + } + + // Log the order immediately (status may be pending) + const initialPrice = price || order.filled_avg_price || 0; + await this.logOrderToDb(order, symbol, side, executionQty, initialPrice, type, sl, tp, finalUserId, tradeId, 'ENTRY'); + this.updateDashboardOrder(order, symbol, side, executionQty, initialPrice, type, tradeId, 'ENTRY'); + + // --- Track for background sync --- + const pendingReservationAmount = capitalReservationAmount; + this.pendingOrders.set(order.id, { + orderId: order.id, + symbol, + side, + qty: executionQty, + type, + requestedPrice: initialPrice, + stopLoss: sl || 0, + takeProfit: tp || 0, + tradeId, + userId: finalUserId, + profileId: ledgerProfileId, + subTag: orderSubTag, + reservedAmount: pendingReservationAmount, + placedAt: Date.now(), + action: 'ENTRY' + }); + pendingCaptured = true; + activeOrderId = order.id; + await releaseLockIfHeld(); + capitalReserved = false; + capitalReservationAmount = 0; + + // --- Order Fill Verification --- + const verifiedOrder = await this.waitForFill(order.id, symbol, type); + + const verifiedStatus = (verifiedOrder?.status || 'filled').toLowerCase(); + const terminalStatuses = new Set(['canceled', 'expired', 'rejected', 'unknown']); + if (!verifiedOrder || terminalStatuses.has(verifiedStatus)) { + logger.warn(`[Executor] âš ï¸ Order ${order.id} was ${verifiedOrder?.status || 'lost'}. Not tracking position.`); + // Update order status in DB + await supabaseService.updateOrderStatus?.(order.id, verifiedOrder?.status || 'canceled'); + const pending = this.pendingOrders.get(order.id); + await this.releasePendingOrderReservation(pending); + this.pendingOrders.delete(order.id); + if (this.apiServer && (verifiedStatus === 'rejected' || verifiedStatus === 'canceled')) { + this.apiServer.recordOrderFailure({ + profileId: this.profileId, + userId: this.userId, + symbol, + side: side === SignalDirection.SELL ? 'SELL' : 'BUY', + qty: executionQty, + reason: `Order ${verifiedStatus}: ${verifiedOrder?.fail_reason || verifiedOrder?.reason || 'no reason provided'}`, + tradeId, + subTag: orderSubTag, + timestamp: Date.now() + }); + } + return { success: false, error: `Order ${verifiedOrder?.status || 'not filled'}` }; + } + + const fillPrice = Number(verifiedOrder?.filled_avg_price || initialPrice || 0); + const filledQty = this.getFilledQuantity(verifiedOrder) || executionQty; + + // ✅ Update order status in DB when filled + await supabaseService.updateOrderStatus?.( + order.id, + verifiedStatus, + new Date(), + fillPrice > 0 ? fillPrice : undefined, + filledQty + ); + + // --- Slippage Guard (for market orders) --- + if (type === 'market' && price && price > 0 && fillPrice > 0) { + const slippagePercent = Math.abs((fillPrice - price) / price) * 100; + if (slippagePercent > config.MAX_SLIPPAGE_PERCENT) { + logger.warn(`[Executor] âš ï¸ SLIPPAGE WARNING: ${symbol} requested ${price}, filled at ${fillPrice} (${slippagePercent.toFixed(2)}% slippage, max ${config.MAX_SLIPPAGE_PERCENT}%)`); + await this.notifier.sendAlert( + `âš ï¸ **SLIPPAGE WARNING**\n${symbol}: ${slippagePercent.toFixed(2)}% slippage\nRequested: $${price}\nFilled: $${fillPrice}`); + observabilityService.emitEvent({ + type: 'SYSTEM_ERROR', + severity: 'ERROR', + message: `Slippage breach for ${symbol}: ${slippagePercent.toFixed(2)}% (max ${config.MAX_SLIPPAGE_PERCENT}%).`, + profileId: this.profileId, + userId: finalUserId, + symbol + }); + if (config.ENABLE_AUTO_PAUSE_ON_SLIPPAGE_BREACH && !healthTracker.isPaused()) { + const pauseReason = `Auto-paused by slippage guard: ${symbol} breached max slippage (${slippagePercent.toFixed(2)}% > ${config.MAX_SLIPPAGE_PERCENT}%).`; + healthTracker.recordTradingControl({ + mode: 'PAUSED', + lastChangedBy: 'system:auto_slippage_guard', + lastChangedAt: Date.now(), + reason: pauseReason + }); + this.apiServer?.publishHealthSnapshot({ broadcast: true, force: true }); + logger.error(`[Guardrail] ${pauseReason}`); + } + } + } + + // Track verified position + this.upsertPosition(symbol, { + symbol, + side, + entryPrice: fillPrice, + size: filledQty, + stopLoss: sl || 0, + takeProfit: tp || 0, + peakPrice: fillPrice, + userId: finalUserId, + profileId: this.profileId, + tradeId + }); + + const pending = this.pendingOrders.get(order.id); + await this.finalizeEntryReservation(pending, fillPrice, filledQty); + this.pendingOrders.delete(order.id); + + logger.info(`[Executor] ✅ Order FILLED: ${symbol} ${side} ${filledQty} @ $${fillPrice} (Trade: ${tradeId})`); + logger.info('ENTRY filled', { + event: 'entry_filled', + symbol, + tradeId, + profileId: this.profileId, + qty: filledQty, + price: fillPrice, + userId: finalUserId, + side, + }); + await this.notifier.sendAlert(`🚀 **ORDER FILLED**\nSymbol: ${symbol}\nSide: ${side}\nQty: ${filledQty}\nPrice: $${fillPrice}`); + if (this.apiServer) { + const allPos = this.getAllActivePositions(); + this.apiServer.updatePositions(allPos, this.profileId || 'global'); + } + return { success: true, orderId: order.id }; + + } catch (error: any) { + await releaseCapitalOnAbort(); + await releaseLockIfHeld(); + console.error('[TradeExecutor] openPosition threw', { + symbol, + side, + type, + qty: executionQty, + price, + error: String(error), + stack: error?.stack + }); + logger.error('[TradeExecutor] openPosition exception', { + symbol, + side, + type, + qty: executionQty, + price, + error: String(error), + stack: error?.stack + }); + logger.error(`[Executor] ❌ Open Failed: ${error.message}`); + await this.notifier.sendAlert(`❌ **OPEN FAILED**\nSymbol: ${symbol}\nError: ${error.message}`); + if (this.apiServer) { + const normalizedSide = side === SignalDirection.SELL ? 'SELL' : 'BUY'; + this.apiServer.recordOrderFailure({ + profileId: this.profileId, + userId: this.userId, + symbol, + side: normalizedSide, + qty: executionQty, + reason: error.message ? error.message : error, + tradeId, + subTag: orderSubTag, + timestamp: Date.now() + }); + } + + return { success: false, error: error.message }; + } finally { + await releaseLockIfHeld(); + } + } + + /** + * Polls the exchange for order status until filled, cancelled, or max attempts reached. + */ + private async waitForFill(orderId: string, symbol: string, type: string): Promise { + // Market orders usually fill instantly, but verify anyway + const maxAttempts = type === 'market' ? 3 : config.ORDER_POLL_MAX_ATTEMPTS; + const pollInterval = config.ORDER_POLL_INTERVAL_MS; + + for (let i = 0; i < maxAttempts; i++) { + try { + const getOrder = this.exchange.getOrder; + if (getOrder) { + const order = await this.instrumentExchangeCall('get_order', () => getOrder.call(this.exchange, orderId)); + if (order) { + const status = order.status?.toLowerCase(); + if (status === 'filled' || status === 'partially_filled') { + logger.info(`[Executor] ✅ Order ${orderId} verified: ${status} @ $${order.filled_avg_price}`); + return order; + } + if (status === 'canceled' || status === 'expired' || status === 'rejected') { + logger.warn(`[Executor] ❌ Order ${orderId} terminal status: ${status}`); + return order; + } + logger.info(`[Executor] ⏳ Order ${orderId} status: ${status} (attempt ${i + 1}/${maxAttempts})`); + } + } else { + // Exchange doesn't support getOrder, assume filled + return { status: 'filled', filled_avg_price: 0 }; + } + } catch (e: any) { + logger.warn(`[Executor] Poll error for ${orderId}: ${e.message}`); + } + + if (i < maxAttempts - 1) { + await new Promise(resolve => setTimeout(resolve, pollInterval)); + } + } + + // --- TIMEOUT HANDLER (AUTO-CANCEL) --- + logger.warn(`[Executor] ⚠️ Order ${orderId} timed out after ${maxAttempts} polls. Attempting to CANCEL...`); + + try { + const cancelOrder = this.exchange.cancelOrder; + if (cancelOrder) { + const tradeSymbol = SymbolMapper.toTradeSymbol(symbol, config.EXECUTION_PROVIDER); + const cancelled = await this.instrumentExchangeCall('cancel_order', () => cancelOrder.call(this.exchange, orderId, tradeSymbol)); + + if (cancelled) { + logger.info(`[Executor] 🛑 Order ${orderId} CANCELLED by bot due to timeout.`); + return { status: 'canceled' }; + } + } else { + logger.warn(`[Executor] Connector does not support cancelOrder. Leaving order ${orderId} open.`); + } + } catch (e: any) { + logger.error(`[Executor] Failed to cancel timeout order ${orderId}: ${e.message}`); + } + + logger.warn(`[Executor] ⚠️ Order ${orderId} status unknown/uncancelled. Checking exchange position as fallback...`); + // Final fallback: check if exchange has the position + try { + const tradeSymbol = SymbolMapper.toTradeSymbol(symbol, config.EXECUTION_PROVIDER); + const pos = await this.exchange.getPosition(tradeSymbol); + if (pos) { + return { status: 'filled', filled_avg_price: parseFloat(pos.avg_entry_price), filled_qty: pos.qty }; + } + } catch (e) { /* ignore */ } + + return { status: 'unknown' }; + } + + private async releaseLedgerReservation(profileId?: string, amount?: number): Promise { + const ledgerProfileId = this.getLedgerProfileId(profileId); + if (!ledgerProfileId || !amount || amount <= 0) return; + await capitalLedger.releaseOrderReservation(ledgerProfileId, amount); + } + + public async releasePendingOrderReservation(pending?: PendingOrder): Promise { + if (!pending) return; + await this.releaseLedgerReservation(pending.profileId, pending.reservedAmount); + } + + public async finalizeEntryReservation(pending: PendingOrder | undefined, fillPrice: number, filledQty: number): Promise { + if (!pending) return; + const ledgerProfileId = this.getLedgerProfileId(pending.profileId); + if (!ledgerProfileId) return; + + const reservedAmount = Number.isFinite(pending.reservedAmount ?? 0) ? pending.reservedAmount || 0 : 0; + const reliablePrice = fillPrice > 0 ? fillPrice : pending.requestedPrice; + if (!Number.isFinite(reliablePrice) || reliablePrice <= 0 || !Number.isFinite(filledQty) || filledQty <= 0) { + if (reservedAmount > 0) { + await capitalLedger.releaseOrderReservation(ledgerProfileId, reservedAmount); + } + return; + } + + const notional = filledQty * reliablePrice; + if (reservedAmount > 0) { + await capitalLedger.releaseOrderReservation(ledgerProfileId, reservedAmount); + } + if (notional > 0) { + await capitalLedger.adjustPositionReservation(ledgerProfileId, notional); + } + const delta = Number((notional - reservedAmount).toFixed(8)); + const deltaWarnThreshold = Math.max(5, reservedAmount * 0.05); + if (Math.abs(delta) >= deltaWarnThreshold) { + logger.warn(`[Executor] Entry settlement delta for ${pending.symbol}: reserved=${reservedAmount.toFixed(2)} filledNotional=${notional.toFixed(2)} delta=${delta.toFixed(2)}`); + } + } + + public async reconcileEntryFill(order: any, fillPrice: number, filledQty: number): Promise { + const orderId = String(order.order_id || order.id || order.orderId || '').trim(); + if (!orderId) return; + + const resolvedSymbol = String(order.symbol || order.symbol_name || '').trim(); + const resolvedQty = Number.isFinite(filledQty) && filledQty > 0 ? filledQty : getReconciliationFillQty(order); + const resolvedPrice = Number.isFinite(fillPrice) && fillPrice > 0 ? fillPrice : getReconciliationFillPrice(order); + if (resolvedQty <= 0 || resolvedPrice <= 0 || !resolvedSymbol) return; + + const pending: PendingOrder = { + orderId, + symbol: resolvedSymbol, + side: this.normalizeSignalDirection(order.side), + qty: resolvedQty, + type: this.normalizeOrderType(order.type || order.order_type), + requestedPrice: resolvedPrice, + stopLoss: Number(order.stop_loss || 0), + takeProfit: Number(order.take_profit || 0), + tradeId: order.trade_id || order.tradeId, + userId: order.user_id || this.userId, + profileId: order.profile_id || this.profileId, + subTag: extractOrderSubTag(order) || undefined, + placedAt: Number(order.timestamp || Date.now()), + action: 'ENTRY', + reservedAmount: Math.max(resolvedQty * resolvedPrice, this.computeReservedAmount(order, resolvedPrice)) + }; + + this.pendingOrders.set(orderId, pending); + try { + await this.finalizeEntryReservation(pending, resolvedPrice, resolvedQty); + } finally { + this.pendingOrders.delete(orderId); + } + } + + public async reconcileExitFill(order: any, fillPrice: number, filledQty: number): Promise { + const symbol = String(order.symbol || order.symbol_name || '').trim(); + if (!symbol) return; + const qty = Number.isFinite(filledQty) && filledQty > 0 ? filledQty : getReconciliationFillQty(order); + const price = Number.isFinite(fillPrice) && fillPrice > 0 ? fillPrice : getReconciliationFillPrice(order); + if (qty <= 0 || price <= 0) return; + const tradeId = String(order.trade_id || order.tradeId || '').trim(); + + await this.applyExitFill(symbol, price, qty, 'Reconciliation fill', tradeId || undefined, order.side); + } + + public async reconcileCancel(order: any): Promise { + const orderId = String(order.order_id || order.id || order.orderId || '').trim(); + if (orderId && this.pendingOrders.has(orderId)) { + const pending = this.pendingOrders.get(orderId); + await this.releasePendingOrderReservation(pending); + this.pendingOrders.delete(orderId); + return; + } + + const ledgerProfileId = this.getLedgerProfileId(order.profile_id || this.profileId); + if (!ledgerProfileId) return; + + const reservedAmount = this.computeReservedAmount(order, getReconciliationFillPrice(order)); + if (reservedAmount > 0) { + await capitalLedger.releaseOrderReservation(ledgerProfileId, reservedAmount); + logger.info('Cancel applied', { + event: 'reconciliation_cancel', + profileId: ledgerProfileId, + orderId, + symbol: String(order.symbol || order.symbol_name || ''), + reservedAmount + }); + } + } + + /** + * Closes an existing position. + */ + public async closePosition( + symbol: string, + reason: string = 'Exit Signal', + tradeId?: string + ): Promise<{ success: boolean, exitPrice?: number, error?: string }> { + const selected = this.resolvePositionSelection(symbol, tradeId); + if (!selected) return { success: false, error: "No active position" }; + const pos = selected.position; + const positionTradeId = String(pos.tradeId || '').trim(); + + try { + const existingExit = this.getExitLifecycle(symbol); + if (existingExit.state === 'initiated' || existingExit.state === 'order_placed' || existingExit.state === 'verifying') { + logger.warn(`[Executor] Exit already in progress for ${symbol}. Current state: ${existingExit.state}`); + return { success: false, error: `Exit already in progress (${existingExit.state})` }; + } + if (existingExit.state === 'quarantined') { + logger.warn(`[Executor] Exit blocked for ${symbol}: lifecycle is quarantined and requires manual reconciliation.`); + return { success: false, error: 'EXIT_REQUIRES_MANUAL_RECONCILIATION' }; + } + + this.setExitLifecycle(symbol, 'initiated', reason); + const tradeSymbol = SymbolMapper.toTradeSymbol(symbol, config.EXECUTION_PROVIDER); + const exitSide = pos.side === SignalDirection.BUY ? 'sell' : 'buy'; + const exitSubTag = this.buildOrderSubTag(positionTradeId || undefined, 'EXIT'); + const exitClientOrderId = positionTradeId + ? `bytelyst-${pos.profileId || this.profileId || 'global'}-${positionTradeId}-exit` + : undefined; + if (exitSubTag) { + logger.info('[Executor] Alpaca sub-tag prepared', { + event: 'alpaca_subtag_prepared', + profileId: pos.profileId || this.profileId, + userId: pos.userId || this.userId, + symbol, + tradeId: positionTradeId, + intent: 'EXIT', + subTag: exitSubTag + }); + } + if (this.hasPendingAction(symbol, 'EXIT') || await this.hasActiveTradeId(positionTradeId)) { + logger.warn(`[Executor] Duplicate EXIT request blocked for ${symbol}`); + return { success: false, error: 'Duplicate exit request blocked (idempotency window)' }; + } + + logger.info(`[Executor] 🚪 Closing ${symbol} | Reason: ${reason}`); + + // --- Pre-flight SELL Guard (Ghost Position Check) --- + if (exitSide === 'sell') { + const currentPos = await this.instrumentExchangeCall('get_position', () => this.exchange.getPosition(tradeSymbol)); + const exchangeQty = Math.abs(Number(currentPos?.qty || 0)); + + if (exchangeQty < pos.size) { + if (exchangeQty > 0) { + logger.warn(`[Hardening] Partial SELL Exit for ${symbol}: adjusting order qty from ${pos.size} to available ${exchangeQty}. Remaining qty stays open pending fill evidence.`); + // We do NOT mutate pos.size here to avoid capital leaks in finalizeTrade. + // We use a local variable for the order volume. + } else { + logger.error(`[Guardrail] Aborting SELL Exit for ${symbol}: Insufficient exchange balance (Requested: ${pos.size}, Exchange: ${exchangeQty}).`); + if (config.REQUIRE_EXCHANGE_FILL_EVIDENCE_FOR_AUTO_CLOSE) { + this.setExitLifecycle(symbol, 'quarantined', 'EXCHANGE_STATE_MISMATCH', `Exchange inventory: ${exchangeQty}`); + observabilityService.emitEvent({ + type: 'EXIT_FILL_COHERENCE_VIOLATION', + severity: 'ERROR', + message: `Exit blocked for ${symbol}: exchange inventory is flat while DB position remains open. Manual reconciliation required.`, + profileId: pos.profileId || this.profileId, + userId: pos.userId || this.userId, + symbol + }); + if (this.apiServer) { + this.apiServer.recordOrderFailure({ + profileId: pos.profileId || this.profileId, + userId: pos.userId || this.userId, + symbol, + side: 'SELL', + qty: pos.size, + reason: 'EXCHANGE_STATE_MISMATCH', + tradeId: positionTradeId, + subTag: exitSubTag, + timestamp: Date.now() + }); + } + return { success: false, error: 'EXCHANGE_STATE_MISMATCH_MANUAL_REVIEW' }; + } + + logger.warn(`[Guardrail] REQUIRE_EXCHANGE_FILL_EVIDENCE_FOR_AUTO_CLOSE=false; applying legacy local finalize fallback for ${symbol}.`); + await this.finalizeTrade(symbol, pos.entryPrice, 'EXCHANGE_STATE_MISMATCH', positionTradeId); + this.setExitLifecycle(symbol, 'failed', 'EXCHANGE_STATE_MISMATCH', `Exchange inventory: ${exchangeQty}`); + return { success: false, error: 'EXCHANGE_STATE_MISMATCH' }; + } + } + } + + // Using helper to get available qty for closure + const exchangeQty = await this.getExchangeAvailableQty(tradeSymbol); + const effectiveOrderQty = (exitSide === 'sell') + ? Math.min(pos.size, exchangeQty) + : pos.size; + + // Fetch current price for logging if needed, or rely on execution + // We usually just execute market exit + const order = await this.instrumentExchangeCall('place_order', () => this.exchange.placeOrder( + tradeSymbol, + exitSide, + effectiveOrderQty, + 'market', + undefined, + undefined, + undefined, + exitClientOrderId, + { + subTag: exitSubTag, + profileId: pos.profileId || this.profileId, + tradeId: positionTradeId || undefined, + intent: 'EXIT' + } + )); + + if (!order) { + return { success: false, error: "Order failed" }; + } + if (exitClientOrderId) { + order.client_order_id = order.client_order_id || exitClientOrderId; + } + if (exitSubTag) { + order.subtag = order.subtag || exitSubTag; + order.sub_tag = order.sub_tag || exitSubTag; + } + this.setExitLifecycle(symbol, 'order_placed', reason, undefined, order.id); + + // --- Order Fill Verification --- + this.setExitLifecycle(symbol, 'verifying', reason, undefined, order.id); + const verifiedOrder = await this.waitForFill(order.id, symbol, 'market'); + const verifiedStatus = (verifiedOrder?.status || '').toLowerCase(); + const exitPrice = Number(verifiedOrder?.filled_avg_price || order.filled_avg_price || 0); + const rawFilledQty = this.getFilledQuantity(verifiedOrder); + const exchangeFilledQty = Number.isFinite(rawFilledQty as number) && Number(rawFilledQty) > 0 + ? Number(rawFilledQty) + : undefined; + const normalizedFilledQty = exchangeFilledQty + ? Math.min(exchangeFilledQty, pos.size) + : undefined; + const persistedExitQty = exchangeFilledQty && exchangeFilledQty > 0 + ? exchangeFilledQty + : pos.size; + const finalUserId = pos.userId || this.userId; + + // Log the Exit with same trade_id for full cycle tracing + await this.logOrderToDb(order, symbol, exitSide, persistedExitQty, exitPrice, 'market', 0, 0, finalUserId, positionTradeId, 'EXIT'); + this.updateDashboardOrder(order, symbol, exitSide, persistedExitQty, exitPrice, 'market', positionTradeId, 'EXIT'); + + // --- Track for background sync --- + this.pendingOrders.set(order.id, { + orderId: order.id, + symbol, + side: pos.side === SignalDirection.BUY ? SignalDirection.SELL : SignalDirection.BUY, // opposite for exit + qty: persistedExitQty, + type: 'market', + requestedPrice: exitPrice, + stopLoss: 0, + takeProfit: 0, + tradeId: positionTradeId, + userId: finalUserId, + subTag: exitSubTag, + placedAt: Date.now(), + action: 'EXIT' + }); + + // Remove from tracking as this close path handles terminal state immediately + this.pendingOrders.delete(order.id); + + // Do NOT finalize trade locally unless exchange confirms a fill. + if (!verifiedOrder || ['canceled', 'expired', 'rejected', 'unknown'].includes(verifiedStatus)) { + logger.warn(`[Executor] ⚠️ Exit order ${order.id} for ${symbol} ended as ${verifiedStatus || 'unknown'}. Keeping local position open.`); + await supabaseService.updateOrderStatus?.(order.id, verifiedStatus || 'unknown'); + this.setExitLifecycle( + symbol, + verifiedStatus === 'unknown' ? 'quarantined' : 'failed', + reason, + `verified_status=${verifiedStatus || 'unknown'}`, + order.id + ); + await this.notifier.sendAlert(`⚠️ **EXIT NOT CONFIRMED**\nSymbol: ${symbol}\nStatus: ${verifiedStatus || 'unknown'}\nAction: Manual review required`); + return { success: false, error: `Exit order ${verifiedStatus || 'unknown'}` }; + } + + // ✅ Update order status in DB for confirmed exit fills + if (verifiedStatus === 'partially_filled' && (!normalizedFilledQty || normalizedFilledQty <= 0)) { + await supabaseService.updateOrderStatus?.(order.id, verifiedStatus, new Date(), exitPrice > 0 ? exitPrice : undefined); + this.setExitLifecycle(symbol, 'quarantined', reason, 'partial_fill_missing_qty', order.id); + await this.notifier.sendAlert(`PARTIAL EXIT QUARANTINED\nSymbol: ${symbol}\nOrder: ${order.id}\nAction: Fill qty missing, manual review required`); + return { success: false, error: 'Partial exit fill qty missing' }; + } + + await supabaseService.updateOrderStatus?.( + order.id, + verifiedStatus || 'filled', + new Date(), + exitPrice > 0 ? exitPrice : undefined, + persistedExitQty + ); + const applied = await this.applyExitFill( + symbol, + exitPrice, + normalizedFilledQty || (verifiedStatus === 'filled' ? pos.size : undefined), + reason, + positionTradeId, + order.side + ); + + if (!applied.success) { + this.setExitLifecycle(symbol, 'failed', reason, applied.error || 'exit_fill_apply_failed', order.id); + return { success: false, error: applied.error || 'Failed to apply exit fill' }; + } + + if (!applied.fullyClosed) { + this.setExitLifecycle(symbol, 'idle', reason, `partial_exit_remaining=${applied.remainingSize}`, order.id); + await this.notifier.sendAlert(`PARTIAL EXIT FILLED\nSymbol: ${symbol}\nFilled Qty: ${applied.appliedQty}\nRemaining Qty: ${applied.remainingSize}\nPrice: ${exitPrice}`); + logger.info('Exit partial fill', { + event: 'exit_partial_fill', + symbol, + tradeId: positionTradeId, + profileId: this.profileId, + filledQty: applied.appliedQty, + remainingQty: applied.remainingSize, + price: exitPrice + }); + return { success: true, exitPrice }; + } + + this.setExitLifecycle(symbol, 'filled', reason, `verified_status=${verifiedStatus || 'filled'}`, order.id); + logger.info('EXIT filled', { + event: 'exit_filled', + symbol, + tradeId: positionTradeId, + profileId: this.profileId, + filledQty: persistedExitQty, + price: exitPrice + }); + await this.notifier.sendAlert(`POSITION CLOSED\nSymbol: ${symbol}\nReason: ${reason}\nPrice: ${exitPrice}`); + return { success: true, exitPrice }; + } catch (error: any) { + logger.error(`[Executor] ❌ Close Failed: ${error.message}`); + this.setExitLifecycle(symbol, 'failed', reason, error.message); + await this.notifier.sendAlert(`❌ **CLOSE FAILED**\nSymbol: ${symbol}\nError: ${error.message}`); + return { success: false, error: error.message }; + } + } + + public async applyExitFill( + symbol: string, + exitPrice: number, + fillQty: number | undefined, + reason: string, + tradeId?: string, + fillSide?: string + ): Promise { + const selected = this.resolvePositionSelection(symbol, tradeId); + if (!selected) { + return { + success: false, + fullyClosed: false, + appliedQty: 0, + remainingSize: 0, + error: 'No active position' + }; + } + const pos = selected.position; + const selectedTradeId = String(pos.tradeId || '').trim(); + + // FIX-05: Directional coherence guard + const exitSideExpected = pos.side === SignalDirection.BUY ? 'SELL' : 'BUY'; + const fillSideNormalized = String(fillSide || '').trim().toUpperCase(); + if (fillSideNormalized && fillSideNormalized !== exitSideExpected && fillSideNormalized !== 'UNKNOWN') { + logger.error(`[Executor] ❌ applyExitFill coherence violation for ${symbol}: position is ${pos.side}, but fill side is ${fillSideNormalized}. Aborting exit application.`); + observabilityService.emitEvent({ + type: 'EXIT_FILL_COHERENCE_VIOLATION', + severity: 'ERROR', + message: `Exit fill side ${fillSideNormalized} does not match expected ${exitSideExpected} for ${pos.side} position.`, + profileId: this.profileId, + symbol + }); + return { success: false, fullyClosed: false, appliedQty: 0, remainingSize: pos.size, error: 'coherence_violation' }; + } + + const requestedFillQty = Number(fillQty); + if (!Number.isFinite(requestedFillQty) || requestedFillQty <= 0) { + return { + success: false, + fullyClosed: false, + appliedQty: 0, + remainingSize: pos.size, + error: 'Missing exit fill qty' + }; + } + + const appliedQty = Math.min(requestedFillQty, pos.size); + const remainingSize = Math.max(0, Number((pos.size - appliedQty).toFixed(8))); + const resolvedExitPrice = Number.isFinite(exitPrice) && exitPrice > 0 ? exitPrice : pos.entryPrice; + + // Fully closed lifecycle: finalize and clear local position state. + if (remainingSize <= 1e-8) { + await this.finalizeTrade(symbol, resolvedExitPrice, reason, selectedTradeId); + return { + success: true, + fullyClosed: true, + appliedQty, + remainingSize: 0 + }; + } + + // Partial exit lifecycle: persist realized slice and keep monitoring remainder. + const pnl = (resolvedExitPrice - pos.entryPrice) * appliedQty * (pos.side === SignalDirection.BUY ? 1 : -1); + const pnlPercent = pos.entryPrice > 0 + ? ((resolvedExitPrice - pos.entryPrice) / pos.entryPrice) * 100 * (pos.side === SignalDirection.BUY ? 1 : -1) + : 0; + const finalUserId = pos.userId || this.userId; + const normalizedTradeId = selectedTradeId; + let canLogPartial = true; + + if (finalUserId !== 'global') { + if (normalizedTradeId) { + canLogPartial = await supabaseService.hasLifecycleEntryOrder( + normalizedTradeId, + pos.profileId || this.profileId, + symbol + ); + if (!canLogPartial) { + logger.warn(`[Executor] Skipping partial EXIT history log for ${symbol}: lifecycle ${normalizedTradeId} has no ENTRY chain.`); + } + } + + if (canLogPartial) { + await supabaseService.logTransaction({ + user_id: finalUserId, + profile_id: pos.profileId || this.profileId, + symbol, + side: pos.side, + entry_price: pos.entryPrice, + exit_price: resolvedExitPrice, + size: appliedQty, + pnl, + pnl_percent: pnlPercent, + reason: `${reason} (Partial Exit)`, + timestamp: Date.now(), + stop_loss: pos.stopLoss || undefined, + take_profit: pos.takeProfit || undefined, + trade_id: pos.tradeId, + source: 'BOT' + }); + } + } + + const ledgerProfileId = this.getLedgerProfileId(pos.profileId || this.profileId); + // Only mutate capital ledger when this partial exit has a valid lifecycle ENTRY chain. + if (ledgerProfileId && canLogPartial) { + const reduction = appliedQty * (pos.entryPrice || 0); + if (reduction > 0) { + await capitalLedger.adjustPositionReservation(ledgerProfileId, -reduction); + } + if (Number.isFinite(pnl)) { + await capitalLedger.recordRealizedPnl(ledgerProfileId, pnl); + } + } else if (ledgerProfileId && !canLogPartial) { + logger.warn(`[Executor] Skipping partial EXIT ledger mutation for ${symbol}: lifecycle ${normalizedTradeId || 'unknown'} has no ENTRY chain.`); + } + + this.upsertPosition(symbol, { + ...pos, + symbol, + size: remainingSize, + peakPrice: resolvedExitPrice + }); + + if (this.apiServer) { + const allPos = this.getAllActivePositions(); + this.apiServer.updatePositions(allPos, this.profileId || 'global'); + } + + return { + success: true, + fullyClosed: false, + appliedQty, + remainingSize + }; + } + + // --- Aliases for Compatibility/Clarity --- + public async executeExit(symbol: string, currentPrice: number, reason: string, tradeId?: string) { + // We ignore currentPrice for market order, but could log it + return this.closePosition(symbol, reason, tradeId); + } + + public async markTradeComplete(symbol: string, exitPrice: number, reason: string = 'Target Reached', tradeId?: string) { + return await this.finalizeTrade(symbol, exitPrice, reason, tradeId); + } + + /** + * Consolidates trade history logic. + * Clears local state and starts cooldown. + */ + public async finalizeTrade(symbol: string, exitPrice: number, reason: string, tradeId?: string) { + const selected = this.resolvePositionSelection(symbol, tradeId); + let pos = selected?.position; + const normalizedInputTradeId = String(tradeId || '').trim(); + + // --- RECOVERY: If lost from memory (restart gap), try to recover ENTRY from DB --- + if (!pos) { + logger.info(`[Executor] 🔍 Position for ${symbol} not in memory. Attempting DB recovery for history log...`); + try { + // Look for the latest ENTRY order for this symbol/profile + if (this.profileId) { + const lastEntry = await supabaseService.getLatestEntryOrder(this.profileId, symbol, this.userId); + + if (lastEntry) { + pos = { + symbol, + side: lastEntry.side as SignalDirection, + entryPrice: lastEntry.price, + size: lastEntry.qty, + stopLoss: lastEntry.stop_loss, + takeProfit: lastEntry.take_profit, + peakPrice: lastEntry.price, + userId: lastEntry.user_id, + profileId: lastEntry.profile_id, + tradeId: lastEntry.trade_id + }; + logger.info(`[Executor] 🧠 Recovered entry details for ${symbol} (Price: ${pos.entryPrice})`); + } + } + } catch (e) { + logger.warn(`[Executor] Failed to recover entry details for ${symbol}: ${e}`); + } + } + + if (pos && exitPrice) { + const profileScope = pos.profileId || this.profileId; + const normalizedTradeId = String(pos.tradeId || normalizedInputTradeId).trim(); + + let hasEntryChain = true; + if (normalizedTradeId) { + hasEntryChain = await supabaseService.hasLifecycleEntryOrder(normalizedTradeId, profileScope, symbol); + if (!hasEntryChain) { + logger.warn(`[Executor] Suppressing finalize history for ${symbol}: lifecycle ${normalizedTradeId} has no ENTRY chain.`); + } + } + + let alreadyFinalized = false; + if (normalizedTradeId && hasEntryChain) { + alreadyFinalized = await supabaseService.hasFinalizedTradeHistory(normalizedTradeId, profileScope, symbol); + } + + const pnl = (exitPrice - pos.entryPrice) * pos.size * (pos.side === SignalDirection.BUY ? 1 : -1); + const pnlPercent = ((exitPrice - pos.entryPrice) / pos.entryPrice) * 100 * (pos.side === SignalDirection.BUY ? 1 : -1); + const ledgerProfileId = this.getLedgerProfileId(profileScope); + const canMutateLedger = hasEntryChain && !alreadyFinalized; + if ( + canMutateLedger + && ledgerProfileId + && Number.isFinite(pos.size) + && pos.size > 0 + && Number.isFinite(pos.entryPrice) + && pos.entryPrice > 0 + ) { + const releaseNotional = pos.size * pos.entryPrice; + await capitalLedger.adjustPositionReservation(ledgerProfileId, -releaseNotional); + await capitalLedger.recordRealizedPnl(ledgerProfileId, pnl); + } else if (ledgerProfileId && !canMutateLedger) { + logger.warn(`[Executor] Skipping finalize ledger mutation for ${symbol} (trade=${normalizedTradeId || 'unknown'}): hasEntryChain=${hasEntryChain}, alreadyFinalized=${alreadyFinalized}.`); + } + + if (alreadyFinalized) { + logger.warn(`[Executor] Duplicate finalize suppressed for ${symbol} (trade=${normalizedTradeId}).`); + } else if (hasEntryChain) { + if (this.apiServer) { + this.apiServer.addHistory({ + symbol, + side: pos.side, + entryPrice: pos.entryPrice, + exitPrice, + size: pos.size, + pnl, + pnlPercent, + reason, + timestamp: Date.now(), + profileId: profileScope, + source: 'BOT', + trade_id: pos.tradeId + }); + } + + const finalUserId = pos.userId || this.userId; + if (finalUserId !== 'global') { + await supabaseService.logTransaction({ + user_id: finalUserId, + profile_id: profileScope, + symbol, + side: pos.side, + entry_price: pos.entryPrice, + exit_price: exitPrice, + size: pos.size, + pnl, + pnl_percent: pnlPercent, + reason, + timestamp: Date.now(), + stop_loss: pos.stopLoss || undefined, + take_profit: pos.takeProfit || undefined, + trade_id: pos.tradeId, + source: 'BOT' + }); + } + } + } else { + logger.warn(`[Executor] Could not finalize trade for ${symbol}: Missing position data.`); + } + + this.removePosition(symbol, normalizedInputTradeId || pos?.tradeId); + const hasRemainingSymbolPositions = this.getPositionsForSymbol(symbol).length > 0; + if (!hasRemainingSymbolPositions) { + this.exitLifecycle.delete(symbol); + this.cooldowns.set(symbol, Date.now()); + } + if (this.apiServer) { + const allPos = this.getAllActivePositions(); + this.apiServer.updatePositions(allPos, this.profileId || 'global'); + } + logger.info(`[Executor] ✅ Trade finalized for ${symbol}. Cooldown started.`); + } + + // --- Helpers --- + + private async getExchangeAvailableQty(tradeSymbol: string): Promise { + try { + const currentPos = await this.instrumentExchangeCall('get_position', () => this.exchange.getPosition(tradeSymbol)); + return Math.abs(Number(currentPos?.qty || 0)); + } catch (e) { + logger.warn(`[Executor] Failed to fetch exchange qty for ${tradeSymbol}: ${e}`); + return 0; + } + } + + private async logOrderToDb(order: any, symbol: string, side: string, qty: number, price: number, type: string, stopLoss?: number, takeProfit?: number, specificUserId?: string, tradeId?: string, action?: string) { + const finalUserId = specificUserId || this.userId; + const orderSubTag = extractOrderSubTag(order) || undefined; + if (finalUserId !== 'global') { + await supabaseService.logOrder({ + user_id: finalUserId, + profile_id: this.profileId, + order_id: order.id, + symbol, + type: normalizeOrderType(type), + side: normalizeTradeSide(side), + qty, + price: price, + status: normalizeOrderStatus(order.status || 'filled'), + timestamp: Date.now(), + stop_loss: stopLoss, + take_profit: takeProfit, + trade_id: tradeId, + action: normalizeOrderAction(action), // 'ENTRY' or 'EXIT' + sub_tag: orderSubTag + }); + } + } + + private updateDashboardOrder(order: any, symbol: string, side: string, qty: number, price: number, type: string, tradeId?: string, action?: string) { + // We now use broadcastOrders to send the FULL current state to avoid overwriting issues + this.broadcastOrders(); + } + + public broadcastOrders() { + if (!this.apiServer) return; + + const orders = Array.from(this.pendingOrders.values()).map(p => ({ + id: p.orderId, + symbol: p.symbol, + type: p.type === 'market' ? 'Market' : 'Limit', + side: p.side as string, + qty: p.qty, + price: p.requestedPrice, + status: 'pending_new', + timestamp: p.placedAt, + profileId: this.profileId, + source: 'BOT' as const, + trade_id: p.tradeId, + subTag: p.subTag, + action: p.action + })); + + this.apiServer.updateOrders(orders, this.profileId || 'global'); + } + + public async syncPositions(symbols: string[]) { + logger.info('[Executor] Syncing exchange positions...'); + const isDedicatedProfileScope = !!this.profileId + && this.profileId !== 'global' + && !this.profileId.startsWith('default-'); + + for (const symbol of symbols) { + try { + const tradeSymbol = SymbolMapper.toTradeSymbol(symbol, config.EXECUTION_PROVIDER); + const position = await this.exchange.getPosition(tradeSymbol); + const hasExchangePosition = !!position && Math.abs(Number(position.qty || 0)) > 0; + + if (isDedicatedProfileScope && this.profileId) { + const symbolCandidates = Array.from(new Set([ + symbol, + tradeSymbol, + SymbolMapper.toDataSymbol(symbol, config.EXECUTION_PROVIDER), + SymbolMapper.toDataSymbol(tradeSymbol, config.EXECUTION_PROVIDER) + ].filter(Boolean))); + + let virtualPosition = null as Awaited>; + for (const candidateSymbol of symbolCandidates) { + virtualPosition = await supabaseService.getVirtualOpenPosition(this.profileId, candidateSymbol); + if (virtualPosition) break; + } + + const previousSymbolPositions = this.getActivePositions(symbol); + if (!virtualPosition) { + if (!hasExchangePosition) { + if (previousSymbolPositions.length > 0) { + this.removePosition(symbol); + logger.info(`[Executor] Cleared ${previousSymbolPositions.length} local profile position(s) for ${symbol} under ${this.profileId}; exchange is flat and no virtual lifecycle remained.`); + } + } else if (previousSymbolPositions.length > 0) { + logger.warn(`[Executor] Retaining ${previousSymbolPositions.length} local profile position(s) for ${symbol} under ${this.profileId}; exchange is open but virtual lifecycle lookup returned empty (checked ${symbolCandidates.join(', ')}).`); + } else { + logger.warn(`[Executor] Skipping sync claim for ${symbol} under profile ${this.profileId}: no profile-scoped virtual open position found (checked ${symbolCandidates.join(', ')}).`); + } + continue; + } + + const virtualSide = virtualPosition.side === 'BUY' ? SignalDirection.BUY : SignalDirection.SELL; + + const exchangeSideRaw = (position?.side || '').toLowerCase(); + const exchangeSide = (exchangeSideRaw === 'long' || exchangeSideRaw === 'buy') + ? SignalDirection.BUY + : SignalDirection.SELL; + const exchangeQty = Math.abs(Number(position?.qty || 0)); + + if (!hasExchangePosition) { + logger.warn(`[Executor] Virtual position exists for ${symbol} under profile ${this.profileId}, but exchange is flat. Keeping virtual profile state.`); + } else { + if (exchangeSide !== virtualSide) { + logger.warn(`[Executor] Side mismatch for ${symbol} under profile ${this.profileId}: virtual=${virtualSide}, exchange=${exchangeSide}.`); + } + if (exchangeQty > 0 && virtualPosition.qty > exchangeQty + 1e-8) { + logger.warn(`[Executor] Qty mismatch for ${symbol} under profile ${this.profileId}: virtual=${virtualPosition.qty}, exchange=${exchangeQty}.`); + } + } + + const virtualTradeIds = Array.from(new Set((virtualPosition.tradeIds || []).map((id) => String(id || '').trim()).filter(Boolean))); + const recoveredSlices: PositionState[] = []; + + for (const tradeId of virtualTradeIds) { + const slice = await supabaseService.getVirtualOpenPositionForTrade( + this.profileId, + virtualPosition.symbol || symbol, + tradeId + ); + if (!slice) continue; + + let recoveredStopLoss = Number(slice.stopLoss || 0); + let recoveredTakeProfit = Number(slice.takeProfit || 0); + if (recoveredStopLoss <= 0 || recoveredTakeProfit <= 0) { + const riskFallback = await supabaseService.getLatestEntryRiskOrder( + this.profileId, + slice.symbol || symbol, + slice.side + ); + if (riskFallback) { + const sl = Number(riskFallback.stop_loss); + const tp = Number(riskFallback.take_profit); + if (recoveredStopLoss <= 0 && Number.isFinite(sl) && sl > 0) { + recoveredStopLoss = sl; + } + if (recoveredTakeProfit <= 0 && Number.isFinite(tp) && tp > 0) { + recoveredTakeProfit = tp; + } + } + } + + const existingLocal = previousSymbolPositions.find((candidate) => String(candidate.tradeId || '').trim() === tradeId); + if (existingLocal && existingLocal.side === (slice.side === 'BUY' ? SignalDirection.BUY : SignalDirection.SELL)) { + if (recoveredStopLoss <= 0 && Number(existingLocal.stopLoss) > 0) { + recoveredStopLoss = Number(existingLocal.stopLoss); + } + if (recoveredTakeProfit <= 0 && Number(existingLocal.takeProfit) > 0) { + recoveredTakeProfit = Number(existingLocal.takeProfit); + } + } + + recoveredSlices.push({ + symbol, + side: slice.side === 'BUY' ? SignalDirection.BUY : SignalDirection.SELL, + entryPrice: slice.entryPrice, + size: slice.qty, + stopLoss: recoveredStopLoss, + takeProfit: recoveredTakeProfit, + peakPrice: slice.entryPrice, + userId: slice.userId || this.userId, + profileId: this.profileId, + tradeId: slice.tradeId + }); + + // FIX-01: Alert when stop-loss cannot be recovered — position will run unprotected. + if (recoveredStopLoss <= 0) { + logger.error(`[Executor] ⚠️ RECOVERY_SL_MISSING: stopLoss=0 for ${symbol} tradeId=${slice.tradeId} profile=${this.profileId}. Position runs WITHOUT stop-loss until data is available.`); + observabilityService.emitEvent({ + type: 'RECOVERY_SL_MISSING', + severity: 'ERROR', + message: `Recovered position for ${symbol} (trade=${slice.tradeId}) has stopLoss=0. Stop-loss protection is DISABLED for this trade until restart or manual correction.`, + profileId: this.profileId, + symbol + }); + } + } + + if (recoveredSlices.length === 0) { + const virtualSide = virtualPosition.side === 'BUY' ? SignalDirection.BUY : SignalDirection.SELL; + let recoveredStopLoss = Number(virtualPosition.stopLoss || 0); + let recoveredTakeProfit = Number(virtualPosition.takeProfit || 0); + if (recoveredStopLoss <= 0 || recoveredTakeProfit <= 0) { + const riskFallback = await supabaseService.getLatestEntryRiskOrder( + this.profileId, + virtualPosition.symbol || symbol, + virtualPosition.side + ); + if (riskFallback) { + const sl = Number(riskFallback.stop_loss); + const tp = Number(riskFallback.take_profit); + if (recoveredStopLoss <= 0 && Number.isFinite(sl) && sl > 0) { + recoveredStopLoss = sl; + } + if (recoveredTakeProfit <= 0 && Number.isFinite(tp) && tp > 0) { + recoveredTakeProfit = tp; + } + } + } + + recoveredSlices.push({ + symbol, + side: virtualSide, + entryPrice: virtualPosition.entryPrice, + size: virtualPosition.qty, + stopLoss: recoveredStopLoss, + takeProfit: recoveredTakeProfit, + peakPrice: virtualPosition.entryPrice, + userId: virtualPosition.userId || this.userId, + profileId: this.profileId, + tradeId: virtualPosition.tradeId + }); + + // FIX-01: Alert when stop-loss cannot be recovered — position will run unprotected. + if (recoveredStopLoss <= 0) { + logger.error(`[Executor] ⚠️ RECOVERY_SL_MISSING: stopLoss=0 for ${symbol} tradeId=${virtualPosition.tradeId} profile=${this.profileId}. Position runs WITHOUT stop-loss until data is available.`); + observabilityService.emitEvent({ + type: 'RECOVERY_SL_MISSING', + severity: 'ERROR', + message: `Recovered position for ${symbol} (trade=${virtualPosition.tradeId}) has stopLoss=0. Stop-loss protection is DISABLED for this trade until restart or manual correction.`, + profileId: this.profileId, + symbol + }); + } + } + + this.removePosition(symbol); + for (const recovered of recoveredSlices) { + this.upsertPosition(symbol, recovered); + } + + logger.info(`[Executor] Recovered ${recoveredSlices.length} virtual profile position(s) for ${symbol} under ${this.profileId}.`); + continue; + } + + if (!hasExchangePosition) { + const hadLocalPositions = this.getPositionsForSymbol(symbol).length > 0; + if (hadLocalPositions) { + this.removePosition(symbol); + logger.info(`[Executor] Exchange has no open position for ${symbol}; local state cleared.`); + } + continue; + } + + const side = (position?.side || '').toLowerCase(); + const finalSide = (side === 'long' || side === 'buy') ? SignalDirection.BUY : SignalDirection.SELL; + const exchangeEntryPrice = Number(position?.avg_entry_price || 0); + const exchangeSize = Math.abs(Number(position?.qty || 0)); + + logger.info(`[Executor] Found existing ${side} position for ${symbol}.`); + let recoveredSl = 0; + let recoveredTp = 0; + let recoveredUserId = this.userId; + let recoveredTradeId = this.buildDeterministicSyncTradeId(symbol); // Default if not found + let recoveredEntryPrice = exchangeEntryPrice; + let recoveredSize = exchangeSize; + + // Try to recover from DB + try { + // Priority 1: Find the FILLED ENTRY for this specific symbol/user/profile + // This ensures we link to the start of the trade lifecycle + const entryOrder = await supabaseService.getLatestFilledEntry(this.userId, symbol, this.profileId); + + if (entryOrder) { + if (entryOrder.trade_id) { + recoveredTradeId = entryOrder.trade_id; + logger.info(`[Executor] Recovered Trade ID ${recoveredTradeId} from ENTRY order for ${symbol}`); + } + recoveredUserId = entryOrder.user_id; + recoveredSl = entryOrder.stop_loss || 0; + recoveredTp = entryOrder.take_profit || 0; + } else { + const scopedEntry = this.profileId + ? await supabaseService.getLatestEntryOrder(this.profileId, symbol, this.userId) + : null; + + if (scopedEntry) { + recoveredSl = scopedEntry.stop_loss || 0; + recoveredTp = scopedEntry.take_profit || 0; + recoveredUserId = scopedEntry.user_id || recoveredUserId; + if (scopedEntry.trade_id) { + recoveredTradeId = scopedEntry.trade_id; + logger.info(`[Executor] Recovered Trade ID ${recoveredTradeId} for ${symbol} from profile-scoped entry fallback`); + } + } else { + // Legacy/global fallback: Check the very last order (might be a modification or partial fill) + const lastOrder = await supabaseService.getLatestOrder(this.userId, symbol); + if (lastOrder) { + recoveredSl = lastOrder.stop_loss || 0; + recoveredTp = lastOrder.take_profit || 0; + recoveredUserId = lastOrder.user_id; + + if (lastOrder.trade_id) { + recoveredTradeId = lastOrder.trade_id; + logger.info(`[Executor] Recovered Trade ID ${recoveredTradeId} for ${symbol} from latest order (fallback)`); + } + logger.info(`[Executor] Recovered SL/TP for ${symbol} from DB: SL=${recoveredSl}, TP=${recoveredTp}`); + } else { + logger.warn(`[Executor] No history found for ${symbol}. Using synthetic Trade ID: ${recoveredTradeId}`); + } + } + } + } catch (dbE) { + logger.warn(`[Executor] Could not recover state from DB for ${symbol}: ${dbE}`); + } + + this.upsertPosition(symbol, { + symbol, + side: finalSide, + entryPrice: recoveredEntryPrice, + size: recoveredSize, + stopLoss: recoveredSl, + takeProfit: recoveredTp, + peakPrice: recoveredEntryPrice, + userId: recoveredUserId, + profileId: this.profileId, + tradeId: recoveredTradeId // Now persisted + }); + } catch (e) { + logger.error(`[Executor] Sync failed for ${symbol}: ${e}`); + } + } + // --- NEW: Immediately sync to dashboard after syncing with exchange --- + if (this.apiServer) { + const allPos = this.getAllActivePositions(); + this.apiServer.updatePositions(allPos, this.profileId || 'global'); + this.broadcastOrders(); + } + + // --- NEW: Recover pending orders from DB for background sync --- + if (this.profileId) { + try { + const pending = await supabaseService.getPendingOrdersForProfile(this.profileId); + for (const p of pending) { + const pendingOrderId = String(p.order_id || '').trim(); + if (!pendingOrderId) { + continue; + } + const normalizedAction = String(p.action || '').toUpperCase(); + const normalizedSide = normalizeTradeSide(p.side || 'BUY'); + const isExitLike = normalizedAction === 'EXIT' + || (!normalizedAction && normalizedSide === 'SELL'); + + if (isExitLike) { + let lifecycleClosed = false; + const normalizedTradeId = String(p.trade_id || '').trim(); + + if (normalizedTradeId) { + lifecycleClosed = await supabaseService.isTradeLifecycleClosed( + normalizedTradeId, + this.profileId, + p.symbol + ); + } else { + const virtualPosition = await supabaseService.getVirtualOpenPosition(this.profileId, p.symbol); + lifecycleClosed = !virtualPosition; + } + + if (lifecycleClosed) { + await supabaseService.updateOrderStatus?.(pendingOrderId, 'canceled'); + logger.warn(`[Executor] Auto-resolved stale pending EXIT ${pendingOrderId} for ${p.symbol} under profile ${this.profileId}.`); + continue; + } + } + + if (!this.pendingOrders.has(pendingOrderId)) { + const normalizedSide = normalizeTradeSide(p.side || 'BUY'); + const resolvedAction = normalizeOrderAction(p.action || undefined) + || (normalizedSide === SignalDirection.SELL ? 'EXIT' : 'ENTRY'); + this.pendingOrders.set(pendingOrderId, { + orderId: pendingOrderId, + symbol: p.symbol, + side: normalizedSide as SignalDirection, + qty: p.qty, + type: (p.type || 'market').toLowerCase() as 'market' | 'limit', + requestedPrice: p.price, + stopLoss: p.stop_loss || 0, + takeProfit: p.take_profit || 0, + tradeId: p.trade_id || '', + userId: p.user_id, + subTag: extractOrderSubTag(p) || undefined, + placedAt: new Date(p.created_at || Date.now()).getTime(), + action: resolvedAction + }); + logger.info(`[Executor] 🔄 Recovered pending order ${p.order_id} for ${p.symbol} into monitoring map.`); + } + } + } catch (pE) { + logger.warn(`[Executor] Failed to recover pending orders: ${pE}`); + } + } + } + + /** + * Returns all currently active trades for dashboard display + */ + public getAllActivePositions() { + return Array.from(this.activeTraders.values()) + .filter((pos) => pos.side !== SignalDirection.NONE) + .map((pos) => ({ + id: pos.tradeId || `pos-${pos.symbol}`, + symbol: pos.symbol, + side: pos.side as 'BUY' | 'SELL', + size: pos.size, + entryPrice: pos.entryPrice, + currentPrice: pos.peakPrice || pos.entryPrice, + stopLoss: pos.stopLoss, + takeProfit: pos.takeProfit, + unrealizedPnl: 0, + unrealizedPnlPercent: 0, + marketValue: pos.size * (pos.peakPrice || pos.entryPrice), + userId: pos.userId, + profileId: pos.profileId, + profileName: '', // Will be matched by dashboard or service + tradeId: pos.tradeId + })); + } + + public getProfileId() { + return this.profileId; + } + + public getUserId() { + return this.userId; + } + + public async checkExchangeOrderStatus(orderId: string, symbol: string): Promise { + if (!this.exchange.getOrder) return null; + try { + const tradeSymbol = SymbolMapper.toTradeSymbol(symbol, config.EXECUTION_PROVIDER); + const order = await this.exchange.getOrder(orderId, tradeSymbol); + const status = String(order?.status || '').trim().toLowerCase(); + return status || null; + } catch (error: any) { + logger.debug(`[Executor] Exchange order status check failed for ${orderId}/${symbol}: ${error.message}`); + return null; + } + } + + private async instrumentExchangeCall(operation: string, fn: () => Promise): Promise { + const start = Date.now(); + try { + return await fn(); + } catch (error: any) { + const errorMsg = error.message || String(error); + if (operation !== 'fetch_ohlcv' && operation !== 'get_position' && operation !== 'fetch_open_orders') { + observabilityService.emitEvent({ + type: 'SYSTEM_ERROR', + severity: 'WARN', + message: `Exchange API ${operation} failed: ${errorMsg}` + }); + } + throw error; + } finally { + observabilityService.observeExchangeLatency(operation, Date.now() - start); + } + } +} diff --git a/backend/src/services/aiClient.ts b/backend/src/services/aiClient.ts new file mode 100644 index 0000000..c1b7d43 --- /dev/null +++ b/backend/src/services/aiClient.ts @@ -0,0 +1,237 @@ +import axios from 'axios'; +import { config } from '../config/index.js'; +import logger from '../utils/logger.js'; + +export type AIProvider = 'openai' | 'perplexity' | 'gemini'; + +export interface AIProviderHealth { + provider: AIProvider; + configured: boolean; + model: string; + inFallbackList: boolean; + fallbackIndex: number | null; + status: 'configured' | 'missing_key' | 'ok' | 'error'; + message: string; +} + +export class AIClient { + public async generateAnalysis(prompt: string): Promise { + const fallbackList = config.AI.FALLBACK_LIST; + + for (const provider of fallbackList) { + try { + logger.info(`[AI] Attempting analysis with provider: ${provider}...`); + let result: string | null = null; + + switch (provider) { + case 'openai': + result = await this.callOpenAI(prompt); + break; + case 'perplexity': + result = await this.callPerplexity(prompt); + break; + case 'gemini': + result = await this.callGemini(prompt); + break; + default: + logger.warn(`[AI] Unsupported provider in fallback list: ${provider}`); + continue; + } + + if (result) { + logger.info(`[AI] Successfully generated analysis using ${provider}.`); + return result; + } + } catch (error: any) { + logger.error(`[AI] Provider ${provider} failed: ${error.message}`); + // Continue to next provider in fallback list + } + } + + logger.error('[AI] All providers in fallback list failed or were not configured.'); + return null; + } + + public async getProviderHealth(probe: boolean = false): Promise { + const fallbackList = config.AI.FALLBACK_LIST; + const providers: AIProvider[] = ['openai', 'perplexity', 'gemini']; + + const results: AIProviderHealth[] = []; + for (const provider of providers) { + const configured = this.isProviderConfigured(provider); + const model = this.resolveModel(provider); + const fallbackIndex = fallbackList.indexOf(provider); + const inFallbackList = fallbackIndex >= 0; + + if (!configured) { + results.push({ + provider, + configured: false, + model, + inFallbackList, + fallbackIndex: inFallbackList ? fallbackIndex : null, + status: 'missing_key', + message: 'API key missing' + }); + continue; + } + + if (!probe) { + results.push({ + provider, + configured: true, + model, + inFallbackList, + fallbackIndex: inFallbackList ? fallbackIndex : null, + status: 'configured', + message: 'Configured (probe skipped)' + }); + continue; + } + + try { + await this.probeProvider(provider); + results.push({ + provider, + configured: true, + model, + inFallbackList, + fallbackIndex: inFallbackList ? fallbackIndex : null, + status: 'ok', + message: 'Provider probe succeeded' + }); + } catch (error: any) { + results.push({ + provider, + configured: true, + model, + inFallbackList, + fallbackIndex: inFallbackList ? fallbackIndex : null, + status: 'error', + message: error?.message || 'Provider probe failed' + }); + } + } + + return results; + } + + private isProviderConfigured(provider: AIProvider): boolean { + switch (provider) { + case 'openai': + return !!config.AI.OPENAI_API_KEY; + case 'perplexity': + return !!config.AI.PERPLEXITY_API_KEY; + case 'gemini': + return !!config.AI.GEMINI_API_KEY; + default: + return false; + } + } + + private resolveModel(provider: AIProvider): string { + switch (provider) { + case 'openai': + return config.AI.MODEL.includes('gpt') ? config.AI.MODEL : 'gpt-4o-mini'; + case 'perplexity': + return config.AI.MODEL.includes('sonar') ? config.AI.MODEL : 'sonar'; + case 'gemini': + return config.AI.MODEL.includes('gemini') ? config.AI.MODEL : 'gemini-1.5-flash'; + default: + return config.AI.MODEL; + } + } + + private async probeProvider(provider: AIProvider): Promise { + const probePrompt = 'Return JSON: {"action":"HOLD","confidence":0,"reasoning":"probe"}'; + let response: string | null = null; + + switch (provider) { + case 'openai': + response = await this.callOpenAI(probePrompt); + break; + case 'perplexity': + response = await this.callPerplexity(probePrompt); + break; + case 'gemini': + response = await this.callGemini(probePrompt); + break; + default: + throw new Error(`Unsupported provider: ${provider}`); + } + + if (!response || !String(response).trim()) { + throw new Error('Empty response from provider'); + } + } + + private async callOpenAI(prompt: string): Promise { + const apiKey = config.AI.OPENAI_API_KEY; + if (!apiKey) return null; + + const model = config.AI.MODEL.includes('gpt') ? config.AI.MODEL : 'gpt-4o-mini'; + + const response = await axios.post( + 'https://api.openai.com/v1/chat/completions', + { + model: model, + messages: [ + { role: 'system', content: 'You are an expert crypto trading assistant. strictly output JSON.' }, + { role: 'user', content: prompt } + ], + temperature: 0.2 + }, + { + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + }, + timeout: 10000 + } + ); + return response.data.choices[0].message.content; + } + + private async callPerplexity(prompt: string): Promise { + const apiKey = config.AI.PERPLEXITY_API_KEY; + if (!apiKey) return null; + + const model = config.AI.MODEL.includes('sonar') ? config.AI.MODEL : 'sonar'; + + const response = await axios.post( + 'https://api.perplexity.ai/chat/completions', + { + model: model, + messages: [ + { role: 'system', content: 'You are an expert crypto trading assistant. Strictly output JSON.' }, + { role: 'user', content: prompt } + ], + temperature: 0.2 + }, + { + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + }, + timeout: 10000 + } + ); + return response.data.choices[0].message.content; + } + + private async callGemini(prompt: string): Promise { + const apiKey = config.AI.GEMINI_API_KEY; + if (!apiKey) return null; + + const model = config.AI.MODEL.includes('gemini') ? config.AI.MODEL : 'gemini-1.5-flash'; + const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`; + + const response = await axios.post(url, { + contents: [{ + parts: [{ text: `You are an expert crypto trading assistant. Strictly output JSON.\n\n${prompt}` }] + }] + }, { timeout: 10000 }); + + return response.data.candidates[0].content.parts[0].text; + } +} diff --git a/backend/src/services/apiServer.ts b/backend/src/services/apiServer.ts new file mode 100644 index 0000000..d9e295a --- /dev/null +++ b/backend/src/services/apiServer.ts @@ -0,0 +1,2488 @@ +import express, { NextFunction, Request, Response } from 'express'; +import { createServer } from 'http'; +import { Server, Socket } from 'socket.io'; +import cors from 'cors'; +import logger from '../utils/logger.js'; +import fs from 'fs'; +import path from 'path'; +import { ManualTrader } from './ManualTrader.js'; +import { config } from '../config/index.js'; +import { AIClient } from './aiClient.js'; +import { supabaseService } from './SupabaseService.js'; +import { healthTracker, HealthSnapshot, TradingControlSnapshot } from './healthTracker.js'; +import { observabilityService } from './observabilityService.js'; +import { mergeOrderSnapshots, mergePositionSnapshots } from './stateMerge.js'; +import { OperationalEvent } from '../domain/operationalEvents.js'; +import { runBacktest } from '../backtest/index.js'; +import type { BacktestRequest, BacktestTimeframe } from '../backtest/types.js'; +import { + canonicalLifecycleService, + type CanonicalLifecycleProfileMeta +} from './canonicalLifecycleService.js'; + +interface AuthenticatedRequest extends Request { + authUserId?: string; +} + +interface RateLimitBucket { + count: number; + windowStart: number; +} + +interface RuntimeHealth { + tradingLoopRunning: boolean; + tradingLoopLastStartedAt: number | null; + tradingLoopLastCompletedAt: number | null; + tradingLoopLastDurationMs: number; + reconciliationRunning: boolean; + reconciliationLastRunAt: number | null; + reconciliationLastDurationMs: number; + staleOrderBacklog: number; + parityMismatchCount: number; + exchangeConnectivity: 'unknown' | 'healthy' | 'degraded'; + reconciliationMismatchCount?: number; + reconciliationMissingFromExchange?: number; + reconciliationMissingInDb?: number; + reconciliationNoGoTrades?: number; + reconciliationParityMismatchTrades?: number; + reconciliationParityQuarantinedTrades?: number; + reconciliationParityAutoClosedTrades?: number; + reconciliationParityMaxMismatchNotionalUsd?: number; + reconciliationParityTotalMismatchNotionalUsd?: number; + reconciliationIntegrityWatchdogTriggered?: boolean; + reconciliationFailedProfiles?: number; + canonicalLifecycleTruncated?: boolean; + canonicalLifecycleOrderRows?: number; +} + +interface TradeAuditEvent { + event: string; + userId?: string; + profileId?: string; + symbol?: string; + outcome?: 'accepted' | 'rejected' | 'error'; + reason?: string; + details?: Record; +} + +interface ChatProfileRuleConfig { + ruleId: string; + enabled: boolean; + params: Record; +} + +interface ChatProfilePayload { + id?: string; + name: string; + allocated_capital: number; + risk_per_trade_percent: number; + symbols: string; + is_active: boolean; + strategy_config: { + rules: ChatProfileRuleConfig[]; + riskLimits: { + maxDailyLossUsd: number; + maxOpenTrades: number; + maxConsecutiveLosses: number; + }; + execution: { + orderType: 'market' | 'limit'; + cooldownMinutes: number; + entryMode: 'both' | 'long_only'; + }; + }; +} + +interface ChatResponsePayload { + action: 'create_profile' | 'update_profile' | 'explain'; + profile?: ChatProfilePayload; + summary: string; + reasoning: string; + fallback?: 'local_deterministic'; +} + +interface AccountSnapshot { + profileId?: string; + userId?: string; + buying_power: number; + cash: number; + currency: string; + timestamp: number; +} + +interface OrderFailureRecord { + profileId?: string; + userId?: string; + symbol: string; + side: 'BUY' | 'SELL'; + qty: number; + reason: string; + tradeId?: string; + subTag?: string; + timestamp: number; +} + +export interface BotState { + symbols: { + [symbol: string]: { + price: number; + change24h: number; + changeToday: number; + session: string; + volatility: string; + signal: string; + signalTime?: number; + tradingMode?: 'Paper' | 'Live' | 'Alerts'; + activePosition?: { + side: 'BUY' | 'SELL'; + entryPrice: number; + size: number; + stopLoss: number; + takeProfit: number; + unrealizedPnl?: number; + unrealizedPnlPercent?: number; + marketValue?: number; + userId?: string; + profileId?: string; + profileName?: string; + tradeId?: string; + } | null; + priceHistory: Array<{ timestamp: number; price: number }>; + rules: { + [ruleName: string]: { + passed: boolean; + reason: string; + metadata?: any; + }; + }; + profileSignals?: { + [profileId: string]: { + profileName?: string; + signal: string; + passed: boolean; + reason?: string; + execution?: { + status: 'EXECUTED' | 'BLOCKED' | 'SKIPPED'; + code: string; + reason: string; + orderId?: string; + }; + rules?: { + [ruleName: string]: { + passed: boolean; + reason: string; + metadata?: any; + }; + }; + }; + }; + indicators: { + ema20_1h?: number; + ema20_15m?: number; + ema50_4h?: number; + ema200_4h?: number; + rsi_1h?: number; + rsi_15m?: number; + }; + }; + }; + alerts: Array<{ + timestamp: number; + type: 'signal' | 'pulse' | 'error' | 'info'; + symbol: string; + message: string; + userId?: string; + profileId?: string; + }>; + positions: Array<{ + id: string; + symbol: string; + side: 'BUY' | 'SELL'; + size: number; + entryPrice: number; + currentPrice: number; + stopLoss: number; + takeProfit: number; + unrealizedPnl: number; + unrealizedPnlPercent: number; + marketValue: number; + userId?: string; + profileId?: string; + profileName?: string; + tradeId?: string; + }>; + orders: Array<{ + id: string; + symbol: string; + type: string; + side: string; + qty: number; + price: number; + status: string; + timestamp: number; + userId?: string; + profileId?: string; + trade_id?: string; + subTag?: string; + action?: string; + source?: 'BOT' | 'MANUAL'; + }>; + history: Array<{ + symbol: string; + side: string; + entryPrice: number; + exitPrice: number; + size: number; + pnl: number; + pnlPercent: number; + reason: string; + timestamp: number; + userId?: string; + profileId?: string; + trade_id?: string; + source?: 'BOT' | 'MANUAL'; + }>; + settings: { + executionMode: string; + riskPerTrade: number; + totalCapital: number; + maxOpenTrades: number; + isAlgoEnabled: boolean; + enabledRules: string[]; + }; + health: HealthSnapshot; + uptime: number; + accountSnapshot?: AccountSnapshot | null; + orderFailures: OrderFailureRecord[]; + operationalEvents: OperationalEvent[]; +} + + + +export class ApiServer { + private app = express(); + private httpServer = createServer(this.app); + private io = new Server(this.httpServer, { + cors: { + origin: (origin, callback) => { + if (this.isCorsOriginAllowed(origin)) { + callback(null, true); + return; + } + callback(new Error(`CORS blocked for origin: ${origin || 'unknown'}`)); + }, + credentials: true + } + }); + + private state: BotState = { + symbols: {}, + positions: [], + alerts: [], + orders: [], + history: [], + settings: { + executionMode: 'Alerts', + riskPerTrade: 0.01, + totalCapital: 1000, + maxOpenTrades: 3, + isAlgoEnabled: false, + enabledRules: [] + }, + health: healthTracker.getSnapshot(), + uptime: 0, + accountSnapshot: null, + orderFailures: [], + operationalEvents: [] + }; + + private accountSnapshotCache: AccountSnapshot[] = []; + + private startTime: number = Date.now(); + private storagePath = path.resolve(process.cwd(), 'bot_state.json'); + private executionManagers: Map = new Map(); // profileId -> ManualTrader + private profileOwners: Map = new Map(); // profileId -> userId + private socketsByUser: Map> = new Map(); // userId -> socket ids + private aiClient = new AIClient(); + private profilePositionsList = new Map(); + private profileOrdersList = new Map(); + private snapshotOwnerId: string | null = null; + private snapshotWriteTimer: NodeJS.Timeout | null = null; + private isSnapshotWriteInFlight = false; + private snapshotWriteQueued = false; + private lastSnapshotWriteAt = 0; + private rateLimitBuckets: Map = new Map(); + + private readonly routeRateLimits = { + trade: { limit: 6, windowMs: 60_000 }, + close: { limit: 10, windowMs: 60_000 }, + chat: { limit: 20, windowMs: 60_000 }, + backtest: { limit: 4, windowMs: 60_000 } + } as const; + private stateWriteTimer: NodeJS.Timeout | null = null; + private isStateWriteInFlight = false; + private stateWriteQueued = false; + private lastHealthBroadcastAt = 0; + private readonly healthBroadcastMinIntervalMs = 1000; + private canonicalTruncationAlertByScope = new Map(); + private runtimeHealth: RuntimeHealth = { + tradingLoopRunning: false, + tradingLoopLastStartedAt: null, + tradingLoopLastCompletedAt: null, + tradingLoopLastDurationMs: 0, + reconciliationRunning: false, + reconciliationLastRunAt: null, + reconciliationLastDurationMs: 0, + staleOrderBacklog: 0, + parityMismatchCount: 0, + exchangeConnectivity: 'unknown', + canonicalLifecycleTruncated: false, + canonicalLifecycleOrderRows: 0 + }; + + constructor(private port: number = 5000) { + this.loadState(); + void this.restoreStateFromLatestSnapshot(); + this.setupMiddleware(); + this.setupRoutes(); + this.setupSocketHandlers(); + this.subscribeToOperationalEvents(); + this.startServer(); + } + + private subscribeToOperationalEvents() { + observabilityService.subscribe((event) => { + this.state.operationalEvents = observabilityService.getEvents(); + this.broadcastOperationalEvent(event); + // Keep operational events durable across restarts via local + DB snapshots. + this.saveState(); + this.scheduleSnapshotWrite(); + }); + } + + private broadcastOperationalEvent(event: OperationalEvent) { + this.emitToConnectedUsers('operational_event', (userId, socket) => { + if (socket.data.isAdmin) return event; + return null; + }); + } + + public registerManualTrader(profileId: string, manager: ManualTrader) { + this.executionManagers.set(profileId, manager); + const owner = String(manager.getUserId() || '').trim(); + if (owner) { + this.profileOwners.set(profileId, owner); + } + logger.info(`[API] Registered Manual Trader for profile: ${profileId}`); + } + + public unregisterManualTrader(profileId: string) { + if (!profileId) return; + + this.executionManagers.delete(profileId); + this.profileOwners.delete(profileId); + this.profilePositionsList.delete(profileId); + this.profileOrdersList.delete(profileId); + + this.state.positions = mergePositionSnapshots(Array.from(this.profilePositionsList.values())); + this.state.orders = mergeOrderSnapshots(Array.from(this.profileOrdersList.values())); + this.broadcastPositionsUpdate(); + this.broadcastOrdersUpdate(); + this.saveState(); + logger.info(`[API] Unregistered Manual Trader for profile: ${profileId}`); + } + + private getUserRoom(userId: string): string { + return `user:${userId}`; + } + + private trackSocket(userId: string, socket: Socket): void { + const normalizedUserId = String(userId || '').trim(); + if (!normalizedUserId) return; + const room = this.getUserRoom(normalizedUserId); + socket.join(room); + const existing = this.socketsByUser.get(normalizedUserId) || new Set(); + existing.add(socket.id); + this.socketsByUser.set(normalizedUserId, existing); + } + + private untrackSocket(userId: string, socketId: string): void { + const normalizedUserId = String(userId || '').trim(); + if (!normalizedUserId) return; + const existing = this.socketsByUser.get(normalizedUserId); + if (!existing) return; + existing.delete(socketId); + if (existing.size === 0) { + this.socketsByUser.delete(normalizedUserId); + return; + } + this.socketsByUser.set(normalizedUserId, existing); + } + + private resolveProfileOwner(profileId?: string, fallbackUserId?: string): string | null { + const fallback = String(fallbackUserId || '').trim(); + if (fallback) return fallback; + + const normalizedProfileId = String(profileId || '').trim(); + if (!normalizedProfileId) return null; + + const mappedOwner = this.profileOwners.get(normalizedProfileId); + if (mappedOwner) return mappedOwner; + + const manager = this.executionManagers.get(normalizedProfileId); + if (manager) { + const managerOwner = String(manager.getUserId() || '').trim(); + if (managerOwner) { + this.profileOwners.set(normalizedProfileId, managerOwner); + return managerOwner; + } + } + + if (normalizedProfileId.startsWith('default-')) { + return normalizedProfileId.slice('default-'.length); + } + + if (normalizedProfileId === 'global') { + return 'global'; + } + + return null; + } + + private isOwnedByUser(userId: string, recordUserId?: string, profileId?: string): boolean { + const normalizedUserId = String(userId || '').trim(); + if (!normalizedUserId) return false; + const directOwner = String(recordUserId || '').trim(); + if (directOwner) return directOwner === normalizedUserId; + const profileOwner = this.resolveProfileOwner(profileId); + if (!profileOwner) return false; + return profileOwner === normalizedUserId; + } + + private getScopedSymbolState(symbolState: BotState['symbols'][string], userId: string): BotState['symbols'][string] { + const profileSignals = Object.entries(symbolState.profileSignals || {}).reduce>((acc, [profileId, signal]) => { + if (this.isOwnedByUser(userId, undefined, profileId)) { + acc[profileId] = signal; + } + return acc; + }, {}); + + const activePosition = symbolState.activePosition + ? (() => { + const candidate = symbolState.activePosition as any; + if (!this.isOwnedByUser(userId, candidate.userId, candidate.profileId)) { + return null; + } + return symbolState.activePosition; + })() + : null; + + return { + ...symbolState, + activePosition, + profileSignals + }; + } + + private getScopedState(userId: string, isAdmin: boolean): BotState { + const scopedSymbols = Object.entries(this.state.symbols).reduce((acc, [symbol, symbolState]) => { + acc[symbol] = this.getScopedSymbolState(symbolState, userId); + return acc; + }, {}); + + const scopedPositions = this.state.positions.filter((position) => + this.isOwnedByUser(userId, position.userId, position.profileId) + ); + const scopedOrders = this.state.orders.filter((order) => + this.isOwnedByUser(userId, order.userId, order.profileId) + ); + const scopedHistory = this.state.history.filter((trade) => + this.isOwnedByUser(userId, trade.userId, trade.profileId) + ); + const scopedAlerts = this.state.alerts.filter((alert) => { + const directUser = String(alert.userId || '').trim(); + if (directUser) return directUser === userId; + const profileOwner = this.resolveProfileOwner(alert.profileId); + if (profileOwner) return profileOwner === userId; + return true; + }); + const scopedOrderFailures = this.state.orderFailures.filter((failure) => + this.isOwnedByUser(userId, failure.userId, failure.profileId) + ); + const latestAssociatedSnapshot = [...this.accountSnapshotCache] + .reverse() + .find((snapshot) => this.isOwnedByUser(userId, snapshot.userId, snapshot.profileId)); + const fallbackSnapshot = this.state.accountSnapshot; + const scopedAccountSnapshot = + latestAssociatedSnapshot + || (fallbackSnapshot && (!fallbackSnapshot.profileId || this.isOwnedByUser(userId, fallbackSnapshot.userId, fallbackSnapshot.profileId)) ? fallbackSnapshot : null); + + return { + ...this.state, + symbols: scopedSymbols, + positions: scopedPositions, + orders: scopedOrders, + history: scopedHistory, + alerts: scopedAlerts, + health: healthTracker.getSnapshot(), + accountSnapshot: scopedAccountSnapshot, + orderFailures: scopedOrderFailures, + operationalEvents: isAdmin ? this.state.operationalEvents : [] + }; + } + + + private emitToUser(userId: string, event: string, payload: unknown): void { + const normalizedUserId = String(userId || '').trim(); + if (!normalizedUserId) return; + this.io.to(this.getUserRoom(normalizedUserId)).emit(event, payload); + } + + private emitToConnectedUsers(event: string, payloadBuilder: (userId: string, socket: Socket) => T): void { + for (const [socketId, socket] of this.io.sockets.sockets) { + const userId = socket.data.userId; + if (!userId) continue; + const payload = payloadBuilder(userId, socket); + socket.emit(event, payload); + } + } + + private broadcastPositionsUpdate(): void { + this.emitToConnectedUsers('positions_update', (userId) => + this.state.positions.filter((position) => this.isOwnedByUser(userId, position.userId, position.profileId)) + ); + } + + private broadcastOrdersUpdate(): void { + this.emitToConnectedUsers('orders_update', (userId) => + this.state.orders.filter((order) => this.isOwnedByUser(userId, order.userId, order.profileId)) + ); + } + + private broadcastHistoryUpdate(trade: BotState['history'][0]): void { + const owner = this.resolveProfileOwner(trade.profileId, trade.userId); + if (!owner) return; + this.emitToUser(owner, 'history_update', trade); + } + + private broadcastSymbolUpdate(symbol: string): void { + const symbolState = this.state.symbols[symbol]; + if (!symbolState) return; + this.emitToConnectedUsers('symbol_update', (userId) => ({ + symbol, + data: this.getScopedSymbolState(symbolState, userId) + })); + } + + private broadcastSettingsUpdate(): void { + this.emitToConnectedUsers('settings_update', () => this.state.settings); + } + + private broadcastHealthUpdate(): void { + this.publishHealthSnapshot({ broadcast: true, force: true }); + } + + + private isCorsOriginAllowed(origin?: string): boolean { + if (!origin) return true; + return config.ALLOWED_ORIGINS.includes(origin); + } + + private extractBearerToken(authorizationHeader?: string | string[]): string | null { + if (!authorizationHeader || typeof authorizationHeader !== 'string') return null; + const [scheme, token] = authorizationHeader.split(' '); + if (scheme?.toLowerCase() !== 'bearer' || !token) return null; + return token.trim(); + } + + private requireAuth = async (req: Request, res: Response, next: NextFunction): Promise => { + const token = this.extractBearerToken(req.headers.authorization); + if (!token) { + res.status(401).json({ error: 'Unauthorized: missing bearer token' }); + return; + } + + const { userId, error } = await supabaseService.verifyAccessToken(token); + if (!userId) { + res.status(401).json({ error: `Unauthorized: ${error || 'invalid token'}` }); + return; + } + + (req as AuthenticatedRequest).authUserId = userId; + next(); + }; + + private requireAdmin = async (req: Request, res: Response, next: NextFunction): Promise => { + const userId = (req as AuthenticatedRequest).authUserId; + if (!userId) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + try { + const isAdmin = await supabaseService.isAdmin(userId); + if (!isAdmin) { + res.status(403).json({ error: 'Forbidden: Admin role required' }); + return; + } + + next(); + } catch (err: any) { + res.status(500).json({ error: `Internal server error: ${err.message}` }); + } + }; + + + private checkRateLimit(userId: string, route: keyof ApiServer['routeRateLimits']): { allowed: boolean; retryAfterMs: number } { + const { limit, windowMs } = this.routeRateLimits[route]; + const bucketKey = `${route}:${userId}`; + const now = Date.now(); + const existing = this.rateLimitBuckets.get(bucketKey); + + if (!existing || (now - existing.windowStart) >= windowMs) { + this.rateLimitBuckets.set(bucketKey, { count: 1, windowStart: now }); + return { allowed: true, retryAfterMs: 0 }; + } + + if (existing.count >= limit) { + return { allowed: false, retryAfterMs: Math.max(0, windowMs - (now - existing.windowStart)) }; + } + + existing.count += 1; + this.rateLimitBuckets.set(bucketKey, existing); + return { allowed: true, retryAfterMs: 0 }; + } + + private enforceRateLimit(req: AuthenticatedRequest, res: Response, route: keyof ApiServer['routeRateLimits']): boolean { + const userId = req.authUserId; + if (!userId) { + res.status(401).json({ success: false, error: 'Unauthorized' }); + return false; + } + + const { allowed, retryAfterMs } = this.checkRateLimit(userId, route); + if (!allowed) { + res.status(429).json({ + success: false, + error: 'Rate limit exceeded', + retryAfterMs + }); + return false; + } + + return true; + } + + private getPersistableState() { + return { + symbols: this.state.symbols, + positions: this.state.positions, + alerts: this.state.alerts, + orders: this.state.orders, + history: this.state.history, + settings: this.state.settings, + health: { + tradingControl: this.state.health.tradingControl + }, + operationalEvents: this.state.operationalEvents + }; + } + + + private getHealthStatus(now: number = Date.now()): 'healthy' | 'degraded' { + const lastLoopCompletedAt = this.runtimeHealth.tradingLoopLastCompletedAt; + const maxLoopGapMs = Math.max(config.POLLING_INTERVAL * 2, 120_000); + if (!lastLoopCompletedAt) return 'degraded'; + if ((now - lastLoopCompletedAt) > maxLoopGapMs) return 'degraded'; + if (this.runtimeHealth.exchangeConnectivity === 'degraded') return 'degraded'; + return 'healthy'; + } + + private async flushStateToDisk(): Promise { + if (this.isStateWriteInFlight) { + this.stateWriteQueued = true; + return; + } + + this.isStateWriteInFlight = true; + const stateToSave = this.getPersistableState(); + const serializedState = JSON.stringify(stateToSave, null, 2); + const tmpPath = `${this.storagePath}.tmp`; + const backupPath = `${this.storagePath}.bak`; + try { + await fs.promises.writeFile(tmpPath, serializedState, 'utf8'); + // Validate serialized snapshot before promoting the file. + JSON.parse(await fs.promises.readFile(tmpPath, 'utf8')); + + if (fs.existsSync(this.storagePath)) { + await fs.promises.copyFile(this.storagePath, backupPath); + } + + try { + await fs.promises.rename(tmpPath, this.storagePath); + } catch { + // Windows fallback when rename replacement is blocked. + await fs.promises.copyFile(tmpPath, this.storagePath); + await fs.promises.unlink(tmpPath).catch(() => undefined); + } + } catch (error) { + logger.error('[API] Failed to save state:', error); + } finally { + this.isStateWriteInFlight = false; + if (this.stateWriteQueued) { + this.stateWriteQueued = false; + void this.flushStateToDisk(); + } + } + } + + private scheduleStateWrite(): void { + if (this.stateWriteTimer) { + clearTimeout(this.stateWriteTimer); + } + + this.stateWriteTimer = setTimeout(() => { + this.stateWriteTimer = null; + void this.flushStateToDisk(); + }, 200); + } + + private auditTradeEvent(evt: TradeAuditEvent): void { + const payload = { + ts: new Date().toISOString(), + ...evt + }; + logger.info(`[AUDIT] ${JSON.stringify(payload)}`); + } + + private buildLocalChatFallback(message: string, context: any[]): ChatResponsePayload { + const lower = String(message || '').toLowerCase(); + const asksForExplain = /(what|how|why|help|explain|suggest)/i.test(lower) + && !/(create|build|make|generate|new profile|strategy|setup|configure|update|modify)/i.test(lower); + if (asksForExplain) { + return { + action: 'explain', + summary: 'AI provider is currently unavailable. A local fallback can still generate deterministic profile configurations.', + reasoning: 'Use prompts that include risk appetite, symbols, capital, and whether you want long-only or both sides.' + }; + } + + const symbols = this.extractSymbolsFromMessage(message); + const entryMode: 'both' | 'long_only' = /(long[\s_-]*only|buy[\s_-]*only)/i.test(lower) ? 'long_only' : 'both'; + const orderType: 'market' | 'limit' = /\blimit\b/i.test(lower) ? 'limit' : 'market'; + const cooldownMinutes = this.extractCooldownMinutes(message); + const allocatedCapital = this.extractCapital(message); + const { riskPerTradePercent, riskTier } = this.extractRiskProfile(message); + const aiRuleEnabled = /\bai\b|\bsentiment\b|\bllm\b/i.test(lower); + const sessions = this.extractSessions(message); + const profileName = this.buildFallbackProfileName(lower); + const isActive = !/\b(inactive|paused|pause|draft)\b/i.test(lower); + + const riskLimits = { + maxDailyLossUsd: riskTier === 'aggressive' + ? Math.max(100, Math.round(allocatedCapital * 0.1)) + : riskTier === 'conservative' + ? Math.max(25, Math.round(allocatedCapital * 0.03)) + : Math.max(50, Math.round(allocatedCapital * 0.05)), + maxOpenTrades: riskTier === 'aggressive' ? 5 : riskTier === 'conservative' ? 2 : 3, + maxConsecutiveLosses: riskTier === 'aggressive' ? 3 : 2 + }; + + const profile: ChatProfilePayload = { + name: profileName, + allocated_capital: allocatedCapital, + risk_per_trade_percent: riskPerTradePercent, + symbols, + is_active: isActive, + strategy_config: { + rules: [ + { ruleId: 'TrendBiasRule', enabled: true, params: { fastPeriod: 50, slowPeriod: 200 } }, + { ruleId: 'MomentumRule', enabled: true, params: { rsiPeriod: 14, overbought: 70, oversold: 30 } }, + { ruleId: 'ZoneRule', enabled: true, params: { zonePercent: 1.5 } }, + { ruleId: 'SessionRule', enabled: true, params: { sessions } }, + { ruleId: 'EntryTriggerRule', enabled: true, params: { showPatterns: true } }, + { ruleId: 'RiskManagementRule', enabled: true, params: { maxRisk: 2.0 } }, + { ruleId: 'AIAnalysisRule', enabled: aiRuleEnabled, params: { minConfidence: 70 } } + ], + riskLimits, + execution: { + orderType, + cooldownMinutes, + entryMode + } + } + }; + + const updateTarget = this.detectProfileToUpdate(message, context); + if (updateTarget) { + const existingConfig = updateTarget.strategy_config || {}; + const existingExecution = existingConfig.execution || {}; + const existingRiskLimits = existingConfig.riskLimits || {}; + const existingRules = Array.isArray(existingConfig.rules) ? existingConfig.rules : profile.strategy_config.rules; + + const mergedProfile: ChatProfilePayload = { + ...updateTarget, + id: updateTarget.id, + name: updateTarget.name || profile.name, + allocated_capital: allocatedCapital || Number(updateTarget.allocated_capital || profile.allocated_capital), + risk_per_trade_percent: riskPerTradePercent || Number(updateTarget.risk_per_trade_percent || profile.risk_per_trade_percent), + symbols: symbols || updateTarget.symbols || profile.symbols, + is_active: typeof updateTarget.is_active === 'boolean' ? updateTarget.is_active : isActive, + strategy_config: { + ...existingConfig, + rules: existingRules, + riskLimits: { + maxDailyLossUsd: Number(existingRiskLimits.maxDailyLossUsd || riskLimits.maxDailyLossUsd), + maxOpenTrades: Number(existingRiskLimits.maxOpenTrades || riskLimits.maxOpenTrades), + maxConsecutiveLosses: Number(existingRiskLimits.maxConsecutiveLosses ?? riskLimits.maxConsecutiveLosses) + }, + execution: { + orderType: existingExecution.orderType === 'limit' ? 'limit' : orderType, + cooldownMinutes: Number(existingExecution.cooldownMinutes ?? cooldownMinutes), + entryMode: existingExecution.entryMode === 'long_only' ? 'long_only' : entryMode + } + } + }; + + return { + action: 'update_profile', + profile: mergedProfile, + summary: `AI was unavailable. Built a deterministic fallback update for profile "${mergedProfile.name}".`, + reasoning: `Applied local heuristics from your prompt (symbols=${symbols}, risk=${riskPerTradePercent}%, mode=${entryMode}, orderType=${orderType}).`, + fallback: 'local_deterministic' + }; + } + + return { + action: 'create_profile', + profile, + summary: `AI was unavailable. Built a deterministic fallback profile "${profileName}".`, + reasoning: `Applied local heuristics from your prompt (symbols=${symbols}, risk=${riskPerTradePercent}%, mode=${entryMode}, orderType=${orderType}, cooldown=${cooldownMinutes}m).`, + fallback: 'local_deterministic' + }; + } + + private extractCapital(message: string): number { + const normalized = String(message || ''); + const directPattern = /\$\s*([0-9][0-9,]*(?:\.[0-9]+)?)\s*([kKmM]?)/; + const keywordPattern = /\b(capital|budget|allocate|allocated)\b[^0-9$]*\$?\s*([0-9][0-9,]*(?:\.[0-9]+)?)\s*([kKmM]?)/i; + let numericPart = ''; + let suffix = ''; + + const keywordMatch = normalized.match(keywordPattern); + if (keywordMatch) { + numericPart = keywordMatch[2]; + suffix = keywordMatch[3] || ''; + } else { + const directMatch = normalized.match(directPattern); + if (directMatch) { + numericPart = directMatch[1]; + suffix = directMatch[2] || ''; + } + } + + const base = Number(String(numericPart || '1000').replace(/,/g, '')); + if (!Number.isFinite(base) || base <= 0) return 1000; + const normalizedSuffix = String(suffix || '').toLowerCase(); + if (normalizedSuffix === 'k') return Math.round(base * 1000); + if (normalizedSuffix === 'm') return Math.round(base * 1000000); + return Math.round(base); + } + + private extractCooldownMinutes(message: string): number { + const normalized = String(message || ''); + const explicit = normalized.match(/([0-9]{1,3})\s*(?:min|mins|minute|minutes|m)\s*(?:cooldown|delay|wait)?/i); + if (explicit) { + const parsed = Number(explicit[1]); + if (Number.isFinite(parsed) && parsed >= 1) return Math.min(240, Math.max(1, parsed)); + } + if (/\bscalp|scalper\b/i.test(normalized)) return 10; + if (/\bswing\b/i.test(normalized)) return 45; + return 30; + } + + private extractRiskProfile(message: string): { riskPerTradePercent: number; riskTier: 'conservative' | 'balanced' | 'aggressive' } { + const normalized = String(message || '').toLowerCase(); + const explicit = normalized.match(/([0-9]+(?:\.[0-9]+)?)\s*%\s*(?:risk|risk\s*per\s*trade)/i); + if (explicit) { + const parsed = Number(explicit[1]); + if (Number.isFinite(parsed) && parsed > 0) { + const bounded = Math.min(10, Math.max(0.1, parsed)); + const riskTier = bounded <= 1 ? 'conservative' : bounded >= 2.5 ? 'aggressive' : 'balanced'; + return { riskPerTradePercent: Number(bounded.toFixed(2)), riskTier }; + } + } + + if (/\b(conservative|low[\s-]*risk|safe)\b/i.test(normalized)) { + return { riskPerTradePercent: 0.8, riskTier: 'conservative' }; + } + if (/\b(aggressive|high[\s-]*risk|scalp|scalper)\b/i.test(normalized)) { + return { riskPerTradePercent: 2.5, riskTier: 'aggressive' }; + } + return { riskPerTradePercent: 1.2, riskTier: 'balanced' }; + } + + private extractSymbolsFromMessage(message: string): string { + const upper = String(message || '').toUpperCase(); + const symbols = new Set(); + const explicitPairs = upper.match(/\b[A-Z]{2,10}\/[A-Z]{2,10}\b/g) || []; + explicitPairs.forEach((pair) => symbols.add(pair)); + + const knownAssets = upper.match(/\b(BTC|ETH|SOL|DOGE|XRP|ADA|BNB|AVAX|MATIC|LTC|LINK|DOT|TRX|SHIB)\b/g) || []; + for (const asset of knownAssets) { + symbols.add(`${asset}/USDT`); + } + + if (symbols.size === 0) { + return 'BTC/USDT, ETH/USDT'; + } + return Array.from(symbols).slice(0, 6).join(', '); + } + + private extractSessions(message: string): string { + const lower = String(message || '').toLowerCase(); + const sessions: string[] = []; + if (/\blondon\b|\bldn\b/.test(lower)) sessions.push('London'); + if (/\bnew york\b|\bny\b/.test(lower)) sessions.push('NY'); + if (/\btokyo\b|\btok\b/.test(lower)) sessions.push('Tokyo'); + if (/\bsydney\b|\bsyd\b/.test(lower)) sessions.push('Sydney'); + return sessions.length > 0 ? sessions.join(',') : 'London,NY'; + } + + private buildFallbackProfileName(messageLower: string): string { + if (/\bconservative\b/.test(messageLower)) return 'Conservative Fallback Strategy'; + if (/\baggressive|scalp|scalper\b/.test(messageLower)) return 'Aggressive Fallback Strategy'; + if (/\bswing\b/.test(messageLower)) return 'Swing Fallback Strategy'; + return 'AI Fallback Strategy'; + } + + private detectProfileToUpdate(message: string, context: any[]): any | null { + const lower = String(message || '').toLowerCase(); + if (!/\b(update|modify|change|edit|tweak)\b/.test(lower)) return null; + if (!Array.isArray(context) || context.length === 0) return null; + + for (const profile of context) { + const name = String(profile?.name || '').toLowerCase().trim(); + if (!name) continue; + if (lower.includes(name)) return profile; + } + return null; + } + + private normalizeBacktestTimeframe(value: unknown): BacktestTimeframe { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === '1m' || normalized === '1min') return '1m'; + if (normalized === '15m' || normalized === '15min') return '15m'; + if (normalized === '1h' || normalized === '60m') return '1h'; + if (normalized === '4h' || normalized === '240m') return '4h'; + throw new Error(`Invalid timeframe "${String(value || '')}". Use 1m, 15m, 1h, or 4h.`); + } + + private parseSymbolList(input: unknown): string[] { + if (Array.isArray(input)) { + return input + .map((value) => String(value || '').trim().toUpperCase()) + .filter(Boolean); + } + return String(input || '') + .split(',') + .map((value) => value.trim().toUpperCase()) + .filter(Boolean); + } + + private enforceBacktestPayloadGuards(dataSource: any): void { + if (!dataSource || typeof dataSource !== 'object') { + throw new Error('Backtest request requires dataSource.'); + } + + if (dataSource.type === 'csv') { + const payload = String(dataSource.payload || ''); + const bytes = Buffer.byteLength(payload, 'utf8'); + if (bytes > config.BACKTEST_MAX_CSV_BYTES) { + throw new Error(`CSV payload too large (${bytes} bytes > ${config.BACKTEST_MAX_CSV_BYTES}).`); + } + const rows = payload.split(/\r?\n/).filter((line: string) => line.trim().length > 0).length; + if (rows > config.BACKTEST_MAX_ROWS + 1) { + throw new Error(`CSV row count exceeds BACKTEST_MAX_ROWS (${config.BACKTEST_MAX_ROWS}).`); + } + return; + } + + if (dataSource.type === 'json' || dataSource.type === 'replay') { + const candles = dataSource?.payload?.candles; + if (Array.isArray(candles) && candles.length > config.BACKTEST_MAX_ROWS) { + throw new Error(`JSON candle count exceeds BACKTEST_MAX_ROWS (${config.BACKTEST_MAX_ROWS}).`); + } + return; + } + + if (dataSource.type === 'kraken') { + return; + } + + throw new Error(`Unsupported backtest dataSource.type "${String(dataSource.type || '')}".`); + } + + private loadState() { + const candidatePaths = [ + this.storagePath, + `${this.storagePath}.bak`, + `${this.storagePath}.tmp` + ]; + + for (const candidate of candidatePaths) { + try { + if (!fs.existsSync(candidate)) continue; + const data = fs.readFileSync(candidate, 'utf8'); + const savedState = JSON.parse(data); + if (!savedState) continue; + + this.state = { + ...this.state, + ...savedState, + settings: savedState.settings || this.state.settings, + health: { + ...this.state.health, + ...(savedState.health || {}) + } + }; + if (this.state.health.tradingControl) { + healthTracker.recordTradingControl(this.state.health.tradingControl); + } + logger.info(`[API] Restored state from ${candidate}`); + return; + } catch (error) { + logger.error(`[API] Failed to load state from ${candidate}:`, error); + } + } + } + + + private async resolveSnapshotOwnerId(): Promise { + if (this.snapshotOwnerId) return this.snapshotOwnerId; + const owner = await supabaseService.getSnapshotOwnerId(); + this.snapshotOwnerId = owner; + return owner; + } + + private async restoreStateFromLatestSnapshot(): Promise { + try { + const ownerId = await this.resolveSnapshotOwnerId(); + if (!ownerId) { + logger.warn('[API] Snapshot owner not resolved; skipping Supabase restore.'); + return; + } + + const snapshot = await supabaseService.loadLatestBotStateSnapshot(ownerId); + if (snapshot && snapshot.state) { + const restoredState = snapshot.state as Partial; + this.state = { + ...this.state, + ...restoredState, + settings: restoredState.settings || this.state.settings, + health: { + ...this.state.health, + ...(restoredState.health || {}) + } + }; + if (this.state.health.tradingControl) { + healthTracker.recordTradingControl(this.state.health.tradingControl); + } + logger.info(`[API] Restored runtime state from Supabase snapshot (user=${ownerId}).`); + } + } catch (error: any) { + logger.error('[API] Failed to restore state from Supabase snapshot:', error); + } + } + + + private scheduleSnapshotWrite(): void { + if (!config.ENABLE_DB_SNAPSHOTS) return; + if (this.snapshotWriteTimer) { + clearTimeout(this.snapshotWriteTimer); + } + // Small debounce to catch bursts, but the actual write is throttled in persistSnapshotToDb + this.snapshotWriteTimer = setTimeout(() => { + void this.persistSnapshotToDb(); + }, 5000); + } + + private async persistSnapshotToDb(): Promise { + if (!config.ENABLE_DB_SNAPSHOTS) return; + + const now = Date.now(); + const elapsed = now - this.lastSnapshotWriteAt; + if (elapsed < config.DB_SNAPSHOT_INTERVAL_MS) { + // If we are too soon, schedule another check at the next interval boundary + const remaining = config.DB_SNAPSHOT_INTERVAL_MS - elapsed; + if (this.snapshotWriteTimer) clearTimeout(this.snapshotWriteTimer); + this.snapshotWriteTimer = setTimeout(() => { + void this.persistSnapshotToDb(); + }, Math.max(remaining, 5000)); + return; + } + + if (this.isSnapshotWriteInFlight) { + this.snapshotWriteQueued = true; + return; + } + + this.isSnapshotWriteInFlight = true; + try { + const ownerId = await this.resolveSnapshotOwnerId(); + if (!ownerId) return; + await supabaseService.saveBotStateSnapshot(ownerId, this.getPersistableState()); + this.lastSnapshotWriteAt = Date.now(); + logger.info(`[API] Persisted snapshot for ${ownerId}. Interval: ${Math.round(elapsed / 1000)}s`); + } catch (error: any) { + logger.error(`[API] Snapshot persistence failed: ${error.message}`); + } finally { + this.isSnapshotWriteInFlight = false; + if (this.snapshotWriteQueued) { + this.snapshotWriteQueued = false; + this.scheduleSnapshotWrite(); + } + } + } + + private saveState() { + this.scheduleStateWrite(); + this.scheduleSnapshotWrite(); + } + + private setupMiddleware() { + this.app.use(cors({ + origin: (origin, callback) => { + if (this.isCorsOriginAllowed(origin)) { + callback(null, true); + return; + } + callback(new Error(`CORS blocked for origin: ${origin || 'unknown'}`)); + }, + credentials: true + })); + this.app.use(express.json()); + } + + private setupRoutes() { + this.app.get('/health', (req, res) => { + const now = Date.now(); + const status = this.getHealthStatus(now); + res.status(status === 'healthy' ? 200 : 503).json({ + status, + uptime: now - this.startTime, + runtime: this.runtimeHealth + }); + }); + + this.app.get('/health/live', (req, res) => { + res.status(200).json({ status: 'alive', uptime: Date.now() - this.startTime }); + }); + + this.app.get('/health/ready', (req, res) => { + const status = this.getHealthStatus(); + res.status(status === 'healthy' ? 200 : 503).json({ + status, + runtime: this.runtimeHealth + }); + }); + + this.app.get('/internal/health', (req, res) => { + const snapshot = healthTracker.getSnapshot(); + res.status(200).json({ + ...snapshot, + observability: observabilityService.getSummary(), + sloFlags: observabilityService.sloFlags() + }); + }); + + this.app.get('/metrics', async (req, res) => { + res.set('Content-Type', observabilityService.contentType()); + try { + const metrics = await observabilityService.metrics(); + res.send(metrics); + } catch (err: any) { + logger.error(`[Metrics] Failed to render Prometheus metrics: ${err.message}`); + res.status(500).json({ error: 'Metrics render failed' }); + } + }); + + this.app.get('/api/state', this.requireAuth, async (req, res) => { + const authUserId = (req as AuthenticatedRequest).authUserId; + if (!authUserId) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + this.state.uptime = Date.now() - this.startTime; + const isAdmin = await supabaseService.isAdmin(authUserId); + const scopedState = this.getScopedState(authUserId, isAdmin); + res.json({ + ...scopedState, + runtimeHealth: this.runtimeHealth + }); + }); + + this.app.get('/api/lifecycle/canonical', this.requireAuth, async (req, res) => { + const authUserId = (req as AuthenticatedRequest).authUserId; + if (!authUserId) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + try { + const isAdmin = await supabaseService.isAdmin(authUserId); + const requestedProfileId = String(req.query.profileId || '').trim(); + const splitByProfileQuery = String(req.query.splitByProfile || '').trim().toLowerCase(); + const splitByProfile = splitByProfileQuery + ? splitByProfileQuery !== 'false' + : true; + const canonicalMaxRowsCap = 200_000; + const defaultMaxRows = Math.max(1_000, Math.min(canonicalMaxRowsCap, Number(config.CANONICAL_LIFECYCLE_MAX_ROWS || 200_000))); + const maxRows = Math.max( + 1_000, + Math.min(canonicalMaxRowsCap, Number.parseInt(String(req.query.maxRows || defaultMaxRows), 10) || defaultMaxRows) + ); + + const profileRows = isAdmin + ? await supabaseService.getAllProfiles() + : await supabaseService.getProfilesForUser(authUserId); + + if (requestedProfileId && !profileRows.some((row) => row.id === requestedProfileId)) { + res.status(403).json({ success: false, error: 'Forbidden: profile does not belong to scoped user context' }); + return; + } + + const profilesInScope = requestedProfileId + ? profileRows.filter((row) => row.id === requestedProfileId) + : profileRows; + + const profileMeta: CanonicalLifecycleProfileMeta[] = profilesInScope.map((row) => ({ + id: String(row.id), + userId: String(row.user_id), + name: String(row.name || row.id), + allocatedCapital: Number(row.allocated_capital || 0), + isActive: Boolean(row.is_active) + })); + const useProfileScopedFetch = !requestedProfileId && splitByProfile && profilesInScope.length > 1; + let lifecycleOrderRows: any[] = []; + let lifecycleOrderTruncated = false; + + if (useProfileScopedFetch) { + const deduped = new Map(); + for (const profileRow of profilesInScope) { + const profileScoped = isAdmin + ? await supabaseService.getFilledLifecycleOrdersGlobal({ + profileId: String(profileRow.id), + maxRows + }) + : await supabaseService.getFilledLifecycleOrdersForUser({ + userId: authUserId, + profileId: String(profileRow.id), + maxRows + }); + + lifecycleOrderTruncated = lifecycleOrderTruncated || profileScoped.truncated; + for (const row of profileScoped.rows || []) { + const key = String( + row?.id + || row?.order_id + || `${row?.profile_id || profileRow.id}|${row?.trade_id || ''}|${row?.created_at || row?.timestamp || ''}|${row?.side || ''}|${row?.action || ''}` + ).trim(); + if (!key) continue; + if (!deduped.has(key)) { + deduped.set(key, row); + } + } + } + lifecycleOrderRows = Array.from(deduped.values()).sort((a: any, b: any) => { + const aTs = Date.parse(String(a?.created_at || a?.filled_at || a?.timestamp || 0)); + const bTs = Date.parse(String(b?.created_at || b?.filled_at || b?.timestamp || 0)); + if (Number.isFinite(aTs) && Number.isFinite(bTs) && aTs !== bTs) { + return aTs - bTs; + } + const aId = String(a?.id || a?.order_id || ''); + const bId = String(b?.id || b?.order_id || ''); + return aId.localeCompare(bId); + }); + } else { + const lifecycleOrderResult = isAdmin + ? await supabaseService.getFilledLifecycleOrdersGlobal({ + profileId: requestedProfileId || undefined, + maxRows + }) + : await supabaseService.getFilledLifecycleOrdersForUser({ + userId: authUserId, + profileId: requestedProfileId || undefined, + maxRows + }); + lifecycleOrderRows = lifecycleOrderResult.rows || []; + lifecycleOrderTruncated = Boolean(lifecycleOrderResult.truncated); + } + + const symbolPrices = Object.entries(this.state.symbols || {}).reduce>((acc, [symbol, value]) => { + const price = Number(value?.price || 0); + if (Number.isFinite(price) && price > 0) { + acc[symbol] = price; + } + return acc; + }, {}); + + const snapshot = canonicalLifecycleService.buildSnapshot({ + orders: lifecycleOrderRows, + profiles: profileMeta, + symbolPrices, + truncated: lifecycleOrderTruncated + }); + this.updateRuntimeHealth({ + canonicalLifecycleTruncated: Boolean(snapshot.diagnostics.truncated), + canonicalLifecycleOrderRows: Number(snapshot.diagnostics.orderRows || 0) + }); + + if (snapshot.diagnostics.truncated) { + const alertScope = requestedProfileId || (isAdmin ? 'global-admin' : authUserId); + const now = Date.now(); + const throttleMs = Math.max(0, Number(config.CANONICAL_LIFECYCLE_TRUNCATION_ALERT_MS || 600_000)); + const lastAlertAt = this.canonicalTruncationAlertByScope.get(alertScope) || 0; + if (throttleMs <= 0 || (now - lastAlertAt) >= throttleMs) { + this.canonicalTruncationAlertByScope.set(alertScope, now); + observabilityService.emitEvent({ + type: 'RECONCILIATION_DEGRADED', + severity: 'WARN', + message: `Canonical lifecycle response truncated at ${snapshot.diagnostics.orderRows} rows (maxRows=${maxRows}). Increase CANONICAL_LIFECYCLE_MAX_ROWS or query narrower scope.`, + profileId: requestedProfileId || undefined, + userId: isAdmin ? undefined : authUserId + }); + } + } + + res.json({ + success: true, + scope: { + isAdmin, + profileId: requestedProfileId || null, + profileCount: profileMeta.length, + splitByProfile: useProfileScopedFetch + }, + snapshot + }); + } catch (error: any) { + res.status(500).json({ success: false, error: error.message }); + } + }); + + this.app.get('/api/alerts', this.requireAuth, async (req, res) => { + const authUserId = (req as AuthenticatedRequest).authUserId; + if (!authUserId) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + const limit = parseInt(req.query.limit as string) || 50; + const isAdmin = await supabaseService.isAdmin(authUserId); + const scopedState = this.getScopedState(authUserId, isAdmin); + const alerts = scopedState.alerts; + res.json(alerts.slice(-limit)); + }); + + // --- SAFE ADMIN TRADE CONTROL ENDPOINTS --- + this.app.get('/internal/trading/status', this.requireAuth, (req, res) => { + res.json(healthTracker.getSnapshot().tradingControl); + }); + + this.app.post('/internal/trading/pause', this.requireAuth, this.requireAdmin, (req, res) => { + const { reason } = req.body; + const userId = (req as AuthenticatedRequest).authUserId || 'unknown'; + + const update: TradingControlSnapshot = { + mode: 'PAUSED', + lastChangedBy: userId, + lastChangedAt: Date.now(), + reason: reason || 'Manual admin pause' + }; + + healthTracker.recordTradingControl(update); + this.broadcastHealthUpdate(); + this.saveState(); + + observabilityService.emitEvent({ + type: 'SYSTEM_ERROR', + severity: 'WARN', + message: `Trading PAUSED by operator: ${update.reason}`, + userId + }); + + logger.warn(`[Admin] Trading PAUSED by ${userId}. Reason: ${update.reason}`); + res.json({ success: true, status: update }); + }); + + this.app.post('/internal/trading/resume', this.requireAuth, this.requireAdmin, (req, res) => { + const { reason } = req.body; + const userId = (req as AuthenticatedRequest).authUserId || 'unknown'; + + const update: TradingControlSnapshot = { + mode: 'RUNNING', + lastChangedBy: userId, + lastChangedAt: Date.now(), + reason: reason || 'Manual admin resume' + }; + + healthTracker.recordTradingControl(update); + this.broadcastHealthUpdate(); + this.saveState(); + + observabilityService.emitEvent({ + type: 'SYSTEM_ERROR', + severity: 'INFO', + message: `Trading RESUMED by operator: ${update.reason}`, + userId + }); + + logger.info(`[Admin] Trading RESUMED by ${userId}.`); + res.json({ success: true, status: update }); + }); + + // Non-destructive batch rollback for reconciliation backfill rows. + this.app.post('/api/admin/revert-backfill-batch', this.requireAuth, this.requireAdmin, async (req, res) => { + const batchId = String(req.body.batchId || '').trim(); + + if (!batchId) { + res.status(400).json({ success: false, error: 'batchId is required' }); + return; + } + + try { + const result = await supabaseService.revertBackfillBatch(batchId); + if (result.errors.length > 0) { + res.status(500).json({ success: false, reverted: result.reverted, errors: result.errors }); + return; + } + res.json({ success: true, reverted: result.reverted }); + } catch (err: any) { + res.status(500).json({ success: false, error: err.message }); + } + }); + + + + this.app.get('/api/symbol/:symbol', this.requireAuth, (req, res) => { + const authUserId = (req as AuthenticatedRequest).authUserId; + if (!authUserId) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + const symbolParam = req.params.symbol; + const symbol = Array.isArray(symbolParam) ? symbolParam[0] : symbolParam; + + if (!symbol) { + res.status(400).json({ error: 'Symbol is required' }); + return; + } + + const symbolState = this.state.symbols[symbol]; + if (symbolState) { + res.json(this.getScopedSymbolState(symbolState, authUserId)); + } else { + res.status(404).json({ error: 'Symbol not found' }); + } + }); + + // --- Bot Configuration (non-secret) --- + this.app.get('/api/config', this.requireAuth, (req, res) => { + res.json({ + DATA_PROVIDER: config.DATA_PROVIDER, + EXECUTION_PROVIDER: config.EXECUTION_PROVIDER, + SYMBOLS: config.SYMBOLS, + TIMEFRAME: config.TIMEFRAME, + POLLING_INTERVAL: config.POLLING_INTERVAL, + PAPER_TRADING: config.PAPER_TRADING, + ASSET_CLASS: config.ASSET_CLASS, + EXCHANGE: config.EXCHANGE, + ENABLE_TRADING: config.ENABLE_TRADING, + TOTAL_CAPITAL: config.TOTAL_CAPITAL, + MAX_OPEN_TRADES: config.MAX_OPEN_TRADES, + COOLDOWN_MS: config.COOLDOWN_MS, + PROFIT_EXIT_PERCENT: config.PROFIT_EXIT_PERCENT, + TRAILING_STOP_PERCENT: config.TRAILING_STOP_PERCENT, + ENABLE_STRICT_CAPITAL_GUARD: config.ENABLE_STRICT_CAPITAL_GUARD, + ENABLE_AUTO_PAUSE_ON_SLIPPAGE_BREACH: config.ENABLE_AUTO_PAUSE_ON_SLIPPAGE_BREACH, + STRICT_CAPITAL_SLIPPAGE_BUFFER_PCT: config.STRICT_CAPITAL_SLIPPAGE_BUFFER_PCT, + STRICT_CAPITAL_FEE_BUFFER_PCT: config.STRICT_CAPITAL_FEE_BUFFER_PCT, + STRICT_CAPITAL_MIN_RESERVE_USD: config.STRICT_CAPITAL_MIN_RESERVE_USD, + PROFILE_SYNC_INTERVAL_MS: config.PROFILE_SYNC_INTERVAL_MS, + MONITOR_INTERVAL_MS: config.MONITOR_INTERVAL_MS, + STALE_ORDER_THRESHOLD_MINUTES: config.STALE_ORDER_THRESHOLD_MINUTES, + ORDER_SYNC_MISSING_GRACE_MINUTES: config.ORDER_SYNC_MISSING_GRACE_MINUTES, + ORDER_SYNC_MISSING_CONFIRMATION_COUNT: config.ORDER_SYNC_MISSING_CONFIRMATION_COUNT, + DYNAMIC_CONFIG_REFRESH_MS: config.DYNAMIC_CONFIG_REFRESH_MS, + LOW_STRESS_MODE: config.LOW_STRESS_MODE, + ENABLE_RECON_EXIT_BACKFILL: config.ENABLE_RECON_EXIT_BACKFILL, + RECON_EXIT_BACKFILL_DRY_RUN: config.RECON_EXIT_BACKFILL_DRY_RUN, + RECON_EXIT_BACKFILL_REQUIRE_PAUSE: config.RECON_EXIT_BACKFILL_REQUIRE_PAUSE, + RECON_EXIT_BACKFILL_DUST_ABS_QTY: config.RECON_EXIT_BACKFILL_DUST_ABS_QTY, + RECON_EXIT_BACKFILL_DUST_REL_PCT: config.RECON_EXIT_BACKFILL_DUST_REL_PCT, + RECON_EXIT_BACKFILL_LOOKBACK_HOURS: config.RECON_EXIT_BACKFILL_LOOKBACK_HOURS, + RECON_EXIT_BACKFILL_REQUIRE_STRONG_ATTRIBUTION: config.RECON_EXIT_BACKFILL_REQUIRE_STRONG_ATTRIBUTION, + RECON_EXIT_BACKFILL_ALLOW_HEURISTIC_MATCH: config.RECON_EXIT_BACKFILL_ALLOW_HEURISTIC_MATCH, + RECON_EXIT_BACKFILL_FILL_AFTER_TRADE_GRACE_MINUTES: config.RECON_EXIT_BACKFILL_FILL_AFTER_TRADE_GRACE_MINUTES, + RECON_EXIT_BACKFILL_PROFILE_ALLOWLIST: config.RECON_EXIT_BACKFILL_PROFILE_ALLOWLIST, + ENABLE_RECON_POSITION_PARITY_HEARTBEAT: config.ENABLE_RECON_POSITION_PARITY_HEARTBEAT, + RECON_POSITION_PARITY_DRY_RUN: config.RECON_POSITION_PARITY_DRY_RUN, + RECON_POSITION_PARITY_CONFIRMATIONS: config.RECON_POSITION_PARITY_CONFIRMATIONS, + RECON_POSITION_PARITY_DUST_ABS_QTY: config.RECON_POSITION_PARITY_DUST_ABS_QTY, + RECON_POSITION_PARITY_MAX_NOTIONAL_PCT: config.RECON_POSITION_PARITY_MAX_NOTIONAL_PCT, + RECON_POSITION_PARITY_REQUIRE_SUBTAG_ATTRIBUTION: config.RECON_POSITION_PARITY_REQUIRE_SUBTAG_ATTRIBUTION, + ENABLE_RECON_ORDER_COVERAGE_SYNC: config.ENABLE_RECON_ORDER_COVERAGE_SYNC, + RECON_ORDER_COVERAGE_DRY_RUN: config.RECON_ORDER_COVERAGE_DRY_RUN, + RECON_ORDER_COVERAGE_REQUIRE_PAUSE: config.RECON_ORDER_COVERAGE_REQUIRE_PAUSE, + RECON_ORDER_COVERAGE_LOOKBACK_HOURS: config.RECON_ORDER_COVERAGE_LOOKBACK_HOURS, + RECON_ORDER_COVERAGE_FETCH_LIMIT_PER_PAGE: config.RECON_ORDER_COVERAGE_FETCH_LIMIT_PER_PAGE, + RECON_ORDER_COVERAGE_MAX_FETCH_PAGES: config.RECON_ORDER_COVERAGE_MAX_FETCH_PAGES, + RECON_ORDER_COVERAGE_MAX_INSERTS_PER_PROFILE: config.RECON_ORDER_COVERAGE_MAX_INSERTS_PER_PROFILE, + RECON_ORDER_COVERAGE_TRADE_ID_LOOKBACK_ROWS: config.RECON_ORDER_COVERAGE_TRADE_ID_LOOKBACK_ROWS, + RECON_ORDER_COVERAGE_REQUIRE_SUBTAG_ATTRIBUTION: config.RECON_ORDER_COVERAGE_REQUIRE_SUBTAG_ATTRIBUTION, + RECON_ORDER_COVERAGE_AUTO_PAUSE_ON_UNATTRIBUTED_FILLS: config.RECON_ORDER_COVERAGE_AUTO_PAUSE_ON_UNATTRIBUTED_FILLS, + RECON_ORDER_COVERAGE_UNATTRIBUTED_PAUSE_MIN_COUNT: config.RECON_ORDER_COVERAGE_UNATTRIBUTED_PAUSE_MIN_COUNT, + RECON_ORDER_COVERAGE_UNATTRIBUTED_BASELINE_MS: config.RECON_ORDER_COVERAGE_UNATTRIBUTED_BASELINE_MS, + ENABLE_RECON_INTEGRITY_WATCHDOG: config.ENABLE_RECON_INTEGRITY_WATCHDOG, + RECON_INTEGRITY_WATCHDOG_THROTTLE_MS: config.RECON_INTEGRITY_WATCHDOG_THROTTLE_MS, + RECON_INTEGRITY_WATCHDOG_MISSING_DB_THRESHOLD: config.RECON_INTEGRITY_WATCHDOG_MISSING_DB_THRESHOLD, + RECON_INTEGRITY_WATCHDOG_NO_GO_THRESHOLD: config.RECON_INTEGRITY_WATCHDOG_NO_GO_THRESHOLD, + ENABLE_RECON_WATCHDOG_AUTO_RESUME: config.ENABLE_RECON_WATCHDOG_AUTO_RESUME, + RECON_WATCHDOG_AUTO_RESUME_MIN_PAUSE_MS: config.RECON_WATCHDOG_AUTO_RESUME_MIN_PAUSE_MS, + RECON_WATCHDOG_AUTO_RESUME_CLEAN_CYCLES: config.RECON_WATCHDOG_AUTO_RESUME_CLEAN_CYCLES, + RECON_WATCHDOG_AUTO_RESUME_COOLDOWN_MS: config.RECON_WATCHDOG_AUTO_RESUME_COOLDOWN_MS, + EXCHANGE_STATE_MISMATCH_THROTTLE_MS: config.EXCHANGE_STATE_MISMATCH_THROTTLE_MS, + REQUIRE_EXCHANGE_FILL_EVIDENCE_FOR_AUTO_CLOSE: config.REQUIRE_EXCHANGE_FILL_EVIDENCE_FOR_AUTO_CLOSE, + ENABLE_ALPACA_SUBTAG: config.ENABLE_ALPACA_SUBTAG, + SUBTAG_OMNIBUS_ONLY: config.SUBTAG_OMNIBUS_ONLY, + ALPACA_SUBTAG_ENV: config.ALPACA_SUBTAG_ENV, + ALPACA_SUBTAG_MAX_LENGTH: config.ALPACA_SUBTAG_MAX_LENGTH, + ALPACA_SUBTAG_DISABLE_FOR_EXCHANGE: config.ALPACA_SUBTAG_DISABLE_FOR_EXCHANGE, + ALPACA_OMNIBUS_PROFILE_ALLOWLIST: config.ALPACA_OMNIBUS_PROFILE_ALLOWLIST, + AI_PROVIDER: config.AI.PROVIDER, + AI_MODEL: config.AI.MODEL, + AI_CONFIDENCE_THRESHOLD: config.AI.CONFIDENCE_THRESHOLD, + AI_FALLBACK_LIST: config.AI.FALLBACK_LIST, + AI_CACHE_HOURS: config.AI.CACHE_HOURS, + AI_FAIL_OPEN: config.AI.FAIL_OPEN, + ENABLE_BACKTEST: config.ENABLE_BACKTEST, + BACKTEST_CUSTOMER_ENABLED: config.BACKTEST_CUSTOMER_ENABLED, + BACKTEST_MAX_CSV_BYTES: config.BACKTEST_MAX_CSV_BYTES, + BACKTEST_MAX_ROWS: config.BACKTEST_MAX_ROWS, + ENABLED_RULES: config.PRO_STRATEGY.ENABLED_RULES, + RISK_PER_TRADE: config.PRO_STRATEGY.PARAMETERS.RISK_PER_TRADE, + RISK_REWARD_RATIO: config.PRO_STRATEGY.PARAMETERS.RISK_REWARD_RATIO, + SL_MULTIPLIER: config.PRO_STRATEGY.PARAMETERS.SL_MULTIPLIER, + TREND_TIMEFRAME: config.PRO_STRATEGY.PARAMETERS.TREND_TIMEFRAME, + EXECUTION_TIMEFRAME: config.PRO_STRATEGY.PARAMETERS.EXECUTION_TIMEFRAME, + MOMENTUM_TIMEFRAME: config.PRO_STRATEGY.PARAMETERS.MOMENTUM_TIMEFRAME, + RSI_PERIOD: config.PRO_STRATEGY.PARAMETERS.RSI_PERIOD, + RSI_OVERBOUGHT: config.PRO_STRATEGY.PARAMETERS.RSI_OVERBOUGHT, + RSI_OVERSOLD: config.PRO_STRATEGY.PARAMETERS.RSI_OVERSOLD, + ATR_PERIOD: config.PRO_STRATEGY.PARAMETERS.ATR_PERIOD, + }); + }); + + this.app.post('/api/backtest/run', this.requireAuth, async (req, res) => { + const authUserId = (req as AuthenticatedRequest).authUserId; + if (!authUserId) { + res.status(401).json({ success: false, error: 'Unauthorized' }); + return; + } + if (!this.enforceRateLimit(req as AuthenticatedRequest, res, 'backtest')) { + return; + } + if (!config.ENABLE_BACKTEST) { + res.status(404).json({ success: false, error: 'Backtest feature is disabled' }); + return; + } + + const isAdmin = await supabaseService.isAdmin(authUserId); + if (!isAdmin && !config.BACKTEST_CUSTOMER_ENABLED) { + res.status(403).json({ + success: false, + error: 'Backtest is restricted to admin users. Ask an admin to enable customer backtest access.' + }); + return; + } + + try { + const body = req.body || {}; + const profileId = String(body.profileId || '').trim(); + let profileSettings: any = undefined; + if (profileId) { + profileSettings = await supabaseService.getProfileForBacktest(profileId, authUserId); + if (!profileSettings) { + res.status(404).json({ success: false, error: 'Backtest profile not found for current user' }); + return; + } + } + + const symbols = this.parseSymbolList(body.symbols || profileSettings?.symbols); + if (!symbols.length) { + res.status(400).json({ success: false, error: 'At least one symbol is required for backtest.' }); + return; + } + + const strategyConfig = body.strategyConfig || profileSettings?.strategy_config; + if (!strategyConfig) { + res.status(400).json({ success: false, error: 'strategyConfig is required (or supply profileId with saved strategy).' }); + return; + } + + this.enforceBacktestPayloadGuards(body.dataSource); + const timeframe = this.normalizeBacktestTimeframe(body.timeframe); + + const backtestRequest: BacktestRequest = { + mode: String(body.mode || 'backtest') as BacktestRequest['mode'], + profileId: profileId || undefined, + strategyConfig, + symbols, + timeframe, + dateRange: { + from: String(body?.dateRange?.from || ''), + to: String(body?.dateRange?.to || '') + }, + dataSource: body.dataSource, + execution: body.execution + }; + + const executionProfileSettings = { + ...profileSettings, + strategy_config: strategyConfig, + symbols: symbols.join(','), + allocated_capital: Number(body?.execution?.initialCapitalUsd ?? (profileSettings?.allocated_capital || config.TOTAL_CAPITAL)), + risk_per_trade_percent: Number(profileSettings?.risk_per_trade_percent || config.PRO_STRATEGY.PARAMETERS.RISK_PER_TRADE * 100) + }; + + const result = await runBacktest(backtestRequest, { + profileSettings: executionProfileSettings + }); + + this.auditTradeEvent({ + event: 'backtest_run', + userId: authUserId, + profileId: profileId || undefined, + outcome: 'accepted', + details: { + symbols, + timeframe, + from: backtestRequest.dateRange.from, + to: backtestRequest.dateRange.to + } + }); + + res.json({ success: true, result }); + } catch (error: any) { + this.auditTradeEvent({ + event: 'backtest_run', + userId: authUserId, + profileId: String(req.body?.profileId || '').trim() || undefined, + outcome: 'error', + reason: error.message + }); + res.status(400).json({ success: false, error: error.message || 'Backtest run failed' }); + } + }); + + // --- AI Health Endpoint --- + this.app.get('/api/ai/health', this.requireAuth, async (req, res) => { + const authUserId = (req as AuthenticatedRequest).authUserId; + if (!this.enforceRateLimit(req as AuthenticatedRequest, res, 'chat')) { + return; + } + + const probeParam = String(req.query.probe || '').toLowerCase(); + const probe = probeParam === '1' || probeParam === 'true' || probeParam === 'yes'; + + try { + const providers = await this.aiClient.getProviderHealth(probe); + const configuredProviders = providers + .filter((p) => p.configured) + .map((p) => p.provider); + const healthyProviders = providers + .filter((p) => p.status === 'ok' || p.status === 'configured') + .map((p) => p.provider); + + this.auditTradeEvent({ + event: 'ai_health_check', + userId: authUserId, + outcome: 'accepted', + details: { probe, configuredProviders, healthyProviders } + }); + + res.json({ + success: true, + probe, + failOpen: config.AI.FAIL_OPEN, + fallbackList: config.AI.FALLBACK_LIST, + providers, + summary: { + configuredCount: configuredProviders.length, + healthyCount: healthyProviders.length, + anyUsable: healthyProviders.length > 0 + } + }); + } catch (error: any) { + this.auditTradeEvent({ + event: 'ai_health_check', + userId: authUserId, + outcome: 'error', + reason: error.message + }); + res.status(500).json({ success: false, error: `AI health check failed: ${error.message}` }); + } + }); + + // --- NEW: Manual Trade Execution Endpoint --- + this.app.post('/api/trade', this.requireAuth, async (req, res) => { + const authUserId = (req as AuthenticatedRequest).authUserId; + const { profile_id, symbol, side, qty, type, price, sl, tp } = req.body; + + if (!symbol || !side || !qty) { + return res.status(400).json({ success: false, error: "Missing required fields" }); + } + if (!authUserId) { + return res.status(401).json({ success: false, error: "Unauthorized" }); + } + if (!this.enforceRateLimit(req as AuthenticatedRequest, res, 'trade')) { + return; + } + + this.auditTradeEvent({ + event: 'trade_request', + userId: authUserId, + profileId: profile_id, + symbol, + details: { side, qty, type: type || 'market' } + }); + + // Find manager + let manager = profile_id ? this.executionManagers.get(profile_id) : undefined; + if (manager && manager.getUserId() !== authUserId) { + this.auditTradeEvent({ + event: 'trade_request', + userId: authUserId, + profileId: profile_id, + symbol, + outcome: 'rejected', + reason: 'profile ownership mismatch' + }); + return res.status(403).json({ success: false, error: 'Forbidden: profile does not belong to authenticated user' }); + } + if (!manager && !profile_id) { + const userManagers = Array.from(this.executionManagers.values()).filter(m => m.getUserId() === authUserId); + if (userManagers.length === 1) { + manager = userManagers[0]; + } else if (userManagers.length > 1) { + return res.status(400).json({ success: false, error: 'Multiple profiles found. Please provide profile_id.' }); + } + } + + if (!manager) { + this.auditTradeEvent({ + event: 'trade_request', + userId: authUserId, + profileId: profile_id, + symbol, + outcome: 'rejected', + reason: 'manual trader unavailable' + }); + return res.status(503).json({ error: 'No Manual Trader available.' }); + } + + try { + const livePrice = Number(this.state.symbols[symbol]?.price || 0); + const priceHint = Number(price) > 0 ? Number(price) : (livePrice > 0 ? livePrice : undefined); + const result = await manager.executeRequest( + symbol, + side, + qty, + type || 'market', + price, + priceHint, + authUserId, + sl, + tp + ); + if (result.success) { + this.auditTradeEvent({ + event: 'trade_request', + userId: authUserId, + profileId: profile_id, + symbol, + outcome: 'accepted', + details: { orderId: result.orderId } + }); + res.json(result); + } else { + this.auditTradeEvent({ + event: 'trade_request', + userId: authUserId, + profileId: profile_id, + symbol, + outcome: 'rejected', + reason: result.error || 'execution returned failure' + }); + res.status(500).json(result); + } + } catch (error: any) { + this.auditTradeEvent({ + event: 'trade_request', + userId: authUserId, + profileId: profile_id, + symbol, + outcome: 'error', + reason: error.message + }); + res.status(500).json({ error: error.message }); + } + }); + + // --- NEW: Close Position Endpoint (Square Off) --- + this.app.post('/api/close', this.requireAuth, async (req, res) => { + const authUserId = (req as AuthenticatedRequest).authUserId; + const { profile_id, symbol } = req.body; + logger.info(`[API] Received Square Off request for ${symbol} (Profile: ${profile_id}, AuthUser: ${authUserId})`); + + if (!symbol) { + return res.status(400).json({ success: false, error: "Missing symbol" }); + } + if (!authUserId) { + return res.status(401).json({ success: false, error: "Unauthorized" }); + } + if (!this.enforceRateLimit(req as AuthenticatedRequest, res, 'close')) { + return; + } + + this.auditTradeEvent({ + event: 'close_request', + userId: authUserId, + profileId: profile_id, + symbol + }); + + // Find manager + let manager = profile_id ? this.executionManagers.get(profile_id) : undefined; + if (manager && manager.getUserId() !== authUserId) { + this.auditTradeEvent({ + event: 'close_request', + userId: authUserId, + profileId: profile_id, + symbol, + outcome: 'rejected', + reason: 'profile ownership mismatch' + }); + return res.status(403).json({ success: false, error: 'Forbidden: profile does not belong to authenticated user' }); + } + if (!manager) { + const userManagers = Array.from(this.executionManagers.values()).filter(m => m.getUserId() === authUserId); + const matchingManagers = userManagers.filter(m => !!m.getActivePosition(symbol)); + + if (matchingManagers.length > 1) { + return res.status(400).json({ success: false, error: 'Multiple matching positions found. Please pass profile_id.' }); + } + manager = matchingManagers[0]; + } + + if (!manager) { + this.auditTradeEvent({ + event: 'close_request', + userId: authUserId, + profileId: profile_id, + symbol, + outcome: 'rejected', + reason: 'execution manager unavailable' + }); + return res.status(503).json({ error: 'No Execution Manager available.' }); + } + + const activePos = manager.getActivePosition(symbol); + if (!activePos) { + this.auditTradeEvent({ + event: 'close_request', + userId: authUserId, + profileId: profile_id, + symbol, + outcome: 'rejected', + reason: 'no active position' + }); + return res.status(404).json({ error: 'No active position found for this symbol.' }); + } + + try { + // Get current price from API state + const currentPrice = this.state.symbols[symbol]?.price || 0; + const symbolPositions = manager.getActivePositions(symbol); + for (const pos of symbolPositions) { + await manager.executeExit(symbol, currentPrice, 'Manual Square Off', pos.tradeId); + } + this.auditTradeEvent({ + event: 'close_request', + userId: authUserId, + profileId: profile_id, + symbol, + outcome: 'accepted' + }); + res.json({ success: true, message: `Squared off ${symbol}` }); + } catch (error: any) { + this.auditTradeEvent({ + event: 'close_request', + userId: authUserId, + profileId: profile_id, + symbol, + outcome: 'error', + reason: error.message + }); + res.status(500).json({ success: false, error: error.message }); + } + }); + + // --- NEW: Clear Operational Events --- + this.app.delete('/api/events', this.requireAuth, this.requireAdmin, async (req, res) => { + try { + observabilityService.clearEvents(); + this.state.operationalEvents = []; + this.emitToConnectedUsers('operational_event_cleared', () => ({ success: true })); + res.json({ success: true, message: 'Operational events cleared' }); + } catch (error: any) { + res.status(500).json({ success: false, error: error.message }); + } + }); + + // --- Reconciliation EXIT Backfill Audit (Admin) --- + this.app.get('/api/reconciliation/backfill/audit', this.requireAuth, this.requireAdmin, async (req, res) => { + try { + const profileId = String(req.query.profileId || '').trim(); + const symbol = String(req.query.symbol || '').trim(); + const batchId = String(req.query.batchId || '').trim(); + const fromParam = String(req.query.from || '').trim(); + const toParam = String(req.query.to || '').trim(); + const days = Math.max(0, Math.min(365, Number.parseInt(String(req.query.days || '7'), 10) || 0)); + const limit = Math.max(1, Math.min(500, Number.parseInt(String(req.query.limit || '100'), 10) || 100)); + const offset = Math.max(0, Number.parseInt(String(req.query.offset || '0'), 10) || 0); + const decisionParam = String(req.query.decision || '').trim(); + const decisions = decisionParam + ? decisionParam.split(',').map((value) => value.trim()).filter(Boolean) + : []; + + let fromIso = fromParam; + if (!fromIso && days > 0) { + fromIso = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString(); + } + const toIso = toParam || undefined; + + const result = await supabaseService.getReconciliationBackfillAuditRows({ + profileId: profileId || undefined, + symbol: symbol || undefined, + batchId: batchId || undefined, + decisions, + fromIso, + toIso, + limit, + offset + }); + + res.json({ + success: true, + filters: { + profileId: profileId || null, + symbol: symbol || null, + batchId: batchId || null, + decisions, + from: fromIso || null, + to: toIso || null, + days + }, + pagination: { + limit, + offset, + totalCount: result.totalCount, + hasMore: offset + result.rows.length < result.totalCount + }, + rows: result.rows + }); + } catch (error: any) { + res.status(500).json({ success: false, error: error.message }); + } + }); + + this.app.get('/api/reconciliation/backfill/batches', this.requireAuth, this.requireAdmin, async (req, res) => { + try { + const profileId = String(req.query.profileId || '').trim(); + const symbol = String(req.query.symbol || '').trim(); + const fromParam = String(req.query.from || '').trim(); + const toParam = String(req.query.to || '').trim(); + const days = Math.max(0, Math.min(365, Number.parseInt(String(req.query.days || '7'), 10) || 0)); + const limit = Math.max(1, Math.min(100, Number.parseInt(String(req.query.limit || '20'), 10) || 20)); + + let fromIso = fromParam; + if (!fromIso && days > 0) { + fromIso = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString(); + } + const toIso = toParam || undefined; + + const batches = await supabaseService.getReconciliationBackfillBatchSummaries({ + profileId: profileId || undefined, + symbol: symbol || undefined, + fromIso, + toIso, + limit + }); + + res.json({ + success: true, + filters: { + profileId: profileId || null, + symbol: symbol || null, + from: fromIso || null, + to: toIso || null, + days + }, + batches + }); + } catch (error: any) { + res.status(500).json({ success: false, error: error.message }); + } + }); + + // --- Chat-based Profile Control --- + this.app.post('/api/chat', this.requireAuth, async (req, res) => { + const authUserId = (req as AuthenticatedRequest).authUserId; + if (!this.enforceRateLimit(req as AuthenticatedRequest, res, 'chat')) { + return; + } + + const { message, context } = req.body; + if (!message) { + return res.status(400).json({ error: 'Message is required' }); + } + + this.auditTradeEvent({ + event: 'chat_profile_control', + userId: authUserId, + details: { + messageLength: typeof message === 'string' ? message.length : 0 + } + }); + + const systemPrompt = `You are the AI assistant for the Bytelyst Trading Platform. You translate plain English instructions into structured trading profile configurations. + +AVAILABLE RULES (use these exact ruleId values): +- TrendBiasRule: EMA50/200 trend direction check. Params: { fastPeriod: number, slowPeriod: number } +- MomentumRule: RSI overbought/oversold logic. Params: { rsiPeriod: number, overbought: number, oversold: number } +- ZoneRule: Price proximity to EMA zones. Params: { zonePercent: number } +- SessionRule: Trading session filter. Params: { sessions: string (comma-separated: "London,NY,Tokyo,Sydney") } +- EntryTriggerRule: Pattern-based entry. Params: { showPatterns: boolean } +- RiskManagementRule: ATR-based risk limits. Params: { maxRisk: number } +- AIAnalysisRule: LLM sentiment analysis. Params: { minConfidence: number (0-1) } + +PROFILE SCHEMA: +{ + "action": "create_profile" | "update_profile" | "explain", + "profile": { + "name": string, + "allocated_capital": number, + "risk_per_trade_percent": number, + "symbols": string (comma-separated, e.g. "BTC/USDT, ETH/USDT"), + "is_active": boolean, + "strategy_config": { + "rules": [ { "ruleId": string, "enabled": boolean, "params": {...} } ], + "riskLimits": { "maxDailyLossUsd": number, "maxOpenTrades": number, "maxConsecutiveLosses": number }, + "execution": { "orderType": "market" | "limit", "cooldownMinutes": number, "entryMode": "both" | "long_only" } + } + }, + "summary": string (1-2 sentence human-readable summary of what you did), + "reasoning": string (brief explanation of why you chose these parameters) +} + +CURRENT CONTEXT (existing profiles): +${context ? JSON.stringify(context, null, 2) : 'No existing profiles.'} + +RULES: +1. For "create_profile": generate a complete profile with sensible defaults based on the user's description. +2. For "update_profile": include the profile "id" field and only change what the user asked for. Keep everything else the same. +3. For "explain": just set action to "explain" and put your answer in "summary". No profile needed. +4. Match the user's risk appetite: "conservative" = low risk (0.5-1%), low capital. "aggressive" = higher risk (2-5%), more rules enabled. +5. Always include at least TrendBiasRule and RiskManagementRule as enabled for safety. +6. Output ONLY valid JSON. No markdown, no backticks, no explanation outside the JSON.`; + + try { + let aiResponse: string | null = null; + try { + aiResponse = await this.aiClient.generateAnalysis( + `${systemPrompt}\n\nUser message: "${message}"` + ); + } catch (aiError: any) { + logger.error(`[Chat] AI provider chain failed: ${aiError.message}`); + } + + if (!aiResponse) { + const fallback = this.buildLocalChatFallback(message, Array.isArray(context) ? context : []); + this.auditTradeEvent({ + event: 'chat_profile_control', + userId: authUserId, + outcome: 'accepted', + details: { action: fallback.action, fallback: 'local_deterministic' } + }); + return res.json(fallback); + } + + // Parse the JSON from AI response (handle markdown code blocks if present) + let parsed: any; + try { + const cleaned = aiResponse.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim(); + parsed = JSON.parse(cleaned); + } catch (parseErr) { + logger.error(`[Chat] Failed to parse AI response: ${aiResponse}`); + const fallback = this.buildLocalChatFallback(message, Array.isArray(context) ? context : []); + return res.json({ + ...fallback, + reasoning: `${fallback.reasoning} AI output was non-JSON, so local fallback parsing was used.` + }); + } + + logger.info(`[Chat] Action: ${parsed.action}, Summary: ${parsed.summary}`); + this.auditTradeEvent({ + event: 'chat_profile_control', + userId: authUserId, + outcome: 'accepted', + details: { action: parsed.action } + }); + res.json(parsed); + } catch (error: any) { + logger.error(`[Chat] Error: ${error.message}`); + this.auditTradeEvent({ + event: 'chat_profile_control', + userId: authUserId, + outcome: 'error', + reason: error.message + }); + res.status(500).json({ error: `Chat failed: ${error.message}` }); + } + }); + } + + private setupSocketHandlers() { + this.io.use(async (socket, next) => { + const authToken = typeof socket.handshake.auth?.token === 'string' + ? socket.handshake.auth.token + : this.extractBearerToken(socket.handshake.headers.authorization); + + if (!authToken) { + next(new Error('Unauthorized: missing token')); + return; + } + + const { userId, error } = await supabaseService.verifyAccessToken(authToken); + if (!userId) { + next(new Error(`Unauthorized: ${error || 'invalid token'}`)); + return; + } + + socket.data.userId = userId; + socket.data.isAdmin = await supabaseService.isAdmin(userId); + next(); + }); + + this.io.on('connection', (socket) => { + const userId = String(socket.data.userId || '').trim(); + logger.info(`[API] Dashboard connected: ${socket.id} (user: ${userId || 'unknown'})`); + if (userId) { + this.trackSocket(userId, socket); + const scopedState = this.getScopedState(userId, !!socket.data.isAdmin); + socket.emit('state', scopedState); + } else { + socket.emit('state', { + symbols: {}, + positions: [], + alerts: [], + orders: [], + history: [], + settings: this.state.settings, + health: healthTracker.getSnapshot(), + uptime: this.state.uptime, + accountSnapshot: null, + orderFailures: [], + operationalEvents: [] + } as BotState); + } + + socket.on('disconnect', () => { + if (userId) { + this.untrackSocket(userId, socket.id); + } + logger.info(`[API] Dashboard disconnected: ${socket.id}`); + }); + }); + } + + private startServer() { + this.httpServer.listen(this.port, () => { + logger.info(`[API] Server running on port ${this.port}`); + }); + } + + public updateSymbol(symbol: string, data: Partial) { + if (!this.state.symbols[symbol]) { + this.state.symbols[symbol] = { + price: 0, + change24h: 0, + changeToday: 0, + session: 'Unknown', + volatility: 'Low', + signal: 'NONE', + tradingMode: 'Alerts', + activePosition: null, + priceHistory: [], + rules: {}, + profileSignals: {}, + indicators: {} + }; + } + + if (data.price !== undefined) { + this.state.symbols[symbol].priceHistory.push({ + timestamp: Date.now(), + price: data.price + }); + if (this.state.symbols[symbol].priceHistory.length > 60) { + this.state.symbols[symbol].priceHistory = this.state.symbols[symbol].priceHistory.slice(-60); + } + } + + this.state.symbols[symbol] = { + ...this.state.symbols[symbol], + ...data + }; + + // --- NEW: Real-time P/L Update for Positions --- + if (data.price) { + let positionsChanged = false; + const currentPrice = data.price; + + this.state.positions = this.state.positions.map(pos => { + if (pos.symbol === symbol) { + positionsChanged = true; + // Calculate P/L + const diff = currentPrice - pos.entryPrice; + const direction = pos.side === 'BUY' ? 1 : -1; + const pnl = diff * pos.size * direction; + const pnlPercent = (diff / pos.entryPrice) * 100 * direction; + + return { + ...pos, + currentPrice: currentPrice, + unrealizedPnl: Number(pnl.toFixed(2)), + unrealizedPnlPercent: Number(pnlPercent.toFixed(2)), + marketValue: Number((pos.size * currentPrice).toFixed(2)) + }; + } + return pos; + }); + + if (positionsChanged) { + this.broadcastPositionsUpdate(); + } + } + + this.broadcastSymbolUpdate(symbol); + this.saveState(); + } + + public addAlert( + type: BotState['alerts'][0]['type'], + symbol: string, + message: string, + context?: { userId?: string; profileId?: string } + ) { + const alert = { + timestamp: Date.now(), + type, + symbol, + message, + userId: context?.userId, + profileId: context?.profileId + }; + this.state.alerts.push(alert); + if (this.state.alerts.length > 100) { + this.state.alerts = this.state.alerts.slice(-100); + } + const owner = this.resolveProfileOwner(alert.profileId, alert.userId); + if (owner) { + this.emitToUser(owner, 'new_alert', alert); + } else { + this.emitToConnectedUsers('new_alert', () => alert); + } + this.saveState(); + } + + public updatePositions(positions: BotState['positions'], sourceId: string = 'global') { + const sourceProfileId = sourceId === 'global' ? undefined : sourceId; + // Enrich incoming positions with current P/L calculations before storing + const enrichedPositions = positions.map(pos => { + const currentPrice = this.state.symbols[pos.symbol]?.price || pos.entryPrice; + const diff = currentPrice - pos.entryPrice; + const direction = pos.side === 'BUY' ? 1 : -1; + const pnl = diff * pos.size * direction; + const pnlPercent = (diff / pos.entryPrice) * 100 * direction; + const resolvedUserId = this.resolveProfileOwner(pos.profileId || sourceProfileId, pos.userId); + + return { + ...pos, + currentPrice: currentPrice, + unrealizedPnl: Number(pnl.toFixed(2)), + unrealizedPnlPercent: Number(pnlPercent.toFixed(2)), + marketValue: Number((pos.size * currentPrice).toFixed(2)), + userId: pos.userId || resolvedUserId || undefined, + profileId: pos.profileId || sourceProfileId + }; + }); + + // Global updates are full snapshots from the trading loop; treat them as authoritative. + if (sourceId === 'global') { + this.profilePositionsList.clear(); + } + this.profilePositionsList.set(sourceId, enrichedPositions); + const merged = mergePositionSnapshots(Array.from(this.profilePositionsList.values())); + this.state.positions = merged; + this.broadcastPositionsUpdate(); + this.saveState(); + } + + public updateOrders(orders: BotState['orders'], sourceId: string = 'global') { + const inferredProfileId = sourceId === 'global' ? undefined : sourceId; + const normalizedOrders = orders.map((order) => ({ + ...order, + trade_id: order.trade_id || (order as any).tradeId, + subTag: order.subTag || (order as any).subtag || (order as any).sub_tag, + profileId: order.profileId || inferredProfileId, + userId: order.userId || this.resolveProfileOwner(order.profileId || inferredProfileId), + source: order.source || ((order.profileId || inferredProfileId) ? 'BOT' : 'MANUAL') + })); + + if (sourceId === 'global') { + this.profileOrdersList.clear(); + } + this.profileOrdersList.set(sourceId, normalizedOrders); + const merged = mergeOrderSnapshots(Array.from(this.profileOrdersList.values())); + this.state.orders = merged; + this.broadcastOrdersUpdate(); + this.saveState(); + } + + public addHistory(trade: BotState['history'][0]) { + const resolvedUserId = this.resolveProfileOwner(trade.profileId, trade.userId); + const normalizedTrade: BotState['history'][0] = { + ...trade, + userId: trade.userId || resolvedUserId || undefined, + source: trade.source || (trade.profileId ? 'BOT' : 'MANUAL') + }; + + this.state.history.push(normalizedTrade); + if (this.state.history.length > 100) { + this.state.history = this.state.history.slice(-100); + } + this.broadcastHistoryUpdate(normalizedTrade); + this.saveState(); + } + + public updateAccountSnapshot(snapshot: AccountSnapshot) { + const enrichedSnapshot: AccountSnapshot = { + ...snapshot, + timestamp: snapshot.timestamp || Date.now() + }; + this.state.accountSnapshot = enrichedSnapshot; + this.accountSnapshotCache = [...this.accountSnapshotCache, enrichedSnapshot].slice(-25); + const owner = this.resolveProfileOwner(snapshot.profileId, snapshot.userId); + if (owner) { + this.emitToUser(owner, 'account_snapshot', enrichedSnapshot); + } else { + this.emitToConnectedUsers('account_snapshot', () => enrichedSnapshot); + } + } + + public broadcast(event: string, data: any) { + this.emitToConnectedUsers(event, () => data); + } + + public recordOrderFailure(failure: OrderFailureRecord) { + const enrichedFailure: OrderFailureRecord = { + ...failure, + timestamp: failure.timestamp || Date.now() + }; + this.state.orderFailures = [...this.state.orderFailures, enrichedFailure].slice(-30); + const owner = this.resolveProfileOwner(failure.profileId, failure.userId); + if (owner) { + this.emitToUser(owner, 'order_failure', enrichedFailure); + } else { + this.emitToConnectedUsers('order_failure', () => enrichedFailure); + } + } + + public updateSettings(settings: Partial) { + this.state.settings = { ...this.state.settings, ...settings }; + this.broadcastSettingsUpdate(); + this.saveState(); + } + + public updateRuntimeHealth(update: Partial) { + this.runtimeHealth = { + ...this.runtimeHealth, + ...update + }; + } + + public publishHealthSnapshot(options?: { broadcast?: boolean; force?: boolean }): HealthSnapshot { + const snapshot = healthTracker.getSnapshot(); + this.state.health = snapshot; + + if (options?.broadcast) { + const now = Date.now(); + const shouldBroadcast = options.force || (now - this.lastHealthBroadcastAt) >= this.healthBroadcastMinIntervalMs; + if (shouldBroadcast) { + this.lastHealthBroadcastAt = now; + this.emitToConnectedUsers('health_update', () => snapshot); + } + } + + return snapshot; + } + + public pruneSymbols(activeSymbols: string[]) { + const currentSymbols = Object.keys(this.state.symbols); + let changed = false; + + currentSymbols.forEach(s => { + if (!activeSymbols.includes(s)) { + delete this.state.symbols[s]; + changed = true; + logger.info(`[API] Pruned legacy symbol: ${s}`); + } + }); + + if (changed) { + this.saveState(); + } + } + + public getState(): BotState { + return this.state; + } + + public async stop(): Promise { + if (!this.httpServer.listening) { + return; + } + + this.io.close(); + await new Promise((resolve, reject) => { + this.httpServer.close((err?: Error) => { + if (err) { + reject(err); + return; + } + resolve(); + }); + }); + } +} diff --git a/backend/src/services/canonicalLifecycleService.ts b/backend/src/services/canonicalLifecycleService.ts new file mode 100644 index 0000000..ea03c67 --- /dev/null +++ b/backend/src/services/canonicalLifecycleService.ts @@ -0,0 +1,612 @@ +import type { FilledLifecycleOrderRow } from './SupabaseService.js'; + +export type CanonicalLifecycleState = 'OPEN' | 'PARTIAL_EXIT' | 'CLOSED' | 'ORPHAN_EXIT'; +export type CanonicalSide = 'BUY' | 'SELL'; + +export interface CanonicalLifecycleProfileMeta { + id: string; + name: string; + allocatedCapital: number; + isActive: boolean; + userId?: string; +} + +export interface CanonicalLifecycleRow { + id: string; + profileId: string; + profileName: string; + tradeId: string; + symbol: string; + side: CanonicalSide; + state: CanonicalLifecycleState; + entryQty: number; + exitQty: number; + matchedQty: number; + openQty: number; + entryAvgPrice: number; + exitAvgPrice: number; + openEntryAvgPrice: number; + openNotional: number; + realizedPnl: number; + realizedPnlPercent: number; + unrealizedPnl: number; + currentPrice: number; + stopLoss?: number; + takeProfit?: number; + subTag?: string; + hasSyntheticOrder: boolean; + lastEventAt: number; +} + +export interface CanonicalOpenPosition { + id: string; + profileId: string; + profileName: string; + tradeId: string; + symbol: string; + side: CanonicalSide; + size: number; + entryPrice: number; + currentPrice: number; + pnl: number; + pnlPercent: number; + stopLoss?: number; + takeProfit?: number; + subTag?: string; + lastEventAt: number; +} + +export interface CanonicalRealizedTrade { + id: string; + profileId: string; + profileName: string; + tradeId: string; + symbol: string; + side: CanonicalSide; + size: number; + entryPrice: number; + exitPrice: number; + pnl: number; + pnlPercent: number; + closedAt: number; + state: CanonicalLifecycleState; + subTag?: string; +} + +export interface CanonicalLifecycleAggregate { + profileId: string; + profileName: string; + allocatedCapital: number; + isActive: boolean; + openTrades: number; + openNotional: number; + realizedPnl: number; + unrealizedPnl: number; + netPnl: number; + tradeCount: number; + wins: number; + winRate: number; + lastClosedTradeAt: number; +} + +export interface CanonicalLifecycleSnapshot { + generatedAt: number; + diagnostics: { + orderRows: number; + lifecycleRows: number; + openPositions: number; + realizedTrades: number; + truncated: boolean; + syntheticRealizedTradesExcluded?: number; + syntheticRealizedPnlExcluded?: number; + }; + profiles: CanonicalLifecycleProfileMeta[]; + lifecycleRows: CanonicalLifecycleRow[]; + openPositions: CanonicalOpenPosition[]; + realizedTrades: CanonicalRealizedTrade[]; + aggregates: { + total: { + openTrades: number; + openNotional: number; + realizedPnl: number; + unrealizedPnl: number; + netPnl: number; + tradeCount: number; + wins: number; + winRate: number; + }; + byProfile: Record; + }; +} + +type BuildSnapshotInput = { + orders: FilledLifecycleOrderRow[]; + profiles: CanonicalLifecycleProfileMeta[]; + symbolPrices: Record; + truncated?: boolean; +}; + +type LifecycleLot = { + qty: number; + price: number; + stopLoss?: number; + takeProfit?: number; +}; + +const EPSILON = 1e-8; +const SYNTHETIC_ORDER_PREFIXES = ['BFILL-', 'MANOVR-', 'RECON-BF', 'RECON-', 'SYNC-']; + +const toNumber = (value: unknown): number => { + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : 0; +}; + +const normalizeStatus = (status?: string | null): string => { + const normalized = String(status || '').trim().toLowerCase().replace(/-/g, '_'); + return normalized; +}; + +const normalizeSide = (side?: string | null): CanonicalSide => { + const normalized = String(side || '').trim().toUpperCase(); + return normalized === 'SELL' || normalized === 'SHORT' ? 'SELL' : 'BUY'; +}; + +const normalizeAction = (action?: string | null): 'ENTRY' | 'EXIT' | undefined => { + const normalized = String(action || '').trim().toUpperCase(); + if (normalized === 'ENTRY' || normalized === 'EXIT') return normalized; + return undefined; +}; + +const parseTimestampCandidate = (value: unknown): number => { + if (typeof value === 'number') { + if (!Number.isFinite(value) || value <= 0) return 0; + return value > 1_000_000_000_000 ? value : value * 1000; + } + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return 0; + if (/^\d+(\.\d+)?$/.test(trimmed)) { + return parseTimestampCandidate(Number(trimmed)); + } + const parsed = Date.parse(trimmed); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + return 0; +}; + +const normalizeTimestamp = (row: FilledLifecycleOrderRow): number => { + // Backfill/synthetic rows can carry historical `filled_at` or stale `timestamp` + // that predates the ENTRY for the same trade. Use the latest persisted event time + // to preserve lifecycle causality inside each trade. + const fromTimestamp = parseTimestampCandidate(row.timestamp); + const fromCreatedAt = parseTimestampCandidate(row.created_at); + const fromFilledAt = parseTimestampCandidate(row.filled_at); + return Math.max(fromTimestamp, fromCreatedAt, fromFilledAt, 0); +}; + +const normalizeSymbolKey = (symbol: string): string => { + return String(symbol || '') + .toUpperCase() + .replace(/[-_/]/g, '') + .replace(/USDT/g, 'USD') + .trim(); +}; + +const normalizeOrderQty = (row: FilledLifecycleOrderRow): number => { + const qty = toNumber(row.qty); + if (qty > EPSILON) return qty; + return toNumber(row.quantity); +}; + +const normalizeOrderPrice = (row: FilledLifecycleOrderRow): number => { + return toNumber(row.price); +}; + +const isSyntheticOrderId = (orderIdRaw: unknown): boolean => { + const normalized = String(orderIdRaw || '').trim().toUpperCase(); + if (!normalized) return false; + return SYNTHETIC_ORDER_PREFIXES.some((prefix) => normalized.startsWith(prefix)); +}; + +const isBotLifecycleOrder = (row: FilledLifecycleOrderRow): boolean => { + const source = String(row.source || '').trim().toUpperCase(); + if (source === 'MANUAL') return false; + const status = normalizeStatus(row.status); + return status === 'filled' || status === 'partially_filled'; +}; + +const buildSymbolPriceMap = (symbolPrices: Record): Map => { + const map = new Map(); + for (const [rawSymbol, rawPrice] of Object.entries(symbolPrices || {})) { + const price = toNumber(rawPrice); + if (price <= EPSILON) continue; + const key = normalizeSymbolKey(rawSymbol); + if (!key) continue; + map.set(key, price); + } + return map; +}; + +const lookupCurrentPrice = (symbol: string, priceMap: Map): number => { + const key = normalizeSymbolKey(symbol); + if (!key) return 0; + return toNumber(priceMap.get(key) || 0); +}; + +export class CanonicalLifecycleService { + buildSnapshot(input: BuildSnapshotInput): CanonicalLifecycleSnapshot { + const profileById = new Map(); + for (const profile of input.profiles || []) { + const profileId = String(profile.id || '').trim(); + if (!profileId) continue; + profileById.set(profileId, profile); + } + + const grouped = new Map(); + for (const row of input.orders || []) { + if (!isBotLifecycleOrder(row)) continue; + + const tradeId = String(row.trade_id || '').trim(); + const profileId = String(row.profile_id || '').trim(); + if (!tradeId || !profileId) continue; + + const qty = normalizeOrderQty(row); + if (qty <= EPSILON) continue; + + const key = `${profileId}|${tradeId}`; + const list = grouped.get(key) || []; + list.push(row); + grouped.set(key, list); + } + + const priceMap = buildSymbolPriceMap(input.symbolPrices || {}); + const lifecycleRows: CanonicalLifecycleRow[] = []; + const openPositions: CanonicalOpenPosition[] = []; + const realizedTrades: CanonicalRealizedTrade[] = []; + let syntheticRealizedTradesExcluded = 0; + let syntheticRealizedPnlExcluded = 0; + + for (const [key, rows] of grouped.entries()) { + const sorted = [...rows].sort((left, right) => { + const leftTs = normalizeTimestamp(left); + const rightTs = normalizeTimestamp(right); + if (leftTs !== rightTs) return leftTs - rightTs; + const leftOrderId = String(left.order_id || left.id || ''); + const rightOrderId = String(right.order_id || right.id || ''); + return leftOrderId.localeCompare(rightOrderId); + }); + + const [profileId, tradeId] = key.split('|'); + const profileMeta = profileById.get(profileId); + const profileName = String(profileMeta?.name || profileId || 'Unknown Profile'); + + let symbol = ''; + let entrySide: CanonicalSide | null = null; + let entryQty = 0; + let exitQty = 0; + let entryNotional = 0; + let exitNotional = 0; + let matchedQty = 0; + let matchedEntryNotional = 0; + let matchedExitNotional = 0; + let realizedPnl = 0; + let lastEventAt = 0; + let subTag = ''; + let hasSyntheticOrder = false; + let fallbackStopLoss = 0; + let fallbackTakeProfit = 0; + const lots: LifecycleLot[] = []; + + for (const row of sorted) { + const qty = normalizeOrderQty(row); + if (qty <= EPSILON) continue; + + const side = normalizeSide(row.side); + const price = normalizeOrderPrice(row); + const action = normalizeAction(row.action) + || (side === 'SELL' ? 'EXIT' : 'ENTRY'); + const rowTs = normalizeTimestamp(row); + lastEventAt = Math.max(lastEventAt, rowTs); + + if (!symbol) { + symbol = String(row.symbol || '').trim(); + } + if (!entrySide && action === 'ENTRY') { + entrySide = side; + } + if (!subTag) { + const candidateTag = String(row.sub_tag || '').trim(); + if (candidateTag) subTag = candidateTag; + } + if (!hasSyntheticOrder && isSyntheticOrderId(row.order_id || row.id)) { + hasSyntheticOrder = true; + } + + const stopLoss = toNumber(row.stop_loss); + const takeProfit = toNumber(row.take_profit); + if (stopLoss > EPSILON) fallbackStopLoss = stopLoss; + if (takeProfit > EPSILON) fallbackTakeProfit = takeProfit; + + if (action === 'ENTRY') { + const sideForEntry: CanonicalSide = entrySide ?? side; + entrySide = sideForEntry; + if (side !== sideForEntry) continue; + + entryQty += qty; + if (price > EPSILON) entryNotional += qty * price; + lots.push({ + qty, + price, + stopLoss: stopLoss > EPSILON ? stopLoss : undefined, + takeProfit: takeProfit > EPSILON ? takeProfit : undefined + }); + continue; + } + + const expectedExitSide: CanonicalSide = (entrySide || 'BUY') === 'BUY' ? 'SELL' : 'BUY'; + if (side !== expectedExitSide) continue; + + exitQty += qty; + if (price > EPSILON) exitNotional += qty * price; + + let remaining = qty; + while (remaining > EPSILON && lots.length > 0) { + const lot = lots[0]; + const matched = Math.min(remaining, lot.qty); + if (matched <= EPSILON) break; + + lot.qty -= matched; + remaining -= matched; + matchedQty += matched; + + if (lot.price > EPSILON && price > EPSILON) { + matchedEntryNotional += matched * lot.price; + matchedExitNotional += matched * price; + realizedPnl += (entrySide || 'BUY') === 'BUY' + ? (price - lot.price) * matched + : (lot.price - price) * matched; + } + + if (lot.qty <= EPSILON) { + lots.shift(); + } + } + } + + const openQty = lots.reduce((sum, lot) => sum + Math.max(0, lot.qty), 0); + const openNotional = lots.reduce((sum, lot) => ( + lot.price > EPSILON ? sum + (lot.qty * lot.price) : sum + ), 0); + const entryAvgPrice = matchedQty > EPSILON && matchedEntryNotional > EPSILON + ? matchedEntryNotional / matchedQty + : (entryQty > EPSILON && entryNotional > EPSILON ? entryNotional / entryQty : 0); + const exitAvgPrice = matchedQty > EPSILON && matchedExitNotional > EPSILON + ? matchedExitNotional / matchedQty + : (exitQty > EPSILON && exitNotional > EPSILON ? exitNotional / exitQty : 0); + const openEntryAvgPrice = openQty > EPSILON && openNotional > EPSILON + ? openNotional / openQty + : 0; + + const side: CanonicalSide = entrySide || 'BUY'; + const direction = side === 'SELL' ? -1 : 1; + const currentPrice = lookupCurrentPrice(symbol, priceMap); + const unrealizedPnl = openQty > EPSILON && openEntryAvgPrice > EPSILON && currentPrice > EPSILON + ? (currentPrice - openEntryAvgPrice) * openQty * direction + : 0; + + let state: CanonicalLifecycleState = 'CLOSED'; + if (entryQty <= EPSILON && exitQty > EPSILON) { + state = 'ORPHAN_EXIT'; + } else if (openQty > EPSILON && matchedQty > EPSILON) { + state = 'PARTIAL_EXIT'; + } else if (openQty > EPSILON) { + state = 'OPEN'; + } else { + state = 'CLOSED'; + } + + const realizedPnlPercent = entryAvgPrice > EPSILON + ? (((exitAvgPrice - entryAvgPrice) / entryAvgPrice) * 100 * direction) + : 0; + + let stopLoss = fallbackStopLoss > EPSILON ? fallbackStopLoss : undefined; + let takeProfit = fallbackTakeProfit > EPSILON ? fallbackTakeProfit : undefined; + for (const lot of lots) { + if (!stopLoss && lot.stopLoss && lot.stopLoss > EPSILON) stopLoss = lot.stopLoss; + if (!takeProfit && lot.takeProfit && lot.takeProfit > EPSILON) takeProfit = lot.takeProfit; + if (stopLoss && takeProfit) break; + } + + const lifecycleRow: CanonicalLifecycleRow = { + id: `life:${profileId}:${tradeId}`, + profileId, + profileName, + tradeId, + symbol, + side, + state, + entryQty: Number(entryQty.toFixed(8)), + exitQty: Number(exitQty.toFixed(8)), + matchedQty: Number(matchedQty.toFixed(8)), + openQty: Number(openQty.toFixed(8)), + entryAvgPrice: Number(entryAvgPrice.toFixed(8)), + exitAvgPrice: Number(exitAvgPrice.toFixed(8)), + openEntryAvgPrice: Number(openEntryAvgPrice.toFixed(8)), + openNotional: Number((openQty * openEntryAvgPrice).toFixed(8)), + realizedPnl: Number(realizedPnl.toFixed(8)), + realizedPnlPercent: Number(realizedPnlPercent.toFixed(4)), + unrealizedPnl: Number(unrealizedPnl.toFixed(8)), + currentPrice: Number(currentPrice.toFixed(8)), + stopLoss, + takeProfit, + subTag: subTag || undefined, + hasSyntheticOrder, + lastEventAt + }; + lifecycleRows.push(lifecycleRow); + + if (openQty > EPSILON) { + const pnlPercent = openEntryAvgPrice > EPSILON + ? (((currentPrice - openEntryAvgPrice) * direction) / openEntryAvgPrice) * 100 + : 0; + openPositions.push({ + id: `open:${profileId}:${tradeId}`, + profileId, + profileName, + tradeId, + symbol, + side, + size: Number(openQty.toFixed(8)), + entryPrice: Number(openEntryAvgPrice.toFixed(8)), + currentPrice: Number(currentPrice.toFixed(8)), + pnl: Number(unrealizedPnl.toFixed(8)), + pnlPercent: Number(pnlPercent.toFixed(4)), + stopLoss, + takeProfit, + subTag: subTag || undefined, + lastEventAt + }); + } + + if (matchedQty > EPSILON && !hasSyntheticOrder) { + realizedTrades.push({ + id: `realized:${profileId}:${tradeId}`, + profileId, + profileName, + tradeId, + symbol, + side, + size: Number(matchedQty.toFixed(8)), + entryPrice: Number(entryAvgPrice.toFixed(8)), + exitPrice: Number(exitAvgPrice.toFixed(8)), + pnl: Number(realizedPnl.toFixed(8)), + pnlPercent: Number(realizedPnlPercent.toFixed(4)), + closedAt: lastEventAt, + state, + subTag: subTag || undefined + }); + } + } + + lifecycleRows.sort((a, b) => b.lastEventAt - a.lastEventAt || a.id.localeCompare(b.id)); + openPositions.sort((a, b) => b.lastEventAt - a.lastEventAt || a.id.localeCompare(b.id)); + realizedTrades.sort((a, b) => b.closedAt - a.closedAt || a.id.localeCompare(b.id)); + + const byProfile: Record = {}; + for (const profile of input.profiles || []) { + const profileId = String(profile.id || '').trim(); + if (!profileId) continue; + byProfile[profileId] = { + profileId, + profileName: profile.name, + allocatedCapital: Number(profile.allocatedCapital || 0), + isActive: Boolean(profile.isActive), + openTrades: 0, + openNotional: 0, + realizedPnl: 0, + unrealizedPnl: 0, + netPnl: 0, + tradeCount: 0, + wins: 0, + winRate: 0, + lastClosedTradeAt: 0 + }; + } + + for (const row of lifecycleRows) { + const profileId = row.profileId; + if (!byProfile[profileId]) { + byProfile[profileId] = { + profileId, + profileName: row.profileName, + allocatedCapital: 0, + isActive: false, + openTrades: 0, + openNotional: 0, + realizedPnl: 0, + unrealizedPnl: 0, + netPnl: 0, + tradeCount: 0, + wins: 0, + winRate: 0, + lastClosedTradeAt: 0 + }; + } + const aggregate = byProfile[profileId]; + aggregate.openNotional += row.openNotional; + aggregate.unrealizedPnl += row.unrealizedPnl; + if (!row.hasSyntheticOrder) { + aggregate.realizedPnl += row.realizedPnl; + } else if (row.matchedQty > EPSILON) { + syntheticRealizedTradesExcluded += 1; + syntheticRealizedPnlExcluded += row.realizedPnl; + } + if (row.openQty > EPSILON) aggregate.openTrades += 1; + if (row.matchedQty > EPSILON && !row.hasSyntheticOrder) { + aggregate.tradeCount += 1; + if (row.realizedPnl > 0) aggregate.wins += 1; + aggregate.lastClosedTradeAt = Math.max(aggregate.lastClosedTradeAt, row.lastEventAt); + } + } + + let totalOpenTrades = 0; + let totalOpenNotional = 0; + let totalRealizedPnl = 0; + let totalUnrealizedPnl = 0; + let totalTrades = 0; + let totalWins = 0; + + for (const aggregate of Object.values(byProfile)) { + aggregate.openNotional = Number(aggregate.openNotional.toFixed(8)); + aggregate.realizedPnl = Number(aggregate.realizedPnl.toFixed(8)); + aggregate.unrealizedPnl = Number(aggregate.unrealizedPnl.toFixed(8)); + aggregate.netPnl = Number((aggregate.realizedPnl + aggregate.unrealizedPnl).toFixed(8)); + aggregate.winRate = aggregate.tradeCount > 0 + ? Number(((aggregate.wins / aggregate.tradeCount) * 100).toFixed(4)) + : 0; + + totalOpenTrades += aggregate.openTrades; + totalOpenNotional += aggregate.openNotional; + totalRealizedPnl += aggregate.realizedPnl; + totalUnrealizedPnl += aggregate.unrealizedPnl; + totalTrades += aggregate.tradeCount; + totalWins += aggregate.wins; + } + + const totalNetPnl = totalRealizedPnl + totalUnrealizedPnl; + + return { + generatedAt: Date.now(), + diagnostics: { + orderRows: input.orders.length, + lifecycleRows: lifecycleRows.length, + openPositions: openPositions.length, + realizedTrades: realizedTrades.length, + truncated: Boolean(input.truncated), + syntheticRealizedTradesExcluded, + syntheticRealizedPnlExcluded: Number(syntheticRealizedPnlExcluded.toFixed(8)) + }, + profiles: input.profiles, + lifecycleRows, + openPositions, + realizedTrades, + aggregates: { + total: { + openTrades: totalOpenTrades, + openNotional: Number(totalOpenNotional.toFixed(8)), + realizedPnl: Number(totalRealizedPnl.toFixed(8)), + unrealizedPnl: Number(totalUnrealizedPnl.toFixed(8)), + netPnl: Number(totalNetPnl.toFixed(8)), + tradeCount: totalTrades, + wins: totalWins, + winRate: totalTrades > 0 + ? Number(((totalWins / totalTrades) * 100).toFixed(4)) + : 0 + }, + byProfile + } + }; + } +} + +export const canonicalLifecycleService = new CanonicalLifecycleService(); diff --git a/backend/src/services/distributedLockService.ts b/backend/src/services/distributedLockService.ts new file mode 100644 index 0000000..9be4c3e --- /dev/null +++ b/backend/src/services/distributedLockService.ts @@ -0,0 +1,143 @@ +import logger from '../utils/logger.js'; +import { supabaseService } from './SupabaseService.js'; +import { healthTracker } from './healthTracker.js'; +import { observabilityService } from './observabilityService.js'; + +const normalizeSymbol = (symbol?: string): string => { + return String(symbol || '').trim(); +}; + +export class DistributedLockService { + async tryAcquireRowLock(profileId: string, symbol: string | undefined, owner: string, ttlSeconds: number = 30): Promise { + if (!profileId || !owner) return false; + const normalizedSymbol = normalizeSymbol(symbol); + const client = supabaseService.getClient(); + if (!client) return false; + + const ttl = Number.isFinite(ttlSeconds) ? Math.max(1, ttlSeconds) : 30; + const { data, error } = await client + .rpc('fn_try_acquire_entry_lock_row', { + p_profile_id: profileId, + p_symbol: normalizedSymbol, + p_owner: owner, + p_ttl_seconds: ttl + }) + .maybeSingle(); + + if (error) { + if (this.isRpcNetworkFailure(error)) { + logger.error(`[DistributedLock] RPC network error while acquiring row lock for ${profileId}:${normalizedSymbol}; failing closed: ${error.message}`); + return false; + } + logger.error(`[DistributedLock] Failed to acquire row lock for ${profileId}:${normalizedSymbol}: ${error.message}`); + return false; + } + + const acquired = Boolean(data); + if (!acquired) { + this.recordContention(); + } + return acquired; + } + + async releaseRowLock(profileId: string, symbol: string | undefined, owner: string): Promise { + if (!profileId || !owner) return false; + const normalizedSymbol = normalizeSymbol(symbol); + const client = supabaseService.getClient(); + if (!client) return false; + + const { data, error } = await client + .rpc('fn_release_entry_lock_row', { + p_profile_id: profileId, + p_symbol: normalizedSymbol, + p_owner: owner + }) + .maybeSingle(); + + if (error) { + logger.warn(`[DistributedLock] Failed to release row lock for ${profileId}:${normalizedSymbol}: ${error.message}`); + return false; + } + + return Boolean(data); + } + + private isRpcNetworkFailure(error: any): boolean { + const message = String(error?.message || '').toLowerCase(); + return message.includes('fetch failed') || message.includes('network'); + } + + async tryAcquireReconciliationLock(profileId: string, owner: string, ttlSeconds: number = 30): Promise { + if (!profileId || !owner) return false; + const client = supabaseService.getClient(); + if (!client) return false; + + const ttl = Number.isFinite(ttlSeconds) ? Math.max(1, ttlSeconds) : 30; + const { data, error } = await client + .rpc('fn_try_acquire_reconciliation_lock_row', { + p_profile_id: profileId, + p_owner: owner, + p_ttl_seconds: ttl + }) + .maybeSingle(); + + if (error) { + if (this.isRpcNetworkFailure(error)) { + logger.error(`[DistributedLock] RPC network error while acquiring reconciliation lock for ${profileId}; failing closed: ${error.message}`); + return false; + } + logger.error(`[DistributedLock] Reconciliation lock acquisition failed for ${profileId}: ${error.message}`); + return false; + } + + return Boolean(data); + } + + async releaseReconciliationLock(profileId: string, owner: string): Promise { + if (!profileId || !owner) return false; + const client = supabaseService.getClient(); + if (!client) return false; + + const { data, error } = await client + .rpc('fn_release_reconciliation_lock_row', { + p_profile_id: profileId, + p_owner: owner + }) + .maybeSingle(); + + if (error) { + logger.warn(`[DistributedLock] Failed to release reconciliation lock for ${profileId}: ${error.message}`); + return false; + } + + return Boolean(data); + } + + async isEntryInProgress(profileId: string, symbol?: string): Promise { + if (!profileId) return false; + const normalizedSymbol = normalizeSymbol(symbol); + const client = supabaseService.getClient(); + if (!client) return false; + + const { data, error } = await client + .rpc('fn_is_entry_in_progress', { + p_profile: profileId, + p_symbol: normalizedSymbol + }) + .maybeSingle(); + + if (error) { + logger.error(`[DistributedLock] Lifecycle check failed for ${profileId}:${normalizedSymbol}: ${error.message}`); + return true; + } + return Boolean(data); + } + + recordContention() { + healthTracker.incrementLockContention(); + observabilityService.incrementEntryLockContention(); + } +} + +export const distributedLockService = new DistributedLockService(); +export const entryLockService = distributedLockService; diff --git a/backend/src/services/executionManager.ts b/backend/src/services/executionManager.ts new file mode 100644 index 0000000..ace0a25 --- /dev/null +++ b/backend/src/services/executionManager.ts @@ -0,0 +1,397 @@ +import { config } from '../config/index.js'; +import { IExchangeConnector } from '../connectors/types.js'; +import { RiskEngine, RiskProfile } from './riskEngine.js'; +import { MarketContext, RuleResult, SignalDirection } from '../strategies/rules/types.js'; +import logger from '../utils/logger.js'; +import { supabaseService } from './SupabaseService.js'; +import { Notifier } from './notifier.js'; +import { ApiServer } from './apiServer.js'; +import { SymbolMapper } from '../utils/symbolMapper.js'; + +let deprecationWarned = false; + +/** + * @deprecated Legacy execution manager retained only for backward compatibility. + * Use TradeExecutor + AutoTrader for all new execution flows. + */ +export class ExecutionManager { + private riskEngine: RiskEngine; + private notifier: Notifier; + private activeTraders: Map = new Map(); // Symbol -> Position Details + private cooldowns: Map = new Map(); // Symbol -> Timestamp of last exit + + constructor( + private exchange: IExchangeConnector, + private apiServer?: ApiServer, + private userId: string = 'global' // Pass 'global' or user UUID + ) { + const allowLegacy = String(process.env.ALLOW_LEGACY_EXECUTION_MANAGER || '').trim().toLowerCase() === 'true'; + if (!allowLegacy) { + throw new Error( + '[ExecutionManager] CRITICAL: This class is DEPRECATED and disabled by default. ' + + 'Set ALLOW_LEGACY_EXECUTION_MANAGER=true only for controlled test tooling. ' + + 'Use TradeExecutor and AutoTrader for runtime flows.' + ); + } + + this.riskEngine = new RiskEngine(); + this.notifier = new Notifier(); + if (!deprecationWarned) { + logger.warn('[ExecutionManager] Legacy execution manager enabled for compatibility/testing only.'); + deprecationWarned = true; + } + } + + /** + * Attempts to execute a trade based on a strategy result. + */ + /** + * Attempts to execute a trade based on a strategy result. + */ + public async handleSignal(symbol: string, result: RuleResult, context: MarketContext) { + if (!config.ENABLE_TRADING) { + logger.info(`[Execution] 💡 Trading is DISABLED. Alert only for ${symbol}.`); + return; + } + + const activePos = this.activeTraders.get(symbol); + + // --- EXIT LOGIC (If in trade) --- + if (activePos) { + // 1. Exit on Signal Flip (Trend Reversal) + const isOpposite = (activePos.side === SignalDirection.BUY && result.signal === SignalDirection.SELL) || + (activePos.side === SignalDirection.SELL && result.signal === SignalDirection.BUY); + + if (isOpposite) { + await this.executeExit(symbol, context.currentPrice, 'Strategy Signal Flip'); + return; + } + + // 2. Exit on Trend Dissipation (Neutral after Profit Target) + const profitPercent = ((context.currentPrice - activePos.entryPrice) / activePos.entryPrice) * 100 * (activePos.side === SignalDirection.BUY ? 1 : -1); + if (profitPercent >= 1.0 && result.signal === SignalDirection.NONE) { + await this.executeExit(symbol, context.currentPrice, 'Trend Neutralized (Profit Target Met)'); + return; + } + + // Update price peaks for trailing logic + if (activePos.side === SignalDirection.BUY) { + activePos.peakPrice = Math.max(activePos.peakPrice || 0, context.currentPrice); + } else { + activePos.peakPrice = Math.min(activePos.peakPrice || 999999, context.currentPrice); + } + + return; // Still in valid trade + } + + if (result.signal === SignalDirection.NONE || !result.passed) { + return; + } + + // --- ENTRY LOGIC --- + // 1. Check Global Max Open Trades Limit + if (this.activeTraders.size >= config.MAX_OPEN_TRADES) { + logger.info(`[Execution] 🛑 Max open trades reached (${config.MAX_OPEN_TRADES}). Skipping entry for ${symbol}.`); + return; + } + + // 2. Check Cooldown (Default 1 hour after exit) + const lastExit = this.cooldowns.get(symbol) || 0; + const cooldownPeriod = 3600000; // 1 Hour + if (Date.now() - lastExit < cooldownPeriod) { + logger.info(`[Execution] 💤 ${symbol} is in cooldown. Skipping entry.`); + return; + } + + // 3. Check for existing position on exchange (Safety Lock) + try { + const tradeSymbol = SymbolMapper.toTradeSymbol(symbol, config.EXECUTION_PROVIDER); + const position = await this.exchange.getPosition(tradeSymbol); + if (position) { + logger.warn(`[Execution] ⚠️ Found existing position on exchange for ${symbol} (Mapped: ${tradeSymbol}). Locking local state.`); + this.activeTraders.set(symbol, { + side: position.side?.toUpperCase(), + entryPrice: parseFloat(position.avg_entry_price), + size: parseFloat(position.qty), + stopLoss: 0, + takeProfit: 0, + peakPrice: parseFloat(position.avg_entry_price) + }); + return; + } + } catch (e) { + logger.error(`[Execution] Error checking position for ${symbol}: ${e}`); + } + + // 4. Calculate Risk-Adjusted Position + const riskProfile = await this.riskEngine.calculateRiskProfile(symbol, result.signal as SignalDirection, context); + if (!riskProfile) { + logger.warn(`[Execution] ❌ Failed to calculate risk profile for ${symbol}.`); + return; + } + + // 5. Place Order + try { + const tradeSymbol = SymbolMapper.toTradeSymbol(symbol, config.EXECUTION_PROVIDER); + logger.info(`[Execution] 🚀 Executing ${riskProfile.action} for ${symbol} (Mapped: ${tradeSymbol})...`); + logger.info(`[Execution] Details: Qty=${riskProfile.positionSize.toFixed(4)}, SL=${riskProfile.stopLoss.toFixed(2)}, TP Trigger=${riskProfile.takeProfit.toFixed(2)}`); + + const order = await this.exchange.placeOrder( + tradeSymbol, + riskProfile.action === SignalDirection.BUY ? 'buy' : 'sell', + riskProfile.positionSize, + 'market', + undefined, + undefined, + undefined + ); + + if (order) { + const posData = { + side: riskProfile.action, + entryPrice: context.currentPrice, + size: riskProfile.positionSize, + stopLoss: riskProfile.stopLoss, + takeProfit: riskProfile.takeProfit, // Trigger for profit guard + peakPrice: context.currentPrice + }; + this.activeTraders.set(symbol, posData); + + // Log order to database + if (this.userId !== 'global') { + supabaseService.logOrder({ + user_id: this.userId, + order_id: order.id || undefined, + symbol, + type: 'Market', + side: riskProfile.action, + qty: riskProfile.positionSize, + price: context.currentPrice, + status: 'Filled', + timestamp: Date.now() + }); + } + + // Update Dashboard Orders + if (this.apiServer) { + this.apiServer.updateOrders([{ + id: Math.random().toString(36).substring(7), + symbol: symbol, + type: 'Market', + side: riskProfile.action, + qty: riskProfile.positionSize, + price: context.currentPrice, + status: 'Filled', + timestamp: Date.now() + }]); + } + + await this.notifier.sendAlert(`🚀 **TRADE EXECUTED**\nSymbol: ${symbol}\nAction: ${riskProfile.action}\nQty: ${riskProfile.positionSize.toFixed(4)}\nSL: ${riskProfile.stopLoss.toFixed(2)}\nMonitoring for 1%+ Runs...`); + } + + } catch (error) { + logger.error(`[Execution] ❌ Failed to execute trade for ${symbol}: ${error}`); + await this.notifier.sendAlert(`❌ **EXECUTION FAILED**\nSymbol: ${symbol}\nError: ${error}`); + } + } + + /** + * Forcefully exits a trade (Market Order). + */ + public async executeExit(symbol: string, currentPrice: number, reason: string = 'Target Reached') { + const pos = this.activeTraders.get(symbol); + if (!pos) return; + + try { + const tradeSymbol = SymbolMapper.toTradeSymbol(symbol, config.EXECUTION_PROVIDER); + logger.info(`[Execution] 🚪 EXITING ${symbol} (Mapped: ${tradeSymbol}) | Reason: ${reason} | Price: ${currentPrice}`); + + const order = await this.exchange.placeOrder( + tradeSymbol, + pos.side === SignalDirection.BUY ? 'sell' : 'buy', + pos.size, + 'market', + undefined, + undefined, + undefined + ); + + if (order) { + await this.notifier.sendAlert(`🚪 **TRADE CLOSED**\nSymbol: ${symbol}\nReason: ${reason}\nPrice: ${currentPrice}`); + this.markTradeComplete(symbol, currentPrice, reason); + } + } catch (error) { + logger.error(`[Execution] ❌ Failed to execute exit for ${symbol}: ${error}`); + await this.notifier.sendAlert(`❌ **EXIT FAILED**\nSymbol: ${symbol}\nError: ${error}`); + } + } + + /** + * Call this when a trade exit is detected (via TradeMonitor or Manual) + */ + public markTradeComplete(symbol: string, exitPrice?: number, reason: string = 'Target Reached') { + const pos = this.activeTraders.get(symbol); + if (pos && exitPrice && this.apiServer) { + const pnl = (exitPrice - pos.entryPrice) * pos.size * (pos.side === SignalDirection.BUY ? 1 : -1); + const pnlPercent = ((exitPrice - pos.entryPrice) / pos.entryPrice) * 100 * (pos.side === SignalDirection.BUY ? 1 : -1); + + this.apiServer.addHistory({ + symbol, + side: pos.side, + entryPrice: pos.entryPrice, + exitPrice, + size: pos.size, + pnl, + pnlPercent, + reason: reason, + timestamp: Date.now() + }); + } + + if (pos && exitPrice) { + const pnl = (exitPrice - pos.entryPrice) * pos.size * (pos.side === SignalDirection.BUY ? 1 : -1); + const pnlPercent = ((exitPrice - pos.entryPrice) / pos.entryPrice) * 100 * (pos.side === SignalDirection.BUY ? 1 : -1); + + // Log to Supabase + if (this.userId !== 'global') { + supabaseService.logTransaction({ + user_id: this.userId, + symbol, + side: pos.side, + entry_price: pos.entryPrice, + exit_price: exitPrice, + size: pos.size, + pnl, + pnl_percent: pnlPercent, + reason, + timestamp: Date.now() + }); + } + } + + this.activeTraders.delete(symbol); + this.cooldowns.set(symbol, Date.now()); + logger.info(`[Execution] ✅ Trade complete for ${symbol}. Cooldown started.`); + } + + /** + * Synchronizes local state with exchange positions (Startup Recovery) + */ + public async syncPositions(symbols: string[]) { + logger.info(`[Execution] 🔄 Synchronizing positions on startup for ${symbols.length} symbols...`); + for (const symbol of symbols) { + try { + const tradeSymbol = SymbolMapper.toTradeSymbol(symbol, config.EXECUTION_PROVIDER); + logger.info(`[Execution] Checking exchange for ${symbol} (Mapped: ${tradeSymbol})...`); + const position = await this.exchange.getPosition(tradeSymbol); + + if (position) { + const side = position.side?.toLowerCase(); + const finalSide = (side === 'long' || side === 'buy') ? SignalDirection.BUY : SignalDirection.SELL; + + logger.info(`[Execution] ✅ Found existing ${side} position for ${symbol}. Recovering as ${finalSide} | Price: ${position.avg_entry_price} | Qty: ${position.qty}`); + this.activeTraders.set(symbol, { + side: finalSide, + entryPrice: parseFloat(position.avg_entry_price), + size: parseFloat(position.qty), + stopLoss: 0, // Recalculated locally during loop + takeProfit: 0, + peakPrice: parseFloat(position.avg_entry_price) + }); + } else { + logger.info(`[Execution] No active position found for ${symbol} on exchange.`); + } + } catch (e) { + logger.error(`[Execution] Failed to sync ${symbol}: ${e}`); + } + } + } + + public isSymbolLocked(symbol: string): boolean { + return this.activeTraders.has(symbol); + } + + public getActiveSymbols(): string[] { + return Array.from(this.activeTraders.keys()); + } + + /** + * Executes a manual trade triggered via API/Dashboard. + */ + public async executeManualTrade(symbol: string, side: 'buy' | 'sell', qty: number, type: 'market' | 'limit' = 'market', price?: number) { + logger.info(`[Execution] 🖐️ Manual Trade Request: ${side.toUpperCase()} ${symbol} | Qty: ${qty} | Type: ${type}`); + + try { + const tradeSymbol = SymbolMapper.toTradeSymbol(symbol, config.EXECUTION_PROVIDER); + + // Check for existing position if opening a new one + // (Optional: You might want to allow adding to a position manually, so we won't strictly block it, but we update tracking) + + const order = await this.exchange.placeOrder( + tradeSymbol, + side, + qty, + type, + price, + undefined, + undefined, + undefined + ); + + if (order) { + const signalSide = side === 'buy' ? SignalDirection.BUY : SignalDirection.SELL; + + // If this is an ENTRY or Adding to position + const currentPrice = price || order.filled_avg_price || 0; // Fallback if market order filling isn't instant + + // Update local tracking + this.activeTraders.set(symbol, { + side: signalSide, + entryPrice: currentPrice, // Note: This might overwrite existing average entry price tracking, effectively averaging it loosely + size: qty, // Simplified: In a real system we'd sum it up + stopLoss: 0, + takeProfit: 0, + peakPrice: currentPrice + }); + + // Log to Supabase + if (this.userId !== 'global') { + supabaseService.logOrder({ + user_id: this.userId, + order_id: order.id, + symbol, + type: type === 'market' ? 'Market' : 'Limit', + side: signalSide, + qty: qty, + price: currentPrice, + status: 'Filled', // Simplified assumption for market orders + timestamp: Date.now() + }); + } + + // Update Dashboard + if (this.apiServer) { + this.apiServer.updateOrders([{ + id: order.id || Math.random().toString(36).substring(7), + symbol: symbol, + type: type === 'market' ? 'Market' : 'Limit', + side: signalSide, + qty: qty, + price: currentPrice, + status: order.repliesstatus || 'Filled', + timestamp: Date.now() + }]); + } + + await this.notifier.sendAlert(`🖐️ **MANUAL TRADE EXECUTED**\nSymbol: ${symbol}\nSide: ${side.toUpperCase()}\nQty: ${qty}`); + return { success: true, orderId: order.id }; + } + } catch (error: any) { + logger.error(`[Execution] ❌ Manual Execution Failed: ${error.message}`); + return { success: false, error: error.message }; + } + } + public getActivePosition(symbol: string): any { + return this.activeTraders.get(symbol) || null; + } +} diff --git a/backend/src/services/healthTracker.ts b/backend/src/services/healthTracker.ts new file mode 100644 index 0000000..2034df4 --- /dev/null +++ b/backend/src/services/healthTracker.ts @@ -0,0 +1,170 @@ +import { config } from '../config/index.js'; + +export interface TradingControlSnapshot { + mode: 'RUNNING' | 'PAUSED'; + lastChangedBy: string; + lastChangedAt: number; + reason?: string; +} + +export interface ReconciliationNoGoSample { + profileId: string; + symbol: string; + tradeId: string; + reason: string; +} + +export interface HealthSnapshot { + tradingLoopHealthy: boolean; + tradingLoopLastRun: number | null; + monitorLoopHealthy: boolean; + monitorLoopLastRun: number | null; + orderSyncHealthy: boolean; + orderSyncLastRun: number | null; + lockContentionCount: number; + reconciliationLoopHealthy: boolean; + reconciliationLoopLastRun: number | null; + reconciliationMismatchCount: number; + reconciliationMissingFromExchange: number; + reconciliationMissingInDb: number; + reconciliationNoGoTrades: number; + reconciliationNoGoReasonCounts: Record; + reconciliationNoGoSamples: ReconciliationNoGoSample[]; + reconciliationIntegrityWatchdogTriggered: boolean; + reconciliationLockContentionCount: number; + tradingControl: TradingControlSnapshot; +} + +export class HealthTracker { + private tradingLoopLastRun: number | null = null; + private tradingLoopLastSuccess = true; + private monitorLoopLastRun: number | null = null; + private orderSyncLastRun: number | null = null; + private lockContention = 0; + private reconciliationLockContention = 0; + private reconciliationLastRun: number | null = null; + private reconciliationLastSuccess = true; + private reconciliationMismatchCount = 0; + private reconciliationMissingFromExchange = 0; + private reconciliationMissingInDb = 0; + private reconciliationNoGoTrades = 0; + private reconciliationNoGoReasonCounts: Record = {}; + private reconciliationNoGoSamples: ReconciliationNoGoSample[] = []; + private reconciliationIntegrityWatchdogTriggered = false; + private tradingControl: TradingControlSnapshot = { + mode: 'RUNNING', + lastChangedBy: 'system', + lastChangedAt: Date.now() + }; + + public recordTradingLoop(healthy: boolean) { + this.tradingLoopLastRun = Date.now(); + this.tradingLoopLastSuccess = healthy; + } + + public recordMonitorLoop() { + this.monitorLoopLastRun = Date.now(); + } + + public recordOrderSyncLoop() { + this.orderSyncLastRun = Date.now(); + } + + public incrementLockContention() { + this.lockContention += 1; + } + + public incrementReconciliationLockContention() { + this.reconciliationLockContention += 1; + } + + public recordTradingControl(update: TradingControlSnapshot) { + this.tradingControl = update; + } + + public isPaused(): boolean { + return this.tradingControl.mode === 'PAUSED'; + } + + public isTradingPaused(): boolean { + return this.isPaused(); + } + + private isFresh(lastRun: number | null, intervalMs: number): boolean { + if (!lastRun) return false; + const now = Date.now(); + const threshold = Math.max(intervalMs * 2, 120_000); + return (now - lastRun) <= threshold; + } + + public getSnapshot(): HealthSnapshot { + return { + tradingLoopHealthy: this.tradingLoopLastSuccess && this.isFresh(this.tradingLoopLastRun, Math.max(config.POLLING_INTERVAL, 1)), + tradingLoopLastRun: this.tradingLoopLastRun, + monitorLoopHealthy: this.isFresh(this.monitorLoopLastRun, Math.max(config.MONITOR_INTERVAL_MS, 1)), + monitorLoopLastRun: this.monitorLoopLastRun, + orderSyncHealthy: this.isFresh(this.orderSyncLastRun, Math.max(config.ORDER_SYNC_INTERVAL_MS, 1)), + orderSyncLastRun: this.orderSyncLastRun, + lockContentionCount: this.lockContention, + reconciliationLoopHealthy: this.reconciliationLastSuccess && this.isFresh(this.reconciliationLastRun, Math.max(config.MONITOR_INTERVAL_MS, 1)), + reconciliationLoopLastRun: this.reconciliationLastRun, + reconciliationMismatchCount: this.reconciliationMismatchCount, + reconciliationMissingFromExchange: this.reconciliationMissingFromExchange, + reconciliationMissingInDb: this.reconciliationMissingInDb, + reconciliationNoGoTrades: this.reconciliationNoGoTrades, + reconciliationNoGoReasonCounts: this.reconciliationNoGoReasonCounts, + reconciliationNoGoSamples: this.reconciliationNoGoSamples, + reconciliationIntegrityWatchdogTriggered: this.reconciliationIntegrityWatchdogTriggered, + reconciliationLockContentionCount: this.reconciliationLockContention, + tradingControl: this.tradingControl + }; + } + + public recordReconciliationLoop(success: boolean, metrics?: { + mismatchCount?: number; + missingFromExchange?: number; + missingInDb?: number; + noGoTrades?: number; + noGoReasonCounts?: Record; + noGoSamples?: ReconciliationNoGoSample[]; + integrityWatchdogTriggered?: boolean; + }) { + this.reconciliationLastRun = Date.now(); + this.reconciliationLastSuccess = success; + if (metrics?.mismatchCount !== undefined) { + this.reconciliationMismatchCount = metrics.mismatchCount; + } + if (metrics?.missingFromExchange !== undefined) { + this.reconciliationMissingFromExchange = metrics.missingFromExchange; + } + if (metrics?.missingInDb !== undefined) { + this.reconciliationMissingInDb = metrics.missingInDb; + } + if (metrics?.noGoTrades !== undefined) { + this.reconciliationNoGoTrades = metrics.noGoTrades; + } + if (metrics?.noGoReasonCounts !== undefined) { + this.reconciliationNoGoReasonCounts = { ...(metrics.noGoReasonCounts || {}) }; + } else if (this.reconciliationNoGoTrades === 0) { + this.reconciliationNoGoReasonCounts = {}; + } + if (metrics?.noGoSamples !== undefined) { + this.reconciliationNoGoSamples = (metrics.noGoSamples || []).slice(0, 10).map((sample) => ({ + profileId: String(sample?.profileId || '').trim(), + symbol: String(sample?.symbol || '').trim(), + tradeId: String(sample?.tradeId || '').trim(), + reason: String(sample?.reason || '').trim() || 'unknown' + })); + } else if (this.reconciliationNoGoTrades === 0) { + this.reconciliationNoGoSamples = []; + } + if (metrics?.integrityWatchdogTriggered !== undefined) { + this.reconciliationIntegrityWatchdogTriggered = metrics.integrityWatchdogTriggered; + } else if (this.reconciliationMismatchCount === 0 && this.reconciliationMissingInDb === 0 && this.reconciliationNoGoTrades === 0) { + this.reconciliationIntegrityWatchdogTriggered = false; + } + } +} + + +export const healthTracker = new HealthTracker(); diff --git a/backend/src/services/notifier.ts b/backend/src/services/notifier.ts new file mode 100644 index 0000000..7da5b09 --- /dev/null +++ b/backend/src/services/notifier.ts @@ -0,0 +1,106 @@ +import https from 'https'; +import logger from '../utils/logger.js'; +import { config } from '../config/index.js'; + +export class Notifier { + private isNotificationEnabled: boolean = true; + private readonly phoneNumbers: string[] = config.NOTIFICATION_PHONE_NUMBERS.length > 0 + ? config.NOTIFICATION_PHONE_NUMBERS + : []; // No default — must be configured via NOTIFICATION_PHONE_NUMBERS env var + + /** + * Sets the notification status. + * @param status - True to enable, false to disable. + */ + public setNotificationStatus(status: boolean): void { + this.isNotificationEnabled = status; + logger.info(`[Notifier] WhatsApp notifications are now ${status ? 'ENABLED' : 'DISABLED'}`); + } + + /** + * Gets the current notification status. + */ + public getNotificationStatus(): boolean { + return this.isNotificationEnabled; + } + + /** + * Sends a WhatsApp message to all configured recipients. + * @param message - The content to send. + */ + public async sendAlert(message: string): Promise { + if (!this.isNotificationEnabled) { + logger.info(`[Notifier] Notifications disabled. Skipping message: ${message.substring(0, 30)}...`); + return; + } + + if (this.phoneNumbers.length === 0) { + logger.warn(`[Notifier] No phone numbers configured (set NOTIFICATION_PHONE_NUMBERS env var). Skipping.`); + return; + } + + const sendPromises = this.phoneNumbers.map(number => this.sendSingleWhatsAppMessage(number, message)); + + try { + await Promise.all(sendPromises); + logger.info(`[Notifier] Alerts broadcasted to ${this.phoneNumbers.length} recipients.`); + } catch (error) { + logger.error('[Notifier] Broadcast failed partially/fully.'); + } + } + + /** + * Sends a WhatsApp message to a specific number using ZenHustles API. + */ + private sendSingleWhatsAppMessage(to: string, message: string): Promise { + return new Promise((resolve, reject) => { + const data = JSON.stringify({ + message: message, + toWhatsAppNumber: to + }); + + const options = { + hostname: config.NOTIFICATION_API_HOST, + port: 443, + path: config.NOTIFICATION_API_PATH, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(data) + } + }; + + const req = https.request(options, (res) => { + let responseBody = ''; + + res.on('data', (chunk) => { + responseBody += chunk; + }); + + res.on('end', () => { + if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { + try { + const parsed = JSON.parse(responseBody); + logger.info(`[Notifier] Successfully sent to ${to}`); + resolve(parsed); + } catch (e) { + logger.info(`[Notifier] Successfully sent to ${to} (Non-JSON response)`); + resolve(responseBody); + } + } else { + logger.error(`[Notifier] Failed for ${to}. Status: ${res.statusCode}, Body: ${responseBody}`); + reject(new Error(`Status ${res.statusCode}`)); + } + }); + }); + + req.on('error', (error) => { + logger.error(`[Notifier] Network error for ${to}: ${error.message}`); + reject(error); + }); + + req.write(data); + req.end(); + }); + } +} diff --git a/backend/src/services/observabilityService.ts b/backend/src/services/observabilityService.ts new file mode 100644 index 0000000..199a2ed --- /dev/null +++ b/backend/src/services/observabilityService.ts @@ -0,0 +1,309 @@ +import logger from '../utils/logger.js'; +import { config } from '../config/index.js'; +import { capitalLedger } from './CapitalLedger.js'; +import { OperationalEvent, OperationalEventType, OperationalEventSeverity } from '../domain/operationalEvents.js'; +import { metrics } from './MetricsService.js'; +import crypto from 'crypto'; + +export interface ObservabilitySummary { + tradingLoop: { + durationMs: number; + lastRunAt: number | null; + healthy: boolean; + }; + monitorLoop: { + durationMs: number; + lastRunAt: number | null; + }; + reconciliation: { + durationMs: number; + lastRunAt: number | null; + mismatchCount: number; + missingFromExchange: number; + missingInDb: number; + healthy: boolean; + }; + lockContention: { + entry: number; + reconciliation: number; + }; + capitalInvariantViolations: number; +} + +export class ObservabilityService { + private lastTradingLoopAt: number | null = null; + private lastTradingLoopDuration = 0; + private lastMonitorLoopAt: number | null = null; + private lastMonitorLoopDuration = 0; + private lastReconciliationAt: number | null = null; + private lastReconciliationDuration = 0; + private lastReconciliationMismatch = 0; + private lastReconciliationMissingFromExchange = 0; + private lastReconciliationMissingInDb = 0; + private reconciliationDegradedStreak = 0; + private reconciliationLastSloAlertAt = 0; + private entryLockContentionsCount = 0; + private reconciliationLockContentionsCount = 0; + private capitalInvariantViolationsCount = 0; + private capitalInvariantLastAlertAt = new Map(); + private profileProvider: (() => string[]) | null = null; + private capitalWatchdog?: NodeJS.Timeout; + private watchdogRunning = false; + + private readonly eventBuffer: OperationalEvent[] = []; + private readonly MAX_EVENTS = Math.max(50, Math.min(10_000, Number(config.OPERATIONAL_EVENTS_MAX_BUFFER || 2000))); + private onEventCallback: ((event: OperationalEvent) => void) | null = null; + + constructor() { + } + + recordTradingLoop(durationMs: number) { + metrics.subsystemDurationSeconds.labels('trading').observe(durationMs / 1000); + metrics.subsystemLastRunTimestamp.labels('trading').set(Date.now() / 1000); + metrics.subsystemAlive.labels('trading').set(1); + + this.lastTradingLoopDuration = durationMs; + this.lastTradingLoopAt = Date.now(); + } + + recordMonitorLoop(durationMs: number) { + metrics.subsystemDurationSeconds.labels('monitor').observe(durationMs / 1000); + metrics.subsystemLastRunTimestamp.labels('monitor').set(Date.now() / 1000); + metrics.subsystemAlive.labels('monitor').set(1); + + this.lastMonitorLoopDuration = durationMs; + this.lastMonitorLoopAt = Date.now(); + } + + recordReconciliationLoop(durationMs: number, mismatchCount: number, missingFromExchange: number, missingInDb: number) { + metrics.subsystemDurationSeconds.labels('reconciliation').observe(durationMs / 1000); + metrics.subsystemLastRunTimestamp.labels('reconciliation').set(Date.now() / 1000); + metrics.subsystemAlive.labels('reconciliation').set(1); + + if (mismatchCount > 0) { + metrics.reconciliationMismatchesTotal.inc(mismatchCount); + } + metrics.reconciliationMissingItemsCount.labels('exchange').set(missingFromExchange); + metrics.reconciliationMissingItemsCount.labels('db').set(missingInDb); + + this.lastReconciliationDuration = durationMs; + this.lastReconciliationAt = Date.now(); + this.lastReconciliationMismatch = mismatchCount; + this.lastReconciliationMissingFromExchange = missingFromExchange; + this.lastReconciliationMissingInDb = missingInDb; + + const mismatchThreshold = Math.max(0, Number(config.RECONCILIATION_SLO_MISMATCH_THRESHOLD || 1)); + const missingExchangeThreshold = Math.max(0, Number(config.RECONCILIATION_SLO_MISSING_EXCHANGE_THRESHOLD || 1)); + const missingDbThreshold = Math.max(0, Number(config.RECONCILIATION_SLO_MISSING_DB_THRESHOLD || 1)); + const degraded = mismatchCount >= mismatchThreshold + || missingFromExchange >= missingExchangeThreshold + || missingInDb >= missingDbThreshold; + if (degraded) { + this.reconciliationDegradedStreak += 1; + } else { + this.reconciliationDegradedStreak = 0; + } + + const alertStreak = Math.max(1, Number(config.RECONCILIATION_SLO_ALERT_STREAK || 2)); + const throttleMs = Math.max(0, Number(config.RECONCILIATION_SLO_ALERT_THROTTLE_MS || 600_000)); + const now = Date.now(); + const canAlert = throttleMs <= 0 || (now - this.reconciliationLastSloAlertAt) >= throttleMs; + if (degraded && this.reconciliationDegradedStreak >= alertStreak && canAlert) { + this.reconciliationLastSloAlertAt = now; + const message = `Reconciliation degraded (${this.reconciliationDegradedStreak} cycles): mismatches=${mismatchCount}, missing_from_exchange=${missingFromExchange}, missing_in_db=${missingInDb}`; + this.emitEvent({ + type: 'RECONCILIATION_DEGRADED', + severity: 'WARN', + message + }); + logger.warn(message, { + event: 'reconciliation_slo_degraded', + mismatchCount, + missingFromExchange, + missingInDb, + degradedStreak: this.reconciliationDegradedStreak + }); + } + } + + incrementEntryLockContention() { + this.entryLockContentionsCount += 1; + } + + incrementReconciliationLockContention() { + this.reconciliationLockContentionsCount += 1; + } + + observeExchangeLatency(operation: string, durationMs: number) { + metrics.exchangeApiLatencySeconds.labels(config.PROVIDER, operation).observe(durationMs / 1000); + } + + incrementUnsupportedFeature(feature: string) { + // We could add a counter for this too if needed, but not in current scope + } + + registerProfileProvider(provider: () => string[]) { + this.profileProvider = provider; + } + + startCapitalWatchdog(intervalMs: number = config.CAPITAL_WATCHDOG_INTERVAL_MS) { + if (this.watchdogRunning) return; + this.watchdogRunning = true; + this.capitalWatchdog = setInterval(() => { + this.checkCapitalInvariant(); + }, intervalMs); + this.capitalWatchdog.unref(); + } + + private async checkCapitalInvariant() { + if (!this.profileProvider) return; + const profileIds = this.profileProvider().filter(Boolean); + const epsilonUsd = Math.max(0, Number(config.CAPITAL_INVARIANT_EPSILON_USD || 0)); + const epsilonPct = Math.max(0, Number(config.CAPITAL_INVARIANT_EPSILON_PCT || 0)); + const throttleMs = Math.max(0, Number(config.CAPITAL_INVARIANT_ALERT_THROTTLE_MS || 0)); + for (const profileId of profileIds) { + const ledger = await capitalLedger.getLedger(profileId); + if (!ledger) continue; + const available = capitalLedger.availableCapital(ledger); + const allocated = Number(ledger.allocated_capital || 0); + const denominator = allocated > 0 ? allocated : 1; + const utilization = (ledger.reserved_for_orders + ledger.reserved_for_positions) / denominator * 100; + const epsilon = Math.max(epsilonUsd, Math.abs(allocated) * epsilonPct); + + metrics.profileUtilizationPercent.labels(profileId).set(utilization); + + if (available < -epsilon) { + metrics.capitalInvariantViolationsTotal.labels(profileId).inc(); + this.capitalInvariantViolationsCount += 1; + + const now = Date.now(); + const lastAlertAt = this.capitalInvariantLastAlertAt.get(profileId) || 0; + const shouldEmit = throttleMs <= 0 || (now - lastAlertAt) >= throttleMs; + if (!shouldEmit) { + continue; + } + this.capitalInvariantLastAlertAt.set(profileId, now); + + this.emitEvent({ + type: 'INSUFFICIENT_BUYING_POWER', + severity: 'ERROR', + message: `Capital invariant violation: available capital is negative ($${available.toFixed(2)})`, + profileId + }); + + logger.error('Capital invariant violation detected', { + event: 'capital_invariant_violation', + profileId, + allocated: ledger.allocated_capital, + reservedOrders: ledger.reserved_for_orders, + reservedPositions: ledger.reserved_for_positions, + realizedPnl: ledger.realized_pnl, + available + }); + } + } + } + + emitEvent(payload: { + type: OperationalEventType; + severity: OperationalEventSeverity; + message: string; + profileId?: string; + userId?: string; + symbol?: string; + }) { + const event: OperationalEvent = { + id: crypto.randomUUID(), + timestamp: Date.now(), + ...payload + }; + + // Push to Prometheus + metrics.operationalEventsTotal.labels( + event.severity, + event.type, + event.profileId || 'global', + event.symbol || 'NA' + ).inc(); + + this.eventBuffer.unshift(event); + if (this.eventBuffer.length > this.MAX_EVENTS) { + this.eventBuffer.pop(); + } + + if (this.onEventCallback) { + this.onEventCallback(event); + } + } + + getEvents(): OperationalEvent[] { + return [...this.eventBuffer]; + } + + clearEvents(): void { + this.eventBuffer.length = 0; + } + + subscribe(callback: (event: OperationalEvent) => void) { + this.onEventCallback = callback; + } + + async metrics() { + // Update aliveness gauges before scraping + const tradingAlive = this.isFresh(this.lastTradingLoopAt, config.POLLING_INTERVAL); + const monitorAlive = this.isFresh(this.lastMonitorLoopAt, config.MONITOR_INTERVAL_MS); + const reconAlive = this.isFresh(this.lastReconciliationAt, config.MONITOR_INTERVAL_MS); + + metrics.subsystemAlive.labels('trading').set(tradingAlive ? 1 : 0); + metrics.subsystemAlive.labels('monitor').set(monitorAlive ? 1 : 0); + metrics.subsystemAlive.labels('reconciliation').set(reconAlive ? 1 : 0); + + return metrics.getMetrics(); + } + + contentType() { + return metrics.getContentType(); + } + + getSummary(): ObservabilitySummary { + return { + tradingLoop: { + durationMs: this.lastTradingLoopDuration, + lastRunAt: this.lastTradingLoopAt, + healthy: this.isFresh(this.lastTradingLoopAt, config.POLLING_INTERVAL) + }, + monitorLoop: { + durationMs: this.lastMonitorLoopDuration, + lastRunAt: this.lastMonitorLoopAt + }, + reconciliation: { + durationMs: this.lastReconciliationDuration, + lastRunAt: this.lastReconciliationAt, + mismatchCount: this.lastReconciliationMismatch, + missingFromExchange: this.lastReconciliationMissingFromExchange, + missingInDb: this.lastReconciliationMissingInDb, + healthy: this.isFresh(this.lastReconciliationAt, config.MONITOR_INTERVAL_MS) + }, + lockContention: { + entry: this.entryLockContentionsCount, + reconciliation: this.reconciliationLockContentionsCount + }, + capitalInvariantViolations: this.capitalInvariantViolationsCount + }; + } + + sloFlags() { + return { + tradingLoopHealthy: this.isFresh(this.lastTradingLoopAt, config.POLLING_INTERVAL), + reconciliationHealthy: this.isFresh(this.lastReconciliationAt, config.MONITOR_INTERVAL_MS), + reconciliationDegradedStreak: this.reconciliationDegradedStreak + }; + } + + private isFresh(lastRun: number | null, intervalMs: number) { + if (!lastRun) return false; + return (Date.now() - lastRun) <= Math.max(intervalMs * 2, 120_000); + } +} + +export const observabilityService = new ObservabilityService(); diff --git a/backend/src/services/reconciliationExitBackfillService.ts b/backend/src/services/reconciliationExitBackfillService.ts new file mode 100644 index 0000000..2e3e517 --- /dev/null +++ b/backend/src/services/reconciliationExitBackfillService.ts @@ -0,0 +1,953 @@ +import { createHash, randomUUID } from 'crypto'; +import { config } from '../config/index.js'; +import logger from '../utils/logger.js'; +import { normalizeOrderAction, normalizeOrderStatus, normalizeTradeSide } from '../domain/tradingEnums.js'; +import { healthTracker } from './healthTracker.js'; +import { observabilityService } from './observabilityService.js'; +import { + FilledLifecycleOrderRow, + ReconciliationBackfillAuditInsert, + ReconciliationBackfillOrderInsert, + supabaseService +} from './SupabaseService.js'; +import type { TradeExecutor } from './TradeExecutor.js'; +import { + extractOrderSubTag, + isBytelystSubTag, + subTagBelongsToProfile, + subTagHintsTrade +} from '../utils/alpacaSubTag.js'; +import { + buildManagedBotSymbolTokenSet, + isManagedBotSymbol, + normalizeBotSymbolToken +} from '../utils/botSymbolScope.js'; + +const EPSILON = 1e-8; + +type MatchMethod = + | 'trade_id' + | 'sub_tag' + | 'client_order_id' + | 'qty_unique' + | 'single_open_trade'; + +type OpenTradeSlice = { + profileId: string; + userId: string; + symbol: string; + symbolToken: string; + tradeId: string; + entrySide: 'BUY' | 'SELL'; + entryQty: number; + exitQty: number; + openQty: number; + latestTimestampMs: number; +}; + +type ExchangeFillEvidence = { + symbolToken: string; + exchangeOrderId: string; + clientOrderId: string; + tradeIdHint: string; + subTag: string; + side: 'BUY' | 'SELL'; + qty: number; + price: number; + filledAtIso: string; + filledAtMs: number; +}; + +type AssignedEvidence = ExchangeFillEvidence & { + matchedBy: MatchMethod; +}; + +type ProposedBackfillCandidate = { + order: ReconciliationBackfillOrderInsert; + exchangeOrderId: string; + exchangeClientOrderId?: string; + exchangeSubTag?: string; + matchedBy: MatchMethod | 'dust'; +}; + +export interface ReconciliationExitBackfillContext { + profileId: string; + userId: string; + executor: TradeExecutor; +} + +export interface ReconciliationExitBackfillResult { + attempted: boolean; + skippedReason?: string; + batchId?: string; + dryRun: boolean; + openTradeCandidates: number; + proposedRows: number; + insertedRows: number; + noGoTrades: number; + noGoReasonCounts: Record; + noGoSamples: Array<{ + symbol: string; + tradeId: string; + reason: string; + }>; +} + +const toNumber = (value: unknown): number => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +}; + +const toTimestampMs = (value: unknown, fallback: number): number => { + if (typeof value === 'number') { + if (Number.isFinite(value) && value > 1_000_000_000_000) return value; + if (Number.isFinite(value) && value > 0) return value * 1000; + return fallback; + } + if (typeof value === 'string') { + if (/^\d+(\.\d+)?$/.test(value.trim())) { + return toTimestampMs(Number(value.trim()), fallback); + } + const parsed = Date.parse(value); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + return fallback; +}; + +const toIso = (timestampMs: number): string => { + const safeTs = Number.isFinite(timestampMs) && timestampMs > 0 ? timestampMs : Date.now(); + return new Date(safeTs).toISOString(); +}; + +const positionQty = (position: any): number => { + const candidates = [ + position?.qty, + position?.size, + position?.position_qty, + position?.positionQty, + position?.contracts + ]; + for (const candidate of candidates) { + const parsed = Math.abs(toNumber(candidate)); + if (parsed > 0) return parsed; + } + return 0; +}; + +const fillQty = (order: any): number => { + const candidates = [ + order?.filled_qty, + order?.filledQty, + order?.filled_quantity, + order?.filled, + order?.qty, + order?.amount, + order?.size + ]; + for (const candidate of candidates) { + const parsed = toNumber(candidate); + if (parsed > 0) return parsed; + } + return 0; +}; + +const fillPrice = (order: any): number => { + const candidates = [order?.filled_avg_price, order?.avg_price, order?.price, order?.limit_price]; + for (const candidate of candidates) { + const parsed = toNumber(candidate); + if (parsed > 0) return parsed; + } + return 0; +}; + +const filledAtMs = (order: any): number => { + const candidates = [ + order?.filled_at, + order?.filledAt, + order?.updated_at, + order?.closed_at, + order?.timestamp, + order?.submitted_at + ]; + for (const candidate of candidates) { + const parsed = toTimestampMs(candidate, 0); + if (parsed > 0) return parsed; + } + return Date.now(); +}; + +const parseProfileAllowlist = (): Set => { + const values = Array.isArray(config.RECON_EXIT_BACKFILL_PROFILE_ALLOWLIST) + ? config.RECON_EXIT_BACKFILL_PROFILE_ALLOWLIST + : []; + return new Set(values.map((value) => String(value || '').trim()).filter(Boolean)); +}; + +const buildBackfillOrderId = ( + profileId: string, + tradeId: string, + exchangeOrderId: string, + filledAtIso: string +): string => { + const digest = createHash('md5') + .update(`${profileId}:${tradeId}:${exchangeOrderId}:${filledAtIso}`) + .digest('hex'); + return `BFILL-${digest}`; +}; + +const normalizeEvidenceOrders = (symbolKey: string, exchangeOrders: any[]): ExchangeFillEvidence[] => { + const out: ExchangeFillEvidence[] = []; + for (const order of exchangeOrders || []) { + const status = normalizeOrderStatus(String(order?.status || '')); + if (status !== 'filled' && status !== 'partially_filled') continue; + + const qty = fillQty(order); + if (!(qty > EPSILON)) continue; + + const orderSymbolToken = normalizeBotSymbolToken(order?.symbol); + if (orderSymbolToken && orderSymbolToken !== symbolKey) continue; + + const orderId = String(order?.id || order?.order_id || '').trim(); + if (!orderId) continue; + + const side = normalizeTradeSide(String(order?.side || 'BUY')); + const ts = filledAtMs(order); + out.push({ + symbolToken: symbolKey, + exchangeOrderId: orderId, + clientOrderId: String(order?.client_order_id || order?.clientOrderId || '').trim(), + tradeIdHint: String(order?.trade_id || order?.tradeId || '').trim(), + subTag: extractOrderSubTag(order), + side, + qty, + price: fillPrice(order), + filledAtIso: toIso(ts), + filledAtMs: ts + }); + } + + return out.sort((a, b) => a.filledAtMs - b.filledAtMs); +}; + +const buildOpenTradeSlices = (profileId: string, rows: FilledLifecycleOrderRow[]): OpenTradeSlice[] => { + type Ledger = { + profileId: string; + userId: string; + symbol: string; + symbolToken: string; + tradeId: string; + entrySide: 'BUY' | 'SELL'; + entryQty: number; + exitQty: number; + latestTimestampMs: number; + }; + + const ledgerByTrade = new Map(); + + const sorted = [...rows].sort((a, b) => { + const aTs = toTimestampMs(a.timestamp ?? a.created_at ?? 0, 0); + const bTs = toTimestampMs(b.timestamp ?? b.created_at ?? 0, 0); + return aTs - bTs; + }); + + for (const row of sorted) { + const tradeId = String(row.trade_id || '').trim(); + if (!tradeId) continue; + + const symbol = String(row.symbol || '').trim(); + if (!symbol) continue; + + const qty = toNumber(row.qty ?? row.quantity); + if (!(qty > EPSILON)) continue; + + const side = normalizeTradeSide(String(row.side || 'BUY')); + const explicitAction = normalizeOrderAction(row.action || undefined); + const key = `${profileId}::${tradeId}`; + const ts = toTimestampMs(row.timestamp ?? row.created_at ?? 0, Date.now()); + + let ledger = ledgerByTrade.get(key); + if (!ledger) { + ledger = { + profileId, + userId: String(row.user_id || '').trim(), + symbol, + symbolToken: normalizeBotSymbolToken(symbol), + tradeId, + entrySide: side, + entryQty: 0, + exitQty: 0, + latestTimestampMs: ts + }; + ledgerByTrade.set(key, ledger); + } + + const action = explicitAction || (side === ledger.entrySide ? 'ENTRY' : 'EXIT'); + if (action === 'ENTRY') { + if (!(ledger.entryQty > EPSILON)) { + ledger.entrySide = side; + } + ledger.entryQty += qty; + } else { + ledger.exitQty += qty; + } + ledger.latestTimestampMs = Math.max(ledger.latestTimestampMs, ts); + if (!ledger.userId) { + ledger.userId = String(row.user_id || '').trim(); + } + } + + const out: OpenTradeSlice[] = []; + for (const ledger of ledgerByTrade.values()) { + const openQty = Number((ledger.entryQty - ledger.exitQty).toFixed(8)); + if (!(openQty > EPSILON)) continue; + out.push({ + profileId: ledger.profileId, + userId: ledger.userId, + symbol: ledger.symbol, + symbolToken: ledger.symbolToken, + tradeId: ledger.tradeId, + entrySide: ledger.entrySide, + entryQty: ledger.entryQty, + exitQty: ledger.exitQty, + openQty, + latestTimestampMs: ledger.latestTimestampMs + }); + } + return out; +}; + +const expectedExitSide = (entrySide: 'BUY' | 'SELL'): 'BUY' | 'SELL' => { + return entrySide === 'BUY' ? 'SELL' : 'BUY'; +}; + +const startsWithCaseInsensitive = (value: string, prefix: string): boolean => { + return value.slice(0, prefix.length).toLowerCase() === prefix.toLowerCase(); +}; + +const parseTradeFromClientOrderId = ( + profileId: string, + clientOrderIdRaw: string +): { tradeId: string } | null => { + const clientOrderId = String(clientOrderIdRaw || '').trim(); + if (!clientOrderId) return null; + const prefix = `bytelyst-${profileId}-`; + if (!startsWithCaseInsensitive(clientOrderId, prefix)) return null; + + const suffix = clientOrderId.slice(prefix.length).trim(); + if (!suffix) return null; + + const lower = suffix.toLowerCase(); + if (lower.endsWith('-exit')) { + const tradeId = suffix.slice(0, -5).trim(); + return tradeId ? { tradeId } : null; + } + if (lower.endsWith('-entry')) { + const tradeId = suffix.slice(0, -6).trim(); + return tradeId ? { tradeId } : null; + } + return { tradeId: suffix }; +}; + +const dustThresholdForTrade = (openQty: number): number => { + const absThreshold = Math.max(0, toNumber(config.RECON_EXIT_BACKFILL_DUST_ABS_QTY)); + const relThreshold = Math.max(0, toNumber(config.RECON_EXIT_BACKFILL_DUST_REL_PCT)) * Math.max(openQty, 0); + return Math.max(absThreshold, relThreshold); +}; + +const summarizeNoGoReasons = (rows: ReconciliationBackfillAuditInsert[]): Record => { + const summary: Record = {}; + for (const row of rows || []) { + const key = String(row?.reason || 'unknown').trim() || 'unknown'; + summary[key] = (summary[key] || 0) + 1; + } + return summary; +}; + +const noGoSamples = (rows: ReconciliationBackfillAuditInsert[], limit = 5): Array<{ symbol: string; tradeId: string; reason: string }> => { + return (rows || []) + .slice(0, Math.max(0, limit)) + .map((row) => ({ + symbol: String(row?.symbol || '').trim(), + tradeId: String(row?.trade_id || '').trim(), + reason: String(row?.reason || '').trim() || 'unknown' + })); +}; + +const isTradeTemporallyEligible = ( + trade: OpenTradeSlice, + row: ExchangeFillEvidence, + graceMs: number +): boolean => { + const tradeTs = toNumber(trade.latestTimestampMs); + const rowTs = toNumber(row.filledAtMs); + if (!(tradeTs > 0) || !(rowTs > 0)) return true; + const safeGraceMs = Math.max(0, Math.floor(graceMs)); + return rowTs + safeGraceMs >= tradeTs; +}; + +const allocateEvidence = ( + trades: OpenTradeSlice[], + evidence: ExchangeFillEvidence[], + profileId: string, + options: { + allowHeuristicMatch: boolean; + fillAfterTradeGraceMs: number; + } +): { + byTrade: Map; + unmatched: ExchangeFillEvidence[]; +} => { + const byTrade = new Map(); + const unmatched: ExchangeFillEvidence[] = []; + const remainingByTrade = new Map(); + const tradeById = new Map(); + trades.forEach((trade) => { + remainingByTrade.set(trade.tradeId, trade.openQty); + tradeById.set(trade.tradeId, trade); + }); + + const assign = (tradeId: string, row: ExchangeFillEvidence, matchedBy: MatchMethod): boolean => { + const trade = tradeById.get(tradeId); + if (!trade) return false; + const expectedSide = expectedExitSide(trade.entrySide); + if (row.side !== expectedSide) return false; + if (!isTradeTemporallyEligible(trade, row, options.fillAfterTradeGraceMs)) return false; + + const remaining = toNumber(remainingByTrade.get(tradeId)); + if (!(remaining > EPSILON)) return false; + + const list = byTrade.get(tradeId) || []; + list.push({ ...row, matchedBy }); + byTrade.set(tradeId, list); + remainingByTrade.set(tradeId, Math.max(0, remaining - row.qty)); + return true; + }; + + const unassigned = [...evidence]; + + // Pass 1: explicit trade_id from exchange order payload. + for (let i = unassigned.length - 1; i >= 0; i--) { + const row = unassigned[i]; + const hinted = String(row.tradeIdHint || '').trim(); + if (!hinted) continue; + if (assign(hinted, row, 'trade_id')) { + unassigned.splice(i, 1); + } + } + + // Pass 2: bytelyst sub-tag carries deterministic trade token. + for (let i = unassigned.length - 1; i >= 0; i--) { + const row = unassigned[i]; + if (!row.subTag || !isBytelystSubTag(row.subTag)) continue; + if (!subTagBelongsToProfile(row.subTag, profileId)) continue; + const matchingTrades = trades.filter((trade) => subTagHintsTrade(row.subTag, trade.tradeId)); + if (matchingTrades.length !== 1) continue; + if (assign(matchingTrades[0].tradeId, row, 'sub_tag')) { + unassigned.splice(i, 1); + } + } + + // Pass 3: client_order_id contains trade_id marker. + for (let i = unassigned.length - 1; i >= 0; i--) { + const row = unassigned[i]; + const resolved = parseTradeFromClientOrderId(profileId, row.clientOrderId); + if (!resolved?.tradeId) continue; + if (assign(resolved.tradeId, row, 'client_order_id')) { + unassigned.splice(i, 1); + } + } + + // Pass 4: if multiple trades are open, assign only when a unique qty match exists. + if (options.allowHeuristicMatch) { + for (let i = unassigned.length - 1; i >= 0; i--) { + const row = unassigned[i]; + const candidates = trades.filter((trade) => { + if (row.side !== expectedExitSide(trade.entrySide)) return false; + if (!isTradeTemporallyEligible(trade, row, options.fillAfterTradeGraceMs)) return false; + const remaining = toNumber(remainingByTrade.get(trade.tradeId)); + if (!(remaining > EPSILON)) return false; + const threshold = dustThresholdForTrade(remaining); + return Math.abs(remaining - row.qty) <= threshold; + }); + if (candidates.length !== 1) continue; + if (assign(candidates[0].tradeId, row, 'qty_unique')) { + unassigned.splice(i, 1); + } + } + + // Pass 5: safe fallback for single-trade symbols. + if (trades.length === 1 && unassigned.length > 0) { + const tradeId = trades[0].tradeId; + for (let i = unassigned.length - 1; i >= 0; i--) { + const row = unassigned[i]; + if (assign(tradeId, row, 'single_open_trade')) { + unassigned.splice(i, 1); + } + } + } + } + + unmatched.push(...unassigned); + return { byTrade, unmatched }; +}; + +export class ReconciliationExitBackfillService { + async runProfile(ctx: ReconciliationExitBackfillContext): Promise { + const dryRun = Boolean(config.RECON_EXIT_BACKFILL_DRY_RUN); + const requireStrongAttribution = Boolean(config.RECON_EXIT_BACKFILL_REQUIRE_STRONG_ATTRIBUTION); + const allowHeuristicMatch = Boolean(config.RECON_EXIT_BACKFILL_ALLOW_HEURISTIC_MATCH); + const fillAfterTradeGraceMs = Math.max( + 0, + Math.floor(Number(config.RECON_EXIT_BACKFILL_FILL_AFTER_TRADE_GRACE_MINUTES || 5)) * 60 * 1000 + ); + const profileAllowlist = parseProfileAllowlist(); + const profileId = String(ctx.profileId || '').trim(); + if (!config.ENABLE_RECON_EXIT_BACKFILL) { + return { + attempted: false, + skippedReason: 'disabled', + dryRun, + openTradeCandidates: 0, + proposedRows: 0, + insertedRows: 0, + noGoTrades: 0, + noGoReasonCounts: {}, + noGoSamples: [] + }; + } + + if (profileAllowlist.size > 0 && !profileAllowlist.has(profileId)) { + return { + attempted: false, + skippedReason: 'profile_not_allowlisted', + dryRun, + openTradeCandidates: 0, + proposedRows: 0, + insertedRows: 0, + noGoTrades: 0, + noGoReasonCounts: {}, + noGoSamples: [] + }; + } + + if (config.RECON_EXIT_BACKFILL_REQUIRE_PAUSE && !healthTracker.isPaused()) { + return { + attempted: false, + skippedReason: 'pause_required', + dryRun, + openTradeCandidates: 0, + proposedRows: 0, + insertedRows: 0, + noGoTrades: 0, + noGoReasonCounts: {}, + noGoSamples: [] + }; + } + + const auditReady = await supabaseService.isReconciliationBackfillAuditAvailable(); + if (!auditReady) { + observabilityService.emitEvent({ + type: 'SYSTEM_ERROR', + severity: 'ERROR', + message: `Reconciliation EXIT backfill aborted: audit table missing for profile ${profileId}`, + profileId + }); + return { + attempted: false, + skippedReason: 'audit_table_missing', + dryRun, + openTradeCandidates: 0, + proposedRows: 0, + insertedRows: 0, + noGoTrades: 0, + noGoReasonCounts: {}, + noGoSamples: [] + }; + } + + const lifecycleRows = await supabaseService.getFilledLifecycleOrdersForProfile(profileId); + const openTrades = buildOpenTradeSlices(profileId, lifecycleRows); + const managedSymbolTokens = buildManagedBotSymbolTokenSet(); + const scopedOpenTrades = openTrades.filter((trade) => { + if (managedSymbolTokens.has(trade.symbolToken)) return true; + return isManagedBotSymbol(trade.symbol, managedSymbolTokens); + }); + + if (openTrades.length !== scopedOpenTrades.length) { + logger.info('[ReconcileBackfill] Skipped non-bot open trades', { + event: 'reconciliation_backfill_symbol_scope_filtered', + profileId, + dropped: openTrades.length - scopedOpenTrades.length + }); + } + + if (scopedOpenTrades.length === 0) { + return { + attempted: true, + dryRun, + openTradeCandidates: 0, + proposedRows: 0, + insertedRows: 0, + noGoTrades: 0, + noGoReasonCounts: {}, + noGoSamples: [] + }; + } + + const pendingRows = await supabaseService.getOpenOrdersForProfile(profileId); + const pendingTradeIds = new Set( + (pendingRows || []) + .map((row) => String((row as any)?.trade_id || '').trim()) + .filter(Boolean) + ); + + const batchId = `RECON-BFILL-${randomUUID()}`; + const proposedRows: ProposedBackfillCandidate[] = []; + const noGoAuditRows: ReconciliationBackfillAuditInsert[] = []; + const advisoryAuditRows: ReconciliationBackfillAuditInsert[] = []; + + const tradesBySymbol = new Map(); + for (const trade of scopedOpenTrades) { + const key = trade.symbolToken; + const list = tradesBySymbol.get(key) || []; + list.push(trade); + tradesBySymbol.set(key, list); + } + + for (const tradesForSymbol of tradesBySymbol.values()) { + const canonicalSymbol = tradesForSymbol[0].symbol; + const symbolKey = tradesForSymbol[0].symbolToken; + + const exchangePosition = await ctx.executor.fetchExchangePosition(canonicalSymbol); + const exchangeOpenQty = positionQty(exchangePosition); + const symbolIsFlat = exchangeOpenQty <= Math.max(0, toNumber(config.RECON_EXIT_BACKFILL_DUST_ABS_QTY)); + + const exchangeClosed = await ctx.executor.fetchExchangeClosedOrders( + [canonicalSymbol], + config.RECON_EXIT_BACKFILL_LOOKBACK_HOURS, + { + limitPerPage: Math.max(1, Math.min(500, Math.floor(Number(config.RECON_ORDER_COVERAGE_FETCH_LIMIT_PER_PAGE || 500)))), + maxPages: Math.max(1, Math.min(100, Math.floor(Number(config.RECON_ORDER_COVERAGE_MAX_FETCH_PAGES || 8)))) + } + ); + const evidence = normalizeEvidenceOrders(symbolKey, exchangeClosed); + let droppedForeignSubTag = 0; + let droppedUnattributed = 0; + let droppedMalformedSubTag = 0; + const scopedEvidence = evidence.filter((row) => { + const hasTradeHint = String(row.tradeIdHint || '').trim().length > 0; + const clientCorrelation = parseTradeFromClientOrderId(profileId, row.clientOrderId); + + if (row.subTag) { + if (!isBytelystSubTag(row.subTag)) { + droppedMalformedSubTag += 1; + if (requireStrongAttribution && !hasTradeHint && !clientCorrelation?.tradeId) { + droppedUnattributed += 1; + return false; + } + return !requireStrongAttribution || hasTradeHint || !!clientCorrelation?.tradeId; + } + + if (!subTagBelongsToProfile(row.subTag, profileId)) { + droppedForeignSubTag += 1; + return false; + } + } + + const attributed = hasTradeHint + || !!clientCorrelation?.tradeId + || (row.subTag && isBytelystSubTag(row.subTag) && subTagBelongsToProfile(row.subTag, profileId)); + + if (requireStrongAttribution && !attributed) { + droppedUnattributed += 1; + return false; + } + return true; + }); + const droppedEvidence = evidence.length - scopedEvidence.length; + if (droppedEvidence > 0) { + logger.info('[ReconcileBackfill] Scoped exchange evidence by attribution/sub-tag', { + event: 'reconciliation_backfill_subtag_scope', + profileId, + symbol: canonicalSymbol, + totalEvidence: evidence.length, + keptEvidence: scopedEvidence.length, + droppedEvidence, + droppedForeignSubTag, + droppedMalformedSubTag, + droppedUnattributed, + requireStrongAttribution + }); + } + const allocation = allocateEvidence(tradesForSymbol, scopedEvidence, profileId, { + allowHeuristicMatch, + fillAfterTradeGraceMs + }); + + for (const trade of tradesForSymbol) { + const tradePending = pendingTradeIds.has(trade.tradeId); + if (tradePending) { + advisoryAuditRows.push({ + batch_id: batchId, + profile_id: profileId, + symbol: trade.symbol, + trade_id: trade.tradeId, + dry_run: dryRun, + decision: 'SKIP_PENDING_ORDER', + reason: 'pending_order_blocker', + metadata: { + openQty: trade.openQty + } + }); + continue; + } + + const assigned = (allocation.byTrade.get(trade.tradeId) || []) + .sort((a, b) => a.filledAtMs - b.filledAtMs); + const threshold = dustThresholdForTrade(trade.openQty); + let remaining = trade.openQty; + let usedEvidenceRows = 0; + + for (const row of assigned) { + if (!(remaining > EPSILON)) break; + const applyQty = Math.min(remaining, row.qty); + if (!(applyQty > EPSILON)) continue; + const backfillOrderId = buildBackfillOrderId( + profileId, + trade.tradeId, + row.exchangeOrderId, + row.filledAtIso + ); + proposedRows.push({ + order: { + user_id: trade.userId || ctx.userId, + profile_id: profileId, + order_id: backfillOrderId, + symbol: trade.symbol, + type: 'market', + side: row.side, + qty: Number(applyQty.toFixed(8)), + quantity: Number(applyQty.toFixed(8)), + price: Number(row.price.toFixed(8)), + status: 'filled', + timestamp: row.filledAtMs, + filled_at: row.filledAtIso, + trade_id: trade.tradeId, + action: 'EXIT', + source: 'BOT', + sub_tag: row.subTag || undefined + }, + exchangeOrderId: row.exchangeOrderId, + exchangeClientOrderId: row.clientOrderId || undefined, + exchangeSubTag: row.subTag || undefined, + matchedBy: row.matchedBy + }); + remaining = Number((remaining - applyQty).toFixed(8)); + usedEvidenceRows += 1; + } + + if (remaining > EPSILON) { + if (remaining <= threshold && symbolIsFlat) { + const dustFilledAtIso = assigned.length > 0 + ? assigned[assigned.length - 1].filledAtIso + : toIso(trade.latestTimestampMs); + const dustTs = assigned.length > 0 + ? assigned[assigned.length - 1].filledAtMs + : trade.latestTimestampMs; + const dustExchangeOrderId = `DUST-REMAINDER-${trade.tradeId}`; + const backfillOrderId = buildBackfillOrderId( + profileId, + trade.tradeId, + dustExchangeOrderId, + dustFilledAtIso + ); + + proposedRows.push({ + order: { + user_id: trade.userId || ctx.userId, + profile_id: profileId, + order_id: backfillOrderId, + symbol: trade.symbol, + type: 'market', + side: expectedExitSide(trade.entrySide), + qty: Number(remaining.toFixed(8)), + quantity: Number(remaining.toFixed(8)), + price: Number((assigned[assigned.length - 1]?.price || 0).toFixed(8)), + status: 'filled', + timestamp: dustTs, + filled_at: dustFilledAtIso, + trade_id: trade.tradeId, + action: 'EXIT', + source: 'BOT', + sub_tag: assigned[assigned.length - 1]?.subTag || undefined + }, + exchangeOrderId: dustExchangeOrderId, + exchangeSubTag: assigned[assigned.length - 1]?.subTag || undefined, + matchedBy: 'dust' + }); + remaining = 0; + } else { + const reason = !symbolIsFlat && usedEvidenceRows === 0 + ? `exchange_not_flat:${exchangeOpenQty}` + : (!symbolIsFlat && remaining <= threshold) + ? 'dust_remainder_blocked_exchange_not_flat' + : 'missing_fill_evidence_for_large_remainder'; + + const isAdvisoryBlockedActive = + reason.startsWith('exchange_not_flat:') + || reason === 'dust_remainder_blocked_exchange_not_flat'; + + const destination = isAdvisoryBlockedActive + ? advisoryAuditRows + : noGoAuditRows; + + destination.push({ + batch_id: batchId, + profile_id: profileId, + symbol: trade.symbol, + trade_id: trade.tradeId, + dry_run: dryRun, + decision: isAdvisoryBlockedActive ? 'SKIP_ACTIVE_POSITION' : 'NO_GO', + reason, + metadata: { + openQty: trade.openQty, + evidenceRows: assigned.length, + evidenceRowsUsed: usedEvidenceRows, + remaining, + dustThreshold: threshold, + unmatchedEvidenceCount: allocation.unmatched.length, + exchangeOpenQty, + symbolIsFlat, + requireStrongAttribution, + allowHeuristicMatch, + fillAfterTradeGraceMs + } + }); + } + } + } + } + + const proposedOrderIds = proposedRows.map((row) => row.order.order_id); + const existingBefore = await supabaseService.getExistingOrderIds(proposedOrderIds, profileId); + const baseAuditRows: ReconciliationBackfillAuditInsert[] = proposedRows.map((row) => ({ + batch_id: batchId, + profile_id: profileId, + symbol: row.order.symbol, + trade_id: row.order.trade_id, + exchange_order_id: row.exchangeOrderId, + exchange_client_order_id: row.exchangeClientOrderId || null, + backfill_order_id: row.order.order_id, + filled_qty: row.order.qty, + filled_price: row.order.price, + filled_at: row.order.filled_at || null, + dry_run: dryRun, + decision: dryRun ? 'DRY_RUN' : 'PENDING_APPLY', + reason: existingBefore.has(row.order.order_id) ? 'already_exists' : 'eligible', + metadata: { + action: row.order.action, + status: row.order.status, + matchedBy: row.matchedBy, + exchangeSubTag: row.exchangeSubTag || null + } + })); + + const preAuditRows = dryRun + ? [...baseAuditRows, ...noGoAuditRows, ...advisoryAuditRows] + : [...baseAuditRows, ...noGoAuditRows, ...advisoryAuditRows]; + const preAuditSaved = await supabaseService.insertReconciliationBackfillAuditRows(preAuditRows); + if (!preAuditSaved) { + return { + attempted: true, + batchId, + dryRun, + openTradeCandidates: scopedOpenTrades.length, + proposedRows: proposedRows.length, + insertedRows: 0, + noGoTrades: noGoAuditRows.length, + noGoReasonCounts: summarizeNoGoReasons(noGoAuditRows), + noGoSamples: noGoSamples(noGoAuditRows) + }; + } + + let insertedRows = 0; + if (!dryRun && proposedRows.length > 0) { + const applyOk = await supabaseService.upsertReconciliationBackfillOrders(proposedRows.map((row) => row.order)); + if (!applyOk) { + observabilityService.emitEvent({ + type: 'SYSTEM_ERROR', + severity: 'ERROR', + message: `Reconciliation EXIT backfill apply failed for profile ${profileId}. Batch ${batchId}`, + profileId + }); + return { + attempted: true, + batchId, + dryRun, + openTradeCandidates: scopedOpenTrades.length, + proposedRows: proposedRows.length, + insertedRows: 0, + noGoTrades: noGoAuditRows.length, + noGoReasonCounts: summarizeNoGoReasons(noGoAuditRows), + noGoSamples: noGoSamples(noGoAuditRows) + }; + } + + const existingAfter = await supabaseService.getExistingOrderIds(proposedOrderIds, profileId); + insertedRows = proposedRows.filter((row) => !existingBefore.has(row.order.order_id) && existingAfter.has(row.order.order_id)).length; + + const postAuditRows: ReconciliationBackfillAuditInsert[] = proposedRows.map((row) => ({ + batch_id: batchId, + profile_id: profileId, + symbol: row.order.symbol, + trade_id: row.order.trade_id, + exchange_order_id: row.exchangeOrderId, + exchange_client_order_id: row.exchangeClientOrderId || null, + backfill_order_id: row.order.order_id, + filled_qty: row.order.qty, + filled_price: row.order.price, + filled_at: row.order.filled_at || null, + dry_run: false, + decision: existingBefore.has(row.order.order_id) ? 'SKIP_EXISTING' : 'APPLIED', + reason: existingBefore.has(row.order.order_id) ? 'already_exists' : 'inserted', + metadata: { + action: row.order.action, + status: row.order.status, + matchedBy: row.matchedBy, + exchangeSubTag: row.exchangeSubTag || null + }, + applied_at: !existingBefore.has(row.order.order_id) ? new Date().toISOString() : null + })); + const postSaved = await supabaseService.insertReconciliationBackfillAuditRows(postAuditRows); + if (!postSaved) { + logger.error(`[ReconcileBackfill] Failed to persist post-apply audit rows for batch ${batchId}`); + } + } + + logger.info('[ReconcileBackfill] EXIT backfill evaluated', { + event: 'reconciliation_exit_backfill', + profileId, + batchId, + dryRun, + openTradeCandidates: scopedOpenTrades.length, + proposedRows: proposedRows.length, + insertedRows, + noGoTrades: noGoAuditRows.length, + advisoryBlockedTrades: advisoryAuditRows.length + }); + + return { + attempted: true, + batchId, + dryRun, + openTradeCandidates: scopedOpenTrades.length, + proposedRows: proposedRows.length, + insertedRows, + noGoTrades: noGoAuditRows.length, + noGoReasonCounts: summarizeNoGoReasons(noGoAuditRows), + noGoSamples: noGoSamples(noGoAuditRows) + }; + } +} + +export const reconciliationExitBackfillService = new ReconciliationExitBackfillService(); diff --git a/backend/src/services/reconciliationOrderCoverageService.ts b/backend/src/services/reconciliationOrderCoverageService.ts new file mode 100644 index 0000000..93b146d --- /dev/null +++ b/backend/src/services/reconciliationOrderCoverageService.ts @@ -0,0 +1,577 @@ +import { config } from '../config/index.js'; +import { + normalizeOrderAction, + normalizeOrderStatus, + normalizeTradeSide +} from '../domain/tradingEnums.js'; +import logger from '../utils/logger.js'; +import { SymbolMapper } from '../utils/symbolMapper.js'; +import { + extractOrderSubTag, + isBytelystSubTag, + subTagBelongsToProfile, + subTagHintsTrade +} from '../utils/alpacaSubTag.js'; +import { healthTracker } from './healthTracker.js'; +import { observabilityService } from './observabilityService.js'; +import { supabaseService } from './SupabaseService.js'; +import type { TradeExecutor } from './TradeExecutor.js'; + +type CoverageAction = 'ENTRY' | 'EXIT'; + +type MissingOrderCandidate = { + orderId: string; + timestampMs: number; + payload: { + user_id: string; + profile_id: string; + order_id: string; + symbol: string; + type: string; + side: string; + qty: number; + price: number; + status: string; + timestamp: number; + trade_id: string; + action: CoverageAction; + sub_tag?: string; + }; +}; + +type UnattributedOrderSample = { + orderId: string; + symbol: string; + side: string; + clientOrderId: string; + subTag: string; + submittedAt: string; + timestampMs: number; +}; + +export interface ReconciliationOrderCoverageContext { + profileId: string; + userId: string; + executor: TradeExecutor; +} + +export interface ReconciliationOrderCoverageResult { + attempted: boolean; + skippedReason?: string; + dryRun: boolean; + scannedOrders: number; + filledLikeOrders: number; + botOwnedOrders: number; + eligibleOrders: number; + missingInDb: number; + insertedRows: number; + skippedNotBotOwned: number; + skippedNotBotOwnedActionable: number; + skippedNotBotOwnedLegacyInDb: number; + skippedNotBotOwnedBeforeBaseline: number; + skippedUnmappedTrade: number; + skippedUnmappedAction: number; + skippedMissingFillData: number; + skippedMissingOrderId: number; + skippedExisting: number; + skippedMaxInsertLimit: number; +} + +const toNumber = (value: unknown): number => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +}; + +const resolveEpochMs = (value: unknown): number => { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) return 0; + const normalized = parsed > 1_000_000_000_000 ? parsed : parsed * 1000; + return Math.floor(normalized); +}; + +const resolveTimestampMs = (order: any): number => { + const candidates = [ + order?.filled_at, + order?.filledAt, + order?.submitted_at, + order?.submittedAt, + order?.updated_at, + order?.timestamp + ]; + for (const candidate of candidates) { + if (typeof candidate === 'number' && Number.isFinite(candidate) && candidate > 0) { + return candidate > 1_000_000_000_000 ? candidate : candidate * 1000; + } + if (typeof candidate === 'string') { + const parsed = Date.parse(candidate); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + const numeric = Number(candidate); + if (Number.isFinite(numeric) && numeric > 0) { + return numeric > 1_000_000_000_000 ? numeric : numeric * 1000; + } + } + } + return Date.now(); +}; + +const resolveOrderId = (order: any): string => { + return String(order?.id || order?.order_id || '').trim(); +}; + +const resolveClientOrderId = (order: any): string => { + return String(order?.client_order_id || order?.clientOrderId || '').trim(); +}; + +const resolveFilledQty = (order: any): number => { + const candidates = [ + order?.filled_qty, + order?.filledQty, + order?.filled_quantity, + order?.qty + ]; + for (const candidate of candidates) { + const parsed = toNumber(candidate); + if (parsed > 0) return parsed; + } + return 0; +}; + +const resolveFilledPrice = (order: any): number => { + const candidates = [ + order?.filled_avg_price, + order?.avg_price, + order?.price, + order?.limit_price + ]; + for (const candidate of candidates) { + const parsed = toNumber(candidate); + if (parsed > 0) return parsed; + } + return 0; +}; + +const startsWithCaseInsensitive = (value: string, prefix: string): boolean => { + return value.slice(0, prefix.length).toLowerCase() === prefix.toLowerCase(); +}; + +const parseTradeFromClientOrderId = ( + profileId: string, + clientOrderIdRaw: string +): { tradeId: string; action?: CoverageAction } | null => { + const clientOrderId = String(clientOrderIdRaw || '').trim(); + if (!clientOrderId) return null; + const prefix = `bytelyst-${profileId}-`; + if (!startsWithCaseInsensitive(clientOrderId, prefix)) return null; + + const tradeToken = clientOrderId.slice(prefix.length).trim(); + if (!tradeToken) return null; + + const lowerToken = tradeToken.toLowerCase(); + if (lowerToken.endsWith('-exit')) { + const tradeId = tradeToken.slice(0, -5).trim(); + if (!tradeId) return null; + return { tradeId, action: 'EXIT' }; + } + if (lowerToken.endsWith('-entry')) { + const tradeId = tradeToken.slice(0, -6).trim(); + if (!tradeId) return null; + return { tradeId, action: 'ENTRY' }; + } + + return { + tradeId: tradeToken, + action: 'ENTRY' + }; +}; + +const resolveTradeFromSubTag = ( + subTag: string, + profileId: string, + knownTradeIds: string[] +): string => { + if (!subTag || !isBytelystSubTag(subTag)) return ''; + if (!subTagBelongsToProfile(subTag, profileId)) return ''; + + let match = ''; + let matchCount = 0; + for (const tradeId of knownTradeIds) { + if (!subTagHintsTrade(subTag, tradeId)) continue; + match = tradeId; + matchCount += 1; + if (matchCount > 1) return ''; + } + return matchCount === 1 ? match : ''; +}; + +export class ReconciliationOrderCoverageService { + private fetchCapAlertByProfile = new Map(); + private unattributedAlertByScope = new Map(); + + private shouldEmitFetchCapAlert(profileId: string): boolean { + const key = String(profileId || '').trim() || 'global'; + const now = Date.now(); + const throttleMs = Math.max(0, Number(config.RECONCILIATION_SLO_ALERT_THROTTLE_MS || 600_000)); + const last = this.fetchCapAlertByProfile.get(key) || 0; + if (throttleMs > 0 && (now - last) < throttleMs) return false; + this.fetchCapAlertByProfile.set(key, now); + return true; + } + + private shouldEmitUnattributedAlert(scope: string): boolean { + const key = String(scope || '').trim() || 'global'; + const now = Date.now(); + const throttleMs = Math.max(0, Number(config.RECONCILIATION_SLO_ALERT_THROTTLE_MS || 600_000)); + const last = this.unattributedAlertByScope.get(key) || 0; + if (throttleMs > 0 && (now - last) < throttleMs) return false; + this.unattributedAlertByScope.set(key, now); + return true; + } + + async runProfile(ctx: ReconciliationOrderCoverageContext): Promise { + const dryRun = Boolean(config.RECON_ORDER_COVERAGE_DRY_RUN); + if (!config.ENABLE_RECON_ORDER_COVERAGE_SYNC) { + return { + attempted: false, + skippedReason: 'disabled', + dryRun, + scannedOrders: 0, + filledLikeOrders: 0, + botOwnedOrders: 0, + eligibleOrders: 0, + missingInDb: 0, + insertedRows: 0, + skippedNotBotOwned: 0, + skippedNotBotOwnedActionable: 0, + skippedNotBotOwnedLegacyInDb: 0, + skippedNotBotOwnedBeforeBaseline: 0, + skippedUnmappedTrade: 0, + skippedUnmappedAction: 0, + skippedMissingFillData: 0, + skippedMissingOrderId: 0, + skippedExisting: 0, + skippedMaxInsertLimit: 0 + }; + } + + if (config.RECON_ORDER_COVERAGE_REQUIRE_PAUSE && !healthTracker.isPaused()) { + return { + attempted: false, + skippedReason: 'requires_pause', + dryRun, + scannedOrders: 0, + filledLikeOrders: 0, + botOwnedOrders: 0, + eligibleOrders: 0, + missingInDb: 0, + insertedRows: 0, + skippedNotBotOwned: 0, + skippedNotBotOwnedActionable: 0, + skippedNotBotOwnedLegacyInDb: 0, + skippedNotBotOwnedBeforeBaseline: 0, + skippedUnmappedTrade: 0, + skippedUnmappedAction: 0, + skippedMissingFillData: 0, + skippedMissingOrderId: 0, + skippedExisting: 0, + skippedMaxInsertLimit: 0 + }; + } + + const lookbackHours = Math.max(1, Math.floor(Number(config.RECON_ORDER_COVERAGE_LOOKBACK_HOURS || 72))); + const fetchLimitPerPage = Math.max(1, Math.min(500, Math.floor(Number(config.RECON_ORDER_COVERAGE_FETCH_LIMIT_PER_PAGE || 500)))); + const fetchMaxPages = Math.max(1, Math.min(100, Math.floor(Number(config.RECON_ORDER_COVERAGE_MAX_FETCH_PAGES || 8)))); + const maxInserts = Math.max(1, Math.floor(Number(config.RECON_ORDER_COVERAGE_MAX_INSERTS_PER_PROFILE || 200))); + const tradeIdLookbackRows = Math.max(100, Math.floor(Number(config.RECON_ORDER_COVERAGE_TRADE_ID_LOOKBACK_ROWS || 2000))); + const requireSubTagAttribution = Boolean(config.RECON_ORDER_COVERAGE_REQUIRE_SUBTAG_ATTRIBUTION); + const unattributedBaselineMs = Math.max(0, resolveEpochMs(config.RECON_ORDER_COVERAGE_UNATTRIBUTED_BASELINE_MS)); + const exchangeClosedOrders = await ctx.executor.fetchExchangeClosedOrders(undefined, lookbackHours, { + limitPerPage: fetchLimitPerPage, + maxPages: fetchMaxPages + }); + const knownTradeIds = await supabaseService.getKnownTradeIdsForProfile(ctx.profileId, tradeIdLookbackRows); + const candidateByOrderId = new Map(); + const unattributedRows: UnattributedOrderSample[] = []; + + const result: ReconciliationOrderCoverageResult = { + attempted: true, + dryRun, + scannedOrders: exchangeClosedOrders.length, + filledLikeOrders: 0, + botOwnedOrders: 0, + eligibleOrders: 0, + missingInDb: 0, + insertedRows: 0, + skippedNotBotOwned: 0, + skippedNotBotOwnedActionable: 0, + skippedNotBotOwnedLegacyInDb: 0, + skippedNotBotOwnedBeforeBaseline: 0, + skippedUnmappedTrade: 0, + skippedUnmappedAction: 0, + skippedMissingFillData: 0, + skippedMissingOrderId: 0, + skippedExisting: 0, + skippedMaxInsertLimit: 0 + }; + + const approximateFetchCap = fetchLimitPerPage * fetchMaxPages; + if (exchangeClosedOrders.length >= approximateFetchCap) { + logger.warn('[ReconcileCoverage] Exchange closed-order fetch may be at page cap', { + event: 'reconciliation_order_coverage_fetch_cap_reached', + profileId: ctx.profileId, + userId: ctx.userId, + scannedOrders: exchangeClosedOrders.length, + lookbackHours, + fetchLimitPerPage, + fetchMaxPages + }); + if (this.shouldEmitFetchCapAlert(ctx.profileId)) { + observabilityService.emitEvent({ + type: 'RECONCILIATION_DEGRADED', + severity: 'WARN', + message: `Closed-order coverage fetch reached cap (${approximateFetchCap}) for profile ${ctx.profileId}. Consider increasing RECON_ORDER_COVERAGE_MAX_FETCH_PAGES.`, + profileId: ctx.profileId, + userId: ctx.userId + }); + } + } + + for (const order of exchangeClosedOrders || []) { + const status = normalizeOrderStatus(String(order?.status || '')); + if (status !== 'filled' && status !== 'partially_filled') continue; + result.filledLikeOrders += 1; + + const orderId = resolveOrderId(order); + if (!orderId) { + result.skippedMissingOrderId += 1; + continue; + } + + const side = normalizeTradeSide(String(order?.side || 'BUY')); + const subTag = extractOrderSubTag(order); + const clientOrderId = resolveClientOrderId(order); + const timestampMs = resolveTimestampMs(order); + const clientCorrelation = parseTradeFromClientOrderId(ctx.profileId, clientOrderId); + const ownedBySubTag = !!(subTag && isBytelystSubTag(subTag) && subTagBelongsToProfile(subTag, ctx.profileId)); + const ownedByClient = !!clientCorrelation; + const orderIsAttributed = requireSubTagAttribution + ? ownedBySubTag + : (ownedBySubTag || ownedByClient); + if (!orderIsAttributed) { + result.skippedNotBotOwned += 1; + unattributedRows.push({ + orderId, + symbol: String(order?.symbol || order?.symbol_name || '').trim(), + side, + clientOrderId, + subTag, + submittedAt: String(order?.submitted_at || order?.filled_at || order?.updated_at || '').trim(), + timestampMs + }); + continue; + } + result.botOwnedOrders += 1; + + let tradeId = String(order?.trade_id || order?.tradeId || '').trim(); + if (!tradeId && clientCorrelation?.tradeId) { + tradeId = clientCorrelation.tradeId; + } + if (!tradeId && ownedBySubTag) { + tradeId = resolveTradeFromSubTag(subTag, ctx.profileId, knownTradeIds); + } + if (!tradeId) { + result.skippedUnmappedTrade += 1; + continue; + } + + const explicitAction = normalizeOrderAction(order?.action); + const action = explicitAction || clientCorrelation?.action; + if (!action) { + result.skippedUnmappedAction += 1; + continue; + } + + const qty = resolveFilledQty(order); + const price = resolveFilledPrice(order); + if (!(qty > 0) || !(price > 0)) { + result.skippedMissingFillData += 1; + continue; + } + + const rawSymbol = String(order?.symbol || order?.symbol_name || '').trim(); + const normalizedSymbol = rawSymbol + ? SymbolMapper.toDataSymbol(rawSymbol, config.EXECUTION_PROVIDER) + : ''; + if (!normalizedSymbol) { + result.skippedUnmappedTrade += 1; + continue; + } + candidateByOrderId.set(orderId, { + orderId, + timestampMs, + payload: { + user_id: ctx.userId, + profile_id: ctx.profileId, + order_id: orderId, + symbol: normalizedSymbol, + type: String(order?.type || order?.order_type || 'market').toLowerCase(), + side, + qty, + price, + status, + timestamp: timestampMs, + trade_id: tradeId, + action, + sub_tag: subTag || undefined + } + }); + } + + let actionableUnattributedRows = unattributedRows; + if (unattributedRows.length > 0) { + const legacyKnownIds = await supabaseService.getExistingOrderIds( + unattributedRows.map((row) => row.orderId), + ctx.profileId + ); + actionableUnattributedRows = unattributedRows.filter((row) => { + if (legacyKnownIds.has(row.orderId)) { + result.skippedNotBotOwnedLegacyInDb += 1; + return false; + } + if (unattributedBaselineMs > 0 && row.timestampMs < unattributedBaselineMs) { + result.skippedNotBotOwnedBeforeBaseline += 1; + return false; + } + return true; + }); + } + result.skippedNotBotOwnedActionable = actionableUnattributedRows.length; + + if (result.skippedNotBotOwnedActionable > 0 && this.shouldEmitUnattributedAlert('global')) { + const sampleSummary = actionableUnattributedRows + .map((row) => `${row.orderId}:${row.symbol}:${row.side}`) + .slice(0, 5) + .join(', '); + const message = `Unattributed filled exchange orders detected (global watchdog): actionable_unattributed=${result.skippedNotBotOwnedActionable}, total_unattributed=${result.skippedNotBotOwned}, legacy_db_suppressed=${result.skippedNotBotOwnedLegacyInDb}, baseline_suppressed=${result.skippedNotBotOwnedBeforeBaseline}, profile=${ctx.profileId}, require_profile_subtag_attribution=${requireSubTagAttribution}. Samples: ${sampleSummary || 'n/a'}`; + logger.warn('[ReconcileCoverage] Filled orders missing bot attribution', { + event: 'reconciliation_order_coverage_unattributed_fills', + profileId: ctx.profileId, + userId: ctx.userId, + skippedNotBotOwned: result.skippedNotBotOwned, + skippedNotBotOwnedActionable: result.skippedNotBotOwnedActionable, + skippedNotBotOwnedLegacyInDb: result.skippedNotBotOwnedLegacyInDb, + skippedNotBotOwnedBeforeBaseline: result.skippedNotBotOwnedBeforeBaseline, + unattributedBaselineMs, + requireSubTagAttribution, + samples: actionableUnattributedRows.slice(0, 5) + }); + observabilityService.emitEvent({ + type: 'RECONCILIATION_DEGRADED', + severity: 'WARN', + message, + userId: ctx.userId + }); + } + const pauseThreshold = Math.max(1, Math.floor(Number(config.RECON_ORDER_COVERAGE_UNATTRIBUTED_PAUSE_MIN_COUNT || 1))); + const shouldAutoPause = Boolean(config.RECON_ORDER_COVERAGE_AUTO_PAUSE_ON_UNATTRIBUTED_FILLS); + if ( + shouldAutoPause + && result.skippedNotBotOwnedActionable >= pauseThreshold + && !healthTracker.isPaused() + ) { + const sampleSummary = actionableUnattributedRows + .map((row) => `${row.orderId}:${row.symbol}:${row.side}`) + .slice(0, 5) + .join(', '); + const reason = `Auto-paused by unattributed fill watchdog for profile ${ctx.profileId}: actionable_unattributed=${result.skippedNotBotOwnedActionable} (threshold=${pauseThreshold}), total_unattributed=${result.skippedNotBotOwned}, legacy_db_suppressed=${result.skippedNotBotOwnedLegacyInDb}, baseline_suppressed=${result.skippedNotBotOwnedBeforeBaseline}. Samples: ${sampleSummary || 'n/a'}`; + healthTracker.recordTradingControl({ + mode: 'PAUSED', + lastChangedBy: 'system:recon_unattributed_fill_watchdog', + lastChangedAt: Date.now(), + reason + }); + logger.error(`[ReconcileCoverage] ${reason}`); + observabilityService.emitEvent({ + type: 'RECONCILIATION_DEGRADED', + severity: 'ERROR', + message: reason, + profileId: ctx.profileId, + userId: ctx.userId + }); + } else if (healthTracker.isPaused() && result.skippedNotBotOwnedActionable === 0) { + const currentControl = healthTracker.getSnapshot().tradingControl; + const changedBy = String(currentControl.lastChangedBy || '').trim(); + if (changedBy === 'system:recon_unattributed_fill_watchdog') { + const reason = `Auto-resumed unattributed watchdog pause for profile ${ctx.profileId}: actionable_unattributed=0 (total_unattributed=${result.skippedNotBotOwned}, legacy_db_suppressed=${result.skippedNotBotOwnedLegacyInDb}, baseline_suppressed=${result.skippedNotBotOwnedBeforeBaseline}).`; + healthTracker.recordTradingControl({ + mode: 'RUNNING', + lastChangedBy: 'system:recon_unattributed_fill_watchdog:auto_resume', + lastChangedAt: Date.now(), + reason + }); + logger.info(`[ReconcileCoverage] ${reason}`); + observabilityService.emitEvent({ + type: 'RECONCILIATION_DEGRADED', + severity: 'INFO', + message: reason, + profileId: ctx.profileId, + userId: ctx.userId + }); + } + } + + const candidates = Array.from(candidateByOrderId.values()) + .sort((a, b) => a.timestampMs - b.timestampMs); + result.eligibleOrders = candidates.length; + if (candidates.length === 0) return result; + + const existing = await supabaseService.getExistingOrderIds( + candidates.map((row) => row.orderId) + ); + const missing = candidates.filter((row) => !existing.has(row.orderId)); + result.skippedExisting = candidates.length - missing.length; + result.missingInDb = missing.length; + if (missing.length === 0 || dryRun) return result; + + const toInsert = missing.slice(0, maxInserts); + result.skippedMaxInsertLimit = Math.max(0, missing.length - toInsert.length); + + for (const row of toInsert) { + await supabaseService.logOrder(row.payload); + } + + const insertedSet = await supabaseService.getExistingOrderIds( + toInsert.map((row) => row.orderId) + ); + result.insertedRows = toInsert.filter((row) => insertedSet.has(row.orderId)).length; + + if (result.insertedRows > 0 || result.skippedMaxInsertLimit > 0) { + logger.warn('[Reconcile] Order coverage sync evaluated', { + event: 'reconciliation_order_coverage_profile', + profileId: ctx.profileId, + userId: ctx.userId, + lookbackHours, + scannedOrders: result.scannedOrders, + filledLikeOrders: result.filledLikeOrders, + botOwnedOrders: result.botOwnedOrders, + eligibleOrders: result.eligibleOrders, + missingInDb: result.missingInDb, + insertedRows: result.insertedRows, + skippedNotBotOwned: result.skippedNotBotOwned, + skippedNotBotOwnedActionable: result.skippedNotBotOwnedActionable, + skippedNotBotOwnedLegacyInDb: result.skippedNotBotOwnedLegacyInDb, + skippedNotBotOwnedBeforeBaseline: result.skippedNotBotOwnedBeforeBaseline, + skippedUnmappedTrade: result.skippedUnmappedTrade, + skippedUnmappedAction: result.skippedUnmappedAction, + skippedMissingFillData: result.skippedMissingFillData, + skippedMissingOrderId: result.skippedMissingOrderId, + skippedExisting: result.skippedExisting, + skippedMaxInsertLimit: result.skippedMaxInsertLimit, + dryRun: result.dryRun + }); + } + + return result; + } +} + +export const reconciliationOrderCoverageService = new ReconciliationOrderCoverageService(); diff --git a/backend/src/services/reconciliationParityHeartbeatService.ts b/backend/src/services/reconciliationParityHeartbeatService.ts new file mode 100644 index 0000000..c872dce --- /dev/null +++ b/backend/src/services/reconciliationParityHeartbeatService.ts @@ -0,0 +1,456 @@ +import { createHash } from 'crypto'; +import { config } from '../config/index.js'; +import logger from '../utils/logger.js'; +import { healthTracker } from './healthTracker.js'; +import { observabilityService } from './observabilityService.js'; +import { supabaseService } from './SupabaseService.js'; +import type { TradeExecutor } from './TradeExecutor.js'; +import { buildAlpacaSubTag } from '../utils/alpacaSubTag.js'; +import { normalizeBotSymbolToken } from '../utils/botSymbolScope.js'; + +type TradeSide = 'BUY' | 'SELL'; + +type TradeParityState = { + streak: number; + lastMismatchAt: number; + quarantined: boolean; + quarantinedReason?: string; + lastSyntheticOrderId?: string; +}; + +type TradeSlice = { + symbol: string; + side: TradeSide; + qty: number; + entryPrice: number; + profileId: string; + userId?: string; + tradeId: string; +}; + +export interface ReconciliationParityHeartbeatContext { + profileId: string; + userId: string; + executor: TradeExecutor; + monitoredSymbols?: string[]; +} + +export interface ReconciliationParityHeartbeatResult { + attempted: boolean; + skippedReason?: string; + symbolsChecked: number; + mismatchTrades: number; + quarantinedTrades: number; + autoClosedTrades: number; + maxMismatchNotionalUsd: number; + totalMismatchNotionalUsd: number; + integrityWatchdogTriggered: boolean; +} + +const toNumber = (value: unknown): number => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +}; + +const toTradeSide = (value: unknown): TradeSide => { + const side = String(value || '').trim().toUpperCase(); + return side === 'SELL' ? 'SELL' : 'BUY'; +}; + +const expectedExitSide = (entrySide: TradeSide): TradeSide => (entrySide === 'BUY' ? 'SELL' : 'BUY'); + +const resolveSafeSymbols = (symbols: string[] | undefined): string[] => { + const source = Array.isArray(symbols) && symbols.length > 0 ? symbols : config.SYMBOLS; + return Array.from(new Set( + source + .map((symbol) => String(symbol || '').trim()) + .filter(Boolean) + )).sort((a, b) => a.localeCompare(b)); +}; + +const buildTradeStateKey = (profileId: string, symbol: string, tradeId: string): string => { + const normalizedProfile = String(profileId || '').trim(); + const normalizedSymbol = normalizeBotSymbolToken(symbol); + const normalizedTradeId = String(tradeId || '').trim(); + return `${normalizedProfile}::${normalizedSymbol}::${normalizedTradeId}`; +}; + +const buildSyntheticExitOrderId = (profileId: string, symbol: string, tradeId: string): string => { + const digest = createHash('md5') + .update(`${String(profileId || '').trim()}:${normalizeBotSymbolToken(symbol)}:${String(tradeId || '').trim()}`) + .digest('hex') + .toUpperCase(); + return `SYNEXIT-${digest}`; +}; + +const calcDustThreshold = (openQty: number): number => { + const absDust = Math.max(0, toNumber(config.RECON_EXIT_BACKFILL_DUST_ABS_QTY)); + const relDust = Math.max(0, toNumber(config.RECON_EXIT_BACKFILL_DUST_REL_PCT)) * Math.max(0, openQty); + const parityAbsFloor = Math.max(0, toNumber(config.RECON_POSITION_PARITY_DUST_ABS_QTY)); + return Math.max(absDust, relDust, parityAbsFloor); +}; + +const computeExchangePositionQty = (position: any): number => { + const candidates = [ + position?.qty, + position?.size, + position?.position_qty, + position?.contracts + ]; + for (const candidate of candidates) { + const parsed = Math.abs(toNumber(candidate)); + if (parsed > 0) return parsed; + } + return 0; +}; + +const normalizeTradeSlices = async ( + profileId: string, + symbol: string +): Promise => { + const virtualPosition = await supabaseService.getVirtualOpenPosition(profileId, symbol); + if (!virtualPosition || !(toNumber(virtualPosition.qty) > 0)) return []; + + const tradeIds = Array.from(new Set( + (virtualPosition.tradeIds || [virtualPosition.tradeId]) + .map((tradeId) => String(tradeId || '').trim()) + .filter(Boolean) + )); + if (tradeIds.length === 0) return []; + + const slices: TradeSlice[] = []; + for (const tradeId of tradeIds) { + const slice = await supabaseService.getVirtualOpenPositionForTrade(profileId, symbol, tradeId); + if (!slice || !(toNumber(slice.qty) > 0)) continue; + slices.push({ + symbol: String(slice.symbol || symbol).trim() || symbol, + side: toTradeSide(slice.side), + qty: Math.max(0, toNumber(slice.qty)), + entryPrice: Math.max(0, toNumber(slice.entryPrice)), + profileId: String(slice.profileId || profileId).trim() || profileId, + userId: String(slice.userId || '').trim() || undefined, + tradeId: String(slice.tradeId || tradeId).trim() || tradeId + }); + } + return slices; +}; + +const roundQty = (value: number): number => Number(value.toFixed(8)); +const roundPrice = (value: number): number => Number(value.toFixed(8)); + +export class ReconciliationParityHeartbeatService { + private tradeParityState = new Map(); + private eventThrottleByKey = new Map(); + private watchdogThrottleByProfile = new Map(); + + private shouldEmitEvent(key: string, throttleMs: number): boolean { + const now = Date.now(); + const previous = this.eventThrottleByKey.get(key) || 0; + if (throttleMs > 0 && (now - previous) < throttleMs) { + return false; + } + this.eventThrottleByKey.set(key, now); + return true; + } + + private cleanupSymbolState(profileId: string, symbol: string): void { + const prefix = `${String(profileId || '').trim()}::${normalizeBotSymbolToken(symbol)}::`; + for (const key of Array.from(this.tradeParityState.keys())) { + if (key.startsWith(prefix)) { + this.tradeParityState.delete(key); + } + } + } + + private maybePauseTradingForHighRiskMismatch( + profileId: string, + userId: string, + symbol: string, + cumulativeMismatchNotionalUsd: number, + allocatedCapitalUsd: number + ): boolean { + if (!config.ENABLE_RECON_INTEGRITY_WATCHDOG) return false; + if (allocatedCapitalUsd <= 0) return false; + + const ratio = cumulativeMismatchNotionalUsd / allocatedCapitalUsd; + const threshold = Math.max(0.01, toNumber(config.RECON_POSITION_PARITY_MAX_NOTIONAL_PCT || 0.5)); + if (ratio < threshold) return false; + + const profileKey = String(profileId || '').trim() || 'global'; + const throttleMs = Math.max(0, toNumber(config.RECON_INTEGRITY_WATCHDOG_THROTTLE_MS || 600_000)); + const now = Date.now(); + const previous = this.watchdogThrottleByProfile.get(profileKey) || 0; + if (throttleMs > 0 && (now - previous) < throttleMs) { + return true; + } + this.watchdogThrottleByProfile.set(profileKey, now); + + const message = `Integrity watchdog paused trading for ${profileId} (${symbol}): cumulative_mismatch_notional=${cumulativeMismatchNotionalUsd.toFixed(2)} exceeds ${(threshold * 100).toFixed(1)}% of allocated capital ${allocatedCapitalUsd.toFixed(2)}.`; + logger.error(`[ReconcileParity] ${message}`); + observabilityService.emitEvent({ + type: 'RECONCILIATION_DEGRADED', + severity: 'ERROR', + message, + profileId, + userId, + symbol + }); + if (!healthTracker.isPaused()) { + healthTracker.recordTradingControl({ + mode: 'PAUSED', + lastChangedBy: 'system:recon_parity_watchdog', + lastChangedAt: now, + reason: message + }); + } + return true; + } + + private buildSyntheticExitPayload(ctx: ReconciliationParityHeartbeatContext, trade: TradeSlice) { + const orderId = buildSyntheticExitOrderId(ctx.profileId, trade.symbol, trade.tradeId); + const exitPrice = trade.entryPrice > 0 ? trade.entryPrice : 0; + const qty = roundQty(trade.qty); + const side = expectedExitSide(trade.side); + const subTag = buildAlpacaSubTag({ + profileId: ctx.profileId, + tradeId: trade.tradeId, + intent: 'EXIT' + }) || undefined; + return { + orderId, + payload: { + user_id: trade.userId || ctx.userId, + profile_id: ctx.profileId, + order_id: orderId, + symbol: trade.symbol, + type: 'market', + side, + qty, + quantity: qty, + price: roundPrice(exitPrice), + status: 'filled', + timestamp: Date.now(), + trade_id: trade.tradeId, + action: 'EXIT' as const, + source: 'BOT' as const, + sub_tag: subTag + } + }; + } + + public async runProfile(ctx: ReconciliationParityHeartbeatContext): Promise { + const enabled = Boolean(config.ENABLE_RECON_POSITION_PARITY_HEARTBEAT); + const dryRun = Boolean(config.RECON_POSITION_PARITY_DRY_RUN); + if (!enabled) { + return { + attempted: false, + skippedReason: 'disabled', + symbolsChecked: 0, + mismatchTrades: 0, + quarantinedTrades: 0, + autoClosedTrades: 0, + maxMismatchNotionalUsd: 0, + totalMismatchNotionalUsd: 0, + integrityWatchdogTriggered: false + }; + } + + const profileId = String(ctx.profileId || '').trim(); + if (!profileId) { + return { + attempted: false, + skippedReason: 'missing_profile', + symbolsChecked: 0, + mismatchTrades: 0, + quarantinedTrades: 0, + autoClosedTrades: 0, + maxMismatchNotionalUsd: 0, + totalMismatchNotionalUsd: 0, + integrityWatchdogTriggered: false + }; + } + + const monitoredSymbols = resolveSafeSymbols(ctx.monitoredSymbols); + const confirmationThreshold = Math.max(1, Math.floor(toNumber(config.RECON_POSITION_PARITY_CONFIRMATIONS || 3))); + const throttleMs = Math.max(0, toNumber(config.RECON_INTEGRITY_WATCHDOG_THROTTLE_MS || 600_000)); + const requireSubTagAttribution = Boolean(config.RECON_POSITION_PARITY_REQUIRE_SUBTAG_ATTRIBUTION); + const allowLegacyEntryAttribution = Boolean(config.RECON_POSITION_PARITY_ALLOW_LEGACY_ENTRY_ATTRIBUTION); + const profileCapital = await supabaseService.getProfileCapital(profileId); + const allocatedCapitalUsd = Math.max(0, toNumber(profileCapital?.allocatedCapital)); + + let mismatchTrades = 0; + let quarantinedTrades = 0; + let autoClosedTrades = 0; + let maxMismatchNotionalUsd = 0; + let totalMismatchNotionalUsd = 0; + let integrityWatchdogTriggered = false; + + for (const symbol of monitoredSymbols) { + const slices = await normalizeTradeSlices(profileId, symbol); + if (!slices.length) { + this.cleanupSymbolState(profileId, symbol); + continue; + } + + const exchangePosition = await ctx.executor.fetchExchangePosition(symbol); + const exchangeQty = computeExchangePositionQty(exchangePosition); + const totalVirtualQty = slices.reduce((sum, item) => sum + Math.max(0, item.qty), 0); + const symbolDustThreshold = calcDustThreshold(totalVirtualQty); + + // Parity is healthy for this symbol if exchange quantity is not effectively flat. + if (exchangeQty > symbolDustThreshold) { + this.cleanupSymbolState(profileId, symbol); + continue; + } + + let symbolAttributableNotionalUsd = 0; + + for (const trade of slices) { + const tradeDustThreshold = calcDustThreshold(trade.qty); + if (trade.qty <= tradeDustThreshold) { + this.tradeParityState.delete(buildTradeStateKey(profileId, trade.symbol, trade.tradeId)); + continue; + } + + mismatchTrades += 1; + const stateKey = buildTradeStateKey(profileId, trade.symbol, trade.tradeId); + const existing = this.tradeParityState.get(stateKey); + const nextState: TradeParityState = { + streak: (existing?.streak || 0) + 1, + lastMismatchAt: Date.now(), + quarantined: false, + quarantinedReason: undefined, + lastSyntheticOrderId: existing?.lastSyntheticOrderId + }; + + if (requireSubTagAttribution) { + let attributed = await supabaseService.hasLifecycleEntryOrderWithProfileSubTag( + trade.tradeId, + profileId, + trade.symbol + ); + let attributionMode: 'subtag' | 'legacy_entry' | null = attributed ? 'subtag' : null; + + if (!attributed && allowLegacyEntryAttribution) { + const legacyAttributed = await supabaseService.hasLifecycleEntryOrder( + trade.tradeId, + profileId, + trade.symbol + ); + if (legacyAttributed) { + attributed = true; + attributionMode = 'legacy_entry'; + } + } + + if (!attributed) { + nextState.quarantined = true; + nextState.quarantinedReason = 'missing_profile_subtag_attribution'; + this.tradeParityState.set(stateKey, nextState); + quarantinedTrades += 1; + + if (this.shouldEmitEvent(`${stateKey}:quarantine`, throttleMs)) { + const message = `Parity mismatch quarantined for ${trade.symbol} trade=${trade.tradeId}: missing profile-attributed sub-tag evidence.`; + logger.warn(`[ReconcileParity] ${message}`); + observabilityService.emitEvent({ + type: 'RECONCILIATION_DEGRADED', + severity: 'WARN', + message, + profileId, + userId: ctx.userId, + symbol: trade.symbol + }); + } + continue; + } + + if (attributionMode === 'legacy_entry' && this.shouldEmitEvent(`${stateKey}:legacy_attribution`, throttleMs)) { + const message = `Parity mismatch accepted for ${trade.symbol} trade=${trade.tradeId} using legacy entry attribution fallback (missing profile sub-tag).`; + logger.warn(`[ReconcileParity] ${message}`); + observabilityService.emitEvent({ + type: 'RECONCILIATION_DEGRADED', + severity: 'WARN', + message, + profileId, + userId: ctx.userId, + symbol: trade.symbol + }); + } + } + + const tradeNotionalUsd = Math.max(0, trade.qty) * Math.max(0, trade.entryPrice); + symbolAttributableNotionalUsd += tradeNotionalUsd; + totalMismatchNotionalUsd += tradeNotionalUsd; + this.tradeParityState.set(stateKey, nextState); + if (nextState.streak < confirmationThreshold) { + continue; + } + + const synthetic = this.buildSyntheticExitPayload(ctx, trade); + const existingIds = await supabaseService.getExistingOrderIds([synthetic.orderId], profileId); + if (existingIds.has(synthetic.orderId)) { + this.tradeParityState.delete(stateKey); + continue; + } + + if (dryRun) { + if (this.shouldEmitEvent(`${stateKey}:dry_run`, throttleMs)) { + const message = `Parity dry-run would close ${trade.symbol} trade=${trade.tradeId} after ${nextState.streak} confirmations.`; + logger.warn(`[ReconcileParity] ${message}`); + observabilityService.emitEvent({ + type: 'PARITY_WARNING', + severity: 'WARN', + message, + profileId, + userId: ctx.userId, + symbol: trade.symbol + }); + } + continue; + } + + await supabaseService.logOrder(synthetic.payload); + await ctx.executor.reconcileExitFill( + synthetic.payload, + Number(synthetic.payload.price || 0), + Number(synthetic.payload.qty || 0) + ); + autoClosedTrades += 1; + this.tradeParityState.delete(stateKey); + + if (this.shouldEmitEvent(`${stateKey}:auto_closed`, throttleMs)) { + const message = `Parity heartbeat auto-closed ghost trade ${trade.tradeId} for ${trade.symbol} after ${nextState.streak} confirmations.`; + logger.warn(`[ReconcileParity] ${message}`); + observabilityService.emitEvent({ + type: 'PARITY_WARNING', + severity: 'WARN', + message, + profileId, + userId: ctx.userId, + symbol: trade.symbol + }); + } + } + + maxMismatchNotionalUsd = Math.max(maxMismatchNotionalUsd, symbolAttributableNotionalUsd); + if (symbolAttributableNotionalUsd > 0) { + if (this.maybePauseTradingForHighRiskMismatch(profileId, ctx.userId, symbol, totalMismatchNotionalUsd, allocatedCapitalUsd)) { + integrityWatchdogTriggered = true; + } + } + } + + return { + attempted: true, + symbolsChecked: monitoredSymbols.length, + mismatchTrades, + quarantinedTrades, + autoClosedTrades, + maxMismatchNotionalUsd: Number(maxMismatchNotionalUsd.toFixed(2)), + totalMismatchNotionalUsd: Number(totalMismatchNotionalUsd.toFixed(2)), + integrityWatchdogTriggered + }; + } +} + +export const reconciliationParityHeartbeatService = new ReconciliationParityHeartbeatService(); diff --git a/backend/src/services/reconciliationService.ts b/backend/src/services/reconciliationService.ts new file mode 100644 index 0000000..f895929 --- /dev/null +++ b/backend/src/services/reconciliationService.ts @@ -0,0 +1,505 @@ +import { randomUUID } from 'crypto'; +import logger from '../utils/logger.js'; +import { TradeExecutor } from './TradeExecutor.js'; +import { supabaseService } from './SupabaseService.js'; +import { distributedLockService } from './distributedLockService.js'; +import { healthTracker } from './healthTracker.js'; +import { observabilityService } from './observabilityService.js'; +import { normalizeOrderAction } from '../domain/tradingEnums.js'; +import { reconciliationOrderCoverageService } from './reconciliationOrderCoverageService.js'; +import { reconciliationExitBackfillService } from './reconciliationExitBackfillService.js'; +import { reconciliationSubTagRepairService } from './reconciliationSubTagRepairService.js'; +import { reconciliationParityHeartbeatService } from './reconciliationParityHeartbeatService.js'; +import { buildManagedBotSymbolTokenSet, isManagedBotSymbol } from '../utils/botSymbolScope.js'; +import { extractOrderSubTag } from '../utils/alpacaSubTag.js'; +import { config } from '../config/index.js'; + +export interface ReconciliationContext { + profileId: string; + userId: string; + executor: TradeExecutor; + monitoredSymbols?: string[]; +} + +export interface ReconciliationResult { + processed: boolean; + mismatchCount: number; + missingFromExchange: number; + missingInDb: number; + noGoTrades: number; + noGoReasonCounts: Record; + noGoSamples: Array<{ + profileId: string; + symbol: string; + tradeId: string; + reason: string; + }>; + parityMismatchTrades: number; + parityQuarantinedTrades: number; + parityAutoClosedTrades: number; + parityMaxMismatchNotionalUsd: number; + parityTotalMismatchNotionalUsd: number; + integrityWatchdogTriggered: boolean; + error?: string; +} + +const normalizeComparableStatus = (statusRaw?: string | null): string => { + const status = String(statusRaw || '').trim().toLowerCase(); + if (!status) return ''; + if (status === 'new' || status === 'accepted' || status === 'pending') return 'pending_new'; + if (status === 'partially-filled') return 'partially_filled'; + if (status === 'cancelled' || status === 'canceled') return 'canceled'; + return status; +}; + +const identifyOrderKeys = (order: any): string[] => { + if (!order) return []; + return [ + order?.order_id, + order?.id, + order?.client_order_id, + order?.clientOrderId, + order?.trade_id, + order?.tradeId + ] + .map((value) => String(value || '').trim().toLowerCase()) + .filter(Boolean); +}; + +const determineAction = (order: any): 'ENTRY' | 'EXIT' => { + const candidate = normalizeOrderAction(order?.action); + if (candidate) return candidate; + const side = String(order?.side || '').trim().toLowerCase(); + if (side === 'sell' || side === 'short') return 'EXIT'; + return 'ENTRY'; +}; + +const determineFillQty = (order: any): number => { + const candidates = [order?.filled_qty, order?.filledQty, order?.filled_quantity, order?.qty, order?.amount, order?.size]; + for (const candidate of candidates) { + const parsed = Number(candidate); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + return 0; +}; + +const determineFillPrice = (order: any): number => { + const candidates = [order?.filled_avg_price, order?.avg_price, order?.price, order?.requestedPrice, order?.requested_price]; + for (const candidate of candidates) { + const parsed = Number(candidate); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + return 0; +}; + +const extractOrderSymbol = (order: any): string => { + return String(order?.symbol || order?.symbol_name || '').trim(); +}; + +export class ReconciliationService { + private skippedSafetyStepAlertByScope = new Map(); + private integrityWatchdogLastEmittedAt = new Map(); + + private shouldEmitSkippedSafetyStepAlert(scope: string): boolean { + const key = String(scope || 'global').trim() || 'global'; + const now = Date.now(); + const throttleMs = Math.max(0, Number(config.RECONCILIATION_SLO_ALERT_THROTTLE_MS || 600_000)); + const last = this.skippedSafetyStepAlertByScope.get(key) || 0; + if (throttleMs > 0 && (now - last) < throttleMs) return false; + this.skippedSafetyStepAlertByScope.set(key, now); + return true; + } + + async reconcileProfile(ctx: ReconciliationContext): Promise { + const lockOwner = `${process.pid}-${randomUUID()}`; + const acquired = await distributedLockService.tryAcquireReconciliationLock(ctx.profileId, lockOwner, 30); + if (!acquired) { + healthTracker.incrementReconciliationLockContention(); + observabilityService.incrementReconciliationLockContention(); + return { + processed: false, + mismatchCount: 0, + missingFromExchange: 0, + missingInDb: 0, + noGoTrades: 0, + noGoReasonCounts: {}, + noGoSamples: [], + parityMismatchTrades: 0, + parityQuarantinedTrades: 0, + parityAutoClosedTrades: 0, + parityMaxMismatchNotionalUsd: 0, + parityTotalMismatchNotionalUsd: 0, + integrityWatchdogTriggered: false + }; + } + + const result: ReconciliationResult = { + processed: true, + mismatchCount: 0, + missingFromExchange: 0, + missingInDb: 0, + noGoTrades: 0, + noGoReasonCounts: {}, + noGoSamples: [], + parityMismatchTrades: 0, + parityQuarantinedTrades: 0, + parityAutoClosedTrades: 0, + parityMaxMismatchNotionalUsd: 0, + parityTotalMismatchNotionalUsd: 0, + integrityWatchdogTriggered: false + }; + + try { + const [dbOpenOrders, dbClosedOrders] = await Promise.all([ + supabaseService.getOpenOrdersForProfile(ctx.profileId), + supabaseService.getRecentlyClosedOrdersForProfile(ctx.profileId, 10) + ]); + const exchangeOrders = await ctx.executor.fetchExchangeOpenOrders(); + const rawDbOpenOrders = dbOpenOrders || []; + const rawDbClosedOrders = dbClosedOrders || []; + const rawExchangeOrders = exchangeOrders || []; + const managedSymbolTokens = buildManagedBotSymbolTokenSet(); + const inManagedScope = (order: any): boolean => { + const symbol = extractOrderSymbol(order); + if (!symbol) return true; + return isManagedBotSymbol(symbol, managedSymbolTokens); + }; + + const scopedDbOpenOrders = rawDbOpenOrders.filter((order) => inManagedScope(order)); + const scopedDbClosedOrders = rawDbClosedOrders.filter((order) => inManagedScope(order)); + const scopedExchangeOrders = rawExchangeOrders.filter((order) => inManagedScope(order)); + + const droppedCount = (rawDbOpenOrders.length - scopedDbOpenOrders.length) + + (rawDbClosedOrders.length - scopedDbClosedOrders.length) + + (rawExchangeOrders.length - scopedExchangeOrders.length); + if (droppedCount > 0) { + logger.info('[Reconcile] Skipped non-bot symbols during parity reconciliation', { + event: 'reconciliation_symbol_scope_filtered', + profileId: ctx.profileId, + dropped: droppedCount + }); + } + + const exchangeLookup = new Map(); + const exchangeHandled = new Set(); + const closedLookup = new Map(); + const closedHandled = new Set(); + scopedExchangeOrders.forEach((order) => { + const key = this.identifyUniqueKey(order); + if (!key) return; + exchangeLookup.set(key, order); + }); + scopedDbClosedOrders.forEach((order) => { + const key = this.identifyUniqueKey(order); + if (!key) return; + closedLookup.set(key, order); + }); + + for (const dbOrder of scopedDbOpenOrders) { + const match = this.findMatchingOrder(dbOrder, exchangeLookup, exchangeHandled); + if (match) { + const normalizedExchangeStatus = normalizeComparableStatus(match.status); + const normalizedDbStatus = normalizeComparableStatus(dbOrder.status); + if (normalizedExchangeStatus && normalizedExchangeStatus !== normalizedDbStatus) { + result.mismatchCount += 1; + await this.processStatusChange(ctx, dbOrder, match, normalizedExchangeStatus); + } + } else { + result.missingFromExchange += 1; + await this.handleMissingExchange(ctx, dbOrder); + } + } + + for (const exchangeOrder of scopedExchangeOrders) { + const key = this.identifyUniqueKey(exchangeOrder); + if (!key || exchangeHandled.has(key)) continue; + const closedMatch = this.matchOrderWithoutHandling(exchangeOrder, closedLookup, closedHandled); + const normalizedStatus = normalizeComparableStatus(exchangeOrder.status); + if (closedMatch) { + result.mismatchCount += 1; + await this.processStatusChange(ctx, closedMatch, exchangeOrder, normalizedStatus); + continue; + } + result.missingInDb += 1; + await this.handleExchangeOnly(ctx, exchangeOrder); + } + + const coverageResult = await reconciliationOrderCoverageService.runProfile(ctx); + if (!coverageResult.attempted && coverageResult.skippedReason) { + if (this.shouldEmitSkippedSafetyStepAlert(`${ctx.profileId}:coverage:${coverageResult.skippedReason}`)) { + observabilityService.emitEvent({ + type: 'RECONCILIATION_DEGRADED', + severity: coverageResult.skippedReason === 'disabled' ? 'ERROR' : 'WARN', + message: `Order coverage reconciliation skipped (${coverageResult.skippedReason}) for profile ${ctx.profileId}.`, + profileId: ctx.profileId, + userId: ctx.userId + }); + } + } + if (coverageResult.attempted && ( + coverageResult.missingInDb > 0 + || coverageResult.insertedRows > 0 + || coverageResult.skippedUnmappedTrade > 0 + || coverageResult.skippedUnmappedAction > 0 + )) { + logger.warn('[Reconcile] Missing-order coverage evaluated', { + event: 'reconciliation_order_coverage_evaluated', + profileId: ctx.profileId, + userId: ctx.userId, + dryRun: coverageResult.dryRun, + scannedOrders: coverageResult.scannedOrders, + filledLikeOrders: coverageResult.filledLikeOrders, + botOwnedOrders: coverageResult.botOwnedOrders, + eligibleOrders: coverageResult.eligibleOrders, + missingInDb: coverageResult.missingInDb, + insertedRows: coverageResult.insertedRows, + skippedNotBotOwned: coverageResult.skippedNotBotOwned, + skippedNotBotOwnedActionable: coverageResult.skippedNotBotOwnedActionable, + skippedNotBotOwnedLegacyInDb: coverageResult.skippedNotBotOwnedLegacyInDb, + skippedNotBotOwnedBeforeBaseline: coverageResult.skippedNotBotOwnedBeforeBaseline, + skippedUnmappedTrade: coverageResult.skippedUnmappedTrade, + skippedUnmappedAction: coverageResult.skippedUnmappedAction, + skippedMissingFillData: coverageResult.skippedMissingFillData, + skippedMissingOrderId: coverageResult.skippedMissingOrderId, + skippedExisting: coverageResult.skippedExisting, + skippedMaxInsertLimit: coverageResult.skippedMaxInsertLimit + }); + } + if (coverageResult.attempted) { + const unresolvedCoverageMissing = Math.max(0, coverageResult.missingInDb - coverageResult.insertedRows); + result.missingInDb += unresolvedCoverageMissing; + } + + const backfillResult = await reconciliationExitBackfillService.runProfile(ctx); + if (!backfillResult.attempted && backfillResult.skippedReason) { + if (this.shouldEmitSkippedSafetyStepAlert(`${ctx.profileId}:backfill:${backfillResult.skippedReason}`)) { + observabilityService.emitEvent({ + type: 'RECONCILIATION_DEGRADED', + severity: backfillResult.skippedReason === 'disabled' ? 'ERROR' : 'WARN', + message: `EXIT backfill reconciliation skipped (${backfillResult.skippedReason}) for profile ${ctx.profileId}.`, + profileId: ctx.profileId, + userId: ctx.userId + }); + } + } + if (backfillResult.attempted && (backfillResult.proposedRows > 0 || backfillResult.noGoTrades > 0)) { + logger.warn('[Reconcile] EXIT backfill evaluated', { + event: 'reconciliation_exit_backfill_profile', + profileId: ctx.profileId, + userId: ctx.userId, + batchId: backfillResult.batchId, + dryRun: backfillResult.dryRun, + openTradeCandidates: backfillResult.openTradeCandidates, + proposedRows: backfillResult.proposedRows, + insertedRows: backfillResult.insertedRows, + noGoTrades: backfillResult.noGoTrades + }); + } + result.noGoTrades = backfillResult.noGoTrades; + result.noGoReasonCounts = { ...(backfillResult.noGoReasonCounts || {}) }; + result.noGoSamples = (backfillResult.noGoSamples || []).map((sample) => ({ + profileId: ctx.profileId, + symbol: String(sample?.symbol || '').trim(), + tradeId: String(sample?.tradeId || '').trim(), + reason: String(sample?.reason || '').trim() || 'unknown' + })); + result.integrityWatchdogTriggered = this.evaluateIntegrityWatchdog(ctx, result.missingInDb, backfillResult.noGoTrades); + if (!backfillResult.attempted && backfillResult.skippedReason === 'audit_table_missing') { + logger.error(`[Reconcile] EXIT backfill skipped for ${ctx.profileId}: audit_table_missing`); + } + + const subTagRepairResult = await reconciliationSubTagRepairService.runProfile({ + profileId: ctx.profileId, + userId: ctx.userId + }); + if (subTagRepairResult.unsupported) { + logger.error(`[Reconcile] Sub-tag repair skipped for ${ctx.profileId}: unsupported_column`); + } + + const parityResult = await reconciliationParityHeartbeatService.runProfile({ + profileId: ctx.profileId, + userId: ctx.userId, + executor: ctx.executor, + monitoredSymbols: ctx.monitoredSymbols + }); + if (parityResult.attempted) { + result.parityMismatchTrades = parityResult.mismatchTrades; + result.parityQuarantinedTrades = parityResult.quarantinedTrades; + result.parityAutoClosedTrades = parityResult.autoClosedTrades; + result.parityMaxMismatchNotionalUsd = parityResult.maxMismatchNotionalUsd; + result.parityTotalMismatchNotionalUsd = parityResult.totalMismatchNotionalUsd; + result.integrityWatchdogTriggered = result.integrityWatchdogTriggered || parityResult.integrityWatchdogTriggered; + } else if (parityResult.skippedReason) { + logger.info(`[Reconcile] Parity heartbeat skipped for ${ctx.profileId}: ${parityResult.skippedReason}`); + } + + return result; + } catch (error: any) { + const message = String(error?.message || error || 'unknown_error'); + logger.error(`[Reconcile] Profile ${ctx.profileId} failed: ${message}`); + observabilityService.emitEvent({ + type: 'RECONCILIATION_DEGRADED', + severity: 'ERROR', + message: `Reconciliation failed for profile ${ctx.profileId}: ${message}`, + profileId: ctx.profileId, + userId: ctx.userId + }); + return { + ...result, + processed: false, + error: message + }; + } finally { + await distributedLockService.releaseReconciliationLock(ctx.profileId, lockOwner); + } + } + + private evaluateIntegrityWatchdog(ctx: ReconciliationContext, missingInDb: number, noGoTrades: number): boolean { + if (!config.ENABLE_RECON_INTEGRITY_WATCHDOG) return false; + + const missingDbThreshold = Math.max(1, Number(config.RECON_INTEGRITY_WATCHDOG_MISSING_DB_THRESHOLD || 1)); + const noGoThreshold = Math.max(1, Number(config.RECON_INTEGRITY_WATCHDOG_NO_GO_THRESHOLD || 1)); + const exceedsThreshold = missingInDb >= missingDbThreshold || noGoTrades >= noGoThreshold; + if (!exceedsThreshold) return false; + + const key = String(ctx.profileId || '').trim() || 'global'; + const now = Date.now(); + const throttleMs = Math.max(0, Number(config.RECON_INTEGRITY_WATCHDOG_THROTTLE_MS || 600_000)); + const last = this.integrityWatchdogLastEmittedAt.get(key) || 0; + if (throttleMs > 0 && (now - last) < throttleMs) { + return true; + } + this.integrityWatchdogLastEmittedAt.set(key, now); + + const message = `Reconciliation integrity watchdog triggered for ${ctx.profileId}: missing_in_db=${missingInDb} (threshold=${missingDbThreshold}), no_go=${noGoTrades} (threshold=${noGoThreshold}).`; + logger.error(`[Reconcile] ${message}`); + observabilityService.emitEvent({ + type: 'RECONCILIATION_DEGRADED', + severity: 'ERROR', + message, + profileId: ctx.profileId, + userId: ctx.userId + }); + return true; + } + + private identifyUniqueKey(order: any): string | null { + const keys = identifyOrderKeys(order); + return keys.length > 0 ? keys[0] : null; + } + + private findMatchingOrder(dbOrder: any, lookup: Map, handled: Set): any | null { + const keys = identifyOrderKeys(dbOrder); + for (const key of keys) { + const exchangeOrder = lookup.get(key); + if (!exchangeOrder) continue; + const uniqueKey = this.identifyUniqueKey(exchangeOrder); + if (!uniqueKey) continue; + if (handled.has(uniqueKey)) continue; + handled.add(uniqueKey); + return exchangeOrder; + } + return null; + } + + private matchOrderWithoutHandling(order: any, lookup: Map, handled: Set): any | null { + const keys = identifyOrderKeys(order); + for (const key of keys) { + const candidate = lookup.get(key); + if (!candidate) continue; + const uniqueKey = this.identifyUniqueKey(candidate); + if (!uniqueKey) continue; + if (handled.has(uniqueKey)) continue; + handled.add(uniqueKey); + return candidate; + } + return null; + } + + private async processStatusChange(ctx: ReconciliationContext, dbOrder: any, exchangeOrder: any, normalizedStatus: string) { + const action = determineAction(dbOrder || exchangeOrder); + await supabaseService.logOrder(this.buildLifecyclePayload(ctx, dbOrder, exchangeOrder, normalizedStatus)); + const orderId = String((exchangeOrder || dbOrder)?.order_id || (exchangeOrder || dbOrder)?.id || '').trim(); + const tradeId = String((exchangeOrder || dbOrder)?.trade_id || (exchangeOrder || dbOrder)?.tradeId || '').trim(); + logger.info('Reconciliation correction applied', { + event: 'reconciliation_correction', + profileId: ctx.profileId, + userId: ctx.userId, + orderId, + tradeId, + status: normalizedStatus, + action + }); + + if (action === 'ENTRY') { + if (['filled', 'partially_filled'].includes(normalizedStatus)) { + await ctx.executor.reconcileEntryFill(exchangeOrder, determineFillPrice(exchangeOrder), determineFillQty(exchangeOrder)); + } else if (normalizedStatus === 'canceled') { + await ctx.executor.reconcileCancel(exchangeOrder); + } + } else { + if (['filled', 'partially_filled'].includes(normalizedStatus)) { + await ctx.executor.reconcileExitFill(exchangeOrder, determineFillPrice(exchangeOrder), determineFillQty(exchangeOrder)); + } else if (normalizedStatus === 'canceled') { + await ctx.executor.reconcileCancel(exchangeOrder); + } + } + } + + private async handleMissingExchange(ctx: ReconciliationContext, dbOrder: any) { + await supabaseService.logOrder(this.buildLifecyclePayload(ctx, dbOrder)); + logger.info('Reconciliation cancel added (missing exchange)', { + event: 'reconciliation_cancel_missing_exchange', + profileId: ctx.profileId, + userId: ctx.userId, + orderId: String(dbOrder?.order_id || dbOrder?.id || '').trim(), + tradeId: String(dbOrder?.trade_id || dbOrder?.tradeId || '').trim(), + symbol: String(dbOrder?.symbol || '') + }); + await ctx.executor.reconcileCancel(dbOrder); + } + + private async handleExchangeOnly(ctx: ReconciliationContext, exchangeOrder: any) { + const normalizedStatus = normalizeComparableStatus(exchangeOrder.status); + await supabaseService.logOrder(this.buildLifecyclePayload(ctx, exchangeOrder, undefined, normalizedStatus)); + logger.info('Reconciliation discovery added', { + event: 'reconciliation_exchange_discovery', + profileId: ctx.profileId, + userId: ctx.userId, + orderId: String(exchangeOrder?.order_id || exchangeOrder?.id || '').trim(), + tradeId: String(exchangeOrder?.trade_id || exchangeOrder?.tradeId || '').trim(), + symbol: String(exchangeOrder?.symbol || exchangeOrder?.symbol_name || '') + }); + if (['filled', 'partially_filled'].includes(normalizedStatus)) { + const action = determineAction(exchangeOrder); + if (action === 'ENTRY') { + await ctx.executor.reconcileEntryFill(exchangeOrder, determineFillPrice(exchangeOrder), determineFillQty(exchangeOrder)); + } else { + await ctx.executor.reconcileExitFill(exchangeOrder, determineFillPrice(exchangeOrder), determineFillQty(exchangeOrder)); + } + } else if (normalizedStatus === 'canceled') { + await ctx.executor.reconcileCancel(exchangeOrder); + } + } + + private buildLifecyclePayload(ctx: ReconciliationContext, dbOrder?: any, exchangeOrder?: any, forcedStatus?: string) { + const order = exchangeOrder || dbOrder || {}; + return { + user_id: ctx.userId, + profile_id: ctx.profileId, + order_id: String(order.order_id || order.id || order.client_order_id || '').trim(), + symbol: String(order.symbol || order.symbol_name || '').trim(), + type: order.type || order.order_type || 'market', + side: order.side || order.direction || 'BUY', + qty: Number(order.qty ?? order.quantity ?? order.amount ?? 0), + price: Number(order.price ?? order.limit_price ?? order.avg_price ?? 0), + status: forcedStatus || exchangeOrder?.status || dbOrder?.status, + timestamp: order.timestamp || Date.now(), + trade_id: order.trade_id || order.tradeId, + action: normalizeOrderAction(order.action) || determineAction(order), + sub_tag: extractOrderSubTag(order) || undefined + }; + } +} + +export const reconciliationService = new ReconciliationService(); diff --git a/backend/src/services/reconciliationSubTagRepairService.ts b/backend/src/services/reconciliationSubTagRepairService.ts new file mode 100644 index 0000000..aa4d04e --- /dev/null +++ b/backend/src/services/reconciliationSubTagRepairService.ts @@ -0,0 +1,77 @@ +import { config } from '../config/index.js'; +import logger from '../utils/logger.js'; +import { observabilityService } from './observabilityService.js'; +import { + type ReconciliationSubTagRepairSummary, + supabaseService +} from './SupabaseService.js'; + +export interface ReconciliationSubTagRepairContext { + profileId: string; + userId: string; +} + +export type ReconciliationSubTagRepairResult = ReconciliationSubTagRepairSummary & { + skippedReason?: string; +}; + +export class ReconciliationSubTagRepairService { + async runProfile(ctx: ReconciliationSubTagRepairContext): Promise { + const dryRun = Boolean(config.RECON_SUBTAG_REPAIR_DRY_RUN); + if (!config.ENABLE_RECON_SUBTAG_REPAIR) { + return { + attempted: false, + skippedReason: 'disabled', + scannedRows: 0, + eligibleRows: 0, + updatedRows: 0, + skippedNoProfile: 0, + skippedNoTrade: 0, + skippedTagDisabled: 0, + skippedAlreadyTagged: 0, + dryRun + }; + } + + const lookbackHours = Math.max(1, Math.floor(Number(config.RECON_SUBTAG_REPAIR_LOOKBACK_HOURS || 720))); + const maxUpdatesPerProfile = Math.max(1, Math.floor(Number(config.RECON_SUBTAG_REPAIR_MAX_UPDATES_PER_PROFILE || 500))); + + const result = await supabaseService.repairMissingSubTagsForProfile({ + profileId: ctx.profileId, + lookbackHours, + maxRows: maxUpdatesPerProfile, + dryRun + }); + + if (result.unsupported) { + observabilityService.emitEvent({ + type: 'SYSTEM_ERROR', + severity: 'ERROR', + message: `Sub-tag repair skipped: orders.sub_tag column unavailable for profile ${ctx.profileId}`, + profileId: ctx.profileId, + userId: ctx.userId + }); + return result; + } + + if (result.updatedRows > 0 || result.eligibleRows > 0) { + logger.warn('[ReconcileSubTag] Missing sub_tag repair evaluated', { + event: 'reconciliation_subtag_repair_profile', + profileId: ctx.profileId, + userId: ctx.userId, + dryRun: result.dryRun, + scannedRows: result.scannedRows, + eligibleRows: result.eligibleRows, + updatedRows: result.updatedRows, + skippedNoProfile: result.skippedNoProfile, + skippedNoTrade: result.skippedNoTrade, + skippedTagDisabled: result.skippedTagDisabled, + skippedAlreadyTagged: result.skippedAlreadyTagged + }); + } + + return result; + } +} + +export const reconciliationSubTagRepairService = new ReconciliationSubTagRepairService(); diff --git a/backend/src/services/reconciliationWatchdogAutoResumeService.ts b/backend/src/services/reconciliationWatchdogAutoResumeService.ts new file mode 100644 index 0000000..cbf9d5b --- /dev/null +++ b/backend/src/services/reconciliationWatchdogAutoResumeService.ts @@ -0,0 +1,117 @@ +import { config } from '../config/index.js'; +import logger from '../utils/logger.js'; +import { healthTracker } from './healthTracker.js'; +import { observabilityService } from './observabilityService.js'; + +export interface WatchdogAutoResumeCycleMetrics { + success: boolean; + mismatchCount: number; + missingFromExchange: number; + missingInDb: number; + noGoTrades: number; + parityMismatchTrades: number; + parityQuarantinedTrades: number; + failedProfiles: number; + integrityWatchdogTriggered: boolean; +} + +export interface WatchdogAutoResumeDecision { + attempted: boolean; + resumed: boolean; + reason?: string; + cleanStreak: number; +} + +const toPositiveInt = (value: unknown, fallback: number): number => { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallback; + return Math.max(0, Math.floor(parsed)); +}; + +export class ReconciliationWatchdogAutoResumeService { + private cleanCycleStreak = 0; + private lastAutoResumeAt = 0; + + private isCleanCycle(metrics: WatchdogAutoResumeCycleMetrics): boolean { + return metrics.success + && metrics.failedProfiles === 0 + && metrics.mismatchCount === 0 + && metrics.missingFromExchange === 0 + && metrics.missingInDb === 0 + && metrics.noGoTrades === 0 + && metrics.parityMismatchTrades === 0 + && metrics.parityQuarantinedTrades === 0 + && !metrics.integrityWatchdogTriggered; + } + + public evaluateCycle(metrics: WatchdogAutoResumeCycleMetrics): WatchdogAutoResumeDecision { + if (!config.ENABLE_RECON_WATCHDOG_AUTO_RESUME) { + this.cleanCycleStreak = 0; + return { attempted: false, resumed: false, reason: 'disabled', cleanStreak: 0 }; + } + + const snapshot = healthTracker.getSnapshot(); + const control = snapshot.tradingControl; + if (control.mode !== 'PAUSED') { + this.cleanCycleStreak = 0; + return { attempted: false, resumed: false, reason: 'not_paused', cleanStreak: 0 }; + } + + const changedBy = String(control.lastChangedBy || '').trim(); + if (!changedBy.startsWith('system:recon_parity_watchdog')) { + this.cleanCycleStreak = 0; + return { attempted: false, resumed: false, reason: 'pause_not_from_parity_watchdog', cleanStreak: 0 }; + } + + const now = Date.now(); + const minPauseMs = toPositiveInt(config.RECON_WATCHDOG_AUTO_RESUME_MIN_PAUSE_MS, 900_000); + const pauseAgeMs = Math.max(0, now - Number(control.lastChangedAt || 0)); + if (pauseAgeMs < minPauseMs) { + this.cleanCycleStreak = 0; + return { attempted: true, resumed: false, reason: 'min_pause_window', cleanStreak: 0 }; + } + + if (!this.isCleanCycle(metrics)) { + this.cleanCycleStreak = 0; + return { attempted: true, resumed: false, reason: 'cycle_not_clean', cleanStreak: 0 }; + } + + const requiredCleanCycles = Math.max(1, toPositiveInt(config.RECON_WATCHDOG_AUTO_RESUME_CLEAN_CYCLES, 2)); + this.cleanCycleStreak += 1; + if (this.cleanCycleStreak < requiredCleanCycles) { + return { + attempted: true, + resumed: false, + reason: `awaiting_clean_streak_${this.cleanCycleStreak}_of_${requiredCleanCycles}`, + cleanStreak: this.cleanCycleStreak + }; + } + + const cooldownMs = toPositiveInt(config.RECON_WATCHDOG_AUTO_RESUME_COOLDOWN_MS, 1_800_000); + const sinceLastResumeMs = Math.max(0, now - this.lastAutoResumeAt); + if (cooldownMs > 0 && this.lastAutoResumeAt > 0 && sinceLastResumeMs < cooldownMs) { + return { attempted: true, resumed: false, reason: 'resume_cooldown', cleanStreak: this.cleanCycleStreak }; + } + + const reason = `Auto-resume after ${requiredCleanCycles} clean reconciliation cycles (pause_age_ms=${pauseAgeMs}, source=${changedBy}).`; + healthTracker.recordTradingControl({ + mode: 'RUNNING', + lastChangedBy: 'system:recon_parity_auto_resume', + lastChangedAt: now, + reason + }); + + logger.info(`[Reconcile] ${reason}`); + observabilityService.emitEvent({ + type: 'PARITY_WARNING', + severity: 'INFO', + message: reason + }); + + this.lastAutoResumeAt = now; + this.cleanCycleStreak = 0; + return { attempted: true, resumed: true, reason: 'resumed', cleanStreak: 0 }; + } +} + +export const reconciliationWatchdogAutoResumeService = new ReconciliationWatchdogAutoResumeService(); diff --git a/backend/src/services/riskEngine.ts b/backend/src/services/riskEngine.ts new file mode 100644 index 0000000..7c0ae70 --- /dev/null +++ b/backend/src/services/riskEngine.ts @@ -0,0 +1,156 @@ +import { config } from '../config/index.js'; +import { Indicators } from '../utils/indicators.js'; +import { MarketContext, SignalDirection } from '../strategies/rules/types.js'; +import logger from '../utils/logger.js'; + +export interface RiskProfile { + symbol: string; + action: SignalDirection; + positionSize: number; + stopLoss: number; + takeProfit: number; + riskAmount: number; +} + +export class RiskEngine { + public async calculateRiskProfile( + symbol: string, + action: SignalDirection, + context: MarketContext, + profileOverrides?: any, + availableCapital?: number + ): Promise { + if (action === SignalDirection.NONE) return null; + + const p = config.PRO_STRATEGY.PARAMETERS; + const currentPrice = context.currentPrice; + + // 1) Calculate risk amount in USD + const capital = profileOverrides?.allocated_capital || config.TOTAL_CAPITAL; + const riskPct = profileOverrides?.risk_per_trade_percent + ? (profileOverrides.risk_per_trade_percent / 100) + : p.RISK_PER_TRADE; + const riskAmount = capital * riskPct; + + // 2) Calculate ATR-based stop-loss distance + const atr = Indicators.calculateATR(context.candles1h, p.ATR_PERIOD); + const slMultiplier = p.SL_MULTIPLIER || 1.0; + let stopLossDistance = 0; + + if (atr === 0) { + logger.warn(`[RiskEngine] Unable to calculate ATR for ${symbol}. Falling back to default 1% SL.`); + stopLossDistance = currentPrice * 0.01; + } else { + stopLossDistance = atr * slMultiplier; + } + + const profile = this.buildProfile( + symbol, + action, + currentPrice, + stopLossDistance, + riskAmount, + p.RISK_REWARD_RATIO, + p.MAX_TP_CAP || 0.01 + ); + + // 3) Quantity/notional/precision guardrails + const executionCfg = profileOverrides?.strategy_config?.execution || {}; + const rawMinQty = Number(executionCfg.minQty ?? config.MIN_POSITION_QTY); + const rawMaxQty = Number(executionCfg.maxQty ?? config.MAX_POSITION_QTY); + const rawPrecision = Number(executionCfg.qtyPrecision ?? config.QUANTITY_PRECISION); + const rawMinNotional = Number(executionCfg.minNotionalUsd ?? config.MIN_NOTIONAL_USD); + const rawMaxNotional = Number(executionCfg.maxNotionalUsd ?? config.MAX_NOTIONAL_USD); + + const minQty = Number.isFinite(rawMinQty) && rawMinQty > 0 ? rawMinQty : 0.0001; + const maxQty = Number.isFinite(rawMaxQty) && rawMaxQty > 0 ? rawMaxQty : Number.POSITIVE_INFINITY; + const qtyPrecision = Number.isFinite(rawPrecision) && rawPrecision >= 0 ? Math.floor(rawPrecision) : 6; + const minNotional = Number.isFinite(rawMinNotional) && rawMinNotional > 0 ? rawMinNotional : 10; + const maxNotional = Number.isFinite(rawMaxNotional) && rawMaxNotional > 0 ? rawMaxNotional : Number.POSITIVE_INFINITY; + + profile.positionSize = Math.min(profile.positionSize, maxQty); + profile.positionSize = this.roundDown(profile.positionSize, qtyPrecision); + + // 4) Available capital guard (with optional reserve) + const reservePct = Number(config.CAPITAL_RESERVE_PERCENT || 0); + const reserveCapital = capital * Math.max(0, reservePct) / 100; + const effectiveAvailableCapital = availableCapital !== undefined + ? availableCapital + : Math.max(0, capital - reserveCapital); + + if (effectiveAvailableCapital > 0) { + const maxAffordableQty = this.roundDown(effectiveAvailableCapital / currentPrice, qtyPrecision); + if (profile.positionSize > maxAffordableQty) { + logger.warn(`[RiskEngine] Position exceeds available capital. Scaling qty from ${profile.positionSize} to ${maxAffordableQty}`); + profile.positionSize = maxAffordableQty; + } + } + + // 5) Notional guardrails + let notional = profile.positionSize * currentPrice; + if (notional > maxNotional) { + const maxQtyByNotional = this.roundDown(maxNotional / currentPrice, qtyPrecision); + logger.warn(`[RiskEngine] Position notional $${notional.toFixed(2)} exceeds max $${maxNotional.toFixed(2)}. Scaling qty to ${maxQtyByNotional}`); + profile.positionSize = Math.min(profile.positionSize, maxQtyByNotional); + notional = profile.positionSize * currentPrice; + } + + if (profile.positionSize < minQty) { + logger.warn(`[RiskEngine] Qty ${profile.positionSize} below min qty ${minQty} for ${symbol}. Rejecting trade.`); + return null; + } + + if (notional < minNotional) { + logger.warn(`[RiskEngine] Notional $${notional.toFixed(2)} below min $${minNotional.toFixed(2)} for ${symbol}. Rejecting trade.`); + return null; + } + + if (profile.positionSize <= 0 || !Number.isFinite(profile.positionSize)) { + logger.warn(`[RiskEngine] Invalid position size ${profile.positionSize} for ${symbol}. Rejecting trade.`); + return null; + } + + // Recompute effective risk after sizing guardrails + profile.riskAmount = profile.positionSize * stopLossDistance; + return profile; + } + + private buildProfile( + symbol: string, + action: SignalDirection, + entry: number, + slDist: number, + riskUsd: number, + rrRatio: number, + maxTpCap: number + ): RiskProfile { + const positionSize = riskUsd / slDist; + + const stopLoss = action === SignalDirection.BUY + ? entry - slDist + : entry + slDist; + + const rawTpDist = slDist * rrRatio; + const maxTpDist = entry * maxTpCap; + const takeProfitDistance = Math.min(rawTpDist, maxTpDist); + + const takeProfit = action === SignalDirection.BUY + ? entry + takeProfitDistance + : entry - takeProfitDistance; + + return { + symbol, + action, + positionSize, + stopLoss, + takeProfit, + riskAmount: riskUsd + }; + } + + private roundDown(value: number, precision: number): number { + const safePrecision = Math.max(0, Math.min(10, precision)); + const factor = Math.pow(10, safePrecision); + return Math.floor(value * factor) / factor; + } +} diff --git a/backend/src/services/stateMerge.ts b/backend/src/services/stateMerge.ts new file mode 100644 index 0000000..8865dd6 --- /dev/null +++ b/backend/src/services/stateMerge.ts @@ -0,0 +1,268 @@ +export interface RuntimePositionSnapshot { + id: string; + symbol: string; + side: 'BUY' | 'SELL'; + size: number; + entryPrice: number; + currentPrice: number; + stopLoss: number; + takeProfit: number; + unrealizedPnl: number; + unrealizedPnlPercent: number; + marketValue: number; + userId?: string; + profileId?: string; + profileName?: string; + tradeId?: string; +} + +export interface RuntimeOrderSnapshot { + id: string; + symbol: string; + type: string; + side: string; + qty: number; + price: number; + status: string; + timestamp: number; + userId?: string; + profileId?: string; + trade_id?: string; + subTag?: string; + action?: string; + source?: 'BOT' | 'MANUAL'; + created_at?: string; +} + +const STABLE_SYNC_SUFFIX = '-SYNC'; + +const normalizeTradeId = (value?: string): string => String(value || '').trim(); + +const hasStableTradeId = (tradeId?: string): boolean => { + const normalized = normalizeTradeId(tradeId); + return normalized.length > 0 && !normalized.endsWith(STABLE_SYNC_SUFFIX); +}; + +const toTimestamp = (value: number | string | undefined, createdAt?: string): number => { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const numeric = Number(value); + if (Number.isFinite(numeric) && numeric > 0) return numeric; + const parsed = Date.parse(value); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + if (createdAt) { + const parsedCreatedAt = Date.parse(createdAt); + if (Number.isFinite(parsedCreatedAt) && parsedCreatedAt > 0) return parsedCreatedAt; + } + return 0; +}; + +const toNumber = (value: unknown): number => { + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : 0; +}; + +const positive = (value: unknown): number => { + const numeric = toNumber(value); + return numeric > 0 ? numeric : 0; +}; + +const positionScore = (position: RuntimePositionSnapshot): number => { + const tradeScore = hasStableTradeId(position.tradeId) ? 4 : (normalizeTradeId(position.tradeId) ? 2 : 0); + const profileScore = position.profileId ? 3 : 0; + const userScore = position.userId ? 2 : 0; + const namedScore = position.profileName ? 1 : 0; + const priceScore = positive(position.currentPrice) > 0 ? 1 : 0; + const notionalScore = positive(position.entryPrice) * positive(position.size); + return tradeScore + profileScore + userScore + namedScore + priceScore + Math.min(notionalScore, 100_000); +}; + +const mergePosition = ( + left: RuntimePositionSnapshot, + right: RuntimePositionSnapshot +): RuntimePositionSnapshot => { + const leftScore = positionScore(left); + const rightScore = positionScore(right); + const preferred = rightScore >= leftScore ? right : left; + const fallback = preferred === right ? left : right; + + return { + ...fallback, + ...preferred, + id: preferred.id || fallback.id, + symbol: preferred.symbol || fallback.symbol, + side: preferred.side || fallback.side, + size: positive(preferred.size) > 0 ? preferred.size : fallback.size, + entryPrice: positive(preferred.entryPrice) > 0 ? preferred.entryPrice : fallback.entryPrice, + currentPrice: positive(preferred.currentPrice) > 0 ? preferred.currentPrice : fallback.currentPrice, + stopLoss: positive(preferred.stopLoss) > 0 ? preferred.stopLoss : fallback.stopLoss, + takeProfit: positive(preferred.takeProfit) > 0 ? preferred.takeProfit : fallback.takeProfit, + marketValue: positive(preferred.marketValue) > 0 ? preferred.marketValue : fallback.marketValue, + profileId: preferred.profileId || fallback.profileId, + userId: preferred.userId || fallback.userId, + profileName: preferred.profileName || fallback.profileName, + tradeId: normalizeTradeId(preferred.tradeId) || normalizeTradeId(fallback.tradeId) || undefined + }; +}; + +const fallbackPositionKey = (position: RuntimePositionSnapshot): string => { + const owner = `${position.userId || 'global'}:${position.profileId || 'global'}`; + return `${owner}:${position.symbol}:${position.side}`; +}; + +const orderStatusRank = (status?: string): number => { + const normalized = String(status || '').trim().toLowerCase(); + if (normalized === 'filled') return 6; + if (normalized === 'partially_filled' || normalized === 'partially-filled') return 5; + if (normalized === 'canceled' || normalized === 'cancelled' || normalized === 'rejected' || normalized === 'expired') return 4; + if (normalized === 'pending_new' || normalized === 'pending' || normalized === 'accepted' || normalized === 'new') return 2; + if (normalized === 'unknown') return 1; + return 2; +}; + +const pickReliableStatus = (left: RuntimeOrderSnapshot, right: RuntimeOrderSnapshot): string => { + const leftStatus = String(left.status || '').trim().toLowerCase() || 'unknown'; + const rightStatus = String(right.status || '').trim().toLowerCase() || 'unknown'; + const leftRank = orderStatusRank(leftStatus); + const rightRank = orderStatusRank(rightStatus); + if (leftRank !== rightRank) { + return rightRank > leftRank ? rightStatus : leftStatus; + } + const leftTs = toTimestamp(left.timestamp, left.created_at); + const rightTs = toTimestamp(right.timestamp, right.created_at); + return rightTs >= leftTs ? rightStatus : leftStatus; +}; + +const normalizeOrderSource = (order: RuntimeOrderSnapshot): 'BOT' | 'MANUAL' => { + const normalized = String(order.source || '').trim().toUpperCase(); + if (normalized === 'BOT' || normalized === 'MANUAL') return normalized; + return order.profileId ? 'BOT' : 'MANUAL'; +}; + +const orderScore = (order: RuntimeOrderSnapshot): number => { + const profileScore = order.profileId ? 2 : 0; + const userScore = order.userId ? 1 : 0; + const tradeScore = normalizeTradeId(order.trade_id) ? 2 : 0; + const actionScore = order.action ? 1 : 0; + const statusScore = orderStatusRank(order.status); + return profileScore + userScore + tradeScore + actionScore + statusScore; +}; + +const mergeOrder = ( + left: RuntimeOrderSnapshot, + right: RuntimeOrderSnapshot +): RuntimeOrderSnapshot => { + const leftScore = orderScore(left); + const rightScore = orderScore(right); + const preferred = rightScore >= leftScore ? right : left; + const fallback = preferred === right ? left : right; + const preferredTs = toTimestamp(preferred.timestamp, preferred.created_at); + const fallbackTs = toTimestamp(fallback.timestamp, fallback.created_at); + + return { + ...fallback, + ...preferred, + id: preferred.id || fallback.id, + symbol: preferred.symbol || fallback.symbol, + type: preferred.type || fallback.type, + side: preferred.side || fallback.side, + qty: positive(preferred.qty) > 0 ? preferred.qty : fallback.qty, + price: positive(preferred.price) > 0 ? preferred.price : fallback.price, + timestamp: Math.max(preferredTs, fallbackTs), + status: pickReliableStatus(left, right), + userId: preferred.userId || fallback.userId, + profileId: preferred.profileId || fallback.profileId, + trade_id: normalizeTradeId(preferred.trade_id) || normalizeTradeId(fallback.trade_id) || undefined, + subTag: preferred.subTag || fallback.subTag, + action: preferred.action || fallback.action, + source: normalizeOrderSource({ + ...fallback, + ...preferred, + profileId: preferred.profileId || fallback.profileId + }) + }; +}; + +export const mergePositionSnapshots = ( + snapshotsBySource: RuntimePositionSnapshot[][] +): RuntimePositionSnapshot[] => { + const mergedByKey = new Map(); + for (const position of snapshotsBySource.flat()) { + const tradeId = normalizeTradeId(position.tradeId); + const key = hasStableTradeId(tradeId) + ? `trade:${tradeId}` + : `fallback:${fallbackPositionKey(position)}`; + const existing = mergedByKey.get(key); + if (!existing) { + mergedByKey.set(key, { ...position, tradeId: tradeId || undefined }); + continue; + } + mergedByKey.set(key, mergePosition(existing, { ...position, tradeId: tradeId || undefined })); + } + + const groupedByOwnerSymbol = new Map(); + for (const position of mergedByKey.values()) { + const groupKey = fallbackPositionKey(position); + const group = groupedByOwnerSymbol.get(groupKey) || []; + group.push(position); + groupedByOwnerSymbol.set(groupKey, group); + } + + const output: RuntimePositionSnapshot[] = []; + for (const group of groupedByOwnerSymbol.values()) { + const stable = group.filter((position) => hasStableTradeId(position.tradeId)); + if (stable.length > 0) { + output.push(...stable); + continue; + } + + const reduced = group.reduce((acc, current) => (acc ? mergePosition(acc, current) : current), undefined as RuntimePositionSnapshot | undefined); + if (reduced) output.push(reduced); + } + + return output.sort((a, b) => { + const profileCompare = String(a.profileId || '').localeCompare(String(b.profileId || '')); + if (profileCompare !== 0) return profileCompare; + const symbolCompare = String(a.symbol || '').localeCompare(String(b.symbol || '')); + if (symbolCompare !== 0) return symbolCompare; + return String(a.tradeId || a.id).localeCompare(String(b.tradeId || b.id)); + }); +}; + +export const mergeOrderSnapshots = ( + snapshotsBySource: RuntimeOrderSnapshot[][] +): RuntimeOrderSnapshot[] => { + const mergedByOrderId = new Map(); + + for (const order of snapshotsBySource.flat()) { + const orderId = String(order.id || '').trim(); + if (!orderId) continue; + + const normalizedOrder: RuntimeOrderSnapshot = { + ...order, + id: orderId, + userId: order.userId || undefined, + profileId: order.profileId || undefined, + trade_id: normalizeTradeId(order.trade_id) || undefined, + subTag: String((order as any).subTag || (order as any).subtag || (order as any).sub_tag || '').trim() || undefined, + status: String(order.status || 'unknown').toLowerCase(), + timestamp: toTimestamp(order.timestamp, order.created_at), + source: normalizeOrderSource(order) + }; + + const existing = mergedByOrderId.get(orderId); + if (!existing) { + mergedByOrderId.set(orderId, normalizedOrder); + continue; + } + + mergedByOrderId.set(orderId, mergeOrder(existing, normalizedOrder)); + } + + return Array.from(mergedByOrderId.values()).sort((a, b) => { + const tsDiff = toTimestamp(b.timestamp, b.created_at) - toTimestamp(a.timestamp, a.created_at); + if (tsDiff !== 0) return tsDiff; + return String(a.id || '').localeCompare(String(b.id || '')); + }); +}; diff --git a/backend/src/services/tradeMonitor.ts b/backend/src/services/tradeMonitor.ts new file mode 100644 index 0000000..170f460 --- /dev/null +++ b/backend/src/services/tradeMonitor.ts @@ -0,0 +1,520 @@ +import { IExchangeConnector } from '../connectors/types.js'; +import { TradeExecutor } from './TradeExecutor.js'; +import logger from '../utils/logger.js'; +import { SymbolMapper } from '../utils/symbolMapper.js'; +import { config } from '../config/index.js'; +import { ApiServer } from './apiServer.js'; +import { healthTracker } from './healthTracker.js'; +import { observabilityService } from './observabilityService.js'; +import { buildManagedBotSymbolTokenSet, isManagedBotSymbol } from '../utils/botSymbolScope.js'; +import { supabaseService } from './SupabaseService.js'; +import { extractOrderSubTag, subTagBelongsToProfile, subTagHintsTrade } from '../utils/alpacaSubTag.js'; + +export class TradeMonitor { + private interval: NodeJS.Timeout | null = null; + private isChecking = false; + private positionMissingDetections = new Map(); + private mismatchEventLastEmitted = new Map(); + private exitEvidenceGapLastEmitted = new Map(); + private consecutiveReconciliationFailures = 0; + private readonly MAX_CONSECUTIVE_FAILURES = 3; + + constructor( + private exchange: IExchangeConnector, + private executionManager: TradeExecutor, + private apiServer?: ApiServer + ) { } + + public start(pollingIntervalMs: number = config.MONITOR_INTERVAL_MS) { + if (this.interval) return; + + logger.info(`[TradeMonitor] Starting background monitoring (every ${pollingIntervalMs / 1000}s)...`); + + this.interval = setInterval(async () => { + await this.checkOpenPositions(); + }, pollingIntervalMs); + } + + public stop() { + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + logger.info('[TradeMonitor] Background monitoring stopped.'); + } + } + + /** + * FIX-04: Clear per-symbol detection state when a symbol is removed from a profile + * during hot-reload. Without this, stale miss-counts can trigger ghost position + * finalization for symbols the profile no longer monitors. + */ + public clearSymbolDetector(symbol: string): void { + this.positionMissingDetections.delete(symbol); + let clearedThrottleKeys = 0; + const keySuffix = `::${symbol}`; + for (const key of Array.from(this.mismatchEventLastEmitted.keys())) { + if (key === symbol || key.endsWith(keySuffix)) { + this.mismatchEventLastEmitted.delete(key); + clearedThrottleKeys += 1; + } + } + for (const key of Array.from(this.exitEvidenceGapLastEmitted.keys())) { + if (key === symbol || key.endsWith(keySuffix)) { + this.exitEvidenceGapLastEmitted.delete(key); + clearedThrottleKeys += 1; + } + } + logger.info(`[TradeMonitor] Cleared detection state for removed symbol: ${symbol} (throttle keys=${clearedThrottleKeys})`); + } + + private async checkOpenPositions() { + if (this.isChecking) { + logger.debug('[TradeMonitor] Previous check still running, skipping this tick.'); + return; + } + const loopStart = Date.now(); + this.isChecking = true; + + try { + // Keep local pending limit orders from lingering forever. + await this.checkPendingOrderTimeouts(); + + const managedSymbolTokens = buildManagedBotSymbolTokenSet(); + const activeSymbols = this.executionManager.getActiveSymbols().filter((symbol) => + isManagedBotSymbol(symbol, managedSymbolTokens) + ); + + for (const symbol of activeSymbols) { + try { + const localPositions = this.executionManager.getActivePositions(symbol); + if (localPositions.length === 0) continue; + + const tradeSymbol = SymbolMapper.toTradeSymbol(symbol, config.EXECUTION_PROVIDER); + const position = await this.instrumentExchange('get_position', () => this.exchange.getPosition(tradeSymbol)); + + // Detection for Exchange ↔ DB parity mismatch with per-profile throttling. + if (!position && localPositions.length > 0) { + const profileId = String(localPositions[0]?.profileId || this.executionManager.getProfileId() || '').trim(); + if (this.shouldEmitExchangeMismatch(profileId, symbol)) { + this.apiServer?.broadcast('PARITY_WARNING', { + symbol, + level: 'exchange_flat_db_open', + message: `State mismatch: ${symbol} is open in DB but flat on exchange.` + }); + observabilityService.emitEvent({ + type: 'EXCHANGE_STATE_MISMATCH', + severity: 'WARN', + message: `State mismatch: ${symbol} is open in DB but flat on exchange.`, + profileId: localPositions[0]?.profileId, + symbol + }); + } + } + + // If position is gone on exchange, mark trade complete locally. + if (!position) { + const hasTradingWindow = this.executionManager.verifyCapability('tradingWindow', 'Trading Window Checks'); + const windowAwareExchange = this.exchange as any; + const tradingWindowOpen = (hasTradingWindow && typeof windowAwareExchange.isTradingWindowOpen === 'function') + ? await windowAwareExchange.isTradingWindowOpen(tradeSymbol) + : true; + const misses = (this.positionMissingDetections.get(symbol) || 0) + 1; + this.positionMissingDetections.set(symbol, misses); + + // Off-session position checks are less reliable for finalization; require more confirmations. + const requiredMisses = tradingWindowOpen === false ? 3 : 2; + if (tradingWindowOpen === null || misses < requiredMisses) { + logger.warn(`[TradeMonitor] Position missing for ${symbol} (${misses}/${requiredMisses}) | tradingWindow=${tradingWindowOpen === null ? 'unknown' : tradingWindowOpen ? 'open' : 'closed'}. Waiting for confirmation.`); + continue; + } + + // Final confirmation before state mutation. + const confirmPosition = await this.instrumentExchange('get_position', () => this.exchange.getPosition(tradeSymbol)); + if (confirmPosition) { + this.positionMissingDetections.delete(symbol); + logger.info(`[TradeMonitor] Position reappeared for ${symbol} after missing checks. Keeping trade active.`); + continue; + } + + const fallbackPrice = this.apiServer?.getState()?.symbols?.[symbol]?.price + || localPositions[0]?.entryPrice + || 0; + let baselineExitPrice = fallbackPrice; + let baselineExitPriceSource = 'fallback'; + const closedOrderRows: any[] = []; + const usedClosedOrderIds = new Set(); + + // FIX-02: Prefer actual exchange fill price over candle close estimate. + const canFetchClosed = this.executionManager.verifyCapability('fetchClosedOrders', 'Fetch Closed Orders'); + if (canFetchClosed && typeof this.exchange.fetchClosedOrders === 'function') { + try { + const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + const closedOrders = await this.instrumentExchange('fetch_closed_orders', () => + this.exchange.fetchClosedOrders!([tradeSymbol], { + after: since, + limit: Math.max(50, localPositions.length * 8), + maxPages: 3 + }) + ); + if (Array.isArray(closedOrders)) closedOrderRows.push(...closedOrders); + const latestFill = this.selectLatestClosedFill(closedOrderRows); + if (latestFill) { + baselineExitPrice = latestFill.price; + baselineExitPriceSource = latestFill.source; + } + } catch (e) { + logger.warn(`[TradeMonitor] Failed to fetch closed orders for ${symbol} fill price: ${e}`); + } + } + + // In strict evidence mode, never estimate finalization prices from candles. + const strictEvidenceRequired = Boolean(config.REQUIRE_EXCHANGE_FILL_EVIDENCE_FOR_AUTO_CLOSE); + if (!strictEvidenceRequired && baselineExitPriceSource !== 'exchange_fill') { + try { + const candles = await this.instrumentExchange('fetch_ohlcv', () => + this.exchange.fetchOHLCV(tradeSymbol, '1Min', 1) + ); + if (candles && candles.length > 0) { + baselineExitPrice = candles[0].close; + baselineExitPriceSource = 'candle_close_estimate'; + logger.warn(`[TradeMonitor] Using candle close as exit price for ${symbol} — this is an ESTIMATE, not the actual fill price.`); + } + } catch { + logger.warn(`[TradeMonitor] Could not fetch exit price for ${symbol}; using fallback ${fallbackPrice}`); + } + } else if (strictEvidenceRequired && baselineExitPriceSource !== 'exchange_fill') { + logger.warn(`[TradeMonitor] Strict evidence mode active: skipping candle-based close estimate for ${symbol}. Waiting for exchange fill evidence.`); + } + + logger.info(`[TradeMonitor] Position for ${symbol} (mapped: ${tradeSymbol}) confirmed closed on exchange. Baseline exit price: ${baselineExitPrice} (source: ${baselineExitPriceSource}).`); + this.positionMissingDetections.delete(symbol); + for (const localPos of localPositions) { + const evidenceFill = this.findClosedFillForPosition(localPos, closedOrderRows, usedClosedOrderIds); + if (!evidenceFill && strictEvidenceRequired) { + const profileId = String(localPos.profileId || this.executionManager.getProfileId() || '').trim(); + if (this.shouldEmitExitEvidenceGap(profileId, symbol)) { + observabilityService.emitEvent({ + type: 'EXIT_FILL_COHERENCE_VIOLATION', + severity: 'ERROR', + message: `Exchange is flat for ${symbol}, but no matching closed-fill evidence was found for DB-open lifecycle. Manual reconciliation required.`, + profileId: localPos.profileId, + symbol + }); + } + const markManualReview = (this.executionManager as any)?.markExitManualReview; + if (typeof markManualReview === 'function') { + markManualReview.call( + this.executionManager, + symbol, + 'missing_exchange_fill_evidence', + 'exchange_flat_no_closed_fill_match', + localPos.tradeId + ); + } + logger.warn(`[TradeMonitor] Skipping finalize for ${symbol} trade=${String(localPos.tradeId || 'unknown')}: missing exchange fill evidence.`); + continue; + } + const exitPrice = evidenceFill?.price || baselineExitPrice; + const exitPriceSource = evidenceFill?.source || baselineExitPriceSource; + if (evidenceFill?.orderId) usedClosedOrderIds.add(evidenceFill.orderId); + logger.info(`[TradeMonitor] Finalizing ${symbol} trade=${String(localPos.tradeId || 'unknown')} using exit price ${exitPrice} (source: ${exitPriceSource}).`); + await this.executionManager.markTradeComplete(symbol, exitPrice, 'Exchange Position Closed', localPos.tradeId); + } + continue; + } + this.positionMissingDetections.delete(symbol); + + // Local SL and trailing-profit guard. + const candles = await this.instrumentExchange('fetch_ohlcv', () => this.exchange.fetchOHLCV(tradeSymbol, '1Min', 1)); + if (!candles || candles.length === 0) continue; + + const currentPrice = candles[0].close; + + for (const localPos of localPositions) { + const stillActive = this.executionManager.getActivePosition(symbol, localPos.tradeId); + if (!stillActive) continue; + + // FIX-01 part 2: Warn when stopLoss=0 — the SL guard below will be a no-op. + if (stillActive.stopLoss <= 0) { + logger.warn(`[TradeMonitor] ⚠️ Position ${symbol} (trade=${stillActive.tradeId}) has stopLoss=0. Stop-loss guard is INACTIVE for this position.`); + } + + if (stillActive.side === 'BUY' && stillActive.stopLoss > 0 && currentPrice <= stillActive.stopLoss) { + await this.executionManager.executeExit(symbol, currentPrice, 'Safety Stop Loss Hit', stillActive.tradeId); + continue; + } + if (stillActive.side === 'SELL' && stillActive.stopLoss > 0 && currentPrice >= stillActive.stopLoss) { + await this.executionManager.executeExit(symbol, currentPrice, 'Safety Stop Loss Hit', stillActive.tradeId); + continue; + } + + const targetPrice = Number(stillActive.takeProfit || 0); + const hasTakeProfit = Number.isFinite(targetPrice) && targetPrice > 0; + const isTargetHit = hasTakeProfit + ? (stillActive.side === 'BUY' ? currentPrice >= targetPrice : currentPrice <= targetPrice) + : false; + + if (isTargetHit || stillActive.profitGuardActive) { + if (!stillActive.profitGuardActive) { + logger.info(`[TradeMonitor] Target hit for ${symbol}. Activating trailing profit guard.`); + stillActive.profitGuardActive = true; + stillActive.peakPrice = currentPrice; + } + + const trailingStopPct = config.TRAILING_STOP_PERCENT; + if (stillActive.side === 'BUY') { + stillActive.peakPrice = Math.max(stillActive.peakPrice, currentPrice); + if (currentPrice <= stillActive.peakPrice * (1 - trailingStopPct)) { + await this.executionManager.executeExit(symbol, currentPrice, `Trailing Profit Guard (${(trailingStopPct * 100).toFixed(1)}% Pullback)`, stillActive.tradeId); + } + } else { + stillActive.peakPrice = Math.min(stillActive.peakPrice, currentPrice); + if (currentPrice >= stillActive.peakPrice * (1 + trailingStopPct)) { + await this.executionManager.executeExit(symbol, currentPrice, `Trailing Profit Guard (${(trailingStopPct * 100).toFixed(1)}% Pullback)`, stillActive.tradeId); + } + } + } + } + } catch (e) { + logger.error(`[TradeMonitor] Error checking position for ${symbol}: ${e}`); + } + } + + this.consecutiveReconciliationFailures = 0; + if (this.apiServer) { + const allPos = this.executionManager.getAllActivePositions(); + this.apiServer.updatePositions(allPos, this.executionManager.getProfileId() || 'global'); + } + } catch (e) { + this.consecutiveReconciliationFailures++; + logger.error(`[TradeMonitor] Reconciliation failed (${this.consecutiveReconciliationFailures}/${this.MAX_CONSECUTIVE_FAILURES}): ${e}`); + + if (this.consecutiveReconciliationFailures >= this.MAX_CONSECUTIVE_FAILURES) { + if (this.apiServer) { + this.apiServer.broadcast('RECONCILIATION_DEGRADED', { + consecutiveFailures: this.consecutiveReconciliationFailures, + lastError: String(e) + }); + } + observabilityService.emitEvent({ + type: 'RECONCILIATION_DEGRADED', + severity: 'ERROR', + message: `Position reconciliation failing repeatedly (${this.consecutiveReconciliationFailures}/${this.MAX_CONSECUTIVE_FAILURES})` + }); + } + } finally { + const duration = Date.now() - loopStart; + observabilityService.recordMonitorLoop(duration); + healthTracker.recordMonitorLoop(); + this.apiServer?.publishHealthSnapshot({ broadcast: true }); + this.isChecking = false; + } + } + + /** + * Cancel stale local limit orders. Exchange order-status reconciliation is owned + * by OrderStatusSyncService to avoid duplicate status writers. + */ + private async checkPendingOrderTimeouts() { + const pendingOrders = this.executionManager.getPendingOrders(); + const timeout = config.LIMIT_ORDER_TIMEOUT_MS; + const now = Date.now(); + + for (const [orderId, pending] of pendingOrders.entries()) { + if (pending.type !== 'limit') continue; + const elapsed = now - pending.placedAt; + + if (elapsed <= timeout) continue; + + logger.warn(`[TradeMonitor] Limit order ${orderId} for ${pending.symbol} timed out after ${Math.round(elapsed / 60000)}m. Cancelling...`); + + const cancelOrder = this.exchange.cancelOrder; + if (cancelOrder) { + const cancelled = await this.instrumentExchange('cancel_order', () => cancelOrder.call(this.exchange, orderId)); + if (cancelled) { + pendingOrders.delete(orderId); + logger.info(`[TradeMonitor] Limit order ${orderId} cancelled due to timeout.`); + try { + await supabaseService.updateOrderStatus(orderId, 'canceled'); + } catch (e) { + logger.warn(`[TradeMonitor] Failed to update DB status for timed-out order ${orderId}: ${e}`); + } + } + } else { + logger.warn('[TradeMonitor] Exchange does not support cancelOrder. Removing from local tracking.'); + pendingOrders.delete(orderId); + try { + await supabaseService.updateOrderStatus(orderId, 'canceled'); + } catch (e) { + logger.warn(`[TradeMonitor] Failed to update DB status for timed-out order ${orderId}: ${e}`); + } + } + } + } + + private async instrumentExchange(operation: string, fn: () => Promise): Promise { + const start = Date.now(); + try { + return await fn(); + } finally { + observabilityService.observeExchangeLatency(operation, Date.now() - start); + } + } + + private shouldEmitExchangeMismatch(profileId: string, symbol: string): boolean { + const throttleMs = Math.max(1, Number(config.EXCHANGE_STATE_MISMATCH_THROTTLE_MS || 300000)); + const key = `${profileId || 'unknown'}::${symbol}`; + const now = Date.now(); + const lastTs = this.mismatchEventLastEmitted.get(key) || 0; + if (now - lastTs < throttleMs) { + return false; + } + this.mismatchEventLastEmitted.set(key, now); + return true; + } + + private shouldEmitExitEvidenceGap(profileId: string, symbol: string): boolean { + const throttleMs = Math.max(1, Number(config.EXCHANGE_STATE_MISMATCH_THROTTLE_MS || 300000)); + const key = `${profileId || 'unknown'}::${symbol}`; + const now = Date.now(); + const lastTs = this.exitEvidenceGapLastEmitted.get(key) || 0; + if (now - lastTs < throttleMs) { + return false; + } + this.exitEvidenceGapLastEmitted.set(key, now); + return true; + } + + private getOrderTimestampMs(order: any): number { + const candidates = [ + order?.filled_at, + order?.updated_at, + order?.submitted_at, + order?.created_at, + order?.timestamp + ]; + for (const candidate of candidates) { + if (typeof candidate === 'number' && Number.isFinite(candidate) && candidate > 0) { + return candidate > 1_000_000_000_000 ? candidate : candidate * 1000; + } + const parsed = Date.parse(String(candidate || '').trim()); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + return 0; + } + + private getOrderFillPrice(order: any): number { + const candidates = [order?.filled_avg_price, order?.avg_price, order?.price]; + for (const candidate of candidates) { + const parsed = Number(candidate); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + return 0; + } + + private isFilledLikeOrder(order: any): boolean { + const status = String(order?.status || '').trim().toLowerCase().replace(/-/g, '_'); + return status === 'filled' || status === 'partially_filled'; + } + + private normalizeOrderSide(sideRaw: unknown): 'BUY' | 'SELL' | '' { + const side = String(sideRaw || '').trim().toUpperCase(); + if (side === 'BUY' || side === 'LONG') return 'BUY'; + if (side === 'SELL' || side === 'SHORT') return 'SELL'; + return ''; + } + + private getOrderIdentity(order: any): string { + return String(order?.id || order?.order_id || order?.client_order_id || '').trim(); + } + + private selectLatestClosedFill(orders: any[]): { price: number; source: string; orderId: string } | null { + const ranked = (orders || []) + .map((order) => ({ + isFilledLike: this.isFilledLikeOrder(order), + orderId: this.getOrderIdentity(order), + price: this.getOrderFillPrice(order), + ts: this.getOrderTimestampMs(order) + })) + .filter((row) => row.isFilledLike && row.price > 0); + if (ranked.length === 0) return null; + ranked.sort((a, b) => (b.ts - a.ts) || b.orderId.localeCompare(a.orderId)); + return { + price: ranked[0].price, + source: 'exchange_fill', + orderId: ranked[0].orderId + }; + } + + private findClosedFillForPosition( + localPos: { tradeId?: string; profileId?: string; side?: string }, + orders: any[], + usedOrderIds: Set + ): { price: number; source: string; orderId: string } | null { + const tradeId = String(localPos.tradeId || '').trim(); + const profileId = String(localPos.profileId || '').trim(); + const expectedExitSide = this.normalizeOrderSide(localPos.side === 'BUY' ? 'SELL' : localPos.side === 'SELL' ? 'BUY' : ''); + + const candidates = (orders || []) + .map((order) => { + const orderId = this.getOrderIdentity(order); + const price = this.getOrderFillPrice(order); + const ts = this.getOrderTimestampMs(order); + const isFilledLike = this.isFilledLikeOrder(order); + const orderSide = this.normalizeOrderSide(order?.side); + const subTag = extractOrderSubTag(order); + const orderTradeId = String(order?.trade_id || order?.tradeId || '').trim(); + const clientOrderId = String(order?.client_order_id || order?.clientOrderId || '').trim(); + return { order, orderId, price, ts, isFilledLike, orderSide, subTag, orderTradeId, clientOrderId }; + }) + .filter((row) => { + if (!row.orderId || usedOrderIds.has(row.orderId)) return false; + if (!row.isFilledLike) return false; + if (!(row.price > 0)) return false; + if (expectedExitSide && row.orderSide && row.orderSide !== expectedExitSide) return false; + return true; + }) + .sort((a, b) => (b.ts - a.ts) || b.orderId.localeCompare(a.orderId)); + + const pick = ( + predicate: (row: { + order: any; + orderId: string; + price: number; + ts: number; + isFilledLike: boolean; + orderSide: 'BUY' | 'SELL' | ''; + subTag: string; + orderTradeId: string; + clientOrderId: string; + }) => boolean, + source: string + ) => { + const selected = candidates.find(predicate); + if (!selected) return null; + return { + price: selected.price, + source, + orderId: selected.orderId + }; + }; + + if (tradeId) { + const byTradeId = pick((row) => row.orderTradeId === tradeId, 'exchange_fill:trade_id'); + if (byTradeId) return byTradeId; + + const bySubTagTrade = pick((row) => row.subTag.length > 0 && subTagHintsTrade(row.subTag, tradeId), 'exchange_fill:sub_tag_trade'); + if (bySubTagTrade) return bySubTagTrade; + + const byClientTrade = pick((row) => row.clientOrderId.includes(tradeId), 'exchange_fill:client_order_trade'); + if (byClientTrade) return byClientTrade; + } + + if (profileId) { + const byProfileSubTag = pick((row) => row.subTag.length > 0 && subTagBelongsToProfile(row.subTag, profileId), 'exchange_fill:sub_tag_profile'); + if (byProfileSubTag) return byProfileSubTag; + } + + return pick(() => true, 'exchange_fill:latest'); + } +} diff --git a/backend/src/strategies/ProStrategyEngine.ts b/backend/src/strategies/ProStrategyEngine.ts new file mode 100644 index 0000000..ad0ce43 --- /dev/null +++ b/backend/src/strategies/ProStrategyEngine.ts @@ -0,0 +1,323 @@ +import { config } from '../config/index.js'; +import { IExchangeConnector, Candle } from '../connectors/types.js'; +import { Indicators } from '../utils/indicators.js'; +import { TrendBiasRule } from './rules/TrendBiasRule.js'; +import { MomentumRule } from './rules/MomentumRule.js'; +import { ZoneRule } from './rules/ZoneRule.js'; +import { SessionRule } from './rules/SessionRule.js'; +import { RiskManagementRule } from './rules/RiskManagementRule.js'; +import { EntryTriggerRule } from './rules/EntryTriggerRule.js'; +import { AIAnalysisRule } from './rules/AIAnalysisRule.js'; +import { IRule, MarketContext, RuleResult, SignalDirection } from './rules/types.js'; +import logger from '../utils/logger.js'; + +export class ProStrategyEngine { + private rules: IRule[] = []; + + constructor(private exchange: IExchangeConnector) { + this.initializeRules(); + } + + private initializeRules() { + this.rules = [ + new TrendBiasRule(), + new MomentumRule(), + new ZoneRule(), + new SessionRule(), + new EntryTriggerRule(), + new RiskManagementRule(), + new AIAnalysisRule() + ]; + const enabled = this.rules.filter(r => r.isEnabled()).map(r => r.name); + logger.info(`[ProEngine] Rules initialized. Enabled: ${enabled.join(', ')}`); + } + + public getEnabledRules(): string[] { + return this.rules.filter(r => r.isEnabled()).map(r => r.name); + } + + /** + * Execute strategy for a symbol. + * @param symbol - Trading pair (e.g. BTC/USDT) + * @param profileRules - Optional per-profile rule overrides from strategy_config.rules[]. + * If provided, only rules with enabled=true in this array will execute. + * If not provided, falls back to global ENABLED_RULES from env config. + */ + public async execute(symbol: string, profileRules?: { ruleId: string; enabled: boolean; params?: Record }[]): Promise { + logger.info(`[ProEngine] Analyzing ${symbol} with ${profileRules ? 'profile-specific' : 'global'} rules...`); + const context = await this.buildMarketContext(symbol); + if (!context) return null; + return this.evaluateContext(context, profileRules); + } + + public async buildMarketContext(symbol: string): Promise { + const candles4h = await this.exchange.fetchOHLCV(symbol, '4h', 300); + const candles1h = await this.exchange.fetchOHLCV(symbol, '1h', 100); + const candles15m = await this.exchange.fetchOHLCV(symbol, '15m', 100); + + if (!candles4h.length || !candles1h.length || !candles15m.length) { + logger.warn(`[ProEngine] Insufficient data for ${symbol} analysis`); + return null; + } + + const context = this.buildContext(symbol, candles4h, candles1h, candles15m); + logger.info(`[ProEngine] Data Loaded. Price: ${context.currentPrice}, EMA4H: ${context.ema50_4h?.toFixed(2)}/${context.ema200_4h?.toFixed(2)}, RSI: ${context.rsi_1h?.toFixed(2)}`); + return context; + } + + public async evaluateContext( + context: MarketContext, + profileRules?: { ruleId: string; enabled: boolean; params?: Record; ruleType?: 'mandatory' | 'voting' }[], + minRulePassRatio: number = 1.0 + ): Promise { + const symbol = context.symbol; + + // Determine which rules to run and in what order + const defaultOrder = ['TrendBiasRule', 'SessionRule', 'ZoneRule', 'MomentumRule', 'EntryTriggerRule', 'RiskManagementRule', 'AIAnalysisRule']; + + // If profile provides rules[], use their order (preserves dashboard drag-and-drop ordering) + const executionOrder = (profileRules && profileRules.length > 0) + ? profileRules.map(r => r.ruleId) + : defaultOrder; + + // Build a set of enabled rule IDs for this execution + const enabledRuleIds = new Set(); + const profileRuleMap = new Map; ruleType?: 'mandatory' | 'voting' }>(); + if (profileRules && profileRules.length > 0) { + // Per-profile: only run rules the profile has enabled + for (const pr of profileRules) { + profileRuleMap.set(pr.ruleId, { enabled: pr.enabled, params: pr.params, ruleType: pr.ruleType }); + if (pr.enabled) enabledRuleIds.add(pr.ruleId); + } + } else { + // Global fallback: use all rules that are enabled in global config + for (const rule of this.rules) { + if (rule.isEnabled()) enabledRuleIds.add(rule.name); + } + } + + let finalSignal = SignalDirection.NONE; + let reasons: string[] = []; + let accumulatedMetadata: any = {}; + let ruleStatuses: any = {}; + + // Pre-populate with 'Pending' for enabled rules + for (const rName of executionOrder) { + if (enabledRuleIds.has(rName)) { + ruleStatuses[rName] = { passed: false, reason: 'Pending...', isPending: true }; + } + } + + let firstFailure: { ruleName: string; reason: string } | null = null; + let totalVotingRules = 0; + let passedVotingRules = 0; + const MANDATORY_RULES = ['RiskManagementRule', 'SessionRule']; + let hasConflict = false; + + // 4. Sequential Rule Execution + for (const ruleName of executionOrder) { + const rule = this.rules.find(r => r.name === ruleName); + if (!rule || !enabledRuleIds.has(ruleName)) continue; + + // Determine if rule is mandatory: profile setting takes priority, else default list + const profileSetting = profileRuleMap.get(ruleName)?.ruleType; + const isMandatory = profileSetting === 'mandatory' || (profileSetting === undefined && MANDATORY_RULES.includes(ruleName)); + + if (!isMandatory) totalVotingRules++; + + const ruleResult = await rule.check(context, profileRuleMap.get(ruleName)?.params); + + // Track status for dashboard + ruleStatuses[rule.name] = { + passed: ruleResult.passed, + reason: ruleResult.reason, + metadata: ruleResult.metadata, + isPending: false, + isSkipped: false + }; + + if (!ruleResult.passed) { + if (isMandatory) { + if (!firstFailure) { + firstFailure = { ruleName: rule.name, reason: ruleResult.reason || 'Unknown reason' }; + } + logger.info(`[ProEngine] ❌ Mandatory Rule Failed: ${rule.name} -> ${ruleResult.reason}`); + break; // Short-circuit on mandatory failure + } else { + if (!firstFailure) { + firstFailure = { ruleName: rule.name, reason: ruleResult.reason || 'Unknown reason' }; + } + logger.info(`[ProEngine] ❌ Voting Rule Failed: ${rule.name} -> ${ruleResult.reason}`); + } + } else { + logger.info(`[ProEngine] ✅ Rule Passed: ${rule.name} -> ${ruleResult.reason}`); + if (!isMandatory) passedVotingRules++; + + // Track signal if rule provides one + if (ruleResult.signal && ruleResult.signal !== SignalDirection.NONE) { + if (finalSignal === SignalDirection.NONE) { + finalSignal = ruleResult.signal; + } else if (finalSignal !== ruleResult.signal) { + logger.info(`[ProEngine] ⚠️ Signal Conflict! Existing: ${finalSignal}, New: ${ruleResult.signal} from ${ruleName}`); + hasConflict = true; + ruleStatuses[ruleName].passed = false; + ruleStatuses[ruleName].reason = `Signal (${ruleResult.signal}) conflicts with established bias (${finalSignal})`; + + passedVotingRules--; // Undo passing score due to conflict + + if (!firstFailure) { + firstFailure = { ruleName: ruleName, reason: `Signal conflict: ${ruleResult.signal} vs ${finalSignal}` }; + } + } + } + reasons.push(ruleResult.reason || 'No reason provided'); + } + + if (ruleResult.metadata) { + accumulatedMetadata = { ...accumulatedMetadata, ...ruleResult.metadata }; + } + } + + // Evaluate Voting Threshold + const rulePassRatio = totalVotingRules > 0 ? passedVotingRules / totalVotingRules : 1.0; + const passedThreshold = rulePassRatio >= minRulePassRatio; + + if (firstFailure && MANDATORY_RULES.includes(firstFailure.ruleName)) { + return { + ruleName: firstFailure.ruleName, + passed: false, + signal: SignalDirection.NONE, + reason: `Mandatory rule failed: ${firstFailure.ruleName} - ${firstFailure.reason}`, + metadata: { ...context, ...accumulatedMetadata, ruleStatuses } + }; + } + + if (hasConflict) { + return { + ruleName: firstFailure ? firstFailure.ruleName : 'ConflictResolution', + passed: false, + signal: SignalDirection.NONE, + reason: `Signal conflict detected among voting rules.`, + metadata: { ...context, ...accumulatedMetadata, ruleStatuses } + }; + } + + if (!passedThreshold) { + return { + ruleName: 'VotingThreshold', + passed: false, + signal: SignalDirection.NONE, + reason: `Voting rules threshold not met: ${passedVotingRules}/${totalVotingRules} (${(rulePassRatio * 100).toFixed(0)}% < ${(minRulePassRatio * 100).toFixed(0)}%)`, + metadata: { ...context, ...accumulatedMetadata, ruleStatuses } + }; + } + + return { + ruleName: 'ProStrategyEngine', + passed: true, + signal: finalSignal, + reason: `ALL RULES PASSED:\n- ${reasons.join('\n- ')}`, + metadata: { + accumulatedMetadata, + context, + ruleStatuses // Always include full status on pass too + } + }; + } + + private buildContext(symbol: string, candles4h: Candle[], candles1h: Candle[], candles15m: Candle[]): MarketContext { + const close4h = candles4h.map(c => c.close); + const close1h = candles1h.map(c => c.close); + const close15m = candles15m.map(c => c.close); + + // CHANGE: Use Closed Candle Logic (Index - 2 is the last fully completed candle) + // Index - 1 is the "current" forming candle (if fetching limit=100) + // ACTUALLY: ccxt fetchOHLCV most recent is usually the current (incomplete) one. + // So we strictly take the one BEFORE the last one. + + // Default execution price depends on Config, but context should have all. + // Let's use 1h for "currentPrice" display or the finest grain? + // Finest grain (15m) is safer for display if we are scalping. + + const latestPrice = close15m[close15m.length - 2] || close1h[close1h.length - 2]; + + // We need to slice arrays to match the "Closed" perspective for indicators? + // Usually indicators are calculated on the whole array, and we just look at the value at [length-2]. + // But for simplicity in "Context", let's pass the whole array, but indicators utils should handle it? + // Wait, the Utils calculate on the array. We just need to grab the right index. + + // Let's optimize: The "Context" indicators (ema20_1h) should be the value at [length-2]. + + const closedIndex = close1h.length - 2; + const closedIndex4h = close4h.length - 2; + const closedIndex15m = close15m.length - 2; + + const closed4hData = close4h.slice(0, closedIndex4h + 1); + const closed1hData = close1h.slice(0, closedIndex + 1); + const closed15mData = close15m.slice(0, closedIndex15m + 1); + + logger.info(`[ProEngine] Context Build: Original 1H len=${close1h.length}, Sliced len=${closed1hData.length}`); + + const price24hAgo = close1h[close1h.length - 25] || close1h[0]; + const change24h = ((latestPrice - price24hAgo) / price24hAgo) * 100; + + // Calculate Today's Change (since 00:00 UTC) + const now = new Date(); + const startOfDay = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())).getTime(); + const candleToday = candles1h.find(c => c.timestamp >= startOfDay) || candles1h[0]; + const priceTodayOpen = candleToday.open; + const changeToday = ((latestPrice - priceTodayOpen) / priceTodayOpen) * 100; + + // Determine Active Session + const utcHour = now.getUTCHours(); + let sessions = []; + let isMajorSession = false; + + if (utcHour >= 0 && utcHour < 9) sessions.push('TOK'); + if (utcHour >= 22 || utcHour < 7) sessions.push('SYD'); + + if (utcHour >= 7 && utcHour < 16) { + sessions.push('LDN'); + isMajorSession = true; + } + if (utcHour >= 13 && utcHour < 22) { + sessions.push('NY'); + isMajorSession = true; + } + const sessionStr = sessions.length > 0 ? sessions.join(' | ') : 'OFF'; + + // Calculate Volatility (ATR % of Price) + const atr = Indicators.calculateATR(candles1h.slice(0, closedIndex + 1), 14); + const atrPercent = (atr / latestPrice) * 100; + let volatility = 'Low'; + if (atrPercent > 0.5) volatility = 'Med'; + if (atrPercent > 1.0) volatility = 'High'; + + return { + symbol, + candles4h: candles4h.slice(0, closedIndex4h + 1), + candles1h: candles1h.slice(0, closedIndex + 1), + candles15m: candles15m.slice(0, closedIndex15m + 1), + + currentPrice: latestPrice, + change24h, + changeToday, + session: sessionStr, + isMajorSession, + volatility, + latestSignal: SignalDirection.NONE, // Placeholder + + // 4H Indicators (Closed) + ema50_4h: Indicators.calculateEMA(closed4hData, 50), + ema200_4h: Indicators.calculateEMA(closed4hData, 200), + + // 1H Indicators (Closed) + ema20_1h: Indicators.calculateEMA(closed1hData, 20), + rsi_1h: Indicators.calculateRSI(closed1hData, 14), + + // 15m Indicators (Closed) + ema20_15m: Indicators.calculateEMA(closed15mData, 20), + rsi_15m: Indicators.calculateRSI(closed15mData, 14), + }; + } +} diff --git a/backend/src/strategies/directionTracker.ts b/backend/src/strategies/directionTracker.ts new file mode 100644 index 0000000..24ba0a7 --- /dev/null +++ b/backend/src/strategies/directionTracker.ts @@ -0,0 +1,109 @@ +import logger from '../utils/logger.js'; + +export enum SignalType { + BUY = 'BUY', + SELL = 'SELL', + NONE = 'NONE' +} + +export interface StrategyResult { + signal: SignalType; + ema: number; + rsi: number; + changed: boolean; +} + +export class DirectionTracker { + private lastSignal: SignalType = SignalType.NONE; + + /** + * Advanced Strategy: EMA + RSI + * BUY: Price > EMA-20 AND RSI < 70 (not overbought) + * SELL: Price < EMA-20 AND RSI > 30 (not oversold) + * + * Includes a state machine to prevent duplicate signals. + */ + public calculateDirection(candles: any[]): StrategyResult { + // Need enough data for RSI (14) and EMA (20) + if (candles.length < 20) { + return { signal: SignalType.NONE, ema: 0, rsi: 0, changed: false }; + } + + const latestCandle = candles[candles.length - 1]; + const latestClose = latestCandle.close; + + // 1. Calculate EMA-20 + const ema20 = this.calculateEMA(candles.map(c => c.close), 20); + + // 2. Calculate RSI-14 + const rsi14 = this.calculateRSI(candles.map(c => c.close), 14); + + // 3. Signal Logic + let currentSignal = SignalType.NONE; + + if (latestClose > ema20 && rsi14 < 70) { + currentSignal = SignalType.BUY; + } else if (latestClose < ema20 && rsi14 > 30) { + currentSignal = SignalType.SELL; + } + + // 4. State Machine (Check if signal changed to avoid duplicates) + const changed = currentSignal !== SignalType.NONE && currentSignal !== this.lastSignal; + + if (changed) { + logger.info(`🚨 New Strategy Signal: ${currentSignal} (EMA: ${ema20.toFixed(2)}, RSI: ${rsi14.toFixed(2)})`); + this.lastSignal = currentSignal; + } + + return { + signal: currentSignal, + ema: ema20, + rsi: rsi14, + changed + }; + } + + private calculateEMA(data: number[], period: number): number { + if (data.length === 0) return 0; + const k = 2 / (period + 1); + let ema = data[0] || 0; + for (let i = 1; i < data.length; i++) { + const current = data[i] || 0; + ema = current * k + ema * (1 - k); + } + return ema; + } + + private calculateRSI(data: number[], period: number): number { + if (data.length <= period) return 50; // Default if not enough data + + let gains = 0; + let losses = 0; + + // First average + for (let i = 1; i <= period; i++) { + const diff = data[i] - data[i - 1]; + if (diff >= 0) gains += diff; + else losses -= diff; + } + + let avgGain = gains / period; + let avgLoss = losses / period; + + // Smoothing + for (let i = period + 1; i < data.length; i++) { + const diff = data[i] - data[i - 1]; + if (diff >= 0) { + avgGain = (avgGain * (period - 1) + diff) / period; + avgLoss = (avgLoss * (period - 1)) / period; + } else { + avgGain = (avgGain * (period - 1)) / period; + avgLoss = (avgLoss * (period - 1) - diff) / period; + } + } + + if (avgLoss === 0) return 100; + const rs = avgGain / avgLoss; + return 100 - (100 / (1 + rs)); + } +} diff --git a/backend/src/strategies/rules/AIAnalysisRule.ts b/backend/src/strategies/rules/AIAnalysisRule.ts new file mode 100644 index 0000000..29e59bb --- /dev/null +++ b/backend/src/strategies/rules/AIAnalysisRule.ts @@ -0,0 +1,140 @@ +import { config } from '../../config/index.js'; +import { IRule, MarketContext, RuleResult, SignalDirection } from './types.js'; +import { AIClient } from '../../services/aiClient.js'; +import logger from '../../utils/logger.js'; + +export class AIAnalysisRule implements IRule { + public name = 'AIAnalysisRule'; + private aiClient: AIClient; + // Cache: Map + private static cache = new Map(); + + constructor() { + this.aiClient = new AIClient(); + } + + public isEnabled(): boolean { + // Only enabled if explicitly in list AND at least one API key exists for a provider in the fallback list + const hasKey = !!config.AI.OPENAI_API_KEY || !!config.AI.GEMINI_API_KEY || !!config.AI.PERPLEXITY_API_KEY; + return config.PRO_STRATEGY.ENABLED_RULES.includes('ai_analysis') && hasKey; + } + + public async check(context: MarketContext, params?: Record): Promise { + const symbol = context.symbol; + const now = Date.now(); + const cacheEntry = AIAnalysisRule.cache.get(symbol); + const cacheDuration = (config.AI.CACHE_HOURS || 4) * 60 * 60 * 1000; + + let evaluation: { action: string, confidence: number, reasoning: string }; + let source = 'Fresh'; + + if (cacheEntry && (now - cacheEntry.timestamp < cacheDuration)) { + evaluation = cacheEntry.evaluation; + source = 'Cached'; + logger.info(`[AI] Using cached analysis for ${symbol} (from ${new Date(cacheEntry.timestamp).toISOString().split('T')[1].split('.')[0]} UTC)`); + } else { + const prompt = this.buildPrompt(context); + logger.info(`[AI] Requesting fresh analysis for ${symbol}...`); + const aiResponse = await this.aiClient.generateAnalysis(prompt); + + if (!aiResponse) { + if (config.AI.FAIL_OPEN) { + return { + ruleName: this.name, + passed: true, + signal: SignalDirection.NONE, + reason: 'AI service unavailable; fail-open enabled, bypassing AI gate.', + metadata: { + ai_source: 'unavailable', + ai_fail_open: true + } + }; + } + return { + ruleName: this.name, + passed: false, + signal: SignalDirection.NONE, + reason: 'AI service unavailable; AIAnalysisRule blocking per configured threshold.' + }; + } + + evaluation = this.parseResponse(aiResponse); + AIAnalysisRule.cache.set(symbol, { evaluation, timestamp: now }); + } + + const action = String(evaluation.action || '').toUpperCase(); + const signal = action === 'BUY' ? SignalDirection.BUY : (action === 'SELL' ? SignalDirection.SELL : SignalDirection.NONE); + const threshold = this.normalizeConfidenceToPct( + Number(params?.minConfidence ?? params?.confidenceThreshold ?? config.AI.CONFIDENCE_THRESHOLD ?? 0) + ); + const confidence = this.normalizeConfidenceToPct(Number(evaluation.confidence || 0)); + const rawPassed = signal !== SignalDirection.NONE && confidence >= threshold; + const passThrough = config.AI.FAIL_OPEN && signal === SignalDirection.NONE; + const passed = rawPassed || passThrough; + + return { + ruleName: this.name, + passed, + signal: signal, + reason: passed + ? passThrough + ? `[${source}] AI returned non-directional output; fail-open bypass applied.` + : `[${source}] AI Sentiment accepted: ${evaluation.action} (${confidence}%, threshold ${threshold}%)` + : `[${source}] AI Sentiment rejected: ${evaluation.action} (${confidence}%, threshold ${threshold}%)`, + metadata: { + confidence, + threshold, + reasoning: evaluation.reasoning, + ai_source: source, + ai_timestamp: cacheEntry?.timestamp || now, + ai_fail_open: passThrough + } + }; + } + + private buildPrompt(ctx: MarketContext): string { + // Summarize data + const candles = ctx.candles1h; // Use 1h for general context + const last5 = candles.slice(-5).map(c => + `Time: ${new Date(c.timestamp).toISOString().split('T')[1]}, Close: ${c.close.toFixed(2)}, Vol: ${c.volume}` + ).join('\n'); + + return ` + Analyze the following Crypto Market Data (${ctx.symbol}) and provide a trading confidence score (0-100) for a TREND FOLLOWING trade. + + CONTEXT: + - Trend (4H): ${ctx.ema50_4h && ctx.ema200_4h ? (ctx.ema50_4h > ctx.ema200_4h ? 'UPTREND' : 'DOWNTREND') : 'UNKNOWN'} + - RSI (1H): ${ctx.rsi_1h?.toFixed(2)} + - Current Price: ${ctx.currentPrice} + - EMA20 (1H): ${ctx.ema20_1h?.toFixed(2)} + + RECENT PRICE ACTION (Last 5 Hours): + ${last5} + + TASK: + Return a JSON object with: + - "action": "BUY", "SELL", or "HOLD" + - "confidence": number (0-100) + - "reasoning": string (short summary) + + JSON ONLY. NO MARKDOWN. + `; + } + + private normalizeConfidenceToPct(rawValue: number): number { + if (!Number.isFinite(rawValue) || rawValue < 0) return 0; + if (rawValue <= 1) return Math.round(rawValue * 10000) / 100; + return Math.min(100, rawValue); + } + + private parseResponse(response: string): { action: string, confidence: number, reasoning: string } { + try { + // Clean up markdown code blocks if present + const clean = response.replace(/```json/g, '').replace(/```/g, '').trim(); + return JSON.parse(clean); + } catch (e) { + logger.error('[AI] Failed to parse JSON response', e); + return { action: 'HOLD', confidence: 0, reasoning: 'Parse Error' }; + } + } +} diff --git a/backend/src/strategies/rules/EntryTriggerRule.ts b/backend/src/strategies/rules/EntryTriggerRule.ts new file mode 100644 index 0000000..9683e33 --- /dev/null +++ b/backend/src/strategies/rules/EntryTriggerRule.ts @@ -0,0 +1,110 @@ +import { config } from '../../config/index.js'; +import { IRule, MarketContext, RuleResult, SignalDirection } from './types.js'; + +export class EntryTriggerRule implements IRule { + public name = 'EntryTriggerRule'; + + public isEnabled(): boolean { + return config.PRO_STRATEGY.ENABLED_RULES.includes('entry_trigger'); + } + + public async check(context: MarketContext, params?: Record): Promise { + const requestedTimeframe = String(params?.timeframe || '').toLowerCase(); + const defaultTimeframe = config.PRO_STRATEGY.PARAMETERS.EXECUTION_TIMEFRAME; + const timeframe = requestedTimeframe === '15m' || requestedTimeframe === '1h' + ? requestedTimeframe + : defaultTimeframe; + const wickThresholdRaw = Number(params?.wickRatioThreshold ?? params?.wickThreshold ?? 0.5); + const wickThreshold = Number.isFinite(wickThresholdRaw) + ? Math.max(0.1, Math.min(0.95, wickThresholdRaw)) + : 0.5; + const enableEmaReclaim = params?.enableEmaReclaim !== false; + const enableWickRejection = params?.enableWickRejection !== false + && params?.showPatterns !== false; + + const candles = (timeframe === '15m') ? context.candles15m : context.candles1h; + const ema20 = (timeframe === '15m') ? context.ema20_15m : context.ema20_1h; + + if (candles.length < 3) return { ruleName: this.name, passed: false, reason: 'Insufficient data' }; + + const current = candles[candles.length - 1]; // This is the "Closed" candle we are analyzing + const prev = candles[candles.length - 2]; // The one before it + + if (!ema20) return { ruleName: this.name, passed: false, reason: `No EMA20 data for ${timeframe}` }; + + // We need to know the INTENDED direction (Bias). + // Since we don't have the Bias passed in directly here easily without coupling, + // we can infer it or checking if the rule engine passes context metadata. + // For simplicity, let's check for ANY valid trigger. + + // TRIGGER 1: EMA RECLAIM (Bullish) + // Previous CLose < EMA, Current Close > EMA + const isBullishReclaim = prev.close < ema20 && current.close > ema20; + + // TRIGGER 2: REJECTION WICK (Bullish) + // Lower wick is > 50% of total candle size + const totalSize = current.high - current.low; + if (totalSize <= 0) { + return { + ruleName: this.name, + passed: false, + signal: SignalDirection.NONE, + reason: 'Flat/invalid candle range; wick ratio check skipped' + }; + } + const lowerWick = Math.min(current.open, current.close) - current.low; + const isBullishWick = (lowerWick / totalSize) > wickThreshold; + + // TRIGGER 3: RSI TURN (Bullish) + // RSI crossed above 30 or 50? + // Let's stick to Candle Patterns for now as requested. + + if (enableEmaReclaim && isBullishReclaim) { + return { + ruleName: this.name, + passed: true, + signal: SignalDirection.BUY, + reason: 'Entry Trigger: EMA20 Reclaim (Price crossed back above EMA)' + }; + } + + if (enableWickRejection && isBullishWick) { + return { + ruleName: this.name, + passed: true, + signal: SignalDirection.BUY, + reason: `Entry Trigger: Bullish Rejection Wick detected (threshold ${wickThreshold.toFixed(2)})` + }; + } + + // Bearish Logic (Inverse) + const isBearishReclaim = prev.close > ema20 && current.close < ema20; + const upperWick = current.high - Math.max(current.open, current.close); + const isBearishWick = (upperWick / totalSize) > wickThreshold; + + if (enableEmaReclaim && isBearishReclaim) { + return { + ruleName: this.name, + passed: true, + signal: SignalDirection.SELL, + reason: 'Entry Trigger: EMA20 Loss (Price crossed back below EMA)' + }; + } + + if (enableWickRejection && isBearishWick) { + return { + ruleName: this.name, + passed: true, + signal: SignalDirection.SELL, + reason: `Entry Trigger: Bearish Rejection Wick detected (threshold ${wickThreshold.toFixed(2)})` + }; + } + + return { + ruleName: this.name, + passed: false, + signal: SignalDirection.NONE, + reason: 'No specific entry trigger (Wait for EMA Reclaim or Wick)' + }; + } +} diff --git a/backend/src/strategies/rules/MomentumRule.ts b/backend/src/strategies/rules/MomentumRule.ts new file mode 100644 index 0000000..c5011da --- /dev/null +++ b/backend/src/strategies/rules/MomentumRule.ts @@ -0,0 +1,62 @@ +import { config } from '../../config/index.js'; +import { IRule, MarketContext, RuleResult, SignalDirection } from './types.js'; +import { Indicators } from '../../utils/indicators.js'; + +export class MomentumRule implements IRule { + public name = 'MomentumRule'; + + public isEnabled(): boolean { + return config.PRO_STRATEGY.ENABLED_RULES.includes('momentum'); + } + + public async check(context: MarketContext, params?: Record): Promise { + const timeframe = (params?.timeframe as string) || config.PRO_STRATEGY.PARAMETERS.MOMENTUM_TIMEFRAME; + const rsiPeriod = Number(params?.rsiPeriod ?? config.PRO_STRATEGY.PARAMETERS.RSI_PERIOD); + const overbought = Number(params?.overbought ?? config.PRO_STRATEGY.PARAMETERS.RSI_OVERBOUGHT); + const oversold = Number(params?.oversold ?? config.PRO_STRATEGY.PARAMETERS.RSI_OVERSOLD); + const candles = timeframe === '15m' ? context.candles15m : context.candles1h; + const closes = candles.map(c => c.close); + const rsi = Indicators.calculateRSI(closes, rsiPeriod); + + if (rsi === undefined) { + return { + ruleName: this.name, + passed: false, + reason: `Insufficient ${timeframe} data for RSI(${rsiPeriod})` + }; + } + + const isMajor = context.isMajorSession; + const buyThreshold = isMajor ? 50 : 60; + const sellThreshold = isMajor ? 50 : 40; + + let signal = SignalDirection.NONE; + let passed = false; + let reason = ''; + + if (rsi > buyThreshold && rsi < overbought) { + signal = SignalDirection.BUY; + passed = true; + reason = isMajor + ? `Bullish Momentum (RSI ${rsi.toFixed(1)} > 50)` + : `Strong Bullish Momentum Required (RSI ${rsi.toFixed(1)} > 60)`; + } else if (rsi < sellThreshold && rsi > oversold) { + signal = SignalDirection.SELL; + passed = true; + reason = isMajor + ? `Bearish Momentum (RSI ${rsi.toFixed(1)} < 50)` + : `Strong Bearish Momentum Required (RSI ${rsi.toFixed(1)} < 40)`; + } else { + reason = isMajor + ? `Momentum weak or extreme (RSI ${rsi.toFixed(1)})` + : `Momentum insufficient for Minor Session (RSI ${rsi.toFixed(1)} needs >60 or <40)`; + } + + return { + ruleName: this.name, + passed, + signal, + reason + }; + } +} diff --git a/backend/src/strategies/rules/RiskManagementRule.ts b/backend/src/strategies/rules/RiskManagementRule.ts new file mode 100644 index 0000000..580d540 --- /dev/null +++ b/backend/src/strategies/rules/RiskManagementRule.ts @@ -0,0 +1,99 @@ +import { config } from '../../config/index.js'; +import { IRule, MarketContext, RuleResult, SignalDirection } from './types.js'; + +export class RiskManagementRule implements IRule { + public name = 'RiskManagementRule'; + + public isEnabled(): boolean { + return config.PRO_STRATEGY.ENABLED_RULES.includes('risk_management'); + } + + public async check(context: MarketContext, params?: Record): Promise { + const atrPeriod = Number(params?.atrPeriod ?? config.PRO_STRATEGY.PARAMETERS.ATR_PERIOD); + const slMultiplier = Number(params?.slMultiplier ?? config.PRO_STRATEGY.PARAMETERS.SL_MULTIPLIER); + let maxRiskPercent = Number(params?.maxRisk ?? 5); + if (Number.isFinite(maxRiskPercent) && maxRiskPercent > 0 && maxRiskPercent <= 1) { + maxRiskPercent = maxRiskPercent * 100; + } + + // Need candles to calculate ATR + // Let's assume we use 1H candles for ATR calculation (faster reaction than 4H) + if (context.candles1h.length < 20) { + return { + ruleName: this.name, + passed: false, + reason: 'Insufficient 1H data for ATR Risk Calc' + }; + } + + const atr = this.calculateATR(context.candles1h, atrPeriod); + const currentPrice = context.currentPrice; + + // Calculate Stop Loss Distance + // Standard: 1.5 * ATR + const slDistance = atr * slMultiplier; + const riskPercent = currentPrice > 0 ? (slDistance / currentPrice) * 100 : 0; + + // Calculate Position Risk + // If we have a $1000 account and risk 1% = $10 risk. + // Size = RiskAmount / SL_Distance + // Since we don't have account balance connected here easily, we will return the "Unit Size" multiplier. + // Or simply return the calculated SL prices. + + const longSL = currentPrice - slDistance; + const shortSL = currentPrice + slDistance; + const isWithinRiskLimit = !Number.isFinite(maxRiskPercent) || maxRiskPercent <= 0 || riskPercent <= maxRiskPercent; + + return { + ruleName: this.name, + passed: isWithinRiskLimit, + signal: SignalDirection.NONE, + reason: isWithinRiskLimit + ? `Risk Calculated (ATR: ${atr.toFixed(2)}, stop distance ${riskPercent.toFixed(2)}%)` + : `Risk too high (${riskPercent.toFixed(2)}% > max ${maxRiskPercent.toFixed(2)}%)`, + metadata: { + atr, + atrPeriod, + slDistance, + slMultiplier, + riskPercent, + maxRiskPercent, + suggestedStopLoss: { + buy: longSL, + sell: shortSL + } + } + }; + } + + private calculateATR(candles: any[], period: number): number { + // Simple TR calculation + let trSum = 0; + + // Need at least period + 1 candles to start + if (candles.length <= period) return 0; + + // Calculate TR for the last 'period' candles + // Note: standard ATR is smoothed, this is a simple average for robustness in this snippet + // Better: standard Wilder's smoothing + + // Simplified Average TR for stability + const recentCandles = candles.slice(-period); + let sumTR = 0; + + for (let i = 1; i < recentCandles.length; i++) { + const high = recentCandles[i].high; + const low = recentCandles[i].low; + const prevClose = recentCandles[i - 1].close; + + const tr = Math.max( + high - low, + Math.abs(high - prevClose), + Math.abs(low - prevClose) + ); + sumTR += tr; + } + + return sumTR / (period - 1); + } +} diff --git a/backend/src/strategies/rules/SessionRule.ts b/backend/src/strategies/rules/SessionRule.ts new file mode 100644 index 0000000..4b9f427 --- /dev/null +++ b/backend/src/strategies/rules/SessionRule.ts @@ -0,0 +1,125 @@ +import { config } from '../../config/index.js'; +import { IRule, MarketContext, RuleResult, SignalDirection } from './types.js'; + +export class SessionRule implements IRule { + public name = 'SessionRule'; + private readonly defaultAllowedSessions = ['LDN', 'NY']; + private readonly allSessions = ['LDN', 'NY', 'TOK', 'SYD']; + + public isEnabled(): boolean { + return config.PRO_STRATEGY.ENABLED_RULES.includes('session'); + } + + public async check(context: MarketContext, params?: Record): Promise { + const sessionName = context.session; + const activeSessions = sessionName === 'OFF' + ? [] + : sessionName.split('|').map(s => s.trim()).filter(Boolean); + const allowedSessions = this.normalizeAllowedSessions(params?.sessions ?? params?.allowedSessions); + + // ── 24/7 Mode Detection ───────────────────────────────────────────── + // If all four major sessions are allowed, it means the profile is set + // to trade 24/7. Skip all session-time checks and always pass. + const is24h = this.allSessions.every(s => allowedSessions.includes(s)); + if (is24h) { + return { + ruleName: this.name, + passed: true, + signal: SignalDirection.NONE, + reason: `24/7 mode: all sessions allowed. Current session: ${sessionName}.` + }; + } + + // ── Restricted Session Mode ────────────────────────────────────────── + // The profile only allows a subset of sessions (e.g. LDN,NY only). + // Pass if the active session is explicitly in the allowed list. + if (sessionName === 'OFF') { + return { + ruleName: this.name, + passed: false, + signal: SignalDirection.NONE, + reason: 'Off-session blocked by SessionRule.' + }; + } + + const hasAllowedSession = activeSessions.some(s => allowedSessions.includes(s)); + if (hasAllowedSession) { + return { + ruleName: this.name, + passed: true, + signal: SignalDirection.NONE, + reason: `Session ${sessionName} is in allowed set (${allowedSessions.join(', ')}).` + }; + } + + return { + ruleName: this.name, + passed: false, + signal: SignalDirection.NONE, + reason: `Session ${sessionName} not in allowed set (${allowedSessions.join(', ')}).` + }; + } + + private normalizeAllowedSessions(raw: unknown): string[] { + if (raw === undefined || raw === null) { + return this.defaultAllowedSessions; + } + + // ── Literal '24/7' shorthand ────────────────────────────────── + // The UI stores '24/7' as a literal string when the user selects + // "Trade 24/7". Expand it to all four session codes immediately. + const rawTokens = (Array.isArray(raw) + ? raw.map(v => String(v)) + : String(raw).split(/[,\|]/)) + .map(token => token.trim()) + .filter(Boolean); + if (rawTokens.length === 0) { + return this.defaultAllowedSessions; + } + + const normalizedTokens = rawTokens + .map(token => this.normalizeSessionToken(token)) + .filter(Boolean) as string[]; + + const has24x7Token = normalizedTokens.some(token => this.isAllSessionsToken(token)); + if (has24x7Token) { + return [...this.allSessions]; + } + + const aliases: Record = { + LONDON: 'LDN', + LDN: 'LDN', + NEWYORK: 'NY', + NY: 'NY', + TOKYO: 'TOK', + TOK: 'TOK', + SYDNEY: 'SYD', + SYD: 'SYD' + }; + + const normalized = normalizedTokens + .map(s => aliases[s] || s) + .filter(Boolean); + + const deduped = Array.from(new Set(normalized)); + return deduped.length > 0 ? deduped : this.defaultAllowedSessions; + } + + private isAllSessionsToken(token: string): boolean { + const compact = String(token || '').replace(/[^A-Z0-9]/g, ''); + return compact === '247' + || compact === '24X7' + || compact === 'ALL' + || compact === 'ALLSESSIONS'; + } + + private normalizeSessionToken(rawToken: string): string { + return String(rawToken || '') + .normalize('NFKC') + .toUpperCase() + .replace(/[⁄∕/\u2215\u2044\u29F8\uFF0F]/g, '/') + .replace(/[×✕✖✗]/g, 'X') + .replace(/[\s_-]+/g, '') + .trim(); + } +} diff --git a/backend/src/strategies/rules/TrendBiasRule.ts b/backend/src/strategies/rules/TrendBiasRule.ts new file mode 100644 index 0000000..eaa9ccc --- /dev/null +++ b/backend/src/strategies/rules/TrendBiasRule.ts @@ -0,0 +1,64 @@ +import { config } from '../../config/index.js'; +import { IRule, MarketContext, RuleResult, SignalDirection } from './types.js'; +import { Indicators } from '../../utils/indicators.js'; + +export class TrendBiasRule implements IRule { + public name = 'TrendBiasRule'; + + public isEnabled(): boolean { + return config.PRO_STRATEGY.ENABLED_RULES.includes('trend_bias'); + } + + public async check(context: MarketContext, params?: Record): Promise { + const fastPeriod = Number(params?.fastPeriod ?? 50); + const slowPeriod = Number(params?.slowPeriod ?? 200); + if (!Number.isFinite(fastPeriod) || !Number.isFinite(slowPeriod) || fastPeriod <= 0 || slowPeriod <= 0 || fastPeriod >= slowPeriod) { + return { + ruleName: this.name, + passed: false, + reason: `Invalid TrendBias params (fast=${fastPeriod}, slow=${slowPeriod})` + }; + } + + const close4h = context.candles4h.map(c => c.close); + if (close4h.length < slowPeriod + 1) { + return { + ruleName: this.name, + passed: false, + reason: `Insufficient 4H data for EMA${fastPeriod}/EMA${slowPeriod}` + }; + } + + const price = context.currentPrice; + const emaFast = Indicators.calculateEMA(close4h, fastPeriod); + const emaSlow = Indicators.calculateEMA(close4h, slowPeriod); + + // Uptrend: Price > 50 > 200 + if (price > emaFast && emaFast > emaSlow) { + return { + ruleName: this.name, + passed: true, + signal: SignalDirection.BUY, + reason: `4H Uptrend (Price > EMA${fastPeriod} > EMA${slowPeriod})` + }; + } + + // Downtrend: Price < 50 < 200 + if (price < emaFast && emaFast < emaSlow) { + return { + ruleName: this.name, + passed: true, + signal: SignalDirection.SELL, + reason: `4H Downtrend (Price < EMA${fastPeriod} < EMA${slowPeriod})` + }; + } + + // Ranging / No Clear Trend + return { + ruleName: this.name, + passed: false, + signal: SignalDirection.NONE, + reason: `No clear 4H trend bias for EMA${fastPeriod}/EMA${slowPeriod}` + }; + } +} diff --git a/backend/src/strategies/rules/ZoneRule.ts b/backend/src/strategies/rules/ZoneRule.ts new file mode 100644 index 0000000..bf1ebed --- /dev/null +++ b/backend/src/strategies/rules/ZoneRule.ts @@ -0,0 +1,50 @@ +import { config } from '../../config/index.js'; +import { IRule, MarketContext, RuleResult, SignalDirection } from './types.js'; + +export class ZoneRule implements IRule { + public name = 'ZoneRule'; + + public isEnabled(): boolean { + return config.PRO_STRATEGY.ENABLED_RULES.includes('zone'); + } + + public async check(context: MarketContext, params?: Record): Promise { + const timeframe = (params?.timeframe as string) || config.PRO_STRATEGY.PARAMETERS.EXECUTION_TIMEFRAME; + const ema20 = (timeframe === '15m') ? context.ema20_15m : context.ema20_1h; + + if (!ema20) { + return { + ruleName: this.name, + passed: false, + reason: `Insufficient ${timeframe} data for Zone check` + }; + } + + const price = context.currentPrice; + + // Calculate "Extension" from EMA + // If price is > 1-2% away from EMA, it's extended. + const diffPercent = Math.abs(price - ema20) / ema20 * 100; + const rawMaxExtension = Number(params?.zonePercent ?? 1.5); + const MAX_EXTENSION = Number.isFinite(rawMaxExtension) && rawMaxExtension > 0 ? rawMaxExtension : 1.5; + + // This rule validates "Location". + // Ideally we want to buy on a pullback, close to the EMA. + // If we are too far, we skip. + + let passed = true; + let reason = `Price is within ${diffPercent.toFixed(2)}% of EMA20 (Value Zone)`; + + if (diffPercent > MAX_EXTENSION) { + passed = false; + reason = `Price overextended (${diffPercent.toFixed(2)}% > ${MAX_EXTENSION}%) from EMA20. Wait for pullback.`; + } + + return { + ruleName: this.name, + passed, + signal: SignalDirection.NONE, // This rule filters, doesn't signal direction itself + reason + }; + } +} diff --git a/backend/src/strategies/rules/types.ts b/backend/src/strategies/rules/types.ts new file mode 100644 index 0000000..265adb6 --- /dev/null +++ b/backend/src/strategies/rules/types.ts @@ -0,0 +1,50 @@ +import type { Candle } from '../../connectors/types.js'; +export type { Candle }; + +export enum SignalDirection { + BUY = 'BUY', + SELL = 'SELL', + NONE = 'NONE' +} + +export interface MarketContext { + // Candles + symbol: string; + candles4h: Candle[]; + candles1h: Candle[]; + candles15m: Candle[]; + + // Core Indicators (Pre-calculated for efficiency) + ema20_1h?: number; + ema50_1h?: number; + ema50_4h?: number; + ema200_4h?: number; + rsi_1h?: number; + + // 15m Indicators (Phase 2) + ema20_15m?: number; + rsi_15m?: number; + + // Current State + currentPrice: number; + change24h: number; + changeToday: number; + session: string; + isMajorSession: boolean; + volatility: string; + latestSignal: SignalDirection; +} + +export interface RuleResult { + ruleName: string; + passed: boolean; // Did this rule allow the trade? + signal?: SignalDirection; // Does this rule suggest a direction? + reason?: string; // Human-readable explanation (e.g., "RSI is 28 (Oversold)") + metadata?: any; // Extra data (e.g., calculated risk size) +} + +export interface IRule { + name: string; + isEnabled(): boolean; + check(context: MarketContext, params?: Record): Promise; +} diff --git a/backend/src/test_simulation.js b/backend/src/test_simulation.js new file mode 100644 index 0000000..2aea7e5 --- /dev/null +++ b/backend/src/test_simulation.js @@ -0,0 +1,71 @@ +// --- Mocking Configuration for Logic Test --- +const config = { + SYMBOL: 'BTC/USD', + LOW_STRESS_MODE: true +}; + +const logger = { + info: (msg) => console.log(`[INFO] ${msg}`), + error: (msg) => console.log(`[ERROR] ${msg}`) +}; + +// --- Mocking Notifier --- +const notifier = { + sendAlert: async (msg) => { + console.log('\n--- DISCORD ALERT SENT ---'); + console.log(msg); + console.log('--------------------------\n'); + } +}; + +async function runSimulation() { + console.log('🚀 Starting Low-Stress Mode Simulation Test (JS Version)...\n'); + + let entryPrice = null; + let lastKnownPrice = 0; + + // --- STEP 1: Simulate BUY Signal --- + console.log('Step 1: Simulating a BUY Signal at $60,000...'); + lastKnownPrice = 60000; + + // Simulate what happens in index.ts when result.changed is true + if (config.LOW_STRESS_MODE) { + entryPrice = lastKnownPrice; + logger.info(`[Low-Stress] Entry Price set at ${entryPrice}`); + await notifier.sendAlert(`🚨 *Trend Signal Alert* 🚨\nSignal: BUY\nAsset: ${config.SYMBOL}\nPrice: ${lastKnownPrice}`); + } + + // --- STEP 2: Simulate Price Movement (Monitoring) --- + const simulatePrice = async (currentPrice) => { + lastKnownPrice = currentPrice; + if (config.LOW_STRESS_MODE && entryPrice) { + const percentChange = ((lastKnownPrice - entryPrice) / entryPrice) * 100; + logger.info(`[Low-Stress] Monitoring: ${percentChange.toFixed(2)}% | Price: ${lastKnownPrice}`); + + if (percentChange >= 1.5) { + const tpMessage = `💰 *Take Profit Target Reached (+1.5%)* 💰\nAsset: ${config.SYMBOL}\nEntry: ${entryPrice}\nCurrent: ${lastKnownPrice}\nProfit: ${percentChange.toFixed(2)}%\nPlan: $10/Day Low-Stress ✅`; + await notifier.sendAlert(tpMessage); + entryPrice = null; // Reset + } else if (percentChange <= -0.7) { + const slMessage = `⚠️ *Stop Loss Buffer Hit (-0.7%)* ⚠️\nAsset: ${config.SYMBOL}\nEntry: ${entryPrice}\nCurrent: ${lastKnownPrice}\nLoss: ${percentChange.toFixed(2)}%\nPlan: Risk Managed 🛡️`; + await notifier.sendAlert(slMessage); + entryPrice = null; // Reset + } + } + }; + + console.log('\nStep 2: Price moves to $60,500 (+0.83%)...'); + await simulatePrice(60500); + + console.log('\nStep 3: Price moves to $60,950 (+1.58%)... (TP SHOULD TRIGGER)'); + await simulatePrice(60950); + + console.log('\nStep 4: Restarting for Stop Loss Test at $60,000...'); + entryPrice = 60000; + console.log('\nStep 5: Price drops to $59,500 (-0.83%)... (SL SHOULD TRIGGER)'); + await simulatePrice(59500); + + console.log('\n✅ Simulation Test Complete.'); +} + +runSimulation(); diff --git a/backend/src/utils/alpacaSubTag.ts b/backend/src/utils/alpacaSubTag.ts new file mode 100644 index 0000000..ef0ae5f --- /dev/null +++ b/backend/src/utils/alpacaSubTag.ts @@ -0,0 +1,232 @@ +import { createHash } from 'crypto'; +import { config } from '../config/index.js'; + +export type AlpacaSubTagIntent = 'ENTRY' | 'EXIT' | 'UNKNOWN'; + +export interface BuildAlpacaSubTagInput { + profileId: string; + tradeId?: string; + intent?: AlpacaSubTagIntent; + envLabel?: string; + maxLength?: number; +} + +type ParsedCompactSubTag = { + format: 'compact'; + raw: string; + env: string; + profileToken: string; + tradeToken: string; + intent: string; +}; + +type ParsedLegacySubTag = { + format: 'legacy'; + raw: string; + env: string; + profileSegment: string; + tradeSegment: string; +}; + +type ParsedSubTag = ParsedCompactSubTag | ParsedLegacySubTag; + +const SUBTAG_PREFIX = 'BL'; + +const cleanToken = (value: unknown, maxLen: number): string => { + const normalized = String(value || '') + .trim() + .toUpperCase() + .replace(/[^A-Z0-9_-]/g, ''); + if (!normalized) return ''; + return normalized.slice(0, Math.max(1, maxLen)); +}; + +const hashToken = (value: unknown, length: number): string => { + const digest = createHash('sha1') + .update(String(value || '').trim()) + .digest('hex') + .toUpperCase(); + return digest.slice(0, Math.max(1, length)); +}; + +const normalizeProfileId = (profileId: unknown): string => String(profileId || '').trim(); + +const resolveEnvToken = (inputEnv?: string): string => { + const explicit = cleanToken(inputEnv || config.ALPACA_SUBTAG_ENV || '', 12); + if (explicit) return explicit; + return config.PAPER_TRADING ? 'PAPER' : 'LIVE'; +}; + +const resolveMaxLength = (override?: number): number => { + const parsed = Number(override ?? config.ALPACA_SUBTAG_MAX_LENGTH); + if (!Number.isFinite(parsed) || parsed < 24) return 48; + return Math.floor(parsed); +}; + +const parseAllowlist = (): Set => { + const values = Array.isArray(config.ALPACA_OMNIBUS_PROFILE_ALLOWLIST) + ? config.ALPACA_OMNIBUS_PROFILE_ALLOWLIST + : []; + return new Set(values.map((item) => normalizeProfileId(item)).filter(Boolean)); +}; + +const parseDisabledExecutionExchanges = (): Set => { + const values = Array.isArray(config.ALPACA_SUBTAG_DISABLE_FOR_EXCHANGE) + ? config.ALPACA_SUBTAG_DISABLE_FOR_EXCHANGE + : []; + return new Set( + values + .map((item) => String(item || '').trim().toLowerCase()) + .filter(Boolean) + ); +}; + +const resolveExecutionExchangeKeys = (): string[] => { + const provider = String(config.EXECUTION_PROVIDER || '').trim().toLowerCase(); + if (!provider) return []; + if (provider !== 'ccxt') return [provider]; + + const exchange = String(config.EXCHANGE || '').trim().toLowerCase(); + return exchange ? [provider, exchange] : [provider]; +}; + +const isTruthyFlag = (value: unknown): boolean => { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + return ['true', '1', 'yes', 'on', 'y'].includes(normalized); + } + if (typeof value === 'number') return value === 1; + return false; +}; + +const resolveOmnibusFromProfile = (profileSettings?: any): boolean => { + if (!profileSettings || typeof profileSettings !== 'object') return false; + + const execution = profileSettings?.strategy_config?.execution || {}; + const modeCandidates = [ + profileSettings?.mode, + profileSettings?.profile_mode, + profileSettings?.broker_mode, + execution?.mode, + execution?.profileMode, + execution?.brokerMode + ] + .map((value) => String(value || '').trim().toLowerCase()) + .filter(Boolean); + if (modeCandidates.some((value) => value === 'omnibus')) { + return true; + } + + const flagCandidates = [ + profileSettings?.is_omnibus, + profileSettings?.is_omnibus_profile, + profileSettings?.isOmnibus, + profileSettings?.isOmnibusProfile, + execution?.is_omnibus, + execution?.is_omnibus_profile, + execution?.isOmnibus, + execution?.isOmnibusProfile + ]; + return flagCandidates.some((value) => isTruthyFlag(value)); +}; + +const parseBytelystSubTag = (subTagRaw: string): ParsedSubTag | null => { + const raw = String(subTagRaw || '').trim(); + if (!raw) return null; + + const parts = raw.split(':').map((part) => part.trim()); + if (parts.length < 4) return null; + if (parts[0].toUpperCase() !== SUBTAG_PREFIX) return null; + + if (parts.length >= 5 && /^P[A-F0-9]+$/i.test(parts[2]) && /^T[A-F0-9]+$/i.test(parts[3])) { + return { + format: 'compact', + raw, + env: cleanToken(parts[1], 16), + profileToken: parts[2].toUpperCase(), + tradeToken: parts[3].toUpperCase(), + intent: cleanToken(parts[4], 8) + }; + } + + return { + format: 'legacy', + raw, + env: cleanToken(parts[1], 16), + profileSegment: cleanToken(parts[2], 64), + tradeSegment: cleanToken(parts[3], 128) + }; +}; + +export const isBytelystSubTag = (subTagRaw: string): boolean => { + const parsed = parseBytelystSubTag(subTagRaw); + return !!parsed; +}; + +export const extractOrderSubTag = (order: any): string => { + return String(order?.subtag || order?.sub_tag || order?.subTag || '').trim(); +}; + +export const shouldAttachAlpacaSubTag = (args: { + profileId?: string; + profileSettings?: any; +}): boolean => { + if (!config.ENABLE_ALPACA_SUBTAG) return false; + const disabledTargets = parseDisabledExecutionExchanges(); + const executionKeys = resolveExecutionExchangeKeys(); + if (executionKeys.some((key) => disabledTargets.has(key))) return false; + if (String(config.EXECUTION_PROVIDER || '').trim().toLowerCase() !== 'alpaca') return false; + if (!config.SUBTAG_OMNIBUS_ONLY) return true; + + const profileId = normalizeProfileId(args.profileId); + if (!profileId) return false; + if (resolveOmnibusFromProfile(args.profileSettings)) return true; + + return parseAllowlist().has(profileId); +}; + +export const buildAlpacaSubTag = (input: BuildAlpacaSubTagInput): string | null => { + const profileId = normalizeProfileId(input.profileId); + if (!profileId) return null; + + const envToken = resolveEnvToken(input.envLabel); + const intentToken = cleanToken(input.intent || 'UNKNOWN', 6) || 'UNKNOWN'; + const profileToken = `P${hashToken(profileId, 10)}`; + const tradeSeed = String(input.tradeId || input.intent || 'UNKNOWN').trim(); + const tradeToken = `T${hashToken(tradeSeed, 12)}`; + + const value = `${SUBTAG_PREFIX}:${envToken}:${profileToken}:${tradeToken}:${intentToken}`; + const maxLength = resolveMaxLength(input.maxLength); + return value.length > maxLength ? value.slice(0, maxLength) : value; +}; + +export const subTagBelongsToProfile = (subTagRaw: string, profileId: string): boolean => { + const parsed = parseBytelystSubTag(subTagRaw); + if (!parsed) return false; + + const normalizedProfileId = normalizeProfileId(profileId); + if (!normalizedProfileId) return false; + + if (parsed.format === 'compact') { + return parsed.profileToken === `P${hashToken(normalizedProfileId, 10)}`; + } + + const profileToken = cleanToken(normalizedProfileId, 64); + return parsed.profileSegment === profileToken; +}; + +export const subTagHintsTrade = (subTagRaw: string, tradeId: string): boolean => { + const parsed = parseBytelystSubTag(subTagRaw); + if (!parsed) return false; + + const normalizedTradeId = String(tradeId || '').trim(); + if (!normalizedTradeId) return false; + + if (parsed.format === 'compact') { + return parsed.tradeToken === `T${hashToken(normalizedTradeId, 12)}`; + } + + const tradeToken = cleanToken(normalizedTradeId, 128); + return parsed.tradeSegment === tradeToken; +}; diff --git a/backend/src/utils/botSymbolScope.ts b/backend/src/utils/botSymbolScope.ts new file mode 100644 index 0000000..1b3de19 --- /dev/null +++ b/backend/src/utils/botSymbolScope.ts @@ -0,0 +1,57 @@ +import { config } from '../config/index.js'; +import { SymbolMapper } from './symbolMapper.js'; + +export const normalizeBotSymbolToken = (symbolRaw: string | null | undefined): string => { + const compact = String(symbolRaw || '').trim().toUpperCase().replace(/[^A-Z0-9]/g, ''); + if (compact.endsWith('USDT')) { + return `${compact.slice(0, -4)}USD`; + } + return compact; +}; + +const deriveSymbolVariants = (symbolRaw: string): string[] => { + const seed = String(symbolRaw || '').trim(); + if (!seed) return []; + + const variants = new Set([seed]); + const executionProvider = String(config.EXECUTION_PROVIDER || '').trim(); + try { + variants.add(SymbolMapper.toTradeSymbol(seed, executionProvider)); + } catch { + // Keep raw variant when mapper does not support a symbol/provider combination. + } + + try { + variants.add(SymbolMapper.toDataSymbol(seed, executionProvider)); + } catch { + // Keep raw variant when mapper does not support a symbol/provider combination. + } + + return Array.from(variants).filter(Boolean); +}; + +export const buildManagedBotSymbolTokenSet = (symbols?: string[]): Set => { + const configuredSymbols = Array.isArray(symbols) && symbols.length > 0 + ? symbols + : (Array.isArray(config.SYMBOLS) ? config.SYMBOLS : []); + + const tokens = new Set(); + for (const configured of configuredSymbols) { + for (const variant of deriveSymbolVariants(String(configured || ''))) { + const token = normalizeBotSymbolToken(variant); + if (token) tokens.add(token); + } + } + + return tokens; +}; + +export const isManagedBotSymbol = ( + symbolRaw: string | null | undefined, + managedTokens?: Set +): boolean => { + const token = normalizeBotSymbolToken(symbolRaw); + if (!token) return false; + const scope = managedTokens || buildManagedBotSymbolTokenSet(); + return scope.has(token); +}; diff --git a/backend/src/utils/indicators.ts b/backend/src/utils/indicators.ts new file mode 100644 index 0000000..e032a5f --- /dev/null +++ b/backend/src/utils/indicators.ts @@ -0,0 +1,62 @@ +export class Indicators { + static calculateEMA(data: number[], period: number): number { + if (data.length === 0) return 0; + const k = 2 / (period + 1); + let ema = data[0] || 0; + for (let i = 1; i < data.length; i++) { + const current = data[i] || 0; + ema = current * k + ema * (1 - k); + } + return ema; + } + + static calculateRSI(data: number[], period: number): number { + if (data.length <= period) return 50; // Default if not enough data + + let gains = 0; + let losses = 0; + + // First average + for (let i = 1; i <= period; i++) { + const diff = data[i] - data[i - 1]; + if (diff >= 0) gains += diff; + else losses -= diff; + } + + let avgGain = gains / period; + let avgLoss = losses / period; + + // Smoothing + for (let i = period + 1; i < data.length; i++) { + const diff = data[i] - data[i - 1]; + if (diff >= 0) { + avgGain = (avgGain * (period - 1) + diff) / period; + avgLoss = (avgLoss * (period - 1)) / period; + } else { + avgGain = (avgGain * (period - 1)) / period; + avgLoss = (avgLoss * (period - 1) - diff) / period; + } + } + + if (avgLoss === 0) return 100; + const rs = avgGain / avgLoss; + return 100 - (100 / (1 + rs)); + } + + static calculateATR(candles: any[], period: number): number { + if (candles.length <= period) return 0; + + let trs: number[] = []; + for (let i = 1; i < candles.length; i++) { + const h = candles[i].high; + const l = candles[i].low; + const pc = candles[i - 1].close; + const tr = Math.max(h - l, Math.abs(h - pc), Math.abs(l - pc)); + trs.push(tr); + } + + // Return SMA of TR + const recentTrs = trs.slice(-period); + return recentTrs.reduce((a, b) => a + b, 0) / period; + } +} diff --git a/backend/src/utils/logger.ts b/backend/src/utils/logger.ts new file mode 100644 index 0000000..5bf8f28 --- /dev/null +++ b/backend/src/utils/logger.ts @@ -0,0 +1,19 @@ +import winston from 'winston'; + +const logger = winston.createLogger({ + level: 'info', + format: winston.format.combine( + winston.format.timestamp(), + winston.format.json() + ), + transports: [ + new winston.transports.Console({ + format: winston.format.combine( + winston.format.colorize(), + winston.format.simple() + ), + }), + ], +}); + +export default logger; diff --git a/backend/src/utils/symbolMapper.ts b/backend/src/utils/symbolMapper.ts new file mode 100644 index 0000000..ae87153 --- /dev/null +++ b/backend/src/utils/symbolMapper.ts @@ -0,0 +1,48 @@ +import { config } from '../config/index.js'; +import logger from './logger.js'; + +export class SymbolMapper { + private static normalizeSymbolToken(symbol: string): string { + return String(symbol || '') + .trim() + .toUpperCase() + .replace(/_/g, '/') + .replace(/-/g, '/') + .replace(/\s+/g, ''); + } + + /** + * Converts a data symbol (e.g. BTC/USDT) to a tradeable symbol for the execution exchange (e.g. BTC/USD for Alpaca). + */ + static toTradeSymbol(symbol: string, provider: string): string { + if (provider.toLowerCase() === 'alpaca') { + // Alpaca prefers USD over USDT for many crypto pairs + const mapped = symbol.replace('/USDT', '/USD'); + if (mapped !== symbol) { + logger.info(`[SymbolMapper] Mapped ${symbol} to ${mapped} for Alpaca execution.`); + } + return mapped; + } + return symbol; + } + + /** + * Returns a normalized symbol key aligned to execution provider semantics. + * Example (alpaca): BTC/USDT and BTC/USD normalize to BTC/USD. + */ + static toExecutionScopeSymbol(symbol: string, provider: string): string { + const normalizedInput = this.normalizeSymbolToken(symbol); + const tradeSymbol = this.toTradeSymbol(normalizedInput, provider); + return this.normalizeSymbolToken(tradeSymbol); + } + + /** + * Optional: Map back if needed for tracking + */ + static toDataSymbol(symbol: string, provider: string): string { + if (provider.toLowerCase() === 'alpaca') { + return symbol.replace('/USD', '/USDT'); + } + return symbol; + } +} diff --git a/backend/strategy_config_schema.json b/backend/strategy_config_schema.json new file mode 100644 index 0000000..1857b81 --- /dev/null +++ b/backend/strategy_config_schema.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Strategy Configuration", + "description": "Configuration for Hybrid Trading Strategy Rules per Profile", + "type": "object", + "properties": { + "rules": { + "type": "array", + "description": "List of active rules for this profile", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "TrendBiasRule", + "MomentumRule", + "ZoneRule", + "SessionRule", + "EntryTriggerRule", + "RiskManagementRule", + "AIAnalysisRule" + ], + "description": "Name of the rule class" + }, + "settings": { + "type": "object", + "description": "Custom parameters for the rule (overrides defaults)", + "additionalProperties": true + } + }, + "required": [ + "name" + ] + } + } + }, + "required": [ + "rules" + ] +} \ No newline at end of file diff --git a/backend/testAlpacaSubTag.ts b/backend/testAlpacaSubTag.ts new file mode 100644 index 0000000..47d1999 --- /dev/null +++ b/backend/testAlpacaSubTag.ts @@ -0,0 +1,157 @@ +import assert from 'node:assert/strict'; +import { config } from '../src/config/index.js'; +import { AlpacaConnector } from '../src/connectors/alpaca.js'; +import { + buildAlpacaSubTag, + shouldAttachAlpacaSubTag, + subTagBelongsToProfile, + subTagHintsTrade +} from '../src/utils/alpacaSubTag.js'; + +const normalizeError = (reason: unknown): Error => { + if (reason instanceof Error) return reason; + if (reason && typeof reason === 'object') return new Error(JSON.stringify(reason)); + return new Error(String(reason ?? 'Unknown error')); +}; + +process.on('unhandledRejection', (reason) => { + const err = normalizeError(reason); + console.error('[alpaca-subtag] unhandled rejection', err); + process.exit(1); +}); + +process.on('uncaughtException', (error) => { + const err = normalizeError(error); + console.error('[alpaca-subtag] uncaught exception', err); + process.exit(1); +}); + +const originalConfig = { + ENABLE_ALPACA_SUBTAG: config.ENABLE_ALPACA_SUBTAG, + SUBTAG_OMNIBUS_ONLY: config.SUBTAG_OMNIBUS_ONLY, + ALPACA_SUBTAG_ENV: config.ALPACA_SUBTAG_ENV, + ALPACA_SUBTAG_MAX_LENGTH: config.ALPACA_SUBTAG_MAX_LENGTH, + ALPACA_SUBTAG_DISABLE_FOR_EXCHANGE: [...(config.ALPACA_SUBTAG_DISABLE_FOR_EXCHANGE || [])], + ALPACA_OMNIBUS_PROFILE_ALLOWLIST: [...config.ALPACA_OMNIBUS_PROFILE_ALLOWLIST], + EXECUTION_PROVIDER: config.EXECUTION_PROVIDER, + EXCHANGE: config.EXCHANGE +}; + +const restoreConfig = () => { + (config as any).ENABLE_ALPACA_SUBTAG = originalConfig.ENABLE_ALPACA_SUBTAG; + (config as any).SUBTAG_OMNIBUS_ONLY = originalConfig.SUBTAG_OMNIBUS_ONLY; + (config as any).ALPACA_SUBTAG_ENV = originalConfig.ALPACA_SUBTAG_ENV; + (config as any).ALPACA_SUBTAG_MAX_LENGTH = originalConfig.ALPACA_SUBTAG_MAX_LENGTH; + (config as any).ALPACA_SUBTAG_DISABLE_FOR_EXCHANGE = [...originalConfig.ALPACA_SUBTAG_DISABLE_FOR_EXCHANGE]; + (config as any).ALPACA_OMNIBUS_PROFILE_ALLOWLIST = [...originalConfig.ALPACA_OMNIBUS_PROFILE_ALLOWLIST]; + (config as any).EXECUTION_PROVIDER = originalConfig.EXECUTION_PROVIDER; + (config as any).EXCHANGE = originalConfig.EXCHANGE; +}; + +const testHelperDeterminism = () => { + const profileId = '503f4da8-e063-4583-9f30-0cc22ede9ff4'; + const tradeId = 'TRD-0cc22ede9ff4-SOLUSDT-BUY-1771637085052-000004'; + + const a = buildAlpacaSubTag({ profileId, tradeId, intent: 'ENTRY' }); + const b = buildAlpacaSubTag({ profileId, tradeId, intent: 'ENTRY' }); + + assert.ok(a, 'sub-tag should be produced'); + assert.equal(a, b, 'sub-tag must be deterministic'); + assert.ok((a as string).startsWith('BL:'), 'sub-tag should use BL prefix'); + assert.ok((a as string).length <= Number(config.ALPACA_SUBTAG_MAX_LENGTH || 48), 'sub-tag should respect max length'); + + assert.equal(subTagBelongsToProfile(a as string, profileId), true, 'sub-tag should map to correct profile'); + assert.equal(subTagBelongsToProfile(a as string, '11111111-1111-1111-1111-111111111111'), false, 'sub-tag should not map to another profile'); + assert.equal(subTagHintsTrade(a as string, tradeId), true, 'sub-tag should map to correct trade'); + assert.equal(subTagHintsTrade(a as string, 'TRD-OTHER'), false, 'sub-tag should not map to another trade'); +}; + +const testFeatureGating = () => { + const profileId = '503f4da8-e063-4583-9f30-0cc22ede9ff4'; + + (config as any).EXECUTION_PROVIDER = 'alpaca'; + (config as any).EXCHANGE = 'kraken'; + (config as any).ENABLE_ALPACA_SUBTAG = false; + (config as any).SUBTAG_OMNIBUS_ONLY = true; + (config as any).ALPACA_SUBTAG_DISABLE_FOR_EXCHANGE = []; + (config as any).ALPACA_OMNIBUS_PROFILE_ALLOWLIST = []; + assert.equal(shouldAttachAlpacaSubTag({ profileId }), false, 'feature disabled should block sub-tagging'); + + (config as any).ENABLE_ALPACA_SUBTAG = true; + (config as any).SUBTAG_OMNIBUS_ONLY = false; + assert.equal(shouldAttachAlpacaSubTag({ profileId }), true, 'omnibus-only disabled should allow sub-tagging'); + + (config as any).ALPACA_SUBTAG_DISABLE_FOR_EXCHANGE = ['kraken', 'binance']; + assert.equal( + shouldAttachAlpacaSubTag({ profileId }), + true, + 'disable list should not block when execution provider is alpaca' + ); + + (config as any).ALPACA_SUBTAG_DISABLE_FOR_EXCHANGE = ['alpaca']; + assert.equal(shouldAttachAlpacaSubTag({ profileId }), false, 'disable list should block alpaca when configured'); + + (config as any).ALPACA_SUBTAG_DISABLE_FOR_EXCHANGE = []; + (config as any).SUBTAG_OMNIBUS_ONLY = true; + assert.equal(shouldAttachAlpacaSubTag({ profileId }), false, 'missing omnibus flag should block when omnibus-only'); + + assert.equal( + shouldAttachAlpacaSubTag({ + profileId, + profileSettings: { strategy_config: { execution: { mode: 'omnibus' } } } + }), + true, + 'profile omnibus mode should allow sub-tagging' + ); + + (config as any).ALPACA_OMNIBUS_PROFILE_ALLOWLIST = [profileId]; + assert.equal(shouldAttachAlpacaSubTag({ profileId }), true, 'allowlisted profile should allow sub-tagging'); +}; + +const testConnectorPayload = async () => { + const connector = new AlpacaConnector('test-key', 'test-secret'); + const capturedOrders: any[] = []; + (connector as any).client = { + createOrder: async (payload: any) => { + capturedOrders.push({ ...payload }); + return { + id: `MOCK-${capturedOrders.length}`, + status: 'accepted' + }; + } + }; + + const withSubTag = await connector.placeOrder( + 'SOL/USD', + 'buy', + 0.5, + 'market', + undefined, + undefined, + undefined, + 'client-1', + { subTag: 'BL:PAPER:PABC:TABC:ENTRY' } + ); + assert.equal(capturedOrders[0].client_order_id, 'client-1', 'client_order_id should pass through'); + assert.equal(capturedOrders[0].subtag, 'BL:PAPER:PABC:TABC:ENTRY', 'sub-tag should be attached when provided'); + assert.equal(withSubTag.subtag, 'BL:PAPER:PABC:TABC:ENTRY', 'connector should keep sub-tag on returned order'); + + await connector.placeOrder('SOL/USD', 'sell', 0.5, 'market'); + assert.equal(capturedOrders[1].subtag, undefined, 'sub-tag must be absent when not provided'); +}; + +async function run() { + try { + (config as any).ALPACA_SUBTAG_ENV = 'paper'; + (config as any).ALPACA_SUBTAG_MAX_LENGTH = 48; + + testHelperDeterminism(); + testFeatureGating(); + await testConnectorPayload(); + console.log('[alpaca-subtag] OK: helper, gating, and connector payload checks passed'); + } finally { + restoreConfig(); + } +} + +await run(); diff --git a/backend/testBacktestIsolation.ts b/backend/testBacktestIsolation.ts new file mode 100644 index 0000000..ed11a5e --- /dev/null +++ b/backend/testBacktestIsolation.ts @@ -0,0 +1,181 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { config } from '../src/config/index.js'; +import { runBacktest } from '../src/backtest/index.js'; +import type { BacktestRequest } from '../src/backtest/types.js'; +import logger from '../src/utils/logger.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, '..'); + +const makeCandle = (timestamp: number, close: number) => ({ + timestamp, + open: close * 0.999, + high: close * 1.002, + low: close * 0.998, + close, + volume: 100 +}); + +const build15mSeries = (startTs: number, count: number): Array> => { + const out: Array> = []; + let price = 100; + for (let i = 0; i < count; i++) { + if (i < Math.floor(count * 0.6)) { + price += 0.5; + } else { + price -= 0.65; + } + out.push(makeCandle(startTs + i * 15 * 60 * 1000, Number(price.toFixed(6)))); + } + return out; +}; + +const buildRequest = (): BacktestRequest => { + const candles = build15mSeries(Date.UTC(2025, 0, 1, 0, 0, 0), 700); + const from = new Date(candles[220].timestamp).toISOString(); + const to = new Date(candles[candles.length - 1].timestamp).toISOString(); + + return { + mode: 'backtest', + symbols: ['BTC/USDT'], + timeframe: '15m', + dateRange: { from, to }, + dataSource: { + type: 'json', + payload: { + candles: { + 'BTC/USDT': { + '15m': candles + } + } + } + }, + strategyConfig: { + rules: [ + { ruleId: 'TrendBiasRule', enabled: true, ruleType: 'voting', params: { fastPeriod: 10, slowPeriod: 20 } }, + { ruleId: 'SessionRule', enabled: true, ruleType: 'mandatory', params: { sessions: '24/7' } }, + { ruleId: 'RiskManagementRule', enabled: true, ruleType: 'mandatory', params: { atrPeriod: 14, maxRisk: 10 } } + ], + riskLimits: { + maxDailyLossUsd: 5000, + dailyProfitTargetUsd: 5000, + maxOpenTrades: 3, + maxConsecutiveLosses: 10 + }, + execution: { + orderType: 'market', + cooldownMinutes: 0, + minRulePassRatio: 1, + entryMode: 'both' + } + }, + execution: { + initialCapitalUsd: 10_000, + orderType: 'market', + slippageBps: 5, + partialFillPct: 1, + enforceWarmup: true, + allowNegativeCash: false, + forceCloseAtWindowEnd: false + } + }; +}; + +const verifySourceIsolation = (): void => { + const backtestRoot = path.join(repoRoot, 'src', 'backtest'); + const files = fs.readdirSync(backtestRoot, { recursive: true }) + .filter((entry) => String(entry).endsWith('.ts')) + .map((entry) => path.join(backtestRoot, String(entry))); + assert(files.length > 0, 'Expected backtest source files to exist.'); + + const forbiddenPatterns = [ + /from ['"]\.\.\/services\/TradeExecutor/i, + /from ['"]\.\.\/services\/AutoTrader/i, + /from ['"]\.\.\/services\/CapitalLedger/i, + /from ['"]\.\.\/connectors\/alpaca/i, + /from ['"]\.\.\/connectors\/ccxt/i, + /\.from\(['"](orders|trade_history|capital_ledgers)['"]\)/ + ]; + + for (const filePath of files) { + const source = fs.readFileSync(filePath, 'utf8'); + for (const pattern of forbiddenPatterns) { + assert(!pattern.test(source), `Isolation violation in ${path.relative(repoRoot, filePath)} (${pattern})`); + } + } +}; + +const verifyBacktestBehavior = async (): Promise => { + const originalFlag = config.ENABLE_BACKTEST; + const originalInfo = (logger as any).info?.bind(logger); + const originalWarn = (logger as any).warn?.bind(logger); + const originalError = (logger as any).error?.bind(logger); + config.ENABLE_BACKTEST = true; + (logger as any).info = () => undefined; + (logger as any).warn = () => undefined; + (logger as any).error = () => undefined; + try { + const request = buildRequest(); + const first = await runBacktest(request, { + profileSettings: { + allocated_capital: 10_000, + risk_per_trade_percent: 1, + strategy_config: request.strategyConfig + } + }); + const second = await runBacktest(request, { + profileSettings: { + allocated_capital: 10_000, + risk_per_trade_percent: 1, + strategy_config: request.strategyConfig + } + }); + + assert.deepEqual(first, second, 'Backtest must be deterministic for identical inputs.'); + const fromTs = Date.parse(request.dateRange.from); + const toTs = Date.parse(request.dateRange.to); + assert.equal(first.window.endOfWindowPolicy, 'OPEN_AT_END', 'Default end-of-window behavior must be OPEN_AT_END.'); + assert.equal(first.window.fromTimestamp, fromTs, 'Window.fromTimestamp must match request.from.'); + assert.equal(first.window.toTimestamp, toTs, 'Window.toTimestamp must match request.to.'); + assert(first.window.includesWarmupCandles, 'Warm-up candles should be loaded before replay start.'); + assert(first.warmup.endTimestamp >= first.warmup.startTimestamp, 'Warm-up report must be valid.'); + assert(first.trades.every((trade) => trade.entryTimestamp >= fromTs), 'No trades allowed before replay window start.'); + assert(first.trades.every((trade) => trade.entryTimestamp <= toTs), 'No trades allowed after replay window end.'); + assert(first.trades.every((trade) => trade.exitTimestamp <= toTs), 'No exits allowed after replay window end.'); + assert(first.trades.every((trade) => trade.entryTimestamp >= first.warmup.endTimestamp), 'No trades allowed before warm-up completion.'); + assert(first.timeline.every((point) => point.cashUsd >= -1e-6), 'Cash must never be negative with allowNegativeCash=false.'); + assert(first.timeline.every((point) => point.reservedUsd <= point.cashUsd + 1e-6), 'Reserved capital cannot exceed cash.'); + assert.equal(first.diagnostics.connectorCalls.placeOrder, 0, 'Backtest must never call placeOrder.'); + + const forcedWindowCloseRequest: BacktestRequest = { + ...request, + execution: { + ...(request.execution || {}), + forceCloseAtWindowEnd: true + } + }; + const forcedCloseResult = await runBacktest(forcedWindowCloseRequest, { + profileSettings: { + allocated_capital: 10_000, + risk_per_trade_percent: 1, + strategy_config: request.strategyConfig + } + }); + assert.equal(forcedCloseResult.window.endOfWindowPolicy, 'FORCE_CLOSE', 'Force-close mode must be reported in window metadata.'); + assert.equal(forcedCloseResult.openPositionsAtEnd.length, 0, 'Force-close mode should not leave OPEN_AT_END positions.'); + assert.equal(forcedCloseResult.diagnostics.connectorCalls.placeOrder, 0, 'Force-close mode must never call placeOrder.'); + } finally { + config.ENABLE_BACKTEST = originalFlag; + (logger as any).info = originalInfo; + (logger as any).warn = originalWarn; + (logger as any).error = originalError; + } +}; + +verifySourceIsolation(); +await verifyBacktestBehavior(); +console.log('[backtest-isolation] OK: warm-up, capital, determinism, and isolation guards validated'); diff --git a/backend/testConnectorAndAiCoverage.ts b/backend/testConnectorAndAiCoverage.ts new file mode 100644 index 0000000..695d94e --- /dev/null +++ b/backend/testConnectorAndAiCoverage.ts @@ -0,0 +1,245 @@ +import assert from 'node:assert/strict'; +import { config } from '../src/config/index.js'; +import { AIClient } from '../src/services/aiClient.js'; +import { AlpacaConnector } from '../src/connectors/alpaca.js'; +import { CCXTConnector } from '../src/connectors/ccxt.js'; +import { ConnectorFactory } from '../src/connectors/factory.js'; +import type { Candle } from '../src/connectors/types.js'; + +async function* generateBars(rows: any[]) { + for (const row of rows) { + yield row; + } +} + +async function testAIClient() { + const originalAiConfig = { + OPENAI_API_KEY: config.AI.OPENAI_API_KEY, + PERPLEXITY_API_KEY: config.AI.PERPLEXITY_API_KEY, + GEMINI_API_KEY: config.AI.GEMINI_API_KEY, + FALLBACK_LIST: [...config.AI.FALLBACK_LIST], + MODEL: config.AI.MODEL + }; + + try { + config.AI['OPENAI_API_KEY'] = ''; + config.AI['PERPLEXITY_API_KEY'] = ''; + config.AI['GEMINI_API_KEY'] = ''; + config.AI.FALLBACK_LIST = ['openai', 'perplexity', 'gemini']; + + const clientNoKeys = new AIClient(); + const noResult = await clientNoKeys.generateAnalysis('prompt'); + assert.equal(noResult, null, 'generateAnalysis should return null when no provider keys are configured'); + + const noProbeHealth = await clientNoKeys.getProviderHealth(false); + assert.equal(noProbeHealth.length, 3); + assert(noProbeHealth.every((item) => item.status === 'missing_key')); + + // Override provider call paths to avoid network and validate fallback routing + const clientWithMocks = new AIClient() as any; + config.AI['OPENAI_API_KEY'] = 'key-openai'; + config.AI['PERPLEXITY_API_KEY'] = 'key-perplexity'; + config.AI['GEMINI_API_KEY'] = 'key-gemini'; + config.AI.FALLBACK_LIST = ['unsupported' as any, 'openai', 'perplexity', 'gemini']; + + clientWithMocks.callOpenAI = async () => '{"action":"BUY","confidence":75,"reasoning":"ok"}'; + clientWithMocks.callPerplexity = async () => '{"action":"SELL","confidence":65,"reasoning":"ok"}'; + clientWithMocks.callGemini = async () => '{"action":"HOLD","confidence":50,"reasoning":"ok"}'; + + const mockedResult = await clientWithMocks.generateAnalysis('probe prompt'); + assert.equal(typeof mockedResult, 'string'); + assert((mockedResult as string).includes('BUY')); + + const probeHealth = await clientWithMocks.getProviderHealth(true); + assert.equal(probeHealth.length, 3); + assert(probeHealth.every((item: any) => item.status === 'ok')); + + // probeProvider unsupported branch + await assert.rejects(async () => { + await clientWithMocks.probeProvider('unsupported'); + }); + } finally { + config.AI['OPENAI_API_KEY'] = originalAiConfig.OPENAI_API_KEY; + config.AI['PERPLEXITY_API_KEY'] = originalAiConfig.PERPLEXITY_API_KEY; + config.AI['GEMINI_API_KEY'] = originalAiConfig.GEMINI_API_KEY; + config.AI.FALLBACK_LIST = originalAiConfig.FALLBACK_LIST; + config.AI.MODEL = originalAiConfig.MODEL; + } +} + +async function testAlpacaConnector() { + const originalAssetClass = config.ASSET_CLASS; + + try { + const connector = new AlpacaConnector('k', 's') as any; + + const bars = [ + { Timestamp: '2026-02-16T00:00:00Z', OpenPrice: 100, HighPrice: 101, LowPrice: 99, ClosePrice: 100, Volume: 10 }, + { Timestamp: '2026-02-16T01:00:00Z', OpenPrice: 101, HighPrice: 102, LowPrice: 100, ClosePrice: 101, Volume: 11 }, + { Timestamp: '2026-02-16T02:00:00Z', OpenPrice: 102, HighPrice: 103, LowPrice: 101, ClosePrice: 102, Volume: 12 }, + { Timestamp: '2026-02-16T03:00:00Z', OpenPrice: 103, HighPrice: 104, LowPrice: 102, ClosePrice: 103, Volume: 13 } + ]; + + connector.client = { + getBarsV2: (_symbol: string, _opts: any) => generateBars(bars), + createOrder: async (opts: any) => ({ id: 'alp-1', ...opts }), + getOrder: async (_id: string) => ({ id: 'alp-1', status: 'filled' }), + cancelOrder: async (_id: string) => undefined, + getPosition: async (_symbol: string) => ({ side: 'long', avg_entry_price: '100', qty: '2' }), + getClock: async () => ({ is_open: true }) + }; + + const mappedTf = connector.mapTimeframe('1m'); + assert.equal(mappedTf, '1Min'); + assert.equal(connector.mapTimeframe('unknown'), 'unknown'); + + const aggregated = connector.aggregateBars([ + { timestamp: 1, open: 1, high: 2, low: 0.5, close: 1.5, volume: 5 }, + { timestamp: 2, open: 1.5, high: 3, low: 1, close: 2.5, volume: 7 } + ], 2); + assert.equal(aggregated.length, 1); + assert.equal(aggregated[0].high, 3); + assert.equal(aggregated[0].volume, 12); + + const candles4h = await connector.fetchOHLCV('BTC/USD', '4h', 1); + assert.equal(candles4h.length, 1); + + config.ASSET_CLASS = 'us_equity'; + const orderWithBracket = await connector.placeOrder('AAPL', 'buy', 1, 'limit', 200, 190, 220); + assert.equal(orderWithBracket.order_class, 'bracket'); + + config.ASSET_CLASS = 'crypto'; + const cryptoOrder = await connector.placeOrder('BTC/USD', 'sell', 0.5, 'market'); + assert.equal(cryptoOrder.side, 'sell'); + + const order = await connector.getOrder('ord-1'); + assert.equal(order.status, 'filled'); + + const cancelOk = await connector.cancelOrder('ord-1'); + assert.equal(cancelOk, true); + + const position = await connector.getPosition('BTC/USD'); + assert.equal(position.qty, '2'); + + const marketOpen = await connector.isTradingWindowOpen(); + assert.equal(marketOpen, true); + + connector.client.getOrder = async () => { throw new Error('boom'); }; + const missingOrder = await connector.getOrder('ord-2'); + assert.equal(missingOrder, null); + + connector.client.cancelOrder = async () => { throw new Error('boom'); }; + const cancelFail = await connector.cancelOrder('ord-2'); + assert.equal(cancelFail, false); + + connector.client.getPosition = async () => { throw new Error('404 not found'); }; + const noPosition = await connector.getPosition('ETH/USD'); + assert.equal(noPosition, null); + + connector.client.getPosition = async () => { throw new Error('500 server'); }; + await assert.rejects(async () => { + await connector.getPosition('ETH/USD'); + }); + + config.ASSET_CLASS = 'us_equity'; + connector.client.getClock = async () => ({ is_open: false }); + const marketClosed = await connector.isTradingWindowOpen(); + assert.equal(marketClosed, false); + + connector.client.getClock = async () => ({ nope: true }); + const marketUnknown = await connector.isTradingWindowOpen(); + assert.equal(marketUnknown, null); + + connector.client.getClock = async () => { throw new Error('clock-fail'); }; + const clockFail = await connector.isTradingWindowOpen(); + assert.equal(clockFail, null); + } finally { + config.ASSET_CLASS = originalAssetClass; + } +} + +async function testCCXTConnectorAndFactory() { + const originalExchange = config.EXCHANGE; + const originalProvider = config.PROVIDER; + + try { + // Unsupported exchange branch + config.EXCHANGE = 'definitely_unsupported_exchange'; + await assert.rejects(async () => { + new CCXTConnector('k', 's'); + }); + + config.EXCHANGE = 'binance'; + const ccxtConnector = new CCXTConnector('k', 's') as any; + ccxtConnector.client = { + fetchOHLCV: async (_symbol: string, _tf: string, _since: any, _limit: number) => [ + [1, 100, 110, 90, 105, 1000], + [2, 105, 112, 100, 111, 1200] + ], + createOrder: async (_symbol: string, _type: string, _side: string, _qty: number, _price: number | undefined) => ({ id: 'ccxt-1' }), + fetchOrder: async (_id: string, _symbol?: string) => ({ id: 'ccxt-1', status: 'closed' }), + fetchPositions: async (_symbols: string[]) => [{ symbol: 'BTC/USDT', contracts: 1 }], + cancelOrder: async (_id: string, _symbol?: string) => undefined + }; + + const candles = await ccxtConnector.fetchOHLCV('BTC/USDT', '1Hour', 2); + assert.equal(candles.length, 2); + assert.equal((candles[0] as Candle).close, 105); + + const order = await ccxtConnector.placeOrder('BTC/USDT', 'buy', 1, 'market'); + assert.equal(order.id, 'ccxt-1'); + + const fetchedOrder = await ccxtConnector.getOrder('ccxt-1', 'BTC/USDT'); + assert.equal(fetchedOrder.status, 'closed'); + + const position = await ccxtConnector.getPosition('BTC/USDT'); + assert.equal(position.symbol, 'BTC/USDT'); + + const isOpen = await ccxtConnector.isTradingWindowOpen(); + assert.equal(isOpen, true); + + const cancelOk = await ccxtConnector.cancelOrder('ccxt-1', 'BTC/USDT'); + assert.equal(cancelOk, true); + + ccxtConnector.client.fetchOrder = async () => { throw new Error('fetch-order-fail'); }; + const missingOrder = await ccxtConnector.getOrder('ccxt-2', 'BTC/USDT'); + assert.equal(missingOrder, null); + + ccxtConnector.client.fetchPositions = async () => { throw new Error('position-fail'); }; + await assert.rejects(async () => { + await ccxtConnector.getPosition('BTC/USDT'); + }); + + ccxtConnector.client.cancelOrder = async () => { throw new Error('cancel-fail'); }; + const cancelFail = await ccxtConnector.cancelOrder('ccxt-2', 'BTC/USDT'); + assert.equal(cancelFail, false); + + // Factory branches + config.PROVIDER = 'alpaca'; + const defaultConnector = ConnectorFactory.getConnector(); + assert(defaultConnector instanceof AlpacaConnector); + + const customCcxt = ConnectorFactory.getCustomConnector('ccxt', 'k', 's'); + assert(customCcxt instanceof CCXTConnector); + + const customAlpaca = ConnectorFactory.getCustomConnector('alpaca', 'k', 's'); + assert(customAlpaca instanceof AlpacaConnector); + + await assert.rejects(async () => { + ConnectorFactory.getCustomConnector('unknown-provider'); + }); + } finally { + config.EXCHANGE = originalExchange; + config.PROVIDER = originalProvider; + } +} + +async function run() { + await testAIClient(); + await testAlpacaConnector(); + await testCCXTConnectorAndFactory(); + + console.log('[connector-ai-coverage] OK: connector and AI client branches validated'); +} + +await run(); diff --git a/backend/testCoreModuleCoverage.ts b/backend/testCoreModuleCoverage.ts new file mode 100644 index 0000000..1f959a5 --- /dev/null +++ b/backend/testCoreModuleCoverage.ts @@ -0,0 +1,505 @@ +import assert from 'node:assert/strict'; +import { Indicators } from '../src/utils/indicators.js'; +import { DirectionTracker, SignalType } from '../src/strategies/directionTracker.js'; +import { TrendBiasRule } from '../src/strategies/rules/TrendBiasRule.js'; +import { MomentumRule } from '../src/strategies/rules/MomentumRule.js'; +import { ZoneRule } from '../src/strategies/rules/ZoneRule.js'; +import { SessionRule } from '../src/strategies/rules/SessionRule.js'; +import { EntryTriggerRule } from '../src/strategies/rules/EntryTriggerRule.js'; +import { RiskManagementRule } from '../src/strategies/rules/RiskManagementRule.js'; +import { AIAnalysisRule } from '../src/strategies/rules/AIAnalysisRule.js'; +import { ProStrategyEngine } from '../src/strategies/ProStrategyEngine.js'; +import { SignalDirection, type MarketContext, type RuleResult } from '../src/strategies/rules/types.js'; +import { config } from '../src/config/index.js'; +import { RiskEngine } from '../src/services/riskEngine.js'; +import { AutoTrader } from '../src/services/AutoTrader.js'; +import { ExecutionManager } from '../src/services/executionManager.js'; +import { supabaseService } from '../src/services/SupabaseService.js'; +import { metrics, MetricsService } from '../src/services/MetricsService.js'; + +const makeCandle = (timestamp: number, open: number, high: number, low: number, close: number, volume: number = 100) => ({ + timestamp, + open, + high, + low, + close, + volume +}); + +const buildSeries = (count: number, start: number, step: number) => { + const out: Array> = []; + for (let i = 0; i < count; i++) { + const price = start + (step * i); + out.push(makeCandle(1700000000000 + (i * 3600000), price - 1, price + 2, price - 2, price, 100 + i)); + } + return out; +}; + +const makeContext = (overrides: Partial = {}): MarketContext => { + const candles1h = overrides.candles1h || buildSeries(120, 100, 1); + const candles4h = overrides.candles4h || buildSeries(320, 80, 1.5); + const candles15m = overrides.candles15m || buildSeries(120, 102, 0.4); + + return { + symbol: overrides.symbol || 'BTC/USD', + candles4h, + candles1h, + candles15m, + currentPrice: overrides.currentPrice ?? candles15m[candles15m.length - 1].close, + change24h: overrides.change24h ?? 1.2, + changeToday: overrides.changeToday ?? 0.5, + session: overrides.session || 'LDN|NY', + isMajorSession: overrides.isMajorSession ?? true, + volatility: overrides.volatility || 'High', + latestSignal: overrides.latestSignal || SignalDirection.NONE, + ema20_1h: overrides.ema20_1h ?? 150, + ema20_15m: overrides.ema20_15m ?? 149, + ema50_4h: overrides.ema50_4h ?? 140, + ema200_4h: overrides.ema200_4h ?? 120, + rsi_1h: overrides.rsi_1h ?? 58, + rsi_15m: overrides.rsi_15m ?? 56 + }; +}; + +async function testIndicatorsAndDirectionTracker() { + assert.equal(Indicators.calculateEMA([], 10), 0, 'EMA should handle empty arrays'); + assert.equal(Indicators.calculateRSI([1, 2, 3], 14), 50, 'RSI should return fallback for short series'); + assert.equal(Indicators.calculateATR([], 14), 0, 'ATR should return 0 when candles are insufficient'); + + const atrCandles = buildSeries(20, 100, 1); + const atr = Indicators.calculateATR(atrCandles, 14); + assert(atr > 0, 'ATR should compute positive value for valid candles'); + + const tracker = new DirectionTracker(); + const shortSeries = tracker.calculateDirection(buildSeries(10, 100, 0.2)); + assert.equal(shortSeries.signal, SignalType.NONE); + assert.equal(shortSeries.changed, false); + + const fromCloses = (closes: number[]) => closes.map((close, idx) => + makeCandle(1700000000000 + (idx * 60000), close - 1, close + 1, close - 2, close, 100) + ); + + const buySeries = tracker.calculateDirection(fromCloses([ + 100, 99, 101, 100, 102, 101, 103, 102, 104, 103, + 105, 104, 106, 105, 107, 106, 108, 107, 109, 108, + 110, 109, 111, 110, 112, 111, 113, 112, 114, 113 + ])); + assert.equal(buySeries.signal, SignalType.BUY); + assert.equal(buySeries.changed, true); + + const duplicateBuy = tracker.calculateDirection(fromCloses([ + 102, 101, 103, 102, 104, 103, 105, 104, 106, 105, + 107, 106, 108, 107, 109, 108, 110, 109, 111, 110, + 112, 111, 113, 112, 114, 113, 115, 114, 116, 115 + ])); + assert.equal(duplicateBuy.signal, SignalType.BUY); + assert.equal(duplicateBuy.changed, false, 'State machine should suppress duplicate signal transitions'); + + const sellSeries = tracker.calculateDirection(fromCloses([ + 200, 201, 199, 200, 198, 199, 197, 198, 196, 197, + 195, 196, 194, 195, 193, 194, 192, 193, 191, 192, + 190, 191, 189, 190, 188, 189, 187, 188, 186, 187 + ])); + assert.equal(sellSeries.signal, SignalType.SELL); + assert.equal(sellSeries.changed, true); +} + +async function testRules() { + const trendRule = new TrendBiasRule(); + const trendInvalid = await trendRule.check(makeContext(), { fastPeriod: 200, slowPeriod: 50 }); + assert.equal(trendInvalid.passed, false); + + const trendInsufficient = await trendRule.check(makeContext({ candles4h: buildSeries(10, 80, 1) })); + assert.equal(trendInsufficient.passed, false); + + const trendBuy = await trendRule.check(makeContext({ currentPrice: 600, ema50_4h: 500, ema200_4h: 400 })); + assert.equal(trendBuy.signal, SignalDirection.BUY); + + const trendSell = await trendRule.check(makeContext({ currentPrice: 60, candles4h: buildSeries(320, 500, -1) })); + assert.equal(trendSell.signal, SignalDirection.SELL); + + const momentumRule = new MomentumRule(); + const momentumShort = await momentumRule.check( + makeContext({ candles1h: buildSeries(5, 100, 1), candles15m: buildSeries(5, 100, 1) }), + { timeframe: '1h', rsiPeriod: 20 } + ); + assert.equal(momentumShort.passed, false); + + const momentumBuySeries = [ + 100, 99, 101, 100, 102, 101, 103, 102, 104, 103, + 105, 104, 106, 105, 107, 106, 108, 107, 109, 108, + 110, 109, 111, 110, 112, 111, 113, 112, 114, 113, + 115, 114, 116, 115, 117, 116, 118, 117, 119, 118 + ].map((close, idx) => makeCandle(1700000000000 + (idx * 60000), close - 1, close + 1, close - 2, close, 100)); + const momentumBuy = await momentumRule.check(makeContext({ isMajorSession: true, candles1h: momentumBuySeries }), { timeframe: '1h' }); + assert.equal(momentumBuy.signal, SignalDirection.BUY); + + const momentumSellSeries = [ + 200, 201, 199, 200, 198, 199, 197, 198, 196, 197, + 195, 196, 194, 195, 193, 194, 192, 193, 191, 192, + 190, 191, 189, 190, 188, 189, 187, 188, 186, 187, + 185, 186, 184, 185, 183, 184, 182, 183, 181, 182 + ].map((close, idx) => makeCandle(1700000000000 + (idx * 60000), close - 1, close + 1, close - 2, close, 100)); + const momentumSell = await momentumRule.check(makeContext({ isMajorSession: false, candles1h: momentumSellSeries }), { timeframe: '1h' }); + assert.equal(momentumSell.signal, SignalDirection.SELL); + + const zoneRule = new ZoneRule(); + const zoneNoEma = await zoneRule.check(makeContext({ ema20_15m: 0 }), { timeframe: '15m' }); + assert.equal(zoneNoEma.passed, false); + + const zonePass = await zoneRule.check(makeContext({ currentPrice: 101, ema20_1h: 100 }), { timeframe: '1h', zonePercent: 2 }); + assert.equal(zonePass.passed, true); + + const zoneFail = await zoneRule.check(makeContext({ currentPrice: 110, ema20_1h: 100 }), { timeframe: '1h', zonePercent: 1 }); + assert.equal(zoneFail.passed, false); + + const sessionRule = new SessionRule(); + const sessionPass = await sessionRule.check(makeContext({ session: 'LDN|TOK', isMajorSession: true }), { sessions: 'LDN,NY' }); + assert.equal(sessionPass.passed, true); + + const sessionFail = await sessionRule.check(makeContext({ session: 'TOK', isMajorSession: false }), { sessions: 'NY' }); + assert.equal(sessionFail.passed, false); + + const sessionOff = await sessionRule.check(makeContext({ session: 'OFF', isMajorSession: false })); + assert.equal(sessionOff.passed, false); + + const entryRule = new EntryTriggerRule(); + const entryShort = await entryRule.check(makeContext({ candles1h: buildSeries(2, 100, 1) })); + assert.equal(entryShort.passed, false); + + const entryNoEma = await entryRule.check(makeContext({ ema20_1h: 0 }), { timeframe: '1h' }); + assert.equal(entryNoEma.passed, false); + + const bullishReclaimCtx = makeContext({ + candles1h: [ + makeCandle(1, 100, 101, 99, 99), + makeCandle(2, 99, 101, 98, 98), + makeCandle(3, 98, 105, 97, 104) + ], + ema20_1h: 100 + }); + const bullishReclaim = await entryRule.check(bullishReclaimCtx, { timeframe: '1h' }); + assert.equal(bullishReclaim.signal, SignalDirection.BUY); + + const bearishReclaimCtx = makeContext({ + candles1h: [ + makeCandle(1, 100, 105, 99, 104), + makeCandle(2, 104, 106, 102, 105), + makeCandle(3, 105, 106, 95, 96) + ], + ema20_1h: 100 + }); + const bearishReclaim = await entryRule.check(bearishReclaimCtx, { timeframe: '1h', enableEmaReclaim: true, enableWickRejection: false }); + assert.equal(bearishReclaim.signal, SignalDirection.SELL); + + const riskRule = new RiskManagementRule(); + const riskShort = await riskRule.check(makeContext({ candles1h: buildSeries(10, 100, 1) })); + assert.equal(riskShort.passed, false); + + const riskPass = await riskRule.check(makeContext(), { maxRisk: 10 }); + assert.equal(riskPass.passed, true); + + const riskFail = await riskRule.check(makeContext({ currentPrice: 1, candles1h: buildSeries(120, 1, 1) }), { maxRisk: 0.1 }); + assert.equal(riskFail.passed, false); +} + +async function testAIAnalysisRule() { + const aiRule = new AIAnalysisRule(); + const aiRuleAsAny = aiRule as any; + + const originalFailOpen = config.AI.FAIL_OPEN; + try { + aiRuleAsAny.aiClient.generateAnalysis = async () => null; + + config.AI.FAIL_OPEN = true; + const failOpenRes = await aiRule.check(makeContext()); + assert.equal(failOpenRes.passed, true); + assert.equal(failOpenRes.metadata.ai_fail_open, true); + + config.AI.FAIL_OPEN = false; + const failClosedRes = await aiRule.check(makeContext()); + assert.equal(failClosedRes.passed, false); + + aiRuleAsAny.aiClient.generateAnalysis = async () => '{"action":"BUY","confidence":0.82,"reasoning":"good"}'; + config.AI.FAIL_OPEN = true; + const parseOk = await aiRule.check(makeContext(), { minConfidence: 70 }); + assert.equal(parseOk.passed, true); + assert.equal(parseOk.signal, SignalDirection.BUY); + + const parsedInvalid = aiRuleAsAny.parseResponse('not-json'); + assert.equal(parsedInvalid.action, 'HOLD'); + + const normalizedPct = aiRuleAsAny.normalizeConfidenceToPct(0.77); + assert.equal(normalizedPct, 77); + const normalizedClamped = aiRuleAsAny.normalizeConfidenceToPct(220); + assert.equal(normalizedClamped, 100); + + const prompt = aiRuleAsAny.buildPrompt(makeContext()); + assert(prompt.includes('Analyze the following Crypto Market Data')); + } finally { + config.AI.FAIL_OPEN = originalFailOpen; + } +} + +async function testRiskEngine() { + const engine = new RiskEngine(); + const noSignal = await engine.calculateRiskProfile('BTC/USD', SignalDirection.NONE, makeContext()); + assert.equal(noSignal, null); + + const risk = await engine.calculateRiskProfile( + 'BTC/USD', + SignalDirection.BUY, + makeContext(), + { + allocated_capital: 1000, + risk_per_trade_percent: 1, + strategy_config: { + execution: { + minQty: 0.001, + maxQty: 5, + qtyPrecision: 3, + minNotionalUsd: 10, + maxNotionalUsd: 10000 + } + } + }, + 500 + ); + + assert(risk !== null); + assert(risk!.positionSize > 0); + + const rejectMinQty = await engine.calculateRiskProfile( + 'BTC/USD', + SignalDirection.BUY, + makeContext({ currentPrice: 50000 }), + { + allocated_capital: 100, + risk_per_trade_percent: 0.1, + strategy_config: { + execution: { + minQty: 5, + maxQty: 10, + qtyPrecision: 2, + minNotionalUsd: 10, + maxNotionalUsd: 1000 + } + } + }, + 5 + ); + assert.equal(rejectMinQty, null); +} + +async function testProStrategyEngine() { + const fakeExchange = { + async fetchOHLCV(_symbol: string, timeframe: string) { + if (timeframe === '4h') return buildSeries(320, 100, 1.2); + if (timeframe === '1h') return buildSeries(140, 120, 0.8); + return buildSeries(140, 121, 0.2); + } + } as any; + + const engine = new ProStrategyEngine(fakeExchange); + + const context = await engine.buildMarketContext('BTC/USD'); + assert(context, 'buildMarketContext should return context with enough candles'); + + const fail = await engine.evaluateContext(makeContext(), [ + { ruleId: 'TrendBiasRule', enabled: true, params: { fastPeriod: 200, slowPeriod: 50 } } + ]); + assert.equal(fail?.passed, false); + + const pass = await engine.evaluateContext(makeContext({ + currentPrice: 600, + session: 'LDN|NY', + isMajorSession: true + }), [ + { ruleId: 'TrendBiasRule', enabled: true, params: { fastPeriod: 50, slowPeriod: 200 } }, + { ruleId: 'SessionRule', enabled: true, params: { sessions: 'LDN,NY' } }, + { ruleId: 'AIAnalysisRule', enabled: false, params: {} } + ]); + + assert(pass, 'evaluateContext should return a result object'); + assert.equal(pass?.passed, true); + assert(pass?.metadata?.ruleStatuses, 'Rule statuses should be included in pass response'); +} + +async function testAutoTrader() { + const originalEnableTrading = config.ENABLE_TRADING; + const originalMaxOpen = config.MAX_OPEN_TRADES; + + const originalDailyLoss = (supabaseService as any).getProfileDailyLossUsd; + const originalConsecutive = (supabaseService as any).getProfileConsecutiveLosses; + + const closeCalls: Array<{ symbol: string; reason: string; tradeId?: string }> = []; + const openCalls: Array = []; + + const executor = { + profileId: 'profile-1', + positionsBySymbol: new Map(), + checkCooldown: () => false, + getOpenPositionCount: () => 0, + getProfileId() { return this.profileId; }, + getAllPositions: () => new Map(), + getActivePositions(symbol: string) { return this.positionsBySymbol.get(symbol) || []; }, + async closePosition(symbol: string, reason: string, tradeId?: string) { + closeCalls.push({ symbol, reason, tradeId }); + }, + async openPosition(...args: any[]) { + openCalls.push(args); + return { success: true }; + } + } as any; + + const exchange = { + async getPosition() { return null; } + } as any; + + const trader = new AutoTrader(executor, exchange, async () => ({ allowed: true })); + const resultBuy: RuleResult = { ruleName: 'x', passed: true, signal: SignalDirection.BUY }; + const resultSell: RuleResult = { ruleName: 'x', passed: true, signal: SignalDirection.SELL }; + + try { + (supabaseService as any).getProfileDailyLossUsd = async () => 0; + (supabaseService as any).getProfileConsecutiveLosses = async () => 0; + + config.ENABLE_TRADING = false; + await trader.handleSignal('BTC/USD', resultBuy, makeContext()); + assert.equal(openCalls.length, 0); + + config.ENABLE_TRADING = true; + + await trader.handleSignal('BTC/USD', resultBuy, makeContext(), { symbols: 'ETH/USD' }); + assert.equal(openCalls.length, 0, 'Symbol filtering should skip symbols not in profile list'); + + executor.positionsBySymbol.set('BTC/USD', [{ side: SignalDirection.BUY, entryPrice: 100, peakPrice: 100, tradeId: 't1' }]); + await trader.handleSignal('BTC/USD', resultSell, makeContext({ currentPrice: 95 }), { strategy_config: { execution: { allowPyramiding: false } } }); + assert.equal(closeCalls.length, 1, 'Opposite signal should close existing position'); + + executor.positionsBySymbol.clear(); + + await trader.handleSignal('BTC/USD', resultSell, makeContext(), { strategy_config: { execution: { entryMode: 'long_only' } } }); + assert.equal(openCalls.length, 0, 'long_only should block SELL entries'); + + const blockedTrader = new AutoTrader(executor, exchange, async () => ({ allowed: false, reason: 'blocked' })); + await blockedTrader.handleSignal('BTC/USD', resultBuy, makeContext()); + assert.equal(openCalls.length, 0, 'Portfolio guard block should stop entries'); + + const maxOpenExecutor = { ...executor, getOpenPositionCount: () => 999 } as any; + const maxOpenTrader = new AutoTrader(maxOpenExecutor, exchange); + config.MAX_OPEN_TRADES = 1; + await maxOpenTrader.handleSignal('BTC/USD', resultBuy, makeContext()); + assert.equal(openCalls.length, 0, 'Max open trade guard should block entries'); + + const cooldownExecutor = { ...executor, checkCooldown: () => true } as any; + const cooldownTrader = new AutoTrader(cooldownExecutor, exchange); + await cooldownTrader.handleSignal('BTC/USD', resultBuy, makeContext()); + assert.equal(openCalls.length, 0, 'Cooldown guard should block entries'); + + (supabaseService as any).getProfileDailyLossUsd = async () => 500; + const riskBlocked = new AutoTrader(executor, exchange); + await riskBlocked.handleSignal('BTC/USD', resultBuy, makeContext(), { + strategy_config: { riskLimits: { maxDailyLossUsd: 100, maxConsecutiveLosses: 5 } } + }); + assert.equal(openCalls.length, 0, 'Runtime risk guard should block entries when daily loss breached'); + + (supabaseService as any).getProfileDailyLossUsd = async () => 0; + (supabaseService as any).getProfileConsecutiveLosses = async () => 0; + + await trader.handleSignal('BTC/USD', resultBuy, makeContext(), { user_id: 'user-1', allocated_capital: 1000, risk_per_trade_percent: 1 }); + assert.equal(openCalls.length, 1, 'Happy path should open new position'); + } finally { + config.ENABLE_TRADING = originalEnableTrading; + config.MAX_OPEN_TRADES = originalMaxOpen; + (supabaseService as any).getProfileDailyLossUsd = originalDailyLoss; + (supabaseService as any).getProfileConsecutiveLosses = originalConsecutive; + } +} + +async function testExecutionManagerAndMetrics() { + const originalEnableTrading = config.ENABLE_TRADING; + const originalAllowLegacy = process.env.ALLOW_LEGACY_EXECUTION_MANAGER; + + const fakeExchange = { + async getPosition() { return null; }, + async placeOrder(_symbol: string, _side: 'buy' | 'sell', qty: number) { + return { id: `ord-${qty}`, filled_avg_price: 123.45 }; + } + } as any; + + const apiServerMock = { + updateOrders: (_orders: any[]) => undefined, + addHistory: (_trade: any) => undefined + } as any; + + try { + process.env.ALLOW_LEGACY_EXECUTION_MANAGER = 'true'; + config.ENABLE_TRADING = true; + + const manager = new ExecutionManager(fakeExchange, apiServerMock, 'global'); + const ctx = makeContext({ currentPrice: 120 }); + + await manager.handleSignal('BTC/USD', { ruleName: 'r', passed: true, signal: SignalDirection.BUY }, ctx); + assert.equal(manager.isSymbolLocked('BTC/USD'), true); + + await manager.handleSignal('BTC/USD', { ruleName: 'r', passed: true, signal: SignalDirection.SELL }, makeContext({ currentPrice: 130 })); + assert.equal(manager.isSymbolLocked('BTC/USD'), false, 'Opposite signal should close and unlock symbol'); + + await manager.executeManualTrade('ETH/USD', 'buy', 1, 'market', 125); + assert.equal(manager.getActivePosition('ETH/USD')?.side, SignalDirection.BUY); + + manager.markTradeComplete('ETH/USD', 130, 'manual test close'); + assert.equal(manager.getActivePosition('ETH/USD'), null); + + const syncExchange = { + async getPosition(symbol: string) { + if (symbol.includes('SOL')) { + return { side: 'long', avg_entry_price: '50', qty: '2' }; + } + return null; + }, + async placeOrder() { + return { id: 'dummy' }; + } + } as any; + + const manager2 = new ExecutionManager(syncExchange, apiServerMock, 'global'); + await manager2.syncPositions(['SOL/USD', 'BTC/USD']); + assert.equal(manager2.isSymbolLocked('SOL/USD'), true); + assert.equal(manager2.isSymbolLocked('BTC/USD'), false); + + // Metrics singleton calls + metrics.operationalEventsTotal.labels({ + severity: 'INFO', + type: 'COVERAGE_TEST', + profile_id: 'p1', + symbol: 'BTC/USD' + }).inc(); + const svc = MetricsService.getInstance(); + const exposition = await svc.getMetrics(); + assert(exposition.includes('bytelyst_bot_operational_events_total')); + assert.equal(typeof svc.getContentType(), 'string'); + } finally { + if (originalAllowLegacy === undefined) { + delete process.env.ALLOW_LEGACY_EXECUTION_MANAGER; + } else { + process.env.ALLOW_LEGACY_EXECUTION_MANAGER = originalAllowLegacy; + } + config.ENABLE_TRADING = originalEnableTrading; + } +} + +async function run() { + await testIndicatorsAndDirectionTracker(); + await testRules(); + await testAIAnalysisRule(); + await testRiskEngine(); + await testProStrategyEngine(); + await testAutoTrader(); + await testExecutionManagerAndMetrics(); + + console.log('[core-module-coverage] OK: core strategy, risk, trader, and legacy execution paths validated'); +} + +await run(); diff --git a/backend/testFailureInjection.ts b/backend/testFailureInjection.ts new file mode 100644 index 0000000..3d7b515 --- /dev/null +++ b/backend/testFailureInjection.ts @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import type { Candle, IExchangeConnector } from '../src/connectors/types.js'; +import { TradeExecutor } from '../src/services/TradeExecutor.js'; +import { SignalDirection } from '../src/strategies/rules/types.js'; +import { OrderStatusSyncService } from '../src/services/OrderStatusSyncService.js'; +import { supabaseService } from '../src/services/SupabaseService.js'; + +class FlakyExchange implements IExchangeConnector { + private failPlaceOrder = false; + private failGetOrder = false; + + setPlaceOrderFailure(flag: boolean) { + this.failPlaceOrder = flag; + } + + setGetOrderFailure(flag: boolean) { + this.failGetOrder = flag; + } + + async fetchOHLCV(_symbol: string, _timeframe: string, _limit?: number): Promise { + return [{ + timestamp: Date.now(), + open: 100, + high: 100, + low: 100, + close: 100, + volume: 1 + }]; + } + + async placeOrder(_symbol: string, _side: 'buy' | 'sell', qty: number, _type: 'market' | 'limit', price?: number): Promise { + if (this.failPlaceOrder) { + throw new Error('simulated-exchange-flap: placeOrder'); + } + return { + id: `mock-${Date.now()}`, + status: 'filled', + filled_avg_price: price || 100, + filled_qty: `${qty}` + }; + } + + async getPosition(_symbol: string): Promise { + return null; + } + + async getOrder(_orderId: string): Promise { + if (this.failGetOrder) { + throw new Error('simulated-exchange-flap: getOrder'); + } + return { status: 'filled', filled_avg_price: 100, filled_qty: '1' }; + } +} + +async function run() { + const exchange = new FlakyExchange(); + + // Failure injection 1: entry should fail safely on exchange placeOrder flap. + const executor = new TradeExecutor(exchange, undefined, 'global', 'failure-test'); + exchange.setPlaceOrderFailure(true); + const result = await executor.openPosition('BTC/USD', SignalDirection.BUY, 1, 'market'); + assert.equal(result.success, false, 'openPosition must fail safely when exchange placeOrder throws.'); + assert.equal(executor.getActivePosition('BTC/USD'), null, 'No local position should be created on failed order placement.'); + + // Failure injection 2: stale-order sync should absorb getOrder flap without crashing or mutating status. + const originalGetStaleOrders = (supabaseService as any).getStaleOrders; + const originalUpdateOrderStatus = (supabaseService as any).updateOrderStatus; + const statusWrites: string[] = []; + + (supabaseService as any).getStaleOrders = async () => [{ + order_id: 'ord-flaky', + symbol: 'BTC/USD', + status: 'pending_new', + created_at: new Date(Date.now() - 10 * 60 * 1000).toISOString() + }]; + (supabaseService as any).updateOrderStatus = async (_orderId: string, status: string) => { + statusWrites.push(status); + }; + + exchange.setGetOrderFailure(true); + try { + const sync = new OrderStatusSyncService(exchange, 5_000); + await sync.triggerSync(); + assert.equal(statusWrites.length, 0, 'Order status should not be mutated when exchange getOrder fails transiently.'); + } finally { + (supabaseService as any).getStaleOrders = originalGetStaleOrders; + (supabaseService as any).updateOrderStatus = originalUpdateOrderStatus; + } + + console.log('[failure-injection] OK: exchange/API flap paths handled safely'); +} + +await run(); diff --git a/backend/testLifecycleRegressions.ts b/backend/testLifecycleRegressions.ts new file mode 100644 index 0000000..0cff067 --- /dev/null +++ b/backend/testLifecycleRegressions.ts @@ -0,0 +1,229 @@ +import assert from 'node:assert/strict'; +import { TradeExecutor } from '../src/services/TradeExecutor.js'; +import { TradeMonitor } from '../src/services/tradeMonitor.js'; +import { SignalDirection } from '../src/strategies/rules/types.js'; +import type { Candle, IExchangeConnector } from '../src/connectors/types.js'; +import { supabaseService } from '../src/services/SupabaseService.js'; +import { config } from '../src/config/index.js'; + +class MockLifecycleExchange implements IExchangeConnector { + private positionQueue: Array = []; + private latestPrice = 100; + + queuePositions(...positions: Array) { + this.positionQueue.push(...positions); + } + + setLatestPrice(price: number) { + this.latestPrice = price; + } + + async fetchOHLCV(_symbol: string, _timeframe: string, _limit?: number): Promise { + return [{ + timestamp: Date.now(), + open: this.latestPrice, + high: this.latestPrice, + low: this.latestPrice, + close: this.latestPrice, + volume: 1 + }]; + } + + async placeOrder(_symbol: string, _side: 'buy' | 'sell', qty: number, _type: 'market' | 'limit', price?: number): Promise { + return { + id: `mock-order-${Date.now()}`, + status: 'filled', + filled_avg_price: price || this.latestPrice, + filled_qty: qty + }; + } + + async getPosition(_symbol: string): Promise { + if (this.positionQueue.length > 0) { + return this.positionQueue.shift(); + } + return { + side: 'long', + qty: '1', + avg_entry_price: this.latestPrice + }; + } + cancelOrder = async (_orderId: string, _symbol?: string): Promise => true; + getCapabilities = () => ({ + fetchOpenOrders: true, + fetchOrders: true, + fetchClosedOrders: true, + fetchMyTrades: true, + fetchOHLCV: true, + cancelOrder: true, + createOrder: true, + editOrder: false, + fetchBalance: true, + fetchLedger: false, + fetchTicker: true, + fetchTickers: true, + fetchPosition: true, + fetchPositions: true, + shorting: true + }); +} + +class MockTradeExecutorForMonitor { + public positions = new Map(); + public exitCalls: Array<{ symbol: string; reason: string; price: number; tradeId?: string }> = []; + public completeCalls: Array<{ symbol: string; reason: string; price: number; tradeId?: string }> = []; + public manualReviewCalls: Array<{ symbol: string; reason: string; details?: string; tradeId?: string }> = []; + public pendingOrders = new Map(); + + getActiveSymbols(): string[] { + return Array.from(this.positions.keys()); + } + + getActivePosition(symbol: string, tradeId?: string): any { + const bySymbol = this.positions.get(symbol) || []; + if (!Array.isArray(bySymbol) || bySymbol.length === 0) return null; + if (tradeId) { + return bySymbol.find((p: any) => p.tradeId === tradeId) || null; + } + return bySymbol[0] || null; + } + + getActivePositions(symbol: string): any[] { + const bySymbol = this.positions.get(symbol) || []; + return Array.isArray(bySymbol) ? bySymbol : []; + } + + async executeExit(symbol: string, currentPrice: number, reason: string, tradeId?: string): Promise<{ success: boolean }> { + this.exitCalls.push({ symbol, reason, price: currentPrice, tradeId }); + return { success: true }; + } + + async markTradeComplete(symbol: string, exitPrice: number, reason: string, tradeId?: string): Promise<{ success: boolean }> { + this.completeCalls.push({ symbol, reason, price: exitPrice, tradeId }); + return { success: true }; + } + + markExitManualReview(symbol: string, reason: string, details?: string, tradeId?: string): void { + this.manualReviewCalls.push({ symbol, reason, details, tradeId }); + } + + getAllActivePositions(): any[] { + return []; + } + + getProfileId(): string { + return 'test-profile'; + } + + getPendingOrders(): Map { + return this.pendingOrders; + } + + verifyCapability(_capability: string, _reason: string): boolean { + return true; + } +} + + +async function testZeroTakeProfitGuard() { + const exchange = new MockLifecycleExchange(); + exchange.setLatestPrice(105); + + const executor = new MockTradeExecutorForMonitor(); + executor.positions.set('BTC/USD', [{ + symbol: 'BTC/USD', + side: 'BUY', + entryPrice: 100, + size: 1, + stopLoss: 0, + takeProfit: 0, + peakPrice: 100, + profitGuardActive: false + }]); + + const monitor = new TradeMonitor(exchange, executor as any); + await (monitor as any).checkOpenPositions(); + + assert.equal(executor.exitCalls.length, 0, 'Zero takeProfit must not activate trailing guard / exit.'); + const posAfter = executor.getActivePosition('BTC/USD'); + assert.equal(Boolean(posAfter?.profitGuardActive), false, 'profitGuardActive must remain false when takeProfit is zero.'); +} + +async function testPartialExitReduction() { + const exchange = new MockLifecycleExchange(); + + // Avoid DB writes in test mode. + (supabaseService as any).logTransaction = async () => { }; + + const executor = new TradeExecutor(exchange, undefined, 'global', 'test-profile'); + const activeMap = (executor as any).activeTraders as Map; + activeMap.set('ETH/USD::TRD-test-partial', { + symbol: 'ETH/USD', + side: SignalDirection.BUY, + entryPrice: 2000, + size: 1, + stopLoss: 1900, + takeProfit: 2200, + peakPrice: 2000, + userId: 'global', + profileId: 'test-profile', + tradeId: 'TRD-test-partial' + }); + + const partial = await executor.applyExitFill('ETH/USD', 2100, 0.4, 'Regression Partial Exit'); + assert.equal(partial.success, true, 'Partial exit should apply successfully.'); + assert.equal(partial.fullyClosed, false, 'Partial exit must not fully close the trade.'); + assert.equal(partial.remainingSize, 0.6, 'Remaining size must be reduced immediately after partial exit.'); + assert.equal(executor.getActivePosition('ETH/USD')?.size, 0.6, 'Active position size should be reduced in-memory.'); + + const final = await executor.applyExitFill('ETH/USD', 2110, 0.6, 'Regression Final Exit'); + assert.equal(final.success, true, 'Final exit should apply successfully.'); + assert.equal(final.fullyClosed, true, 'Second fill should close remaining quantity.'); + assert.equal(executor.getActivePosition('ETH/USD'), null, 'Position must be removed after full exit fill.'); +} + +async function testStalePositionReconciliation() { + const previousStrictEvidenceSetting = config.REQUIRE_EXCHANGE_FILL_EVIDENCE_FOR_AUTO_CLOSE; + (config as any).REQUIRE_EXCHANGE_FILL_EVIDENCE_FOR_AUTO_CLOSE = true; + + try { + const exchange = new MockLifecycleExchange(); + exchange.setLatestPrice(99); + exchange.queuePositions( + null, // first scan: miss #1 + null, // second scan: miss #2 + null // second scan confirmation: still missing + ); + + const executor = new MockTradeExecutorForMonitor(); + executor.positions.set('SOL/USD', [{ + symbol: 'SOL/USD', + side: 'BUY', + entryPrice: 100, + size: 2, + stopLoss: 90, + takeProfit: 110, + peakPrice: 100 + }]); + + const monitor = new TradeMonitor(exchange, executor as any); + await (monitor as any).checkOpenPositions(); + assert.equal(executor.completeCalls.length, 0, 'Single missing detection must not finalize trade.'); + + await (monitor as any).checkOpenPositions(); + assert.equal(executor.completeCalls.length, 0, 'Strict evidence mode must not auto-finalize stale position without exchange fill evidence.'); + assert.equal(executor.manualReviewCalls.length, 1, 'Second confirmed missing detection should route stale position to manual review.'); + assert.equal(executor.manualReviewCalls[0].symbol, 'SOL/USD'); + } finally { + (config as any).REQUIRE_EXCHANGE_FILL_EVIDENCE_FOR_AUTO_CLOSE = previousStrictEvidenceSetting; + } +} + +async function run() { + await testZeroTakeProfitGuard(); + await testPartialExitReduction(); + await testStalePositionReconciliation(); + console.log('[lifecycle-regressions] OK: zero-TP, partial-exit, stale reconciliation checks passed'); +} + +await run(); diff --git a/backend/testManualTraderCapitalGuard.ts b/backend/testManualTraderCapitalGuard.ts new file mode 100644 index 0000000..e2144e9 --- /dev/null +++ b/backend/testManualTraderCapitalGuard.ts @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import { ManualTrader } from '../src/services/ManualTrader.js'; +import { SignalDirection } from '../src/strategies/rules/types.js'; +import { supabaseService } from '../src/services/SupabaseService.js'; + +class MockExecutor { + public positions = new Map(); + public calls: Array<{ symbol: string; side: SignalDirection; qty: number; type: 'market' | 'limit'; price?: number }> = []; + + getAllPositions() { + return this.positions; + } + + getProfileId() { + return 'profile-cap-1'; + } + + getUserId() { + return 'user-cap-1'; + } + + getActivePosition(_symbol: string) { + return null; + } + + async closePosition(_symbol: string, _reason: string) { + return { success: true }; + } + + async openPosition( + symbol: string, + side: SignalDirection, + qty: number, + type: 'market' | 'limit', + price?: number + ) { + this.calls.push({ symbol, side, qty, type, price }); + return { success: true, orderId: `mock-${Date.now()}` }; + } +} + +async function testScaleToRemainingCapital() { + const executor = new MockExecutor(); + executor.positions.set('BTC/USD', { + entryPrice: 100, + size: 9 // committed = 900 + }); + + const trader = new ManualTrader(executor as any); + const result = await trader.executeRequest('ETH/USD', 'buy', 2, 'market', undefined, 100, 'user-cap-1'); + + assert.equal(result.success, true, 'Manual trade should succeed when some capital remains.'); + assert.equal(Number(result.adjustedQty), 1, 'Quantity should be reduced to remaining capital capacity.'); + assert.equal(executor.calls.length, 1, 'openPosition should be called once.'); + assert.equal(Number(executor.calls[0].qty), 1, 'Executor should receive scaled quantity.'); +} + +async function testWaitForCapitalRelease() { + const executor = new MockExecutor(); + executor.positions.set('BTC/USD', { + entryPrice: 100, + size: 10 // committed = 1000, no immediate room + }); + + const trader = new ManualTrader(executor as any); + + setTimeout(() => { + executor.positions.set('BTC/USD', { + entryPrice: 100, + size: 9 // remaining = 100 after release + }); + }, 1200); + + const start = Date.now(); + const result = await trader.executeRequest('SOL/USD', 'buy', 1, 'market', undefined, 100, 'user-cap-1'); + const elapsed = Date.now() - start; + + assert.equal(result.success, true, 'Manual trade should proceed once capital is released.'); + assert(elapsed >= 1000, 'Manual trader should wait for capital release window.'); + assert.equal(executor.calls.length, 1, 'openPosition should execute after wait.'); + assert.equal(Number(executor.calls[0].qty), 1, 'Released capital should allow requested quantity.'); +} + +async function run() { + const originalGetProfileCapital = (supabaseService as any).getProfileCapital; + (supabaseService as any).getProfileCapital = async () => ({ + allocatedCapital: 1000, + isActive: true, + userId: 'user-cap-1' + }); + + try { + await testScaleToRemainingCapital(); + await testWaitForCapitalRelease(); + } finally { + (supabaseService as any).getProfileCapital = originalGetProfileCapital; + } + + console.log('[manual-capital-guard] OK: scale-to-remaining and wait-for-release paths validated'); +} + +await run(); diff --git a/backend/testOrderStatusSyncRegressions.ts b/backend/testOrderStatusSyncRegressions.ts new file mode 100644 index 0000000..66d8b4f --- /dev/null +++ b/backend/testOrderStatusSyncRegressions.ts @@ -0,0 +1,264 @@ +import assert from 'node:assert/strict'; +import { OrderStatusSyncService, type OrderStatusSyncEvent } from '../src/services/OrderStatusSyncService.js'; +import type { Candle, IExchangeConnector } from '../src/connectors/types.js'; +import { supabaseService } from '../src/services/SupabaseService.js'; +import { config } from '../src/config/index.js'; + +class MockOrderSyncExchange implements IExchangeConnector { + private orderResponses = new Map(); + + setOrder(orderId: string, payload: any) { + this.orderResponses.set(orderId, payload); + } + + async fetchOHLCV(_symbol: string, _timeframe: string, _limit?: number): Promise { + return [{ + timestamp: Date.now(), + open: 100, + high: 100, + low: 100, + close: 100, + volume: 1 + }]; + } + + async placeOrder(_symbol: string, _side: 'buy' | 'sell', qty: number, _type: 'market' | 'limit', price?: number): Promise { + return { + id: `mock-order-${Date.now()}`, + status: 'filled', + filled_avg_price: price || 100, + filled_qty: qty + }; + } + + async getPosition(_symbol: string): Promise { + return null; + } + + async getOrder(orderId: string): Promise { + const response = this.orderResponses.get(orderId); + if (response instanceof Error) { + throw response; + } + return response ?? null; + } + + cancelOrder = async (_orderId: string, _symbol?: string): Promise => true; + + getCapabilities = () => ({ + fetchOpenOrders: true, + fetchOrders: true, + fetchClosedOrders: true, + fetchMyTrades: true, + fetchOHLCV: true, + cancelOrder: true, + createOrder: true, + editOrder: false, + fetchBalance: true, + fetchLedger: false, + fetchTicker: true, + fetchTickers: true, + fetchPosition: true, + fetchPositions: true, + shorting: true + }); +} + +async function run() { + const exchange = new MockOrderSyncExchange(); + const seenEvents: OrderStatusSyncEvent[] = []; + const updateCalls: Array<{ orderId: string; status: string; fillQty?: number }> = []; + const previousMissingGrace = config.ORDER_SYNC_MISSING_GRACE_MINUTES; + const previousMissingConfirmations = config.ORDER_SYNC_MISSING_CONFIRMATION_COUNT; + + const originalGetStaleOrders = (supabaseService as any).getStaleOrders; + const originalUpdateOrderStatus = (supabaseService as any).updateOrderStatus; + const originalIsTradeLifecycleClosed = (supabaseService as any).isTradeLifecycleClosed; + const originalGetVirtualOpenPosition = (supabaseService as any).getVirtualOpenPosition; + + const staleRows = [ + { + order_id: 'ord-partial', + symbol: 'BTC/USD', + status: 'pending_new', + action: 'EXIT', + trade_id: 'TRD-sync-1', + user_id: 'user-1', + profile_id: 'profile-1', + created_at: new Date(Date.now() - 15 * 60 * 1000).toISOString() + }, + { + order_id: 'ord-missing-old', + symbol: 'ETH/USD', + status: 'pending_new', + action: 'ENTRY', + trade_id: 'TRD-sync-2', + user_id: 'user-1', + profile_id: 'profile-1', + created_at: new Date(Date.now() - 26 * 60 * 60 * 1000).toISOString() + }, + { + order_id: 'ord-ghost-exit', + symbol: 'BTC/USD', + status: 'pending_new', + action: 'EXIT', + trade_id: 'TRD-sync-closed', + user_id: 'user-1', + profile_id: 'profile-1', + created_at: new Date(Date.now() - 15 * 60 * 1000).toISOString() + }, + { + order_id: 'ord-legacy-closed-error', + symbol: 'ETH/USD', + status: 'pending_new', + trade_id: 'TRD-sync-closed-error', + user_id: 'user-1', + profile_id: 'profile-1', + created_at: new Date(Date.now() - 20 * 60 * 1000).toISOString() + }, + { + order_id: 'ord-transient-failure', + symbol: 'ETH/USD', + status: 'pending_new', + action: 'EXIT', + trade_id: 'TRD-sync-timeout', + user_id: 'user-1', + profile_id: 'profile-1', + created_at: new Date(Date.now() - 20 * 60 * 1000).toISOString() + }, + { + order_id: 'ord-legacy-no-trade', + symbol: 'SOL/USD', + side: 'SELL', + status: 'pending_new', + action: 'EXIT', + user_id: 'user-1', + profile_id: 'profile-1', + created_at: new Date(Date.now() - 30 * 60 * 1000).toISOString() + } + ]; + + exchange.setOrder('ord-partial', { + id: 'ord-partial', + status: 'partially_filled', + filled_avg_price: 101.25, + filled_qty: '0.35' + }); + exchange.setOrder('ord-missing-old', null); + exchange.setOrder('ord-legacy-closed-error', new Error('404 order not found')); + exchange.setOrder('ord-transient-failure', new Error('socket timeout')); + exchange.setOrder('ord-legacy-no-trade', null); + + (supabaseService as any).getStaleOrders = async () => staleRows; + (supabaseService as any).isTradeLifecycleClosed = async (tradeId: string) => + tradeId === 'TRD-sync-closed' || tradeId === 'TRD-sync-closed-error'; + (supabaseService as any).getVirtualOpenPosition = async (profileId: string, symbol: string) => { + if (profileId === 'profile-1' && symbol === 'SOL/USD') { + return null; + } + return { + profileId, + symbol, + side: 'BUY', + qty: 1, + entryPrice: 100, + stopLoss: 90, + takeProfit: 110, + tradeId: 'TRD-open' + }; + }; + (supabaseService as any).updateOrderStatus = async ( + orderId: string, + status: string, + _filledAt?: Date, + _price?: number, + qty?: number + ) => { + updateCalls.push({ orderId, status, fillQty: qty }); + }; + + try { + (config as any).ORDER_SYNC_MISSING_GRACE_MINUTES = 0; + (config as any).ORDER_SYNC_MISSING_CONFIRMATION_COUNT = 2; + + const service = new OrderStatusSyncService( + exchange, + 10_000, + 'profile-1', + (event) => { + seenEvents.push(event); + } + ); + + await service.triggerSync(); + + const partialUpdate = updateCalls.find((c) => c.orderId === 'ord-partial'); + assert(partialUpdate, 'Partial-fill stale order should be updated.'); + assert.equal(partialUpdate.status, 'partially_filled'); + assert.equal(Number(partialUpdate.fillQty), 0.35, 'Partial-fill quantity should be preserved in status update.'); + + const partialEvent = seenEvents.find((e) => e.orderId === 'ord-partial'); + assert(partialEvent, 'Partial-fill stale order should emit lifecycle callback.'); + assert.equal(partialEvent?.status, 'partially_filled'); + assert.equal(Number(partialEvent?.fillQty), 0.35); + assert.equal(Boolean(partialEvent?.quarantined), false); + + assert.equal( + Boolean(updateCalls.find((c) => c.orderId === 'ord-missing-old')), + false, + 'Missing orders should not mutate on first detection in confirmation mode.' + ); + + await service.triggerSync(); + + const missingOldUpdate = updateCalls.find((c) => c.orderId === 'ord-missing-old'); + assert(missingOldUpdate, 'Old missing order should be quarantined to unknown.'); + assert.equal(missingOldUpdate.status, 'unknown'); + + const missingOldEvent = seenEvents.find((e) => e.orderId === 'ord-missing-old'); + assert(missingOldEvent, 'Old missing order should emit quarantined callback.'); + assert.equal(missingOldEvent?.status, 'unknown'); + assert.equal(Boolean(missingOldEvent?.quarantined), true); + + const ghostExitUpdate = updateCalls.find((c) => c.orderId === 'ord-ghost-exit'); + assert(ghostExitUpdate, 'Closed lifecycle ghost EXIT should be auto-resolved.'); + assert.equal(ghostExitUpdate.status, 'canceled'); + + const ghostExitEvent = seenEvents.find((e) => e.orderId === 'ord-ghost-exit'); + assert(ghostExitEvent, 'Closed lifecycle ghost EXIT should emit callback.'); + assert.equal(ghostExitEvent?.status, 'canceled'); + assert.equal(Boolean(ghostExitEvent?.quarantined), false); + + const legacyClosedUpdate = updateCalls.find((c) => c.orderId === 'ord-legacy-closed-error'); + assert(legacyClosedUpdate, 'Legacy closed lifecycle with exchange not-found exception should be auto-resolved.'); + assert.equal(legacyClosedUpdate.status, 'canceled'); + + const legacyClosedEvent = seenEvents.find((e) => e.orderId === 'ord-legacy-closed-error'); + assert(legacyClosedEvent, 'Legacy closed lifecycle should emit callback.'); + assert.equal(legacyClosedEvent?.status, 'canceled'); + assert.equal(Boolean(legacyClosedEvent?.quarantined), false); + + const transientFailureUpdate = updateCalls.find((c) => c.orderId === 'ord-transient-failure'); + assert.equal(Boolean(transientFailureUpdate), false, 'Transient exchange failures must not mutate order status.'); + + const legacyNoTradeUpdate = updateCalls.find((c) => c.orderId === 'ord-legacy-no-trade'); + assert(legacyNoTradeUpdate, 'Legacy EXIT-like order without trade_id should be auto-resolved when profile lifecycle is flat.'); + assert.equal(legacyNoTradeUpdate.status, 'canceled'); + + const legacyNoTradeEvent = seenEvents.find((e) => e.orderId === 'ord-legacy-no-trade'); + assert(legacyNoTradeEvent, 'Legacy EXIT-like order without trade_id should emit callback.'); + assert.equal(legacyNoTradeEvent?.status, 'canceled'); + assert.equal(Boolean(legacyNoTradeEvent?.quarantined), false); + } finally { + (config as any).ORDER_SYNC_MISSING_GRACE_MINUTES = previousMissingGrace; + (config as any).ORDER_SYNC_MISSING_CONFIRMATION_COUNT = previousMissingConfirmations; + (supabaseService as any).getStaleOrders = originalGetStaleOrders; + (supabaseService as any).updateOrderStatus = originalUpdateOrderStatus; + (supabaseService as any).isTradeLifecycleClosed = originalIsTradeLifecycleClosed; + (supabaseService as any).getVirtualOpenPosition = originalGetVirtualOpenPosition; + } + + console.log('[order-status-sync-regressions] OK: stale partial fill, ghost EXIT resolution, and quarantine paths validated'); +} + +await run(); diff --git a/backend/testReconciliationExitBackfillEvidenceGuard.ts b/backend/testReconciliationExitBackfillEvidenceGuard.ts new file mode 100644 index 0000000..6c850fe --- /dev/null +++ b/backend/testReconciliationExitBackfillEvidenceGuard.ts @@ -0,0 +1,248 @@ +import assert from 'node:assert/strict'; +import { config } from '../src/config/index.js'; +import { supabaseService } from '../src/services/SupabaseService.js'; +import { + ReconciliationExitBackfillService +} from '../src/services/reconciliationExitBackfillService.js'; + +type MutableConfig = typeof config & Record; +const mutableConfig = config as MutableConfig; + +const restoreConfig = (snapshot: Record) => { + for (const [key, value] of Object.entries(snapshot)) { + mutableConfig[key] = value; + } +}; + +const buildLifecycleEntry = (timestampMs: number) => ({ + profile_id: 'profile-1', + user_id: 'user-1', + symbol: 'BTC/USDT', + trade_id: 'TRD-profile-1-BTCUSDT-BUY-000001', + side: 'BUY', + action: 'ENTRY', + qty: 1, + quantity: 1, + price: 65000, + status: 'filled', + timestamp: timestampMs, + created_at: new Date(timestampMs).toISOString(), + sub_tag: 'BL:PAPER:P9999999999:T999999999999:ENTRY' +}); + +const createMockExecutor = (closedOrders: any[], exchangePosition: any = null) => ({ + fetchExchangePosition: async () => exchangePosition, + fetchExchangeClosedOrders: async () => closedOrders +}) as any; + +const withPatchedSupabase = async ( + lifecycleRows: any[], + run: (calls: { + auditRows: any[]; + upsertRows: any[]; + }) => Promise +) => { + const auditRows: any[] = []; + const upsertRows: any[] = []; + + const originalIsAuditAvailable = supabaseService.isReconciliationBackfillAuditAvailable.bind(supabaseService); + const originalGetFilledLifecycleOrdersForProfile = supabaseService.getFilledLifecycleOrdersForProfile.bind(supabaseService); + const originalGetOpenOrdersForProfile = supabaseService.getOpenOrdersForProfile.bind(supabaseService); + const originalGetExistingOrderIds = supabaseService.getExistingOrderIds.bind(supabaseService); + const originalInsertAuditRows = supabaseService.insertReconciliationBackfillAuditRows.bind(supabaseService); + const originalUpsertBackfillOrders = supabaseService.upsertReconciliationBackfillOrders.bind(supabaseService); + + try { + (supabaseService as any).isReconciliationBackfillAuditAvailable = async () => true; + (supabaseService as any).getFilledLifecycleOrdersForProfile = async () => lifecycleRows; + (supabaseService as any).getOpenOrdersForProfile = async () => []; + (supabaseService as any).getExistingOrderIds = async () => new Set(); + (supabaseService as any).insertReconciliationBackfillAuditRows = async (rows: any[]) => { + auditRows.push(...(rows || [])); + return true; + }; + (supabaseService as any).upsertReconciliationBackfillOrders = async (rows: any[]) => { + upsertRows.push(...(rows || [])); + return true; + }; + + await run({ auditRows, upsertRows }); + } finally { + (supabaseService as any).isReconciliationBackfillAuditAvailable = originalIsAuditAvailable; + (supabaseService as any).getFilledLifecycleOrdersForProfile = originalGetFilledLifecycleOrdersForProfile; + (supabaseService as any).getOpenOrdersForProfile = originalGetOpenOrdersForProfile; + (supabaseService as any).getExistingOrderIds = originalGetExistingOrderIds; + (supabaseService as any).insertReconciliationBackfillAuditRows = originalInsertAuditRows; + (supabaseService as any).upsertReconciliationBackfillOrders = originalUpsertBackfillOrders; + } +}; + +const testUnattributedEvidenceBlocked = async () => { + const now = Date.now(); + const service = new ReconciliationExitBackfillService(); + const executor = createMockExecutor([ + { + id: 'ex-001', + symbol: 'BTC/USD', + side: 'sell', + status: 'filled', + filled_qty: 1, + filled_avg_price: 64000, + filled_at: new Date(now + 60_000).toISOString(), + client_order_id: '8f42d6d5-c35a-4c3f-9f5e-b2e3fdcbe0f7', + sub_tag: null + } + ]); + + await withPatchedSupabase([buildLifecycleEntry(now)], async ({ upsertRows }) => { + const result = await service.runProfile({ + profileId: 'profile-1', + userId: 'user-1', + executor + }); + assert.equal(result.attempted, true); + assert.equal(result.proposedRows, 0, 'Unattributed fills must not be proposed for auto-backfill.'); + assert.equal(result.insertedRows, 0); + assert.equal(result.noGoTrades, 1); + assert.equal(result.noGoReasonCounts.missing_fill_evidence_for_large_remainder, 1); + assert.equal(result.noGoSamples.length, 1); + assert.equal(result.noGoSamples[0].reason, 'missing_fill_evidence_for_large_remainder'); + assert.equal(upsertRows.length, 0, 'No synthetic EXIT rows should be inserted for unattributed fills.'); + }); +}; + +const testAttributedClientOrderEvidenceAccepted = async () => { + const now = Date.now(); + const service = new ReconciliationExitBackfillService(); + const tradeId = 'TRD-profile-1-BTCUSDT-BUY-000001'; + const executor = createMockExecutor([ + { + id: 'ex-002', + symbol: 'BTC/USD', + side: 'sell', + status: 'filled', + filled_qty: 1, + filled_avg_price: 64250, + filled_at: new Date(now + 60_000).toISOString(), + client_order_id: `bytelyst-profile-1-${tradeId}-exit`, + sub_tag: null + } + ]); + + await withPatchedSupabase([buildLifecycleEntry(now)], async ({ upsertRows }) => { + const result = await service.runProfile({ + profileId: 'profile-1', + userId: 'user-1', + executor + }); + assert.equal(result.attempted, true); + assert.equal(result.proposedRows, 1, 'Attributed client_order_id should produce one backfill candidate.'); + assert.equal(result.noGoTrades, 0); + assert.deepEqual(result.noGoReasonCounts, {}); + assert.equal(result.noGoSamples.length, 0); + assert.equal(upsertRows.length, 0, 'Dry-run mode must not write rows.'); + }); +}; + +const testTemporalGateRejectsStaleEvidence = async () => { + const now = Date.now(); + const service = new ReconciliationExitBackfillService(); + const tradeId = 'TRD-profile-1-BTCUSDT-BUY-000001'; + const executor = createMockExecutor([ + { + id: 'ex-003', + symbol: 'BTC/USD', + side: 'sell', + status: 'filled', + filled_qty: 1, + filled_avg_price: 64100, + filled_at: new Date(now - (10 * 60_000)).toISOString(), + client_order_id: `bytelyst-profile-1-${tradeId}-exit`, + sub_tag: null + } + ]); + + await withPatchedSupabase([buildLifecycleEntry(now)], async ({ upsertRows }) => { + const result = await service.runProfile({ + profileId: 'profile-1', + userId: 'user-1', + executor + }); + assert.equal(result.attempted, true); + assert.equal(result.proposedRows, 0, 'Stale fills before lifecycle timestamp must not be auto-assigned.'); + assert.equal(result.noGoTrades, 1); + assert.equal(result.noGoReasonCounts.missing_fill_evidence_for_large_remainder, 1); + assert.equal(result.noGoSamples.length, 1); + assert.equal(result.noGoSamples[0].reason, 'missing_fill_evidence_for_large_remainder'); + assert.equal(upsertRows.length, 0, 'Dry-run mode must not write rows.'); + }); +}; + +const testOpenExchangePositionIsAdvisoryNotNoGo = async () => { + const now = Date.now(); + const service = new ReconciliationExitBackfillService(); + const executor = createMockExecutor([], { qty: 1 }); + + await withPatchedSupabase([buildLifecycleEntry(now)], async ({ auditRows, upsertRows }) => { + const result = await service.runProfile({ + profileId: 'profile-1', + userId: 'user-1', + executor + }); + assert.equal(result.attempted, true); + assert.equal(result.proposedRows, 0, 'Open exchange positions must not auto-propose backfill rows.'); + assert.equal(result.noGoTrades, 0, 'Open exchange positions should be advisory, not NO_GO.'); + assert.deepEqual(result.noGoReasonCounts, {}, 'Advisory blockers must not populate NO_GO reason counts.'); + assert.equal(result.noGoSamples.length, 0); + assert.equal(upsertRows.length, 0, 'Dry-run mode must not write rows.'); + assert.ok( + auditRows.some((row) => row?.decision === 'SKIP_ACTIVE_POSITION'), + 'Expected advisory audit entry for active exchange position blocker.' + ); + }); +}; + +async function main() { + const snapshot: Record = { + ENABLE_RECON_EXIT_BACKFILL: mutableConfig.ENABLE_RECON_EXIT_BACKFILL, + RECON_EXIT_BACKFILL_DRY_RUN: mutableConfig.RECON_EXIT_BACKFILL_DRY_RUN, + RECON_EXIT_BACKFILL_REQUIRE_PAUSE: mutableConfig.RECON_EXIT_BACKFILL_REQUIRE_PAUSE, + RECON_EXIT_BACKFILL_DUST_ABS_QTY: mutableConfig.RECON_EXIT_BACKFILL_DUST_ABS_QTY, + RECON_EXIT_BACKFILL_DUST_REL_PCT: mutableConfig.RECON_EXIT_BACKFILL_DUST_REL_PCT, + RECON_EXIT_BACKFILL_LOOKBACK_HOURS: mutableConfig.RECON_EXIT_BACKFILL_LOOKBACK_HOURS, + RECON_EXIT_BACKFILL_REQUIRE_STRONG_ATTRIBUTION: mutableConfig.RECON_EXIT_BACKFILL_REQUIRE_STRONG_ATTRIBUTION, + RECON_EXIT_BACKFILL_ALLOW_HEURISTIC_MATCH: mutableConfig.RECON_EXIT_BACKFILL_ALLOW_HEURISTIC_MATCH, + RECON_EXIT_BACKFILL_FILL_AFTER_TRADE_GRACE_MINUTES: mutableConfig.RECON_EXIT_BACKFILL_FILL_AFTER_TRADE_GRACE_MINUTES, + RECON_ORDER_COVERAGE_FETCH_LIMIT_PER_PAGE: mutableConfig.RECON_ORDER_COVERAGE_FETCH_LIMIT_PER_PAGE, + RECON_ORDER_COVERAGE_MAX_FETCH_PAGES: mutableConfig.RECON_ORDER_COVERAGE_MAX_FETCH_PAGES, + SYMBOLS: mutableConfig.SYMBOLS + }; + + try { + mutableConfig.ENABLE_RECON_EXIT_BACKFILL = true; + mutableConfig.RECON_EXIT_BACKFILL_DRY_RUN = true; + mutableConfig.RECON_EXIT_BACKFILL_REQUIRE_PAUSE = false; + mutableConfig.RECON_EXIT_BACKFILL_DUST_ABS_QTY = 0.0001; + mutableConfig.RECON_EXIT_BACKFILL_DUST_REL_PCT = 0; + mutableConfig.RECON_EXIT_BACKFILL_LOOKBACK_HOURS = 72; + mutableConfig.RECON_EXIT_BACKFILL_REQUIRE_STRONG_ATTRIBUTION = true; + mutableConfig.RECON_EXIT_BACKFILL_ALLOW_HEURISTIC_MATCH = false; + mutableConfig.RECON_EXIT_BACKFILL_FILL_AFTER_TRADE_GRACE_MINUTES = 1; + mutableConfig.RECON_ORDER_COVERAGE_FETCH_LIMIT_PER_PAGE = 500; + mutableConfig.RECON_ORDER_COVERAGE_MAX_FETCH_PAGES = 8; + mutableConfig.SYMBOLS = ['BTC/USDT']; + + await testUnattributedEvidenceBlocked(); + await testAttributedClientOrderEvidenceAccepted(); + await testTemporalGateRejectsStaleEvidence(); + await testOpenExchangePositionIsAdvisoryNotNoGo(); + console.log('[reconciliation-exit-backfill-evidence-guard] OK: attribution and temporal evidence guards validated'); + } finally { + restoreConfig(snapshot); + } +} + +main().catch((error) => { + console.error('[reconciliation-exit-backfill-evidence-guard] failed', error); + process.exit(1); +}); diff --git a/backend/testReconciliationParityHeartbeat.ts b/backend/testReconciliationParityHeartbeat.ts new file mode 100644 index 0000000..447da3d --- /dev/null +++ b/backend/testReconciliationParityHeartbeat.ts @@ -0,0 +1,297 @@ +import assert from 'node:assert/strict'; +import { config } from '../src/config/index.js'; +import { healthTracker } from '../src/services/healthTracker.js'; +import { supabaseService } from '../src/services/SupabaseService.js'; +import { + ReconciliationParityHeartbeatService +} from '../src/services/reconciliationParityHeartbeatService.js'; + +type MutableConfig = typeof config & Record; +const mutableConfig = config as MutableConfig; + +const restoreConfig = (snapshot: Record) => { + for (const [key, value] of Object.entries(snapshot)) { + mutableConfig[key] = value; + } +}; + +const resetTradingControl = () => { + healthTracker.recordTradingControl({ + mode: 'RUNNING', + lastChangedBy: 'test', + lastChangedAt: Date.now(), + reason: 'reset' + }); +}; + +const createMockExecutor = () => { + const reconcileCalls: any[] = []; + return { + executor: { + fetchExchangePosition: async () => null, + reconcileExitFill: async (...args: any[]) => { + reconcileCalls.push(args); + } + } as any, + reconcileCalls + }; +}; + +const withPatchedSupabase = async (run: (calls: { + logOrderCalls: any[]; + existingOrderIds: Set; +}) => Promise) => { + const logOrderCalls: any[] = []; + const existingOrderIds = new Set(); + + const originalGetProfileCapital = supabaseService.getProfileCapital.bind(supabaseService); + const originalGetVirtualOpenPosition = supabaseService.getVirtualOpenPosition.bind(supabaseService); + const originalGetVirtualOpenPositionForTrade = supabaseService.getVirtualOpenPositionForTrade.bind(supabaseService); + const originalHasLifecycleEntryOrder = supabaseService.hasLifecycleEntryOrder.bind(supabaseService); + const originalHasLifecycleEntryOrderWithProfileSubTag = supabaseService.hasLifecycleEntryOrderWithProfileSubTag.bind(supabaseService); + const originalGetExistingOrderIds = supabaseService.getExistingOrderIds.bind(supabaseService); + const originalLogOrder = supabaseService.logOrder.bind(supabaseService); + + try { + (supabaseService as any).getProfileCapital = async () => ({ + allocatedCapital: 1000, + isActive: true, + userId: 'user-1' + }); + (supabaseService as any).getVirtualOpenPosition = async (_profileId: string, symbol: string) => { + if (symbol === 'BTC/USDT') { + return { + profileId: 'profile-1', + symbol, + side: 'BUY', + qty: 1, + entryPrice: 100, + stopLoss: 95, + takeProfit: 120, + userId: 'user-1', + tradeId: 'trade-1', + tradeIds: ['trade-1'] + }; + } + return null; + }; + (supabaseService as any).getVirtualOpenPositionForTrade = async (_profileId: string, symbol: string, tradeId: string) => { + if (symbol === 'BTC/USDT' && tradeId === 'trade-1') { + return { + profileId: 'profile-1', + symbol, + side: 'BUY', + qty: 1, + entryPrice: 100, + stopLoss: 95, + takeProfit: 120, + userId: 'user-1', + tradeId, + tradeIds: [tradeId] + }; + } + return null; + }; + (supabaseService as any).hasLifecycleEntryOrder = async () => true; + (supabaseService as any).hasLifecycleEntryOrderWithProfileSubTag = async () => true; + (supabaseService as any).getExistingOrderIds = async (orderIds: string[]) => { + const out = new Set(); + for (const id of orderIds || []) { + if (existingOrderIds.has(String(id))) out.add(String(id)); + } + return out; + }; + (supabaseService as any).logOrder = async (payload: any) => { + logOrderCalls.push(payload); + existingOrderIds.add(String(payload?.order_id || '')); + }; + + await run({ logOrderCalls, existingOrderIds }); + } finally { + (supabaseService as any).getProfileCapital = originalGetProfileCapital; + (supabaseService as any).getVirtualOpenPosition = originalGetVirtualOpenPosition; + (supabaseService as any).getVirtualOpenPositionForTrade = originalGetVirtualOpenPositionForTrade; + (supabaseService as any).hasLifecycleEntryOrder = originalHasLifecycleEntryOrder; + (supabaseService as any).hasLifecycleEntryOrderWithProfileSubTag = originalHasLifecycleEntryOrderWithProfileSubTag; + (supabaseService as any).getExistingOrderIds = originalGetExistingOrderIds; + (supabaseService as any).logOrder = originalLogOrder; + } +}; + +const testStreakAndIdempotency = async () => { + const service = new ReconciliationParityHeartbeatService(); + const { executor, reconcileCalls } = createMockExecutor(); + + await withPatchedSupabase(async ({ logOrderCalls }) => { + const r1 = await service.runProfile({ + profileId: 'profile-1', + userId: 'user-1', + executor, + monitoredSymbols: ['BTC/USDT'] + }); + const r2 = await service.runProfile({ + profileId: 'profile-1', + userId: 'user-1', + executor, + monitoredSymbols: ['BTC/USDT'] + }); + const r3 = await service.runProfile({ + profileId: 'profile-1', + userId: 'user-1', + executor, + monitoredSymbols: ['BTC/USDT'] + }); + const r4 = await service.runProfile({ + profileId: 'profile-1', + userId: 'user-1', + executor, + monitoredSymbols: ['BTC/USDT'] + }); + + assert.equal(r1.autoClosedTrades, 0); + assert.equal(r2.autoClosedTrades, 0); + assert.equal(r3.autoClosedTrades, 1, 'Third parity confirmation should auto-close once.'); + assert.equal(r4.autoClosedTrades, 0, 'No duplicate close expected after idempotent synthetic row exists.'); + assert.equal(r3.totalMismatchNotionalUsd, 100, 'Mismatch notional should capture the open trade exposure.'); + assert.equal(logOrderCalls.length, 1, 'Synthetic close must be logged once.'); + assert.equal(reconcileCalls.length, 1, 'Reconcile exit fill should run once.'); + }); +}; + +const testSubTagQuarantine = async () => { + const service = new ReconciliationParityHeartbeatService(); + const { executor, reconcileCalls } = createMockExecutor(); + + await withPatchedSupabase(async ({ logOrderCalls }) => { + (supabaseService as any).hasLifecycleEntryOrderWithProfileSubTag = async () => false; + (supabaseService as any).hasLifecycleEntryOrder = async () => false; + const result = await service.runProfile({ + profileId: 'profile-1', + userId: 'user-1', + executor, + monitoredSymbols: ['BTC/USDT'] + }); + assert.equal(result.autoClosedTrades, 0, 'Unattributed trades must never auto-close.'); + assert.equal(result.quarantinedTrades, 1, 'Unattributed trades should be quarantined.'); + assert.equal(result.totalMismatchNotionalUsd, 0, 'Unattributed quarantined trades should not contribute to watchdog mismatch notional.'); + assert.equal(logOrderCalls.length, 0, 'No synthetic close rows should be logged.'); + assert.equal(reconcileCalls.length, 0, 'No exit fill should be applied for quarantined trades.'); + }); +}; + +const testLegacyAttributionFallback = async () => { + const service = new ReconciliationParityHeartbeatService(); + const { executor, reconcileCalls } = createMockExecutor(); + + await withPatchedSupabase(async ({ logOrderCalls }) => { + (supabaseService as any).hasLifecycleEntryOrderWithProfileSubTag = async () => false; + (supabaseService as any).hasLifecycleEntryOrder = async () => true; + const result = await service.runProfile({ + profileId: 'profile-1', + userId: 'user-1', + executor, + monitoredSymbols: ['BTC/USDT'] + }); + assert.equal(result.quarantinedTrades, 0, 'Legacy fallback should keep attributable trades actionable.'); + assert.equal(result.totalMismatchNotionalUsd, 100, 'Legacy-attributed trades should still count for mismatch notional.'); + assert.equal(result.autoClosedTrades, 0, 'Single confirmation should not auto-close immediately.'); + assert.equal(logOrderCalls.length, 0, 'No synthetic close should be logged on first confirmation.'); + assert.equal(reconcileCalls.length, 0, 'No exit fill should be applied on first confirmation.'); + }); +}; + +const testWatchdogPause = async () => { + const service = new ReconciliationParityHeartbeatService(); + const { executor } = createMockExecutor(); + resetTradingControl(); + + await withPatchedSupabase(async () => { + (supabaseService as any).getProfileCapital = async () => ({ + allocatedCapital: 1000, + isActive: true, + userId: 'user-1' + }); + (supabaseService as any).getVirtualOpenPosition = async (_profileId: string, symbol: string) => { + if (symbol !== 'BTC/USDT') return null; + return { + profileId: 'profile-1', + symbol, + side: 'BUY', + qty: 10, + entryPrice: 100, + stopLoss: 95, + takeProfit: 120, + userId: 'user-1', + tradeId: 'trade-watchdog', + tradeIds: ['trade-watchdog'] + }; + }; + (supabaseService as any).getVirtualOpenPositionForTrade = async (_profileId: string, symbol: string, tradeId: string) => ({ + profileId: 'profile-1', + symbol, + side: 'BUY', + qty: 10, + entryPrice: 100, + stopLoss: 95, + takeProfit: 120, + userId: 'user-1', + tradeId, + tradeIds: [tradeId] + }); + + const result = await service.runProfile({ + profileId: 'profile-1', + userId: 'user-1', + executor, + monitoredSymbols: ['BTC/USDT'] + }); + + assert.equal(result.integrityWatchdogTriggered, true, 'Watchdog must trigger for large mismatch notional.'); + assert.equal(result.totalMismatchNotionalUsd, 1000, 'Watchdog test should report cumulative mismatch notional.'); + assert.equal(healthTracker.isPaused(), true, 'Trading should be paused by watchdog for high-risk mismatch.'); + }); +}; + +async function main() { + const configSnapshot: Record = { + ENABLE_RECON_POSITION_PARITY_HEARTBEAT: mutableConfig.ENABLE_RECON_POSITION_PARITY_HEARTBEAT, + RECON_POSITION_PARITY_DRY_RUN: mutableConfig.RECON_POSITION_PARITY_DRY_RUN, + RECON_POSITION_PARITY_CONFIRMATIONS: mutableConfig.RECON_POSITION_PARITY_CONFIRMATIONS, + RECON_POSITION_PARITY_DUST_ABS_QTY: mutableConfig.RECON_POSITION_PARITY_DUST_ABS_QTY, + RECON_POSITION_PARITY_MAX_NOTIONAL_PCT: mutableConfig.RECON_POSITION_PARITY_MAX_NOTIONAL_PCT, + RECON_POSITION_PARITY_REQUIRE_SUBTAG_ATTRIBUTION: mutableConfig.RECON_POSITION_PARITY_REQUIRE_SUBTAG_ATTRIBUTION, + RECON_POSITION_PARITY_ALLOW_LEGACY_ENTRY_ATTRIBUTION: mutableConfig.RECON_POSITION_PARITY_ALLOW_LEGACY_ENTRY_ATTRIBUTION, + RECON_EXIT_BACKFILL_DUST_ABS_QTY: mutableConfig.RECON_EXIT_BACKFILL_DUST_ABS_QTY, + RECON_EXIT_BACKFILL_DUST_REL_PCT: mutableConfig.RECON_EXIT_BACKFILL_DUST_REL_PCT, + ENABLE_RECON_INTEGRITY_WATCHDOG: mutableConfig.ENABLE_RECON_INTEGRITY_WATCHDOG, + RECON_INTEGRITY_WATCHDOG_THROTTLE_MS: mutableConfig.RECON_INTEGRITY_WATCHDOG_THROTTLE_MS + }; + + try { + mutableConfig.ENABLE_RECON_POSITION_PARITY_HEARTBEAT = true; + mutableConfig.RECON_POSITION_PARITY_DRY_RUN = false; + mutableConfig.RECON_POSITION_PARITY_CONFIRMATIONS = 3; + mutableConfig.RECON_POSITION_PARITY_DUST_ABS_QTY = 0.0001; + mutableConfig.RECON_POSITION_PARITY_MAX_NOTIONAL_PCT = 0.5; + mutableConfig.RECON_POSITION_PARITY_REQUIRE_SUBTAG_ATTRIBUTION = true; + mutableConfig.RECON_POSITION_PARITY_ALLOW_LEGACY_ENTRY_ATTRIBUTION = true; + mutableConfig.RECON_EXIT_BACKFILL_DUST_ABS_QTY = 0.0001; + mutableConfig.RECON_EXIT_BACKFILL_DUST_REL_PCT = 0; + mutableConfig.ENABLE_RECON_INTEGRITY_WATCHDOG = true; + mutableConfig.RECON_INTEGRITY_WATCHDOG_THROTTLE_MS = 0; + + await testStreakAndIdempotency(); + await testSubTagQuarantine(); + await testLegacyAttributionFallback(); + await testWatchdogPause(); + console.log('[reconciliation-parity-heartbeat] OK: streak, idempotency, quarantine, and watchdog behaviors validated'); + } finally { + restoreConfig(configSnapshot); + resetTradingControl(); + } +} + +main().catch((error) => { + console.error('[reconciliation-parity-heartbeat] failed', error); + process.exit(1); +}); diff --git a/backend/testReconciliationWatchdogAutoResume.ts b/backend/testReconciliationWatchdogAutoResume.ts new file mode 100644 index 0000000..aef9f2b --- /dev/null +++ b/backend/testReconciliationWatchdogAutoResume.ts @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict'; +import { config } from '../src/config/index.js'; +import { healthTracker } from '../src/services/healthTracker.js'; +import { + ReconciliationWatchdogAutoResumeService, + type WatchdogAutoResumeCycleMetrics +} from '../src/services/reconciliationWatchdogAutoResumeService.js'; + +type MutableConfig = typeof config & Record; +const mutableConfig = config as MutableConfig; + +const restoreConfig = (snapshot: Record) => { + for (const [key, value] of Object.entries(snapshot)) { + mutableConfig[key] = value; + } +}; + +const setTradingControl = (mode: 'RUNNING' | 'PAUSED', lastChangedBy: string, lastChangedAt: number, reason?: string) => { + healthTracker.recordTradingControl({ + mode, + lastChangedBy, + lastChangedAt, + reason + }); +}; + +const cleanMetrics = (): WatchdogAutoResumeCycleMetrics => ({ + success: true, + mismatchCount: 0, + missingFromExchange: 0, + missingInDb: 0, + noGoTrades: 0, + parityMismatchTrades: 0, + parityQuarantinedTrades: 0, + failedProfiles: 0, + integrityWatchdogTriggered: false +}); + +const testDoesNotResumeWhenNotPaused = () => { + const service = new ReconciliationWatchdogAutoResumeService(); + setTradingControl('RUNNING', 'test', Date.now(), 'baseline'); + const decision = service.evaluateCycle(cleanMetrics()); + assert.equal(decision.resumed, false); + assert.equal(decision.reason, 'not_paused'); +}; + +const testDoesNotResumeManualPause = () => { + const service = new ReconciliationWatchdogAutoResumeService(); + setTradingControl('PAUSED', 'admin-user', Date.now() - 60_000, 'manual'); + const decision = service.evaluateCycle(cleanMetrics()); + assert.equal(decision.resumed, false); + assert.equal(decision.reason, 'pause_not_from_parity_watchdog'); + assert.equal(healthTracker.isPaused(), true); +}; + +const testResumesAfterCleanStreak = () => { + const service = new ReconciliationWatchdogAutoResumeService(); + const pauseAt = Date.now() - 120_000; + setTradingControl('PAUSED', 'system:recon_parity_watchdog', pauseAt, 'watchdog'); + + const first = service.evaluateCycle(cleanMetrics()); + assert.equal(first.resumed, false); + assert.equal(first.cleanStreak, 1); + + const second = service.evaluateCycle(cleanMetrics()); + assert.equal(second.resumed, true, 'Second clean cycle should auto-resume when threshold=2.'); + const control = healthTracker.getSnapshot().tradingControl; + assert.equal(control.mode, 'RUNNING'); + assert.equal(control.lastChangedBy, 'system:recon_parity_auto_resume'); +}; + +const testDirtyCycleResetsStreak = () => { + const service = new ReconciliationWatchdogAutoResumeService(); + const pauseAt = Date.now() - 120_000; + setTradingControl('PAUSED', 'system:recon_parity_watchdog', pauseAt, 'watchdog'); + + const first = service.evaluateCycle(cleanMetrics()); + assert.equal(first.cleanStreak, 1); + + const dirty = service.evaluateCycle({ + ...cleanMetrics(), + mismatchCount: 1 + }); + assert.equal(dirty.resumed, false); + assert.equal(dirty.reason, 'cycle_not_clean'); + assert.equal(dirty.cleanStreak, 0); + + const afterReset = service.evaluateCycle(cleanMetrics()); + assert.equal(afterReset.resumed, false, 'Streak must restart after dirty cycle.'); + assert.equal(afterReset.cleanStreak, 1); +}; + +const testMinPauseWindowBlocksResume = () => { + const service = new ReconciliationWatchdogAutoResumeService(); + const pauseAt = Date.now() - 2_000; + setTradingControl('PAUSED', 'system:recon_parity_watchdog', pauseAt, 'watchdog'); + const decision = service.evaluateCycle(cleanMetrics()); + assert.equal(decision.resumed, false); + assert.equal(decision.reason, 'min_pause_window'); +}; + +async function main() { + const configSnapshot: Record = { + ENABLE_RECON_WATCHDOG_AUTO_RESUME: mutableConfig.ENABLE_RECON_WATCHDOG_AUTO_RESUME, + RECON_WATCHDOG_AUTO_RESUME_MIN_PAUSE_MS: mutableConfig.RECON_WATCHDOG_AUTO_RESUME_MIN_PAUSE_MS, + RECON_WATCHDOG_AUTO_RESUME_CLEAN_CYCLES: mutableConfig.RECON_WATCHDOG_AUTO_RESUME_CLEAN_CYCLES, + RECON_WATCHDOG_AUTO_RESUME_COOLDOWN_MS: mutableConfig.RECON_WATCHDOG_AUTO_RESUME_COOLDOWN_MS + }; + + try { + mutableConfig.ENABLE_RECON_WATCHDOG_AUTO_RESUME = true; + mutableConfig.RECON_WATCHDOG_AUTO_RESUME_MIN_PAUSE_MS = 5_000; + mutableConfig.RECON_WATCHDOG_AUTO_RESUME_CLEAN_CYCLES = 2; + mutableConfig.RECON_WATCHDOG_AUTO_RESUME_COOLDOWN_MS = 0; + + testDoesNotResumeWhenNotPaused(); + testDoesNotResumeManualPause(); + testResumesAfterCleanStreak(); + testDirtyCycleResetsStreak(); + testMinPauseWindowBlocksResume(); + + console.log('[reconciliation-watchdog-auto-resume] OK: guard, streak, and min-pause behavior validated'); + } finally { + restoreConfig(configSnapshot); + setTradingControl('RUNNING', 'test', Date.now(), 'reset'); + } +} + +main().catch((error) => { + console.error('[reconciliation-watchdog-auto-resume] failed', error); + process.exit(1); +}); diff --git a/backend/testSessionRuleNormalization.ts b/backend/testSessionRuleNormalization.ts new file mode 100644 index 0000000..1b8c153 --- /dev/null +++ b/backend/testSessionRuleNormalization.ts @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import { SessionRule } from './src/strategies/rules/SessionRule.js'; +import { SignalDirection, type MarketContext } from './src/strategies/rules/types.js'; + +const buildContext = (session: string): MarketContext => ({ + symbol: 'SOL/USDT', + candles4h: [], + candles1h: [], + candles15m: [], + currentPrice: 100, + change24h: 0, + changeToday: 0, + session, + isMajorSession: false, + volatility: 'High', + latestSignal: SignalDirection.NONE +}); + +async function run() { + const rule = new SessionRule(); + const tokSydContext = buildContext('TOK | SYD'); + const ldnContext = buildContext('LDN'); + + const direct24h = await rule.check(tokSydContext, { sessions: '24/7' }); + assert.equal(direct24h.passed, true, 'string 24/7 must always pass'); + + const array24h = await rule.check(tokSydContext, { sessions: ['24/7'] }); + assert.equal(array24h.passed, true, 'array ["24/7"] must always pass'); + + const spaced24h = await rule.check(tokSydContext, { sessions: ['24 / 7'] }); + assert.equal(spaced24h.passed, true, 'array ["24 / 7"] must always pass'); + + const legacy24x7 = await rule.check(tokSydContext, { sessions: ['24x7'] }); + assert.equal(legacy24x7.passed, true, 'legacy 24x7 token must always pass'); + + const unicodeFractionSlash = await rule.check(tokSydContext, { sessions: ['24⁄7'] }); + assert.equal(unicodeFractionSlash.passed, true, 'unicode fraction slash 24⁄7 must always pass'); + + const unicodeDivisionSlash = await rule.check(tokSydContext, { sessions: ['24∕7'] }); + assert.equal(unicodeDivisionSlash.passed, true, 'unicode division slash 24∕7 must always pass'); + + const unicodeFullwidthSlash = await rule.check(tokSydContext, { sessions: ['24/7'] }); + assert.equal(unicodeFullwidthSlash.passed, true, 'unicode fullwidth slash 24/7 must always pass'); + + const unicodeMultiply = await rule.check(tokSydContext, { sessions: ['24×7'] }); + assert.equal(unicodeMultiply.passed, true, 'unicode multiply 24×7 must always pass'); + + const restricted = await rule.check(tokSydContext, { sessions: 'LDN,NY' }); + assert.equal(restricted.passed, false, 'LDN/NY schedule must block TOK/SYD session'); + + const asiaAliases = await rule.check(tokSydContext, { sessions: 'Tokyo,Sydney' }); + assert.equal(asiaAliases.passed, true, 'Tokyo/Sydney aliases must map to TOK/SYD'); + + const defaultSchedule = await rule.check(tokSydContext, {}); + assert.equal(defaultSchedule.passed, false, 'default schedule must remain LDN/NY only'); + + const defaultDuringLdn = await rule.check(ldnContext, {}); + assert.equal(defaultDuringLdn.passed, true, 'default schedule must pass during LDN'); + + console.log('[session-rule-normalization] OK: session token normalization and 24/7 handling checks passed'); +} + +await run(); diff --git a/backend/testStateMergeCoverage.ts b/backend/testStateMergeCoverage.ts new file mode 100644 index 0000000..eccc760 --- /dev/null +++ b/backend/testStateMergeCoverage.ts @@ -0,0 +1,156 @@ +import assert from 'node:assert/strict'; +import { mergeOrderSnapshots, mergePositionSnapshots } from '../src/services/stateMerge.js'; + +const testMergePositionSnapshots = () => { + const merged = mergePositionSnapshots([ + [ + { + id: 'p-global-1', + symbol: 'BTC/USD', + side: 'BUY', + size: 0.5, + entryPrice: 50000, + currentPrice: 50500, + stopLoss: 49000, + takeProfit: 52000, + unrealizedPnl: 250, + unrealizedPnlPercent: 1, + marketValue: 25250, + tradeId: 'TRD-abc-1' + } + ], + [ + { + id: 'p-profile-1', + symbol: 'BTC/USD', + side: 'BUY', + size: 0.5, + entryPrice: 50000, + currentPrice: 50600, + stopLoss: 49100, + takeProfit: 52100, + unrealizedPnl: 300, + unrealizedPnlPercent: 1.2, + marketValue: 25300, + userId: 'user-1', + profileId: 'profile-1', + profileName: 'High Risk', + tradeId: 'TRD-abc-1' + } + ] + ]); + + assert.equal(merged.length, 1, 'stable trade_id duplicates should collapse'); + assert.equal(merged[0].profileId, 'profile-1', 'profile should be preserved from richer position'); + assert.equal(merged[0].userId, 'user-1', 'user should be preserved from richer position'); + assert.equal(merged[0].tradeId, 'TRD-abc-1'); +}; + +const testMergePositionFallbackReduction = () => { + const merged = mergePositionSnapshots([ + [ + { + id: 'fallback-1', + symbol: 'ETH/USD', + side: 'BUY', + size: 1, + entryPrice: 2000, + currentPrice: 2010, + stopLoss: 1900, + takeProfit: 2200, + unrealizedPnl: 10, + unrealizedPnlPercent: 0.5, + marketValue: 2010, + profileId: 'profile-1', + tradeId: 'TRD-SYNC-profile-1-ETHUSD' + }, + { + id: 'fallback-2', + symbol: 'ETH/USD', + side: 'BUY', + size: 1, + entryPrice: 2000, + currentPrice: 2020, + stopLoss: 1890, + takeProfit: 2210, + unrealizedPnl: 20, + unrealizedPnlPercent: 1, + marketValue: 2020, + profileId: 'profile-1' + } + ] + ]); + + assert.equal(merged.length, 1, 'fallback same owner+symbol+side should reduce to one row'); + assert.equal(merged[0].profileId, 'profile-1'); + assert.equal(merged[0].symbol, 'ETH/USD'); +}; + +const testMergeOrderSnapshots = () => { + const merged = mergeOrderSnapshots([ + [ + { + id: 'ord-1', + symbol: 'BTC/USD', + type: 'Market', + side: 'BUY', + qty: 0.25, + price: 50000, + status: 'pending_new', + timestamp: 1000 + } + ], + [ + { + id: 'ord-1', + symbol: 'BTC/USD', + type: 'Market', + side: 'BUY', + qty: 0.25, + price: 50010, + status: 'filled', + timestamp: 1050, + profileId: 'profile-2', + trade_id: 'TRD-ord-1', + action: 'ENTRY', + source: 'BOT' + }, + { + id: 'ord-2', + symbol: 'ETH/USD', + type: 'Market', + side: 'SELL', + qty: 1, + price: 2000, + status: 'rejected', + timestamp: 900, + profileId: 'profile-2', + trade_id: 'TRD-ord-2', + action: 'EXIT', + source: 'BOT' + } + ] + ]); + + assert.equal(merged.length, 2, 'orders should be merged by order id'); + const ord1 = merged.find((order) => order.id === 'ord-1'); + assert.ok(ord1, 'ord-1 should exist'); + assert.equal(ord1?.status, 'filled', 'terminal status should not be downgraded'); + assert.equal(ord1?.profileId, 'profile-2'); + assert.equal(ord1?.trade_id, 'TRD-ord-1'); + assert.equal(ord1?.source, 'BOT'); + assert.equal(ord1?.action, 'ENTRY'); + + const ord2 = merged.find((order) => order.id === 'ord-2'); + assert.equal(ord2?.status, 'rejected'); +}; + +const run = () => { + testMergePositionSnapshots(); + testMergePositionFallbackReduction(); + testMergeOrderSnapshots(); + console.log('[state-merge-coverage] OK: position/order snapshot merge behavior validated'); +}; + +run(); + diff --git a/backend/testStrictCapitalGuard.ts b/backend/testStrictCapitalGuard.ts new file mode 100644 index 0000000..3141ea5 --- /dev/null +++ b/backend/testStrictCapitalGuard.ts @@ -0,0 +1,230 @@ +import assert from 'node:assert/strict'; +import { config } from '../src/config/index.js'; +import type { Candle, IExchangeConnector } from '../src/connectors/types.js'; +import { TradeExecutor } from '../src/services/TradeExecutor.js'; +import { capitalLedger } from '../src/services/CapitalLedger.js'; +import { distributedLockService } from '../src/services/distributedLockService.js'; +import { supabaseService } from '../src/services/SupabaseService.js'; +import { SignalDirection } from '../src/strategies/rules/types.js'; + +class MockExchange implements IExchangeConnector { + public placedQty = 0; + public placedPrice = 0; + private orderSeq = 0; + + getCapabilities() { + return { + fetchOpenOrders: true, + fetchClosedOrders: true, + shorting: true + }; + } + + async fetchOHLCV(_symbol: string, _timeframe: string, _limit?: number): Promise { + return [{ + timestamp: Date.now(), + open: 100, + high: 100, + low: 100, + close: 100, + volume: 1 + }]; + } + + async placeOrder( + symbol: string, + side: 'buy' | 'sell', + qty: number, + _type: 'market' | 'limit', + price?: number + ): Promise { + this.orderSeq += 1; + this.placedQty = Number(qty); + this.placedPrice = Number(price || 100); + return { + id: `strict-cap-${this.orderSeq}`, + symbol, + side, + qty, + status: 'filled', + filled_avg_price: this.placedPrice, + filled_qty: String(qty) + }; + } + + async getPosition(_symbol: string): Promise { + return null; + } + + async getOrder(orderId: string): Promise { + return { + id: orderId, + status: 'filled', + filled_avg_price: 100, + filled_qty: String(this.placedQty || 1) + }; + } +} + +const saveConfig = () => ({ + ENTRY_CAPITAL_BUFFER_PCT: config.ENTRY_CAPITAL_BUFFER_PCT, + ENABLE_STRICT_CAPITAL_GUARD: config.ENABLE_STRICT_CAPITAL_GUARD, + STRICT_CAPITAL_SLIPPAGE_BUFFER_PCT: config.STRICT_CAPITAL_SLIPPAGE_BUFFER_PCT, + STRICT_CAPITAL_FEE_BUFFER_PCT: config.STRICT_CAPITAL_FEE_BUFFER_PCT, + STRICT_CAPITAL_MIN_RESERVE_USD: config.STRICT_CAPITAL_MIN_RESERVE_USD +}); + +type Stubbed = { + hasActiveOrderForTradeId: any; + hasFinalizedTradeHistory: any; + getVirtualOpenPosition: any; + getPendingOrdersForProfile: any; + updateOrderStatus: any; + logOrder: any; + logTransaction: any; + reserveForOrder: any; + releaseOrderReservation: any; + adjustPositionReservation: any; + recordRealizedPnl: any; + getAvailableCapital: any; + tryAcquireRowLock: any; + releaseRowLock: any; + isEntryInProgress: any; +}; + +const stubDependencies = (availableCapital: number, reservationLog: number[]): Stubbed => { + const originals: Stubbed = { + hasActiveOrderForTradeId: (supabaseService as any).hasActiveOrderForTradeId, + hasFinalizedTradeHistory: (supabaseService as any).hasFinalizedTradeHistory, + getVirtualOpenPosition: (supabaseService as any).getVirtualOpenPosition, + getPendingOrdersForProfile: (supabaseService as any).getPendingOrdersForProfile, + updateOrderStatus: (supabaseService as any).updateOrderStatus, + logOrder: (supabaseService as any).logOrder, + logTransaction: (supabaseService as any).logTransaction, + reserveForOrder: (capitalLedger as any).reserveForOrder, + releaseOrderReservation: (capitalLedger as any).releaseOrderReservation, + adjustPositionReservation: (capitalLedger as any).adjustPositionReservation, + recordRealizedPnl: (capitalLedger as any).recordRealizedPnl, + getAvailableCapital: (capitalLedger as any).getAvailableCapital, + tryAcquireRowLock: (distributedLockService as any).tryAcquireRowLock, + releaseRowLock: (distributedLockService as any).releaseRowLock, + isEntryInProgress: (distributedLockService as any).isEntryInProgress + }; + + (supabaseService as any).hasActiveOrderForTradeId = async () => false; + (supabaseService as any).hasFinalizedTradeHistory = async () => false; + (supabaseService as any).getVirtualOpenPosition = async () => null; + (supabaseService as any).getPendingOrdersForProfile = async () => []; + (supabaseService as any).updateOrderStatus = async () => { }; + (supabaseService as any).logOrder = async () => { }; + (supabaseService as any).logTransaction = async () => { }; + + (capitalLedger as any).getAvailableCapital = async () => availableCapital; + (capitalLedger as any).reserveForOrder = async (_profileId: string, amount: number) => { + reservationLog.push(Number(amount)); + return true; + }; + (capitalLedger as any).releaseOrderReservation = async () => { }; + (capitalLedger as any).adjustPositionReservation = async () => { }; + (capitalLedger as any).recordRealizedPnl = async () => { }; + + (distributedLockService as any).tryAcquireRowLock = async () => true; + (distributedLockService as any).releaseRowLock = async () => true; + (distributedLockService as any).isEntryInProgress = async () => false; + + return originals; +}; + +const restoreDependencies = (originals: Stubbed) => { + (supabaseService as any).hasActiveOrderForTradeId = originals.hasActiveOrderForTradeId; + (supabaseService as any).hasFinalizedTradeHistory = originals.hasFinalizedTradeHistory; + (supabaseService as any).getVirtualOpenPosition = originals.getVirtualOpenPosition; + (supabaseService as any).getPendingOrdersForProfile = originals.getPendingOrdersForProfile; + (supabaseService as any).updateOrderStatus = originals.updateOrderStatus; + (supabaseService as any).logOrder = originals.logOrder; + (supabaseService as any).logTransaction = originals.logTransaction; + + (capitalLedger as any).reserveForOrder = originals.reserveForOrder; + (capitalLedger as any).releaseOrderReservation = originals.releaseOrderReservation; + (capitalLedger as any).adjustPositionReservation = originals.adjustPositionReservation; + (capitalLedger as any).recordRealizedPnl = originals.recordRealizedPnl; + (capitalLedger as any).getAvailableCapital = originals.getAvailableCapital; + + (distributedLockService as any).tryAcquireRowLock = originals.tryAcquireRowLock; + (distributedLockService as any).releaseRowLock = originals.releaseRowLock; + (distributedLockService as any).isEntryInProgress = originals.isEntryInProgress; +}; + +const apiServerStub = { + updatePositions: () => { }, + updateOrders: () => { }, + addHistory: () => { }, + recordOrderFailure: () => { }, + getState: () => ({ accountSnapshot: { buying_power: 10_000 } }), + updateAccountSnapshot: () => { }, + updateAccountSnapshotCache: () => { } +}; + +async function runCase(strictEnabled: boolean): Promise<{ placedQty: number; reservedAmount: number }> { + const originalConfig = saveConfig(); + const reservations: number[] = []; + const originals = stubDependencies(100, reservations); + + try { + config.ENTRY_CAPITAL_BUFFER_PCT = 0; + config.ENABLE_STRICT_CAPITAL_GUARD = strictEnabled; + config.STRICT_CAPITAL_SLIPPAGE_BUFFER_PCT = 1; + config.STRICT_CAPITAL_FEE_BUFFER_PCT = 0.15; + config.STRICT_CAPITAL_MIN_RESERVE_USD = 0; + + const exchange = new MockExchange(); + const executor = new TradeExecutor( + exchange, + apiServerStub as any, + 'global', + strictEnabled ? 'profile-strict-on' : 'profile-strict-off' + ); + + const result = await executor.openPosition('BTC/USD', SignalDirection.BUY, 1, 'limit', 100); + executor.dispose(); + + assert.equal(result.success, true, `Expected entry to succeed (strict=${strictEnabled})`); + assert(exchange.placedQty > 0, 'Expected exchange order to be placed'); + assert(reservations.length > 0, 'Expected capital reservation to be recorded'); + + return { + placedQty: Number(exchange.placedQty), + reservedAmount: Number(reservations[0]) + }; + } finally { + config.ENTRY_CAPITAL_BUFFER_PCT = originalConfig.ENTRY_CAPITAL_BUFFER_PCT; + config.ENABLE_STRICT_CAPITAL_GUARD = originalConfig.ENABLE_STRICT_CAPITAL_GUARD; + config.STRICT_CAPITAL_SLIPPAGE_BUFFER_PCT = originalConfig.STRICT_CAPITAL_SLIPPAGE_BUFFER_PCT; + config.STRICT_CAPITAL_FEE_BUFFER_PCT = originalConfig.STRICT_CAPITAL_FEE_BUFFER_PCT; + config.STRICT_CAPITAL_MIN_RESERVE_USD = originalConfig.STRICT_CAPITAL_MIN_RESERVE_USD; + restoreDependencies(originals); + } +} + +async function run() { + const relaxed = await runCase(false); + const strict = await runCase(true); + + assert.strictEqual(relaxed.placedQty, 1, 'Without strict guard, qty should use full available notional.'); + assert( + strict.placedQty < relaxed.placedQty, + `Strict guard must reduce qty. relaxed=${relaxed.placedQty} strict=${strict.placedQty}` + ); + assert( + strict.reservedAmount <= 100 + 1e-6, + `Strict reservation should remain within available capital envelope. strict=${strict.reservedAmount}` + ); + assert( + strict.reservedAmount > (strict.placedQty * 100), + `Strict reservation should include slippage/fee buffer. qty=${strict.placedQty} reserved=${strict.reservedAmount}` + ); + + console.log('[strict-capital-guard] OK: strict guard reduces qty and raises reservation envelope'); +} + +await run(); diff --git a/backend/testSupabaseOrderPersistenceRegressions.ts b/backend/testSupabaseOrderPersistenceRegressions.ts new file mode 100644 index 0000000..3981322 --- /dev/null +++ b/backend/testSupabaseOrderPersistenceRegressions.ts @@ -0,0 +1,283 @@ +import assert from 'node:assert/strict'; +import { supabaseService } from '../src/services/SupabaseService.js'; + +type Row = Record; +type Filter = + | { op: 'eq'; field: string; value: any } + | { op: 'in'; field: string; value: any[] } + | { op: 'lt'; field: string; value: any }; + +class FakeSupabaseClient { + public orders: Row[]; + private rowId = 0; + + constructor(seedOrders: Row[] = []) { + this.orders = seedOrders.map((row) => ({ ...row })); + for (const row of this.orders) { + this.rowId = Math.max(this.rowId, Number(String(row.id || '').replace(/[^0-9]/g, '')) || 0); + } + } + + public nextId(): string { + this.rowId += 1; + return `row-${this.rowId}`; + } + + from(table: string) { + if (table !== 'orders') { + throw new Error(`Fake client only supports orders table (received: ${table})`); + } + return new FakeOrdersQuery(this); + } +} + +class FakeOrdersQuery implements PromiseLike<{ data: any; error: any }> { + private mode: 'select' | 'update' = 'select'; + private filters: Filter[] = []; + private sortField: string | null = null; + private sortAscending = true; + private limitCount: number | null = null; + private updatePayload: Row = {}; + + constructor(private readonly db: FakeSupabaseClient) { } + + select(_columns: string) { + this.mode = 'select'; + return this; + } + + eq(field: string, value: any) { + this.filters.push({ op: 'eq', field, value }); + return this; + } + + in(field: string, value: any[]) { + this.filters.push({ op: 'in', field, value }); + return this; + } + + lt(field: string, value: any) { + this.filters.push({ op: 'lt', field, value }); + return this; + } + + order(field: string, options?: { ascending?: boolean }) { + this.sortField = field; + this.sortAscending = options?.ascending !== false; + return this; + } + + limit(count: number) { + this.limitCount = count; + return this; + } + + update(payload: Row) { + this.mode = 'update'; + this.updatePayload = { ...payload }; + return this; + } + + insert(rows: Row[]) { + const inserted = rows.map((row) => { + const id = row.id || this.db.nextId(); + const createdAt = row.created_at || new Date().toISOString(); + return { ...row, id, created_at: createdAt }; + }); + this.db.orders.push(...inserted); + return Promise.resolve({ data: inserted, error: null }); + } + + private matchesFilters(row: Row): boolean { + return this.filters.every((filter) => { + const value = row[filter.field]; + if (filter.op === 'eq') { + return filter.value === null ? value == null : value === filter.value; + } + if (filter.op === 'in') { + return filter.value.includes(value); + } + + const leftRaw = value; + const rightRaw = filter.value; + const leftTs = Date.parse(String(leftRaw || '')); + const rightTs = Date.parse(String(rightRaw || '')); + if (Number.isFinite(leftTs) && Number.isFinite(rightTs)) { + return leftTs < rightTs; + } + return String(leftRaw || '') < String(rightRaw || ''); + }); + } + + private executeSelect(): { data: any; error: any } { + let rows = this.db.orders.filter((row) => this.matchesFilters(row)).map((row) => ({ ...row })); + if (this.sortField) { + const field = this.sortField; + const direction = this.sortAscending ? 1 : -1; + rows = rows.sort((a, b) => { + const aValue = a[field]; + const bValue = b[field]; + const aTs = Date.parse(String(aValue || '')); + const bTs = Date.parse(String(bValue || '')); + if (Number.isFinite(aTs) && Number.isFinite(bTs) && aTs !== bTs) { + return direction * (aTs - bTs); + } + if (aValue === bValue) return 0; + return direction * (String(aValue || '').localeCompare(String(bValue || ''))); + }); + } + if (this.limitCount !== null) { + rows = rows.slice(0, this.limitCount); + } + return { data: rows, error: null }; + } + + private executeUpdate(): { data: any; error: any } { + const updatedRows: Row[] = []; + for (const row of this.db.orders) { + if (!this.matchesFilters(row)) continue; + Object.assign(row, this.updatePayload); + updatedRows.push({ ...row }); + } + return { data: updatedRows, error: null }; + } + + then( + onfulfilled?: ((value: { data: any; error: any }) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: any) => TResult2 | PromiseLike) | null + ): Promise { + try { + const result = this.mode === 'update' ? this.executeUpdate() : this.executeSelect(); + if (!onfulfilled) { + return Promise.resolve(result as TResult1); + } + return Promise.resolve(onfulfilled(result)); + } catch (error) { + if (onrejected) { + return Promise.resolve(onrejected(error)); + } + return Promise.reject(error); + } + } +} + +async function run() { + const originalClient = (supabaseService as any).client; + const fakeClient = new FakeSupabaseClient(); + (supabaseService as any).client = fakeClient as any; + + try { + const now = Date.now(); + await supabaseService.logOrder({ + user_id: 'u-1', + profile_id: 'p-1', + order_id: 'ORD-1', + symbol: 'BTC/USD', + type: 'market', + side: 'BUY', + qty: 1, + price: 100, + status: 'pending_new', + timestamp: now, + trade_id: 'TRD-1', + action: 'ENTRY', + sub_tag: 'BL:PAPER:PTEST:TTEST:ENTRY' + }); + assert.equal(fakeClient.orders.length, 1, 'Expected first order insert to create one row.'); + + await supabaseService.logOrder({ + user_id: 'u-1', + profile_id: 'p-1', + order_id: 'ORD-1', + symbol: 'BTC/USD', + type: 'market', + side: 'BUY', + qty: 1, + price: 100, + status: 'pending_new', + timestamp: now + 1000, + trade_id: 'TRD-1', + action: 'ENTRY' + }); + assert.equal(fakeClient.orders.length, 1, 'Duplicate order_id within same profile must be upserted, not inserted.'); + + await supabaseService.updateOrderStatus('ORD-1', 'filled', new Date(now + 2000), 101, 1.5); + await supabaseService.logOrder({ + user_id: 'u-1', + profile_id: 'p-1', + order_id: 'ORD-1', + symbol: 'BTC/USD', + type: 'market', + side: 'BUY', + qty: 1, + price: 99, + status: 'pending_new', + timestamp: now + 3000, + trade_id: 'TRD-1', + action: 'ENTRY' + }); + + const persisted = fakeClient.orders.find((row) => row.order_id === 'ORD-1' && row.profile_id === 'p-1'); + assert(persisted, 'Expected persisted order row to exist after updates.'); + assert.equal(persisted.status, 'filled', 'Order status must not downgrade from terminal status on duplicate log.'); + assert.equal(Number(persisted.qty), 1.5, 'Order qty must retain terminal filled quantity.'); + assert.equal(Number(persisted.price), 101, 'Order price must retain terminal fill price.'); + assert.equal(persisted.sub_tag, 'BL:PAPER:PTEST:TTEST:ENTRY', 'Order sub_tag must persist for traceability.'); + + await supabaseService.logOrder({ + user_id: 'u-1', + profile_id: 'p-2', + order_id: 'ORD-SHARED', + symbol: 'ETH/USD', + type: 'market', + side: 'BUY', + qty: 1, + price: 2000, + status: 'pending_new', + timestamp: now, + trade_id: 'TRD-SHARED-1', + action: 'ENTRY' + }); + await supabaseService.logOrder({ + user_id: 'u-1', + profile_id: 'p-3', + order_id: 'ORD-SHARED', + symbol: 'ETH/USD', + type: 'market', + side: 'BUY', + qty: 1, + price: 2000, + status: 'pending_new', + timestamp: now + 10, + trade_id: 'TRD-SHARED-2', + action: 'ENTRY' + }); + const sharedRows = fakeClient.orders.filter((row) => row.order_id === 'ORD-SHARED'); + assert.equal(sharedRows.length, 2, 'Same exchange order id across different profiles must remain isolated.'); + + const staleCreatedAt = new Date(Date.now() - 10 * 60_000).toISOString(); + fakeClient.orders.push( + { id: fakeClient.nextId(), order_id: 'ORD-PN', status: 'pending_new', created_at: staleCreatedAt, symbol: 'BTC/USD' }, + { id: fakeClient.nextId(), order_id: 'ORD-P', status: 'pending', created_at: staleCreatedAt, symbol: 'BTC/USD' }, + { id: fakeClient.nextId(), order_id: 'ORD-A', status: 'accepted', created_at: staleCreatedAt, symbol: 'BTC/USD' }, + { id: fakeClient.nextId(), order_id: 'ORD-N', status: 'new', created_at: staleCreatedAt, symbol: 'BTC/USD' }, + { id: fakeClient.nextId(), order_id: 'ORD-F', status: 'filled', created_at: staleCreatedAt, symbol: 'BTC/USD' }, + { id: fakeClient.nextId(), order_id: 'ORD-YOUNG', status: 'pending_new', created_at: new Date().toISOString(), symbol: 'BTC/USD' } + ); + + const stale = await supabaseService.getStaleOrders(5); + const staleIds = new Set((stale || []).map((row: any) => String(row.order_id || row.id))); + assert(staleIds.has('ORD-PN'), 'Expected pending_new to be considered stale.'); + assert(staleIds.has('ORD-P'), 'Expected pending to be considered stale.'); + assert(staleIds.has('ORD-A'), 'Expected accepted to be considered stale.'); + assert(staleIds.has('ORD-N'), 'Expected new to be considered stale.'); + assert(!staleIds.has('ORD-F'), 'Filled orders must not be included in stale-pending query.'); + assert(!staleIds.has('ORD-YOUNG'), 'Recent pending_new orders must not be included as stale.'); + + console.log('[supabase-order-persistence-regressions] OK: order upsert and stale status selection are stable'); + } finally { + (supabaseService as any).client = originalClient; + } +} + +await run(); diff --git a/backend/testSupabaseTradeHistorySourceFallback.ts b/backend/testSupabaseTradeHistorySourceFallback.ts new file mode 100644 index 0000000..a605179 --- /dev/null +++ b/backend/testSupabaseTradeHistorySourceFallback.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import { supabaseService } from '../src/services/SupabaseService.js'; + +const MISSING_SOURCE_ERROR = "Could not find the 'source' column of 'trade_history' in the schema cache"; + +async function run() { + const serviceAny = supabaseService as any; + const originalClient = serviceAny.client; + const originalSupportFlag = serviceAny.tradeHistorySupportsSource; + + const insertPayloads: any[] = []; + let firstInsert = true; + + const mockClient = { + from: (_table: string) => ({ + insert: async (rows: any[]) => { + insertPayloads.push(rows?.[0]); + if (firstInsert) { + firstInsert = false; + return { error: { message: MISSING_SOURCE_ERROR } }; + } + return { error: null }; + } + }) + }; + + serviceAny.client = mockClient; + serviceAny.tradeHistorySupportsSource = null; + + try { + await supabaseService.logTransaction({ + user_id: 'user-1', + profile_id: 'profile-1', + symbol: 'BTC/USD', + side: 'BUY', + entry_price: 100, + exit_price: 101, + size: 1, + pnl: 1, + pnl_percent: 1, + reason: 'test', + timestamp: Date.now(), + source: 'BOT' + }); + + assert.equal(insertPayloads.length, 2, 'Should retry transaction insert with legacy payload.'); + assert.equal(insertPayloads[0]?.source, 'BOT', 'First payload should include source column.'); + assert.equal('source' in insertPayloads[1], false, 'Fallback payload should omit source column.'); + assert.equal(serviceAny.tradeHistorySupportsSource, false, 'Support flag should switch to legacy mode after missing-column error.'); + + insertPayloads.length = 0; + await supabaseService.logTransaction({ + user_id: 'user-1', + profile_id: 'profile-1', + symbol: 'ETH/USD', + side: 'BUY', + entry_price: 100, + exit_price: 102, + size: 1, + pnl: 2, + pnl_percent: 2, + reason: 'test-2', + timestamp: Date.now(), + source: 'BOT' + }); + + assert.equal(insertPayloads.length, 1, 'Legacy mode should avoid source insert attempt after detection.'); + assert.equal('source' in insertPayloads[0], false, 'Legacy-mode payload should omit source column.'); + } finally { + serviceAny.client = originalClient; + serviceAny.tradeHistorySupportsSource = originalSupportFlag; + } + + console.log('[supabase-trade-history-fallback] OK: source-column fallback behavior validated'); +} + +await run(); diff --git a/backend/testTradeExecutorLifecycle.ts b/backend/testTradeExecutorLifecycle.ts new file mode 100644 index 0000000..e44fa64 --- /dev/null +++ b/backend/testTradeExecutorLifecycle.ts @@ -0,0 +1,269 @@ +import assert from 'node:assert/strict'; +import { TradeExecutor } from '../src/services/TradeExecutor.js'; +import { SignalDirection } from '../src/strategies/rules/types.js'; +import { Candle, IExchangeConnector } from '../src/connectors/types.js'; +import { supabaseService } from '../src/services/SupabaseService.js'; +import { distributedLockService } from '../src/services/distributedLockService.js'; +import { observabilityService } from '../src/services/observabilityService.js'; +import { capitalLedger } from '../src/services/CapitalLedger.js'; + +const normalizeError = (reason: unknown): Error => { + if (reason instanceof Error) return reason; + if (reason && typeof reason === 'object') return new Error(JSON.stringify(reason)); + return new Error(String(reason ?? 'Unknown error')); +}; + +const logAndRethrow = (kind: string, reason: unknown) => { + const err = normalizeError(reason); + console.error(`[testTradeExecutorLifecycle] ${kind}`, err); + throw err; +}; + +process.on('unhandledRejection', (reason) => logAndRethrow('unhandled rejection', reason)); +process.on('uncaughtException', (error) => logAndRethrow('uncaught exception', error)); + +console.log('[testTradeExecutorLifecycle] script started'); + +class MockExchangeConnector implements IExchangeConnector { + private statuses: string[] = []; + private orderSeq = 0; + private cancelSuccess = true; + private syncedPosition: any | null = null; + + public setCancelResult(shouldSucceed: boolean) { + this.cancelSuccess = shouldSucceed; + } + + public setPosition(position: any | null) { + this.syncedPosition = position; + } + + public queueStatuses(...statuses: string[]) { + this.statuses.push(...statuses.map((s) => s.toLowerCase())); + } + + fetchOHLCV = async (_symbol: string, _timeframe: string, _limit?: number): Promise => { + return [ + { + timestamp: Date.now(), + open: 100, + high: 101, + low: 99, + close: 100, + volume: 1 + } + ]; + }; + placeOrder = async (symbol: string, side: 'buy' | 'sell', qty: number, type: 'market' | 'limit', price?: number): Promise => { + this.orderSeq += 1; + return { + id: `MOCK-${this.orderSeq}`, + symbol, + side, + qty, + type, + status: 'pending_new', + filled_avg_price: price || 100, + filled_qty: `${qty}` + }; + } + + getPosition = async (_symbol: string): Promise => this.syncedPosition; + + getOrder = async (orderId: string): Promise => { + const status = this.statuses.shift() || 'filled'; + return { + id: orderId, + status, + filled_avg_price: 100, + filled_qty: '1' + }; + } + + cancelOrder = async (_orderId: string, _symbol?: string): Promise => this.cancelSuccess; + getCapabilities = () => ({ + fetchOpenOrders: true, + fetchOrders: true, + fetchClosedOrders: true, + fetchMyTrades: true, + fetchOHLCV: true, + cancelOrder: true, + createOrder: true, + editOrder: false, + fetchBalance: true, + fetchLedger: false, + fetchTicker: true, + fetchTickers: true, + fetchPosition: true, + fetchPositions: true, + shorting: true + }); +} + +process.on('unhandledRejection', (reason) => { + console.error('[trade-executor-lifecycle] unhandled rejection', reason); +}); + +process.on('uncaughtException', (error) => { + console.error('[trade-executor-lifecycle] uncaught exception', error); +}); + +async function run() { + // Keep this test deterministic and offline. + let logOrderWrites = 0; + let updateOrderWritesObservedAfterLog = false; + const pushedPositionSnapshots: Array = []; + const profileId = '00000000-0000-0000-0000-000000000000'; + + (supabaseService as any).getClient = () => null; + (observabilityService as any).emitEvent = () => { }; + (supabaseService as any).updateOrderStatus = async () => { + if (logOrderWrites > 0) { + updateOrderWritesObservedAfterLog = true; + } + }; + (supabaseService as any).logOrder = async () => { + await new Promise((resolve) => setTimeout(resolve, 15)); + logOrderWrites += 1; + }; + + (supabaseService as any).logTransaction = async () => { }; + (supabaseService as any).getVirtualOpenPosition = async () => null; + (supabaseService as any).getPendingOrdersForProfile = async () => []; + (supabaseService as any).hasActiveOrderForTradeId = async () => false; + (supabaseService as any).hasFinalizedTradeHistory = async () => false; + (capitalLedger as any).getAvailableCapital = async () => 1_000_000; + (capitalLedger as any).reserveForOrder = async () => true; + (capitalLedger as any).releaseOrderReservation = async () => { }; + (capitalLedger as any).adjustPositionReservation = async () => { }; + (capitalLedger as any).recordRealizedPnl = async () => { }; + (capitalLedger as any).rebuildLedger = async () => { }; + (capitalLedger as any).getLedger = async () => ({ + profile_id: profileId, + allocated_capital: 1_000_000, + reserved_for_orders: 0, + reserved_for_positions: 0, + realized_pnl: 0, + updated_at: new Date().toISOString() + }); + + const apiServerStub = { + updatePositions: (positions: any[]) => { + pushedPositionSnapshots.push(positions.map((position) => ({ ...position }))); + }, + updateOrders: () => { }, + addHistory: () => { }, + recordOrderFailure: () => { }, + getState: () => ({ accountSnapshot: null }), + updateAccountSnapshot: () => { }, + updateAccountSnapshotCache: () => { } + }; + + const connector = new MockExchangeConnector(); + (distributedLockService as any).tryAcquireRowLock = async () => true; + (distributedLockService as any).releaseRowLock = async () => true; + (distributedLockService as any).isEntryInProgress = async () => false; + const executor = new TradeExecutor(connector, apiServerStub as any, 'test-user', profileId); + + connector.queueStatuses('filled'); + let openResult; + try { + openResult = await executor.openPosition('BTC/USD', SignalDirection.BUY, 1, 'market'); + } catch (err) { + console.error('[trade-executor-lifecycle] openPosition threw', err); + throw err; + } + console.log('[trade-executor-lifecycle] openResult', openResult); + + assert.equal(openResult.success, true, 'Expected openPosition to succeed'); + assert(executor.getActivePosition('BTC/USD'), 'Active position missing after filled entry'); + assert( + pushedPositionSnapshots.some((snapshot) => snapshot.some((position) => position.symbol === 'BTC/USD')), + 'Dashboard position snapshot should be pushed immediately after entry fill.' + ); + + connector.setPosition({ + side: 'long', + qty: '1', + avg_entry_price: '100' + }); + connector.queueStatuses('rejected'); + const rejectedExit = await executor.closePosition('BTC/USD', 'test-rejected-exit'); + + assert.equal(rejectedExit.success, false, 'Expected rejected exit to fail'); + assert.equal(executor.getExitLifecycle('BTC/USD').state, 'failed', 'Exit lifecycle should be failed after rejected exit'); + assert(executor.getActivePosition('BTC/USD'), 'Position should remain open after rejected exit'); + + connector.queueStatuses('filled'); + const secondOpen = await executor.openPosition('ETH/USD', SignalDirection.BUY, 1, 'market'); + assert.equal(secondOpen.success, true, 'Expected second openPosition to succeed'); + + connector.setCancelResult(false); + connector.queueStatuses('filled'); + const unknownOpen = await executor.openPosition('XRP/USD', SignalDirection.BUY, 1, 'market'); + assert.equal(unknownOpen.success, true, 'Expected unknown-path openPosition to succeed'); + // Unknown exit path + connector.setCancelResult(false); + let getPosCount = 0; + const originalGetPosition = connector.getPosition; + connector.getPosition = async () => { + getPosCount++; + if (getPosCount === 1) return { qty: 1, side: 'long', avg_entry_price: 100 }; + return null; + }; + connector.queueStatuses('new', 'new', 'new'); + const unknownExit = await executor.closePosition('XRP/USD', 'test-unknown-exit'); + connector.getPosition = originalGetPosition; // Restore + + assert.equal(unknownExit.success, false, 'Expected unknown exit to fail'); + assert.equal(executor.getExitLifecycle('XRP/USD').state, 'quarantined', 'Unknown exit should enter quarantined state'); + assert(executor.getActivePosition('XRP/USD'), 'Position should remain open after unknown exit'); + + + connector.setCancelResult(true); + connector.setPosition({ + side: 'long', + qty: '1', + avg_entry_price: '100' + }); + connector.queueStatuses('filled'); + const filledExit = await executor.closePosition('ETH/USD', 'test-filled-exit'); + assert.equal(filledExit.success, true, 'Expected filled exit to succeed'); + assert.equal(executor.getExitLifecycle('ETH/USD').state, 'filled', 'Exit lifecycle should be marked filled after final close'); + assert.equal(executor.getActivePosition('ETH/USD'), null, 'Position should be removed after successful exit'); + + + const retainTradeId = 'TRD-retain-local'; + const internalPositions = executor.getAllPositions(); + internalPositions.set(`SOL/USD::${retainTradeId}`, { + symbol: 'SOL/USD', + side: SignalDirection.BUY, + entryPrice: 100, + size: 1, + stopLoss: 95, + takeProfit: 110, + peakPrice: 100, + userId: 'test-user', + profileId, + tradeId: retainTradeId + }); + connector.setPosition({ + side: 'long', + qty: '1', + avg_entry_price: '100' + }); + await executor.syncPositions(['SOL/USD']); + assert( + executor.getActivePosition('SOL/USD', retainTradeId), + 'Dedicated profile sync must retain local lifecycle state when exchange is open but virtual lookup is temporarily empty.' + ); + + console.log('[trade-executor-lifecycle] OK: entry/exit terminal-state checks passed'); +} + +try { + await run(); +} catch (error: any) { + console.error('[trade-executor-lifecycle] Test failed', error?.message, error); + process.exit(1); +} diff --git a/backend/test_ai_cache.ts b/backend/test_ai_cache.ts new file mode 100644 index 0000000..60910a1 --- /dev/null +++ b/backend/test_ai_cache.ts @@ -0,0 +1,46 @@ +import { AIAnalysisRule } from '../src/strategies/rules/AIAnalysisRule.js'; +import { MarketContext, SignalDirection } from '../src/strategies/rules/types.js'; +import logger from '../src/utils/logger.js'; +import * as dotenv from 'dotenv'; + +dotenv.config(); + +async function testAICache() { + console.log("--- 🧠 TESTING AI CACHING & NON-BLOCKING LOGIC ---"); + + const aiRule = new AIAnalysisRule(); + + const mockContext: MarketContext = { + symbol: 'BTC/USDT', + candles4h: [], + candles1h: [], + candles15m: [], + currentPrice: 100000, + change24h: 0, + changeToday: 0, + session: 'NY', + isMajorSession: true, + volatility: 'Low', + latestSignal: SignalDirection.NONE + }; + + console.log("\n1. Running FIRST check (Should be 'Fresh')..."); + const result1 = await aiRule.check(mockContext); + console.log("Result 1:", result1.reason); + console.log("Passed (Should be true):", result1.passed); + + console.log("\n2. Running SECOND check (Should be 'Cached')..."); + const result2 = await aiRule.check(mockContext); + console.log("Result 2:", result2.reason); + console.log("Passed (Should be true):", result2.passed); + + if (result2.reason?.includes('[Cached]')) { + console.log("\n✅ SUCCESS: Cache working correctly."); + } else { + console.error("\n❌ FAILED: Cache not working as expected."); + } + + console.log("\n--- ✨ TEST COMPLETE ---"); +} + +testAICache().catch(console.error); diff --git a/backend/test_ai_fallback.ts b/backend/test_ai_fallback.ts new file mode 100644 index 0000000..5e863af --- /dev/null +++ b/backend/test_ai_fallback.ts @@ -0,0 +1,27 @@ +import { AIClient } from '../src/services/aiClient.js'; +import logger from '../src/utils/logger.js'; +import * as dotenv from 'dotenv'; + +dotenv.config(); + +async function testAIFallback() { + console.log("--- 🤖 TESTING AI MULTI-PROVIDER FALLBACK ---"); + + const aiClient = new AIClient(); + const testPrompt = "Return a JSON object with action: HOLD, confidence: 50, reasoning: 'Test run'. JSON ONLY."; + + console.log("\nAttempting analysis with current configuration..."); + const result = await aiClient.generateAnalysis(testPrompt); + + if (result) { + console.log("\n✅ SUCCESS! Received response:"); + console.log(result); + } else { + console.error("\n❌ FAILED! All providers in fallback list failed or were not configured."); + console.log("Tip: Check your .env for AI_FALLBACK_LIST and respective API keys."); + } + + console.log("\n--- ✨ TEST COMPLETE ---"); +} + +testAIFallback().catch(console.error); diff --git a/backend/test_ai_layer.ts b/backend/test_ai_layer.ts new file mode 100644 index 0000000..b355137 --- /dev/null +++ b/backend/test_ai_layer.ts @@ -0,0 +1,91 @@ +import { ProStrategyEngine } from '../src/strategies/ProStrategyEngine.js'; +import { IExchangeConnector, Candle } from '../src/connectors/types.js'; +import { config } from '../src/config/index.js'; +import logger from '../src/utils/logger.js'; +import { SignalDirection } from '../src/strategies/rules/types.js'; + +// Mock Exchange +class MockExchange implements IExchangeConnector { + async fetchOHLCV(symbol: string, timeframe: string, limit?: number): Promise { + // Return mostly flat/bullish data to pass other rules + const candles: Candle[] = []; + let price = 50000; + const now = Date.now(); + for (let i = 0; i < 300; i++) { + price += (Math.random() - 0.4) * 100; // Slight uptrend + candles.push({ + timestamp: now - (300 - i) * 3600000, + open: price, high: price + 50, low: price - 50, close: price + 10, volume: 1000 + }); + } + return candles; + } + + async placeOrder(symbol: string, side: 'buy' | 'sell', qty: number, type: 'market' | 'limit'): Promise { + return { id: 'mock-order' }; + } + + async getPosition(symbol: string): Promise { + return null; + } +} + +async function runTest() { + logger.info('--- STARTING AI LAYER VERIFICATION ---'); + + // MOCK CONFIG + config.PRO_STRATEGY.ENABLED_RULES = ['ai_analysis']; // Only checking AI + config.AI.API_KEY = 'test-key'; // Fake key to pass isEnabled check + config.AI.CONFIDENCE_THRESHOLD = 70; + + const exchange = new MockExchange(); + const engine = new ProStrategyEngine(exchange); + + // INJECT MOCK AI CLIENT + const aiRule: any = engine['rules'].find((r: any) => r.name === 'AIAnalysisRule'); + if (!aiRule) { + logger.error('❌ AIAnalysisRule not found in engine!'); + return; + } + + // Mock the generateAnalysis method + aiRule.aiClient.generateAnalysis = async (prompt: string) => { + logger.info(`[MockAI] Received Prompt length: ${prompt.length}`); + + // Return a HIGH confidence response + return JSON.stringify({ + action: "BUY", + confidence: 85, + reasoning: "Strong uptrend confirmed by EMA alignment." + }); + }; + + logger.info('[TEST 1] High Confidence Scenario'); + const resultPass = await engine.execute('BTC/USD'); + + if (resultPass?.passed && resultPass?.metadata?.aiReasoning) { + logger.info(`✅ PASSED -> AI Confidence 85 accepted. Reasoning: ${resultPass.metadata.aiReasoning}`); + } else { + logger.error(`❌ FAILED -> ${resultPass?.reason}`); + } + + // TEST LOW CONFIDENCE + logger.info('\n[TEST 2] Low Confidence Scenario'); + aiRule.aiClient.generateAnalysis = async (prompt: string) => { + return JSON.stringify({ + action: "HOLD", + confidence: 40, + reasoning: "Market choppy, low confidence." + }); + }; + + const resultFail = await engine.execute('BTC/USD'); + + if (!resultFail?.passed && resultFail?.ruleName === 'AIAnalysisRule') { + logger.info(`✅ CORRECTLY FAILED -> AI Confidence 40 rejected. Reason: ${resultFail.reason}`); + } else { + logger.error(`❌ UNEXPECTED PASS or WRONG FAIL -> ${resultFail?.reason}`); + } +} + +runTest().catch(console.error); diff --git a/backend/test_alert.ts b/backend/test_alert.ts new file mode 100644 index 0000000..e7643b9 --- /dev/null +++ b/backend/test_alert.ts @@ -0,0 +1,15 @@ +import { Notifier } from '../src/services/notifier.js'; +import logger from '../src/utils/logger.js'; + +async function sendTest() { + const notifier = new Notifier(); + const testMessage = "🔔 *Test Alert* 🔔\n\nThis is a manual test alert from your Trading Bot to verify the WhatsApp integration. If you are reading this, everything is working perfectly! 🚀"; + + logger.info('Sending manual test alert...'); + await notifier.sendAlert(testMessage); + logger.info('Test alert process complete.'); +} + +sendTest().catch(err => { + logger.error('Test Alert Failed:', err); +}); diff --git a/backend/test_alpaca.ts b/backend/test_alpaca.ts new file mode 100644 index 0000000..008a1d3 --- /dev/null +++ b/backend/test_alpaca.ts @@ -0,0 +1,28 @@ +import Alpaca from '@alpacahq/alpaca-trade-api'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +async function test() { + const alpaca = new (Alpaca as any)({ + keyId: process.env.ALPACA_API_KEY, + secretKey: process.env.ALPACA_API_SECRET, + paper: true, + }); + + try { + console.log('Testing Crypto BTCUSD...'); + const bars = alpaca.getBarsV2('BTCUSD', { + timeframe: '1Min', + limit: 1, + start: new Date(Date.now() - 3600000 * 24).toISOString() + }); + for await (const b of bars) { + console.log('Got Crypto Bar!'); + } + console.log('Success with Crypto BTCUSD!'); + } catch (e: any) { + console.error('Test Failed:', e.message, 'Code:', e.code); + } +} + +test(); diff --git a/backend/test_alpaca_execution.ts b/backend/test_alpaca_execution.ts new file mode 100644 index 0000000..61d2483 --- /dev/null +++ b/backend/test_alpaca_execution.ts @@ -0,0 +1,40 @@ +import * as dotenv from 'dotenv'; +import path from 'path'; +import { AlpacaConnector } from '../src/connectors/alpaca.js'; + +async function testExecution() { + console.log('--- Alpaca Execution Test Start ---'); + dotenv.config({ path: path.resolve(process.cwd(), '.env') }); + + console.log(`Config Key: ${process.env.ALPACA_API_KEY ? 'FOUND' : 'MISSING'}`); + console.log(`Asset Class: ${process.env.ASSET_CLASS || 'NOT SET'}`); + console.log(`Paper Trading: ${process.env.PAPER_TRADING}`); + + const connector = new AlpacaConnector(); + + // We use BTC/USD specifically as it's a known crypto pair on Alpaca + const symbolsToTest = ['BTC/USD', 'BTCUSD']; + const side = 'buy'; + const qty = 0.001; // Approx $95 at $95k price. Minimum is $10. + const type = 'market'; + + for (const symbol of symbolsToTest) { + try { + console.log(`--- Testing order placement for ${symbol} ---`); + const order = await connector.placeOrder(symbol, side, qty, type); + if (order && order.id) { + console.log(`✅ SUCCESS for ${symbol}: Order placed! Order ID: ${order.id}`); + return; // Stop if success + } + } catch (error: any) { + const errorDetails = error.response ? JSON.stringify(error.response.data) : (error.message || error); + console.log(`❌ FAILED for ${symbol}: ${errorDetails}`); + } + } + + console.log('--- Alpaca Execution Test End ---'); +} + +testExecution().catch(err => { + console.log('Test script crashed:', err); +}); diff --git a/backend/test_alpaca_exhaustive.ts b/backend/test_alpaca_exhaustive.ts new file mode 100644 index 0000000..8a9457a --- /dev/null +++ b/backend/test_alpaca_exhaustive.ts @@ -0,0 +1,36 @@ +import Alpaca from '@alpacahq/alpaca-trade-api'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +async function test() { + const alpaca = new (Alpaca as any)({ + keyId: process.env.ALPACA_API_KEY, + secretKey: process.env.ALPACA_API_SECRET, + paper: true, + }); + + const symbols = ['BTC/USD', 'BTCUSD']; + const methods = ['getBarsV2', 'getCryptoBars']; + + for (const method of methods) { + for (const symbol of symbols) { + console.log(`Checking ${method} for ${symbol}...`); + try { + const bars = (alpaca as any)[method](symbol, { + timeframe: '1Min', + limit: 10, + start: new Date(Date.now() - 3600000 * 24).toISOString() + }); + let count = 0; + for await (const b of bars) { + count++; + } + console.log(`Result: ${count} bars found.`); + } catch (e: any) { + console.error(`Method ${method} failed for ${symbol}: ${e.message}`); + } + } + } +} + +test(); diff --git a/backend/test_alpaca_exit.ts b/backend/test_alpaca_exit.ts new file mode 100644 index 0000000..d67db50 --- /dev/null +++ b/backend/test_alpaca_exit.ts @@ -0,0 +1,51 @@ +import * as dotenv from 'dotenv'; +import path from 'path'; +import { AlpacaConnector } from '../src/connectors/alpaca.js'; +import { ExecutionManager } from '../src/services/executionManager.js'; +import { SignalDirection } from '../src/strategies/rules/types.js'; + +async function testExitFlow() { + console.log('--- Alpaca Exit Flow Test Start ---'); + dotenv.config({ path: path.resolve(process.cwd(), '.env') }); + + const connector = new AlpacaConnector(); + const manager = new ExecutionManager(connector); + + const symbol = 'BTC/USD'; + const qty = 0.001; // Tiny amount for testing + + try { + // 1. Place a fresh BUY order to ensure we have something to exit + console.log(`Step 1: Placing initial BUY order for ${symbol}...`); + const buyOrder = await connector.placeOrder(symbol, 'buy', qty, 'market'); + + if (!buyOrder || !buyOrder.id) { + throw new Error('Failed to place initial buy order'); + } + console.log(`✅ Buy order placed: ${buyOrder.id}`); + + // 2. Mock the ExecutionManager state so it thinks it's in a trade + console.log('Step 2: Syncing local ExecutionManager state...'); + // We simulate the post-buy state + (manager as any).activeTraders.set(symbol, { + side: SignalDirection.BUY, + entryPrice: 95000, // Dummy price + size: qty, + stopLoss: 94000, + takeProfit: 97000 + }); + + // 3. Trigger the EXIT logic (simulating a TP/SL hit) + console.log('Step 3: Triggering automated EXIT logic (executeExit)...'); + // We pass the current dummy price to simulate the exit + await manager.executeExit(symbol, 97000, 'Test Take Profit Hit'); + + console.log('✅ EXIT logic completed successfully.'); + } catch (error: any) { + console.log(`❌ FAILED: ${error.message || error}`); + } + + console.log('--- Alpaca Exit Flow Test End ---'); +} + +testExitFlow().catch(console.error); diff --git a/backend/test_alpaca_full_cycle.ts b/backend/test_alpaca_full_cycle.ts new file mode 100644 index 0000000..23853bf --- /dev/null +++ b/backend/test_alpaca_full_cycle.ts @@ -0,0 +1,44 @@ +import * as dotenv from 'dotenv'; +import path from 'path'; +import { AlpacaConnector } from '../src/connectors/alpaca.js'; + +async function testFullExecutionCycle() { + console.log('--- Alpaca Full Cycle (Buy -> Sell) Test Start ---'); + dotenv.config({ path: path.resolve(process.cwd(), '.env') }); + + const connector = new AlpacaConnector(); + const symbol = 'BTC/USD'; + const qty = 0.001; // Approx $95 + + try { + // 1. PLACE BUY ORDER + console.log(`Step 1: Placing MARKET BUY for ${qty} ${symbol}...`); + const buyOrder = await connector.placeOrder(symbol, 'buy', qty, 'market'); + if (buyOrder && buyOrder.id) { + console.log(`✅ BUY SUCCESS: Order ID ${buyOrder.id}`); + } else { + throw new Error('Buy order failed - no ID returned'); + } + + // Wait a few seconds for the buy to process + console.log('Waiting 3 seconds for position to register...'); + await new Promise(r => setTimeout(r, 3000)); + + // 2. PLACE SELL ORDER (The real test for the user) + console.log(`Step 2: Placing MARKET SELL for ${qty} ${symbol}...`); + const sellOrder = await connector.placeOrder(symbol, 'sell', qty, 'market'); + if (sellOrder && sellOrder.id) { + console.log(`✅ SELL SUCCESS: Order ID ${sellOrder.id}`); + } else { + throw new Error('Sell order failed - no ID returned'); + } + + console.log('\n--- FULL CYCLE VERIFIED SUCCESSFULLY ---'); + } catch (error: any) { + console.log(`❌ FAILED: ${error.message || error}`); + } + + console.log('--- Alpaca Full Cycle Test End ---'); +} + +testFullExecutionCycle().catch(console.error); diff --git a/backend/test_broadcast_logic.ts b/backend/test_broadcast_logic.ts new file mode 100644 index 0000000..099cf13 --- /dev/null +++ b/backend/test_broadcast_logic.ts @@ -0,0 +1,55 @@ +import { ProStrategyEngine } from '../src/strategies/ProStrategyEngine.js'; +import { AutoTrader } from '../src/services/AutoTrader.js'; +import { TradeExecutor } from '../src/services/TradeExecutor.js'; +import { RuleResult, SignalDirection } from '../src/strategies/rules/types.js'; +import logger from '../src/utils/logger.js'; + +// Mock objects +const mockExchange: any = { + fetchOHLCV: async () => [], + getPosition: async () => null, + placeOrder: async () => ({ id: 'mock-order', status: 'filled' }) +}; + +async function testBroadcast() { + console.log("🧪 Testing Signal Broadcast to Multiple Profiles..."); + + // Create 3 Simulated Profiles + const profiles = [ + { id: 'profile-alpha', name: 'Alpha Scalper', capital: 1000 }, + { id: 'profile-beta', name: 'Beta Trend', capital: 5000 }, + { id: 'profile-gamma', name: 'Gamma Swing', capital: 2500 } + ]; + + const traders: AutoTrader[] = []; + + for (const p of profiles) { + const executor = new TradeExecutor(mockExchange, undefined, 'test-user', p.id); + const trader = new AutoTrader(executor, mockExchange, p.capital, 1, ['BTC/USDT']); + traders.push(trader); + } + + // Simulate a PASSING signal + const mockResult: RuleResult = { + passed: true, + signal: SignalDirection.BUY, + reason: "Test Signal Pass", + rules: {} + }; + + const mockContext: any = { + symbol: 'BTC/USDT', + currentPrice: 50000, + indicators: {} + }; + + console.log("\n🚀 Broadcasting Signal..."); + // This replicates the logic in index.ts: + // await Promise.allSettled(userContexts.map(ctx => ctx.autoTrader.handleSignal(symbol, result!, context!))); + + await Promise.allSettled(traders.map(t => t.handleSignal('BTC/USDT', mockResult, mockContext))); + + console.log("\n✅ Broadcast Phase Complete. Check logs above for profile identifiers."); +} + +testBroadcast(); diff --git a/backend/test_feed_us.ts b/backend/test_feed_us.ts new file mode 100644 index 0000000..b9d6182 --- /dev/null +++ b/backend/test_feed_us.ts @@ -0,0 +1,28 @@ +import Alpaca from '@alpacahq/alpaca-trade-api'; + +const alpaca = new (Alpaca as any)({ + keyId: 'PKTHEUUYVGMCWDLAREMD2YXNOV', + secretKey: 'eVi9LZQ1rKx4yy9ExbtLt2fCMWAMujJfapyv9RHnixK', + paper: true, +}); + +async function run() { + const symbol = 'BTCUSD'; + console.log(`Testing ${symbol} with getBarsV2 and feed: us...`); + try { + const bars = alpaca.getBarsV2(symbol, { + timeframe: '1Min', + start: new Date(Date.now() - 3600000 * 24).toISOString(), + limit: 5, + feed: 'us' + }); + let count = 0; + for await (const b of bars) { + count++; + } + console.log(`Finished. Found ${count} bars.`); + } catch (e: any) { + console.log('FAILED:', e.message); + } +} +run(); diff --git a/backend/test_final.ts b/backend/test_final.ts new file mode 100644 index 0000000..238d6c8 --- /dev/null +++ b/backend/test_final.ts @@ -0,0 +1,26 @@ +import Alpaca from '@alpacahq/alpaca-trade-api'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +const alpaca = new (Alpaca as any)({ + keyId: process.env.ALPACA_API_KEY, + secretKey: process.env.ALPACA_API_SECRET, + paper: true, +}); + +async function run() { + console.log('Testing BTC/USD (arrayMap)...'); + try { + const barsMap = await alpaca.getCryptoBars(['BTC/USD'], { + timeframe: '1Min', + start: new Date(Date.now() - 3600000).toISOString(), + limit: 5 + }); + console.log('BarsMap keys:', Object.keys(barsMap)); + const bars = barsMap['BTC/USD'] || []; + console.log(`Got ${bars.length} bars!`); + } catch (e: any) { + console.log('Error:', e.message); + } +} +run(); diff --git a/backend/test_fuzzy_match.ts b/backend/test_fuzzy_match.ts new file mode 100644 index 0000000..9221ea2 --- /dev/null +++ b/backend/test_fuzzy_match.ts @@ -0,0 +1,62 @@ + +import { AutoTrader } from '../src/services/AutoTrader.js'; +import { TradeExecutor } from '../src/services/TradeExecutor.js'; +import { SignalDirection } from '../src/strategies/rules/types.js'; +import logger from '../src/utils/logger.js'; + +// Mock Executor +const mockExecutor = { + getActivePosition: () => null, + getActiveSymbols: () => [], + checkCooldown: () => false, + openPosition: async (symbol, side, qty) => { + console.log(`✅ EXECUTION TRIGGERED: Open ${side} on ${symbol} for ${qty}`); + return { success: true }; + }, + syncPositions: async () => { }, + currentProfileId: 'test-profile' +} as unknown as TradeExecutor; + +// Mock Exchange +const mockExchange = { + getPosition: async () => null +} as any; + +async function testThinking() { + console.log('--- 🧪 TESTING AUTO-TRADER FUZZY MATCH LOGIC ---'); + + // Scenario: User allows "BTC/USD" (Alpaca style) + // Signal comes in as "BTC/USDT" (Data style) + const allowedSymbols = ['BTC/USD', 'ETH/USD']; + + const trader = new AutoTrader( + mockExecutor, + mockExchange, + 1000, // Capital + 1, // Risk % + allowedSymbols + ); + + const incomingSymbol = 'BTC/USDT'; + + console.log(`User Allowed Symbols: ${JSON.stringify(allowedSymbols)}`); + console.log(`Incoming Signal Symbol: ${incomingSymbol}`); + + const mockSignal = { + ruleName: 'TestRule', + passed: true, + signal: SignalDirection.BUY, + reason: 'Test Buy Signal' + }; + + const mockContext = { + currentPrice: 90000, + candles1h: Array(50).fill({ high: 90100, low: 89900, close: 90000 }), // Mock for ATR + } as any; + + console.log('\nInvoking handleSignal...'); + await trader.handleSignal(incomingSymbol, mockSignal, mockContext); + console.log('--- Test Complete (Check above for EXECUTION TRIGGERED) ---'); +} + +testThinking(); diff --git a/backend/test_hot_loading.ts b/backend/test_hot_loading.ts new file mode 100644 index 0000000..76f3d3c --- /dev/null +++ b/backend/test_hot_loading.ts @@ -0,0 +1,57 @@ +import { createClient } from '@supabase/supabase-js'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_KEY!); + +async function test() { + const { data: users } = await supabase.from('users').select('user_id, email').limit(1); + if (!users || users.length === 0) { + console.error("No users found"); + return; + } + const userId = users[0].user_id; + console.log(`Using User ID: ${userId} (${users[0].email})`); + + const testProfileName = `TEST_HOT_LOAD_${Date.now()}`; + + console.log(`\n--- 1. Testing INSERT (Dynamic Load) ---`); + const { data: insertData, error: insError } = await supabase.from('trade_profiles').insert([{ + user_id: userId, + name: testProfileName, + allocated_capital: 555, + risk_per_trade_percent: 0.5, + symbols: 'BTC/USDT, ETH/USDT', + is_active: true + }]).select(); + + if (insError) { + console.error("Insert error:", insError); + return; + } + const profileId = insertData[0].id; + console.log(`✅ Profile created: ${profileId}. Bot should log [HOT-LOAD]...`); + + // Wait for bot to pick it up + await new Promise(r => setTimeout(r, 5000)); + + console.log(`\n--- 2. Testing UPDATE (Hot Swap) ---`); + const { error: updError } = await supabase.from('trade_profiles') + .update({ allocated_capital: 999 }) + .eq('id', profileId); + + if (updError) console.error("Update error:", updError); + else console.log(`✅ Capital updated to 999. Bot should log [HOT-REMOVE] then [HOT-LOAD]...`); + + await new Promise(r => setTimeout(r, 5000)); + + console.log(`\n--- 3. Testing DELETE (Hot Remove) ---`); + const { error: delError } = await supabase.from('trade_profiles') + .delete() + .eq('id', profileId); + + if (delError) console.error("Delete error:", delError); + else console.log(`✅ Profile deleted. Bot should log [HOT-REMOVE]...`); +} + +test(); diff --git a/backend/test_noslash_hardcoded.ts b/backend/test_noslash_hardcoded.ts new file mode 100644 index 0000000..e5f92bb --- /dev/null +++ b/backend/test_noslash_hardcoded.ts @@ -0,0 +1,28 @@ +import Alpaca from '@alpacahq/alpaca-trade-api'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +const alpaca = new (Alpaca as any)({ + keyId: 'PKTHEUUYVGMCWDLAREMD2YXNOV', + secretKey: 'eVi9LZQ1rKx4yy9ExbtLt2fCMWAMujJfapyv9RHnixK', + paper: true, +}); + +async function run() { + const symbol = 'BTCUSD'; + console.log(`Testing ${symbol} with getBarsV2...`); + try { + const bars = alpaca.getBarsV2(symbol, { + timeframe: '1Min', + start: new Date(Date.now() - 3600000 * 24).toISOString(), + limit: 5 + }); + for await (const b of bars) { + console.log('Got bar!', b.Timestamp); + } + console.log('SUCCESS!'); + } catch (e: any) { + console.log('FAILED:', e.message); + } +} +run(); diff --git a/backend/test_pro_strategy.ts b/backend/test_pro_strategy.ts new file mode 100644 index 0000000..9926022 --- /dev/null +++ b/backend/test_pro_strategy.ts @@ -0,0 +1,214 @@ +import { EntryTriggerRule } from '../src/strategies/rules/EntryTriggerRule.js'; +import { ProStrategyEngine } from '../src/strategies/ProStrategyEngine.js'; +import { IExchangeConnector, Candle } from '../src/connectors/types.js'; +import { config } from '../src/config/index.js'; +import logger from '../src/utils/logger.js'; + +// --- MOCK EXCHANGE --- +class MockExchange implements IExchangeConnector { + private candles4h: Candle[]; + private candles1h: Candle[]; + private candles15m: Candle[]; // Added for 15m specific data + + constructor(data: { '4h': Candle[], '1h': Candle[], '15m'?: Candle[] }) { + this.candles4h = data['4h']; + this.candles1h = data['1h']; + this.candles15m = data['15m'] || []; // Initialize 15m, default to empty array if not provided + } + + async fetchOHLCV(symbol: string, timeframe: string, since?: number): Promise { + if (timeframe === '4h') return this.candles4h; + if (timeframe === '1h') return this.candles1h; + if (timeframe === '15m') return this.candles15m.length > 0 ? this.candles15m : this.candles1h; // Mock 15m with 1h data if no specific 15m data is provided + return []; + } + async placeOrder(symbol: string, side: 'buy' | 'sell', qty: number, type: 'market' | 'limit'): Promise { + return { id: 'mock-order' }; + } + + async getPosition(symbol: string): Promise { + return null; + } +} + +// --- HELPER TO GENERATE CANDLES --- +function generateTrend(length: number, startPrice: number, trend: 'up' | 'down' | 'flat', noise: number = 0.001): Candle[] { + const candles: Candle[] = []; + let price = startPrice; + const now = Date.now(); + + for (let i = 0; i < length; i++) { + let change = 0; + if (trend === 'up') change = price * 0.0005; // +0.05% per candle (Steady trend, not parabolic) + if (trend === 'down') change = -price * 0.0005; + + // Add minimal noise + price += change + (Math.random() - 0.5) * price * noise; + + candles.push({ + timestamp: now - (length - i) * 60 * 60 * 1000, + open: price, + high: price * 1.001, + low: price * 0.999, + close: price, + volume: 1000 + }); + } + return candles; +} + +// --- TEST RUNNER --- +async function runTest() { + logger.info('--- STARTING PRO STRATEGY VERIFICATION ---'); + + // SCENARIO 1: STRONG UPTREND (Expect BUY) + // 4H: Uptrend + // 1H: Uptrend + Pullback? + // Session: Force Session window to active + + // Mock Session & RSI Limits for Test + const currentHour = new Date().getUTCHours(); + config.PRO_STRATEGY.PARAMETERS.SESSION_WINDOWS = [{ start: 0, end: 24 }]; + config.PRO_STRATEGY.PARAMETERS.RSI_OVERBOUGHT = 101; // Allow strong trend for test case + + const candles4h_up = generateTrend(300, 50000, 'up'); // 50000 -> 80000 + const candles1h_up = generateTrend(100, 80000, 'up'); + + // Inject Pullback in last few candles of 1H to satisfy ZoneRule if purely momentum + // Actually ZoneRule says "Price <= EMA20 * 1.5%". + // MomentumRule says "RSI > 50". + + // Creating Mock Exchange + const mockExchange = new MockExchange({ + '4h': candles4h_up, + '1h': candles1h_up + }); + + const engine = new ProStrategyEngine(mockExchange); + const result = await engine.execute('BTC/USD'); + + logger.info(`\n[TEST 1] UPTREND SCENARIO RESULT:`); + if (result?.passed) { + logger.info(`✅ PASSED -> Signal: ${result.signal}`); + logger.info(`\n--- DETAILED RULE BREAKDOWN ---`); + logger.info(result.reason); + logger.info(`-------------------------------\n`); + } else { + logger.error(`❌ FAILED -> ${result?.ruleName}: ${result?.reason}`); + } + + // SCENARIO 2: MOMENTUM MISMATCH (Expect FAIL) + // 4H: Uptrend + // 1H: Sharp Drop (Bearish Momentum) + const candles1h_crash = generateTrend(100, 80000, 'down'); + + const mockExchange2 = new MockExchange({ + '4h': candles4h_up, // 4H says UP + '1h': candles1h_crash // 1H says DOWN + }); + + const engine2 = new ProStrategyEngine(mockExchange2); + const resultConflict = await engine2.execute('BTC/USD'); + + logger.info(`\n[TEST 2] CONFLICT SCENARIO RESULT:`); + if (resultConflict?.passed) { + logger.error(`❌ FAILED -> Should have skipped due to conflict!`); + } else { + logger.info(`✅ CORRECTLY FAILED -> ${resultConflict?.ruleName}: ${resultConflict?.reason}`); + } + + // --- TEST 3: ENTRY TRIGGER SCENARIO --- + logger.info(`\n[TEST 3] ENTRY TRIGGER SCENARIO: EMA RECLAIM`); + + // 1. Uptrend Base + const candlesReclaim = generateTrend(100, 80000, 'up'); + const len = candlesReclaim.length; + + // 2. Manipulate candles for Reclaim (Dip -> Reclaim) + // We strictly test CLOSED candles (Engine slices off the last one) + // So we need to modify [len-3] (Prev Closed) and [len-2] (Last Closed) + + candlesReclaim[len - 3].close = 79000; // Dip below EMA (~83k) + candlesReclaim[len - 2].close = 85000; // Reclaim above EMA + + // Build Context + // Now expects (4h, 1h, 15m) + // For this test, we can just pass the "Trend" candles as 15m instructions too if we wanted, + // or just pass dummy data for 15m if the rule we are testing (EntryTrigger) uses the "Execution" timeframe using 1h for now? + // Wait, we haven't updated the rules yet. They probably still use 1h explicitly. + // Ideally we pass valid 15m data. Let's reuse the Reclaim candles for 15m slot if we are testing the reclaim logic. + + const contextReclaim = engine['buildContext'](candles4h_up, candlesReclaim, candlesReclaim); + // Force Context Price to match last closed candle + contextReclaim.currentPrice = 85000; + + // Verify Trigger + const resultReclaim = await new EntryTriggerRule().check(contextReclaim); + + if (resultReclaim.passed) { + logger.info(`✅ PASSED -> Signal: ${resultReclaim.signal} | ${resultReclaim.reason}`); + } else { + // Debug info + logger.error(`❌ FAILED -> ${resultReclaim.reason}`); + logger.info(`Debug: Prev Close: ${candlesReclaim[len - 2].close}, Curr Close: ${candlesReclaim[len - 1].close}, EMA: ${contextReclaim.ema20_1h}`); + } + + // --- TEST 4: SCALPING 15m VERIFICATION --- + logger.info(`\n[TEST 4] SCALPING 15m VERIFICATION`); + + // Switch to 15m Execution + config.PRO_STRATEGY.PARAMETERS.EXECUTION_TIMEFRAME = '15m'; + + // Scenario: + // 1H is Bearish (RSI < 50) -> If we used 1H, Momentum would Fail. + // 15m is Bullish (RSI > 50) -> If we use 15m, Momentum should Pass. + + // Generate 1H Bearish Trend + const candles1h_bearish = generateTrend(100, 80000, 'down'); // RSI likely < 30 or low + + // Generate 15m Bullish Trend (Rally) + const candles15m_bullish = generateTrend(100, 80000, 'up'); // RSI likely > 70 + + // Need to ensure 15m RSI is healthy (e.g. 60), not Overbought (>70 is default overbought check?) + // generateTrend 'up' usually creates very strong trend (RSI 100). + // Let's temper the 15m trend to be "Healthy" (RSI ~ 60). + // Or just check that it passes "Bullish Momentum" but fails "Overbought". + // Wait, generateTrend makes RSI 100 which is Overbought. + // Let's manually set the 15m candles to be flat then rising slightly. + + // Actually, simply: + // 1H = RSI 40 (Sell) + // 15m = RSI 60 (Buy) + + // Let's trust that Momentum Rule checks RSI nicely. + // If Logic is: Bias BUY -> Need Momentum BUY. + // Bias is based on 4H (which is Uptrend in our context). + // So we need Momentum to be Bullish. + + // If we use 1H (Bearish), it will fail. + // If we use 15m (Bullish), it will pass. + + const contextScalp = engine['buildContext'](candles4h_up, candles1h_bearish, candles15m_bullish); + // Overwrite RSI to be explicit to avoid "generateTrend" randomness + contextScalp.rsi_1h = 40; // Bearish + contextScalp.rsi_15m = 60; // Bullish + + // Also Zone Rule uses 15m. + // 1H might be away from EMA, 15m might be close. + contextScalp.ema20_1h = 90000; // Price 80000 -> Far away + contextScalp.ema20_15m = 80000; // Price 80000 -> Close + contextScalp.currentPrice = 80000; + + const momentumRule = engine['rules'].find((r: any) => r.name === 'MomentumRule'); + if (!momentumRule) throw new Error('MomentumRule not found'); + const resultScalp = await momentumRule.check(contextScalp); + + if (resultScalp.passed && resultScalp.signal === 'BUY') { + logger.info(`✅ PASSED -> Scalping Logic used 15m RSI (${contextScalp.rsi_15m}) correctly.`); + } else { + logger.error(`❌ FAILED -> Scalping Logic failed. Reason: ${resultScalp.reason}`); + logger.info(`Debug: 1H RSI: ${contextScalp.rsi_1h}, 15m RSI: ${contextScalp.rsi_15m}`); + } +} + +runTest().catch(console.error); diff --git a/backend/test_query.ts b/backend/test_query.ts new file mode 100644 index 0000000..7203c0f --- /dev/null +++ b/backend/test_query.ts @@ -0,0 +1,28 @@ +import { createClient } from '@supabase/supabase-js'; +import * as dotenv from 'dotenv'; +import path from 'path'; + +dotenv.config({ path: path.resolve(process.cwd(), '.env') }); +const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_KEY!); + +const userId = '8d5efd9e-0760-4859-8c07-0930ab3ede5a'; + +async function testQueryConflict() { + console.log(`Testing query with UUID (${userId}) and string (global)...`); + + // This is what HistoryTab.tsx is doing + const { data, error } = await supabase + .from('trade_history') + .select('*') + .or(`user_id.eq.${userId},user_id.eq.global`); + + if (error) { + console.error('❌ Query FAILED:', error.message); + console.error('Details:', error.details); + console.error('Code:', error.code); + } else { + console.log('✅ Query SUCCEEDED:', data?.length, 'rows found.'); + } +} + +testQueryConflict(); diff --git a/backend/test_risk_formula.ts b/backend/test_risk_formula.ts new file mode 100644 index 0000000..6de7019 --- /dev/null +++ b/backend/test_risk_formula.ts @@ -0,0 +1,42 @@ +import { RiskEngine } from '../src/services/riskEngine.js'; +import { MarketContext, SignalDirection } from '../src/strategies/rules/types.js'; + +async function testRiskFormula() { + console.log('--- Testing New Risk Formula ---'); + const engine = new RiskEngine(); + + const mockContext: MarketContext = { + currentPrice: 100000, + candles4h: [], + candles1h: Array(20).fill({ close: 100000, high: 100500, low: 99500 }), + candles15m: [], + change24h: 0, + changeToday: 0, + session: 'NY', + isMajorSession: true, + volatility: 'Low', + latestSignal: SignalDirection.NONE + }; + + // Note: Since we don't have true ATR here, let's mock calculateRiskProfile call or check buildProfile + console.log('Mock Entry: 100,000'); + console.log('Mock ATR: 1,000'); + console.log('SL Multiplier: 1.0 -> SL Dist: 1,000'); + console.log('RRR: 1.5 -> Raw TP Dist: 1,500'); + console.log('Max TP Cap (1%): 1,000'); + + const profile = (engine as any).buildProfile('BTC/USD', SignalDirection.BUY, 100000, 1000, 10, 1.5); + + console.log('\nResults:'); + console.log(`SL: ${profile.stopLoss} (Expected 99,000)`); + console.log(`TP: ${profile.takeProfit} (Expected 101,000 - capped by 1%)`); + console.log(`Size: ${profile.positionSize}`); + + if (profile.stopLoss === 99000 && profile.takeProfit === 101000) { + console.log('\n✅ Formula mapping is CORRECT.'); + } else { + console.log('\n❌ Formula mapping is INCORRECT.'); + } +} + +testRiskFormula().catch(console.error); diff --git a/backend/test_risk_scenarios.ts b/backend/test_risk_scenarios.ts new file mode 100644 index 0000000..664f7d1 --- /dev/null +++ b/backend/test_risk_scenarios.ts @@ -0,0 +1,84 @@ +import { config } from '../src/config/index.js'; +import { RiskEngine } from '../src/services/riskEngine.js'; +import { SignalDirection, MarketContext, Candle } from '../src/strategies/rules/types.js'; +import logger from '../src/utils/logger.js'; + +// Helper to generate candles with specific volatility +function generateCandles(basePrice: number, volatility: number): Candle[] { + const candles: Candle[] = []; + for (let i = 0; i < 20; i++) { + candles.push({ + timestamp: Date.now() - (i * 3600000), + open: basePrice, + high: basePrice + volatility, + low: basePrice - volatility, + close: basePrice, + volume: 1000 + }); + } + return candles; +} + +async function testRiskScenarios() { + const riskEngine = new RiskEngine(); + const basePrice = 90000; // BTC Example + + console.log(`--- RISK ENGINE SCENARIO TEST ---`); + console.log(`Config Balance: $${config.TOTAL_CAPITAL}`); + console.log(`Risk Per Trade: ${config.PRO_STRATEGY.PARAMETERS.RISK_PER_TRADE * 100}%`); + console.log(`Target Risk Amount: $${config.TOTAL_CAPITAL * config.PRO_STRATEGY.PARAMETERS.RISK_PER_TRADE}\n`); + + // --- Scenario 1: Calm Market (Low Volatility) --- + const calmContext: MarketContext = { + currentPrice: basePrice, + candles4h: [], + candles1h: generateCandles(basePrice, 450), // ATR will be around 900 (High-Low) + candles15m: [], + change24h: 0, + changeToday: 0, + session: 'NY', + isMajorSession: true, + volatility: 'Low', + latestSignal: SignalDirection.NONE + }; + + const calmProfile = await riskEngine.calculateRiskProfile('BTC/USDT', SignalDirection.BUY, calmContext); + + console.log(`[SCENARIO A: CALM]`); + if (calmProfile) { + console.log(`ATR-based Stop Distance: $${(basePrice - calmProfile.stopLoss).toFixed(2)}`); + console.log(`Position Size: ${calmProfile.positionSize.toFixed(4)} BTC`); + console.log(`Stop Loss Price: $${calmProfile.stopLoss.toFixed(2)}`); + console.log(`Total $ Risked: $${calmProfile.riskAmount.toFixed(2)}`); + } + + console.log('\n---------------------------\n'); + + // --- Scenario 2: Volatile Market (High Volatility) --- + const volatileContext: MarketContext = { + currentPrice: basePrice, + candles4h: [], + candles1h: generateCandles(basePrice, 900), // ATR will be around 1800 + candles15m: [], + change24h: 0, + changeToday: 0, + session: 'NY', + isMajorSession: true, + volatility: 'High', + latestSignal: SignalDirection.NONE + }; + + const volatileProfile = await riskEngine.calculateRiskProfile('BTC/USDT', SignalDirection.BUY, volatileContext); + + console.log(`[SCENARIO B: VOLATILE]`); + if (volatileProfile) { + console.log(`ATR-based Stop Distance: $${(basePrice - volatileProfile.stopLoss).toFixed(2)}`); + console.log(`Position Size: ${volatileProfile.positionSize.toFixed(4)} BTC (Bigger Stop = Smaller Size)`); + console.log(`Stop Loss Price: $${volatileProfile.stopLoss.toFixed(2)}`); + console.log(`Total $ Risked: $${volatileProfile.riskAmount.toFixed(2)}`); + } + + console.log(`\n--- TEST COMPLETE ---`); +} + +testRiskScenarios().catch(console.error); diff --git a/backend/test_safe.ts b/backend/test_safe.ts new file mode 100644 index 0000000..4712099 --- /dev/null +++ b/backend/test_safe.ts @@ -0,0 +1,36 @@ +import Alpaca from '@alpacahq/alpaca-trade-api'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +async function test() { + const alpaca = new (Alpaca as any)({ + keyId: process.env.ALPACA_API_KEY, + secretKey: process.env.ALPACA_API_SECRET, + paper: true, + }); + + const combinations = [ + { m: 'getBarsV2', s: 'BTCUSD' }, + { m: 'getBarsV2', s: 'BTC/USD' }, + { m: 'getCryptoBars', s: 'BTCUSD' }, + { m: 'getCryptoBars', s: 'BTC/USD' }, + ]; + + for (const item of combinations) { + console.log(`--- Testing ${item.m} with ${item.s} ---`); + try { + const bars = (alpaca as any)[item.m](item.s, { + timeframe: '1Min', + limit: 5, + start: new Date(Date.now() - 3600000 * 48).toISOString() + }); + let count = 0; + for await (const b of bars) { count++; } + console.log(`Success! Found ${count} bars.`); + } catch (e: any) { + console.log(`Failed: ${e.message}`); + } + } +} + +test(); diff --git a/backend/test_signal_sim.ts b/backend/test_signal_sim.ts new file mode 100644 index 0000000..dbc7a3a --- /dev/null +++ b/backend/test_signal_sim.ts @@ -0,0 +1,47 @@ +import { DirectionTracker, SignalType } from '../src/strategies/directionTracker.js'; +import { Notifier } from '../src/services/notifier.js'; +import logger from '../src/utils/logger.js'; + +async function simulateSignalChange() { + const tracker = new DirectionTracker(); + const notifier = new Notifier(); + + logger.info('--- Starting Signal Change Simulation ---'); + + // 1. Create Neutral/None state (Price = EMA, RSI = 50) + // We need 20 candles + const basePrice = 50000; + const neutralCandles = Array.from({ length: 20 }, (_, i) => ({ + close: basePrice, + timestamp: Date.now() - (20 - i) * 60000 + })); + + logger.info('Phase 1: Neutral state...'); + tracker.calculateDirection(neutralCandles); + + // 2. Force a BUY signal (Price > EMA, RSI < 70) + // Price jumps up, but RSI stays moderate + const buyCandles = [...neutralCandles, { close: 51000, timestamp: Date.now() }]; + logger.info('Phase 2: Forcing BUY signal...'); + const buyResult = tracker.calculateDirection(buyCandles); + + if (buyResult.changed && buyResult.signal === SignalType.BUY) { + const message = `🚨 *SIMULATION: BUY SIGNAL* 🚨\nAsset: BTC/USD (MOCK)\nPrice: 51000\nEMA-20: ${buyResult.ema.toFixed(2)}\nRSI-14: ${buyResult.rsi.toFixed(2)}\nLogic: Price > EMA and RSI < 70`; + await notifier.sendAlert(message); + } + + // 3. Force a SELL signal (Price < EMA, RSI > 30) + // Price drops sharply + const sellCandles = [...buyCandles.slice(1), { close: 49000, timestamp: Date.now() + 60000 }]; + logger.info('Phase 3: Forcing SELL signal...'); + const sellResult = tracker.calculateDirection(sellCandles); + + if (sellResult.changed && sellResult.signal === SignalType.SELL) { + const message = `🚨 *SIMULATION: SELL SIGNAL* 🚨\nAsset: BTC/USD (MOCK)\nPrice: 49000\nEMA-20: ${sellResult.ema.toFixed(2)}\nRSI-14: ${sellResult.rsi.toFixed(2)}\nLogic: Price < EMA and RSI > 30`; + await notifier.sendAlert(message); + } + + logger.info('--- Simulation Complete ---'); +} + +simulateSignalChange().catch(err => logger.error(err)); diff --git a/backend/test_simulation.ts b/backend/test_simulation.ts new file mode 100644 index 0000000..448ac95 --- /dev/null +++ b/backend/test_simulation.ts @@ -0,0 +1,78 @@ +// --- Simplified Internal Types for Logic Test --- +enum SignalType { + BUY = 'BUY', + SELL = 'SELL', + NONE = 'NONE' +} + +const logger = { + info: (msg: string) => console.log(`[INFO] ${msg}`), + error: (msg: string) => console.log(`[ERROR] ${msg}`) +}; + +// --- Mocking Configuration for Logic Test --- +const config = { + SYMBOL: 'BTC/USD', + LOW_STRESS_MODE: true +}; + +// --- Mocking Notifier --- +const notifier = { + sendAlert: async (msg: string) => { + console.log('\n--- DISCORD ALERT SENT ---'); + console.log(msg); + console.log('--------------------------\n'); + } +}; + +async function runSimulation() { + console.log('🚀 Starting Low-Stress Mode Simulation Test...\n'); + + let entryPrice: number | null = null; + let lastKnownPrice = 0; + + // --- STEP 1: Simulate BUY Signal --- + console.log('Step 1: Simulating a BUY Signal at $60,000...'); + lastKnownPrice = 60000; + + // Simulate what happens in index.ts when result.changed is true + if (config.LOW_STRESS_MODE) { + entryPrice = lastKnownPrice; + logger.info(`[Low-Stress] Entry Price set at ${entryPrice}`); + await notifier.sendAlert(`🚨 *Trend Signal Alert* 🚨\nSignal: BUY\nAsset: ${config.SYMBOL}\nPrice: ${lastKnownPrice}`); + } + + // --- STEP 2: Simulate Price Movement (Monitoring) --- + const simulatePrice = async (currentPrice: number) => { + lastKnownPrice = currentPrice; + if (config.LOW_STRESS_MODE && entryPrice) { + const percentChange = ((lastKnownPrice - entryPrice) / entryPrice) * 100; + logger.info(`[Low-Stress] Monitoring: ${percentChange.toFixed(2)}% | Price: ${lastKnownPrice}`); + + if (percentChange >= 1.5) { + const tpMessage = `💰 *Take Profit Target Reached (+1.5%)* 💰\nAsset: ${config.SYMBOL}\nEntry: ${entryPrice}\nCurrent: ${lastKnownPrice}\nProfit: ${percentChange.toFixed(2)}%\nPlan: $10/Day Low-Stress ✅`; + await notifier.sendAlert(tpMessage); + entryPrice = null; // Reset + } else if (percentChange <= -0.7) { + const slMessage = `⚠️ *Stop Loss Buffer Hit (-0.7%)* ⚠️\nAsset: ${config.SYMBOL}\nEntry: ${entryPrice}\nCurrent: ${lastKnownPrice}\nLoss: ${percentChange.toFixed(2)}%\nPlan: Risk Managed 🛡️`; + await notifier.sendAlert(slMessage); + entryPrice = null; // Reset + } + } + }; + + console.log('\nStep 2: Price moves to $60,500 (+0.83%)...'); + await simulatePrice(60500); + + console.log('\nStep 3: Price moves to $60,950 (+1.58%)... (TP SHOULD TRIGGER)'); + await simulatePrice(60950); + + console.log('\nStep 4: Restarting for Stop Loss Test at $60,000...'); + entryPrice = 60000; + console.log('\nStep 5: Price drops to $59,500 (-0.83%)... (SL SHOULD TRIGGER)'); + await simulatePrice(59500); + + console.log('\n✅ Simulation Test Complete.'); +} + +runSimulation(); diff --git a/backend/test_simulation_pro.ts b/backend/test_simulation_pro.ts new file mode 100644 index 0000000..6a92285 --- /dev/null +++ b/backend/test_simulation_pro.ts @@ -0,0 +1,116 @@ +import { ProStrategyEngine } from '../src/strategies/ProStrategyEngine.js'; +import { IExchangeConnector, Candle } from '../src/connectors/types.js'; +import { config } from '../src/config/index.js'; +import logger from '../src/utils/logger.js'; + +// --- MOCK LIVE EXCHANGE --- +class LiveSimulationExchange implements IExchangeConnector { + private currentTime = Date.now(); + private price = 50000; + private trend = 1; // 1 = Up, -1 = Down + + async fetchOHLCV(symbol: string, timeframe: string, limit?: number): Promise { + // Generate a history of candles ending at 'currentTime' + const candles: Candle[] = []; + let tempPrice = this.price - (300 * 10); // Start back in time + + const count = limit || 100; + + for (let i = 0; i < count; i++) { + // Add some "trend" bias based on our internal state + const noise = (Math.random() - 0.5) * 50; + const move = (this.trend * 50) + noise; // Stronger trend (was 20) + + tempPrice += move; + + candles.push({ + timestamp: this.currentTime - ((count - i) * getMs(timeframe)), + open: tempPrice, + high: tempPrice + 50, + low: tempPrice - 50, + close: tempPrice, // Close is what matters for EMA typically + volume: 1000 + }); + } + return candles; + } + + // Helper to advance the simulation + public tick() { + this.currentTime += 60 * 60 * 1000; // Advance 1 hour + this.price += this.trend * 100 + (Math.random() - 0.5) * 200; // Move price + } + + public setTrend(dir: 1 | -1) { + this.trend = dir; + } + async placeOrder(symbol: string, side: 'buy' | 'sell', qty: number, type: 'market' | 'limit'): Promise { + return { id: 'mock-sim-order' }; + } + + async getPosition(symbol: string): Promise { + return null; + } +} + +function getMs(tf: string): number { + if (tf === '4h') return 4 * 60 * 60 * 1000; + if (tf === '1h') return 1 * 60 * 60 * 1000; + return 60000; +} + +// --- MAIN RUNNER --- +async function runLiveSimulation() { + console.log('🚀 INITIALIZING PRO STRATEGY LIVE SIMULATION...\n'); + + // 1. Setup Mock Exchange + const exchange = new LiveSimulationExchange(); + const engine = new ProStrategyEngine(exchange); + + // Mock Config + config.PRO_STRATEGY.PARAMETERS.SESSION_WINDOWS = [{ start: 0, end: 24 }]; + config.PRO_STRATEGY.PARAMETERS.RSI_OVERBOUGHT = 99; + + console.log('--- PHASE 1: WARMUP (Generating Uptrend Data) ---'); + exchange.setTrend(1); // Uptrend + + // Simulate 5 "Ticks" (Hours) of trading + for (let i = 1; i <= 5; i++) { + console.log(`\n⏰ HOUR ${i}: Fetching Market Data...`); + exchange.tick(); // Advance time/price + + const result = await engine.execute('BTC/USD'); + + if (result?.passed) { + const reason = result.reason || 'No reason'; + console.log(`✅ [Trade Taken] ${result.signal} | Reason: ${reason.split('\n')[0]}...`); + } else { + console.log(`⏸️ [Skipped] Reason: ${result?.reason}`); + } + + // Sleep for effect + await new Promise(r => setTimeout(r, 500)); + } + + console.log('\n--- PHASE 2: MARKET CRASH (Trend Reversal) ---'); + exchange.setTrend(-1); // Downtrend + + for (let i = 1; i <= 10; i++) { // Need enough ticks to bend the 4H EMA + // Speed up simulation + exchange.tick(); + exchange.tick(); + exchange.tick(); + } + + console.log(`\n⏰ HOUR 6 (After Crash): Checking Logic...`); + const resultCrash = await engine.execute('BTC/USD'); + if (!resultCrash?.passed) { + console.log(`✅ [Correctly Skipped] Market Crash filtered! Reason: ${resultCrash?.reason}`); + } else { + console.log(`❌ [BAD TRADE] Strategy bought during crash? Signal: ${resultCrash?.signal}`); + } + + console.log('\n✨ SIMULATION COMPLETE'); +} + +runLiveSimulation(); diff --git a/backend/test_slash.ts b/backend/test_slash.ts new file mode 100644 index 0000000..8336252 --- /dev/null +++ b/backend/test_slash.ts @@ -0,0 +1,31 @@ +import Alpaca from '@alpacahq/alpaca-trade-api'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +const alpaca = new (Alpaca as any)({ + keyId: process.env.ALPACA_API_KEY, + secretKey: process.env.ALPACA_API_SECRET, + paper: true, +}); + +async function run() { + const symbol = 'BTC/USD'; + console.log(`Testing ${symbol} with getCryptoBars...`); + try { + const bars = alpaca.getCryptoBars(symbol, { + timeframe: '1Min', + start: new Date(Date.now() - 3600000).toISOString(), + limit: 5 + }); + let found = false; + for await (const b of bars) { + console.log('Got bar!'); + found = true; + } + if (found) console.log('SUCCESS!'); + else console.log('No bars found but no error.'); + } catch (e: any) { + console.log('FAILED:', e.message); + } +} +run(); diff --git a/backend/test_stock_baseline.ts b/backend/test_stock_baseline.ts new file mode 100644 index 0000000..449584c --- /dev/null +++ b/backend/test_stock_baseline.ts @@ -0,0 +1,31 @@ +import Alpaca from '@alpacahq/alpaca-trade-api'; +import { config } from '../src/config/index.js'; + +async function testStock() { + const alpaca = new (Alpaca as any)({ + keyId: config.ALPACA_API_KEY, + secretKey: config.ALPACA_API_SECRET, + paper: true, + }); + + console.log('Testing Stock data (AAPL)...'); + try { + const bars = alpaca.getBarsV2('AAPL', { + timeframe: '1Min', + start: new Date(Date.now() - 3600000 * 24).toISOString(), + limit: 5, + feed: 'iex' + }); + let count = 0; + for await (const b of bars) { + count++; + console.log(`Got Stock Bar: ${b.Timestamp} | Price: ${b.ClosePrice}`); + } + if (count > 0) console.log('✅ Success! Stock data is working.'); + else console.log('❌ No stock bars found. This might be due to market hours or delay.'); + } catch (e: any) { + console.error('❌ Stock API Error:', e.message); + } +} + +testStock(); diff --git a/backend/test_strategy_logic.ts b/backend/test_strategy_logic.ts new file mode 100644 index 0000000..12a6907 --- /dev/null +++ b/backend/test_strategy_logic.ts @@ -0,0 +1,92 @@ +import { ExecutionManager } from '../src/services/executionManager.js'; +import { TradeMonitor } from '../src/services/tradeMonitor.js'; +import { SignalDirection, MarketContext, RuleResult } from '../src/strategies/rules/types.js'; +import { IExchangeConnector } from '../src/connectors/types.js'; + +async function testTrailAndHold() { + console.log('--- Trailing Guard & Signal Exit Test Start ---'); + + // 1. Mock Exchange + const mockExchange: any = { + getPosition: async () => ({ side: 'long', qty: '1', avg_entry_price: '10000' }), + placeOrder: async (sym: string, side: string) => { + console.log(`[Mock] Order Placed: ${side} for ${sym}`); + return { id: 'test-exit-id' }; + }, + fetchOHLCV: async () => [{ close: 10150 }] // 1.5% profit mock price + }; + + const manager = new ExecutionManager(mockExchange as any); + const monitor = new TradeMonitor(mockExchange as any, manager); + + const symbol = 'BTC/USD'; + const entryPrice = 10000; + + // --- TEST 1: Signal Reversal Exit --- + console.log('\n--- Scenario 1: Signal Reversal ---'); + (manager as any).activeTraders.set(symbol, { + side: SignalDirection.BUY, + entryPrice: entryPrice, + size: 1, + stopLoss: 9000, + takeProfit: 10100, // 1% target + peakPrice: entryPrice + }); + + const sellSignal: RuleResult = { + ruleName: 'ReverseSignal', + signal: SignalDirection.SELL, + passed: true, + reason: 'Trend flipped' + }; + const context: MarketContext = { + currentPrice: 10050, + candles4h: [], + candles1h: [], + candles15m: [], + change24h: 0, + changeToday: 0, + session: 'NY', + isMajorSession: true, + volatility: 'Low', + latestSignal: SignalDirection.BUY + }; + + console.log('Action: Sending SELL signal while in BUY trade...'); + await manager.handleSignal(symbol, sellSignal, context); + + if (!(manager as any).activeTraders.has(symbol)) { + console.log('✅ PASS: Trade exited on Signal Flip.'); + } else { + console.log('❌ FAIL: Trade still active after Signal Flip.'); + } + + // --- TEST 2: Trailing Profit Guard --- + console.log('\n--- Scenario 2: Trailing Profit Guard ---'); + // Reset state + (manager as any).activeTraders.set(symbol, { + side: SignalDirection.BUY, + entryPrice: entryPrice, + size: 1, + stopLoss: 9000, + takeProfit: 10100, // 1% target + peakPrice: 10150, // Peak reached + profitGuardActive: true + }); + + // Mock price pullback (0.2% pullback from 10150 peak -> 10129) + mockExchange.fetchOHLCV = async () => [{ close: 10120 }]; + + console.log('Action: Simulating price pullback (0.3%) after 1.5% run...'); + await (monitor as any).checkOpenPositions(); + + if (!(manager as any).activeTraders.has(symbol)) { + console.log('✅ PASS: Trailing guard triggered exit on pullback.'); + } else { + console.log('❌ FAIL: Trailing guard failed to trigger exit.'); + } + + console.log('\n--- Trailing Guard & Signal Exit Test End ---'); +} + +testTrailAndHold().catch(console.error); diff --git a/backend/test_verbose_insert.ts b/backend/test_verbose_insert.ts new file mode 100644 index 0000000..59d42f8 --- /dev/null +++ b/backend/test_verbose_insert.ts @@ -0,0 +1,50 @@ +import { createClient } from '@supabase/supabase-js'; +import * as dotenv from 'dotenv'; +import path from 'path'; + +dotenv.config({ path: path.resolve(process.cwd(), '.env') }); +const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_KEY!); + +const userId = '8d5efd9e-0760-4859-8c07-0930ab3ede5a'; + +async function testVerboseInsert() { + console.log(`Attempting insert for ${userId}...`); + + // 1. Try Order + const orderData = { + user_id: userId, + symbol: 'BTC/USD', + type: 'Market', + side: 'buy', + qty: 0.1, + price: 90000, + status: 'Filled', + timestamp: Date.now() + }; + + console.log('Inserting into orders...'); + const { data: oData, error: oErr } = await supabase.from('orders').insert([orderData]).select(); + if (oErr) console.error('Orders Insert Failed:', oErr); + else console.log('Orders Insert OK:', oData[0].user_id); + + // 2. Try Trade History + const historyData = { + user_id: userId, + symbol: 'BTC/USD', + side: 'buy', + entry_price: 90000, + exit_price: 91000, + size: 0.1, + pnl: 100, + pnl_percent: 1.1, + reason: 'VERBOSE_TEST', + timestamp: Date.now() + }; + + console.log('Inserting into trade_history...'); + const { data: hData, error: hErr } = await supabase.from('trade_history').insert([historyData]).select(); + if (hErr) console.error('History Insert Failed:', hErr); + else console.log('History Insert OK:', hData[0].user_id); +} + +testVerboseInsert(); diff --git a/backend/test_wa.ts b/backend/test_wa.ts new file mode 100644 index 0000000..80c052a --- /dev/null +++ b/backend/test_wa.ts @@ -0,0 +1,33 @@ +import https from 'https'; + +const message = "🚨 Test Alert from Bot 🚨"; +const to = "+918939139701"; + +const data = JSON.stringify({ + message: message, + toWhatsAppNumber: to +}); + +const options = { + hostname: 'www.zenhustles.com', + port: 443, + path: '/api/whatsapp/send', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(data) + } +}; + +const req = https.request(options, (res) => { + let responseBody = ''; + res.on('data', (chunk) => responseBody += chunk); + res.on('end', () => { + console.log(`Status: ${res.statusCode}`); + console.log(`Body: ${responseBody}`); + }); +}); + +req.on('error', (e) => console.error(e)); +req.write(data); +req.end(); diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..88e8dec --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "src/test_*" + ] +} \ No newline at end of file diff --git a/backend/verifyRlsPolicies.ts b/backend/verifyRlsPolicies.ts new file mode 100644 index 0000000..4822862 --- /dev/null +++ b/backend/verifyRlsPolicies.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = __dirname; +const schemaDir = path.join(repoRoot, 'schema'); + +const schemaSql = fs + .readdirSync(schemaDir) + .filter((name) => name.endsWith('.sql')) + .sort() + .map((name) => fs.readFileSync(path.join(schemaDir, name), 'utf8').toLowerCase()) + .join('\n'); + +function expectSql(pattern: RegExp, message: string) { + assert(pattern.test(schemaSql), message); +} + +// Core auth-scoped table protections currently defined in migrations. +expectSql(/\balter\s+table\s+trade_profiles\s+enable\s+row\s+level\s+security\b/, 'Missing RLS enable for trade_profiles'); +expectSql(/\bcreate\s+policy\b[\s\S]*users can manage own profiles[\s\S]*on\s+trade_profiles\b/, 'Missing ownership policy on trade_profiles'); + +expectSql(/\balter\s+table\s+bot_config\s+enable\s+row\s+level\s+security\b/, 'Missing RLS enable for bot_config'); +expectSql(/\bcreate\s+policy\b[\s\S]*authenticated users can read bot_config[\s\S]*on\s+bot_config\b/, 'Missing read policy on bot_config'); +expectSql(/\bcreate\s+policy\b[\s\S]*admins can manage bot_config[\s\S]*on\s+bot_config\b/, 'Missing admin policy on bot_config'); + +expectSql(/\balter\s+table\s+orders\s+enable\s+row\s+level\s+security\b/, 'Missing RLS enable for orders'); +expectSql(/\bcreate\s+policy\b[\s\S]*users can read own orders[\s\S]*on\s+orders\b/, 'Missing read policy on orders'); +expectSql(/\bcreate\s+policy\b[\s\S]*users can insert own orders[\s\S]*on\s+orders\b/, 'Missing insert policy on orders'); +expectSql(/\bcreate\s+policy\b[\s\S]*users can update own orders[\s\S]*on\s+orders\b/, 'Missing update policy on orders'); + +expectSql(/\balter\s+table\s+trade_history\s+enable\s+row\s+level\s+security\b/, 'Missing RLS enable for trade_history'); +expectSql(/\bcreate\s+policy\b[\s\S]*users can read own trade history[\s\S]*on\s+trade_history\b/, 'Missing read policy on trade_history'); +expectSql(/\bcreate\s+policy\b[\s\S]*users can insert own trade history[\s\S]*on\s+trade_history\b/, 'Missing insert policy on trade_history'); +expectSql(/\bcreate\s+policy\b[\s\S]*users can update own trade history[\s\S]*on\s+trade_history\b/, 'Missing update policy on trade_history'); + +expectSql(/\bcreate\s+table\s+if\s+not\s+exists\s+bot_state_snapshots\b/, 'Missing bot_state_snapshots table definition'); +expectSql(/\bcreate\s+policy\b[\s\S]*users can manage own snapshots[\s\S]*on\s+bot_state_snapshots\b/, 'Missing policy for bot_state_snapshots'); + +expectSql(/\bcreate\s+table\s+if\s+not\s+exists\s+capital_ledgers\b/, 'Missing capital_ledgers table definition'); +expectSql(/\bcreate\s+policy\b[\s\S]*users can manage own ledger[\s\S]*on\s+capital_ledgers\b/, 'Missing policy for capital_ledgers'); + +console.log('[rls-policies] OK: required RLS enable statements and policies are present in schema migrations'); diff --git a/backend/verifySchemaContract.ts b/backend/verifySchemaContract.ts new file mode 100644 index 0000000..5d59493 --- /dev/null +++ b/backend/verifySchemaContract.ts @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = __dirname; +const schemaDir = path.join(repoRoot, 'schema'); +const schemaFiles = fs + .readdirSync(schemaDir) + .filter((name) => name.endsWith('.sql')) + .sort(); + +const combinedSql = schemaFiles + .map((name) => fs.readFileSync(path.join(schemaDir, name), 'utf8').toLowerCase()) + .join('\n'); + +function assertSql(pattern: RegExp, message: string) { + assert(pattern.test(combinedSql), message); +} + +// Core tables used by runtime. +assertSql(/\bcreate\s+table\s+if\s+not\s+exists\s+trade_profiles\b/, 'Missing trade_profiles table definition'); +assertSql(/\bcreate\s+table\s+if\s+not\s+exists\s+bot_config\b/, 'Missing bot_config table definition'); +assertSql(/\bcreate\s+table\s+if\s+not\s+exists\s+dynamic_config\b/, 'Missing dynamic_config table definition'); + +// Orders columns controlled by local migrations. +assertSql(/\balter\s+table\s+orders\b/, 'orders migration statements missing'); +assertSql(/\borders\b[\s\S]*\bprofile_id\b/, 'orders.profile_id not found in migration contract'); +assertSql(/\borders\b[\s\S]*\bstop_loss\b/, 'orders.stop_loss not found in migration contract'); +assertSql(/\borders\b[\s\S]*\btake_profit\b/, 'orders.take_profit not found in migration contract'); +assertSql(/\borders\b[\s\S]*\baction\b/, 'orders.action not found in migration contract'); +assertSql(/\borders\b[\s\S]*\btrade_id\b/, 'orders.trade_id not found in migration contract'); +assertSql(/\borders\b[\s\S]*\bsub_tag\b/, 'orders.sub_tag not found in migration contract'); + +// Capital ledger reserve formula must include realized_pnl to match runtime available-capital math. +assertSql( + /\bcreate\s+or\s+replace\s+function\s+fn_reserve_for_order\b[\s\S]*allocated_capital\s*\+\s*realized_pnl[\s\S]*reserved_for_orders[\s\S]*reserved_for_positions[\s\S]*>=\s*p_amount/, + 'fn_reserve_for_order must gate reservation using allocated_capital + realized_pnl - reserved balances' +); + +// Trade history columns used by dashboard/bot traceability. +assertSql(/\btrade_history\b[\s\S]*\bprofile_id\b/, 'trade_history.profile_id not found in migration contract'); +assertSql(/\btrade_history\b[\s\S]*\brules_metadata\b/, 'trade_history.rules_metadata not found in migration contract'); +assertSql(/\btrade_history\b[\s\S]*\btrade_id\b/, 'trade_history.trade_id not found in migration contract'); +assertSql(/\btrade_history\b[\s\S]*\bsource\b/, 'trade_history.source not found in migration contract'); + +// RLS presence for critical user-scoped tables. +assertSql(/\balter\s+table\s+trade_profiles\s+enable\s+row\s+level\s+security\b/, 'trade_profiles RLS enable statement missing'); +assertSql(/\bcreate\s+policy\b[\s\S]*trade_profiles\b/, 'trade_profiles policy definition missing'); + +console.log(`[schema-contract] OK: validated ${schemaFiles.length} schema files`); diff --git a/backend/verifySecretHygiene.ts b/backend/verifySecretHygiene.ts new file mode 100644 index 0000000..8b5cbb3 --- /dev/null +++ b/backend/verifySecretHygiene.ts @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = __dirname; +const SCAN_ROOTS = ['src', 'scripts', 'schema', 'docs', '.github', 'package.json', 'tsconfig.json', '.gitleaks.toml'] as const; + +const TEXT_FILE_EXTENSIONS = new Set([ + '.ts', '.tsx', '.js', '.mjs', '.cjs', '.json', '.md', '.yml', '.yaml', '.toml', '.sql', '.txt' +]); + +const EXCLUDED_PATH_SEGMENTS = ['node_modules', 'dist', '.git', '.vite']; +const PLACEHOLDER_HINTS = ['your_', 'your-', 'example', 'changeme', 'placeholder', '', 'xxxx']; + +const SECRET_ASSIGNMENT_PATTERNS: Array<{ name: string; regex: RegExp }> = [ + { + name: 'env_secret_assignment', + regex: /\b(?:SUPABASE_(?:KEY|SERVICE_ROLE_KEY)|ALPACA_API_KEY|ALPACA_SECRET_KEY|REAL_ALPACA_API_KEY|REAL_ALPACA_SECRET_KEY|OPENAI_API_KEY|GEMINI_API_KEY|PERPLEXITY_API_KEY)\s*=\s*([^\s#'"]{12,})/gi + }, + { + name: 'openai_like_secret', + regex: /\bsk-[a-z0-9]{20,}\b/gi + }, + { + name: 'aws_access_key_like', + regex: /\bAKIA[0-9A-Z]{16}\b/g + } +]; + +function shouldScan(filePath: string): boolean { + const normalized = filePath.replace(/\\/g, '/'); + if (EXCLUDED_PATH_SEGMENTS.some((segment) => normalized.includes(`/${segment}/`) || normalized.startsWith(`${segment}/`))) { + return false; + } + + const ext = path.extname(filePath).toLowerCase(); + if (TEXT_FILE_EXTENSIONS.has(ext)) return true; + const base = path.basename(filePath).toLowerCase(); + return base.startsWith('.env'); +} + +function isLikelyPlaceholder(value: string): boolean { + const normalized = value.trim().toLowerCase(); + return PLACEHOLDER_HINTS.some((hint) => normalized.includes(hint)); +} + +function main() { + const files: string[] = []; + const walk = (dirPath: string) => { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + for (const entry of entries) { + const absolute = path.join(dirPath, entry.name); + const relative = path.relative(repoRoot, absolute); + if (!relative) continue; + if (!shouldScan(relative) && entry.isFile()) continue; + if (entry.isDirectory()) { + if (EXCLUDED_PATH_SEGMENTS.includes(entry.name)) continue; + walk(absolute); + } else { + if (shouldScan(relative)) { + files.push(relative); + } + } + } + }; + for (const rootEntry of SCAN_ROOTS) { + const absolutePath = path.join(repoRoot, rootEntry); + if (!fs.existsSync(absolutePath)) continue; + const stat = fs.statSync(absolutePath); + if (stat.isDirectory()) { + walk(absolutePath); + } else { + files.push(path.relative(repoRoot, absolutePath)); + } + } + + const findings: string[] = []; + + for (const relativePath of files) { + const absolutePath = path.join(repoRoot, relativePath); + let content = ''; + try { + content = fs.readFileSync(absolutePath, 'utf8'); + } catch { + continue; + } + + for (const pattern of SECRET_ASSIGNMENT_PATTERNS) { + let match: RegExpExecArray | null = null; + while ((match = pattern.regex.exec(content)) !== null) { + const rawValue = match[1] || match[0]; + if (isLikelyPlaceholder(rawValue)) continue; + findings.push(`[${pattern.name}] ${relativePath}: ${match[0].slice(0, 120)}`); + } + pattern.regex.lastIndex = 0; + } + } + + assert.equal( + findings.length, + 0, + `Potential plaintext secrets detected:\n${findings.join('\n')}\n\nRotate credentials and scrub artifacts before release.` + ); + + console.log(`[secret-hygiene] OK: scanned ${files.length} tracked text files, no plaintext secret patterns found`); +} + +main(); diff --git a/backend/verifySecurityGuards.ts b/backend/verifySecurityGuards.ts new file mode 100644 index 0000000..4e8ef23 --- /dev/null +++ b/backend/verifySecurityGuards.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ApiServer } from './src/services/apiServer.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = __dirname; + +async function verifyStaticGuards(): Promise { + const apiServerSource = fs.readFileSync(path.join(repoRoot, 'src/services/apiServer.ts'), 'utf8'); + const supabaseSource = fs.readFileSync(path.join(repoRoot, 'src/services/SupabaseService.ts'), 'utf8'); + + assert(/app\.post\('\/api\/trade',\s*this\.requireAuth/.test(apiServerSource), 'Missing auth guard on /api/trade'); + assert(/app\.post\('\/api\/close',\s*this\.requireAuth/.test(apiServerSource), 'Missing auth guard on /api/close'); + assert(/app\.post\('\/api\/chat',\s*this\.requireAuth/.test(apiServerSource), 'Missing auth guard on /api/chat'); + assert(/this\.io\.use\(async\s*\(socket,\s*next\)/.test(apiServerSource), 'Missing websocket auth middleware'); + assert(/Unauthorized:\s*missing token/.test(apiServerSource), 'Missing explicit websocket unauthorized path'); + assert(/SUPABASE_JWT_ISSUER/.test(supabaseSource), 'Missing JWT issuer check wiring'); + assert(/SUPABASE_JWT_AUDIENCE/.test(supabaseSource), 'Missing JWT audience check wiring'); + assert(/Invalid token issuer/.test(supabaseSource), 'Missing explicit invalid issuer rejection'); + assert(/Invalid token audience/.test(supabaseSource), 'Missing explicit invalid audience rejection'); +} + +async function verifyRuntimeGuards(): Promise { + const port = 5900 + Math.floor(Math.random() * 300); + const server = new ApiServer(port); + + try { + await new Promise((resolve) => setTimeout(resolve, 250)); + + const response = await fetch(`http://127.0.0.1:${port}/api/trade`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + symbol: 'BTC/USD', + side: 'buy', + qty: 0.01, + type: 'market' + }) + }); + + assert.equal(response.status, 401, `Expected 401 for unauthorized /api/trade, got ${response.status}`); + } finally { + await server.stop(); + } +} + +await verifyStaticGuards(); +await verifyRuntimeGuards(); +console.log('[security-guards] OK: static + unauthorized REST checks passed'); diff --git a/backend/verifyTenantIsolation.ts b/backend/verifyTenantIsolation.ts new file mode 100644 index 0000000..90e174e --- /dev/null +++ b/backend/verifyTenantIsolation.ts @@ -0,0 +1,253 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ApiServer } from './src/services/apiServer.js'; +import { supabaseService } from './src/services/SupabaseService.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = __dirname; + +async function verifyStaticIsolationGuards(): Promise { + const apiServerSource = fs.readFileSync(path.join(repoRoot, 'src/services/apiServer.ts'), 'utf8'); + + assert(!/this\.io\.emit\(/.test(apiServerSource), 'Global websocket broadcasts detected (this.io.emit).'); + assert(!/socket\.emit\('state',\s*this\.state\)/.test(apiServerSource), 'Global runtime state push detected on websocket connect.'); + assert(/private\s+getScopedState\s*\(/.test(apiServerSource), 'Scoped state projector missing.'); + assert(/private\s+emitToConnectedUsers/.test(apiServerSource), 'Tenant-scoped websocket emitter missing.'); +} + +async function verifyRuntimeIsolationGuards(): Promise { + const originalVerify = supabaseService.verifyAccessToken.bind(supabaseService); + const mockedVerify = async (token: string): Promise<{ userId: string | null; error?: string }> => { + if (token === 'token-user-a') return { userId: 'user-a' }; + if (token === 'token-user-b') return { userId: 'user-b' }; + return { userId: null, error: 'invalid token' }; + }; + (supabaseService as any).verifyAccessToken = mockedVerify; + const originalLoadSnapshot = supabaseService.loadLatestBotStateSnapshot.bind(supabaseService); + (supabaseService as any).loadLatestBotStateSnapshot = async () => null; + + const port = 6200 + Math.floor(Math.random() * 400); + const server = new ApiServer(port); + + try { + await new Promise((resolve) => setTimeout(resolve, 250)); + const runtimeState = server.getState(); + runtimeState.symbols = {}; + runtimeState.positions = []; + runtimeState.orders = []; + runtimeState.history = []; + runtimeState.alerts = []; + + server.registerManualTrader('profile-a', { getUserId: () => 'user-a' } as any); + server.registerManualTrader('profile-b', { getUserId: () => 'user-b' } as any); + + server.updatePositions([ + { + id: 'pos-a', + symbol: 'BTC/USD', + side: 'BUY', + size: 1, + entryPrice: 100, + currentPrice: 101, + stopLoss: 90, + takeProfit: 110, + unrealizedPnl: 1, + unrealizedPnlPercent: 1, + marketValue: 101, + userId: 'user-a', + profileId: 'profile-a', + profileName: 'Profile A', + tradeId: 'TRD-A-1' + } + ], 'profile-a'); + + server.updatePositions([ + { + id: 'pos-b', + symbol: 'BTC/USD', + side: 'BUY', + size: 2, + entryPrice: 100, + currentPrice: 102, + stopLoss: 90, + takeProfit: 110, + unrealizedPnl: 4, + unrealizedPnlPercent: 2, + marketValue: 204, + userId: 'user-b', + profileId: 'profile-b', + profileName: 'Profile B', + tradeId: 'TRD-B-1' + } + ], 'profile-b'); + + server.updateOrders([ + { + id: 'ord-a', + symbol: 'BTC/USD', + type: 'Market', + side: 'BUY', + qty: 1, + price: 100, + status: 'filled', + timestamp: Date.now(), + profileId: 'profile-a', + userId: 'user-a', + trade_id: 'TRD-A-1', + action: 'ENTRY', + source: 'BOT' + } + ], 'profile-a'); + + server.updateOrders([ + { + id: 'ord-b', + symbol: 'BTC/USD', + type: 'Market', + side: 'BUY', + qty: 2, + price: 100, + status: 'filled', + timestamp: Date.now(), + profileId: 'profile-b', + userId: 'user-b', + trade_id: 'TRD-B-1', + action: 'ENTRY', + source: 'BOT' + } + ], 'profile-b'); + + server.addHistory({ + symbol: 'BTC/USD', + side: 'BUY', + entryPrice: 100, + exitPrice: 105, + size: 1, + pnl: 5, + pnlPercent: 5, + reason: 'target', + timestamp: Date.now(), + userId: 'user-a', + profileId: 'profile-a', + trade_id: 'TRD-A-1', + source: 'BOT' + }); + + server.addHistory({ + symbol: 'BTC/USD', + side: 'BUY', + entryPrice: 100, + exitPrice: 95, + size: 1, + pnl: -5, + pnlPercent: -5, + reason: 'stop', + timestamp: Date.now(), + userId: 'user-b', + profileId: 'profile-b', + trade_id: 'TRD-B-1', + source: 'BOT' + }); + + server.addAlert('info', 'BTC/USD', 'alert-a', { userId: 'user-a', profileId: 'profile-a' }); + server.addAlert('info', 'BTC/USD', 'alert-b', { userId: 'user-b', profileId: 'profile-b' }); + + server.updateSymbol('BTC/USD', { + price: 101, + change24h: 0.5, + changeToday: 0.2, + session: 'NY', + volatility: 'Low', + signal: 'BUY', + profileSignals: { + 'profile-a': { + profileName: 'Profile A', + signal: 'BUY', + passed: true, + reason: 'ok', + rules: {} + }, + 'profile-b': { + profileName: 'Profile B', + signal: 'SELL', + passed: true, + reason: 'ok', + rules: {} + } + }, + activePosition: { + side: 'BUY', + entryPrice: 100, + size: 2, + stopLoss: 90, + takeProfit: 110, + userId: 'user-b', + profileId: 'profile-b', + tradeId: 'TRD-B-1', + profileName: 'Profile B' + }, + indicators: { + ema20_1h: 99 + }, + rules: {} + }); + + const readStatus = async (token: string): Promise => { + const response = await fetch(`http://127.0.0.1:${port}/api/state`, { + headers: { Authorization: `Bearer ${token}` } + }); + assert.equal(response.status, 200, `Expected /api/state 200 for token ${token}`); + return await response.json(); + }; + + const userAState = await readStatus('token-user-a'); + const userBState = await readStatus('token-user-b'); + + assert.equal(userAState.positions.length, 1, 'User A should only receive one owned position.'); + assert.equal(userAState.positions[0].profileId, 'profile-a', 'User A received foreign position.'); + assert.equal(userAState.orders.length, 1, 'User A should only receive one owned order.'); + assert.equal(userAState.orders[0].profileId, 'profile-a', 'User A received foreign order.'); + assert.equal(userAState.history.length, 1, 'User A should only receive one owned history row.'); + assert.equal(userAState.history[0].profileId, 'profile-a', 'User A received foreign history row.'); + assert.equal(userAState.alerts.length, 1, 'User A should only receive one owned alert.'); + assert.equal(userAState.alerts[0].profileId, 'profile-a', 'User A received foreign alert.'); + assert.deepEqual(Object.keys(userAState.symbols['BTC/USD'].profileSignals || {}), ['profile-a'], 'User A received foreign profile signal.'); + assert.equal(userAState.symbols['BTC/USD'].activePosition, null, 'User A should not receive User B active symbol position.'); + + assert.equal(userBState.positions.length, 1, 'User B should only receive one owned position.'); + assert.equal(userBState.positions[0].profileId, 'profile-b', 'User B received foreign position.'); + assert.equal(userBState.orders.length, 1, 'User B should only receive one owned order.'); + assert.equal(userBState.orders[0].profileId, 'profile-b', 'User B received foreign order.'); + assert.equal(userBState.history.length, 1, 'User B should only receive one owned history row.'); + assert.equal(userBState.history[0].profileId, 'profile-b', 'User B received foreign history row.'); + assert.equal(userBState.alerts.length, 1, 'User B should only receive one owned alert.'); + assert.equal(userBState.alerts[0].profileId, 'profile-b', 'User B received foreign alert.'); + assert.deepEqual(Object.keys(userBState.symbols['BTC/USD'].profileSignals || {}), ['profile-b'], 'User B received foreign profile signal.'); + assert.equal(userBState.symbols['BTC/USD'].activePosition?.profileId, 'profile-b', 'User B should receive owned active symbol position.'); + + const symbolResponseA = await fetch(`http://127.0.0.1:${port}/api/symbol/BTC%2FUSD`, { + headers: { Authorization: 'Bearer token-user-a' } + }); + assert.equal(symbolResponseA.status, 200, 'User A symbol endpoint should be reachable.'); + const symbolStateA = await symbolResponseA.json(); + assert.deepEqual(Object.keys(symbolStateA.profileSignals || {}), ['profile-a'], 'Symbol endpoint leaked foreign profile signal to User A.'); + + const symbolResponseB = await fetch(`http://127.0.0.1:${port}/api/symbol/BTC%2FUSD`, { + headers: { Authorization: 'Bearer token-user-b' } + }); + assert.equal(symbolResponseB.status, 200, 'User B symbol endpoint should be reachable.'); + const symbolStateB = await symbolResponseB.json(); + assert.deepEqual(Object.keys(symbolStateB.profileSignals || {}), ['profile-b'], 'Symbol endpoint leaked foreign profile signal to User B.'); + } finally { + await server.stop(); + (supabaseService as any).verifyAccessToken = originalVerify; + (supabaseService as any).loadLatestBotStateSnapshot = originalLoadSnapshot; + } +} + +await verifyStaticIsolationGuards(); +await verifyRuntimeIsolationGuards(); +console.log('[tenant-isolation] OK: tenant-scoped runtime state guards passed'); diff --git a/backend/verify_btc_logic.ts b/backend/verify_btc_logic.ts new file mode 100644 index 0000000..1dfa2dc --- /dev/null +++ b/backend/verify_btc_logic.ts @@ -0,0 +1,55 @@ + +import { ConnectorFactory } from '../src/connectors/factory.js'; +import { config } from '../src/config/index.js'; +import { Indicators } from '../src/utils/indicators.js'; +import logger from '../src/utils/logger.js'; + +async function verifyBTC() { + const exchange = ConnectorFactory.getCustomConnector('ccxt', '', ''); // Using CCXT (Binance/Kraken) for data + const symbol = 'BTC/USDT'; + + console.log(`\n--- 📊 BTC ANALYSIS VERIFICATION (Last 4 Hours) ---`); + + // 1. Fetch 4H Data for Trend Bias + const candles4h = await exchange.fetchOHLCV(symbol, '4h', 100); + const ema50_4h = Indicators.calculateEMA(candles4h.map(c => c.close), 50); + const ema200_4h = Indicators.calculateEMA(candles4h.map(c => c.close), 200); + const last4h = candles4h[candles4h.length - 1]; + + console.log(`4H Context:`); + console.log(` Current Price: $${last4h.close}`); + console.log(` EMA50 (4H): $${ema50_4h.toFixed(2)}`); + console.log(` EMA200 (4H): $${ema200_4h.toFixed(2)}`); + console.log(` Trend Bias: ${last4h.close > ema50_4h ? 'BULLISH' : 'BEARISH'} (Price vs EMA50)`); + + // 2. Fetch 1H Data for Momentum + const candles1h = await exchange.fetchOHLCV(symbol, '1h', 50); + const rsi1h = Indicators.calculateRSI(candles1h.map(c => c.close), 14); + const ema20_1h = Indicators.calculateEMA(candles1h.map(c => c.close), 20); + + console.log(`\n1H Context:`); + console.log(` RSI (1H): ${rsi1h.toFixed(2)}`); + console.log(` EMA20 (1H): $${ema20_1h.toFixed(2)}`); + console.log(` Momentum: ${rsi1h > 50 ? 'BULLISH' : 'BEARISH'}`); + + // 3. Performance Check (Last 2 Hours) + const candles15m = await exchange.fetchOHLCV(symbol, '15m', 12); + const startPrice = candles15m[0].open; + const endPrice = candles15m[candles15m.length - 1].close; + const change = ((endPrice - startPrice) / startPrice) * 100; + + console.log(`\nRecent Performance (Last 3 Hours - 15m candles):`); + console.log(` Opening: $${startPrice.toFixed(2)}`); + console.log(` Closing: $${endPrice.toFixed(2)}`); + console.log(` Change: ${change.toFixed(2)}%`); + + console.log(`\n--- BOT LOGIC CHECK ---`); + if (last4h.close < ema50_4h) { + console.log(`⚠️ BOT IS BEARISH because Price ($${last4h.close}) is BELOW EMA50 ($${ema50_4h.toFixed(2)}) on 4H.`); + console.log(` Even if the last 2 hours were up, the "Higher Timeframe Trend" is still down.`); + } else { + console.log(`✅ BOT SHOULD BE BULLISH.`); + } +} + +verifyBTC(); diff --git a/backend/verify_dynamic_config.ts b/backend/verify_dynamic_config.ts new file mode 100644 index 0000000..1c7fdd6 --- /dev/null +++ b/backend/verify_dynamic_config.ts @@ -0,0 +1,29 @@ +import { supabaseService } from '../src/services/SupabaseService.js'; +import { loadDynamicConfig, config } from '../src/config/index.js'; +import logger from '../src/utils/logger.js'; + +async function testDynamicConfigLoading() { + logger.info('--- Testing Dynamic Config Loading from Supabase ---'); + + // 1. Initial State (from .env) + const initialSymbols = [...config.SYMBOLS]; + logger.info(`Initial Symbols (.env): ${initialSymbols.join(', ')}`); + + // 2. Load from DB + await loadDynamicConfig(supabaseService); + + // 3. Final State + logger.info(`Final Symbols (After DB Load): ${config.SYMBOLS.join(', ')}`); + + if (JSON.stringify(initialSymbols) !== JSON.stringify(config.SYMBOLS)) { + logger.info('✅ SUCCESS: Dynamic config successfully overwrote .env defaults!'); + } else { + logger.info('ℹ️ INFO: config.SYMBOLS remained the same. This is normal if DB matches .env or if DB was empty.'); + } + + // Check other keys if present + logger.info(`Execution Provider: ${config.EXECUTION_PROVIDER}`); + logger.info(`Total Capital: ${config.TOTAL_CAPITAL}`); +} + +testDynamicConfigLoading().catch(console.error); diff --git a/backend/verify_e2e_fix.ts b/backend/verify_e2e_fix.ts new file mode 100644 index 0000000..9da3d9d --- /dev/null +++ b/backend/verify_e2e_fix.ts @@ -0,0 +1,156 @@ + +import { supabaseService } from '../src/services/SupabaseService.js'; +import { AutoTrader } from '../src/services/AutoTrader.js'; +import { TradeExecutor } from '../src/services/TradeExecutor.js'; +import { AlpacaConnector } from '../src/connectors/alpaca.js'; +import { config } from '../src/config/index.js'; +import { SignalDirection, MarketContext, RuleResult, StrategyAnalysisResult } from '../src/strategies/rules/types.js'; +import logger from '../src/utils/logger.js'; + +// Setup environment and global config overrides for testing +config.ENABLE_TRADING = true; +// Force Paper Trading for safety +config.PAPER_TRADING = true; + +const TEST_SYMBOL = 'BTC/USD'; // Alpaca Paper usually supports this for crypto +const SLEEP_MS = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +async function runFullE2ETest() { + logger.info('==================================================='); + logger.info('🚀 STARTING END-TO-END BACKEND SYSTEM TEST (FINAL VERIFICATION)'); + logger.info(' Scope: Configs -> Signal -> Order -> Position -> DB -> Exit -> PnL'); + logger.info(' Goal: Confirm DB Logging uses correct lowercase syntax.'); + logger.info('==================================================='); + + // 1. Load Profiles (Simulating "Multiple Configurations") + logger.info('\n📡 1. Loading Configurations from Database...'); + const profiles = await supabaseService.getActiveProfiles(); + + if (profiles.length === 0) { + logger.error('❌ No active profiles found in DB. Please create a Strategy Cluster in the Dashboard first.'); + process.exit(1); + } + logger.info(`✅ Found ${profiles.length} Active Profiles.`); + + // 2. Iterate Profiles and Execute Test Cycle + for (const profile of profiles) { + logger.info(`\n---------------------------------------------------`); + logger.info(`👤 Testing Profile: [${profile.name}] (ID: ${profile.id})`); + logger.info(` Allocated Capital: $${profile.allocated_capital}`); + + const user = profile.users; + if (!user) { + logger.warn(' ⚠️ User data missing for this profile. Skipping.'); + continue; + } + + // Initialize Services for this specific User/Profile + const userKey = config.PAPER_TRADING ? user.ALPACA_API_KEY : user.REAL_ALPACA_API_KEY; + const userSecret = config.PAPER_TRADING ? user.ALPACA_SECRET_KEY : user.REAL_ALPACA_SECRET_KEY; + + if (!userKey || !userSecret) { + logger.warn(' ⚠️ Alpaca Credentials missing. Skipping.'); + continue; + } + + const exchange = new AlpacaConnector(userKey, userSecret); + const executor = new TradeExecutor(exchange, undefined, user.user_id, profile.id); + const trader = new AutoTrader(executor, exchange, profile); + + // SYNC: Ensure we start clean + logger.info(' 🔄 Syncing existing positions...'); + await executor.syncPositions([TEST_SYMBOL]); + + // Close any existing position on Test Symbol to ensure clean test + const existingPos = executor.getActivePosition(TEST_SYMBOL); + if (existingPos) { + logger.info(` 🧹 Closing pre-existing position on ${TEST_SYMBOL}...`); + await executor.closePosition(TEST_SYMBOL, 'E2E PRE-CLEANUP'); + await SLEEP_MS(5000); + } + + // 3. Simulate Market Context + const currentPrice = 65000; + const mockContext: MarketContext = { + symbol: TEST_SYMBOL, + currentPrice: currentPrice, + candles1h: [], candles15m: [], candles4h: [], + rsi_1h: 30, // Oversold + ema20_1h: 64000, + change24h: 1.5, + changeToday: 0.5, + volatility: 'Low', + session: 'NY', + isMajorSession: true, + latestSignal: SignalDirection.NONE + }; + + // 4. Simulate BUY Signal + logger.info(' 🟢 Simulating BUY Signal (Trend + Momentum)...'); + + const ruleResults: Record = {}; + const rulesToMock = ['TrendBiasRule', 'MomentumRule', 'ZoneRule', 'RiskManagementRule']; + rulesToMock.forEach(rId => { + ruleResults[rId] = { ruleName: rId, passed: true, signal: SignalDirection.BUY, reason: 'Test Pass', metadata: {} }; + }); + + const buyAnalysis: StrategyAnalysisResult = { + symbol: TEST_SYMBOL, + globalSignal: SignalDirection.BUY, + rules: ruleResults, + context: mockContext + }; + + await trader.handleSignal(TEST_SYMBOL, buyAnalysis); + + // 5. Verify Order & Position Creation + logger.info(' ⏳ Waiting for Order Fill & DB Sync (10s)...'); + await SLEEP_MS(10000); + + const activePos = executor.getActivePosition(TEST_SYMBOL); + if (!activePos) { + logger.error(` ❌ FAIL: Position not created for ${profile.name}`); + continue; + } + + logger.info(` ✅ SUCCESS: Position Created! Qty: ${activePos.size}`); + + // 6. Simulate Profit Update (Price Move Up) + logger.info(' 📈 Simulating Price Move (+10%)...'); + const newPrice = currentPrice * 1.10; + mockContext.currentPrice = newPrice; + + // 7. Simulate SELL/EXIT Signal + logger.info(' 🔴 Simulating SELL/EXIT Signal (Trend Reversal)...'); + const sellAnalysis: StrategyAnalysisResult = { + symbol: TEST_SYMBOL, + globalSignal: SignalDirection.SELL, // Reversal triggers exit + rules: ruleResults, + context: mockContext + }; + + await trader.handleSignal(TEST_SYMBOL, sellAnalysis); + + // 8. Verify Exit & PnL Realization + logger.info(' ⏳ Waiting for Close & Profit Realization (10s)...'); + await SLEEP_MS(10000); + + const closedPos = executor.getActivePosition(TEST_SYMBOL); + if (!closedPos) { + logger.info(` ✅ SUCCESS: Position Closed.`); + logger.info(` ✅ CHECK LOGS: Ensure no '[Supabase] Error' messages appeared above.`); + } else { + logger.error(` ❌ FAIL: Position still active!`); + } + } + + logger.info('\n==================================================='); + logger.info('✅ FINAL E2E CHECK COMPLETED'); + logger.info('==================================================='); + process.exit(0); +} + +runFullE2ETest().catch(err => { + logger.error('CRITICAL TEST FAILURE:', err); + process.exit(1); +}); diff --git a/backend/verify_fetch.ts b/backend/verify_fetch.ts new file mode 100644 index 0000000..723a99e --- /dev/null +++ b/backend/verify_fetch.ts @@ -0,0 +1,26 @@ +import { supabaseService } from '../src/services/SupabaseService.js'; +import logger from '../src/utils/logger.js'; + +async function testFetch() { + logger.info('--- Testing Data Retrieval ---'); + const userId = '88a2446e-740f-4c87-a94c-fad0ee5167ba'; + + // 1. Check Orders + // We don't have a public method to fetch ALL orders in SupabaseService, so let's use the internal client if possible + // or just assume if we can log we can read. + // Actually SupabaseService has `getLatestOrder`. + + logger.info(`Fetching latest order for ${userId} (BTC/USDT)...`); + const order = await supabaseService.getLatestOrder(userId, 'BTC/USDT'); + + if (order) { + logger.info('✅ FOUND ORDER in DB:', order); + } else { + logger.error('❌ NO ORDER FOUND in DB for this user/symbol.'); + } + + // 2. We can't easily fetch trade_history via the service wrapper as it doesn't expose a getter for history. + // But if orders exist, history likely does too. +} + +testFetch().catch(console.error); diff --git a/backend/verify_final_e2e.ts b/backend/verify_final_e2e.ts new file mode 100644 index 0000000..a37c257 --- /dev/null +++ b/backend/verify_final_e2e.ts @@ -0,0 +1,62 @@ +import axios from 'axios'; + +async function finalVerify() { + try { + console.log("🔍 COMPREHENSIVE SYSTEM VERIFICATION 🔍"); + + // 1. Check API Heartbeat + const statusRes = await axios.get('http://localhost:5000/api/status'); + const state = statusRes.data; + console.log(`✅ Backend API: Active (Uptime: ${(state.uptime / 60000).toFixed(2)} mins)`); + + // 2. Verify Symbols & Rule Engines + const symbols = Object.keys(state.symbols); + console.log(`✅ Monitored Symbols: ${symbols.join(', ')}`); + + symbols.forEach(s => { + const sym = state.symbols[s]; + const rulesPassed = Object.values(sym.rules).filter((r: any) => r.passed).length; + const rulesTotal = Object.keys(sym.rules).length; + console.log(` 🔸 ${s}: Rules ${rulesPassed}/${rulesTotal} Passing | Last Signal: ${sym.signal}`); + }); + + // 3. Verify Profiles & Global Config + console.log('\n--- 🛠 INFRASTRUCTURE CHECK ---'); + try { + const configRes = await axios.get('http://localhost:5000/api/config'); + console.log(`✅ Config Engine: Providing synchronized parameters to all clusters.`); + console.log(` Master Symbols: ${configRes.data.SYMBOLS.join(', ')}`); + } catch (e) { + console.log("❌ Config Endpoint unreachable."); + } + + // 4. Signal Alert Propagation + console.log('\n--- 📡 SIGNAL PROPAGATION ---'); + if (state.alerts.length > 0) { + const latestSignal = state.alerts.filter((a: any) => a.type === 'signal').pop(); + if (latestSignal) { + console.log(`✅ Recent Signal Found: ${latestSignal.symbol} at ${new Date(latestSignal.timestamp).toLocaleTimeString()}`); + console.log(` Message snippet: ${latestSignal.message.substring(0, 50)}...`); + } else { + console.log("ℹ️ No recent 'BUY/SELL' signals detected in the current buffer."); + } + } + + console.log('\n--- 💰 ACTIVE AUTO-TRADING ---'); + if (state.positions.length > 0) { + console.log(`🔥 ALERT: Active Auto-Trades detected!`); + state.positions.forEach((p: any) => { + console.log(` 📍 [${p.symbol}] ${p.side} | Profile: ${p.profileId || 'System'} | PnL: ${p.unrealizedPnlPercent?.toFixed(2)}%`); + }); + } else { + console.log("ℹ️ Engine IDLE: Waiting for 100% rule alignment for execution."); + } + + console.log("\n🚀 VERIFICATION COMPLETE: System architecture is healthy and reactive."); + + } catch (err: any) { + console.error('❌ CRITICAL: Failed to communicate with Bot Engine.', err.message); + } +} + +finalVerify(); diff --git a/backend/verify_full_lifecycle.ts b/backend/verify_full_lifecycle.ts new file mode 100644 index 0000000..9967c00 --- /dev/null +++ b/backend/verify_full_lifecycle.ts @@ -0,0 +1,53 @@ +import { config } from '../src/config/index.js'; +import { AlpacaConnector } from '../src/connectors/alpaca.js'; +import { TradeExecutor } from '../src/services/TradeExecutor.js'; +import logger from '../src/utils/logger.js'; + +async function testFullLifeCycle() { + logger.info('--- Starting FINAL E2E Trade Life Cycle Verification ---'); + + // Use your specific user ID + const userId = '8d5efd9e-0760-4859-8c07-0930ab3ede5a'; + const symbol = 'BTC/USD'; + const qty = 0.001; + + const exchange = new AlpacaConnector(config.ALPACA_API_KEY, config.ALPACA_API_SECRET); + const executor = new TradeExecutor(exchange, undefined, userId); + + try { + // 1. OPEN POSITION + logger.info(`[Step 1] Opening Position for ${userId}...`); + const openResult = await executor.openPosition(symbol, 'BUY' as any, qty, 'market'); + + if (!openResult.success) { + throw new Error(`Open failed: ${openResult.error}`); + } + logger.info(`✅ Open Processed: ${openResult.orderId}`); + + // Wait a bit for fill and status + logger.info('Waiting 5 seconds for fill confirmation...'); + await new Promise(r => setTimeout(r, 5000)); + + // 2. CLOSE POSITION + logger.info(`[Step 2] Closing Position for ${userId}...`); + const closeResult = await executor.closePosition(symbol, 'E2E_VERIFICATION_COMPLETE'); + + if (!closeResult.success) { + throw new Error(`Close failed: ${closeResult.error}`); + } + logger.info(`✅ Close Processed: Exit Price ${closeResult.exitPrice}`); + + // 3. FINAL WAIT FOR LOGGING + logger.info('Waiting 10 seconds for all Supabase async logs to finish...'); + await new Promise(r => setTimeout(r, 10000)); + + logger.info('--- E2E TEST COMPLETED ---'); + logger.info('Please check your Dashboard -> Trade History Tab.'); + logger.info(`Looking for entry: Symbol=${symbol}, Reason=E2E_VERIFICATION_COMPLETE`); + + } catch (error: any) { + logger.error(`❌ TEST FAILED: ${error.message}`); + } +} + +testFullLifeCycle().catch(console.error); diff --git a/backend/verify_order_logging.ts b/backend/verify_order_logging.ts new file mode 100644 index 0000000..7466939 --- /dev/null +++ b/backend/verify_order_logging.ts @@ -0,0 +1,37 @@ +import { config } from '../src/config/index.js'; +import { AlpacaConnector } from '../src/connectors/alpaca.js'; +import { TradeExecutor } from '../src/services/TradeExecutor.js'; +import logger from '../src/utils/logger.js'; + +async function testLogging() { + logger.info('--- Starting Order Logging Verification ---'); + + const exchange = new AlpacaConnector(config.ALPACA_API_KEY, config.ALPACA_API_SECRET); + const executor = new TradeExecutor(exchange, undefined, 'test-user'); + + const symbol = 'BTC/USDT'; + + // 1. Test Open + logger.info('[Test] Opening Position...'); + const openRes = await executor.openPosition(symbol, 'BUY' as any, 0.001, 'market'); + + if (openRes.success) { + logger.info('✅ Open logged.'); + + // 2. Test Close + logger.info('[Test] Closing Position...'); + const closeRes = await executor.closePosition(symbol, 'Verification Test'); + + if (closeRes.success) { + logger.info('✅ Close logged.'); + logger.info('--- Verification Complete ---'); + logger.info('Please check the logs to see if two "Logged order to DB" messages appeared.'); + } else { + logger.error('❌ Close failed:', closeRes.error); + } + } else { + logger.error('❌ Open failed:', openRes.error); + } +} + +testLogging().catch(console.error); diff --git a/backend/verify_profiles.ts b/backend/verify_profiles.ts new file mode 100644 index 0000000..080e6b7 --- /dev/null +++ b/backend/verify_profiles.ts @@ -0,0 +1,10 @@ + +import { supabaseService } from '../src/services/SupabaseService.js'; + +async function check() { + const { data } = await supabaseService.client.from('trade_profiles').select('name, risk_per_trade_percent'); + console.log('--- CURRENT PROFILES ---'); + console.log(JSON.stringify(data, null, 2)); + process.exit(0); +} +check(); diff --git a/backend/verify_profiles_e2e.ts b/backend/verify_profiles_e2e.ts new file mode 100644 index 0000000..3b748c2 --- /dev/null +++ b/backend/verify_profiles_e2e.ts @@ -0,0 +1,69 @@ +import { supabaseService } from '../src/services/SupabaseService.js'; +import { AutoTrader } from '../src/services/AutoTrader.js'; +import { TradeExecutor } from '../src/services/TradeExecutor.js'; +import { AlpacaConnector } from '../src/connectors/alpaca.js'; +import { config } from '../src/config/index.js'; +import { SignalDirection, MarketContext, RuleResult } from '../src/strategies/rules/types.js'; +import logger from '../src/utils/logger.js'; + +async function testProfileExecution() { + logger.info('--- PROFILE-BASED E2E TEST STARTING ---'); + + const profiles = await supabaseService.getActiveProfiles(); + if (profiles.length === 0) { + logger.error('No profiles found.'); return; + } + + const testProfile = profiles[0]; + const user = testProfile.users; + const symbols = testProfile.symbols ? testProfile.symbols.split(',').map(s => s.trim()).filter(s => s !== "") : ['BTC/USDT']; + const testSymbol = symbols[0]; + + logger.info(`Profile: ${testProfile.name} | Symbol: ${testSymbol} | Capital: ${testProfile.allocated_capital}`); + + const userKey = config.PAPER_TRADING ? user.ALPACA_API_KEY : user.REAL_ALPACA_API_KEY; + const userSecret = config.PAPER_TRADING ? user.ALPACA_SECRET_KEY : user.REAL_ALPACA_SECRET_KEY; + const exchange = new AlpacaConnector(userKey, userSecret); + const executor = new TradeExecutor(exchange, undefined, user.user_id, testProfile.id); + + // IMPORTANT: Inject global config for trading enabled + config.ENABLE_TRADING = true; + + const trader = new AutoTrader( + executor, + exchange, + parseFloat(testProfile.allocated_capital.toString()), + parseFloat(testProfile.risk_per_trade_percent?.toString() || '1'), + symbols + ); + + const mockResult: RuleResult = { + ruleName: 'TEST', passed: true, signal: SignalDirection.BUY, reason: 'E2E_TEST', metadata: {} + }; + + const mockContext: MarketContext = { + symbol: testSymbol, currentPrice: 50000, candles1h: [], candles15m: [], candles4h: [], + rsi_1h: 40, ema20_1h: 49000, change24h: 2, changeToday: 1, volatility: 'Low', session: 'NY', + isMajorSession: true, latestSignal: SignalDirection.NONE + }; + + logger.info('Simulating Signal...'); + await trader.handleSignal(testSymbol, mockResult, mockContext); + + const pos = executor.getActivePosition(testSymbol); + if (pos) { + logger.info(`✅ SUCCESS: Position found! ProfileID: ${pos.profileId} | Qty: ${pos.size}`); + await executor.closePosition(testSymbol, 'E2E CLEANUP'); + } else { + logger.error('❌ FAILED: Position not created. Checking why...'); + // Manually try openPosition to see exchange error + const direct = await executor.openPosition(testSymbol, SignalDirection.BUY, 0.001); + if (!direct.success) { + logger.error(`Direct Open Error: ${direct.error}`); + } else { + logger.info('Direct open worked. Something is wrong in AutoTrader logic.'); + } + } +} + +testProfileExecution().catch(console.error); diff --git a/backend/verify_realtime.ts b/backend/verify_realtime.ts new file mode 100644 index 0000000..eccb505 --- /dev/null +++ b/backend/verify_realtime.ts @@ -0,0 +1,56 @@ +import { supabaseService } from '../src/services/SupabaseService'; +import { createClient } from '@supabase/supabase-js'; +import * as dotenv from 'dotenv'; +import logger from '../src/utils/logger'; +dotenv.config(); + +const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_KEY!); + +async function verify() { + console.log("Starting Realtime Verification Test..."); + + let eventReceived = false; + + // 1. Setup Listener + const subscription = supabaseService.subscribeToProfiles((payload) => { + console.log("🔥 REALTIME EVENT RECEIVED:", payload.eventType); + eventReceived = true; + }); + + if (!subscription) { + console.error("Failed to subscribe"); + return; + } + + console.log("Waiting for subscription to settle..."); + await new Promise(r => setTimeout(r, 2000)); + + // 2. Find User + const { data: users } = await supabase.from('users').select('user_id').limit(1); + const userId = users![0].user_id; + + // 3. Trigger Event + console.log("Triggering INSERT..."); + const testName = `VERIFY_SIGNAL_${Date.now()}`; + await supabase.from('trade_profiles').insert([{ + user_id: userId, + name: testName, + allocated_capital: 1, + is_active: false + }]); + + // 4. Wait & Check + await new Promise(r => setTimeout(r, 5000)); + + if (eventReceived) { + console.log("✅ SUCCESS: Realtime listener caught the database event."); + } else { + console.error("❌ FAILURE: Realtime listener did not trigger."); + } + + // Cleanup + await supabase.from('trade_profiles').delete().eq('name', testName); + process.exit(eventReceived ? 0 : 1); +} + +verify(); diff --git a/backend/verify_signals_live.ts b/backend/verify_signals_live.ts new file mode 100644 index 0000000..57c48d3 --- /dev/null +++ b/backend/verify_signals_live.ts @@ -0,0 +1,31 @@ +import axios from 'axios'; + +async function verify() { + try { + const res = await axios.get('http://localhost:5000/api/status'); + const state = res.data; + + console.log('\n--- 🎯 SYMBOLS & SIGNALS ---'); + Object.entries(state.symbols).forEach(([symbol, data]: [string, any]) => { + console.log(`${symbol}: Signal=[${data.signal}] Price=${data.price}`); + Object.entries(data.rules).forEach(([rule, res]: [string, any]) => { + if (res.passed) console.log(` ✅ ${rule}: ${res.reason}`); + else console.log(` ❌ ${rule}: ${res.reason}`); + }); + }); + + console.log('\n--- 💼 ACTIVE POSITIONS ---'); + state.positions.forEach((pos: any) => { + console.log(`[${pos.symbol}] ${pos.side} Size:${pos.size} PnL:${pos.unrealizedPnl} Profile:${pos.profileId}`); + }); + + if (state.positions.length === 0) { + console.log('No active positions found.'); + } + + } catch (err: any) { + console.error('Failed to connect to bot:', err.message); + } +} + +verify(); diff --git a/backend/verify_sl_tp_persistence.ts b/backend/verify_sl_tp_persistence.ts new file mode 100644 index 0000000..c6f0311 --- /dev/null +++ b/backend/verify_sl_tp_persistence.ts @@ -0,0 +1,83 @@ +import * as dotenv from 'dotenv'; +dotenv.config(); +import { ConnectorFactory } from '../src/connectors/factory.js'; +import { TradeExecutor } from '../src/services/TradeExecutor.js'; +import { ManualTrader } from '../src/services/ManualTrader.js'; +import { supabaseService } from '../src/services/SupabaseService.js'; +import { config } from '../src/config/index.js'; +import { SignalDirection } from '../src/strategies/rules/types.js'; +import logger from '../src/utils/logger.js'; + +async function verifyFullCycleWithSL() { + console.log("--- 🚀 STARTING FULL CYCLE VERIFICATION (SL/TP PERSISTENCE) ---"); + + const testUserId = "550e8400-e29b-41d4-a716-446655440000"; // Fake UUID for test + const symbol = "BTC/USDT"; + const qty = 0.0002; // Smallest safe qty for Alpaca + const sl = 80000; + const tp = 110000; + + // 1. Setup + const exchange = ConnectorFactory.getCustomConnector( + config.EXECUTION_PROVIDER, + config.ALPACA_API_KEY!, + config.ALPACA_API_SECRET! + ); + const executor = new TradeExecutor(exchange, undefined, 'global'); + const manualTrader = new ManualTrader(executor); + + console.log(`\nSTEP 1: Opening Manual Position for ${symbol}...`); + const openResult = await manualTrader.executeRequest( + symbol, + 'buy', + qty, + 'market', + undefined, + testUserId, + sl, + tp + ); + + if (!openResult.success) { + console.error("❌ Open Failed:", openResult.error); + return; + } + console.log("✅ Position Opened. Order ID:", openResult.orderId); + + // 2. Verify persistence in DB + console.log("\nSTEP 2: Verifying SL/TP Persistence in Supabase..."); + // Artificial delay for DB write + await new Promise(r => setTimeout(r, 2000)); + + const latestOrder = await supabaseService.getLatestOrder(testUserId, symbol); + if (latestOrder && latestOrder.stop_loss === sl && latestOrder.take_profit === tp) { + console.log(`✅ SL/TP VERIFIED in DB: SL=${latestOrder.stop_loss}, TP=${latestOrder.take_profit}`); + } else { + console.error("❌ DB Verification Failed!", latestOrder); + } + + // 3. Verify Recovery (Simulate Restart) + console.log("\nSTEP 3: Simulating Bot Restart (Recovery from DB)..."); + const newExecutor = new TradeExecutor(exchange, undefined, testUserId); + await newExecutor.syncPositions([symbol]); + + const recoveredPos = newExecutor.getActivePosition(symbol); + if (recoveredPos && recoveredPos.stopLoss === sl && recoveredPos.userId === testUserId) { + console.log(`✅ RECOVERY VERIFIED: Recovered SL=${recoveredPos.stopLoss} for User=${recoveredPos.userId}`); + } else { + console.error("❌ Recovery Failed!", recoveredPos); + } + + // 4. Close Position + console.log("\nSTEP 4: Closing Position..."); + const closeResult = await newExecutor.closePosition(symbol, "Test Verification Close"); + if (closeResult.success) { + console.log("✅ Position Closed."); + } else { + console.error("❌ Close Failed:", closeResult.error); + } + + console.log("\n--- ✨ VERIFICATION COMPLETE ---"); +} + +verifyFullCycleWithSL().catch(console.error); diff --git a/backend/verify_sync.ts b/backend/verify_sync.ts new file mode 100644 index 0000000..cbd9792 --- /dev/null +++ b/backend/verify_sync.ts @@ -0,0 +1,43 @@ +import { config } from '../src/config/index.js'; +import { AlpacaConnector } from '../src/connectors/alpaca.js'; +import { SymbolMapper } from '../src/utils/symbolMapper.js'; +import logger from '../src/utils/logger.js'; + +async function verifySync() { + logger.info('--- Starting Sync Verification ---'); + + // 1. Setup Connector + const exchange = new AlpacaConnector(config.ALPACA_API_KEY, config.ALPACA_API_SECRET); + + // 2. Test Symbol + const inputSymbol = 'BTC/USDT'; + const tradeSymbol = SymbolMapper.toTradeSymbol(inputSymbol, 'alpaca'); + logger.info(`Mapped ${inputSymbol} -> ${tradeSymbol}`); + + // 3. Fetch Position (Test 1: Mapped) + try { + const position = await exchange.getPosition(tradeSymbol); + if (position) { + logger.info(`✅ Position Found for ${tradeSymbol}:`, position); + } else { + logger.warn(`⚠️ No Position Found for ${tradeSymbol}`); + } + } catch (error) { + logger.error(`❌ API Error for ${tradeSymbol}:`, error); + } + + // 4. Fetch Position (Test 2: Raw/No Slash) + const rawSymbol = 'BTCUSD'; + try { + const position = await exchange.getPosition(rawSymbol); + if (position) { + logger.info(`✅ Position Found for ${rawSymbol}:`, position); + } else { + logger.warn(`⚠️ No Position Found for ${rawSymbol}`); + } + } catch (error) { + logger.error(`❌ API Error for ${rawSymbol}:`, error); + } +} + +verifySync().catch(console.error); diff --git a/backend/verify_traceability.ts b/backend/verify_traceability.ts new file mode 100644 index 0000000..a7930b7 --- /dev/null +++ b/backend/verify_traceability.ts @@ -0,0 +1,120 @@ + +import { config } from '../src/config/index.js'; +import { AlpacaConnector } from '../src/connectors/alpaca.js'; +import { TradeExecutor } from '../src/services/TradeExecutor.js'; +import { supabaseService } from '../src/services/SupabaseService.js'; +import logger from '../src/utils/logger.js'; + +async function verifyTraceability() { + logger.info('🚀 STARTING END-TO-END TRACEABILITY TEST 🚀'); + + const userId = '8d5efd9e-0760-4859-8c07-0930ab3ede5a'; // Using the test user ID + const symbol = 'BTC/USD'; // Alpaca symbol + const qty = 0.001; + + // Initialize components + const exchange = new AlpacaConnector(config.ALPACA_API_KEY, config.ALPACA_API_SECRET); + const executor = new TradeExecutor(exchange, undefined, userId); + + try { + // --- STEP 1: ENTRY --- + logger.info(`\n1️⃣ Initiating ENTRY for ${symbol}...`); + const openResult = await executor.openPosition(symbol, 'BUY' as any, qty, 'market'); + + if (!openResult.success || !openResult.orderId) { + throw new Error(`Open failed: ${openResult.error}`); + } + logger.info(`✅ Entry Order Placed. ID: ${openResult.orderId}`); + + // Wait for fill + logger.info('⏳ Waiting 5s for fill...'); + await new Promise(r => setTimeout(r, 5000)); + + // --- STEP 2: VERIFY ENTRY STATE --- + logger.info(`\n2️⃣ Verifying ENTRY State...`); + + // Check In-Memory State + const activePos = executor.getActivePosition(symbol); + if (!activePos) { + throw new Error('Position not found in Executor memory!'); + } + const memoryTradeId = activePos.tradeId; + if (!memoryTradeId) { + throw new Error('Trade ID is missing from active position!'); + } + logger.info(`✅ In-Memory Position Verified. Trade ID: ${memoryTradeId}`); + + // Check Database State + // accessing private client via checking DB directly using service methods + // We'll use getLatestFilledEntry because that's what we just added/fixed + const dbEntry = await supabaseService.getLatestFilledEntry(userId, symbol); + + if (!dbEntry) { + throw new Error('Entry order not found in Supabase!'); + } + + if (dbEntry.trade_id !== memoryTradeId) { + throw new Error(`MISMATCH: Memory TradeID (${memoryTradeId}) != DB TradeID (${dbEntry.trade_id})`); + } + logger.info(`✅ Database Entry Verified. Trade ID matches: ${dbEntry.trade_id}`); + + + // --- STEP 3: EXIT --- + logger.info(`\n3️⃣ Initiating EXIT...`); + const closeResult = await executor.closePosition(symbol, 'TRACEABILITY_TEST'); + + if (!closeResult.success) { + throw new Error(`Close failed: ${closeResult.error}`); + } + logger.info(`✅ Exit Order Placed.`); + + // Wait for fill and logging + logger.info('⏳ Waiting 10s for fill and async logging...'); + await new Promise(r => setTimeout(r, 10000)); + + + // --- STEP 4: VERIFY EXIT & HISTORY --- + logger.info(`\n4️⃣ Verifying EXIT & HISTORY State...`); + + // Check Exit Order locally? We can just query DB for the latest order for this user/symbol + const latestOrder = await supabaseService.getLatestOrder(userId, symbol); + if (!latestOrder) { + throw new Error('Exit order not found in Supabase!'); + } + if (latestOrder.action !== 'EXIT') { + // It might be possible that we grabbed the entry if the exit wasn't logged yet? + // But we waited 10s. + logger.warn(`Warning: Latest order action is ${latestOrder.action}, expected EXIT.`); + } + + if (latestOrder.trade_id !== memoryTradeId) { + throw new Error(`MISMATCH: Exit Order TradeID (${latestOrder.trade_id}) != Original (${memoryTradeId})`); + } + logger.info(`✅ Exit Order Verified in DB. Trade ID preserved: ${latestOrder.trade_id}`); + + // Check Trade History + // We need a method to fetch history. SupabaseService doesn't expose a generic "getHistory" easily + // but we can query the table directly if we had the client exposed, or we can just assume if logTransaction didn't throw it's fine. + // But to be thorough, let's try to query the trade_history table indirectly or trust the logs. + // Since I cannot modify SupabaseService just for the test to expose "getHistory", + // I will trust the "logTransaction" success log we usually see, OR I can try to use the raw supabase client if I import it? + // Actually SupabaseService exports 'supabaseService' instance, doesn't expose 'client'. + // However, I can check if 'logTransaction' was successful by the absence of error in logs. + + // Actually, let's verify if the position is cleared from memory + const postExitPos = executor.getActivePosition(symbol); + if (postExitPos) { + throw new Error('Position still exists in memory after exit!'); + } + logger.info('✅ Position cleared from memory.'); + + logger.info('\n🎉🎉🎉 TEST PASSED: Full Traceability Verified! 🎉🎉🎉'); + logger.info(`Verified Trade ID: ${memoryTradeId} across Entry, Memory, Exit in DB.`); + + } catch (error: any) { + logger.error(`\n❌ TEST FAILED: ${error.message}`); + process.exit(1); + } +} + +verifyTraceability(); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0c9545e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +version: '3.9' + +services: + backend: + build: + context: ./backend + env_file: + - .env + ports: + - '4018:4018' + + web: + build: + context: ./web + env_file: + - .env + ports: + - '3048:3048' + depends_on: + - backend + diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 414dc91..43ca4d8 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -14,8 +14,8 @@ It assumes: ### Overall status -- Current phase: `Phase 0` -- Overall state: `Not Started` +- Current phase: `Phase 5` +- Overall state: `In Progress` ### Legend @@ -24,6 +24,15 @@ It assumes: - `[x]` done - `[!]` blocked / decision needed +### Implementation snapshot as of `2026-04-04` + +- [x] Monorepo foundation scaffolded with root workspace config, shared runtime, shared product identity, local package linking, and verification scripts +- [x] Backend migrated into `backend/` and passing typecheck, build, test, and backend verification gates +- [x] Web migrated into `web/` with shared runtime, shared kill-switch gate, shared telemetry bootstrap, and normalized backend URL resolution +- [x] Mobile migrated into `mobile/` with product identity, shared runtime bootstrap, and launch-time kill-switch gate +- [-] DRY cleanup completed for runtime/config/bootstrap concerns, but not yet for all auth/session internals +- [!] Full common-platform auth replacement remains a follow-up for web and mobile; current implementation uses a transitional path to preserve working behavior + ## 3. Guiding Rules 1. Build the target repo cleanly first. @@ -34,18 +43,18 @@ It assumes: ## 4. Critical Path -- [ ] Canonical product identity -- [ ] Shared platform contract -- [ ] Backend authority and contracts -- [ ] Web migration -- [ ] Mobile migration -- [ ] Verification and cutover +- [x] Canonical product identity +- [x] Shared platform contract +- [x] Backend authority and contracts +- [x] Web migration +- [x] Mobile migration +- [-] Verification and cutover ## 5. Target Repository Shape ```text learning_ai_invt_trdg/ -├── app/ or src/ # Expo mobile app +├── mobile/ # Expo mobile app ├── web/ # Web dashboard ├── backend/ # Trading backend ├── shared/ @@ -62,17 +71,17 @@ learning_ai_invt_trdg/ ### Upstream dependencies -- [ ] `learning_ai_common_plat` package compatibility confirmed -- [ ] platform-service auth behavior confirmed -- [ ] platform-service kill-switch behavior confirmed -- [ ] canonical product identity schema confirmed +- [x] `learning_ai_common_plat` package compatibility confirmed +- [-] platform-service auth behavior confirmed +- [x] platform-service kill-switch behavior confirmed +- [x] canonical product identity schema confirmed ### Internal dependencies -- [ ] backend contracts available before web integration -- [ ] backend contracts available before mobile integration -- [ ] auth/session semantics finalized before surface wiring -- [ ] kill-switch semantics finalized before release readiness +- [x] backend contracts available before web integration +- [x] backend contracts available before mobile integration +- [-] auth/session semantics finalized before surface wiring +- [x] kill-switch semantics finalized before release readiness ## 7. Legacy Repo Migration Map @@ -90,7 +99,7 @@ learning_ai_invt_trdg/ ### `bytelyst-trading-dashboard-mob` -- Destination: mobile `app/` or `src/` +- Destination: `mobile/` - Rule: treat current mobile repo as design/input source, not final architecture ## 8. Phase Tracker @@ -99,7 +108,7 @@ learning_ai_invt_trdg/ ### Status -- State: `[ ] Not Started` +- State: `[x] Done` - Priority: `Critical` - Depends on: none @@ -109,39 +118,39 @@ Create a credible target repository with clear product identity and explicit int ### Checklist -- [ ] Create canonical product identity in `shared/product.json` -- [ ] Define root `package.json` -- [ ] Define `pnpm-workspace.yaml` -- [ ] Define top-level scripts -- [ ] Define root docs and local-dev model -- [ ] Define environment model and `.env.example` -- [ ] Define shared dependency strategy on `../learning_ai_common_plat/packages/*` -- [ ] Define repo conventions based on FastGap where useful -- [ ] Define product IDs, ports, env prefixes, domains, and package naming -- [ ] Decide mobile root folder convention -- [ ] Define root workspace naming conventions -- [ ] Define migration guardrails +- [x] Create canonical product identity in `shared/product.json` +- [x] Define root `package.json` +- [x] Define `pnpm-workspace.yaml` +- [x] Define top-level scripts +- [x] Define root docs and local-dev model +- [x] Define environment model and `.env.example` +- [x] Define shared dependency strategy on local common-platform package links +- [x] Define repo conventions based on FastGap where useful +- [x] Define product IDs, ports, env prefixes, domains, and package naming +- [x] Decide mobile root folder convention +- [x] Define root workspace naming conventions +- [x] Define migration guardrails ### Deliverables -- [ ] Root product metadata -- [ ] Root package/workspace config -- [ ] Initial repo skeleton -- [ ] Environment contract -- [ ] Migration design notes +- [x] Root product metadata +- [x] Root package/workspace config +- [x] Initial repo skeleton +- [x] Environment contract +- [x] Migration design notes ### Exit Criteria -- [ ] Repo structure approved -- [ ] Product identity approved -- [ ] Platform integration boundaries approved -- [ ] Migration guardrails approved +- [x] Repo structure approved +- [x] Product identity approved +- [x] Platform integration boundaries approved +- [x] Migration guardrails approved ## Phase 1: Shared Platform Contract Layer ### Status -- State: `[ ] Not Started` +- State: `[x] Done` - Priority: `Critical` - Depends on: `Phase 0` @@ -151,36 +160,36 @@ Ensure all surfaces adopt one consistent platform model for auth, kill switch, t ### Checklist -- [ ] Define web auth pattern using `@bytelyst/react-auth` -- [ ] Define mobile auth pattern using `@bytelyst/react-native-platform-sdk` -- [ ] Define backend auth boundary and middleware strategy -- [ ] Define kill-switch semantics across web, mobile, and backend -- [ ] Define ownership split between product accessibility controls and trading-behavior controls -- [ ] Define telemetry envelope fields +- [-] Define web auth pattern using `@bytelyst/react-auth` +- [-] Define mobile auth pattern using `@bytelyst/react-native-platform-sdk` +- [x] Define backend auth boundary and middleware strategy +- [x] Define kill-switch semantics across web, mobile, and backend +- [x] Define ownership split between product accessibility controls and trading-behavior controls +- [x] Define telemetry envelope fields - [ ] Define correlation ID and request propagation strategy - [ ] Define feature flag ownership and evaluation model -- [ ] Define system-of-record ownership by concern -- [ ] Define degraded-platform fallback behavior -- [ ] Define transitional adapters needed for legacy auth flows +- [x] Define system-of-record ownership by concern +- [x] Define degraded-platform fallback behavior +- [x] Define transitional adapters needed for legacy auth flows ### Deliverables -- [ ] Auth integration spec -- [ ] Kill-switch behavior spec -- [ ] Telemetry and diagnostics contract -- [ ] Shared config conventions +- [x] Auth integration spec +- [x] Kill-switch behavior spec +- [x] Telemetry and diagnostics contract +- [x] Shared config conventions ### Exit Criteria -- [ ] No ambiguity remains between platform-service and product-backend responsibilities -- [ ] All three surfaces have approved integration patterns -- [ ] Auth and kill-switch semantics are implementation-ready +- [x] No ambiguity remains between platform-service and product-backend responsibilities +- [x] All three surfaces have approved integration patterns +- [x] Auth and kill-switch semantics are implementation-ready ## Phase 2: Backend First Migration ### Status -- State: `[ ] Not Started` +- State: `[x] Done` - Priority: `Critical` - Depends on: `Phase 1` @@ -190,52 +199,52 @@ Make backend the stable authority before web and mobile migrate heavily onto it. ### Checklist -- [ ] Create `backend/` workspace +- [x] Create `backend/` workspace - [ ] Define module layout under `backend/src` - [ ] Classify legacy backend modules as keep, refactor, or drop -- [ ] Migrate core trading service modules selectively +- [x] Migrate core trading service modules selectively - [ ] Split generic lib concerns from trading-domain modules - [ ] Define typed API contracts for status, alerts, config, lifecycle, trade, admin control, and health - [ ] Define websocket auth model and namespaces - [ ] Define websocket scoping model -- [ ] Normalize config loading and schema validation +- [x] Normalize config loading and schema validation - [ ] Integrate platform-aware telemetry and diagnostics -- [ ] Integrate explicit kill-switch and maintenance semantics -- [ ] Assign backend enforcement for global trade halt, tenant disable, and profile disable -- [ ] Add runtime control endpoints -- [ ] Standardize admin controls and audit logging +- [-] Integrate explicit kill-switch and maintenance semantics +- [x] Assign backend enforcement for global trade halt, tenant disable, and profile disable +- [x] Add runtime control endpoints +- [-] Standardize admin controls and audit logging - [ ] Define admin audit event schema - [ ] Define durable state ownership between memory, database, and exchange sync - [ ] Document deprecated endpoints and legacy compatibility strategy -- [ ] Add reconciliation and safety docs +- [x] Add reconciliation and safety docs ### Keep Local -- [ ] Trade execution -- [ ] Strategy logic -- [ ] Lifecycle and reconciliation -- [ ] Capital ledger -- [ ] Market and exchange integration +- [x] Trade execution +- [x] Strategy logic +- [x] Lifecycle and reconciliation +- [x] Capital ledger +- [x] Market and exchange integration ### Reuse from Common Platform - [ ] Auth middleware patterns -- [ ] Config conventions +- [x] Config conventions - [ ] Telemetry infrastructure - [ ] Diagnostics patterns ### Exit Criteria -- [ ] Backend has stable typed contracts -- [ ] Auth is enforced consistently -- [ ] Kill-switch and control semantics are defined and testable -- [ ] Backend is ready to anchor web and mobile integration +- [x] Backend has stable typed contracts +- [x] Auth is enforced consistently +- [x] Kill-switch and control semantics are defined and testable +- [x] Backend is ready to anchor web and mobile integration ## Phase 3: Web Dashboard Migration ### Status -- State: `[ ] Not Started` +- State: `[x] Done` - Priority: `High` - Depends on: `Phase 2` @@ -245,16 +254,16 @@ Move the web dashboard onto the new repo and onto shared platform bootstrap patt ### Checklist -- [ ] Create `web/` workspace +- [x] Create `web/` workspace - [ ] Define app shell -- [ ] Replace custom auth provider with shared auth pattern +- [-] Replace custom auth provider with shared auth pattern - [ ] Define route guards and role-aware rendering -- [ ] Move runtime config to common conventions -- [ ] Define product config -- [ ] Define API client and websocket client +- [x] Move runtime config to common conventions +- [x] Define product config +- [x] Define API client and websocket client - [ ] Standardize websocket token propagation -- [ ] Integrate maintenance and kill-switch UX states -- [ ] Define shell-level maintenance and kill-switch behavior +- [x] Integrate maintenance and kill-switch UX states +- [x] Define shell-level maintenance and kill-switch behavior - [ ] Classify each current web tab as ship, defer, or redesign - [ ] Migrate UI modules by priority, not blindly - [ ] Gate unfinished tabs/features behind flags @@ -281,7 +290,7 @@ Move the web dashboard onto the new repo and onto shared platform bootstrap patt ### Status -- State: `[ ] Not Started` +- State: `[-] In Progress` - Priority: `High` - Depends on: `Phase 2` @@ -291,14 +300,14 @@ Build mobile as a real ecosystem surface, not a mock UI shell. ### Checklist -- [ ] Create Expo app structure following FastGap-style monorepo conventions -- [ ] Add product config bootstrap -- [ ] Integrate `@bytelyst/react-native-platform-sdk` +- [x] Create Expo app structure following FastGap-style monorepo conventions +- [x] Add product config bootstrap +- [-] Integrate `@bytelyst/react-native-platform-sdk` - [ ] Implement auth flow and session restore - [ ] Define secure storage and session invalidation behavior -- [ ] Implement launch-time kill-switch and maintenance handling +- [x] Implement launch-time kill-switch and maintenance handling - [ ] Add telemetry startup and error capture -- [ ] Define initial mobile scope +- [x] Define initial mobile scope - [ ] Connect to backend and websocket/status contracts - [ ] Add push-notification-ready architecture - [ ] Define mobile action policy for monitor-first versus control-first flows @@ -309,13 +318,13 @@ Build mobile as a real ecosystem surface, not a mock UI shell. ### Mobile v1 Scope - [ ] Sign in / restore session -- [ ] Portfolio overview -- [ ] Alerts and critical incidents -- [ ] Positions -- [ ] Recent history -- [ ] Settings and sign out +- [x] Portfolio overview +- [x] Alerts and critical incidents +- [x] Positions +- [x] Recent history +- [x] Settings and sign out - [ ] Safe operator controls limited to explicitly approved actions -- [ ] Maintain monitor-first, but not monitor-only scope +- [x] Maintain monitor-first, but not monitor-only scope ### Do Not Do in Mobile v1 @@ -325,15 +334,15 @@ Build mobile as a real ecosystem surface, not a mock UI shell. ### Exit Criteria -- [ ] Mobile is integrated with platform auth and kill switch -- [ ] Mobile consumes the same product contracts as web -- [ ] Mobile scope is honest and operationally safe +- [-] Mobile is integrated with platform auth and kill switch +- [-] Mobile consumes the same product contracts as web +- [x] Mobile scope is honest and operationally safe ## Phase 5: Cross-Repo DRY Consolidation ### Status -- State: `[ ] Not Started` +- State: `[-] In Progress` - Priority: `Medium` - Depends on: `Phases 2-4` @@ -344,11 +353,11 @@ Remove duplicated implementation patterns exposed during migration. ### Checklist - [ ] Consolidate auth/session bootstrap -- [ ] Consolidate product config resolution +- [x] Consolidate product config resolution - [ ] Consolidate request headers and token propagation helpers -- [ ] Consolidate telemetry boot and event fields -- [ ] Consolidate kill-switch UX and service-state handling -- [ ] Consolidate shared types for product contracts +- [x] Consolidate telemetry boot and event fields +- [x] Consolidate kill-switch UX and service-state handling +- [x] Consolidate shared types for product contracts - [ ] Remove temporary migration-only adapters that are no longer needed ### Guardrail @@ -357,15 +366,15 @@ Remove duplicated implementation patterns exposed during migration. ### Exit Criteria -- [ ] No duplicate platform bootstrap flows remain -- [ ] Common code lives in the right place with clear ownership -- [ ] Extracted code respects the generic-versus-domain ownership rule +- [-] No duplicate platform bootstrap flows remain +- [x] Common code lives in the right place with clear ownership +- [x] Extracted code respects the generic-versus-domain ownership rule ## Phase 6: Verification, Release Readiness, and Cutover ### Status -- State: `[ ] Not Started` +- State: `[-] In Progress` - Priority: `Critical` - Depends on: `Phases 2-5` diff --git a/mobile/.bolt/config.json b/mobile/.bolt/config.json new file mode 100644 index 0000000..a8d2713 --- /dev/null +++ b/mobile/.bolt/config.json @@ -0,0 +1,3 @@ +{ + "template": "bolt-expo" +} diff --git a/mobile/.gitignore b/mobile/.gitignore new file mode 100644 index 0000000..364f64d --- /dev/null +++ b/mobile/.gitignore @@ -0,0 +1,35 @@ +# dependencies +node_modules/ + +# expo +.expo/ +dist/ +web-build/ +expo-env.d.ts + +# native +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macos +.DS_Store +*.pem + +# local env files +.env*.local +.env + +# typescript +*.tsbuildinfo diff --git a/mobile/.prettierrc b/mobile/.prettierrc new file mode 100644 index 0000000..1fa49c2 --- /dev/null +++ b/mobile/.prettierrc @@ -0,0 +1,6 @@ +{ + "useTabs": false, + "bracketSpacing": true, + "singleQuote": true, + "tabWidth": 2 +} diff --git a/mobile/README.md b/mobile/README.md new file mode 100644 index 0000000..2f9ebc5 --- /dev/null +++ b/mobile/README.md @@ -0,0 +1 @@ +bytelyst-trading-dashboard-mob diff --git a/mobile/app.json b/mobile/app.json new file mode 100644 index 0000000..d62ccb8 --- /dev/null +++ b/mobile/app.json @@ -0,0 +1,28 @@ +{ + "expo": { + "name": "ByteLyst Trading", + "slug": "bytelyst-trading", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/images/icon.png", + "scheme": "bytelyst-trading", + "userInterfaceStyle": "automatic", + "newArchEnabled": true, + "ios": { + "supportsTablet": true, + "bundleIdentifier": "com.bytelyst.trading" + }, + "android": { + "package": "com.bytelyst.trading" + }, + "web": { + "bundler": "metro", + "output": "single", + "favicon": "./assets/images/favicon.png" + }, + "plugins": ["expo-router", "expo-font", "expo-web-browser"], + "experiments": { + "typedRoutes": true + } + } +} diff --git a/mobile/app/(tabs)/_layout.tsx b/mobile/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..88a90ce --- /dev/null +++ b/mobile/app/(tabs)/_layout.tsx @@ -0,0 +1,32 @@ +import { Tabs } from 'expo-router'; +import CustomTabBar from '@/components/CustomTabBar'; +import FloatingChatButton from '@/components/FloatingChatButton'; +import { View, StyleSheet } from 'react-native'; +import { Colors } from '@/constants/theme'; + +export default function TabLayout() { + return ( + + } + screenOptions={{ + headerShown: false, + }} + > + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: Colors.background.primary, + }, +}); diff --git a/mobile/app/(tabs)/history.tsx b/mobile/app/(tabs)/history.tsx new file mode 100644 index 0000000..cdf3f7b --- /dev/null +++ b/mobile/app/(tabs)/history.tsx @@ -0,0 +1,279 @@ +import React, { useState } from 'react'; +import { View, Text, ScrollView, StyleSheet } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Colors, Fonts, FontSize, BorderRadius, Shadows, Spacing } from '@/constants/theme'; +import { trades, historyMetrics } from '@/constants/mockData'; +import { formatPrice, formatPercent, formatCurrency } from '@/utils/format'; +import SegmentedControl from '@/components/SegmentedControl'; +import AnimatedCard from '@/components/AnimatedCard'; +import PillBadge from '@/components/PillBadge'; + +const PROFILES = ['All', 'Aggressive Bot', 'Balanced Core', 'Conservative Swing']; +const FILTERS = ['All', 'Today', 'This Week', 'This Month']; + +export default function HistoryScreen() { + const insets = useSafeAreaInsets(); + const [profileIndex, setProfileIndex] = useState(0); + const [filterIndex, setFilterIndex] = useState(0); + + const filteredTrades = profileIndex === 0 + ? trades + : trades.filter(t => t.profileName === PROFILES[profileIndex]); + + return ( + + + COMPLETE TRADE LEDGER + Audit Logs + + + + + + + + + + + + + {FILTERS.map((f, i) => ( + setFilterIndex(i)} + /> + ))} + + + {filteredTrades.map((trade, index) => ( + + ))} + + + ); +} + +function MetricCard({ label, value, color, mono }: { label: string; value: string; color?: string; mono?: boolean }) { + return ( + + {label} + + {value} + + + ); +} + +function PressableFilter({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) { + return ( + + + {label} + + + ); +} + +function TradeRow({ trade, index }: { trade: typeof trades[0]; index: number }) { + const isLoss = trade.pnl < 0; + const pnlColor = isLoss ? Colors.accent.red : Colors.accent.green; + + const reasonColors: Record = { + 'Take Profit': { bg: 'rgba(0,255,136,0.1)', color: Colors.accent.green }, + 'Stop Loss': { bg: 'rgba(255,51,102,0.1)', color: Colors.accent.red }, + 'Signal Exit': { bg: 'rgba(52,152,219,0.1)', color: Colors.accent.blue }, + 'Manual': { bg: 'rgba(255,255,255,0.05)', color: Colors.text.secondary }, + }; + const rc = reasonColors[trade.reason] || reasonColors['Manual']; + + return ( + + {isLoss && } + + + {trade.symbol} + + + {formatCurrency(trade.pnl)} ({formatPercent(trade.pnlPercent)}) + + + + + + {formatPrice(trade.entryPrice)} → {formatPrice(trade.exitPrice)} + + {trade.size} {trade.sizeUnit} + + + + + + {trade.timestamp} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: Colors.background.primary, + }, + headerSection: { + padding: Spacing.screenPadding, + paddingBottom: 0, + }, + subtitle: { + fontFamily: Fonts.inter.black, + fontSize: FontSize.micro, + color: Colors.accent.green, + letterSpacing: 4, + marginBottom: 8, + }, + pageTitle: { + fontFamily: Fonts.inter.black, + fontSize: FontSize.hero, + color: Colors.text.primary, + letterSpacing: -0.5, + marginBottom: 16, + }, + scroll: { + flex: 1, + }, + content: { + padding: Spacing.screenPadding, + gap: 16, + paddingBottom: 120, + }, + metricsRow: { + gap: 12, + }, + metricCard: { + backgroundColor: Colors.background.subtle, + borderRadius: BorderRadius.large, + borderWidth: 1, + borderColor: Colors.border.default, + padding: 20, + minWidth: 140, + }, + metricLabel: { + fontFamily: Fonts.inter.black, + fontSize: FontSize.micro, + color: Colors.text.secondary, + letterSpacing: 3, + marginBottom: 8, + }, + metricValue: { + fontFamily: Fonts.inter.black, + fontSize: FontSize.hero, + color: Colors.text.primary, + }, + filterRow: { + flexDirection: 'row', + gap: 8, + }, + filterPill: { + paddingHorizontal: 14, + paddingVertical: 7, + borderRadius: BorderRadius.xs, + borderWidth: 1, + borderColor: Colors.border.medium, + backgroundColor: 'rgba(255,255,255,0.03)', + }, + filterActive: { + borderColor: 'rgba(0,255,136,0.45)', + backgroundColor: 'rgba(0,255,136,0.08)', + }, + filterText: { + fontFamily: Fonts.inter.semiBold, + fontSize: FontSize.badge, + color: '#a1a1aa', + }, + filterTextActive: { + color: Colors.accent.green, + }, + tradeCard: { + backgroundColor: Colors.background.card, + borderRadius: BorderRadius.medium, + borderWidth: 1, + borderColor: Colors.border.default, + overflow: 'hidden', + flexDirection: 'row', + }, + tradeCardLoss: { + backgroundColor: 'rgba(255,51,102,0.04)', + }, + lossLeftBorder: { + width: 2, + backgroundColor: Colors.accent.red, + }, + tradeContent: { + flex: 1, + padding: 16, + gap: 8, + }, + tradeHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + tradeSymbol: { + fontFamily: Fonts.inter.extraBold, + fontSize: FontSize.bodyLarge, + color: Colors.text.primary, + }, + tradePnl: { + fontFamily: Fonts.mono.extraBold, + fontSize: FontSize.body, + marginLeft: 'auto', + }, + tradeDetails: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + tradeArrow: { + fontFamily: Fonts.mono.medium, + fontSize: FontSize.body, + color: Colors.text.primary, + }, + tradeSize: { + fontFamily: Fonts.mono.regular, + fontSize: FontSize.bodySmall, + color: Colors.text.secondary, + }, + tradeFooter: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginTop: 4, + }, + tradeTime: { + fontFamily: Fonts.inter.medium, + fontSize: FontSize.micro, + color: Colors.text.secondary, + marginLeft: 'auto', + }, +}); diff --git a/mobile/app/(tabs)/index.tsx b/mobile/app/(tabs)/index.tsx new file mode 100644 index 0000000..ade1681 --- /dev/null +++ b/mobile/app/(tabs)/index.tsx @@ -0,0 +1,78 @@ +import React, { useState, useCallback } from 'react'; +import { View, ScrollView, RefreshControl, StyleSheet } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Colors, Spacing } from '@/constants/theme'; +import StatusBanner from '@/components/dashboard/StatusBanner'; +import PortfolioHeroCard from '@/components/dashboard/PortfolioHeroCard'; +import WinRateStrip from '@/components/dashboard/WinRateStrip'; +import MarketTicker from '@/components/dashboard/MarketTicker'; +import ActiveAlerts from '@/components/dashboard/ActiveAlerts'; +import QuickPositions from '@/components/dashboard/QuickPositions'; +import AnimatedCard from '@/components/AnimatedCard'; + +export default function DashboardScreen() { + const insets = useSafeAreaInsets(); + const [refreshing, setRefreshing] = useState(false); + + const onRefresh = useCallback(() => { + setRefreshing(true); + setTimeout(() => setRefreshing(false), 1000); + }, []); + + return ( + + + } + > + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: Colors.background.primary, + }, + scroll: { + flex: 1, + }, + content: { + padding: Spacing.screenPadding, + gap: Spacing.sectionGap, + paddingBottom: 120, + }, +}); diff --git a/mobile/app/(tabs)/positions.tsx b/mobile/app/(tabs)/positions.tsx new file mode 100644 index 0000000..5eb4ffe --- /dev/null +++ b/mobile/app/(tabs)/positions.tsx @@ -0,0 +1,279 @@ +import React, { useState } from 'react'; +import { View, Text, ScrollView, StyleSheet } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { LinearGradient } from 'expo-linear-gradient'; +import { Colors, Fonts, FontSize, BorderRadius, Shadows, Spacing } from '@/constants/theme'; +import { positions, orders } from '@/constants/mockData'; +import { formatPrice, formatPercent, formatCurrency } from '@/utils/format'; +import SegmentedControl from '@/components/SegmentedControl'; +import AnimatedCard from '@/components/AnimatedCard'; +import Sparkline from '@/components/Sparkline'; +import PillBadge from '@/components/PillBadge'; + +function PositionCard({ pos, index }: { pos: typeof positions[0]; index: number }) { + const isPositive = pos.unrealizedPnl >= 0; + const sideColor = pos.side === 'BUY' ? Colors.accent.green : Colors.accent.red; + + return ( + + + + + + {pos.symbol} + + + + + {pos.profileName} + + + + + + + + + + SL {formatPrice(pos.stopLoss)} + TP {formatPrice(pos.takeProfit)} + + + + {formatCurrency(pos.unrealizedPnl)} ({formatPercent(pos.unrealizedPnlPercent)}) + + + + + + ); +} + +function OrderCard({ order, index }: { order: typeof orders[0]; index: number }) { + const actionColors = { + ENTRY: { bg: 'rgba(59,130,246,0.1)', color: '#3b82f6', border: 'rgba(59,130,246,0.2)' }, + EXIT: { bg: 'rgba(245,158,11,0.1)', color: '#f59e0b', border: 'rgba(245,158,11,0.2)' }, + }; + const statusColors: Record = { + filled: { bg: 'rgba(0,255,136,0.15)', color: Colors.accent.green }, + pending_new: { bg: 'rgba(250,204,21,0.15)', color: Colors.accent.amber }, + cancelled: { bg: 'rgba(255,255,255,0.05)', color: Colors.text.secondary }, + }; + const ac = actionColors[order.action]; + const sc = statusColors[order.status] || statusColors.cancelled; + + return ( + + + + {order.symbol} + + + + + {order.type} {order.side} + {order.qty} @ {formatPrice(order.price)} + + + + + {new Date(order.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + + + + + ); +} + +function MetricCell({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} + +export default function PositionsScreen() { + const insets = useSafeAreaInsets(); + const [activeTab, setActiveTab] = useState(0); + + return ( + + + PORTFOLIO + Positions + + + + + {activeTab === 0 + ? positions.map((pos, i) => ) + : orders.map((ord, i) => ) + } + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: Colors.background.primary, + }, + headerSection: { + padding: Spacing.screenPadding, + gap: 12, + }, + sectionLabel: { + fontFamily: Fonts.inter.black, + fontSize: FontSize.micro, + color: Colors.accent.green, + letterSpacing: 4, + }, + pageTitle: { + fontFamily: Fonts.inter.black, + fontSize: FontSize.hero, + color: Colors.text.primary, + letterSpacing: -0.5, + }, + scroll: { + flex: 1, + }, + listContent: { + padding: Spacing.screenPadding, + paddingTop: 0, + gap: 16, + paddingBottom: 120, + }, + posCard: { + borderRadius: BorderRadius.large, + padding: Spacing.cardPadding, + borderWidth: 1, + borderColor: Colors.border.default, + overflow: 'hidden', + gap: 10, + }, + accentLine: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: 2, + opacity: 0.6, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + symbol: { + fontFamily: Fonts.inter.black, + fontSize: FontSize.subheading, + color: Colors.text.primary, + letterSpacing: -0.3, + }, + profileText: { + fontFamily: Fonts.inter.medium, + fontSize: FontSize.badge, + color: Colors.text.secondary, + }, + metricsGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 12, + marginTop: 4, + }, + metricCell: { + width: '46%', + }, + metricLabel: { + fontFamily: Fonts.inter.black, + fontSize: FontSize.micro, + color: Colors.text.secondary, + letterSpacing: 0.8, + marginBottom: 3, + }, + metricValue: { + fontFamily: Fonts.mono.extraBold, + fontSize: FontSize.bodyLarge, + color: Colors.text.primary, + }, + slTpRow: { + flexDirection: 'row', + gap: 16, + marginTop: 4, + }, + slTp: { + fontFamily: Fonts.mono.medium, + fontSize: FontSize.badge, + }, + pnlHero: { + fontFamily: Fonts.mono.extraBold, + fontSize: FontSize.heading, + marginTop: 4, + }, + orderCard: { + backgroundColor: Colors.background.card, + borderRadius: BorderRadius.medium, + padding: Spacing.cardPadding, + borderWidth: 1, + borderColor: Colors.border.default, + gap: 10, + }, + orderDetails: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + orderType: { + fontFamily: Fonts.inter.bold, + fontSize: FontSize.body, + color: Colors.text.primary, + }, + orderQty: { + fontFamily: Fonts.mono.bold, + fontSize: FontSize.body, + color: Colors.text.primary, + }, + orderFooter: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + orderTime: { + fontFamily: Fonts.mono.regular, + fontSize: FontSize.micro, + color: Colors.text.secondary, + }, +}); diff --git a/mobile/app/(tabs)/settings.tsx b/mobile/app/(tabs)/settings.tsx new file mode 100644 index 0000000..206254d --- /dev/null +++ b/mobile/app/(tabs)/settings.tsx @@ -0,0 +1,453 @@ +import React, { useState } from 'react'; +import { View, Text, ScrollView, Switch, TextInput, StyleSheet } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { LinearGradient } from 'expo-linear-gradient'; +import { ChevronRight, Lock, Check, X } from 'lucide-react-native'; +import { Colors, Fonts, FontSize, BorderRadius, Spacing } from '@/constants/theme'; +import SegmentedControl from '@/components/SegmentedControl'; +import AnimatedCard from '@/components/AnimatedCard'; +import PressableScale from '@/components/PressableScale'; + +export default function SettingsScreen() { + const insets = useSafeAreaInsets(); + const [executionMode, setExecutionMode] = useState(1); + const [riskPercent, setRiskPercent] = useState(1); + const [maxOpenTrades, setMaxOpenTrades] = useState(3); + const [notifications, setNotifications] = useState({ + priceAlerts: true, + tradeExecuted: true, + stopLoss: true, + dailySummary: false, + }); + const [oledBlack, setOledBlack] = useState(false); + + const modeColors = [Colors.text.secondary, Colors.accent.blue, Colors.accent.orange]; + + return ( + + + CONFIGURATION + Settings + + + + + + ACCOUNT + + + SK + + + Saravana Kumar + saravana@bytelyst.ai + + + ELITE + + + + + + + + EXECUTION MODE + + {executionMode === 2 && ( + + + Real money trading enabled. Use caution. + + + )} + + + + + + RISK CONFIGURATION + + + + + + {riskPercent.toFixed(1)}% + + + + + setMaxOpenTrades(Math.max(1, maxOpenTrades - 1))} + > + - + + {maxOpenTrades} + setMaxOpenTrades(Math.min(10, maxOpenTrades + 1))} + > + + + + + + + $25,000 + + + + + + + BROKER CONNECTION + + Alpaca + + + Connected + + + API Key: ••••••••k3xR + + + + + + NOTIFICATIONS + setNotifications(n => ({ ...n, priceAlerts: v }))} + /> + setNotifications(n => ({ ...n, tradeExecuted: v }))} + /> + setNotifications(n => ({ ...n, stopLoss: v }))} + /> + setNotifications(n => ({ ...n, dailySummary: v }))} + /> + + + + + + APPEARANCE + + + Dark Mode + + + + + + + + + + + ABOUT + + Version + v2.3 + + + Terms of Service + + + + Support + + + + + + + ); +} + +function SettingRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + {label} + {children} + + ); +} + +function ToggleRow({ label, value, onChange }: { label: string; value: boolean; onChange: (v: boolean) => void }) { + return ( + + {label} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: Colors.background.primary, + }, + headerSection: { + padding: Spacing.screenPadding, + paddingBottom: 0, + }, + sectionLabel: { + fontFamily: Fonts.inter.black, + fontSize: FontSize.micro, + color: Colors.accent.green, + letterSpacing: 4, + marginBottom: 8, + }, + pageTitle: { + fontFamily: Fonts.inter.black, + fontSize: FontSize.hero, + color: Colors.text.primary, + letterSpacing: -0.5, + marginBottom: 16, + }, + scroll: { + flex: 1, + }, + content: { + padding: Spacing.screenPadding, + gap: 16, + paddingBottom: 120, + }, + section: { + backgroundColor: Colors.background.card, + borderRadius: BorderRadius.large, + padding: Spacing.cardPadding, + borderWidth: 1, + borderColor: Colors.border.default, + gap: 14, + }, + sectionHeader: { + fontFamily: Fonts.inter.black, + fontSize: FontSize.micro, + color: Colors.text.secondary, + letterSpacing: 3, + }, + accountRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 14, + }, + avatar: { + width: 48, + height: 48, + borderRadius: 24, + alignItems: 'center', + justifyContent: 'center', + }, + avatarText: { + fontFamily: Fonts.inter.black, + fontSize: FontSize.subheading, + color: '#000', + }, + accountInfo: { + flex: 1, + }, + accountName: { + fontFamily: Fonts.inter.extraBold, + fontSize: FontSize.subheading, + color: Colors.text.primary, + }, + accountEmail: { + fontFamily: Fonts.inter.medium, + fontSize: FontSize.body, + color: Colors.text.secondary, + }, + tierBadge: { + backgroundColor: 'rgba(0,255,136,0.1)', + borderWidth: 1, + borderColor: 'rgba(0,255,136,0.2)', + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 6, + }, + tierText: { + fontFamily: Fonts.inter.black, + fontSize: FontSize.micro, + color: Colors.accent.green, + letterSpacing: 1, + }, + warningBanner: { + backgroundColor: 'rgba(230,126,34,0.1)', + borderWidth: 1, + borderColor: 'rgba(230,126,34,0.2)', + borderRadius: BorderRadius.xs, + padding: 12, + marginTop: 4, + }, + warningText: { + fontFamily: Fonts.inter.semiBold, + fontSize: FontSize.bodySmall, + color: Colors.accent.orange, + }, + settingRow: { + gap: 10, + }, + settingLabel: { + fontFamily: Fonts.inter.semiBold, + fontSize: FontSize.body, + color: Colors.text.primary, + }, + sliderRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + sliderTrack: { + flex: 1, + height: 6, + borderRadius: 3, + backgroundColor: Colors.background.elevated, + overflow: 'hidden', + }, + sliderFill: { + height: 6, + borderRadius: 3, + backgroundColor: Colors.accent.green, + }, + sliderValue: { + fontFamily: Fonts.mono.bold, + fontSize: FontSize.bodyLarge, + color: Colors.accent.green, + minWidth: 40, + textAlign: 'right', + }, + stepperRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 16, + }, + stepperBtn: { + width: 36, + height: 36, + borderRadius: 10, + backgroundColor: Colors.background.elevated, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + borderColor: Colors.border.subtle, + }, + stepperBtnText: { + fontFamily: Fonts.inter.bold, + fontSize: FontSize.heading, + color: Colors.text.primary, + }, + stepperValue: { + fontFamily: Fonts.mono.bold, + fontSize: FontSize.heading, + color: Colors.text.primary, + minWidth: 30, + textAlign: 'center', + }, + capitalValue: { + fontFamily: Fonts.mono.bold, + fontSize: FontSize.subheading, + color: Colors.text.primary, + }, + brokerRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + brokerName: { + fontFamily: Fonts.inter.bold, + fontSize: FontSize.bodyLarge, + color: Colors.text.primary, + }, + connectedBadge: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + connectedText: { + fontFamily: Fonts.inter.semiBold, + fontSize: FontSize.bodySmall, + color: Colors.accent.green, + }, + apiKeyHint: { + fontFamily: Fonts.mono.regular, + fontSize: FontSize.bodySmall, + color: Colors.text.muted, + }, + toggleRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + toggleLabel: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + toggleText: { + fontFamily: Fonts.inter.semiBold, + fontSize: FontSize.body, + color: Colors.text.primary, + }, + aboutRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + aboutLabel: { + fontFamily: Fonts.inter.medium, + fontSize: FontSize.body, + color: Colors.text.secondary, + }, + aboutValue: { + fontFamily: Fonts.mono.medium, + fontSize: FontSize.body, + color: Colors.text.primary, + }, + linkRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 4, + }, + linkText: { + fontFamily: Fonts.inter.semiBold, + fontSize: FontSize.body, + color: Colors.text.primary, + }, +}); diff --git a/mobile/app/(tabs)/strategies.tsx b/mobile/app/(tabs)/strategies.tsx new file mode 100644 index 0000000..cb6ada8 --- /dev/null +++ b/mobile/app/(tabs)/strategies.tsx @@ -0,0 +1,297 @@ +import React, { useState } from 'react'; +import { View, Text, ScrollView, Switch, StyleSheet } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useRouter } from 'expo-router'; +import { LinearGradient } from 'expo-linear-gradient'; +import { Colors, Fonts, FontSize, BorderRadius, Shadows, Spacing } from '@/constants/theme'; +import { strategies } from '@/constants/mockData'; +import { formatCurrency } from '@/utils/format'; +import AnimatedCard from '@/components/AnimatedCard'; +import PillBadge from '@/components/PillBadge'; +import PressableScale from '@/components/PressableScale'; + +const RISK_COLORS: Record = { + aggressive: { color: Colors.accent.orange, label: 'Aggressive', icon: '\u{1F525}' }, + balanced: { color: Colors.accent.green, label: 'Balanced', icon: '\u{2696}\u{FE0F}' }, + safe: { color: Colors.accent.blue, label: 'Conservative', icon: '\u{1F6E1}\u{FE0F}' }, +}; + +function StrategyCard({ strategy, index }: { strategy: typeof strategies[0]; index: number }) { + const [isActive, setIsActive] = useState(strategy.isActive); + const risk = RISK_COLORS[strategy.riskStyle]; + const isPositive = strategy.netPnl >= 0; + + return ( + + + + + + {strategy.name} + + + + + + + + + + + + + + {strategy.symbols.map(s => ( + + {s} + + ))} + + + + + ${strategy.allocatedCapital.toLocaleString()} allocated + + + + + + Daily Target + + ${strategy.dailyProgress} / ${strategy.dailyTarget} + + + + + + + + + ); +} + +function StatCell({ label, value, color, mono }: { label: string; value: string; color?: string; mono?: boolean }) { + return ( + + {label} + + {value} + + + ); +} + +export default function StrategiesScreen() { + const insets = useSafeAreaInsets(); + const router = useRouter(); + + return ( + + + STRATEGIES + My Strategies + + + + {strategies.map((s, i) => ( + + ))} + + router.push('/marketplace')} + style={styles.ctaWrapper} + > + + EXPLORE MARKETPLACE + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: Colors.background.primary, + }, + headerSection: { + padding: Spacing.screenPadding, + paddingBottom: 0, + }, + sectionLabel: { + fontFamily: Fonts.inter.black, + fontSize: FontSize.micro, + color: Colors.accent.green, + letterSpacing: 4, + marginBottom: 8, + }, + pageTitle: { + fontFamily: Fonts.inter.black, + fontSize: FontSize.hero, + color: Colors.text.primary, + letterSpacing: -0.5, + marginBottom: 16, + }, + scroll: { + flex: 1, + }, + content: { + padding: Spacing.screenPadding, + gap: 16, + paddingBottom: 120, + }, + card: { + borderRadius: BorderRadius.large, + padding: Spacing.cardPaddingLarge, + borderWidth: 1, + borderColor: Colors.border.default, + overflow: 'hidden', + gap: 12, + }, + accentLine: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: 2, + opacity: 0.6, + }, + cardHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + stratName: { + fontFamily: Fonts.inter.black, + fontSize: 18, + color: Colors.text.primary, + }, + statsGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 10, + marginTop: 4, + }, + statCell: { + width: '46%', + }, + statLabel: { + fontFamily: Fonts.inter.black, + fontSize: FontSize.micro, + color: Colors.text.secondary, + letterSpacing: 0.8, + marginBottom: 3, + }, + statValue: { + fontFamily: Fonts.inter.extraBold, + fontSize: FontSize.bodyLarge, + color: Colors.text.primary, + }, + assetPills: { + flexDirection: 'row', + gap: 6, + flexWrap: 'wrap', + }, + assetPill: { + backgroundColor: 'rgba(255,255,255,0.05)', + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: BorderRadius.xs, + }, + assetText: { + fontFamily: Fonts.mono.medium, + fontSize: FontSize.micro, + color: Colors.text.secondary, + }, + capitalRow: { + marginTop: 2, + }, + capitalLabel: { + fontFamily: Fonts.mono.medium, + fontSize: FontSize.bodySmall, + color: Colors.text.secondary, + }, + progressSection: { + gap: 6, + }, + progressHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + progressLabel: { + fontFamily: Fonts.inter.semiBold, + fontSize: FontSize.bodySmall, + color: Colors.text.secondary, + }, + progressValue: { + fontFamily: Fonts.mono.bold, + fontSize: FontSize.bodySmall, + color: Colors.text.primary, + }, + progressTrack: { + height: 4, + borderRadius: 2, + backgroundColor: Colors.background.elevated, + overflow: 'hidden', + }, + progressFill: { + height: 4, + borderRadius: 2, + }, + ctaWrapper: { + marginTop: 8, + borderRadius: 18, + shadowColor: 'rgba(0,255,136,0.4)', + shadowOffset: { width: 0, height: 12 }, + shadowOpacity: 1, + shadowRadius: 36, + elevation: 8, + }, + ctaButton: { + height: 56, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + }, + ctaText: { + fontFamily: Fonts.inter.black, + fontSize: FontSize.bodySmall, + color: '#000', + letterSpacing: 2, + }, +}); diff --git a/mobile/app/+not-found.tsx b/mobile/app/+not-found.tsx new file mode 100644 index 0000000..2fc89c5 --- /dev/null +++ b/mobile/app/+not-found.tsx @@ -0,0 +1,33 @@ +import { Link, Stack } from 'expo-router'; +import { StyleSheet, Text, View } from 'react-native'; + +export default function NotFoundScreen() { + return ( + <> + + + This screen does not exist. + + Go to home screen! + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: 20, + }, + text: { + fontSize: 20, + fontWeight: 600, + }, + link: { + marginTop: 15, + paddingVertical: 15, + }, +}); diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx new file mode 100644 index 0000000..07f1f9b --- /dev/null +++ b/mobile/app/_layout.tsx @@ -0,0 +1,71 @@ +import { useEffect } from 'react'; +import { Stack } from 'expo-router'; +import { StatusBar } from 'expo-status-bar'; +import { useFrameworkReady } from '@/hooks/useFrameworkReady'; +import { useFonts } from 'expo-font'; +import { + Inter_400Regular, + Inter_500Medium, + Inter_600SemiBold, + Inter_700Bold, + Inter_800ExtraBold, + Inter_900Black, +} from '@expo-google-fonts/inter'; +import { + JetBrainsMono_400Regular, + JetBrainsMono_500Medium, + JetBrainsMono_700Bold, + JetBrainsMono_800ExtraBold, +} from '@expo-google-fonts/jetbrains-mono'; +import * as SplashScreen from 'expo-splash-screen'; +import { ProductAvailabilityGate } from '@/components/ProductAvailabilityGate'; +import { createMobilePlatformSdk, mobileRuntime } from '@/lib/runtime'; + +SplashScreen.preventAutoHideAsync(); + +const mobilePlatformSdk = createMobilePlatformSdk(); +console.info('[mobile] platform bootstrap', { + productId: mobileRuntime.productId, + tradingApiUrl: mobileRuntime.tradingApiUrl, + platformApiUrl: mobileRuntime.platformApiUrl, + sdkReady: Boolean(mobilePlatformSdk), +}); + +export default function RootLayout() { + useFrameworkReady(); + + const [fontsLoaded, fontError] = useFonts({ + 'Inter-Regular': Inter_400Regular, + 'Inter-Medium': Inter_500Medium, + 'Inter-SemiBold': Inter_600SemiBold, + 'Inter-Bold': Inter_700Bold, + 'Inter-ExtraBold': Inter_800ExtraBold, + 'Inter-Black': Inter_900Black, + 'JetBrainsMono-Regular': JetBrainsMono_400Regular, + 'JetBrainsMono-Medium': JetBrainsMono_500Medium, + 'JetBrainsMono-Bold': JetBrainsMono_700Bold, + 'JetBrainsMono-ExtraBold': JetBrainsMono_800ExtraBold, + }); + + useEffect(() => { + if (fontsLoaded || fontError) { + SplashScreen.hideAsync(); + } + }, [fontsLoaded, fontError]); + + if (!fontsLoaded && !fontError) { + return null; + } + + return ( + + + + + + + + + + ); +} diff --git a/mobile/app/chat.tsx b/mobile/app/chat.tsx new file mode 100644 index 0000000..2adef37 --- /dev/null +++ b/mobile/app/chat.tsx @@ -0,0 +1,323 @@ +import React, { useState, useRef } from 'react'; +import { + View, + Text, + ScrollView, + TextInput, + Pressable, + StyleSheet, + KeyboardAvoidingView, + Platform, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useRouter } from 'expo-router'; +import { LinearGradient } from 'expo-linear-gradient'; +import { X, ArrowUp, Cpu } from 'lucide-react-native'; +import Animated, { FadeIn, SlideInDown } from 'react-native-reanimated'; +import { Colors, Fonts, FontSize, BorderRadius, Spacing } from '@/constants/theme'; +import { chatMessages, chatSuggestions, ChatMessage } from '@/constants/mockData'; +import PressableScale from '@/components/PressableScale'; + +function MessageBubble({ message }: { message: ChatMessage }) { + const isBot = message.role === 'bot'; + + return ( + + {isBot && ( + + + + )} + + + {message.text} + + + + ); +} + +export default function ChatScreen() { + const insets = useSafeAreaInsets(); + const router = useRouter(); + const [messages, setMessages] = useState(chatMessages); + const [inputText, setInputText] = useState(''); + const scrollRef = useRef(null); + + const sendMessage = (text: string) => { + if (!text.trim()) return; + const newMsg: ChatMessage = { + id: `user-${Date.now()}`, + role: 'user', + text: text.trim(), + }; + setMessages(prev => [...prev, newMsg]); + setInputText(''); + + setTimeout(() => { + const botReply: ChatMessage = { + id: `bot-${Date.now()}`, + role: 'bot', + text: "I'm analyzing your request. Based on current market conditions and your portfolio allocation, I'd recommend reviewing your SOL/USDT position which is showing strong momentum.", + }; + setMessages(prev => [...prev, botReply]); + }, 1000); + }; + + return ( + + + + + + + Bytelyst AI + Trading Assistant + + router.back()}> + + + + + + scrollRef.current?.scrollToEnd({ animated: true })} + > + {messages.map((msg) => ( + + ))} + + {messages.length <= chatMessages.length && ( + + {chatSuggestions.map((s) => ( + sendMessage(s)} + > + {s} + + ))} + + )} + + + + + sendMessage(inputText)} + selectionColor={Colors.accent.green} + /> + sendMessage(inputText)} + > + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: Colors.background.primary, + }, + flex: { + flex: 1, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 18, + paddingVertical: 14, + borderBottomWidth: 1, + borderBottomColor: Colors.border.default, + gap: 12, + }, + headerAvatar: { + width: 40, + height: 40, + borderRadius: 12, + backgroundColor: 'rgba(0,255,136,0.1)', + borderWidth: 1, + borderColor: 'rgba(0,255,136,0.2)', + alignItems: 'center', + justifyContent: 'center', + }, + headerInfo: { + flex: 1, + }, + headerTitle: { + fontFamily: Fonts.inter.extraBold, + fontSize: FontSize.body, + color: Colors.text.primary, + }, + headerSubtitle: { + fontFamily: Fonts.inter.medium, + fontSize: FontSize.micro, + color: Colors.text.secondary, + }, + closeBtn: { + width: 36, + height: 36, + borderRadius: 10, + backgroundColor: 'rgba(255,255,255,0.05)', + alignItems: 'center', + justifyContent: 'center', + }, + messages: { + flex: 1, + }, + messagesContent: { + padding: Spacing.screenPadding, + gap: 14, + paddingBottom: 20, + }, + msgRow: { + flexDirection: 'row', + gap: 10, + maxWidth: '85%', + }, + msgRowBot: { + alignSelf: 'flex-start', + }, + msgRowUser: { + alignSelf: 'flex-end', + }, + botAvatar: { + width: 30, + height: 30, + borderRadius: 10, + backgroundColor: 'rgba(0,255,136,0.1)', + alignItems: 'center', + justifyContent: 'center', + marginTop: 2, + }, + bubble: { + padding: 14, + paddingHorizontal: 16, + borderRadius: 16, + borderWidth: 1, + flexShrink: 1, + }, + botBubble: { + borderTopLeftRadius: 4, + borderColor: 'rgba(255,255,255,0.06)', + }, + userBubble: { + borderTopRightRadius: 4, + borderColor: 'rgba(59,130,246,0.2)', + }, + msgText: { + fontFamily: Fonts.inter.medium, + fontSize: FontSize.body, + lineHeight: 20, + }, + botText: { + color: '#d4d4d8', + }, + userText: { + color: '#93c5fd', + }, + suggestions: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + marginTop: 8, + }, + suggestionChip: { + backgroundColor: 'rgba(255,255,255,0.05)', + borderWidth: 1, + borderColor: Colors.border.medium, + borderRadius: 20, + paddingHorizontal: 16, + paddingVertical: 8, + }, + suggestionText: { + fontFamily: Fonts.inter.medium, + fontSize: FontSize.bodySmall, + color: Colors.text.secondary, + }, + inputBar: { + paddingHorizontal: Spacing.screenPadding, + paddingTop: 12, + borderTopWidth: 1, + borderTopColor: Colors.border.default, + backgroundColor: Colors.background.primary, + }, + inputContainer: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#161722', + borderRadius: 16, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.12)', + paddingLeft: 16, + paddingRight: 6, + gap: 8, + }, + input: { + flex: 1, + fontFamily: Fonts.inter.medium, + fontSize: FontSize.body, + color: Colors.text.primary, + paddingVertical: 12, + }, + sendBtnWrapper: { + borderRadius: 10, + }, + sendBtn: { + width: 36, + height: 36, + borderRadius: 10, + alignItems: 'center', + justifyContent: 'center', + }, +}); diff --git a/mobile/app/marketplace.tsx b/mobile/app/marketplace.tsx new file mode 100644 index 0000000..3cd4d90 --- /dev/null +++ b/mobile/app/marketplace.tsx @@ -0,0 +1,229 @@ +import React from 'react'; +import { View, Text, ScrollView, Pressable, StyleSheet } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useRouter } from 'expo-router'; +import { LinearGradient } from 'expo-linear-gradient'; +import { ArrowLeft } from 'lucide-react-native'; +import { Colors, Fonts, FontSize, BorderRadius, Shadows, Spacing } from '@/constants/theme'; +import { marketplacePresets } from '@/constants/mockData'; +import AnimatedCard from '@/components/AnimatedCard'; +import PillBadge from '@/components/PillBadge'; +import PressableScale from '@/components/PressableScale'; + +const RISK_COLORS: Record = { + aggressive: { color: Colors.accent.orange, label: 'Aggressive' }, + balanced: { color: Colors.accent.green, label: 'Balanced' }, + safe: { color: Colors.accent.blue, label: 'Conservative' }, +}; + +export default function MarketplaceScreen() { + const insets = useSafeAreaInsets(); + const router = useRouter(); + + return ( + + + router.back()} style={styles.backBtn}> + + + + MARKETPLACE + + Strategy Marketplace + + + + + + {marketplacePresets.map((preset, index) => { + const risk = RISK_COLORS[preset.risk]; + return ( + + + + {preset.isPopular && ( + + Popular + + )} + + {preset.name} + {preset.description} + + + + {preset.trades} + + + + {preset.assets.map(a => ( + + {a} + + ))} + + + + {preset.tag} + + + + + USE STRATEGY + + + + + + ); + })} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: Colors.background.primary, + }, + headerSection: { + padding: Spacing.screenPadding, + flexDirection: 'row', + alignItems: 'center', + gap: 14, + }, + backBtn: { + width: 40, + height: 40, + borderRadius: 12, + backgroundColor: Colors.background.card, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + borderColor: Colors.border.default, + }, + sectionLabel: { + fontFamily: Fonts.inter.black, + fontSize: FontSize.micro, + color: Colors.accent.green, + letterSpacing: 4, + marginBottom: 4, + }, + pageTitle: { + fontFamily: Fonts.inter.black, + fontSize: FontSize.heading, + color: Colors.text.primary, + }, + scroll: { + flex: 1, + }, + content: { + padding: Spacing.screenPadding, + gap: 20, + paddingBottom: 60, + }, + cardOuter: { + borderRadius: 28, + }, + card: { + backgroundColor: Colors.background.card, + borderRadius: 28, + padding: 32, + borderWidth: 1, + borderColor: Colors.border.default, + gap: 14, + }, + popularBadge: { + alignSelf: 'flex-start', + backgroundColor: 'rgba(0,255,136,0.05)', + borderWidth: 1, + borderColor: 'rgba(0,255,136,0.1)', + paddingHorizontal: 12, + paddingVertical: 4, + borderRadius: 8, + }, + popularText: { + fontFamily: Fonts.inter.bold, + fontSize: FontSize.badge, + color: Colors.accent.green, + }, + presetName: { + fontFamily: Fonts.inter.black, + fontSize: 18, + color: Colors.text.primary, + }, + presetDesc: { + fontFamily: Fonts.inter.medium, + fontSize: FontSize.body, + color: Colors.text.secondary, + lineHeight: 20, + }, + metaRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + tradesPerDay: { + fontFamily: Fonts.mono.medium, + fontSize: FontSize.bodySmall, + color: Colors.text.secondary, + }, + assetPills: { + flexDirection: 'row', + gap: 6, + flexWrap: 'wrap', + }, + assetPill: { + backgroundColor: 'rgba(255,255,255,0.05)', + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: BorderRadius.xs, + }, + assetText: { + fontFamily: Fonts.mono.medium, + fontSize: FontSize.micro, + color: Colors.text.secondary, + }, + tagRow: { + marginTop: 2, + }, + tagText: { + fontFamily: Fonts.mono.bold, + fontSize: FontSize.body, + color: Colors.accent.green, + }, + ctaWrapper: { + marginTop: 6, + borderRadius: 18, + shadowColor: 'rgba(0,255,136,0.4)', + shadowOffset: { width: 0, height: 12 }, + shadowOpacity: 1, + shadowRadius: 36, + elevation: 8, + }, + ctaButton: { + height: 56, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + }, + ctaText: { + fontFamily: Fonts.inter.black, + fontSize: FontSize.bodySmall, + color: '#000', + letterSpacing: 2, + }, +}); diff --git a/mobile/assets/images/favicon.png b/mobile/assets/images/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..e75f697b1801871ad8cd9309b05e8ffe8c6b6d01 GIT binary patch literal 1466 zcmV;r1x5OaP)F>1w{Y zBeHf{*q3<2*AtQf4s&-m0MsH$EBv51Nj=s=Appw|nd1Yi(-DKZBN$9bAlWN83A_)0 z$4U=S!XyBuAm(`t#aW=l*tHPgHRE~MrmzGWN*Eidc=$BV2uYe|Rpi@t-me&ht6I?| ze$M(9=%DxSVTwNL7B*O`z`fRE$T)18O{B^J5OHo#W%kD-}gAcJO3n1x6Q{X*TFh-d!yx?Z$G16f%*K?exQ+p ztyb%4*R_Y=)qQBLG-9hc_A|ub$th|8Sk1bi@fFe$DwUpU57nc*-z8<&dM#e3a2hB! z16wLhz7o)!MC8}$7Jv9c-X$w^Xr(M9+`Py)~O3rGmgbvjOzXjGl>h9lp*QEn%coj{`wU^_3U|=B`xxU;X3K1L?JT?0?+@K!|MWVr zmC=;rjX@CoW3kMZA^8ZAy52^R{+-YG!J5q^YP&$t9F`&J8*KzV4t3ZZZJ>~XP7}Bs z<}$a~2r_E?4rlN=(}RBkF~6rBo}Sz7#r{X49&!gODP+TcB*@uq57EII-_>qWEt44B z`5o+tysMLY*Dq^n@4_vzKRu3We5|DI+i%NV=Z|)QAl{di_@%07*qoM6N<$f(5Fv<^TWy literal 0 HcmV?d00001 diff --git a/mobile/assets/images/icon.png b/mobile/assets/images/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a0b1526fc7b78680fd8d733dbc6113e1af695487 GIT binary patch literal 22380 zcma&NXFwBA)Gs`ngeqM?rCU%8AShC#M(H35F#)9rii(013!tDx|bcg~9p;sv(x$FOVKfIsreLf|7>hGMHJu^FJH{SV>t+=RyC;&j*-p&dS z00#Ms0m5kH$L?*gw<9Ww*BeXm9UqYx~jJ+1t_4 zJ1{Wx<45o0sR{IH8 zpmC-EeHbTu>$QEi`V0Qoq}8`?({Rz68cT=&7S_Iul9ZEM5bRQwBQDxnr>(iToF)+n z|JO^V$Ny90|8HRG;s3_y|EE!}{=bF6^uYgbVbpK_-xw{eD%t$*;YA)DTk&JD*qleJ z3TBmRf4+a|j^2&HXyGR4BQKdWw|n?BtvJ!KqCQ={aAW0QO*2B496##!#j&gBie2#! zJqxyG2zbFyOA35iJ|1mKYsk?1s;L@_PFX7rKfhZiQdNiEao^8KiD5~5!EgHUD82iG z2XpL^%96Md=;9x?U3$~srSaj;7MG>wT)P_wCb&+1hO4~8uflnL7sq6JejFX4?J(MR z(VPq?4ewa9^aaSgWBhg7Ud4T;BZ7{82adX7MF%W0zZ_mYu+wLYAP^lOQLYY@cUjE4 zBeFNA4tH1neDX`Q|J)mZ`?;#~XzBag&Di1NCjfbREm)XTezLrDtUcF|>r`6d+9;Z2K=0gYw6{= zO`r(C`LX~v_q!oQTzP=V(dpBYRX_m=XTYed%&nR+E%|WO3PI)^4uPRJk7kq+L(WmAOy(ux(#<@^3fSK25b1mHZ&DAw`q0&a5 zXU$pWf=NbJ*j}V$*`Y zMAz4Zi@A4?iMs{U8hRx*ihsZYHPTpP)TpG}jw4o_5!ny)yKkJoo=Bir+@d$gzUtPf z76rl^DOsUwy9uARy%q+*hrZZzh_{hGBXepC05GjPV+X0aCfbk@fQWuf;3wQF@_yMe zt5AXhdB6CNa}=s;{GA3bi9jK8Kx#cdW9+*ie&)lhyA|*h09Nk?0_r>m95{nVXO$6+ z$R>+ZL^ryBs*)RkM6AqpNS?#{nnq$qo^Vt5G+ytRnl4dc&s0sMr1WG4?WRPcp+ zP;4wHTl?f)^!Gj@FV%`g0(eGv;HbO<_}J0}FndK2L|Kcxs9q1mJ&rMg$cKcFmX!S! z0vJ1OH3owS*d>`!`*;8rrX8t`(L`=H!AifKdlcO~&e#f~Gz*D+&)!2#ud^j$6ZANS!q}@cvw*7N5+0Q4R zvKIiqx03&fsKF9NtB8=DY2R$GBF zFO>1hO8{sMa4qRW4rz_ZeDmKOIy>H_iVr#{5#Sj@pJ!sj&rhsFLFP!^^K&|Dr6uLtPu&2WmLoOp+72f`> zM88yjBZc@DHb&cF31E_s3Lc>O?h=~(jh!O*kcTy{W=1>28}m0z!NXv!+39S{1Oo=094 zX=(h?=(7}XGb1D8Le$|=j;d-;;crtG&kl~$1R;+jNJ~%pbCYscUVDFEU78K}k--e# za(QZW#pp2ud*;SAz*bwBzqqTRikI2Y#5?gmB4!gw{q?IKxBJ$Ekk*C1u@L4^va%|d zg`199czf=a{W_rZV(o9cO3-ss^nlj#!JCtP7Us%{K*#UAfC_J8t8O95*4X1neL!uT z7q+4#870U_4@PTELQHYcP!d#&(5s=1xX@nu4~{P ziXP#%91t7KLLnvdo!MHcGH5gCyUtMXC>j$4q!W8-qKL+{QA?W|P_g@&o};Qr{V>;Uw00_+`9LV$n}g$1Wz-iO^%O9@tw3qx-3ufU%wo0W1X6 zd5hj=!1>$2#x-W=@#r)rb>i#BX;&5+G{ip^1}TzYa#zzvid~=DT3juEZzPd*Ptx5PlmOekc^%T@qfGKnX zVLtTc?`|*HLs@&g^HLc-XM;hT*okFVoGV>Rk7|YR#rP|>d%?%Ac6a6tD?jV(PEM2| z)!GQ%0<#4uaBClL!}ieEL#lNYchYI!%yOx-k)Hrt@v}`10WkK6dpyGbIn3J}K<9>6 z&Qr3w#HH4O-)FlVQbmE0IsYU?*2#U}c**@5bJg+B;Z3a{C!Wn z%}5?fNU7QX-m!{(5YE8DV9$RRbxu+^pZ&ZnAiN>7Ej;=f|mchq~oo_duHA zm}UoOBhc=BYSg6-FC`~!vzKFuZxq)d%0s_mkb=8gcX@+)g%YXM+P;snBBP?OLzICI z^nONGyOXmz_6V@ewl4VaqES4q;1}i2cE%ze0*luwQ@4j=-woV5=th~qD7<$}vxHqH zki`K3_K?tAp3?w8qw7CdG)(7lggoq>PPlkt@rNqVm`Ycg!CT9)9T8abyZIZA;Y;5m z%X*dax+I%)X7Yjc(a(`}0da228T?%A)(62CEkfr13$PzqKi>>_-(@aRUSr2JRNn||G!L%}1dKJ|E9+0HUy|x0-9#8- z__=}bb&@;)o<6PQ+SsWesX{>caBlo2%~rhkUU6n+Pfy5N$X8vK18kZm*^~XJsG(og zBO`Kur%3CE5}R|r$by?(@1|{;bLg+dG6WvJ5JO>#SNDdi)Mq0e&KQ?o%pyICN1`}n zIPG++itoD%6Zjho*jBp)LaVIDkPL41VQx_s+y{K#ZZMFUJN!!59D>C?pv3!jpgav( zrWmF`%6QG9&{*|Y2TOEg;yXX+f+FH}@zJ?z;cQ;60`OsF+Pun!-_^Oh_aQkQeRK|! z@R;}3_d5Uqj>@W;{SAaq0{e2oR($}c?m}x>mw3U&EK8p zbDNT;)(io|2H)fID;xYi(7M`Pl2^igo1pxecivhQoZrDJYYqKXg7)kPm6M}H&wk?1 z|CR)0PYBK27ml4L*mD4!ulgjD!q2H)&b>^b(Z}^4enh{P^oa<(*DW{p)=!K!Cf2yxArAy8esW_t$!wO}OC;g>-Y;p?(8K5Lqzo zVOhL8FZn_oA~?Q9?Wp}%Z1Q|bKd}2%!+#WJCx^^$C*0K6QZ2#Lm}2_VciwAguz0^a zyw?EN>H_b-HZ}3A`6@(yG~8IYa)emU9NjV=esnMsEpL5I0ZtmYfC8%y6>s_lxxw#E zG^q&>1%X%Rq$(&YCp2v6OnGR-mI-$;?ekV}$>8saMk6~@idK;{+s(Zq?`iUsro#Rn zzK=vUonDa1DE+ob8@-xJ^13dF>)CrThqq%v97t^q4e`&PYde{8V33VaZdX`=oBAPu4=@9clN{P5AM&b z`|?IsKKKQs>6f)XqgFHWEv{GF=(s$!WorDO7lh60_n?q_z;I`mZq z*dn<86V%zQ*m>k6jwwD*+Tvl&G&c*s)!Qmq5P(FqOG?8SR457Mh3XI}o* zNHJnfNc3rddr4S%F5TL`3ttEi2p&B*92mBV{y_fFcD~9Cc1oH&eyi!@W)XDmr!-Lc}2ziivlJ7K)m%-)5hd*#%qjqpv-I0wp)Ww;Zmhe}i%+uMaYSzlf15j7cS4Lcg zSw_~_f!|o?!98lFa72N~m5HV*@680?k@kjT&o_ld&VK=i#LoRgmXTJI{t}u-HdRZ?xP84*Y8~` zqFW_yBG2VbRtq|$md@m7E{$t7b^3%Cqa|@prg-_BqkTptrIu-ROancLO)(0 z`=1nJO?$p%(=%NhuS`x@r3G||Oy!YPtYHd3F8}Gpd5? zgBlTI*{@j)(&e2)r%evo5bP~_(UYOO{MQk^fQqpvQIEd=s`Y7!rEyHF6#dd&lqXBj z{|hLWB%YCqcVlq&AE8P_$lodI-p~4@dR;nHMQ2FmIOOL`<)D1t5VfCd_YzcanOlBt zsL8m#o5134a;vzx!oLHR`N~~sP@WwvT?bz)a<^pV!b6r$f9^=S!iu>(V~l$UF_QW@ z!jio9i1}8uto)xGyTH-HFBncUqGi4lrD{Q`&u+;dL z7?|h3?1oggBM*H{DI5sULUT1H*YkzV_qLG^sc%iIgZTIw;OSOeyh1tMAY zSE>_9do_gknQA?7{grd7)rmnvoMHyAhTAnruXGW5CH(TqWX~?>l+3`Z`IZ{MAO_}t z>z0mi4wXAv4ZRp4DOLP=OH9o7w>!9tx#eDG2oy4Ma3!FI|DH(Z`MZqlPjidSN?!+$ zxAP0oI8On(1j=wbLHW9&CxWKM7y*dfaz2%0e>3Bk9$HH+poGt8IM4O2Zp!L+{o>)TGM-lB`>PR8Dne1b=v{V}GsGFDR6 zL?jl3X>eP9=IXDRx^qg$yDfIGM{KhS@4j*WHp6TdG>Mie2RHg82( z!YwvpPJtaPNlyo|V5-ByJ~FNdS3jtrR5LFZZFjc~l%lkvldKPru(A4oET?;Mo0KeZZgt?p`a4@) z)CnT%?S_k4DegHCHilm~^F_lg&w*-=5wnY--|%|j;2c`kM4F~{#!A9F)TLy9i5Om! zGf^3|Fd`_!fUwfTJ2E~!Q?Nf4IKX|HVM;0LSu(H^|202t;=Pkd%$wl(mvzH4!mEbw zygM6z8hzkanzrS;p+34V;Ahu&2H1nB;i!W~D1yw={CxUbmC`pccY_aa!KB#G3x?Ji zjkKo#t+c@lLa%4C|1#`FT!RHCmzUmffD-n|KTh5?_aJ_j@Nf4G@ZKA5hRyL~KE=D;$L6#A z+anClym(vFCUa6`mh2H+eCQ}j7N2II_7beG;%^FrtEsL|yur#E`@#U~)2`~Y^efsA z&Upac9Y>`9d312?bE^)0sxhayO07&;g z#&4bUh`Z(-7Y*$M_{0jbRs9@D@;s;4AI~j|qj`T1G9)vhRn0lBf&; zDThp@IKRj>^IItes}_6lK!YanIoN&LGLU&fXeWbwO$Lw+3`D`~?+tZ)+C3D*F4VD! z!YA~jLKQc(iUKMbQ${@@%PvI=Cvet*TcTe`3Tm9?Jw8D`#1kU0%T!+yTD58D#$S?< z08SIHoPJ5$Fu7)8-82N`9ssG(k|}5@(`$kkOa^DI=sjZ>mJDIzT@2*l#~G!|Y;P30 zEuj{><|Y7e0`>g8mDh}S)d-(egD^KCCcoEcx=L42Y*7{IQPA_2Gj63jC*yH7VYxse z^WgiuLu--n2w?CMkhX~&mpdQ?WAV5g_oGDJALfosHq;QF2`+9#-&$?d77|K|-T`aV z+KtI?WJ6w|m{mH^#phJS02_?+l7+Op8`d)%&%CXKh)>}rVP{1RNQ;v^0vU&c_mg}) z=~Xr1v*?=v8`h%Z(4W5)bGiKujAq3i}g-nmv90otzcnAI&?}v10NoRzG$vHYtyd4DyePWNt^4l%sO^^H!E(f~f8VWd6 zaJO8ZJ&I;+fTqUsn|B1gu%75Zzq_eGBQ(ZuR)Zt@d4&PdgiG-=F~!N8!zgM0#=p=> z+GPqp`i^As;$u*G^A&%^ML+kf0E*Dj;~-lx&ovlnsXlm+u4shDPz!rV$sP&RKi|8G z|6ruV{hm;FVq8i|l0F6a1wYu8{yckALq*+Y>?Xe)`jeFxXP#11gM(6xUBeSk{Uk!krUo5_7H>e;Dv&W$_2jrFH?#*z2jY zI#JyAOQ@r-f0EX@5RWJ8!L|#5xZB3zS2t_qd=bafdoDfGk8lF3pL8KAZ!a4!!pgf83>i5Pu zYMyimE!m+Pmb_Cldje-6xU_|0Y~>W12^QzJUQ%KCfn-h(j9E~e3Rza5+0iCjw=GkR zllb*}Z;86cW~@;2#H$^c?SJjen|Sl%_P;(afLk#HkXSF6^#|7u~~%Oy-b&-M3mB zF)Nw4XIen0`tv16 zUQginofO=-m#!+HAyx5_)7k><*g@oL(=yTyqlA8~)>yHvh1y^rUuUl|# zX@i}tPv7iUsqQXZG$9MxrNW8?H{CBD{?0gIv|}eNLWrI3|6z_KZp)J8kIAx3`nI`v zt!LS*vFdaj6)Dg7@H4xJox2zl%!i(imn*s>~@mV%AwKd#8KUFwB& zsSP3wcW}%>|F!f^RigSket-v+*WKx%61S80a{Wkv_#Epof`lZKNR<`w^~r~xkgQ$3|sxDc|{U&nVydhl3 z5zEN}oJ`pV{udB9#Pgu;WrF(!CAP~yte|3PJ3KnMU4zxuhn{w+$U_6zeNK0}-V(8T zgBs86T&@CVG+5dDki6y_0YK$NCZ?s>68}OCmdv1jjBwgApk%Vl5O&WmNnmUbPR9p= z8=TL5VlG1b?Z8?9uY5Fb#-(Ca&__o^EzC02_O!n$pmUEcluV)@_mE8G_r7g{ z_dMXFp3`5VcBcz&2MP)FotYrnziA%ADhbT`;&Ak?>a(iE$j4wQ3*>1=%u=6@W^d-C z%A0mJAG1qSL9I{~*5uT(0rwc&$7OB58ZO&-S@Fq*eJO+;gL|V0+B|VwE|{mlwy&vl zgIqxW`{S9=(Z_^TBe@wDxibSgU!NH4kui-Vtf02zv`cDBj-yuqg+sEjCj|C`%bCEz zd=kBf@b^zG#QC+Y^taq&f>5r6Jz;_Y0JF+M#7-rxfdn~+_XuFj7@zDz7Y!k6LSo$4 z$wm>j>f*QauR^_q@}2~WpSig8*rvl1v^_a%eD5pXhgbDkB`mompqC=tJ=rz?(E=S*zcha14B;fw`=0=Vl# zgMX@BccXu%)OHr^5;@K=bbFX5Nwh7X0Gt`DcnnM4LDq?(HMn}+Yi>c!UV>MgD~62( zz*Zgf$8KU|VoDT#%^svR|3%G4!?Vu%0#YboHfZpIV5L%~V?g6=gDp91Zq2Vt2(x1M z77X|ci>WCA|J04*{}gkXhJ5ILR$)pUeJ3mhMt&Xtgx`FX(a=dzs9rdk8u90I*_@`_ zth12y2|+N)Lf?KMI)~=XJBIe%q~Mol^c#HbRX7E4PlS>4x)3$T;RmP;F(BMKK*SE5 z{)0t5YoK5m;t(td&e9&^*&9*FyHA05x1VDD!sk8c5ktSwKpC`#vG$jPAetb*=iBy$ z>&Mp?mGMJs`6l^9tOa09&^^SVUc7i}h&4SyPuUxD)YFkzn1md*nE@dxAxDv_bBOk# zXqA9%{Ai@0-zGeif6w7I41QxK3U;xSpq=7%(x1Iq)vdNoU}xemV0yJ zp7HDQfyym#9qDVe6<{;O0bJ|9IPfYkoIxYRY=XToDSunStmuT3fFT64FNWDKgmGvD z+f6=CH$a|_tey)ajUTUAI=(O7+LKn>f5AQEF3Bh7e8pbYAwz~5egE7&ptm+z-r ztWoekP40Rl7K4-YzWjX{be8rm34X7}$`P2iORL~tixDmlq;Z(fG2o+6@qWrhOStVH zbFcjxChq=9_whhS;w4xF7=1W?>Tc(uzAY@zJVX0>TUFAI4CAZ({12O=K;08G;HA}m zTle>T!oaprs}9KTCixt#IrR`=L^qo~CFr$2!*6|hf=&oCk!lpxnBpJVeO(9`3TWUz zZDza?g3o_-DtI#na}{pxV%bgz{6@2-t|V?A&nt_S1jF1s{BopN-!rP?!q3KJq+J4X zTV>T0fuo^!)nIXJJRwXu#an<$St-rAHVvxLg<$z_;7-Ff&?=hkh+PKb3LYhn3(357 zDnQd1arx>TLs}B3|G?tC_R!SP-r zw?k?T@6*IVnPNzb5UjxT#9LtWdM#V~D+v|Cun;5jN}Nb=>u(MG@@Zs%8>2HGlbMu= z`%Pbj7}DG~>bwy~&0C>?Y z=Ebap803V9nrSLWlB0m#wf^lDz8jeR{RNkf3n(pvhmRn~{$~@9B*CW6Lj1A~xEO;^ z=ahG9j{u)sV1->1D{F1bm&T)d}DZNCGRjEBpw}K1i|b z#T=G>O^6Zw1^7m}Pk2$Y>SfknQS)zt2RC1|i)j${u&nn!|=9;ZYe-{Wb@? zRyg;gyZDsCD0rCvVZ-dYSgc(1$yY?0eT+#-*^ln+xfo+$?4hj+6b{e`mEB*rvx2qX z9?~=^hk9F~>6E?ocXN-Dq-h~r8RbqKX;HY|qIb9lTy|SyZ-7#NpBFz*TM_5lQf9M) z);F*BGk}$qK~up`>nKwFp)PWhrXcOSCYx=j@i-CFkcVdP^uHo)A%YWvm0DE2@HETU zHjUOU(KtnAaHMlwCX7(*v>3IOVPEjZz+L0v-eQCA(6r8gK#Kn9L7Wid&nszI!9PyL ziTfR#&;G2Z3Zix}9E2Ea>R=iYV2mF=G#icUe)U+t1`aNHMD&N(-zKfu5JKNrNWA;; zD(VPWTDdrNo)%%s&&My{$^xWo@;@X(z~dLj8Os#?z~^thrTkOw1PN9%E_P5O4h!NO zBy@|K!p=CRg$#G8$@PhaK*yFm_P-3?xkYFr>*QZc%4{)AGZ8l~^-N}&7=a{dk3!~)!n3yks4(~nhE0wleQu)VTDwl*>Uk^-2Gj4kQ*l>vLAU^j$%7@IaFaE8@0 z3+dWFd@ab3WmUHBX`ruH0!@0wF-_tc5a;j6>m8^&Or>Ib!PR}jU`GZs@`(21VCOIA z1ghU0)IsLDEE=pCSw!gou?-)uI-XmTlYlMum7H#9be#y@S9Yzkk7BU1QZ-%oZLqu2 zECe!NhNpcOm#t+zq#vxuop!(byd(5p^ORt-5ZJlP1>6k*rca9CEfu}`N%b_KCXTuN z_29!yXf20wQyU?cgyCEp%v3?v;9+k1&6qSv(3%$MwtE7O0!w`&QQ*PpCwIn>7ZS7# zqrh~jK--svvT)WJUVaF=}_FZ?L%^AOmN)&-7wBK+d>6 z)}kj_AS$2c9{zGy7*e%GJ_O?{zo2PRrvuWC>0Ol<1q1TH*1chmD!BE<9YRz`@BHBS zC<7RUL#|q%;MW1K$EC-?^h5=Afdb$jVoc9$sw3x@;iCh7avo={xt8I<^m+8XJ3Rpc z|D)s#sNWp|b2q9miZm(EN)T9H-0LLVVLF)G?2qf2mgP5 zk-yAxE#$J{9`irn&WLLP7>oYxSiDE=r<*xqd{b<*Fac1#h^}mZLF8?uaH737@S)5? z>|mi?h-%CRaDIZJFNLvadCv0#^=JqF&qvu4;^Jl*1aV~Jo<(d+q__;9qV=NkHIeB?H;{gu+oLz=pX zF;2vEjY=KRwZD8^Xl(r~SzZKg;hQ$cIk@4V5FJ&&zppbTVfzX9W#IGh;0|*zK6*!T zpVtA%`BBB#-4E*KKz^cZ@Q>y?V0rq7`|W^xl7JRr_8JNy#b168_X^}&7`uVG7m!-X zdqs0_z<-QbrW>Sh4pgq;$FeqW%R@7GuT2Eyv{V>ix=B6Fo&UDQ?G)10{SqOk<@&ww zX6~c2M}^&27F2e${pMltA2fUS84aKHJ6b;o;l3fQfxDO}0!`y{;y|`@ zMTJNy5u`k)Jyip@30b2^MBYS?0Q!P}Bzzmo)_12HaLg}2QauF+2MAk;99YN{Y*83D zZahhIpNPMe5iAJ*A^%!QcNS!$eawnb>8GD$z475a`<4D(qVqsAhyq`Jm7GSi2e+gP zoZZev?JNDqcq!I818$!c$n3&bY-&{xy#T=$>z@r@MpxX}15`o8%Q|ypRnc)yFg`zb zWW9EwA~ib=3R(hopPP_E}og1_mqyHwHqH`>JPK(jK3U+6qr%&EDiuevSEe=wQ=GH}5$N zo5U^;$A2(Hjg;Ki>2wE64xb{|(=K}k8qidag5Dlwhd&hyXk}1ytqnh8&9D)IgPgLM zZHrDnH3OjQm6zS3?Zh0@@93aZ@)S0>Wig43rR{-;;{qcu8eeNA*Pr0F3cT5#IZnE+T~Z>)gy+e_Q$xsj*}TIUz5Bd`7LREo`%zq zT9a88Gs%pwD{P1JIx3n|(r#^f$4|RK_8Ja7pofd^UT5hx9?4Lcgqv^T1$bM=^(We+mGxRi6*8Ipg z;PPw#RQki84bK<0I4w3#gH}D9pW|>1Y>?KhgQ5}|dTv?B9?TlQ^z{75CZFW=<_Yvs zGzfXrCXku~zp?>6_-L`L7Z<{vOv|UCkkYAr0b!rE;4MoA*gG^lK92~tQjF1&*Oq}) z5O0s2K8c4+EkT9>vbF9wwN4eh)z|SKM6=1!$Q^MvGy4c_-0VYPY8~lndlVQk$)e#u z?PQF3bx!BCZ4XWU21kp&^m1HC91tf@k#0SOtg-t9I-lXi-_<;~kJgJixU?RcU;8{7 z@)M2QFejGga0u$h0H0T1rng*P(&Y3{_=a5$ObI8(ZBCE`vD|cn`e&;Jht7I*#T7|V zr$|2v6jZ_1FXA7C81?46k^SBW&w|+^m}^XK;1l1dnS;HitpLUEC5yk7|D#1rm?Z) zg&P;AwTWL*f&ga;qusIEptBAyKKyDj)tEeHpILiMNAGN~6M%P(ZqiPZ2TEH&*-F!f z6~&;}Uz=BW9o6<(jv3^1t+b8E#)LeuErSpReL2(q{cq`vD+;`nG0LaBK*5{QAOcH7 zUKNFR$i479)BYRD_P7*|@&*MrBmhP*pNl6+GX^A1J$kv%>K_n~mjpa$ofX^|jMZ-x zhR+JM$3>Lp3}V1pVdP;Va@ykoNZwLOZg<<7ySZ~ zVrYV0HZ*9ithjz<&v}cP%0$YlV{98R;>_9Cy*(vQ+gCL;J14v1to%<+flFbW0%vbr zo_5p^37EI{dMt4zhH^la(|_;q+!WozZ17sauRU;7a943PDIaP@9w4n&uzcHB$~xZKw$x)E5L>JU$XZtC-K6W9ZQDGil8&(C<^w!V^)6 zNC_}mvjVLH9Ej=bB?$Izl%q`^GT~`|;*Ev9ne1t|>bP;Q`32zS)~`B*DaAd}^>p=r zROYm=E;Q+1XXAUOsrQpBX5Bdcgt3vE5&ZF}asB)Am#G@)dB6Onv9Ob)O@Q-!^zy19 zXa&8d*mDufmCoK zQy(&#k4XGEc*e3Ap5veCHM{#fs}c={uAEz<>Xt!6JVNRrI_sm?-_};^HMAzv6he zzJ7i;H0!YLc4>+P0rtQQE>!bWxL0|w* zjxBAUBj&B>tGyH@JR$r^n(7VekMfOhLK|84th-9kf1JC`pRBJ&vco>0PeDG!zJz`u z4g++no(Q2fpf`%q&7jW%54KY{k>Dut(#ugdbN|U5xZRe70mzQorRg=HWk=iP6OC2qnOWDytmOau8PU9a$_gVr!b=s}mk=^LHAN zhF;wBXZf99rLWu{1tLWK$^{Ew0%_h$OlF}r5pW*?0=>w5=W92XjG73Bx}Be3oxeg} zRkV&?DhK1y_5}Js8x}cRmtea@uSF8NA;9!K&?+9b;T|F2CvT+4zo+z06rq8?KEZbQ zddUG7i`dQ5F_|wO(+GzARU`@HENgRmDL>A3f%H>CqT=hTS}Lzn-y1p4DH8?G_2|n! zpyv`|xDlg^BDgt-#MQfDS^3@q)5L{wFvaoEgIBJUkdiqAA;GdN?`xxt4~$)CyLcOB zi4}vO>Sy34#@Y*Sz6#40mRhLg%XSVt`cNQ>e2GI3hb6?=QN5+4K zpC%y`n~>&je;bM?WJtOA#1L5lFI&=Khe{AEABsK~@kXuHA=Lh1?k3tU=o&mvuTjm9 zmWMOfLn>OF(#pFlN*D2DRB z$7c_YE;}Qfn)l!J)Sp}{oohJ8q%C9~j|7^m-6v$I1rfU{#h2C-EY=eCpqSfEG=0h| z5%I1`VOP1+(tk(ACyD!%`X*7_&=2{&-%RPrK#rp=_TH4T5_1u{p?FcOYIX| zbam;>yyqKFzaTY@vvKH7%3fMd5>K7Hf1!``V7EA{ z1wfp4Pd!A;Kstvm^z=AAQ1*5zEXWGy2d^#@?rfFeY!((vGw` zDdT0qa^$BC;Gifg9Q@PvUrwx3;fP1DOkGH%a>_$x80qX}tQ$WJ zqe865Jb3J)%JpLfw}t%onQ4aI-(#IaXaw4%-Wj zXg>WbwKSV@FpBojDzRtfkBig2*_t*vo=bXyIR~e^$P103Eb$Pt+CW70YAj z2_gq57u5l3KlPY-`|l|}%PI9MSgD17lw4kCb?wW*&EhW0PM;6Dra9|#Q?C66l>%!g0MA-f46xZaAU@`@OSeBho_TBL&2DXRGdheZ~P(Z)}XJq2Q8k=q8N$` zL;S>jYc@wOBwOe}X9xwDqor4g`L{f4FEpuYgH?i0pUe6+hH{yNRtR=G1QX0kgH)dn z-gA@VWM%~2QX#znU+mL*T@=@v&B{d8La-YDWGrFV{t}w*l#8 z-8?eqS=B}mIRCXGtM~Uh!7C6jhqjwxd3qg;jmUmql_zVIzej$q|KOQuKS>LH_iO>! z0=pZ|T^wbx>dF+n`hh?MX4H4-%n6Zd9&9?WSBt>!g`QqQ> z+xI;;rbR0~ZERT1-|?FBAjj(P10exmQ)oM>6!UAl{(@=qiKoHbC&7ivr-yQmUkmmq z%*fv%Z@LqtC7oz^dYMobXqf)7$XW+1xInOVZtBl#^8-~= z&Y|KAqijRzdGE0*3-K*(A{E+KDC1$wAXVdylLr{zT1oub<7J-e1dW{R*oeDV#2M96 z&Iu%*@Z@Tm1%nTu&fH&(7Hl&(jI-qP51t$R}hJ{Z~{i+tbob)(Tr zZUAZs`y{LrcqY&RJoxQPTcft01g4pIz>Hn=OMxH&BKtqJsb<0&ZX&FPl<>jE7jDQ` zpwnujjafn{#H)fL!|FiApOcyY0DC+;zXOrekddL+Z~89FHeTykiP?athQ^tIZ3HoJ z2ULxy4orq4KEHK>-fM_YX*k~^%3nJbL2GECl6s7~5y(Q5ZK?wOnaIe^2~P*qtV6(V z1&;i}eS%2vHI@k<53C8*k%dEYdE^TZif;Jdy&Wb`4-~M5ix!&n4z6IDcJ zvt)%^3k3MK4AmT7z0dE|qTaldwnj6~l3bq-X|iAr?+Gu)^;NSbN0cIUg}S)0*AMg2 zYHjzT)5WyI1XJkYZR)zqDw8UAz4cu9Xg6dU*%CZ~>20c>Y~yD?^oI6%+u?H0VQKwA zy70#FuKY0~`-2uy2}&cD%wE4^Nj_-p zRhJ9BP%vMZUr*6p(T!7A}v3+URVm6+e?B9Q7i3|P)NaorWDmpz;PX(cJ> zs_kx9aqq|7+_0P{a^$`{LjE+~%>$i7SV^j45KN^Oxx&G&d5Tqp3mdp8MIUUmPa#(x59Rm$?~Jh*N`sHcsBBY~3YF4KF(k=0&)Ao=sG$!j6loq>WMrvGo4pt_ zV+)DWC?5$$VGxOIX;8w5!OZXR{eJ)bet&<>eeQXm<(@P5dA;s)&pB~b@8zq=k*{~c zo+b+Tevv7!NP6JD%7%AOs(V&|IPxsbt&!1pqdFp^TlK813HicpPm>MQ1F2%`LqB1r zzNi_M+VX?0=`=z^S*pU!&kUPN*naNY3BNQddunqPbsf1*bSt5Ur49S@8~<@K;caS! zHf8q++8mVo(EDf>o7!x-Y=sqzJiJt?>}v5#mla&JBMMYaHoB~asR6bYlOuN|h_R?? z&O~~^GZtRqs-nh?^O)Svt-~4TMhQ)eH04F?>z{1MB*r~YAlrxgsR139W;MNnuJAJ} zco#7P;jt*eaxQ)MQRs6ewODwL61f4@{Sh;Pg$_0)K>T@%p{wYHhgV&3IPNn>*Agog zd>k^bhS)T5mawZ}@B?Vuf=ntXvUs-&^Q8F2z7?DyEG9!rF5v(<8raq`BRp9wtK}

_m_Cz!aI|OA~=>rPyDZB}LviY`DTRyq;E+O1bb*mtHP+eDp`ie;@gD)I~c+6GFbPa%hM z`8Vex*~}cS+digqY0sJMuZM`)j&b;BN&8Bf8ycw7yWTmLRzF2`&mV!i;_!0GY1hGp zb*$&h%G&BIe^cNQG&UZZL;uTN8%^xvNkkx~^#*AkS2X%ziIv8gqo$-Nk*@_^rPWH^ z*L)RAHm5TNw>h1~z)`GS!g!lHyu<>rZ>9iOrAIRH!X2`(0Nu~%Lxif$TC5$#DE+cE z{ijLX5#>7=*o}4n?U~M}J*BAU9vkM+h)#@@4!X98>sImyC=SSCNgT*sNI%C2T>i<-!9=`VB~MoE;PLJfXms7b`3UkFsopktZsUu2`1dq zLkKAkxB;K`WB#D)vXr>P;vI^hlReihTzq^o^ujke-_P4>d&|7Z>G0neSdVpD=_A{p zzaXC1y}rJtmP2<8MZ2q_YZJL9G7Oh;K{yL5V|e}*m1NTIb3GA>WrghgOgWuW{3aYU zC!vPfD%{X@ANAJ&0p;vM@vCuDDUKM~vORWNZI%l6eB+aw;A5p(Le52ja>c7Dso?Z& zwJa(*Ju3oD?8P4uRoM4M$N_2sO2~Y$I{|HGih=XE!=%b(>#B&zHELo519p)LB}gf- zIcriktD7O1*bNvLRB?xUzAHNJL=zjS55!G$oTK{=ZsKKXWsUA>L407$9?hfeuNv~+ zV(7Nu1QQsdH@enfB8Y2~QO~5;=if?cz*gq9X|3Oj_Vr;ouRHdF_LpwG7$hWA?kw3I z7lNtHprmKTT;3k$nlzOWd^!OqefbPJs~VbLtR(+^r?&D;fs8LVlbz?b9l`FSq~E(Q z91@`=0oM3ougBzcJV0l?;+o3fAH7d^yD$I5@`-MzfvacD@$=fV=KQoICRXSms6$j*@>%B4$Zu&2iJZcpZYc6IalE1 zvefh96Nz{OLsVyVDL-r{ysURGx|WF#U5f9I>~y(I5`<}kCXXnY+n?H0FP$I_-U7NC zxGwSeTidqo))zxLP)@I5(L~*=60Ol$Z|zvxKIIeB@$eRugHua)KcSQG)z^+&6VTUW zGtS?*TVEaJklp@53!^@M0ri?zw*fJk58rQwXay8SlYr?8f8V)T5>yKz;CSB*aYb_tKPX(}k z<-Nmh>UaB*isssB>l(Sc?2X_1yb(&R{dv+c%5t+gBCN;0xu5V?nJWM1H61Xu#Q*ew zJ3g<6)$zcaK4}DZ6IW4tG;oOLZ6<<;6p{b;!^tC7(Ks^) z7)I|ml)Sf?8KO4675nLqP{t$9E@ObSbK$D%tRu=_g_8-a-qXAKb8gT2ENXawopM}4 z0`lHRiIa78$mX9-^xSbw7iByhx3cEk`BBmpZkY%zy)f+zaG@Bq(IQtnzo z%PE_dB+x4QTfAxUhdM?2aBnQt7!^jLP z6p1kMLr{zdHvBSSTdkwCAXC?&5(J9{m-Ddn%kR(4`PhTobU%IrLb8Xe#eG)?%W0Dz zCiC}6s*q#m0+iHJhxXXVNrcM6jX(nHy~;=~xk4PSZ&~V2j?k zG|`DtuOZxpw-AY`^ORuoHM0{}8K&Q|>4z}_GxXGN26MhH(*yL)Wh#Wq)~aU7Y+-t> z2Gi$X&&c{>T-F`5Id&^R_U(!2wJTKOCLLzNOV-BSUQ;j8Q_q&Bo)TCfrbifrN`A(C zsH8<9&qKAN7yoI|fj4+LZmmiVQ< zr)G;VNGNJ!3WxTKPt)_?T-;#uwgw5u2GX}-upj0;v5T$T^D>^-KKl#8xUn$h*i zDKNN+<#-{d5?`yhYH`5sJC$>we$z~cVgB&3Jlr7Xs@bI=O}lU<@hcjBqsqiK(ddWR zYH?T;6}Jl8x@9lZ+iv&Fx08o7jo19{-!6WPLCH=sPP5mqNwP(Pe7Qa@-c*=m-8&6YljhO=0g=sdnhY>(3u~b(HH7@hHN! zX_EN{NMW6@`eU4I(!C1BI za8t+(oEN(5)x_I2Q%qwX2%Ga>6go|O}1S`eIgR_1yGQ?Hs-gyHadT(a8-+F!f z*)M+!Jx-xzC>i(}?yZ@6l485#m1y7R-Cf2u5bj1IZk^rTLEjINCq>OKTR9g$^`6)* zr9)BhS$FoZ(+d&QTZ~+`h&Q(?vO6>Il=h8HlDRsrr0>_6OD&&gzv9_NO);lzCZ8Y; zlZw$=iRH{7R#O9Q@WEj$xOA^PfS3a>_!E8cF;wGL;mDCQ%|Kc%DHEo5d}1cD zd9eexRBf?fEF`B65$6Z>3Q1koOhDvF+{lM&T=_X1q^7>_Ff1P>l?AE0dR;LShNmC~ z_@Lr)p+XNXZDGu8g})2-Jq7hry0Tg?gDg&N^$nqJ7WBcLE6LH~-@}7>Bc25)q;?>m zMU(z~brJ_7V&6_d4=G+9NFt`doaw#pgaxaojM?Vx*@f62rL3DlsW{2CULK+K7og#3 z1tLqeluZc3rCJ1e?U}8P`xKTNeNolv3Z6F}{ zWeYeL>MG~?E&R4;0^cr$Wc|YG3@A#FrgaMsbmdV3bC}}Q$P@fl-zo{zxaBwS_AGkq zh5l*L+f{%=A@|J)p&zkGt#s9UIpjVFDi)!dk;Gv~FMr2WL}E7gO}COZB2n_I*t8Vj zl~Mg2vDV1*ulDL2MLtTP;{;dY(}*G>GCZIrt_Zmyhg|i$2r3A~uuAfsFH-hIvE{d} zc&&Z<1O~v)g+GgFvnx*d-7o$FX$$q;LtkiWyAcAxOL(F+0K0mr3qK5xu1vhe6A`Oh zD&31jfrychVu37ZscaUNdFcD86P-1XR;NfIWx=OV`q2?e8sy4sa ziLnwCyu#GvqAVK?w-V@l#EA~_=;_r!jb%*J<7SdkL`W(*(1!n*aYYNEX`-zxnAW;g zhsNcRs*9+1v@LRq1^c$V_{VPNgOIc8l@vbTdXU{|a9}xQ z1j!X9x2p_NmI=RgC}3bMC1@tid=-wnJef4(FMPWecsB5oaJ{RH9t&D)2u;^xYC4c! zOu*McDTa5XGpeG+iAFZEzz~t|lmcC1?pc^bM7XP#}O^uD@>2uHf zvY@iHgUC7+G!Du~M)<3e(0 zz6vYN92GBHwcKV=9C*E+{BCQE!>Re>8P6m`yiMT;GrqX;4=+9h6yc zcumctv&^SaUv@5ZWTN5r5yLX|cceP_gdt@WSE43Q*656Q>d?GpFTo^s~$(q0a!#*Y0^2DTl?R*d#Ly|?u@6<(g3mi!=$zFfeZ zv$uR~_T9qh?LQfRk0swkGBA@x#u}lsAu@vCyW-uelR1ZORH@y28R591A;ewXIxt!- z_FpjlQ$LCN$&0}W;@x1HmiZlhx=-}H6*1C2chKjlM95CX;y){Eyu&5Z>s*@AdtFn} zMCi$NlTn?0W0GAd;urGp;xO|Wuc2pVNKR;WDXOE<9|bSvf7CX(sp4EETTrb1oEpmc zOBM`^2Jlm_*`+>i5_+U#G2wpt&gMBQ%x5<8GlS+u`vrGAU*YlzaodXC-kWq0>q@_f zn5zMiqn8{>*#AD@W0DC>26`cvj{oli-hCX6>?l5MjfMU*;QyH$gE0WW`&~tyL1z_C z#zZrwk#?@a+?*z)mFq$h9WQcp93kMDOGtxP5rgsMKfnJI^lzee!T$^Tfk^zHAfD*o eYX2uFQ^E?}>e@W{JrCL6z=m|hvgm+s%>M!WQ(8m- literal 0 HcmV?d00001 diff --git a/mobile/components/AnimatedCard.tsx b/mobile/components/AnimatedCard.tsx new file mode 100644 index 0000000..99f3e2d --- /dev/null +++ b/mobile/components/AnimatedCard.tsx @@ -0,0 +1,38 @@ +import React, { useEffect } from 'react'; +import { ViewStyle } from 'react-native'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withDelay, + withTiming, + Easing, +} from 'react-native-reanimated'; + +interface AnimatedCardProps { + index?: number; + delay?: number; + style?: any; + children: React.ReactNode; +} + +export default function AnimatedCard({ index = 0, delay, style, children }: AnimatedCardProps) { + const opacity = useSharedValue(0); + const translateY = useSharedValue(20); + + useEffect(() => { + const d = delay ?? index * 80; + opacity.value = withDelay(d, withTiming(1, { duration: 500, easing: Easing.out(Easing.exp) })); + translateY.value = withDelay(d, withTiming(0, { duration: 500, easing: Easing.out(Easing.exp) })); + }, []); + + const animatedStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + transform: [{ translateY: translateY.value }], + })); + + return ( + + {children} + + ); +} diff --git a/mobile/components/CustomTabBar.tsx b/mobile/components/CustomTabBar.tsx new file mode 100644 index 0000000..50c6af1 --- /dev/null +++ b/mobile/components/CustomTabBar.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import { View, Text, Pressable, StyleSheet, Platform } from 'react-native'; +import { BottomTabBarProps } from '@react-navigation/bottom-tabs'; +import { Activity, Layers, Clock, Cpu, FileSliders as Sliders } from 'lucide-react-native'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withSpring, +} from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Colors, Fonts, FontSize } from '@/constants/theme'; +import { triggerHaptic } from '@/utils/haptics'; + +const TAB_ICONS = [Activity, Layers, Clock, Cpu, Sliders]; +const TAB_LABELS = ['Dashboard', 'Positions', 'History', 'Strategies', 'Settings']; + +function TabItem({ + index, + isFocused, + onPress, +}: { + index: number; + isFocused: boolean; + onPress: () => void; +}) { + const scale = useSharedValue(1); + const Icon = TAB_ICONS[index]; + const label = TAB_LABELS[index]; + + const animStyle = useAnimatedStyle(() => ({ + transform: [{ scale: scale.value }], + })); + + React.useEffect(() => { + scale.value = withSpring(isFocused ? 1.1 : 1, { damping: 15, stiffness: 150 }); + }, [isFocused]); + + return ( + { + triggerHaptic('light'); + onPress(); + }} + > + + + {isFocused && } + + {isFocused && ( + {label} + )} + + ); +} + +export default function CustomTabBar({ state, navigation }: BottomTabBarProps) { + const insets = useSafeAreaInsets(); + + return ( + + {state.routes.map((route, index) => { + const isFocused = state.index === index; + const onPress = () => { + const event = navigation.emit({ + type: 'tabPress', + target: route.key, + canPreventDefault: true, + }); + if (!isFocused && !event.defaultPrevented) { + navigation.navigate(route.name); + } + }; + return ( + + ); + })} + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + backgroundColor: Colors.background.primary, + borderTopWidth: 1, + borderTopColor: Colors.border.default, + paddingTop: 10, + }, + tabItem: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + gap: 4, + }, + iconContainer: { + alignItems: 'center', + justifyContent: 'center', + position: 'relative', + }, + glowDot: { + position: 'absolute', + bottom: -8, + width: 4, + height: 4, + borderRadius: 2, + backgroundColor: Colors.accent.green, + shadowColor: Colors.accent.green, + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.8, + shadowRadius: 4, + elevation: 4, + }, + activeLabel: { + fontFamily: Fonts.inter.extraBold, + fontSize: 9, + color: Colors.accent.green, + textTransform: 'uppercase', + letterSpacing: 1, + marginTop: 6, + }, +}); diff --git a/mobile/components/FloatingChatButton.tsx b/mobile/components/FloatingChatButton.tsx new file mode 100644 index 0000000..b489922 --- /dev/null +++ b/mobile/components/FloatingChatButton.tsx @@ -0,0 +1,101 @@ +import React, { useEffect } from 'react'; +import { View, StyleSheet } from 'react-native'; +import { useRouter } from 'expo-router'; +import { LinearGradient } from 'expo-linear-gradient'; +import { Ionicons } from '@expo/vector-icons'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withRepeat, + withSequence, + withTiming, + withSpring, +} from 'react-native-reanimated'; +import { Colors, Shadows } from '@/constants/theme'; +import PressableScale from '@/components/PressableScale'; + +export default function FloatingChatButton() { + const router = useRouter(); + const floatY = useSharedValue(0); + const dotScale = useSharedValue(1); + + useEffect(() => { + floatY.value = withRepeat( + withSequence( + withTiming(-4, { duration: 1500 }), + withTiming(0, { duration: 1500 }) + ), + -1 + ); + dotScale.value = withRepeat( + withSequence( + withTiming(1.3, { duration: 1000 }), + withTiming(1, { duration: 1000 }) + ), + -1 + ); + }, []); + + const iconFloat = useAnimatedStyle(() => ({ + transform: [{ translateY: floatY.value }], + })); + + const dotPulse = useAnimatedStyle(() => ({ + transform: [{ scale: dotScale.value }], + })); + + return ( + router.push('/chat')} + > + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + position: 'absolute', + bottom: 100, + right: 20, + width: 56, + height: 56, + borderRadius: 16, + zIndex: 100, + ...Shadows.card, + shadowColor: '#000', + shadowOpacity: 0.5, + }, + gradient: { + width: 56, + height: 56, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1.5, + borderColor: 'rgba(0,255,136,0.25)', + }, + pulseDot: { + position: 'absolute', + top: -3, + right: -3, + width: 14, + height: 14, + borderRadius: 7, + backgroundColor: Colors.accent.green, + borderWidth: 2, + borderColor: '#0a0b10', + }, +}); diff --git a/mobile/components/PillBadge.tsx b/mobile/components/PillBadge.tsx new file mode 100644 index 0000000..ac5033d --- /dev/null +++ b/mobile/components/PillBadge.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { Fonts, FontSize } from '@/constants/theme'; + +interface PillBadgeProps { + label: string; + color: string; + bgColor: string; + borderColor?: string; + size?: 'sm' | 'md'; +} + +export default function PillBadge({ label, color, bgColor, borderColor, size = 'sm' }: PillBadgeProps) { + return ( + + + {label} + + + ); +} + +const styles = StyleSheet.create({ + pill: { + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 6, + borderWidth: 1, + alignSelf: 'flex-start', + }, + pillMd: { + paddingHorizontal: 12, + paddingVertical: 5, + borderRadius: 8, + }, + text: { + fontFamily: Fonts.inter.black, + fontSize: FontSize.micro, + textTransform: 'uppercase', + letterSpacing: 1, + }, + textMd: { + fontSize: FontSize.badge, + }, +}); diff --git a/mobile/components/PressableScale.tsx b/mobile/components/PressableScale.tsx new file mode 100644 index 0000000..cbd2d98 --- /dev/null +++ b/mobile/components/PressableScale.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { Pressable, PressableProps } from 'react-native'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withSpring, +} from 'react-native-reanimated'; +import { triggerHaptic } from '@/utils/haptics'; + +const AnimatedPressable = Animated.createAnimatedComponent(Pressable); + +interface PressableScaleProps extends PressableProps { + haptic?: 'light' | 'medium' | 'selection'; + children: React.ReactNode; +} + +export default function PressableScale({ + haptic = 'light', + children, + style, + onPress, + ...props +}: PressableScaleProps) { + const scale = useSharedValue(1); + const translateY = useSharedValue(0); + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [ + { scale: scale.value }, + { translateY: translateY.value }, + ], + })); + + return ( + { + scale.value = withSpring(0.97, { damping: 15, stiffness: 150 }); + translateY.value = withSpring(-2, { damping: 15, stiffness: 150 }); + }} + onPressOut={() => { + scale.value = withSpring(1, { damping: 15, stiffness: 150 }); + translateY.value = withSpring(0, { damping: 15, stiffness: 150 }); + }} + onPress={(e) => { + triggerHaptic(haptic); + onPress?.(e); + }} + style={[animatedStyle, style as any]} + {...props} + > + {children} + + ); +} diff --git a/mobile/components/ProductAvailabilityGate.tsx b/mobile/components/ProductAvailabilityGate.tsx new file mode 100644 index 0000000..42b470e --- /dev/null +++ b/mobile/components/ProductAvailabilityGate.tsx @@ -0,0 +1,97 @@ +import { useEffect, useState } from 'react'; +import type { ReactNode } from 'react'; +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; +import { mobileKillSwitchClient } from '@/lib/runtime'; +import { Colors, Fonts, Spacing } from '@/constants/theme'; + +type AvailabilityStatus = 'loading' | 'available' | 'maintenance' | 'product_disabled'; + +export function ProductAvailabilityGate({ children }: { children: ReactNode }) { + const [status, setStatus] = useState('loading'); + const [message, setMessage] = useState(); + + useEffect(() => { + let active = true; + + async function loadAvailability() { + try { + const result = await mobileKillSwitchClient.check(); + if (!active) { + return; + } + + if (result.disabled) { + setStatus('product_disabled'); + setMessage(result.message ?? 'Trading access is temporarily disabled.'); + return; + } + + setStatus('available'); + } catch (error) { + console.warn('[ProductAvailabilityGate] Failed to evaluate kill switch.', error); + if (active) { + setStatus('available'); + } + } + } + + void loadAvailability(); + return () => { + active = false; + }; + }, []); + + if (status === 'loading') { + return ( + + + Loading trading workspace... + + ); + } + + if (status !== 'available') { + return ( + + CONTROL PLANE + + {status === 'maintenance' ? 'Trading under maintenance' : 'Trading temporarily unavailable'} + + {message ? {message} : null} + + ); + } + + return <>{children}; +} + +const styles = StyleSheet.create({ + centered: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: Colors.background.primary, + padding: Spacing.screenPadding, + gap: 12, + }, + eyebrow: { + fontFamily: Fonts.inter.black, + fontSize: 11, + letterSpacing: 2.5, + color: Colors.accent.green, + }, + title: { + fontFamily: Fonts.inter.black, + fontSize: 24, + color: Colors.text.primary, + textAlign: 'center', + }, + body: { + fontFamily: Fonts.inter.medium, + fontSize: 14, + lineHeight: 22, + color: Colors.text.secondary, + textAlign: 'center', + maxWidth: 320, + }, +}); diff --git a/mobile/components/PulsingDot.tsx b/mobile/components/PulsingDot.tsx new file mode 100644 index 0000000..3d2646e --- /dev/null +++ b/mobile/components/PulsingDot.tsx @@ -0,0 +1,48 @@ +import React, { useEffect } from 'react'; +import { StyleSheet } from 'react-native'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withRepeat, + withSequence, + withTiming, +} from 'react-native-reanimated'; +import { Colors, Shadows } from '@/constants/theme'; + +interface PulsingDotProps { + size?: number; + color?: string; +} + +export default function PulsingDot({ size = 6, color = Colors.accent.green }: PulsingDotProps) { + const opacity = useSharedValue(1); + + useEffect(() => { + opacity.value = withRepeat( + withSequence( + withTiming(0.4, { duration: 1000 }), + withTiming(1, { duration: 1000 }) + ), + -1 + ); + }, []); + + const animatedStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + })); + + return ( + + ); +} diff --git a/mobile/components/SegmentedControl.tsx b/mobile/components/SegmentedControl.tsx new file mode 100644 index 0000000..5324830 --- /dev/null +++ b/mobile/components/SegmentedControl.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { View, Text, Pressable, StyleSheet } from 'react-native'; +import Animated, { + useAnimatedStyle, + withSpring, +} from 'react-native-reanimated'; +import { Colors, Fonts, FontSize, BorderRadius } from '@/constants/theme'; +import { triggerHaptic } from '@/utils/haptics'; + +interface SegmentedControlProps { + segments: string[]; + activeIndex: number; + onPress: (index: number) => void; + activeColor?: string; + activeTextColor?: string; +} + +export default function SegmentedControl({ + segments, + activeIndex, + onPress, + activeColor = Colors.accent.green, + activeTextColor = '#000', +}: SegmentedControlProps) { + const handlePress = (index: number) => { + triggerHaptic('selection'); + onPress(index); + }; + + return ( + + {segments.map((segment, index) => { + const isActive = index === activeIndex; + return ( + handlePress(index)} + > + + {segment} + + + ); + })} + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + backgroundColor: 'rgba(255,255,255,0.05)', + padding: 4, + borderRadius: BorderRadius.small, + borderWidth: 1, + borderColor: Colors.border.default, + }, + segment: { + flex: 1, + paddingVertical: 10, + borderRadius: 10, + alignItems: 'center', + justifyContent: 'center', + }, + text: { + fontFamily: Fonts.inter.semiBold, + fontSize: FontSize.body, + }, +}); diff --git a/mobile/components/SkeletonLoader.tsx b/mobile/components/SkeletonLoader.tsx new file mode 100644 index 0000000..dc785b7 --- /dev/null +++ b/mobile/components/SkeletonLoader.tsx @@ -0,0 +1,62 @@ +import React, { useEffect } from 'react'; +import { View, StyleSheet } from 'react-native'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, + Easing, +} from 'react-native-reanimated'; +import { LinearGradient } from 'expo-linear-gradient'; +import { Colors, BorderRadius } from '@/constants/theme'; + +interface SkeletonLoaderProps { + width?: number | string; + height?: number; + borderRadius?: number; + style?: any; +} + +export default function SkeletonLoader({ + width = '100%', + height = 120, + borderRadius = BorderRadius.large, + style, +}: SkeletonLoaderProps) { + const translateX = useSharedValue(-300); + + useEffect(() => { + translateX.value = withRepeat( + withTiming(300, { duration: 1200, easing: Easing.linear }), + -1 + ); + }, []); + + const shimmerStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: translateX.value }], + })); + + return ( + + + + + + ); +} diff --git a/mobile/components/Sparkline.tsx b/mobile/components/Sparkline.tsx new file mode 100644 index 0000000..8b7e6ab --- /dev/null +++ b/mobile/components/Sparkline.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { View } from 'react-native'; +import Svg, { Polyline, Defs, LinearGradient, Stop, Rect } from 'react-native-svg'; + +interface SparklineProps { + data: number[]; + width?: number; + height?: number; + color?: string; + strokeWidth?: number; +} + +export default function Sparkline({ + data, + width = 100, + height = 40, + color = '#00ff88', + strokeWidth = 1.5, +}: SparklineProps) { + if (!data || data.length < 2) return null; + + const min = Math.min(...data); + const max = Math.max(...data); + const range = max - min || 1; + const padding = 2; + + const points = data + .map((val, i) => { + const x = (i / (data.length - 1)) * (width - padding * 2) + padding; + const y = height - padding - ((val - min) / range) * (height - padding * 2); + return `${x},${y}`; + }) + .join(' '); + + return ( + + + + + + + + + + + + ); +} diff --git a/mobile/components/dashboard/ActiveAlerts.tsx b/mobile/components/dashboard/ActiveAlerts.tsx new file mode 100644 index 0000000..3833682 --- /dev/null +++ b/mobile/components/dashboard/ActiveAlerts.tsx @@ -0,0 +1,116 @@ +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { Colors, Fonts, FontSize, BorderRadius, Spacing } from '@/constants/theme'; +import { alerts, AlertType } from '@/constants/mockData'; +import AnimatedCard from '@/components/AnimatedCard'; + +const ALERT_COLORS: Record = { + signal: Colors.accent.green, + pulse: '#4da6ff', + error: Colors.accent.red, + info: '#888888', +}; + +const ALERT_ICONS: Record = { + signal: '\u{1F680}', + pulse: '\u{23F0}', + error: '\u{26A0}\u{FE0F}', + info: '\u{2139}\u{FE0F}', +}; + +export default function ActiveAlerts() { + return ( + + + RECENT ALERTS + + {alerts.length} + + + + {alerts.map((alert, index) => ( + + + + + {ALERT_ICONS[alert.type]} + {alert.symbol} + {alert.timestamp} + + {alert.message} + + + ))} + + ); +} + +const styles = StyleSheet.create({ + container: { + gap: 10, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginBottom: 4, + }, + title: { + fontFamily: Fonts.inter.black, + fontSize: FontSize.micro, + color: Colors.text.secondary, + letterSpacing: 3, + }, + countBadge: { + backgroundColor: 'rgba(0,255,136,0.15)', + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 6, + }, + countText: { + fontFamily: Fonts.mono.bold, + fontSize: FontSize.micro, + color: Colors.accent.green, + }, + alertCard: { + backgroundColor: 'rgba(255,255,255,0.03)', + borderRadius: BorderRadius.small, + borderWidth: 1, + borderColor: Colors.border.subtle, + flexDirection: 'row', + overflow: 'hidden', + }, + leftBorder: { + width: 3, + }, + alertContent: { + flex: 1, + padding: 14, + gap: 6, + }, + alertHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + icon: { + fontSize: 14, + }, + symbol: { + fontFamily: Fonts.inter.extraBold, + fontSize: FontSize.badge, + color: Colors.text.primary, + }, + timestamp: { + fontFamily: Fonts.inter.medium, + fontSize: FontSize.micro, + color: Colors.text.secondary, + marginLeft: 'auto', + }, + message: { + fontFamily: Fonts.inter.medium, + fontSize: FontSize.body, + color: Colors.text.primary, + lineHeight: 18, + }, +}); diff --git a/mobile/components/dashboard/MarketTicker.tsx b/mobile/components/dashboard/MarketTicker.tsx new file mode 100644 index 0000000..1689e34 --- /dev/null +++ b/mobile/components/dashboard/MarketTicker.tsx @@ -0,0 +1,104 @@ +import React, { useEffect, useRef } from 'react'; +import { View, Text, StyleSheet, ScrollView } from 'react-native'; +import Animated, { + useSharedValue, + useAnimatedStyle, + withRepeat, + withTiming, + Easing, +} from 'react-native-reanimated'; +import { TrendingUp } from 'lucide-react-native'; +import { Colors, Fonts, FontSize, BorderRadius } from '@/constants/theme'; +import { marketTicker } from '@/constants/mockData'; + +export default function MarketTicker() { + const translateX = useSharedValue(0); + + useEffect(() => { + translateX.value = withRepeat( + withTiming(-600, { duration: 30000, easing: Easing.linear }), + -1 + ); + }, []); + + const animStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: translateX.value }], + })); + + const items = [...marketTicker, ...marketTicker, ...marketTicker]; + + return ( + + + + MARKET PULSE + + + + {items.map((item, index) => { + const isPositive = item.change >= 0; + return ( + + {item.symbol} + + ${item.price >= 1 ? item.price.toLocaleString('en-US', { minimumFractionDigits: 2 }) : item.price.toFixed(4)} + + + {isPositive ? '▲' : '▼'} {isPositive ? '+' : ''}{item.change.toFixed(2)}% + + + ); + })} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + backgroundColor: Colors.background.card, + borderRadius: BorderRadius.small, + padding: 14, + borderWidth: 1, + borderColor: Colors.border.default, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + marginBottom: 12, + }, + headerText: { + fontFamily: Fonts.inter.black, + fontSize: FontSize.micro, + color: Colors.accent.green, + letterSpacing: 1.5, + }, + tickerWrap: { + overflow: 'hidden', + }, + tickerRow: { + flexDirection: 'row', + gap: 24, + }, + tickerItem: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + symbol: { + fontFamily: Fonts.inter.extraBold, + fontSize: FontSize.badge, + color: Colors.text.primary, + }, + price: { + fontFamily: Fonts.mono.medium, + fontSize: FontSize.badge, + color: Colors.text.primary, + }, + change: { + fontFamily: Fonts.mono.bold, + fontSize: FontSize.badge, + }, +}); diff --git a/mobile/components/dashboard/PortfolioHeroCard.tsx b/mobile/components/dashboard/PortfolioHeroCard.tsx new file mode 100644 index 0000000..57127f3 --- /dev/null +++ b/mobile/components/dashboard/PortfolioHeroCard.tsx @@ -0,0 +1,223 @@ +import React, { useEffect } from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { LinearGradient } from 'expo-linear-gradient'; +import Animated, { + useSharedValue, + useAnimatedProps, + withTiming, + useDerivedValue, + useAnimatedStyle, + runOnJS, +} from 'react-native-reanimated'; +import { Colors, Fonts, FontSize, BorderRadius, Shadows, Spacing } from '@/constants/theme'; +import { portfolioData } from '@/constants/mockData'; +import { formatNumber } from '@/utils/format'; + +function CountUpValue({ target, prefix, suffix, style, duration = 800 }: { + target: number; + prefix?: string; + suffix?: string; + style: any; + duration?: number; +}) { + const [display, setDisplay] = React.useState('0'); + const value = useSharedValue(0); + + useEffect(() => { + value.value = withTiming(target, { duration }); + }, [target]); + + useEffect(() => { + const interval = setInterval(() => { + const current = value.value; + if (Math.abs(current) >= 1000) { + setDisplay(current.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })); + } else { + setDisplay(current.toFixed(2)); + } + }, 16); + return () => clearInterval(interval); + }, []); + + return ( + + {prefix}{display}{suffix} + + ); +} + +export default function PortfolioHeroCard() { + return ( + + + + + PORTFOLIO OVERVIEW + + + + + + + + + + + + + + + + + {portfolioData.utilization}% utilized + + + + + Realized P&L + + +${portfolioData.realizedPnl.toLocaleString('en-US', { minimumFractionDigits: 2 })} + + + + Unrealized P&L + + +${portfolioData.unrealizedPnl.toLocaleString('en-US', { minimumFractionDigits: 2 })} + + + + + + ); +} + +function MetricItem({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} + +const styles = StyleSheet.create({ + wrapper: { + borderRadius: BorderRadius.large, + overflow: 'hidden', + }, + card: { + borderRadius: BorderRadius.large, + padding: Spacing.cardPaddingLarge, + borderWidth: 1, + borderColor: Colors.border.default, + overflow: 'hidden', + }, + accentLine: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: 2, + backgroundColor: Colors.accent.green, + opacity: 0.6, + }, + label: { + fontFamily: Fonts.inter.black, + fontSize: FontSize.micro, + color: Colors.accent.green, + letterSpacing: 4, + marginBottom: 16, + }, + heroValue: { + fontFamily: Fonts.mono.extraBold, + fontSize: FontSize.pageTitle, + color: Colors.accent.green, + letterSpacing: -0.5, + }, + heroPercent: { + fontFamily: Fonts.mono.bold, + fontSize: FontSize.bodyLarge, + color: Colors.accent.green, + marginTop: 2, + }, + divider: { + height: 1, + backgroundColor: Colors.border.default, + marginVertical: 16, + }, + metricsRow: { + flexDirection: 'row', + justifyContent: 'space-between', + }, + metricItem: { + flex: 1, + }, + metricLabel: { + fontFamily: Fonts.inter.black, + fontSize: FontSize.micro, + color: Colors.text.secondary, + letterSpacing: 1, + marginBottom: 4, + }, + metricValue: { + fontFamily: Fonts.mono.extraBold, + fontSize: FontSize.subheading, + color: Colors.text.primary, + }, + utilizationContainer: { + marginTop: 16, + }, + utilizationTrack: { + height: 4, + borderRadius: 2, + backgroundColor: Colors.background.elevated, + overflow: 'hidden', + }, + utilizationFill: { + height: 4, + borderRadius: 2, + }, + utilizationText: { + fontFamily: Fonts.inter.medium, + fontSize: FontSize.micro, + color: Colors.text.secondary, + marginTop: 6, + }, + pnlRow: { + flexDirection: 'row', + marginTop: 16, + gap: 24, + }, + pnlItem: { + flex: 1, + }, + pnlLabel: { + fontFamily: Fonts.inter.medium, + fontSize: FontSize.micro, + color: Colors.text.secondary, + marginBottom: 2, + }, + pnlValue: { + fontFamily: Fonts.mono.bold, + fontSize: FontSize.body, + }, +}); diff --git a/mobile/components/dashboard/QuickPositions.tsx b/mobile/components/dashboard/QuickPositions.tsx new file mode 100644 index 0000000..90098fd --- /dev/null +++ b/mobile/components/dashboard/QuickPositions.tsx @@ -0,0 +1,159 @@ +import React from 'react'; +import { View, Text, Pressable, StyleSheet } from 'react-native'; +import { useRouter } from 'expo-router'; +import { LinearGradient } from 'expo-linear-gradient'; +import { Colors, Fonts, FontSize, BorderRadius, Shadows, Spacing } from '@/constants/theme'; +import { positions } from '@/constants/mockData'; +import { formatPrice, formatPercent, formatCurrency } from '@/utils/format'; +import Sparkline from '@/components/Sparkline'; +import AnimatedCard from '@/components/AnimatedCard'; + +export default function QuickPositions() { + const router = useRouter(); + const preview = positions.slice(0, 2); + + return ( + + + ACTIVE POSITIONS + router.push('/positions' as any)}> + See All → + + + + {preview.map((pos, index) => { + const isPositive = pos.unrealizedPnl >= 0; + const color = isPositive ? Colors.accent.green : Colors.accent.red; + return ( + + + + + {pos.symbol} + + {pos.side === 'BUY' ? 'LONG' : 'SHORT'} + + {pos.profileName} + + + + {formatPrice(pos.currentPrice)} + Entry {formatPrice(pos.entryPrice)} + + + + {formatCurrency(pos.unrealizedPnl)} ({formatPercent(pos.unrealizedPnlPercent)}) + + + + + + + ); + })} + + ); +} + +const styles = StyleSheet.create({ + container: { + gap: 10, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 4, + }, + title: { + fontFamily: Fonts.inter.black, + fontSize: FontSize.micro, + color: Colors.text.secondary, + letterSpacing: 3, + }, + seeAll: { + fontFamily: Fonts.inter.semiBold, + fontSize: FontSize.bodySmall, + color: Colors.accent.green, + }, + cardWrapper: { + borderRadius: BorderRadius.large, + }, + card: { + borderRadius: BorderRadius.large, + padding: Spacing.cardPadding, + borderWidth: 1, + borderColor: Colors.border.default, + overflow: 'hidden', + }, + accentLine: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: 2, + opacity: 0.6, + }, + topRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginBottom: 12, + }, + symbol: { + fontFamily: Fonts.inter.black, + fontSize: FontSize.subheading, + color: Colors.text.primary, + letterSpacing: -0.3, + }, + sideBadge: { + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 6, + }, + sideText: { + fontFamily: Fonts.inter.black, + fontSize: FontSize.micro, + letterSpacing: 1, + }, + profileName: { + fontFamily: Fonts.inter.medium, + fontSize: FontSize.badge, + color: Colors.text.secondary, + marginLeft: 'auto', + }, + priceRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-end', + marginBottom: 12, + }, + currentPrice: { + fontFamily: Fonts.mono.bold, + fontSize: FontSize.heading, + color: Colors.text.primary, + }, + entryPrice: { + fontFamily: Fonts.mono.regular, + fontSize: FontSize.badge, + color: Colors.text.secondary, + marginTop: 2, + }, + pnlContainer: { + alignItems: 'flex-end', + }, + pnl: { + fontFamily: Fonts.mono.bold, + fontSize: FontSize.bodyLarge, + }, +}); diff --git a/mobile/components/dashboard/StatusBanner.tsx b/mobile/components/dashboard/StatusBanner.tsx new file mode 100644 index 0000000..88b8289 --- /dev/null +++ b/mobile/components/dashboard/StatusBanner.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { Colors, Fonts, FontSize, BorderRadius } from '@/constants/theme'; +import PulsingDot from '@/components/PulsingDot'; + +export default function StatusBanner() { + return ( + + + + RUNNING + + + PAPER + + 4d 12h 33m + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: Colors.background.card, + borderRadius: BorderRadius.small, + padding: 12, + paddingHorizontal: 16, + gap: 10, + borderWidth: 1, + borderColor: Colors.border.default, + }, + badge: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + backgroundColor: 'rgba(0,255,136,0.15)', + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: 6, + }, + runningText: { + fontFamily: Fonts.inter.black, + fontSize: FontSize.micro, + color: Colors.accent.green, + letterSpacing: 1, + }, + paperBadge: { + backgroundColor: 'rgba(52,152,219,0.15)', + }, + paperText: { + fontFamily: Fonts.inter.black, + fontSize: FontSize.micro, + color: Colors.accent.blue, + letterSpacing: 1, + }, + uptime: { + fontFamily: Fonts.mono.regular, + fontSize: FontSize.bodySmall, + color: Colors.text.secondary, + marginLeft: 'auto', + }, +}); diff --git a/mobile/components/dashboard/WinRateStrip.tsx b/mobile/components/dashboard/WinRateStrip.tsx new file mode 100644 index 0000000..1062bde --- /dev/null +++ b/mobile/components/dashboard/WinRateStrip.tsx @@ -0,0 +1,61 @@ +import React, { useState } from 'react'; +import { View, Text, ScrollView, Pressable, StyleSheet } from 'react-native'; +import { Colors, Fonts, FontSize, BorderRadius } from '@/constants/theme'; +import { winRates } from '@/constants/mockData'; +import { triggerHaptic } from '@/utils/haptics'; + +export default function WinRateStrip() { + const [activeIndex, setActiveIndex] = useState(0); + + return ( + + {winRates.map((item, index) => { + const isActive = index === activeIndex; + return ( + { + triggerHaptic('selection'); + setActiveIndex(index); + }} + > + + {item.label} {item.value}% + + + ); + })} + + ); +} + +const styles = StyleSheet.create({ + container: { + gap: 8, + }, + pill: { + paddingHorizontal: 14, + paddingVertical: 7, + borderRadius: BorderRadius.xs, + borderWidth: 1, + borderColor: Colors.border.medium, + backgroundColor: 'rgba(255,255,255,0.03)', + }, + activePill: { + borderColor: 'rgba(0,255,136,0.45)', + backgroundColor: 'rgba(0,255,136,0.08)', + }, + text: { + fontFamily: Fonts.mono.bold, + fontSize: FontSize.badge, + color: '#a1a1aa', + }, + activeText: { + color: Colors.accent.green, + }, +}); diff --git a/mobile/constants/mockData.ts b/mobile/constants/mockData.ts new file mode 100644 index 0000000..a201338 --- /dev/null +++ b/mobile/constants/mockData.ts @@ -0,0 +1,186 @@ +export const portfolioData = { + netPnl: 1284.5, + netPnlPercent: 5.14, + totalCapital: 25000, + deployed: 18450, + available: 6550, + utilization: 73.8, + realizedPnl: 890.2, + unrealizedPnl: 394.3, +}; + +export const winRates = [ + { label: '24H', value: 75, active: true }, + { label: '7D', value: 68, active: false }, + { label: '30D', value: 71, active: false }, + { label: 'ALL', value: 66, active: false }, +]; + +export const marketTicker = [ + { symbol: 'BTC/USDT', price: 67432.1, change: 2.41 }, + { symbol: 'ETH/USDT', price: 3521.8, change: -0.82 }, + { symbol: 'SOL/USDT', price: 142.55, change: 5.12 }, + { symbol: 'AVAX/USDT', price: 38.9, change: 1.44 }, + { symbol: 'DOGE/USDT', price: 0.1842, change: -2.15 }, +]; + +export type AlertType = 'signal' | 'error' | 'pulse' | 'info'; + +export interface Alert { + id: string; + type: AlertType; + symbol: string; + message: string; + timestamp: string; +} + +export const alerts: Alert[] = [ + { id: '1', type: 'signal', symbol: 'SOL/USDT', message: 'BUY signal triggered — EMA crossover confirmed on 1H', timestamp: '2m ago' }, + { id: '2', type: 'error', symbol: 'ETH/USDT', message: 'STOP LOSS hit — exited position at $3,480.00', timestamp: '18m ago' }, + { id: '3', type: 'signal', symbol: 'BTC/USDT', message: 'Position opened: LONG 0.05 BTC @ $67,100', timestamp: '1h ago' }, + { id: '4', type: 'pulse', symbol: 'BTC/USDT', message: 'Volatility spike detected — RSI approaching overbought', timestamp: '2h ago' }, + { id: '5', type: 'info', symbol: 'ALL', message: 'Daily profit target reached for Aggressive Bot profile', timestamp: '4h ago' }, +]; + +export interface Position { + id: string; + symbol: string; + side: 'BUY' | 'SELL'; + size: number; + entryPrice: number; + currentPrice: number; + stopLoss: number; + takeProfit: number; + unrealizedPnl: number; + unrealizedPnlPercent: number; + marketValue: number; + profileName: string; + source: 'BOT' | 'MANUAL'; + tradeId: string; + sparkData: number[]; +} + +export const positions: Position[] = [ + { id: '1', symbol: 'BTC/USDT', side: 'BUY', size: 0.05, entryPrice: 67100, currentPrice: 67432.1, stopLoss: 66200, takeProfit: 69000, unrealizedPnl: 16.61, unrealizedPnlPercent: 0.49, marketValue: 3371.61, profileName: 'Aggressive Bot', source: 'BOT', tradeId: 'TRD-001', sparkData: [67100, 67200, 67150, 67300, 67250, 67350, 67400, 67432, 67410, 67450, 67420, 67432] }, + { id: '2', symbol: 'ETH/USDT', side: 'BUY', size: 1.5, entryPrice: 3480, currentPrice: 3521.8, stopLoss: 3380, takeProfit: 3700, unrealizedPnl: 62.7, unrealizedPnlPercent: 1.2, marketValue: 5282.7, profileName: 'Balanced Core', source: 'BOT', tradeId: 'TRD-002', sparkData: [3480, 3490, 3475, 3500, 3510, 3495, 3515, 3520, 3510, 3525, 3518, 3521] }, + { id: '3', symbol: 'SOL/USDT', side: 'BUY', size: 50, entryPrice: 138.2, currentPrice: 142.55, stopLoss: 132, takeProfit: 155, unrealizedPnl: 217.5, unrealizedPnlPercent: 3.15, marketValue: 7127.5, profileName: 'Aggressive Bot', source: 'BOT', tradeId: 'TRD-003', sparkData: [138.2, 139, 138.8, 140, 140.5, 141, 141.2, 142, 141.8, 142.3, 142.1, 142.55] }, + { id: '4', symbol: 'SOL/USDT', side: 'SELL', size: 20, entryPrice: 145.1, currentPrice: 142.55, stopLoss: 150, takeProfit: 135, unrealizedPnl: 51.0, unrealizedPnlPercent: 1.76, marketValue: 2851.0, profileName: 'Conservative Swing', source: 'MANUAL', tradeId: 'TRD-004', sparkData: [145.1, 144.5, 144.8, 144, 143.5, 143.8, 143, 142.8, 143.2, 142.6, 142.8, 142.55] }, +]; + +export interface Order { + id: string; + symbol: string; + type: 'LIMIT' | 'STOP' | 'MARKET'; + side: 'BUY' | 'SELL'; + qty: number; + price: number; + status: 'pending_new' | 'filled' | 'cancelled'; + action: 'ENTRY' | 'EXIT'; + source: 'BOT' | 'MANUAL'; + profileId: string; + timestamp: number; +} + +export const orders: Order[] = [ + { id: 'ord-1', symbol: 'BTC/USDT', type: 'LIMIT', side: 'BUY', qty: 0.03, price: 66800, status: 'pending_new', action: 'ENTRY', source: 'BOT', profileId: 'p1', timestamp: Date.now() - 3600000 }, + { id: 'ord-2', symbol: 'ETH/USDT', type: 'STOP', side: 'SELL', qty: 1.5, price: 3380, status: 'pending_new', action: 'EXIT', source: 'BOT', profileId: 'p2', timestamp: Date.now() - 7200000 }, + { id: 'ord-3', symbol: 'SOL/USDT', type: 'MARKET', side: 'BUY', qty: 25, price: 141.2, status: 'filled', action: 'ENTRY', source: 'MANUAL', profileId: 'p1', timestamp: Date.now() - 1800000 }, +]; + +export interface Trade { + id: string; + symbol: string; + side: 'BUY' | 'SELL'; + entryPrice: number; + exitPrice: number; + size: number; + sizeUnit: string; + pnl: number; + pnlPercent: number; + reason: string; + profileName: string; + source: 'BOT' | 'MANUAL'; + timestamp: string; +} + +export const trades: Trade[] = [ + { id: 't1', symbol: 'BTC/USDT', side: 'BUY', entryPrice: 67100, exitPrice: 67850, size: 0.05, sizeUnit: 'BTC', pnl: 37.5, pnlPercent: 0.56, reason: 'Take Profit', profileName: 'Aggressive Bot', source: 'BOT', timestamp: 'Mar 6, 2:34 PM' }, + { id: 't2', symbol: 'ETH/USDT', side: 'BUY', entryPrice: 3450, exitPrice: 3380, size: 2, sizeUnit: 'ETH', pnl: -140, pnlPercent: -2.03, reason: 'Stop Loss', profileName: 'Balanced Core', source: 'BOT', timestamp: 'Mar 6, 11:20 AM' }, + { id: 't3', symbol: 'SOL/USDT', side: 'BUY', entryPrice: 135.5, exitPrice: 141.2, size: 30, sizeUnit: 'SOL', pnl: 171, pnlPercent: 4.21, reason: 'Take Profit', profileName: 'Aggressive Bot', source: 'BOT', timestamp: 'Mar 6, 9:15 AM' }, + { id: 't4', symbol: 'BTC/USDT', side: 'SELL', entryPrice: 68200, exitPrice: 67600, size: 0.03, sizeUnit: 'BTC', pnl: 18, pnlPercent: 0.88, reason: 'Signal Exit', profileName: 'Conservative Swing', source: 'MANUAL', timestamp: 'Mar 5, 4:45 PM' }, + { id: 't5', symbol: 'SOL/USDT', side: 'BUY', entryPrice: 140, exitPrice: 137.8, size: 40, sizeUnit: 'SOL', pnl: -88, pnlPercent: -1.57, reason: 'Stop Loss', profileName: 'Aggressive Bot', source: 'BOT', timestamp: 'Mar 5, 2:10 PM' }, + { id: 't6', symbol: 'ETH/USDT', side: 'BUY', entryPrice: 3400, exitPrice: 3520, size: 1.5, sizeUnit: 'ETH', pnl: 180, pnlPercent: 3.53, reason: 'Take Profit', profileName: 'Balanced Core', source: 'BOT', timestamp: 'Mar 5, 10:30 AM' }, + { id: 't7', symbol: 'AVAX/USDT', side: 'BUY', entryPrice: 36.5, exitPrice: 38.2, size: 100, sizeUnit: 'AVAX', pnl: 170, pnlPercent: 4.66, reason: 'Take Profit', profileName: 'Aggressive Bot', source: 'BOT', timestamp: 'Mar 4, 3:20 PM' }, + { id: 't8', symbol: 'BTC/USDT', side: 'BUY', entryPrice: 66800, exitPrice: 67300, size: 0.04, sizeUnit: 'BTC', pnl: 20, pnlPercent: 0.75, reason: 'Signal Exit', profileName: 'Balanced Core', source: 'BOT', timestamp: 'Mar 4, 1:15 PM' }, + { id: 't9', symbol: 'SOL/USDT', side: 'SELL', entryPrice: 143, exitPrice: 145.5, size: 25, sizeUnit: 'SOL', pnl: -62.5, pnlPercent: -1.75, reason: 'Stop Loss', profileName: 'Conservative Swing', source: 'MANUAL', timestamp: 'Mar 4, 9:00 AM' }, + { id: 't10', symbol: 'ETH/USDT', side: 'BUY', entryPrice: 3380, exitPrice: 3440, size: 2, sizeUnit: 'ETH', pnl: 120, pnlPercent: 1.78, reason: 'Take Profit', profileName: 'Aggressive Bot', source: 'BOT', timestamp: 'Mar 3, 5:30 PM' }, + { id: 't11', symbol: 'BTC/USDT', side: 'BUY', entryPrice: 66500, exitPrice: 67100, size: 0.06, sizeUnit: 'BTC', pnl: 36, pnlPercent: 0.9, reason: 'Take Profit', profileName: 'Balanced Core', source: 'BOT', timestamp: 'Mar 3, 2:45 PM' }, + { id: 't12', symbol: 'DOGE/USDT', side: 'BUY', entryPrice: 0.175, exitPrice: 0.182, size: 5000, sizeUnit: 'DOGE', pnl: 35, pnlPercent: 4, reason: 'Take Profit', profileName: 'Aggressive Bot', source: 'BOT', timestamp: 'Mar 3, 11:00 AM' }, + { id: 't13', symbol: 'SOL/USDT', side: 'BUY', entryPrice: 132, exitPrice: 129.5, size: 35, sizeUnit: 'SOL', pnl: -87.5, pnlPercent: -1.89, reason: 'Stop Loss', profileName: 'Aggressive Bot', source: 'BOT', timestamp: 'Mar 2, 4:00 PM' }, + { id: 't14', symbol: 'ETH/USDT', side: 'SELL', entryPrice: 3550, exitPrice: 3480, size: 1, sizeUnit: 'ETH', pnl: 70, pnlPercent: 1.97, reason: 'Take Profit', profileName: 'Conservative Swing', source: 'MANUAL', timestamp: 'Mar 2, 10:20 AM' }, + { id: 't15', symbol: 'BTC/USDT', side: 'BUY', entryPrice: 65800, exitPrice: 66400, size: 0.05, sizeUnit: 'BTC', pnl: 30, pnlPercent: 0.91, reason: 'Signal Exit', profileName: 'Balanced Core', source: 'BOT', timestamp: 'Mar 1, 3:15 PM' }, + { id: 't16', symbol: 'AVAX/USDT', side: 'BUY', entryPrice: 37, exitPrice: 36.2, size: 80, sizeUnit: 'AVAX', pnl: -64, pnlPercent: -2.16, reason: 'Stop Loss', profileName: 'Aggressive Bot', source: 'BOT', timestamp: 'Mar 1, 11:30 AM' }, +]; + +export interface Strategy { + id: string; + name: string; + riskStyle: 'aggressive' | 'balanced' | 'safe'; + allocatedCapital: number; + tradeCount: number; + wins: number; + winRate: number; + netPnl: number; + dailyTarget: number; + dailyProgress: number; + symbols: string[]; + isActive: boolean; +} + +export const strategies: Strategy[] = [ + { id: 's1', name: 'Aggressive Bot', riskStyle: 'aggressive', allocatedCapital: 5000, tradeCount: 18, wins: 13, winRate: 72.2, netPnl: 520.4, dailyTarget: 100, dailyProgress: 80, symbols: ['BTC/USDT', 'SOL/USDT'], isActive: true }, + { id: 's2', name: 'Balanced Core', riskStyle: 'balanced', allocatedCapital: 12000, tradeCount: 22, wins: 15, winRate: 68.2, netPnl: 614.8, dailyTarget: 75, dailyProgress: 55, symbols: ['BTC/USDT', 'ETH/USDT', 'SOL/USDT'], isActive: true }, + { id: 's3', name: 'Conservative Swing', riskStyle: 'safe', allocatedCapital: 8000, tradeCount: 7, wins: 4, winRate: 57.1, netPnl: -245, dailyTarget: 50, dailyProgress: 12, symbols: ['BTC/USDT'], isActive: false }, +]; + +export interface MarketplacePreset { + id: string; + name: string; + risk: 'aggressive' | 'balanced' | 'safe'; + assets: string[]; + trades: string; + tag: string; + isPopular: boolean; + description: string; +} + +export const marketplacePresets: MarketplacePreset[] = [ + { id: 'mp1', name: 'Top Performing Aggressive Bot', risk: 'aggressive', assets: ['BTC/USDT', 'SOL/USDT'], trades: '8-12/day', tag: '+14.2% (Last 30d)', isPopular: true, description: 'High-frequency scalping strategy targeting volatile breakouts with tight risk management.' }, + { id: 'mp2', name: 'Low Volatility BTC Bot', risk: 'safe', assets: ['BTC/USDT'], trades: '1-2/day', tag: 'Low Risk', isPopular: false, description: 'Conservative mean-reversion strategy on Bitcoin with wide stops and modest targets.' }, + { id: 'mp3', name: 'Standard Balanced Core', risk: 'balanced', assets: ['BTC/USDT', 'ETH/USDT', 'SOL/USDT'], trades: '3-5/day', tag: 'Consistent', isPopular: false, description: 'Multi-asset swing trading approach balancing risk across major crypto pairs.' }, +]; + +export interface ChatMessage { + id: string; + role: 'bot' | 'user'; + text: string; +} + +export const chatMessages: ChatMessage[] = [ + { id: 'c1', role: 'bot', text: "Hey! I'm your trading assistant. How can I help?" }, + { id: 'c2', role: 'user', text: "How's my portfolio doing today?" }, + { id: 'c3', role: 'bot', text: "Your portfolio is up +5.14% ($1,284.50) with 4 active positions. SOL/USDT is your best performer at +3.15%. Your 7-day win rate is 68%, and you're 80% toward your daily profit target on the Aggressive Bot profile. Want me to break down any specific position?" }, +]; + +export const chatSuggestions = [ + 'Show my P&L', + 'Pause all trades', + 'Explain my best strategy', + 'Market analysis', +]; + +export const historyMetrics = { + totalTrades: 47, + winRate: 68.1, + netPnl: 890.2, +}; diff --git a/mobile/constants/theme.ts b/mobile/constants/theme.ts new file mode 100644 index 0000000..631ad79 --- /dev/null +++ b/mobile/constants/theme.ts @@ -0,0 +1,157 @@ +export const Colors = { + background: { + primary: '#0a0b0d', + card: '#14151a', + elevated: '#1a1b22', + subtle: 'rgba(255,255,255,0.02)', + hover: 'rgba(255,255,255,0.04)', + }, + accent: { + green: '#00ff88', + red: '#ff3366', + blue: '#3498db', + orange: '#e67e22', + amber: '#facc15', + purple: '#a855f7', + }, + text: { + primary: '#ffffff', + secondary: '#929292', + muted: '#666666', + ultraDim: '#555555', + }, + border: { + default: 'rgba(255,255,255,0.05)', + subtle: 'rgba(255,255,255,0.08)', + medium: 'rgba(255,255,255,0.1)', + accent: 'rgba(0,255,136,0.12)', + accentStrong: 'rgba(0,255,136,0.3)', + }, + tier: { + free: '#94a3b8', + pro: '#3b82f6', + elite: '#00ff88', + }, + status: { + running: '#4ade80', + paused: '#ff9500', + cooldown: '#facc15', + armed: '#38bdf8', + idle: '#a1a1aa', + }, +}; + +export const Fonts = { + inter: { + regular: 'Inter-Regular', + medium: 'Inter-Medium', + semiBold: 'Inter-SemiBold', + bold: 'Inter-Bold', + extraBold: 'Inter-ExtraBold', + black: 'Inter-Black', + }, + mono: { + regular: 'JetBrainsMono-Regular', + medium: 'JetBrainsMono-Medium', + bold: 'JetBrainsMono-Bold', + extraBold: 'JetBrainsMono-ExtraBold', + }, +}; + +export const FontSize = { + micro: 10, + badge: 11, + bodySmall: 12, + body: 13, + bodyLarge: 14, + subheading: 16, + heading: 20, + hero: 28, + pageTitle: 36, +}; + +export const Spacing = { + screenPadding: 20, + cardPadding: 20, + cardPaddingLarge: 24, + sectionGap: 24, + innerGap: 12, + innerGapLarge: 16, +}; + +export const BorderRadius = { + large: 20, + medium: 16, + small: 12, + xs: 8, + pill: 20, +}; + +export const Shadows = { + card: { + shadowColor: '#000', + shadowOffset: { width: 0, height: 8 }, + shadowOpacity: 0.3, + shadowRadius: 32, + elevation: 8, + }, + elevated: { + shadowColor: '#000', + shadowOffset: { width: 0, height: 25 }, + shadowOpacity: 0.6, + shadowRadius: 80, + elevation: 16, + }, + glowGreen: { + shadowColor: '#00ff88', + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.1, + shadowRadius: 20, + elevation: 4, + }, + activeDotGlow: { + shadowColor: '#00ff88', + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.5, + shadowRadius: 8, + elevation: 4, + }, +}; + +export const Gradients = { + cardSurface: { + colors: ['rgba(20,21,26,0.9)', 'rgba(14,15,18,0.95)'], + start: { x: 0, y: 0 }, + end: { x: 1, y: 1 }, + }, + greenCta: { + colors: ['#00ff88', '#00cc6a'], + start: { x: 0, y: 0 }, + end: { x: 1, y: 1 }, + }, + chatUserBubble: { + colors: ['rgba(59,130,246,0.15)', 'rgba(59,130,246,0.08)'], + start: { x: 0, y: 0 }, + end: { x: 1, y: 1 }, + }, + chatBotBubble: { + colors: ['rgba(255,255,255,0.04)', 'rgba(255,255,255,0.015)'], + start: { x: 0, y: 0 }, + end: { x: 1, y: 1 }, + }, + signalCard: { + colors: ['#1e1e2e', '#2a2a3e'], + start: { x: 0, y: 0 }, + end: { x: 1, y: 1 }, + }, + chatHeader: { + colors: ['#14151f', '#0f1017'], + start: { x: 0, y: 0 }, + end: { x: 1, y: 1 }, + }, + fabButton: { + colors: ['#1a1b2e', '#0f1017'], + start: { x: 0, y: 0 }, + end: { x: 1, y: 1 }, + }, +}; diff --git a/mobile/docs/prompt.md b/mobile/docs/prompt.md new file mode 100644 index 0000000..e69de29 diff --git a/mobile/eslint.config.js b/mobile/eslint.config.js new file mode 100644 index 0000000..ba708ed --- /dev/null +++ b/mobile/eslint.config.js @@ -0,0 +1,10 @@ +// https://docs.expo.dev/guides/using-eslint/ +const { defineConfig } = require('eslint/config'); +const expoConfig = require("eslint-config-expo/flat"); + +module.exports = defineConfig([ + expoConfig, + { + ignores: ["dist/*"], + } +]); diff --git a/mobile/hooks/useFrameworkReady.ts b/mobile/hooks/useFrameworkReady.ts new file mode 100644 index 0000000..1e292cb --- /dev/null +++ b/mobile/hooks/useFrameworkReady.ts @@ -0,0 +1,13 @@ +import { useEffect } from 'react'; + +declare global { + interface Window { + frameworkReady?: () => void; + } +} + +export function useFrameworkReady() { + useEffect(() => { + window.frameworkReady?.(); + }); +} diff --git a/mobile/lib/runtime.ts b/mobile/lib/runtime.ts new file mode 100644 index 0000000..5cf3b03 --- /dev/null +++ b/mobile/lib/runtime.ts @@ -0,0 +1,11 @@ +import 'react-native-url-polyfill/auto'; + +import { getRuntimeEnvironment } from '../../shared/runtime.js'; +import { createTradingKillSwitchClient, createTradingMobileSdk } from '../../shared/platform-mobile.js'; + +export const mobileRuntime = getRuntimeEnvironment('mobile'); +export const mobileKillSwitchClient = createTradingKillSwitchClient('mobile'); + +export function createMobilePlatformSdk(getAccessToken: () => string | null = () => null) { + return createTradingMobileSdk(getAccessToken); +} diff --git a/mobile/package.json b/mobile/package.json new file mode 100644 index 0000000..417fdc9 --- /dev/null +++ b/mobile/package.json @@ -0,0 +1,57 @@ +{ + "name": "@bytelyst/trading-mobile", + "main": "expo-router/entry", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "EXPO_NO_TELEMETRY=1 expo start", + "build:web": "expo export --platform web", + "lint": "eslint . --quiet", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@bytelyst/kill-switch-client": "link:../../../learning_ai/learning_ai_common_plat/packages/kill-switch-client", + "@bytelyst/react-native-platform-sdk": "link:../../../learning_ai/learning_ai_common_plat/packages/react-native-platform-sdk", + "@expo-google-fonts/inter": "^0.4.2", + "@expo-google-fonts/jetbrains-mono": "^0.4.1", + "@expo/vector-icons": "^15.0.2", + "@lucide/lab": "^0.1.2", + "@react-navigation/bottom-tabs": "^7.2.0", + "@react-navigation/native": "^7.0.14", + "@supabase/supabase-js": "^2.58.0", + "expo": "^54.0.10", + "expo-blur": "~15.0.7", + "expo-camera": "~17.0.8", + "expo-constants": "~18.0.9", + "expo-font": "~14.0.8", + "expo-haptics": "~15.0.7", + "expo-linear-gradient": "~15.0.7", + "expo-linking": "~8.0.8", + "expo-router": "~6.0.8", + "expo-splash-screen": "~31.0.10", + "expo-status-bar": "~3.0.8", + "expo-symbols": "~1.0.7", + "expo-system-ui": "~6.0.7", + "expo-web-browser": "~15.0.7", + "lucide-react-native": "^0.544.0", + "react": "19.1.0", + "react-dom": "19.1.0", + "react-native": "0.81.4", + "react-native-gesture-handler": "~2.28.0", + "react-native-reanimated": "~4.1.1", + "react-native-safe-area-context": "~5.6.0", + "react-native-screens": "~4.16.0", + "react-native-svg": "15.12.1", + "react-native-url-polyfill": "^2.0.0", + "react-native-web": "^0.21.0", + "react-native-webview": "13.15.0" + }, + "devDependencies": { + "@babel/core": "^7.25.2", + "@types/node": "^24.10.1", + "@types/react": "~19.1.10", + "eslint": "^9.39.1", + "eslint-config-expo": "~10.0.0", + "typescript": "~5.9.2" + } +} diff --git a/mobile/prompt.md b/mobile/prompt.md new file mode 100644 index 0000000..5df3352 --- /dev/null +++ b/mobile/prompt.md @@ -0,0 +1,46 @@ +# Crypto Trading Bot Companion App — Design Specification + +Build a React Native (Expo) mobile companion app for a crypto trading bot platform. +UX/UI showcase with hardcoded mock data — zero backend or API integration. +Premium fintech product: fluid animations, deliberate spacing, typographic hierarchy, micro-interactions. + +## Design System + +### Colors +- Background primary: #0a0b0d, card: #14151a, elevated: #1a1b22 +- Accent green: #00ff88, red: #ff3366, blue: #3498db, orange: #e67e22, amber: #facc15 +- Text primary: #ffffff, secondary: #929292, muted: #666666 +- Borders: rgba(255,255,255,0.05) to rgba(255,255,255,0.1) + +### Typography +- Primary: Inter (400-900), Mono: JetBrains Mono (prices, percentages, P&L) +- Scale: 10px micro labels → 36px page titles + +### Spacing +- Card padding: 20-24px, border-radius: 20/16/12/8px, section gap: 24px, screen padding: 20px + +## Navigation (5 Tabs) +1. Dashboard (activity icon) +2. Positions (layers icon) +3. History (clock icon) +4. Strategies (cpu icon) +5. Settings (sliders icon) ++ Floating AI Chat button + +## Screens +1. Dashboard — Portfolio hero, status banner, market ticker, alerts, positions preview +2. Positions & Orders — Segmented control, detailed position cards, order list +3. Trade History — Audit logs, profile switcher, metrics, trade list +4. Strategies — Strategy cards with stats, marketplace link +5. Settings — Account, execution mode, risk config, broker, notifications +6. AI Chat Modal — Full conversation UI with suggestion chips + +## Key Features +- All mock data hardcoded +- Staggered card entrance animations +- Count-up number animations +- Pulsing status dots +- Custom tab bar with glow effects +- Skeleton loading states +- Pull to refresh +- Haptic feedback on interactions diff --git a/mobile/tsconfig.json b/mobile/tsconfig.json new file mode 100644 index 0000000..fb0de03 --- /dev/null +++ b/mobile/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "types": ["node"], + "paths": { + "@/*": ["./*"] + } + }, + "include": [ + "**/*.ts", + "**/*.tsx", + "../shared/**/*.ts", + ".expo/types/**/*.ts", + "expo-env.d.ts", + "nativewind-env.d.ts" + ] +} diff --git a/mobile/utils/format.ts b/mobile/utils/format.ts new file mode 100644 index 0000000..ae8bbb1 --- /dev/null +++ b/mobile/utils/format.ts @@ -0,0 +1,28 @@ +export const formatCurrency = (value: number, decimals = 2): string => { + const abs = Math.abs(value); + const formatted = abs.toLocaleString('en-US', { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }); + const prefix = value >= 0 ? '+$' : '-$'; + return `${prefix}${formatted}`; +}; + +export const formatPrice = (value: number): string => { + if (value >= 1000) { + return `$${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + } + if (value >= 1) { + return `$${value.toFixed(2)}`; + } + return `$${value.toFixed(4)}`; +}; + +export const formatPercent = (value: number): string => { + const prefix = value >= 0 ? '+' : ''; + return `${prefix}${value.toFixed(2)}%`; +}; + +export const formatNumber = (value: number): string => { + return `$${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; +}; diff --git a/mobile/utils/haptics.ts b/mobile/utils/haptics.ts new file mode 100644 index 0000000..dbdf488 --- /dev/null +++ b/mobile/utils/haptics.ts @@ -0,0 +1,22 @@ +import { Platform } from 'react-native'; + +export const triggerHaptic = async (type: 'light' | 'medium' | 'success' | 'selection' = 'light') => { + if (Platform.OS === 'web') return; + try { + const Haptics = await import('expo-haptics'); + switch (type) { + case 'light': + await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + break; + case 'medium': + await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + break; + case 'success': + await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + break; + case 'selection': + await Haptics.selectionAsync(); + break; + } + } catch {} +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..d3342e1 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "@bytelyst/invt-trading-root", + "version": "0.1.0", + "private": true, + "packageManager": "pnpm@10.6.5", + "type": "module", + "scripts": { + "build": "pnpm --filter @bytelyst/trading-backend build && pnpm --filter @bytelyst/trading-web build && pnpm --filter @bytelyst/trading-mobile typecheck", + "lint": "pnpm --filter @bytelyst/trading-backend lint && pnpm --filter @bytelyst/trading-web lint && pnpm --filter @bytelyst/trading-mobile lint", + "test": "pnpm --filter @bytelyst/trading-backend test && pnpm --filter @bytelyst/trading-web test", + "typecheck": "pnpm --filter @bytelyst/trading-backend typecheck && pnpm --filter @bytelyst/trading-web typecheck && pnpm --filter @bytelyst/trading-mobile typecheck", + "verify": "./scripts/verify.sh" + }, + "dependencies": { + "@bytelyst/kill-switch-client": "link:../../learning_ai/learning_ai_common_plat/packages/kill-switch-client", + "@bytelyst/react-native-platform-sdk": "link:../../learning_ai/learning_ai_common_plat/packages/react-native-platform-sdk", + "@bytelyst/telemetry-client": "link:../../learning_ai/learning_ai_common_plat/packages/telemetry-client" + }, + "devDependencies": { + "typescript": "^5.9.3" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..7b9ae61 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,13264 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@bytelyst/kill-switch-client': + specifier: link:../../learning_ai/learning_ai_common_plat/packages/kill-switch-client + version: link:../../learning_ai/learning_ai_common_plat/packages/kill-switch-client + '@bytelyst/react-native-platform-sdk': + specifier: link:../../learning_ai/learning_ai_common_plat/packages/react-native-platform-sdk + version: link:../../learning_ai/learning_ai_common_plat/packages/react-native-platform-sdk + '@bytelyst/telemetry-client': + specifier: link:../../learning_ai/learning_ai_common_plat/packages/telemetry-client + version: link:../../learning_ai/learning_ai_common_plat/packages/telemetry-client + devDependencies: + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + backend: + dependencies: + '@alpacahq/alpaca-trade-api': + specifier: ^3.1.3 + version: 3.1.3 + '@supabase/supabase-js': + specifier: ^2.90.1 + version: 2.101.1 + '@types/cors': + specifier: ^2.8.19 + version: 2.8.19 + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 + axios: + specifier: ^1.13.2 + version: 1.14.0 + ccxt: + specifier: ^4.5.31 + version: 4.5.46 + cors: + specifier: ^2.8.5 + version: 2.8.6 + dotenv: + specifier: ^17.2.3 + version: 17.4.0 + express: + specifier: ^5.2.1 + version: 5.2.1 + prom-client: + specifier: ^15.1.3 + version: 15.1.3 + socket.io: + specifier: ^4.8.3 + version: 4.8.3 + winston: + specifier: ^3.19.0 + version: 3.19.0 + devDependencies: + '@types/axios': + specifier: ^0.14.4 + version: 0.14.4 + '@types/node': + specifier: ^25.0.3 + version: 25.5.2 + c8: + specifier: ^10.1.3 + version: 10.1.3 + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@25.5.2)(typescript@5.9.3) + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + mobile: + dependencies: + '@bytelyst/kill-switch-client': + specifier: link:../../../learning_ai/learning_ai_common_plat/packages/kill-switch-client + version: link:../../../learning_ai/learning_ai_common_plat/packages/kill-switch-client + '@bytelyst/react-native-platform-sdk': + specifier: link:../../../learning_ai/learning_ai_common_plat/packages/react-native-platform-sdk + version: link:../../../learning_ai/learning_ai_common_plat/packages/react-native-platform-sdk + '@expo-google-fonts/inter': + specifier: ^0.4.2 + version: 0.4.2 + '@expo-google-fonts/jetbrains-mono': + specifier: ^0.4.1 + version: 0.4.1 + '@expo/vector-icons': + specifier: ^15.0.2 + version: 15.1.1(expo-font@14.0.11(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@lucide/lab': + specifier: ^0.1.2 + version: 0.1.2 + '@react-navigation/bottom-tabs': + specifier: ^7.2.0 + version: 7.15.9(@react-navigation/native@7.2.2(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@react-navigation/native': + specifier: ^7.0.14 + version: 7.2.2(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@supabase/supabase-js': + specifier: ^2.58.0 + version: 2.101.1 + expo: + specifier: ^54.0.10 + version: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.15.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-blur: + specifier: ~15.0.7 + version: 15.0.8(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-camera: + specifier: ~17.0.8 + version: 17.0.10(expo@54.0.33)(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-constants: + specifier: ~18.0.9 + version: 18.0.13(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0)) + expo-font: + specifier: ~14.0.8 + version: 14.0.11(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-haptics: + specifier: ~15.0.7 + version: 15.0.8(expo@54.0.33) + expo-linear-gradient: + specifier: ~15.0.7 + version: 15.0.8(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-linking: + specifier: ~8.0.8 + version: 8.0.11(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-router: + specifier: ~6.0.8 + version: 6.0.23(68e2fe297303e98ef2913faa2068e740) + expo-splash-screen: + specifier: ~31.0.10 + version: 31.0.13(expo@54.0.33) + expo-status-bar: + specifier: ~3.0.8 + version: 3.0.9(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-symbols: + specifier: ~1.0.7 + version: 1.0.8(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0)) + expo-system-ui: + specifier: ~6.0.7 + version: 6.0.9(expo@54.0.33)(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0)) + expo-web-browser: + specifier: ~15.0.7 + version: 15.0.10(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0)) + lucide-react-native: + specifier: ^0.544.0 + version: 0.544.0(react-native-svg@15.12.1(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react: + specifier: 19.1.0 + version: 19.1.0 + react-dom: + specifier: 19.1.0 + version: 19.1.0(react@19.1.0) + react-native: + specifier: 0.81.4 + version: 0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + react-native-gesture-handler: + specifier: ~2.28.0 + version: 2.28.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-reanimated: + specifier: ~4.1.1 + version: 4.1.7(react-native-worklets@0.8.1(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-safe-area-context: + specifier: ~5.6.0 + version: 5.6.2(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-screens: + specifier: ~4.16.0 + version: 4.16.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-svg: + specifier: 15.12.1 + version: 15.12.1(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-url-polyfill: + specifier: ^2.0.0 + version: 2.0.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0)) + react-native-web: + specifier: ^0.21.0 + version: 0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react-native-webview: + specifier: 13.15.0 + version: 13.15.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + devDependencies: + '@babel/core': + specifier: ^7.25.2 + version: 7.29.0 + '@types/node': + specifier: ^24.10.1 + version: 24.12.2 + '@types/react': + specifier: ~19.1.10 + version: 19.1.17 + eslint: + specifier: ^9.39.1 + version: 9.39.4(jiti@2.6.1) + eslint-config-expo: + specifier: ~10.0.0 + version: 10.0.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + typescript: + specifier: ~5.9.2 + version: 5.9.3 + + web: + dependencies: + '@bytelyst/kill-switch-client': + specifier: link:../../../learning_ai/learning_ai_common_plat/packages/kill-switch-client + version: link:../../../learning_ai/learning_ai_common_plat/packages/kill-switch-client + '@bytelyst/telemetry-client': + specifier: link:../../../learning_ai/learning_ai_common_plat/packages/telemetry-client + version: link:../../../learning_ai/learning_ai_common_plat/packages/telemetry-client + '@supabase/supabase-js': + specifier: ^2.90.1 + version: 2.101.1 + lucide-react: + specifier: ^0.562.0 + version: 0.562.0(react@19.2.4) + react: + specifier: ^19.2.0 + version: 19.2.4 + react-dom: + specifier: ^19.2.0 + version: 19.2.4(react@19.2.4) + recharts: + specifier: ^3.6.0 + version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@19.2.4)(react@19.2.4)(redux@5.0.1) + socket.io-client: + specifier: ^4.8.3 + version: 4.8.3 + devDependencies: + '@eslint/js': + specifier: ^9.39.1 + version: 9.39.4 + '@tailwindcss/postcss': + specifier: ^4.1.18 + version: 4.2.2 + '@tailwindcss/vite': + specifier: ^4.1.18 + version: 4.2.2(vite@7.3.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) + '@types/node': + specifier: ^24.10.1 + version: 24.12.2 + '@types/react': + specifier: ^19.2.5 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^5.1.1 + version: 5.2.0(vite@7.3.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/coverage-v8': + specifier: ^4.0.18 + version: 4.1.2(vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(jsdom@28.1.0)(vite@7.3.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))) + agentation: + specifier: ^2.2.0 + version: 2.3.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + autoprefixer: + specifier: ^10.4.23 + version: 10.4.27(postcss@8.5.8) + eslint: + specifier: ^9.39.1 + version: 9.39.4(jiti@2.6.1) + eslint-plugin-react-hooks: + specifier: ^7.0.1 + version: 7.0.1(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-react-refresh: + specifier: ^0.4.24 + version: 0.4.26(eslint@9.39.4(jiti@2.6.1)) + globals: + specifier: ^16.5.0 + version: 16.5.0 + jsdom: + specifier: ^28.1.0 + version: 28.1.0 + postcss: + specifier: ^8.5.6 + version: 8.5.8 + tailwindcss: + specifier: ^4.1.18 + version: 4.2.2 + typescript: + specifier: ~5.9.3 + version: 5.9.3 + typescript-eslint: + specifier: ^8.46.4 + version: 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + vite: + specifier: ^7.2.4 + version: 7.3.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vitest: + specifier: ^4.0.18 + version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(jsdom@28.1.0)(vite@7.3.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + +packages: + + '@0no-co/graphql.web@1.2.0': + resolution: {integrity: sha512-/1iHy9TTr63gE1YcR5idjx8UREz1s0kFhydf3bBLCXyqjhkIc6igAzTOx3zPifCwFR87tsh/4Pa9cNts6d2otw==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 + peerDependenciesMeta: + graphql: + optional: true + + '@acemir/cssom@0.9.31': + resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@alpacahq/alpaca-trade-api@3.1.3': + resolution: {integrity: sha512-0b0mAvxaxh1JVoX70g0/Pw28QT+MZdDbvpu+xkf3ZZUT8iYpMVacrB0nWA1qKSM0inwzrcDlVn9uSunOL1wmNQ==} + engines: {node: '>=16.9', npm: '>=6'} + + '@asamuzakjp/css-color@5.1.5': + resolution: {integrity: sha512-8cMAA1bE66Mb/tfmkhcfJLjEPgyT7SSy6lW6id5XL113ai1ky76d/1L27sGnXCMsLfq66DInAU3OzuahB4lu9Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@6.8.1': + resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + + '@babel/code-frame@7.10.4': + resolution: {integrity: sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==} + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.27.3': + resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.28.6': + resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-create-regexp-features-plugin@7.28.5': + resolution: {integrity: sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-define-polyfill-provider@0.6.8': + resolution: {integrity: sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.28.5': + resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.27.1': + resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-remap-async-to-generator@7.27.1': + resolution: {integrity: sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-replace-supers@7.28.6': + resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-wrap-function@7.28.6': + resolution: {integrity: sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + + '@babel/highlight@7.25.9': + resolution: {integrity: sha512-llL88JShoCsth8fF8R4SJnIn+WLvR6ccFxu1H3FlMhDontdcmZWf2HgIZ7AIqV3Xcck1idlohrN4EUBQz6klbw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-proposal-decorators@7.29.0': + resolution: {integrity: sha512-CVBVv3VY/XRMxRYq5dwr2DS7/MvqPm23cOCjbwNnVrfOqcWlnefua1uUs0sjdKOGjvPUG633o07uWzJq4oI6dA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-proposal-export-default-from@7.27.1': + resolution: {integrity: sha512-hjlsMBl1aJc5lp8MoCDEZCiYzlgdRAShOjAfRw6X+GlpLpUPU7c3XNLsKFZbQk/1cRzBlJ7CXg3xJAJMrFa1Uw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-async-generators@7.8.4': + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-bigint@7.8.3': + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-properties@7.12.13': + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-static-block@7.14.5': + resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-decorators@7.28.6': + resolution: {integrity: sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-dynamic-import@7.8.3': + resolution: {integrity: sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-export-default-from@7.28.6': + resolution: {integrity: sha512-Svlx1fjJFnNz0LZeUaybRukSxZI3KkpApUmIRzEdXC5k8ErTOz0OD0kNrICi5Vc3GlpP5ZCeRyRO+mfWTSz+iQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-flow@7.28.6': + resolution: {integrity: sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.28.6': + resolution: {integrity: sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-json-strings@7.8.3': + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.28.6': + resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4': + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-numeric-separator@7.10.4': + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-object-rest-spread@7.8.3': + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3': + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-chaining@7.8.3': + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-private-property-in-object@7.14.5': + resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-top-level-await@7.14.5': + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.28.6': + resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-arrow-functions@7.27.1': + resolution: {integrity: sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-generator-functions@7.29.0': + resolution: {integrity: sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-to-generator@7.28.6': + resolution: {integrity: sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-block-scoping@7.28.6': + resolution: {integrity: sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-properties@7.28.6': + resolution: {integrity: sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-static-block@7.28.6': + resolution: {integrity: sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.12.0 + + '@babel/plugin-transform-classes@7.28.6': + resolution: {integrity: sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-computed-properties@7.28.6': + resolution: {integrity: sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-destructuring@7.28.5': + resolution: {integrity: sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-export-namespace-from@7.27.1': + resolution: {integrity: sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-flow-strip-types@7.27.1': + resolution: {integrity: sha512-G5eDKsu50udECw7DL2AcsysXiQyB7Nfg521t2OAJ4tbfTJ27doHLeF/vlI1NZGlLdbb/v+ibvtL1YBQqYOwJGg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-for-of@7.27.1': + resolution: {integrity: sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-function-name@7.27.1': + resolution: {integrity: sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-literals@7.27.1': + resolution: {integrity: sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-logical-assignment-operators@7.28.6': + resolution: {integrity: sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.28.6': + resolution: {integrity: sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.0': + resolution: {integrity: sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-nullish-coalescing-operator@7.28.6': + resolution: {integrity: sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-numeric-separator@7.28.6': + resolution: {integrity: sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-object-rest-spread@7.28.6': + resolution: {integrity: sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-catch-binding@7.28.6': + resolution: {integrity: sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-chaining@7.28.6': + resolution: {integrity: sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-parameters@7.27.7': + resolution: {integrity: sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-methods@7.28.6': + resolution: {integrity: sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-property-in-object@7.28.6': + resolution: {integrity: sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-display-name@7.28.0': + resolution: {integrity: sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-development@7.27.1': + resolution: {integrity: sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx@7.28.6': + resolution: {integrity: sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-pure-annotations@7.27.1': + resolution: {integrity: sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-regenerator@7.29.0': + resolution: {integrity: sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-runtime@7.29.0': + resolution: {integrity: sha512-jlaRT5dJtMaMCV6fAuLbsQMSwz/QkvaHOHOSXRitGGwSpR1blCY4KUKoyP2tYO8vJcqYe8cEj96cqSztv3uF9w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-shorthand-properties@7.27.1': + resolution: {integrity: sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-spread@7.28.6': + resolution: {integrity: sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-sticky-regex@7.27.1': + resolution: {integrity: sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-template-literals@7.27.1': + resolution: {integrity: sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@7.28.6': + resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-regex@7.27.1': + resolution: {integrity: sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-react@7.28.5': + resolution: {integrity: sha512-Z3J8vhRq7CeLjdC58jLv4lnZ5RKFUJWqH5emvxmv9Hv3BD1T9R/Im713R4MTKwvFaV74ejZ3sM01LyEKk4ugNQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.28.5': + resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@colors/colors@1.6.0': + resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} + engines: {node: '>=0.1.90'} + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.1.1': + resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.0.2': + resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.2': + resolution: {integrity: sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + + '@dabh/diagnostics@2.0.8': + resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} + + '@egjs/hammerjs@2.0.17': + resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==} + engines: {node: '>=0.8.0'} + + '@emnapi/core@1.9.2': + resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} + + '@emnapi/runtime@1.9.2': + resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@2.1.4': + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@8.57.1': + resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + + '@expo-google-fonts/inter@0.4.2': + resolution: {integrity: sha512-syfiImMaDmq7cFi0of+waE2M4uSCyd16zgyWxdPOY7fN2VBmSLKEzkfbZgeOjJq61kSqPBNNtXjggiQiSD6gMQ==} + + '@expo-google-fonts/jetbrains-mono@0.4.1': + resolution: {integrity: sha512-CslACrtMOcRwoWXCO7OMEI+9w3fukWSoBtvNz46OqPoogEuuoY0tkDY1O8sFumk8t0pC6Cx0Xr95O0TOQhpkug==} + + '@expo/cli@54.0.23': + resolution: {integrity: sha512-km0h72SFfQCmVycH/JtPFTVy69w6Lx1cHNDmfLfQqgKFYeeHTjx7LVDP4POHCtNxFP2UeRazrygJhlh4zz498g==} + hasBin: true + peerDependencies: + expo: '*' + expo-router: '*' + react-native: '*' + peerDependenciesMeta: + expo-router: + optional: true + react-native: + optional: true + + '@expo/code-signing-certificates@0.0.6': + resolution: {integrity: sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w==} + + '@expo/config-plugins@54.0.4': + resolution: {integrity: sha512-g2yXGICdoOw5i3LkQSDxl2Q5AlQCrG7oniu0pCPPO+UxGb7He4AFqSvPSy8HpRUj55io17hT62FTjYRD+d6j3Q==} + + '@expo/config-types@54.0.10': + resolution: {integrity: sha512-/J16SC2an1LdtCZ67xhSkGXpALYUVUNyZws7v+PVsFZxClYehDSoKLqyRaGkpHlYrCc08bS0RF5E0JV6g50psA==} + + '@expo/config@12.0.13': + resolution: {integrity: sha512-Cu52arBa4vSaupIWsF0h7F/Cg//N374nYb7HAxV0I4KceKA7x2UXpYaHOL7EEYYvp7tZdThBjvGpVmr8ScIvaQ==} + + '@expo/devcert@1.2.1': + resolution: {integrity: sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==} + + '@expo/devtools@0.1.8': + resolution: {integrity: sha512-SVLxbuanDjJPgc0sy3EfXUMLb/tXzp6XIHkhtPVmTWJAp+FOr6+5SeiCfJrCzZFet0Ifyke2vX3sFcKwEvCXwQ==} + peerDependencies: + react: '*' + react-native: '*' + peerDependenciesMeta: + react: + optional: true + react-native: + optional: true + + '@expo/env@2.0.11': + resolution: {integrity: sha512-xV+ps6YCW7XIPVUwFVCRN2nox09dnRwy8uIjwHWTODu0zFw4kp4omnVkl0OOjuu2XOe7tdgAHxikrkJt9xB/7Q==} + + '@expo/fingerprint@0.15.4': + resolution: {integrity: sha512-eYlxcrGdR2/j2M6pEDXo9zU9KXXF1vhP+V+Tl+lyY+bU8lnzrN6c637mz6Ye3em2ANy8hhUR03Raf8VsT9Ogng==} + hasBin: true + + '@expo/image-utils@0.8.12': + resolution: {integrity: sha512-3KguH7kyKqq7pNwLb9j6BBdD/bjmNwXZG/HPWT6GWIXbwrvAJt2JNyYTP5agWJ8jbbuys1yuCzmkX+TU6rmI7A==} + + '@expo/json-file@10.0.13': + resolution: {integrity: sha512-pX/XjQn7tgNw6zuuV2ikmegmwe/S7uiwhrs2wXrANMkq7ozrA+JcZwgW9Q/8WZgciBzfAhNp5hnackHcrmapQA==} + + '@expo/metro-config@54.0.14': + resolution: {integrity: sha512-hxpLyDfOR4L23tJ9W1IbJJsG7k4lv2sotohBm/kTYyiG+pe1SYCAWsRmgk+H42o/wWf/HQjE5k45S5TomGLxNA==} + peerDependencies: + expo: '*' + peerDependenciesMeta: + expo: + optional: true + + '@expo/metro-runtime@6.1.2': + resolution: {integrity: sha512-nvM+Qv45QH7pmYvP8JB1G8JpScrWND3KrMA6ZKe62cwwNiX/BjHU28Ear0v/4bQWXlOY0mv6B8CDIm8JxXde9g==} + peerDependencies: + expo: '*' + react: '*' + react-dom: '*' + react-native: '*' + peerDependenciesMeta: + react-dom: + optional: true + + '@expo/metro@54.2.0': + resolution: {integrity: sha512-h68TNZPGsk6swMmLm9nRSnE2UXm48rWwgcbtAHVMikXvbxdS41NDHHeqg1rcQ9AbznDRp6SQVC2MVpDnsRKU1w==} + + '@expo/osascript@2.4.2': + resolution: {integrity: sha512-/XP7PSYF2hzOZzqfjgkoWtllyeTN8dW3aM4P6YgKcmmPikKL5FdoyQhti4eh6RK5a5VrUXJTOlTNIpIHsfB5Iw==} + engines: {node: '>=12'} + + '@expo/package-manager@1.10.3': + resolution: {integrity: sha512-ZuXiK/9fCrIuLjPSe1VYmfp0Sa85kCMwd8QQpgyi5ufppYKRtLBg14QOgUqj8ZMbJTxE0xqzd0XR7kOs3vAK9A==} + + '@expo/plist@0.4.8': + resolution: {integrity: sha512-pfNtErGGzzRwHP+5+RqswzPDKkZrx+Cli0mzjQaus1ZWFsog5ibL+nVT3NcporW51o8ggnt7x813vtRbPiyOrQ==} + + '@expo/prebuild-config@54.0.8': + resolution: {integrity: sha512-EA7N4dloty2t5Rde+HP0IEE+nkAQiu4A/+QGZGT9mFnZ5KKjPPkqSyYcRvP5bhQE10D+tvz6X0ngZpulbMdbsg==} + peerDependencies: + expo: '*' + + '@expo/schema-utils@0.1.8': + resolution: {integrity: sha512-9I6ZqvnAvKKDiO+ZF8BpQQFYWXOJvTAL5L/227RUbWG1OVZDInFifzCBiqAZ3b67NRfeAgpgvbA7rejsqhY62A==} + + '@expo/sdk-runtime-versions@1.0.0': + resolution: {integrity: sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ==} + + '@expo/spawn-async@1.7.2': + resolution: {integrity: sha512-QdWi16+CHB9JYP7gma19OVVg0BFkvU8zNj9GjWorYI8Iv8FUxjOCcYRuAmX4s/h91e4e7BPsskc8cSrZYho9Ew==} + engines: {node: '>=12'} + + '@expo/sudo-prompt@9.3.2': + resolution: {integrity: sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==} + + '@expo/vector-icons@15.1.1': + resolution: {integrity: sha512-Iu2VkcoI5vygbtYngm7jb4ifxElNVXQYdDrYkT7UCEIiKLeWnQY0wf2ZhHZ+Wro6Sc5TaumpKUOqDRpLi5rkvw==} + peerDependencies: + expo-font: '>=14.0.4' + react: '*' + react-native: '*' + + '@expo/ws-tunnel@1.0.6': + resolution: {integrity: sha512-nDRbLmSrJar7abvUjp3smDwH8HcbZcoOEa5jVPUv9/9CajgmWw20JNRwTuBRzWIWIkEJDkz20GoNA+tSwUqk0Q==} + + '@expo/xcpretty@4.4.1': + resolution: {integrity: sha512-KZNxZvnGCtiM2aYYZ6Wz0Ix5r47dAvpNLApFtZWnSoERzAdOMzVBOPysBoM0JlF6FKWZ8GPqgn6qt3dV/8Zlpg==} + hasBin: true + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/config-array@0.13.0': + resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} + engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/object-schema@2.0.3': + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} + deprecated: Use @eslint/object-schema instead + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + + '@isaacs/ttlcache@1.4.1': + resolution: {integrity: sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==} + engines: {node: '>=12'} + + '@istanbuljs/load-nyc-config@1.1.0': + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} + + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + + '@jest/create-cache-key-function@29.7.0': + resolution: {integrity: sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/environment@29.7.0': + resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/fake-timers@29.7.0': + resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/transform@29.7.0': + resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/types@29.6.3': + resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@lucide/lab@0.1.2': + resolution: {integrity: sha512-VprF2BJa7ZuTGOhUd5cf8tHJXyL63wdxcGieAiVVoR9hO0YmPsnZO0AGqDiX2/br+/MC6n8BoJcmPilltOXIJA==} + + '@napi-rs/wasm-runtime@0.2.12': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@nolyfill/is-core-module@1.0.39': + resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} + engines: {node: '>=12.4.0'} + + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.0': + resolution: {integrity: sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-tabs@1.1.13': + resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@react-native/assets-registry@0.81.4': + resolution: {integrity: sha512-AMcDadefBIjD10BRqkWw+W/VdvXEomR6aEZ0fhQRAv7igrBzb4PTn4vHKYg+sUK0e3wa74kcMy2DLc/HtnGcMA==} + engines: {node: '>= 20.19.4'} + + '@react-native/babel-plugin-codegen@0.81.5': + resolution: {integrity: sha512-oF71cIH6je3fSLi6VPjjC3Sgyyn57JLHXs+mHWc9MoCiJJcM4nqsS5J38zv1XQ8d3zOW2JtHro+LF0tagj2bfQ==} + engines: {node: '>= 20.19.4'} + + '@react-native/babel-plugin-codegen@0.84.1': + resolution: {integrity: sha512-vorvcvptGxtK0qTDCFQb+W3CU6oIhzcX5dduetWRBoAhXdthEQM0MQnF+GTXoXL8/luffKgy7PlZRG/WeI/oRQ==} + engines: {node: '>= 20.19.4'} + + '@react-native/babel-preset@0.81.5': + resolution: {integrity: sha512-UoI/x/5tCmi+pZ3c1+Ypr1DaRMDLI3y+Q70pVLLVgrnC3DHsHRIbHcCHIeG/IJvoeFqFM2sTdhSOLJrf8lOPrA==} + engines: {node: '>= 20.19.4'} + peerDependencies: + '@babel/core': '*' + + '@react-native/babel-preset@0.84.1': + resolution: {integrity: sha512-3GpmCKk21f4oe32bKIdmkdn+WydvhhZL+1nsoFBGi30Qrq9vL16giKu31OcnWshYz139x+mVAvCyoyzgn8RXSw==} + engines: {node: '>= 20.19.4'} + peerDependencies: + '@babel/core': '*' + + '@react-native/codegen@0.81.4': + resolution: {integrity: sha512-LWTGUTzFu+qOQnvkzBP52B90Ym3stZT8IFCzzUrppz8Iwglg83FCtDZAR4yLHI29VY/x/+pkcWAMCl3739XHdw==} + engines: {node: '>= 20.19.4'} + peerDependencies: + '@babel/core': '*' + + '@react-native/codegen@0.81.5': + resolution: {integrity: sha512-a2TDA03Up8lpSa9sh5VRGCQDXgCTOyDOFH+aqyinxp1HChG8uk89/G+nkJ9FPd0rqgi25eCTR16TWdS3b+fA6g==} + engines: {node: '>= 20.19.4'} + peerDependencies: + '@babel/core': '*' + + '@react-native/codegen@0.84.1': + resolution: {integrity: sha512-n1RIU0QAavgCg1uC5+s53arL7/mpM+16IBhJ3nCFSd/iK5tUmCwxQDcIDC703fuXfpub/ZygeSjVN8bcOWn0gA==} + engines: {node: '>= 20.19.4'} + peerDependencies: + '@babel/core': '*' + + '@react-native/community-cli-plugin@0.81.4': + resolution: {integrity: sha512-8mpnvfcLcnVh+t1ok6V9eozWo8Ut+TZhz8ylJ6gF9d6q9EGDQX6s8jenan5Yv/pzN4vQEKI4ib2pTf/FELw+SA==} + engines: {node: '>= 20.19.4'} + peerDependencies: + '@react-native-community/cli': '*' + '@react-native/metro-config': '*' + peerDependenciesMeta: + '@react-native-community/cli': + optional: true + '@react-native/metro-config': + optional: true + + '@react-native/debugger-frontend@0.81.4': + resolution: {integrity: sha512-SU05w1wD0nKdQFcuNC9D6De0ITnINCi8MEnx9RsTD2e4wN83ukoC7FpXaPCYyP6+VjFt5tUKDPgP1O7iaNXCqg==} + engines: {node: '>= 20.19.4'} + + '@react-native/debugger-frontend@0.81.5': + resolution: {integrity: sha512-bnd9FSdWKx2ncklOetCgrlwqSGhMHP2zOxObJbOWXoj7GHEmih4MKarBo5/a8gX8EfA1EwRATdfNBQ81DY+h+w==} + engines: {node: '>= 20.19.4'} + + '@react-native/dev-middleware@0.81.4': + resolution: {integrity: sha512-hu1Wu5R28FT7nHXs2wWXvQ++7W7zq5GPY83llajgPlYKznyPLAY/7bArc5rAzNB7b0kwnlaoPQKlvD/VP9LZug==} + engines: {node: '>= 20.19.4'} + + '@react-native/dev-middleware@0.81.5': + resolution: {integrity: sha512-WfPfZzboYgo/TUtysuD5xyANzzfka8Ebni6RIb2wDxhb56ERi7qDrE4xGhtPsjCL4pQBXSVxyIlCy0d8I6EgGA==} + engines: {node: '>= 20.19.4'} + + '@react-native/gradle-plugin@0.81.4': + resolution: {integrity: sha512-T7fPcQvDDCSusZFVSg6H1oVDKb/NnVYLnsqkcHsAF2C2KGXyo3J7slH/tJAwNfj/7EOA2OgcWxfC1frgn9TQvw==} + engines: {node: '>= 20.19.4'} + + '@react-native/js-polyfills@0.81.4': + resolution: {integrity: sha512-sr42FaypKXJHMVHhgSbu2f/ZJfrLzgaoQ+HdpRvKEiEh2mhFf6XzZwecyLBvWqf2pMPZa+CpPfNPiejXjKEy8w==} + engines: {node: '>= 20.19.4'} + + '@react-native/js-polyfills@0.84.1': + resolution: {integrity: sha512-UsTe2AbUugsfyI7XIHMQq4E7xeC8a6GrYwuK+NohMMMJMxmyM3JkzIk+GB9e2il6ScEQNMJNaj+q+i5za8itxQ==} + engines: {node: '>= 20.19.4'} + + '@react-native/metro-babel-transformer@0.84.1': + resolution: {integrity: sha512-NswINguTz0eg1Dc0oGO/1dejXSr6iQaz8/NnCRn5HJdA3dGfqadS7zlYv0YjiWpgKgcW6uENaIEgJOQww0KSpw==} + engines: {node: '>= 20.19.4'} + peerDependencies: + '@babel/core': '*' + + '@react-native/metro-config@0.84.1': + resolution: {integrity: sha512-KlRawK4aXxRLlR3HYVfZKhfQp7sejQefQ/LttUWUkErhKO0AFt+yznoSLq7xwIrH9K3A3YwImHuFVtUtuDmurA==} + engines: {node: '>= 20.19.4'} + + '@react-native/normalize-colors@0.74.89': + resolution: {integrity: sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg==} + + '@react-native/normalize-colors@0.81.4': + resolution: {integrity: sha512-9nRRHO1H+tcFqjb9gAM105Urtgcanbta2tuqCVY0NATHeFPDEAB7gPyiLxCHKMi1NbhP6TH0kxgSWXKZl1cyRg==} + + '@react-native/normalize-colors@0.81.5': + resolution: {integrity: sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g==} + + '@react-native/virtualized-lists@0.81.4': + resolution: {integrity: sha512-hBM+rMyL6Wm1Q4f/WpqGsaCojKSNUBqAXLABNGoWm1vabZ7cSnARMxBvA/2vo3hLcoR4v7zDK8tkKm9+O0LjVA==} + engines: {node: '>= 20.19.4'} + peerDependencies: + '@types/react': ^19.1.0 + react: '*' + react-native: '*' + peerDependenciesMeta: + '@types/react': + optional: true + + '@react-navigation/bottom-tabs@7.15.9': + resolution: {integrity: sha512-Ou28A1aZLj5wiFQ3F93aIsrI4NCwn3IJzkkjNo9KLFXsc0Yks+UqrVaFlffHFLsrbajuGRG/OQpnMA1ljayY5Q==} + peerDependencies: + '@react-navigation/native': ^7.2.2 + react: '>= 18.2.0' + react-native: '*' + react-native-safe-area-context: '>= 4.0.0' + react-native-screens: '>= 4.0.0' + + '@react-navigation/core@7.17.2': + resolution: {integrity: sha512-Rt2OZwcgOmjv401uLGAKaRM6xo0fiBce/A7LfRHI1oe5FV+KooWcgAoZ2XOtgKj6UzVMuQWt3b2e6rxo/mDJRA==} + peerDependencies: + react: '>= 18.2.0' + + '@react-navigation/elements@2.9.14': + resolution: {integrity: sha512-lKqzu+su2pI/YIZmR7L7xdOs4UL+rVXKJAMpRMBrwInEy96SjIFst6QDGpE89Dunnu3VjVpjWfByo9f2GWBHDQ==} + peerDependencies: + '@react-native-masked-view/masked-view': '>= 0.2.0' + '@react-navigation/native': ^7.2.2 + react: '>= 18.2.0' + react-native: '*' + react-native-safe-area-context: '>= 4.0.0' + peerDependenciesMeta: + '@react-native-masked-view/masked-view': + optional: true + + '@react-navigation/native-stack@7.14.10': + resolution: {integrity: sha512-mCbYbYhi7Em2R2nEgwYGdLU38smy+KK+HMMVcwuzllWsF3Qb+jOUEYbB6Or7LvE7SS77BZ6sHdx4HptCEv50hQ==} + peerDependencies: + '@react-navigation/native': ^7.2.2 + react: '>= 18.2.0' + react-native: '*' + react-native-safe-area-context: '>= 4.0.0' + react-native-screens: '>= 4.0.0' + + '@react-navigation/native@7.2.2': + resolution: {integrity: sha512-kem1Ko2BcbAjmbQIv66dNmr6EtfDut3QU0qjsVhMnLLhktwyXb6FzZYp8gTrUb6AvkAbaJoi+BF5Pl55pAUa5w==} + peerDependencies: + react: '>= 18.2.0' + react-native: '*' + + '@react-navigation/routers@7.5.3': + resolution: {integrity: sha512-1tJHg4KKRJuQ1/EvJxatrMef3NZXEPzwUIUZ3n1yJ2t7Q97siwRtbynRpQG9/69ebbtiZ8W3ScOZF/OmhvM4Rg==} + + '@reduxjs/toolkit@2.11.2': + resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + + '@rolldown/pluginutils@1.0.0-rc.3': + resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} + + '@rollup/rollup-android-arm-eabi@4.60.1': + resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.1': + resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.1': + resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.1': + resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.1': + resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.1': + resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.60.1': + resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.60.1': + resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.60.1': + resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.60.1': + resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.60.1': + resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.60.1': + resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.60.1': + resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.60.1': + resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.60.1': + resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.1': + resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.1': + resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.1': + resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.1': + resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.1': + resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==} + cpu: [x64] + os: [win32] + + '@rtsao/scc@1.1.0': + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + + '@sinclair/typebox@0.27.10': + resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} + + '@sinonjs/commons@3.0.1': + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + + '@sinonjs/fake-timers@10.3.0': + resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + + '@so-ric/colorspace@1.1.6': + resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} + + '@socket.io/component-emitter@3.1.2': + resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + + '@supabase/auth-js@2.101.1': + resolution: {integrity: sha512-Kd0Wey+RkFHgyVep7adS6UOE2pN6MJ3mZ32PAXSvfw6IjUkFRC7IQpdZZjUOcUe5pXr1ejufCRgF6lsGINe4Tw==} + engines: {node: '>=20.0.0'} + + '@supabase/functions-js@2.101.1': + resolution: {integrity: sha512-OZWU7YtaG+NNNFZK8p/FuJ6gpq7pFyrG2fLOopP73HAIDHDGpOttPJapvO8ADu3RkqfQfkwrB354vPkSBbZ20A==} + engines: {node: '>=20.0.0'} + + '@supabase/phoenix@0.4.0': + resolution: {integrity: sha512-RHSx8bHS02xwfHdAbX5Lpbo6PXbgyf7lTaXTlwtFDPwOIw64NnVRwFAXGojHhjtVYI+PEPNSWwkL90f4agN3bw==} + + '@supabase/postgrest-js@2.101.1': + resolution: {integrity: sha512-UW1RajH5jbZoK+ldAJ1I6VZ+HWwZ2oaKjEQ6Gn+AQ67CHQVxGl8wNQoLYyumbyaExm41I+wn7arulcY1eHeZJw==} + engines: {node: '>=20.0.0'} + + '@supabase/realtime-js@2.101.1': + resolution: {integrity: sha512-Oa6dno0OB9I+hv5do5zsZHbFu41ViZnE9IWjmkeeF/8fPmB5fWoHGqeTYEC3/0DAgtpUoFJa4FpvzFH0SBHo1Q==} + engines: {node: '>=20.0.0'} + + '@supabase/storage-js@2.101.1': + resolution: {integrity: sha512-WhTaUOBgeEvnKLy95Cdlp6+D5igSF/65yC727w1olxbet5nzUvMlajKUWyzNtQu2efrz2cQ7FcdVBdQqgT9YKQ==} + engines: {node: '>=20.0.0'} + + '@supabase/supabase-js@2.101.1': + resolution: {integrity: sha512-Jnhm3LfuACwjIzvk2pfUbGQn7pa7hi6MFzfSyPrRYWVCCu69RPLCFyHSBl7HSBwadbQ3UZOznnD3gPca3ePrRA==} + engines: {node: '>=20.0.0'} + + '@tailwindcss/node@4.2.2': + resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} + + '@tailwindcss/oxide-android-arm64@4.2.2': + resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.2.2': + resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.2.2': + resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.2.2': + resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.2.2': + resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==} + engines: {node: '>= 20'} + + '@tailwindcss/postcss@4.2.2': + resolution: {integrity: sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==} + + '@tailwindcss/vite@4.2.2': + resolution: {integrity: sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + 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 + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@tsconfig/node10@1.0.12': + resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} + + '@tsconfig/node12@1.0.11': + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + + '@tsconfig/node14@1.0.3': + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + + '@tsconfig/node16@1.0.4': + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/axios@0.14.4': + resolution: {integrity: sha512-9JgOaunvQdsQ/qW2OPmE5+hCeUB52lQSolecrFrthct55QekhmXEwT203s20RL+UHtCQc15y3VXpby9E7Kkh/g==} + deprecated: This is a stub types definition. axios provides its own type definitions, so you do not need this installed. + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/cors@2.8.19': + resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/express-serve-static-core@5.1.1': + resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} + + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + + '@types/graceful-fs@4.1.9': + resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} + + '@types/hammerjs@2.0.46': + resolution: {integrity: sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/json5@0.0.29': + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + + '@types/node@24.12.2': + resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==} + + '@types/node@25.5.2': + resolution: {integrity: sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==} + + '@types/qs@6.15.0': + resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.1.17': + resolution: {integrity: sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==} + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + + '@types/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + + '@types/triple-beam@1.3.5': + resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} + + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.35': + resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + + '@typescript-eslint/eslint-plugin@8.58.0': + resolution: {integrity: sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.58.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.58.0': + resolution: {integrity: sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.58.0': + resolution: {integrity: sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.58.0': + resolution: {integrity: sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.58.0': + resolution: {integrity: sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.58.0': + resolution: {integrity: sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.58.0': + resolution: {integrity: sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.58.0': + resolution: {integrity: sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.58.0': + resolution: {integrity: sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.58.0': + resolution: {integrity: sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} + cpu: [arm] + os: [android] + + '@unrs/resolver-binding-android-arm64@1.11.1': + resolution: {integrity: sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==} + cpu: [arm64] + os: [android] + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + resolution: {integrity: sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==} + cpu: [arm64] + os: [darwin] + + '@unrs/resolver-binding-darwin-x64@1.11.1': + resolution: {integrity: sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==} + cpu: [x64] + os: [darwin] + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + resolution: {integrity: sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==} + cpu: [x64] + os: [freebsd] + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + resolution: {integrity: sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + resolution: {integrity: sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} + cpu: [arm64] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} + cpu: [arm64] + os: [linux] + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} + cpu: [ppc64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} + cpu: [s390x] + os: [linux] + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} + cpu: [x64] + os: [linux] + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} + cpu: [x64] + os: [linux] + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + resolution: {integrity: sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==} + cpu: [arm64] + os: [win32] + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + resolution: {integrity: sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==} + cpu: [ia32] + os: [win32] + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + resolution: {integrity: sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==} + cpu: [x64] + os: [win32] + + '@urql/core@5.2.0': + resolution: {integrity: sha512-/n0ieD0mvvDnVAXEQgX/7qJiVcvYvNkOHeBvkwtylfjydar123caCXcl58PXFY11oU1oquJocVXHxLAbtv4x1A==} + + '@urql/exchange-retry@1.3.2': + resolution: {integrity: sha512-TQMCz2pFJMfpNxmSfX1VSfTjwUIFx/mL+p1bnfM1xjjdla7Z+KnGMW/EhFbpckp3LyWAH4PgOsMwOMnIN+MBFg==} + peerDependencies: + '@urql/core': ^5.0.0 + + '@vitejs/plugin-react@5.2.0': + resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + + '@vitest/coverage-v8@4.1.2': + resolution: {integrity: sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg==} + peerDependencies: + '@vitest/browser': 4.1.2 + vitest: 4.1.2 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.1.2': + resolution: {integrity: sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==} + + '@vitest/mocker@4.1.2': + resolution: {integrity: sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.2': + resolution: {integrity: sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==} + + '@vitest/runner@4.1.2': + resolution: {integrity: sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==} + + '@vitest/snapshot@4.1.2': + resolution: {integrity: sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==} + + '@vitest/spy@4.1.2': + resolution: {integrity: sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==} + + '@vitest/utils@4.1.2': + resolution: {integrity: sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==} + + '@xmldom/xmldom@0.8.12': + resolution: {integrity: sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==} + engines: {node: '>=10.0.0'} + + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} + engines: {node: '>=0.4.0'} + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + agentation@2.3.3: + resolution: {integrity: sha512-AUZgFCdBQ/nAohlFsHByM9S2Dp7ECMNqVjlOke4hv/90v+wTiwrGladEkgWS60RDQp+CJ5p97meeCthYgTFlKQ==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + + anser@1.4.10: + resolution: {integrity: sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-regex@4.1.1: + resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==} + engines: {node: '>=6'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-includes@3.1.9: + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlastindex@1.2.6: + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@1.0.0: + resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + async-limiter@1.0.1: + resolution: {integrity: sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + autoprefixer@10.4.27: + resolution: {integrity: sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + axios@0.21.4: + resolution: {integrity: sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==} + + axios@1.14.0: + resolution: {integrity: sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==} + + babel-jest@29.7.0: + resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.8.0 + + babel-plugin-istanbul@6.1.1: + resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} + engines: {node: '>=8'} + + babel-plugin-jest-hoist@29.6.3: + resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + babel-plugin-polyfill-corejs2@0.4.17: + resolution: {integrity: sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-corejs3@0.13.0: + resolution: {integrity: sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-regenerator@0.6.8: + resolution: {integrity: sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-react-compiler@1.0.0: + resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==} + + babel-plugin-react-native-web@0.21.2: + resolution: {integrity: sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA==} + + babel-plugin-syntax-hermes-parser@0.29.1: + resolution: {integrity: sha512-2WFYnoWGdmih1I1J5eIqxATOeycOqRwYxAQBu3cUu/rhwInwHUg7k60AFNbuGjSDL8tje5GDrAnxzRLcu2pYcA==} + + babel-plugin-syntax-hermes-parser@0.32.0: + resolution: {integrity: sha512-m5HthL++AbyeEA2FcdwOLfVFvWYECOBObLHNqdR8ceY4TsEdn4LdX2oTvbB2QJSSElE2AWA/b2MXZ/PF/CqLZg==} + + babel-plugin-transform-flow-enums@0.0.2: + resolution: {integrity: sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ==} + + babel-preset-current-node-syntax@1.2.0: + resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} + peerDependencies: + '@babel/core': ^7.0.0 || ^8.0.0-0 + + babel-preset-expo@54.0.10: + resolution: {integrity: sha512-wTt7POavLFypLcPW/uC5v8y+mtQKDJiyGLzYCjqr9tx0Qc3vCXcDKk1iCFIj/++Iy5CWhhTflEa7VvVPNWeCfw==} + peerDependencies: + '@babel/runtime': ^7.20.0 + expo: '*' + react-refresh: '>=0.14.0 <1.0.0' + peerDependenciesMeta: + '@babel/runtime': + optional: true + expo: + optional: true + + babel-preset-jest@29.6.3: + resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.0.0 + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + base64id@2.0.0: + resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} + engines: {node: ^4.5.0 || >= 5.9} + + baseline-browser-mapping@2.10.14: + resolution: {integrity: sha512-fOVLPAsFTsQfuCkvahZkzq6nf8KvGWanlYoTh0SVA0A/PIUxQGU2AOZAoD95n2gFLVDW/jP6sbGLny95nmEuHA==} + engines: {node: '>=6.0.0'} + hasBin: true + + better-opn@3.0.2: + resolution: {integrity: sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==} + engines: {node: '>=12.0.0'} + + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + + big-integer@1.6.52: + resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} + engines: {node: '>=0.6'} + + bintrees@1.0.2: + resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + bplist-creator@0.1.0: + resolution: {integrity: sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==} + + bplist-parser@0.3.1: + resolution: {integrity: sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA==} + engines: {node: '>= 5.10.0'} + + bplist-parser@0.3.2: + resolution: {integrity: sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==} + engines: {node: '>= 5.10.0'} + + brace-expansion@1.1.13: + resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} + + brace-expansion@2.0.3: + resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + c8@10.1.3: + resolution: {integrity: sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + monocart-coverage-reports: ^2 + peerDependenciesMeta: + monocart-coverage-reports: + optional: true + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.8: + resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + caniuse-lite@1.0.30001785: + resolution: {integrity: sha512-blhOL/WNR+Km1RI/LCVAvA73xplXA7ZbjzI4YkMK9pa6T/P3F2GxjNpEkyw5repTw9IvkyrjyHpwjnhZ5FOvYQ==} + + ccxt@4.5.46: + resolution: {integrity: sha512-C0GJSOKWbF+u9tWKeyP1IatZPtFJ0ZQcEjX37lTz8PiFI9STtZbxvYNDReuShsjrtoHyTvMPm00Rgn7uW0MWpw==} + engines: {node: '>=15.0.0'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + + chrome-launcher@0.15.2: + resolution: {integrity: sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==} + engines: {node: '>=12.13.0'} + hasBin: true + + chromium-edge-launcher@0.2.0: + resolution: {integrity: sha512-JfJjUnq25y9yg4FABRRVPmBGWPZZi+AQXT4mxupb67766/0UlhG8PAZCz6xzEMXTbW3CsSoE8PcCWA49n35mKg==} + + ci-info@2.0.0: + resolution: {integrity: sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==} + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + + cli-cursor@2.1.0: + resolution: {integrity: sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==} + engines: {node: '>=4'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-convert@3.1.3: + resolution: {integrity: sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==} + engines: {node: '>=14.6'} + + color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-name@2.1.0: + resolution: {integrity: sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==} + engines: {node: '>=12.20'} + + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + + color-string@2.1.4: + resolution: {integrity: sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==} + engines: {node: '>=18'} + + color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + + color@5.0.3: + resolution: {integrity: sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==} + engines: {node: '>=18'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + compressible@2.0.18: + resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} + engines: {node: '>= 0.6'} + + compression@1.8.1: + resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} + engines: {node: '>= 0.8.0'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + connect@3.7.0: + resolution: {integrity: sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==} + engines: {node: '>= 0.10.0'} + + content-disposition@1.0.1: + resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + core-js-compat@3.49.0: + resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + + cross-fetch@3.2.0: + resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-in-js-utils@3.1.0: + resolution: {integrity: sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==} + + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-tree@1.1.3: + resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==} + engines: {node: '>=8.0.0'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssstyle@6.2.0: + resolution: {integrity: sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==} + engines: {node: '>=20'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + decode-uri-component@0.2.2: + resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} + engines: {node: '>=0.10'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + diff@4.0.4: + resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} + engines: {node: '>=0.3.1'} + + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dotenv-expand@11.0.7: + resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} + engines: {node: '>=12'} + + dotenv@16.4.7: + resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==} + engines: {node: '>=12'} + + dotenv@17.4.0: + resolution: {integrity: sha512-kCKF62fwtzwYm0IGBNjRUjtJgMfGapII+FslMHIjMR5KTnwEmBmWLDRSnc3XSNP8bNy34tekgQyDT0hr7pERRQ==} + engines: {node: '>=12'} + + dotenv@6.2.0: + resolution: {integrity: sha512-HygQCKUBSFl8wKQZBSemMywRWcEDNidvNbjGVyZu3nbZ8qq9ubiPoGLMdRDpfSrpkkm9BXYFkpKxxFX38o/76w==} + engines: {node: '>=6'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.331: + resolution: {integrity: sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + enabled@2.0.0: + resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==} + + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + engine.io-client@6.6.4: + resolution: {integrity: sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==} + + engine.io-parser@5.2.3: + resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} + engines: {node: '>=10.0.0'} + + engine.io@6.6.6: + resolution: {integrity: sha512-U2SN0w3OpjFRVlrc17E6TMDmH58Xl9rai1MblNjAdwWp07Kk+llmzX0hjDpQdrDGzwmvOtgM5yI+meYX6iZ2xA==} + engines: {node: '>=10.2.0'} + + enhanced-resolve@5.20.1: + resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} + engines: {node: '>=10.13.0'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + env-editor@0.4.2: + resolution: {integrity: sha512-ObFo8v4rQJAE59M69QzwloxPZtd33TpYEIjtKD1rrFDcM1Gd7IkDxEBU+HriziN6HSHQnBJi8Dmy+JWkav5HKA==} + engines: {node: '>=8'} + + error-stack-parser@2.1.4: + resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} + + es-abstract@1.24.1: + resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-iterator-helpers@1.3.1: + resolution: {integrity: sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.0: + resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + engines: {node: '>= 0.4'} + + es-toolkit@1.45.1: + resolution: {integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-expo@10.0.0: + resolution: {integrity: sha512-/XC/DvniUWTzU7Ypb/cLDhDD4DXqEio4lug1ObD/oQ9Hcx3OVOR8Mkp4u6U4iGoZSJyIQmIk3WVHe/P1NYUXKw==} + peerDependencies: + eslint: '>=8.10' + + eslint-import-resolver-node@0.3.10: + resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==} + + eslint-import-resolver-typescript@3.10.1: + resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '*' + eslint-plugin-import: '*' + eslint-plugin-import-x: '*' + peerDependenciesMeta: + eslint-plugin-import: + optional: true + eslint-plugin-import-x: + optional: true + + eslint-module-utils@2.12.1: + resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + + eslint-plugin-expo@1.0.0: + resolution: {integrity: sha512-qLtunR+cNFtC+jwYCBia5c/PJurMjSLMOV78KrEOyQK02ohZapU4dCFFnS2hfrJuw0zxfsjVkjqg3QBqi933QA==} + engines: {node: '>=18.0.0'} + peerDependencies: + eslint: '>=8.10' + + eslint-plugin-import@2.32.0: + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + + eslint-plugin-react-hooks@5.2.0: + resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} + engines: {node: '>=10'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + + eslint-plugin-react-hooks@7.0.1: + resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + + eslint-plugin-react-refresh@0.4.26: + resolution: {integrity: sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==} + peerDependencies: + eslint: '>=8.40' + + eslint-plugin-react@7.37.5: + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + + eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@8.57.1: + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + expo-asset@12.0.12: + resolution: {integrity: sha512-CsXFCQbx2fElSMn0lyTdRIyKlSXOal6ilLJd+yeZ6xaC7I9AICQgscY5nj0QcwgA+KYYCCEQEBndMsmj7drOWQ==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-blur@15.0.8: + resolution: {integrity: sha512-rWyE1NBRZEu9WD+X+5l7gyPRszw7n12cW3IRNAb5i6KFzaBp8cxqT5oeaphJapqURvcqhkOZn2k5EtBSbsuU7w==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-camera@17.0.10: + resolution: {integrity: sha512-w1RBw83mAGVk4BPPwNrCZyFop0VLiVSRE3c2V9onWbdFwonpRhzmB4drygG8YOUTl1H3wQvALJHyMPTbgsK1Jg==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + react-native-web: '*' + peerDependenciesMeta: + react-native-web: + optional: true + + expo-constants@18.0.13: + resolution: {integrity: sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==} + peerDependencies: + expo: '*' + react-native: '*' + + expo-file-system@19.0.21: + resolution: {integrity: sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg==} + peerDependencies: + expo: '*' + react-native: '*' + + expo-font@14.0.11: + resolution: {integrity: sha512-ga0q61ny4s/kr4k8JX9hVH69exVSIfcIc19+qZ7gt71Mqtm7xy2c6kwsPTCyhBW2Ro5yXTT8EaZOpuRi35rHbg==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-haptics@15.0.8: + resolution: {integrity: sha512-lftutojy8Qs8zaDzzjwM3gKHFZ8bOOEZDCkmh2Ddpe95Ra6kt2izeOfOfKuP/QEh0MZ1j9TfqippyHdRd1ZM9g==} + peerDependencies: + expo: '*' + + expo-keep-awake@15.0.8: + resolution: {integrity: sha512-YK9M1VrnoH1vLJiQzChZgzDvVimVoriibiDIFLbQMpjYBnvyfUeHJcin/Gx1a+XgupNXy92EQJLgI/9ZuXajYQ==} + peerDependencies: + expo: '*' + react: '*' + + expo-linear-gradient@15.0.8: + resolution: {integrity: sha512-V2d8Wjn0VzhPHO+rrSBtcl+Fo+jUUccdlmQ6OoL9/XQB7Qk3d9lYrqKDJyccwDxmQT10JdST3Tmf2K52NLc3kw==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-linking@8.0.11: + resolution: {integrity: sha512-+VSaNL5om3kOp/SSKO5qe6cFgfSIWnnQDSbA7XLs3ECkYzXRquk5unxNS3pg7eK5kNUmQ4kgLI7MhTggAEUBLA==} + peerDependencies: + react: '*' + react-native: '*' + + expo-modules-autolinking@3.0.24: + resolution: {integrity: sha512-TP+6HTwhL7orDvsz2VzauyQlXJcAWyU3ANsZ7JGL4DQu8XaZv/A41ZchbtAYLfozNA2Ya1Hzmhx65hXryBMjaQ==} + hasBin: true + + expo-modules-core@3.0.29: + resolution: {integrity: sha512-LzipcjGqk8gvkrOUf7O2mejNWugPkf3lmd9GkqL9WuNyeN2fRwU0Dn77e3ZUKI3k6sI+DNwjkq4Nu9fNN9WS7Q==} + peerDependencies: + react: '*' + react-native: '*' + + expo-router@6.0.23: + resolution: {integrity: sha512-qCxVAiCrCyu0npky6azEZ6dJDMt77OmCzEbpF6RbUTlfkaCA417LvY14SBkk0xyGruSxy/7pvJOI6tuThaUVCA==} + peerDependencies: + '@expo/metro-runtime': ^6.1.2 + '@react-navigation/drawer': ^7.5.0 + '@testing-library/react-native': '>= 12.0.0' + expo: '*' + expo-constants: ^18.0.13 + expo-linking: ^8.0.11 + react: '*' + react-dom: '*' + react-native: '*' + react-native-gesture-handler: '*' + react-native-reanimated: '*' + react-native-safe-area-context: '>= 5.4.0' + react-native-screens: '*' + react-native-web: '*' + react-server-dom-webpack: ~19.0.4 || ~19.1.5 || ~19.2.4 + peerDependenciesMeta: + '@react-navigation/drawer': + optional: true + '@testing-library/react-native': + optional: true + react-dom: + optional: true + react-native-gesture-handler: + optional: true + react-native-reanimated: + optional: true + react-native-web: + optional: true + react-server-dom-webpack: + optional: true + + expo-server@1.0.5: + resolution: {integrity: sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA==} + engines: {node: '>=20.16.0'} + + expo-splash-screen@31.0.13: + resolution: {integrity: sha512-1epJLC1cDlwwj089R2h8cxaU5uk4ONVAC+vzGiTZH4YARQhL4Stlz1MbR6yAS173GMosvkE6CAeihR7oIbCkDA==} + peerDependencies: + expo: '*' + + expo-status-bar@3.0.9: + resolution: {integrity: sha512-xyYyVg6V1/SSOZWh4Ni3U129XHCnFHBTcUo0dhWtFDrZbNp/duw5AGsQfb2sVeU0gxWHXSY1+5F0jnKYC7WuOw==} + peerDependencies: + react: '*' + react-native: '*' + + expo-symbols@1.0.8: + resolution: {integrity: sha512-7bNjK350PaQgxBf0owpmSYkdZIpdYYmaPttDBb2WIp6rIKtcEtdzdfmhsc2fTmjBURHYkg36+eCxBFXO25/1hw==} + peerDependencies: + expo: '*' + react-native: '*' + + expo-system-ui@6.0.9: + resolution: {integrity: sha512-eQTYGzw1V4RYiYHL9xDLYID3Wsec2aZS+ypEssmF64D38aDrqbDgz1a2MSlHLQp2jHXSs3FvojhZ9FVela1Zcg==} + peerDependencies: + expo: '*' + react-native: '*' + react-native-web: '*' + peerDependenciesMeta: + react-native-web: + optional: true + + expo-web-browser@15.0.10: + resolution: {integrity: sha512-fvDhW4bhmXAeWFNFiInmsGCK83PAqAcQaFyp/3pE/jbdKmFKoRCWr46uZGIfN4msLK/OODhaQ/+US7GSJNDHJg==} + peerDependencies: + expo: '*' + react-native: '*' + + expo@54.0.33: + resolution: {integrity: sha512-3yOEfAKqo+gqHcV8vKcnq0uA5zxlohnhA3fu4G43likN8ct5ZZ3LjAh9wDdKteEkoad3tFPvwxmXW711S5OHUw==} + hasBin: true + peerDependencies: + '@expo/dom-webview': '*' + '@expo/metro-runtime': '*' + react: '*' + react-native: '*' + react-native-webview: '*' + peerDependenciesMeta: + '@expo/dom-webview': + optional: true + '@expo/metro-runtime': + optional: true + react-native-webview: + optional: true + + exponential-backoff@3.1.3: + resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + extend@2.0.2: + resolution: {integrity: sha512-AgFD4VU+lVLP6vjnlNfF7OeInLTyeyckCNPEsuxz1vi786UuK/nk6ynPuhn/h+Ju9++TQyr5EpLRI14fc1QtTQ==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + + fbjs-css-vars@1.0.2: + resolution: {integrity: sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==} + + fbjs@3.0.5: + resolution: {integrity: sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fecha@4.2.3: + resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} + + file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + filter-obj@1.1.0: + resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==} + engines: {node: '>=0.10.0'} + + finalhandler@1.1.2: + resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==} + engines: {node: '>= 0.8'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@3.2.0: + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} + engines: {node: ^10.12.0 || >=12.0.0} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + flow-enums-runtime@0.0.6: + resolution: {integrity: sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==} + + fn.name@1.1.0: + resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} + + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + fontfaceobserver@2.3.0: + resolution: {integrity: sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==} + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + freeport-async@2.0.0: + resolution: {integrity: sha512-K7od3Uw45AJg00XUmy15+Hae2hOcgKcmN3/EF6Y7i01O0gaqiRx8sUSpsb9+BRNL8RPBrhzPsVfy8q9ADlJuWQ==} + engines: {node: '>=8'} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.1.8: + resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.13.7: + resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} + + getenv@2.0.0: + resolution: {integrity: sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==} + engines: {node: '>=6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@16.5.0: + resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} + engines: {node: '>=18'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-estree@0.29.1: + resolution: {integrity: sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ==} + + hermes-estree@0.32.0: + resolution: {integrity: sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==} + + hermes-estree@0.33.3: + resolution: {integrity: sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + hermes-parser@0.29.1: + resolution: {integrity: sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA==} + + hermes-parser@0.32.0: + resolution: {integrity: sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==} + + hermes-parser@0.33.3: + resolution: {integrity: sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA==} + + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + + hosted-git-info@7.0.2: + resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} + engines: {node: ^16.14.0 || >=18.0.0} + + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + hyphenate-style-name@1.1.0: + resolution: {integrity: sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==} + + iceberg-js@0.8.1: + resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==} + engines: {node: '>=20.0.0'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + image-size@1.2.1: + resolution: {integrity: sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==} + engines: {node: '>=16.x'} + hasBin: true + + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + + immer@11.1.4: + resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + inflight@1.0.6: + resolution: {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. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + inline-style-prefixer@7.0.1: + resolution: {integrity: sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + invariant@2.2.4: + resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-arrayish@0.3.4: + resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-bun-module@2.0.0: + resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@5.2.1: + resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + iterator.prototype@1.1.5: + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jest-environment-node@29.7.0: + resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-get-type@29.6.3: + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-haste-map@29.7.0: + resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-message-util@29.7.0: + resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-mock@29.7.0: + resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-regex-util@29.6.3: + resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-util@29.7.0: + resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-validate@29.7.0: + resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-worker@29.7.0: + resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jimp-compact@0.16.1: + resolution: {integrity: sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww==} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@3.14.2: + resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + hasBin: true + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsc-safe-url@0.2.4: + resolution: {integrity: sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==} + + jsdom@28.1.0: + resolution: {integrity: sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + + just-extend@4.2.1: + resolution: {integrity: sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + kuler@2.0.0: + resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} + + lan-network@0.1.7: + resolution: {integrity: sha512-mnIlAEMu4OyEvUNdzco9xpuB9YVcPkQec+QsgycBCtPZvEqWPCDPfbAE4OJMdBBWpZWtpCn1xw9jJYlwjWI5zQ==} + hasBin: true + + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lighthouse-logger@1.4.2: + resolution: {integrity: sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.throttle@4.1.1: + resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + log-symbols@2.2.0: + resolution: {integrity: sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==} + engines: {node: '>=4'} + + logform@2.7.0: + resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} + engines: {node: '>= 12.0.0'} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.2.7: + resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==} + engines: {node: 20 || >=22} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react-native@0.544.0: + resolution: {integrity: sha512-ylN6TfmVmZVAd82CmTWyKnYv8e5WQLvaaabOHIB5z3OMA2Vg/jtMW5ORDoR2J7Hr8LMSys1v2b2Fr1GVxr3XQA==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-native: '*' + react-native-svg: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 + + lucide-react@0.562.0: + resolution: {integrity: sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.2: + resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + + makeerror@1.0.12: + resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + + marky@1.3.0: + resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdn-data@2.0.14: + resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + memoize-one@5.2.1: + resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + + memoize-one@6.0.0: + resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + metro-babel-transformer@0.83.3: + resolution: {integrity: sha512-1vxlvj2yY24ES1O5RsSIvg4a4WeL7PFXgKOHvXTXiW0deLvQr28ExXj6LjwCCDZ4YZLhq6HddLpZnX4dEdSq5g==} + engines: {node: '>=20.19.4'} + + metro-babel-transformer@0.83.5: + resolution: {integrity: sha512-d9FfmgUEVejTiSb7bkQeLRGl6aeno2UpuPm3bo3rCYwxewj03ymvOn8s8vnS4fBqAPQ+cE9iQM40wh7nGXR+eA==} + engines: {node: '>=20.19.4'} + + metro-cache-key@0.83.3: + resolution: {integrity: sha512-59ZO049jKzSmvBmG/B5bZ6/dztP0ilp0o988nc6dpaDsU05Cl1c/lRf+yx8m9WW/JVgbmfO5MziBU559XjI5Zw==} + engines: {node: '>=20.19.4'} + + metro-cache-key@0.83.5: + resolution: {integrity: sha512-Ycl8PBajB7bhbAI7Rt0xEyiF8oJ0RWX8EKkolV1KfCUlC++V/GStMSGpPLwnnBZXZWkCC5edBPzv1Hz1Yi0Euw==} + engines: {node: '>=20.19.4'} + + metro-cache@0.83.3: + resolution: {integrity: sha512-3jo65X515mQJvKqK3vWRblxDEcgY55Sk3w4xa6LlfEXgQ9g1WgMh9m4qVZVwgcHoLy0a2HENTPCCX4Pk6s8c8Q==} + engines: {node: '>=20.19.4'} + + metro-cache@0.83.5: + resolution: {integrity: sha512-oH+s4U+IfZyg8J42bne2Skc90rcuESIYf86dYittcdWQtPfcaFXWpByPyTuWk3rR1Zz3Eh5HOrcVImfEhhJLng==} + engines: {node: '>=20.19.4'} + + metro-config@0.83.3: + resolution: {integrity: sha512-mTel7ipT0yNjKILIan04bkJkuCzUUkm2SeEaTads8VfEecCh+ltXchdq6DovXJqzQAXuR2P9cxZB47Lg4klriA==} + engines: {node: '>=20.19.4'} + + metro-config@0.83.5: + resolution: {integrity: sha512-JQ/PAASXH7yczgV6OCUSRhZYME+NU8NYjI2RcaG5ga4QfQ3T/XdiLzpSb3awWZYlDCcQb36l4Vl7i0Zw7/Tf9w==} + engines: {node: '>=20.19.4'} + + metro-core@0.83.3: + resolution: {integrity: sha512-M+X59lm7oBmJZamc96usuF1kusd5YimqG/q97g4Ac7slnJ3YiGglW5CsOlicTR5EWf8MQFxxjDoB6ytTqRe8Hw==} + engines: {node: '>=20.19.4'} + + metro-core@0.83.5: + resolution: {integrity: sha512-YcVcLCrf0ed4mdLa82Qob0VxYqfhmlRxUS8+TO4gosZo/gLwSvtdeOjc/Vt0pe/lvMNrBap9LlmvZM8FIsMgJQ==} + engines: {node: '>=20.19.4'} + + metro-file-map@0.83.3: + resolution: {integrity: sha512-jg5AcyE0Q9Xbbu/4NAwwZkmQn7doJCKGW0SLeSJmzNB9Z24jBe0AL2PHNMy4eu0JiKtNWHz9IiONGZWq7hjVTA==} + engines: {node: '>=20.19.4'} + + metro-file-map@0.83.5: + resolution: {integrity: sha512-ZEt8s3a1cnYbn40nyCD+CsZdYSlwtFh2kFym4lo+uvfM+UMMH+r/BsrC6rbNClSrt+B7rU9T+Te/sh/NL8ZZKQ==} + engines: {node: '>=20.19.4'} + + metro-minify-terser@0.83.3: + resolution: {integrity: sha512-O2BmfWj6FSfzBLrNCXt/rr2VYZdX5i6444QJU0fFoc7Ljg+Q+iqebwE3K0eTvkI6TRjELsXk1cjU+fXwAR4OjQ==} + engines: {node: '>=20.19.4'} + + metro-minify-terser@0.83.5: + resolution: {integrity: sha512-Toe4Md1wS1PBqbvB0cFxBzKEVyyuYTUb0sgifAZh/mSvLH84qA1NAWik9sISWatzvfWf3rOGoUoO5E3f193a3Q==} + engines: {node: '>=20.19.4'} + + metro-resolver@0.83.3: + resolution: {integrity: sha512-0js+zwI5flFxb1ktmR///bxHYg7OLpRpWZlBBruYG8OKYxeMP7SV0xQ/o/hUelrEMdK4LJzqVtHAhBm25LVfAQ==} + engines: {node: '>=20.19.4'} + + metro-resolver@0.83.5: + resolution: {integrity: sha512-7p3GtzVUpbAweJeCcUJihJeOQl1bDuimO5ueo1K0BUpUtR41q5EilbQ3klt16UTPPMpA+tISWBtsrqU556mY1A==} + engines: {node: '>=20.19.4'} + + metro-runtime@0.83.3: + resolution: {integrity: sha512-JHCJb9ebr9rfJ+LcssFYA2x1qPYuSD/bbePupIGhpMrsla7RCwC/VL3yJ9cSU+nUhU4c9Ixxy8tBta+JbDeZWw==} + engines: {node: '>=20.19.4'} + + metro-runtime@0.83.5: + resolution: {integrity: sha512-f+b3ue9AWTVlZe2Xrki6TAoFtKIqw30jwfk7GQ1rDUBQaE0ZQ+NkiMEtb9uwH7uAjJ87U7Tdx1Jg1OJqUfEVlA==} + engines: {node: '>=20.19.4'} + + metro-source-map@0.83.3: + resolution: {integrity: sha512-xkC3qwUBh2psVZgVavo8+r2C9Igkk3DibiOXSAht1aYRRcztEZNFtAMtfSB7sdO2iFMx2Mlyu++cBxz/fhdzQg==} + engines: {node: '>=20.19.4'} + + metro-source-map@0.83.5: + resolution: {integrity: sha512-VT9bb2KO2/4tWY9Z2yeZqTUao7CicKAOps9LUg2aQzsz+04QyuXL3qgf1cLUVRjA/D6G5u1RJAlN1w9VNHtODQ==} + engines: {node: '>=20.19.4'} + + metro-symbolicate@0.83.3: + resolution: {integrity: sha512-F/YChgKd6KbFK3eUR5HdUsfBqVsanf5lNTwFd4Ca7uuxnHgBC3kR/Hba/RGkenR3pZaGNp5Bu9ZqqP52Wyhomw==} + engines: {node: '>=20.19.4'} + hasBin: true + + metro-symbolicate@0.83.5: + resolution: {integrity: sha512-EMIkrjNRz/hF+p0RDdxoE60+dkaTLPN3vaaGkFmX5lvFdO6HPfHA/Ywznzkev+za0VhPQ5KSdz49/MALBRteHA==} + engines: {node: '>=20.19.4'} + hasBin: true + + metro-transform-plugins@0.83.3: + resolution: {integrity: sha512-eRGoKJU6jmqOakBMH5kUB7VitEWiNrDzBHpYbkBXW7C5fUGeOd2CyqrosEzbMK5VMiZYyOcNFEphvxk3OXey2A==} + engines: {node: '>=20.19.4'} + + metro-transform-plugins@0.83.5: + resolution: {integrity: sha512-KxYKzZL+lt3Os5H2nx7YkbkWVduLZL5kPrE/Yq+Prm/DE1VLhpfnO6HtPs8vimYFKOa58ncl60GpoX0h7Wm0Vw==} + engines: {node: '>=20.19.4'} + + metro-transform-worker@0.83.3: + resolution: {integrity: sha512-Ztekew9t/gOIMZX1tvJOgX7KlSLL5kWykl0Iwu2cL2vKMKVALRl1hysyhUw0vjpAvLFx+Kfq9VLjnHIkW32fPA==} + engines: {node: '>=20.19.4'} + + metro-transform-worker@0.83.5: + resolution: {integrity: sha512-8N4pjkNXc6ytlP9oAM6MwqkvUepNSW39LKYl9NjUMpRDazBQ7oBpQDc8Sz4aI8jnH6AGhF7s1m/ayxkN1t04yA==} + engines: {node: '>=20.19.4'} + + metro@0.83.3: + resolution: {integrity: sha512-+rP+/GieOzkt97hSJ0MrPOuAH/jpaS21ZDvL9DJ35QYRDlQcwzcvUlGUf79AnQxq/2NPiS/AULhhM4TKutIt8Q==} + engines: {node: '>=20.19.4'} + hasBin: true + + metro@0.83.5: + resolution: {integrity: sha512-BgsXevY1MBac/3ZYv/RfNFf/4iuW9X7f4H8ZNkiH+r667HD9sVujxcmu4jvEzGCAm4/WyKdZCuyhAcyhTHOucQ==} + engines: {node: '>=20.19.4'} + hasBin: true + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + mimic-fn@1.2.0: + resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==} + engines: {node: '>=4'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + msgpack5@5.3.2: + resolution: {integrity: sha512-e9jz+6InQIUb2cGzZ/Mi+rQBs1KFLby+gNi+22VwQ1pnC9ieZjysKGmRMjdlf6IyjsltbgY4tGoHwHmyS7l94A==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-postinstall@0.3.4: + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + hasBin: true + + nats@1.4.12: + resolution: {integrity: sha512-Jf4qesEF0Ay0D4AMw3OZnKMRTQm+6oZ5q8/m4gpy5bTmiDiK6wCXbZpzEslmezGpE93LV3RojNEG6dpK/mysLQ==} + engines: {node: '>= 8.0.0'} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + negotiator@0.6.4: + resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} + engines: {node: '>= 0.6'} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + nested-error-stacks@2.0.1: + resolution: {integrity: sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==} + + node-exports-info@1.6.0: + resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} + engines: {node: '>= 0.4'} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-forge@1.4.0: + resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} + engines: {node: '>= 6.13.0'} + + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + + node-releases@2.0.37: + resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + npm-package-arg@11.0.3: + resolution: {integrity: sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==} + engines: {node: ^16.14.0 || >=18.0.0} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + nuid@1.1.6: + resolution: {integrity: sha512-Eb3CPCupYscP1/S1FQcO5nxtu6l/F3k0MQ69h7f5osnsemVk5pkc8/5AyalVT+NCfra9M71U8POqF6EZa6IHvg==} + engines: {node: '>= 8.16.0'} + + nullthrows@1.1.1: + resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==} + + ob1@0.83.3: + resolution: {integrity: sha512-egUxXCDwoWG06NGCS5s5AdcpnumHKJlfd3HH06P3m9TEMwwScfcY35wpQxbm9oHof+dM/lVH9Rfyu1elTVelSA==} + engines: {node: '>=20.19.4'} + + ob1@0.83.5: + resolution: {integrity: sha512-vNKPYC8L5ycVANANpF/S+WZHpfnRWKx/F3AYP4QMn6ZJTh+l2HOrId0clNkEmua58NB9vmI9Qh7YOoV/4folYg==} + engines: {node: '>=20.19.4'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.groupby@1.0.3: + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + on-finished@2.3.0: + resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} + engines: {node: '>= 0.8'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + one-time@1.0.0: + resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==} + + onetime@2.0.1: + resolution: {integrity: sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==} + engines: {node: '>=4'} + + open@7.4.2: + resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} + engines: {node: '>=8'} + + open@8.4.2: + resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} + engines: {node: '>=12'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + ora@3.4.0: + resolution: {integrity: sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==} + engines: {node: '>=6'} + + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-png@2.1.0: + resolution: {integrity: sha512-Nt/a5SfCLiTnQAjx3fHlqp8hRgTL3z7kTQZzvIMS9uCAepnCyjpdEc6M/sz69WqMBdaDBw9sF1F1UaHROYzGkQ==} + engines: {node: '>=10'} + + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@3.0.2: + resolution: {integrity: sha512-cfDHL6LStTEKlNilboNtobT/kEa30PtAf2Q1OgszfrG/rpVl1xaFWT9ktfkS306GmHgmnad1Sw4wabhlvFtsTw==} + engines: {node: '>=10'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + plist@3.1.0: + resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} + engines: {node: '>=10.4.0'} + + pngjs@3.4.0: + resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==} + engines: {node: '>=4.0.0'} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.4.49: + resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==} + engines: {node: ^10 || ^12 || >=14} + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + pretty-bytes@5.6.0: + resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==} + engines: {node: '>=6'} + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + proc-log@4.2.0: + resolution: {integrity: sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + + prom-client@15.1.3: + resolution: {integrity: sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==} + engines: {node: ^16 || ^18 || >=20} + + promise@7.3.1: + resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==} + + promise@8.3.0: + resolution: {integrity: sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==} + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qrcode-terminal@0.11.0: + resolution: {integrity: sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==} + hasBin: true + + qs@6.15.0: + resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} + engines: {node: '>=0.6'} + + query-string@7.1.3: + resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + queue@6.0.2: + resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + react-devtools-core@6.1.5: + resolution: {integrity: sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==} + + react-dom@19.1.0: + resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==} + peerDependencies: + react: ^19.1.0 + + react-dom@19.2.4: + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} + peerDependencies: + react: ^19.2.4 + + react-fast-compare@3.2.2: + resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} + + react-freeze@1.0.4: + resolution: {integrity: sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA==} + engines: {node: '>=10'} + peerDependencies: + react: '>=17.0.0' + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + react-is@19.2.4: + resolution: {integrity: sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==} + + react-native-gesture-handler@2.28.0: + resolution: {integrity: sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-is-edge-to-edge@1.3.1: + resolution: {integrity: sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-reanimated@4.1.7: + resolution: {integrity: sha512-Q4H6xA3Tn7QL0/E/KjI86I1KK4tcf+ErRE04LH34Etka2oVQhW6oXQ+Q8ZcDCVxiWp5vgbBH6XcH8BOo4w/Rhg==} + peerDependencies: + react: '*' + react-native: 0.78 - 0.82 + react-native-worklets: 0.5 - 0.8 + + react-native-safe-area-context@5.6.2: + resolution: {integrity: sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-screens@4.16.0: + resolution: {integrity: sha512-yIAyh7F/9uWkOzCi1/2FqvNvK6Wb9Y1+Kzn16SuGfN9YFJDTbwlzGRvePCNTOX0recpLQF3kc2FmvMUhyTCH1Q==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-svg@15.12.1: + resolution: {integrity: sha512-vCuZJDf8a5aNC2dlMovEv4Z0jjEUET53lm/iILFnFewa15b4atjVxU6Wirm6O9y6dEsdjDZVD7Q3QM4T1wlI8g==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-url-polyfill@2.0.0: + resolution: {integrity: sha512-My330Do7/DvKnEvwQc0WdcBnFPploYKp9CYlefDXzIdEaA+PAhDYllkvGeEroEzvc4Kzzj2O4yVdz8v6fjRvhA==} + peerDependencies: + react-native: '*' + + react-native-web@0.21.2: + resolution: {integrity: sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + react-native-webview@13.15.0: + resolution: {integrity: sha512-Vzjgy8mmxa/JO6l5KZrsTC7YemSdq+qB01diA0FqjUTaWGAGwuykpJ73MDj3+mzBSlaDxAEugHzTtkUQkQEQeQ==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-worklets@0.8.1: + resolution: {integrity: sha512-oWP/lStsAHU6oYCaWDXrda/wOHVdhusQJz1e6x9gPnXdFf4ndNDAOtWCmk2zGrAnlapfyA3rM6PCQq94mPg9cw==} + peerDependencies: + '@babel/core': '*' + '@react-native/metro-config': '*' + react: '*' + react-native: 0.81 - 0.85 + + react-native@0.81.4: + resolution: {integrity: sha512-bt5bz3A/+Cv46KcjV0VQa+fo7MKxs17RCcpzjftINlen4ZDUl0I6Ut+brQ2FToa5oD0IB0xvQHfmsg2EDqsZdQ==} + engines: {node: '>= 20.19.4'} + hasBin: true + peerDependencies: + '@types/react': ^19.1.0 + react: ^19.1.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-redux@9.2.0: + resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + + react-refresh@0.14.2: + resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} + engines: {node: '>=0.10.0'} + + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} + engines: {node: '>=0.10.0'} + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react@19.1.0: + resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} + engines: {node: '>=0.10.0'} + + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + engines: {node: '>=0.10.0'} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + recharts@3.8.1: + resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + regenerate-unicode-properties@10.2.2: + resolution: {integrity: sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==} + engines: {node: '>=4'} + + regenerate@1.4.2: + resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} + + regenerator-runtime@0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + regexpu-core@6.4.0: + resolution: {integrity: sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==} + engines: {node: '>=4'} + + regjsgen@0.8.0: + resolution: {integrity: sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==} + + regjsparser@0.13.0: + resolution: {integrity: sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==} + hasBin: true + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + requireg@0.2.2: + resolution: {integrity: sha512-nYzyjnFcPNGR3lx9lwPPPnuQxv6JWEZd2Ci0u9opN7N5zUEPIhY/GbL3vMGOr2UXwEg9WwSyV9X9Y/kLFgPsOg==} + engines: {node: '>= 4.0.0'} + + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve-workspace-root@2.0.1: + resolution: {integrity: sha512-nR23LHAvaI6aHtMg6RWoaHpdR4D881Nydkzi2CixINyg9T00KgaJdJI6Vwty+Ps8WLxZHuxsS0BseWjxSA4C+w==} + + resolve.exports@2.0.3: + resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} + engines: {node: '>=10'} + + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + + resolve@1.7.1: + resolution: {integrity: sha512-c7rwLofp8g1U+h1KNyHL/jicrKg1Ek4q+Lr33AL65uZTinUZHe30D5HlyN5V9NW0JX1D5dXQ4jqW5l7Sy/kGfw==} + + resolve@2.0.0-next.6: + resolution: {integrity: sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==} + engines: {node: '>= 0.4'} + hasBin: true + + restore-cursor@2.0.0: + resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==} + engines: {node: '>=4'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rollup@4.60.1: + resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-array-concat@1.1.3: + resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} + engines: {node: '>=0.4'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + engines: {node: '>=11.0.0'} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.26.0: + resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.6.3: + resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} + engines: {node: '>=10'} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} + engines: {node: '>= 0.8.0'} + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serialize-error@2.1.0: + resolution: {integrity: sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==} + engines: {node: '>=0.10.0'} + + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} + engines: {node: '>= 0.8.0'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + sf-symbols-typescript@2.2.0: + resolution: {integrity: sha512-TPbeg0b7ylrswdGCji8FRGFAKuqbpQlLbL8SOle3j1iHSs5Ob5mhvMAxWN2UItOjgALAB5Zp3fmMfj8mbWvXKw==} + engines: {node: '>=10'} + + shallowequal@1.1.0: + resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shell-quote@1.8.3: + resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} + engines: {node: '>= 0.4'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + simple-plist@1.3.1: + resolution: {integrity: sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==} + + simple-swizzle@0.2.4: + resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + slugify@1.6.9: + resolution: {integrity: sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==} + engines: {node: '>=8.0.0'} + + socket.io-adapter@2.5.6: + resolution: {integrity: sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==} + + socket.io-client@4.8.3: + resolution: {integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==} + engines: {node: '>=10.0.0'} + + socket.io-parser@4.2.6: + resolution: {integrity: sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==} + engines: {node: '>=10.0.0'} + + socket.io@4.8.3: + resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==} + engines: {node: '>=10.2.0'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.5.7: + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} + engines: {node: '>=0.10.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + split-on-first@1.1.0: + resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} + engines: {node: '>=6'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + stable-hash@0.0.5: + resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + + stack-trace@0.0.10: + resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + stackframe@1.3.4: + resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} + + stacktrace-parser@0.1.11: + resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==} + engines: {node: '>=6'} + + statuses@1.5.0: + resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} + engines: {node: '>= 0.6'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@4.0.0: + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + stream-buffers@2.2.0: + resolution: {integrity: sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==} + engines: {node: '>= 0.10.0'} + + strict-uri-encode@2.0.0: + resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} + engines: {node: '>=4'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string.prototype.matchall@4.0.12: + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + engines: {node: '>= 0.4'} + + string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + + string.prototype.trim@1.2.10: + resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.9: + resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@5.2.0: + resolution: {integrity: sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==} + engines: {node: '>=6'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + structured-headers@0.4.1: + resolution: {integrity: sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==} + + styleq@0.1.3: + resolution: {integrity: sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-hyperlinks@2.3.0: + resolution: {integrity: sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tailwindcss@4.2.2: + resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} + + tapable@2.3.2: + resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} + engines: {node: '>=6'} + + tar@7.5.13: + resolution: {integrity: sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==} + engines: {node: '>=18'} + + tdigest@0.1.2: + resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==} + + terminal-link@2.1.1: + resolution: {integrity: sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==} + engines: {node: '>=8'} + + terser@5.46.1: + resolution: {integrity: sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==} + engines: {node: '>=10'} + hasBin: true + + test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + + test-exclude@7.0.2: + resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} + engines: {node: '>=18'} + + text-hex@1.0.0: + resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} + + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + throat@5.0.0: + resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==} + + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.0.4: + resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} + engines: {node: '>=18'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.0.27: + resolution: {integrity: sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==} + + tldts@7.0.27: + resolution: {integrity: sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==} + hasBin: true + + tmpl@1.0.5: + resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + + triple-beam@1.4.1: + resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} + engines: {node: '>= 14.0.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + ts-nkeys@1.0.16: + resolution: {integrity: sha512-1qrhAlavbm36wtW+7NtKOgxpzl+70NTF8xlz9mEhiA5zHMlMxjj3sEVKWm3pGZhHXE0Q3ykjrj+OSRVaYw+Dqg==} + + ts-node@10.9.2: + resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + + tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + tweetnacl@1.0.3: + resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + type-fest@0.7.1: + resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} + engines: {node: '>=8'} + + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.7: + resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + engines: {node: '>= 0.4'} + + typescript-eslint@8.58.0: + resolution: {integrity: sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ua-parser-js@1.0.41: + resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==} + hasBin: true + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + undici@6.24.1: + resolution: {integrity: sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==} + engines: {node: '>=18.17'} + + undici@7.24.7: + resolution: {integrity: sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==} + engines: {node: '>=20.18.1'} + + unicode-canonical-property-names-ecmascript@2.0.1: + resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} + engines: {node: '>=4'} + + unicode-match-property-ecmascript@2.0.0: + resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==} + engines: {node: '>=4'} + + unicode-match-property-value-ecmascript@2.2.1: + resolution: {integrity: sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==} + engines: {node: '>=4'} + + unicode-property-aliases-ecmascript@2.2.0: + resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} + engines: {node: '>=4'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + unrs-resolver@1.11.1: + resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + urljoin@0.1.5: + resolution: {integrity: sha512-OSGi+PS3zxk8XfQ+7buaupOdrW9P9p+V9rjxGzJaYEYDe/B2rv3WJCupq5LNERW4w4kWxsduUUrhCxZZiQ2udw==} + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-latest-callback@0.2.6: + resolution: {integrity: sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg==} + peerDependencies: + react: '>=16.8' + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + uuid@7.0.3: + resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==} + hasBin: true + + v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + + v8-to-istanbul@9.3.0: + resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} + engines: {node: '>=10.12.0'} + + validate-npm-package-name@5.0.1: + resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vaul@1.1.2: + resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + 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 + + vitest@4.1.2: + resolution: {integrity: sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.2 + '@vitest/browser-preview': 4.1.2 + '@vitest/browser-webdriverio': 4.1.2 + '@vitest/ui': 4.1.2 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + 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 + + vlq@1.0.1: + resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==} + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + walker@1.0.8: + resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + + warn-once@0.1.1: + resolution: {integrity: sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==} + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + webidl-conversions@5.0.0: + resolution: {integrity: sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==} + engines: {node: '>=8'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-fetch@3.6.20: + resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url-without-unicode@8.0.0-3: + resolution: {integrity: sha512-HoKuzZrUlgpz35YO27XgD28uh/WJH4B0+3ttFqRo//lmq+9T/mIOJ6kqmINI9HpUpz1imRC/nR/lxKpJiv0uig==} + engines: {node: '>=10'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.20: + resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + winston-transport@4.9.0: + resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==} + engines: {node: '>= 12.0.0'} + + winston@3.19.0: + resolution: {integrity: sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==} + engines: {node: '>= 12.0.0'} + + wonka@6.3.6: + resolution: {integrity: sha512-MXH+6mDHAZ2GuMpgKS055FR6v0xVP3XwquxIMYXgiW+FejHQlMGlvVRZT4qMCxR+bEo/FCtIdKxwej9WV3YQag==} + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + write-file-atomic@4.0.2: + resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + ws@6.2.3: + resolution: {integrity: sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@7.5.10: + resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + 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 + + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + 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 + + xcode@3.0.1: + resolution: {integrity: sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==} + engines: {node: '>=10.0.0'} + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xml2js@0.6.0: + resolution: {integrity: sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==} + engines: {node: '>=4.0.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + + xmlbuilder@15.1.1: + resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} + engines: {node: '>=8.0'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + xmlhttprequest-ssl@2.1.2: + resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} + engines: {node: '>=0.4.0'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + + yaml@2.8.3: + resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + +snapshots: + + '@0no-co/graphql.web@1.2.0': {} + + '@acemir/cssom@0.9.31': {} + + '@adobe/css-tools@4.4.4': {} + + '@alloc/quick-lru@5.2.0': {} + + '@alpacahq/alpaca-trade-api@3.1.3': + dependencies: + axios: 0.21.4 + dotenv: 6.2.0 + eslint: 8.57.1 + events: 3.3.0 + just-extend: 4.2.1 + lodash: 4.18.1 + minimist: 1.2.8 + msgpack5: 5.3.2 + nats: 1.4.12 + urljoin: 0.1.5 + ws: 7.5.10 + transitivePeerDependencies: + - bufferutil + - debug + - supports-color + - utf-8-validate + + '@asamuzakjp/css-color@5.1.5': + dependencies: + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.2.7 + + '@asamuzakjp/dom-selector@6.8.1': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.7 + + '@asamuzakjp/nwsapi@2.3.9': {} + + '@babel/code-frame@7.10.4': + dependencies: + '@babel/highlight': 7.25.9 + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.27.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.29.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-create-regexp-features-plugin@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + regexpu-core: 6.4.0 + semver: 6.3.1 + + '@babel/helper-define-polyfill-provider@0.6.8(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + debug: 4.4.3 + lodash.debounce: 4.0.8 + resolve: 1.22.11 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-member-expression-to-functions@7.28.5': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.27.1': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-remap-async-to-generator@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-wrap-function': 7.28.6 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helper-wrap-function@7.28.6': + dependencies: + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/highlight@7.25.9': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + chalk: 2.4.2 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/parser@7.29.2': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-proposal-export-default-from@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-decorators@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-export-default-from@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-flow@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-async-generator-functions@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.0) + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-async-to-generator@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-block-scoping@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-class-properties@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-class-static-block@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-classes@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-globals': 7.28.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-computed-properties@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/template': 7.28.6 + + '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-export-namespace-from@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-flow-strip-types@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-flow': 7.28.6(@babel/core@7.29.0) + + '@babel/plugin-transform-for-of@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-function-name@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-literals@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-logical-assignment-operators@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-nullish-coalescing-operator@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-numeric-separator@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-object-rest-spread@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-optional-catch-binding@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-optional-chaining@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-parameters@7.27.7(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-private-methods@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-private-property-in-object@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-display-name@7.28.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-development@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-pure-annotations@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-regenerator@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-runtime@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.0) + babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.29.0) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.0) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-shorthand-properties@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-spread@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-sticky-regex@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-template-literals@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-unicode-regex@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/preset-react@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-pure-annotations': 7.27.1(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/preset-typescript@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/runtime@7.29.2': {} + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@1.0.2': {} + + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@colors/colors@1.6.0': {} + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.2(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + + '@dabh/diagnostics@2.0.8': + dependencies: + '@so-ric/colorspace': 1.1.6 + enabled: 2.0.0 + kuler: 2.0.0 + + '@egjs/hammerjs@2.0.17': + dependencies: + '@types/hammerjs': 2.0.46 + + '@emnapi/core@1.9.2': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.9.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@8.57.1)': + dependencies: + eslint: 8.57.1 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': + dependencies: + eslint: 9.39.4(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@2.1.4': + dependencies: + ajv: 6.14.0 + debug: 4.4.3 + espree: 9.6.1 + globals: 13.24.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.14.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@8.57.1': {} + + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@exodus/bytes@1.15.0': {} + + '@expo-google-fonts/inter@0.4.2': {} + + '@expo-google-fonts/jetbrains-mono@0.4.1': {} + + '@expo/cli@54.0.23(expo-router@6.0.23)(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))': + dependencies: + '@0no-co/graphql.web': 1.2.0 + '@expo/code-signing-certificates': 0.0.6 + '@expo/config': 12.0.13 + '@expo/config-plugins': 54.0.4 + '@expo/devcert': 1.2.1 + '@expo/env': 2.0.11 + '@expo/image-utils': 0.8.12 + '@expo/json-file': 10.0.13 + '@expo/metro': 54.2.0 + '@expo/metro-config': 54.0.14(expo@54.0.33) + '@expo/osascript': 2.4.2 + '@expo/package-manager': 1.10.3 + '@expo/plist': 0.4.8 + '@expo/prebuild-config': 54.0.8(expo@54.0.33) + '@expo/schema-utils': 0.1.8 + '@expo/spawn-async': 1.7.2 + '@expo/ws-tunnel': 1.0.6 + '@expo/xcpretty': 4.4.1 + '@react-native/dev-middleware': 0.81.5 + '@urql/core': 5.2.0 + '@urql/exchange-retry': 1.3.2(@urql/core@5.2.0) + accepts: 1.3.8 + arg: 5.0.2 + better-opn: 3.0.2 + bplist-creator: 0.1.0 + bplist-parser: 0.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + compression: 1.8.1 + connect: 3.7.0 + debug: 4.4.3 + env-editor: 0.4.2 + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.15.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-server: 1.0.5 + freeport-async: 2.0.0 + getenv: 2.0.0 + glob: 13.0.6 + lan-network: 0.1.7 + minimatch: 9.0.9 + node-forge: 1.4.0 + npm-package-arg: 11.0.3 + ora: 3.4.0 + picomatch: 3.0.2 + pretty-bytes: 5.6.0 + pretty-format: 29.7.0 + progress: 2.0.3 + prompts: 2.4.2 + qrcode-terminal: 0.11.0 + require-from-string: 2.0.2 + requireg: 0.2.2 + resolve: 1.22.11 + resolve-from: 5.0.0 + resolve.exports: 2.0.3 + semver: 7.7.4 + send: 0.19.2 + slugify: 1.6.9 + source-map-support: 0.5.21 + stacktrace-parser: 0.1.11 + structured-headers: 0.4.1 + tar: 7.5.13 + terminal-link: 2.1.1 + undici: 6.24.1 + wrap-ansi: 7.0.0 + ws: 8.20.0 + optionalDependencies: + expo-router: 6.0.23(68e2fe297303e98ef2913faa2068e740) + react-native: 0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + transitivePeerDependencies: + - bufferutil + - graphql + - supports-color + - utf-8-validate + + '@expo/code-signing-certificates@0.0.6': + dependencies: + node-forge: 1.4.0 + + '@expo/config-plugins@54.0.4': + dependencies: + '@expo/config-types': 54.0.10 + '@expo/json-file': 10.0.13 + '@expo/plist': 0.4.8 + '@expo/sdk-runtime-versions': 1.0.0 + chalk: 4.1.2 + debug: 4.4.3 + getenv: 2.0.0 + glob: 13.0.6 + resolve-from: 5.0.0 + semver: 7.7.4 + slash: 3.0.0 + slugify: 1.6.9 + xcode: 3.0.1 + xml2js: 0.6.0 + transitivePeerDependencies: + - supports-color + + '@expo/config-types@54.0.10': {} + + '@expo/config@12.0.13': + dependencies: + '@babel/code-frame': 7.10.4 + '@expo/config-plugins': 54.0.4 + '@expo/config-types': 54.0.10 + '@expo/json-file': 10.0.13 + deepmerge: 4.3.1 + getenv: 2.0.0 + glob: 13.0.6 + require-from-string: 2.0.2 + resolve-from: 5.0.0 + resolve-workspace-root: 2.0.1 + semver: 7.7.4 + slugify: 1.6.9 + sucrase: 3.35.1 + transitivePeerDependencies: + - supports-color + + '@expo/devcert@1.2.1': + dependencies: + '@expo/sudo-prompt': 9.3.2 + debug: 3.2.7 + transitivePeerDependencies: + - supports-color + + '@expo/devtools@0.1.8(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + dependencies: + chalk: 4.1.2 + optionalDependencies: + react: 19.1.0 + react-native: 0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + + '@expo/env@2.0.11': + dependencies: + chalk: 4.1.2 + debug: 4.4.3 + dotenv: 16.4.7 + dotenv-expand: 11.0.7 + getenv: 2.0.0 + transitivePeerDependencies: + - supports-color + + '@expo/fingerprint@0.15.4': + dependencies: + '@expo/spawn-async': 1.7.2 + arg: 5.0.2 + chalk: 4.1.2 + debug: 4.4.3 + getenv: 2.0.0 + glob: 13.0.6 + ignore: 5.3.2 + minimatch: 9.0.9 + p-limit: 3.1.0 + resolve-from: 5.0.0 + semver: 7.7.4 + transitivePeerDependencies: + - supports-color + + '@expo/image-utils@0.8.12': + dependencies: + '@expo/spawn-async': 1.7.2 + chalk: 4.1.2 + getenv: 2.0.0 + jimp-compact: 0.16.1 + parse-png: 2.1.0 + resolve-from: 5.0.0 + semver: 7.7.4 + + '@expo/json-file@10.0.13': + dependencies: + '@babel/code-frame': 7.29.0 + json5: 2.2.3 + + '@expo/metro-config@54.0.14(expo@54.0.33)': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@expo/config': 12.0.13 + '@expo/env': 2.0.11 + '@expo/json-file': 10.0.13 + '@expo/metro': 54.2.0 + '@expo/spawn-async': 1.7.2 + browserslist: 4.28.2 + chalk: 4.1.2 + debug: 4.4.3 + dotenv: 16.4.7 + dotenv-expand: 11.0.7 + getenv: 2.0.0 + glob: 13.0.6 + hermes-parser: 0.29.1 + jsc-safe-url: 0.2.4 + lightningcss: 1.32.0 + minimatch: 9.0.9 + postcss: 8.4.49 + resolve-from: 5.0.0 + optionalDependencies: + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.15.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@expo/metro-runtime@6.1.2(expo@54.0.33)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + dependencies: + anser: 1.4.10 + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.15.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + pretty-format: 29.7.0 + react: 19.1.0 + react-native: 0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + stacktrace-parser: 0.1.11 + whatwg-fetch: 3.6.20 + optionalDependencies: + react-dom: 19.1.0(react@19.1.0) + + '@expo/metro@54.2.0': + dependencies: + metro: 0.83.3 + metro-babel-transformer: 0.83.3 + metro-cache: 0.83.3 + metro-cache-key: 0.83.3 + metro-config: 0.83.3 + metro-core: 0.83.3 + metro-file-map: 0.83.3 + metro-minify-terser: 0.83.3 + metro-resolver: 0.83.3 + metro-runtime: 0.83.3 + metro-source-map: 0.83.3 + metro-symbolicate: 0.83.3 + metro-transform-plugins: 0.83.3 + metro-transform-worker: 0.83.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@expo/osascript@2.4.2': + dependencies: + '@expo/spawn-async': 1.7.2 + + '@expo/package-manager@1.10.3': + dependencies: + '@expo/json-file': 10.0.13 + '@expo/spawn-async': 1.7.2 + chalk: 4.1.2 + npm-package-arg: 11.0.3 + ora: 3.4.0 + resolve-workspace-root: 2.0.1 + + '@expo/plist@0.4.8': + dependencies: + '@xmldom/xmldom': 0.8.12 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + + '@expo/prebuild-config@54.0.8(expo@54.0.33)': + dependencies: + '@expo/config': 12.0.13 + '@expo/config-plugins': 54.0.4 + '@expo/config-types': 54.0.10 + '@expo/image-utils': 0.8.12 + '@expo/json-file': 10.0.13 + '@react-native/normalize-colors': 0.81.5 + debug: 4.4.3 + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.15.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + resolve-from: 5.0.0 + semver: 7.7.4 + xml2js: 0.6.0 + transitivePeerDependencies: + - supports-color + + '@expo/schema-utils@0.1.8': {} + + '@expo/sdk-runtime-versions@1.0.0': {} + + '@expo/spawn-async@1.7.2': + dependencies: + cross-spawn: 7.0.6 + + '@expo/sudo-prompt@9.3.2': {} + + '@expo/vector-icons@15.1.1(expo-font@14.0.11(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + dependencies: + expo-font: 14.0.11(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-native: 0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + + '@expo/ws-tunnel@1.0.6': {} + + '@expo/xcpretty@4.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + chalk: 4.1.2 + js-yaml: 4.1.1 + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/config-array@0.13.0': + dependencies: + '@humanwhocodes/object-schema': 2.0.3 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/object-schema@2.0.3': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + + '@isaacs/ttlcache@1.4.1': {} + + '@istanbuljs/load-nyc-config@1.1.0': + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.14.2 + resolve-from: 5.0.0 + + '@istanbuljs/schema@0.1.3': {} + + '@jest/create-cache-key-function@29.7.0': + dependencies: + '@jest/types': 29.6.3 + + '@jest/environment@29.7.0': + dependencies: + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 24.12.2 + jest-mock: 29.7.0 + + '@jest/fake-timers@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@sinonjs/fake-timers': 10.3.0 + '@types/node': 24.12.2 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.10 + + '@jest/transform@29.7.0': + dependencies: + '@babel/core': 7.29.0 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.31 + babel-plugin-istanbul: 6.1.1 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + micromatch: 4.0.8 + pirates: 4.0.7 + slash: 3.0.0 + write-file-atomic: 4.0.2 + transitivePeerDependencies: + - supports-color + + '@jest/types@29.6.3': + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 24.12.2 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@lucide/lab@0.1.2': {} + + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@nolyfill/is-core-module@1.0.39': {} + + '@opentelemetry/api@1.9.1': {} + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.17)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.17 + '@types/react-dom': 19.2.3(@types/react@19.1.17) + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.1.17)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.17 + + '@radix-ui/react-context@1.1.2(@types/react@19.1.17)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.17 + + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.17)(react@19.1.0) + aria-hidden: 1.2.6 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react-remove-scroll: 2.7.2(@types/react@19.1.17)(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.17 + '@types/react-dom': 19.2.3(@types/react@19.1.17) + + '@radix-ui/react-direction@1.1.1(@types/react@19.1.17)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.17 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.1.17)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.17 + '@types/react-dom': 19.2.3(@types/react@19.1.17) + + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.1.17)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.17 + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.17)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.17 + '@types/react-dom': 19.2.3(@types/react@19.1.17) + + '@radix-ui/react-id@1.1.1(@types/react@19.1.17)(react@19.1.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.17)(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.17 + + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.17)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.17 + '@types/react-dom': 19.2.3(@types/react@19.1.17) + + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.17)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.17 + '@types/react-dom': 19.2.3(@types/react@19.1.17) + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.17)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.17 + '@types/react-dom': 19.2.3(@types/react@19.1.17) + + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.17)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.17 + '@types/react-dom': 19.2.3(@types/react@19.1.17) + + '@radix-ui/react-slot@1.2.0(@types/react@19.1.17)(react@19.1.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.17 + + '@radix-ui/react-slot@1.2.3(@types/react@19.1.17)(react@19.1.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.17)(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.17 + + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.17)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.17 + '@types/react-dom': 19.2.3(@types/react@19.1.17) + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.1.17)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.17 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.1.17)(react@19.1.0)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.17)(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.17 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.1.17)(react@19.1.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.17)(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.17 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.1.17)(react@19.1.0)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.17)(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.17 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.1.17)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.17 + + '@react-native/assets-registry@0.81.4': {} + + '@react-native/babel-plugin-codegen@0.81.5(@babel/core@7.29.0)': + dependencies: + '@babel/traverse': 7.29.0 + '@react-native/codegen': 0.81.5(@babel/core@7.29.0) + transitivePeerDependencies: + - '@babel/core' + - supports-color + + '@react-native/babel-plugin-codegen@0.84.1(@babel/core@7.29.0)': + dependencies: + '@babel/traverse': 7.29.0 + '@react-native/codegen': 0.84.1(@babel/core@7.29.0) + transitivePeerDependencies: + - '@babel/core' + - supports-color + + '@react-native/babel-preset@0.81.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-export-default-from': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-async-generator-functions': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-async-to-generator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-block-scoping': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-computed-properties': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-logical-assignment-operators': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-numeric-separator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-optional-catch-binding': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) + '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-regenerator': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-runtime': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-spread': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.0) + '@babel/template': 7.28.6 + '@react-native/babel-plugin-codegen': 0.81.5(@babel/core@7.29.0) + babel-plugin-syntax-hermes-parser: 0.29.1 + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.0) + react-refresh: 0.14.2 + transitivePeerDependencies: + - supports-color + + '@react-native/babel-preset@0.84.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-export-default-from': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-transform-async-generator-functions': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-async-to-generator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-block-scoping': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-optional-catch-binding': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-regenerator': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-runtime': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.0) + '@react-native/babel-plugin-codegen': 0.84.1(@babel/core@7.29.0) + babel-plugin-syntax-hermes-parser: 0.32.0 + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.0) + react-refresh: 0.14.2 + transitivePeerDependencies: + - supports-color + + '@react-native/codegen@0.81.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.2 + glob: 7.2.3 + hermes-parser: 0.29.1 + invariant: 2.2.4 + nullthrows: 1.1.1 + yargs: 17.7.2 + + '@react-native/codegen@0.81.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.2 + glob: 7.2.3 + hermes-parser: 0.29.1 + invariant: 2.2.4 + nullthrows: 1.1.1 + yargs: 17.7.2 + + '@react-native/codegen@0.84.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.2 + hermes-parser: 0.32.0 + invariant: 2.2.4 + nullthrows: 1.1.1 + tinyglobby: 0.2.15 + yargs: 17.7.2 + + '@react-native/community-cli-plugin@0.81.4(@react-native/metro-config@0.84.1(@babel/core@7.29.0))': + dependencies: + '@react-native/dev-middleware': 0.81.4 + debug: 4.4.3 + invariant: 2.2.4 + metro: 0.83.5 + metro-config: 0.83.5 + metro-core: 0.83.5 + semver: 7.7.4 + optionalDependencies: + '@react-native/metro-config': 0.84.1(@babel/core@7.29.0) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@react-native/debugger-frontend@0.81.4': {} + + '@react-native/debugger-frontend@0.81.5': {} + + '@react-native/dev-middleware@0.81.4': + dependencies: + '@isaacs/ttlcache': 1.4.1 + '@react-native/debugger-frontend': 0.81.4 + chrome-launcher: 0.15.2 + chromium-edge-launcher: 0.2.0 + connect: 3.7.0 + debug: 4.4.3 + invariant: 2.2.4 + nullthrows: 1.1.1 + open: 7.4.2 + serve-static: 1.16.3 + ws: 6.2.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@react-native/dev-middleware@0.81.5': + dependencies: + '@isaacs/ttlcache': 1.4.1 + '@react-native/debugger-frontend': 0.81.5 + chrome-launcher: 0.15.2 + chromium-edge-launcher: 0.2.0 + connect: 3.7.0 + debug: 4.4.3 + invariant: 2.2.4 + nullthrows: 1.1.1 + open: 7.4.2 + serve-static: 1.16.3 + ws: 6.2.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@react-native/gradle-plugin@0.81.4': {} + + '@react-native/js-polyfills@0.81.4': {} + + '@react-native/js-polyfills@0.84.1': {} + + '@react-native/metro-babel-transformer@0.84.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@react-native/babel-preset': 0.84.1(@babel/core@7.29.0) + hermes-parser: 0.32.0 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + + '@react-native/metro-config@0.84.1(@babel/core@7.29.0)': + dependencies: + '@react-native/js-polyfills': 0.84.1 + '@react-native/metro-babel-transformer': 0.84.1(@babel/core@7.29.0) + metro-config: 0.83.5 + metro-runtime: 0.83.5 + transitivePeerDependencies: + - '@babel/core' + - supports-color + + '@react-native/normalize-colors@0.74.89': {} + + '@react-native/normalize-colors@0.81.4': {} + + '@react-native/normalize-colors@0.81.5': {} + + '@react-native/virtualized-lists@0.81.4(@types/react@19.1.17)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + dependencies: + invariant: 2.2.4 + nullthrows: 1.1.1 + react: 19.1.0 + react-native: 0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.17 + + '@react-navigation/bottom-tabs@7.15.9(@react-navigation/native@7.2.2(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + dependencies: + '@react-navigation/elements': 2.9.14(@react-navigation/native@7.2.2(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@react-navigation/native': 7.2.2(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + color: 4.2.3 + react: 19.1.0 + react-native: 0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + react-native-safe-area-context: 5.6.2(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-screens: 4.16.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + sf-symbols-typescript: 2.2.0 + transitivePeerDependencies: + - '@react-native-masked-view/masked-view' + + '@react-navigation/core@7.17.2(react@19.1.0)': + dependencies: + '@react-navigation/routers': 7.5.3 + escape-string-regexp: 4.0.0 + fast-deep-equal: 3.1.3 + nanoid: 3.3.11 + query-string: 7.1.3 + react: 19.1.0 + react-is: 19.2.4 + use-latest-callback: 0.2.6(react@19.1.0) + use-sync-external-store: 1.6.0(react@19.1.0) + + '@react-navigation/elements@2.9.14(@react-navigation/native@7.2.2(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + dependencies: + '@react-navigation/native': 7.2.2(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + color: 4.2.3 + react: 19.1.0 + react-native: 0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + react-native-safe-area-context: 5.6.2(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + use-latest-callback: 0.2.6(react@19.1.0) + use-sync-external-store: 1.6.0(react@19.1.0) + + '@react-navigation/native-stack@7.14.10(@react-navigation/native@7.2.2(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + dependencies: + '@react-navigation/elements': 2.9.14(@react-navigation/native@7.2.2(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@react-navigation/native': 7.2.2(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + color: 4.2.3 + react: 19.1.0 + react-native: 0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + react-native-safe-area-context: 5.6.2(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-screens: 4.16.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + sf-symbols-typescript: 2.2.0 + warn-once: 0.1.1 + transitivePeerDependencies: + - '@react-native-masked-view/masked-view' + + '@react-navigation/native@7.2.2(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + dependencies: + '@react-navigation/core': 7.17.2(react@19.1.0) + escape-string-regexp: 4.0.0 + fast-deep-equal: 3.1.3 + nanoid: 3.3.11 + react: 19.1.0 + react-native: 0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + use-latest-callback: 0.2.6(react@19.1.0) + + '@react-navigation/routers@7.5.3': + dependencies: + nanoid: 3.3.11 + + '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.4 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.1.1 + optionalDependencies: + react: 19.2.4 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1) + + '@rolldown/pluginutils@1.0.0-rc.3': {} + + '@rollup/rollup-android-arm-eabi@4.60.1': + optional: true + + '@rollup/rollup-android-arm64@4.60.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.1': + optional: true + + '@rollup/rollup-darwin-x64@4.60.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.1': + optional: true + + '@rtsao/scc@1.1.0': {} + + '@sinclair/typebox@0.27.10': {} + + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/fake-timers@10.3.0': + dependencies: + '@sinonjs/commons': 3.0.1 + + '@so-ric/colorspace@1.1.6': + dependencies: + color: 5.0.3 + text-hex: 1.0.0 + + '@socket.io/component-emitter@3.1.2': {} + + '@standard-schema/spec@1.1.0': {} + + '@standard-schema/utils@0.3.0': {} + + '@supabase/auth-js@2.101.1': + dependencies: + tslib: 2.8.1 + + '@supabase/functions-js@2.101.1': + dependencies: + tslib: 2.8.1 + + '@supabase/phoenix@0.4.0': {} + + '@supabase/postgrest-js@2.101.1': + dependencies: + tslib: 2.8.1 + + '@supabase/realtime-js@2.101.1': + dependencies: + '@supabase/phoenix': 0.4.0 + '@types/ws': 8.18.1 + tslib: 2.8.1 + ws: 8.20.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@supabase/storage-js@2.101.1': + dependencies: + iceberg-js: 0.8.1 + tslib: 2.8.1 + + '@supabase/supabase-js@2.101.1': + dependencies: + '@supabase/auth-js': 2.101.1 + '@supabase/functions-js': 2.101.1 + '@supabase/postgrest-js': 2.101.1 + '@supabase/realtime-js': 2.101.1 + '@supabase/storage-js': 2.101.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@tailwindcss/node@4.2.2': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.20.1 + jiti: 2.6.1 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.2 + + '@tailwindcss/oxide-android-arm64@4.2.2': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.2.2': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.2.2': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + optional: true + + '@tailwindcss/oxide@4.2.2': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-x64': 4.2.2 + '@tailwindcss/oxide-freebsd-x64': 4.2.2 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.2 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-x64-musl': 4.2.2 + '@tailwindcss/oxide-wasm32-wasi': 4.2.2 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 + + '@tailwindcss/postcss@4.2.2': + dependencies: + '@alloc/quick-lru': 5.2.0 + '@tailwindcss/node': 4.2.2 + '@tailwindcss/oxide': 4.2.2 + postcss: 8.5.8 + tailwindcss: 4.2.2 + + '@tailwindcss/vite@4.2.2(vite@7.3.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + '@tailwindcss/node': 4.2.2 + '@tailwindcss/oxide': 4.2.2 + tailwindcss: 4.2.2 + vite: 7.3.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.29.2 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.29.2 + '@testing-library/dom': 10.4.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + + '@tsconfig/node10@1.0.12': {} + + '@tsconfig/node12@1.0.11': {} + + '@tsconfig/node14@1.0.3': {} + + '@tsconfig/node16@1.0.4': {} + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/aria-query@5.0.4': {} + + '@types/axios@0.14.4': + dependencies: + axios: 1.14.0 + transitivePeerDependencies: + - debug + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 25.5.2 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 25.5.2 + + '@types/cors@2.8.19': + dependencies: + '@types/node': 25.5.2 + + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/express-serve-static-core@5.1.1': + dependencies: + '@types/node': 25.5.2 + '@types/qs': 6.15.0 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.1 + '@types/serve-static': 2.2.0 + + '@types/graceful-fs@4.1.9': + dependencies: + '@types/node': 24.12.2 + + '@types/hammerjs@2.0.46': {} + + '@types/http-errors@2.0.5': {} + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/json-schema@7.0.15': {} + + '@types/json5@0.0.29': {} + + '@types/node@24.12.2': + dependencies: + undici-types: 7.16.0 + + '@types/node@25.5.2': + dependencies: + undici-types: 7.18.2 + + '@types/qs@6.15.0': {} + + '@types/range-parser@1.2.7': {} + + '@types/react-dom@19.2.3(@types/react@19.1.17)': + dependencies: + '@types/react': 19.1.17 + optional: true + + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.1.17': + dependencies: + csstype: 3.2.3 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@types/send@1.2.1': + dependencies: + '@types/node': 25.5.2 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 25.5.2 + + '@types/stack-utils@2.0.3': {} + + '@types/triple-beam@1.3.5': {} + + '@types/use-sync-external-store@0.0.6': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.5.2 + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.35': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/type-utils': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.0 + eslint: 9.39.4(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.0 + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.58.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) + '@typescript-eslint/types': 8.58.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.58.0': + dependencies: + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/visitor-keys': 8.58.0 + + '@typescript-eslint/tsconfig-utils@8.58.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.58.0': {} + + '@typescript-eslint/typescript-estree@8.58.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.58.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/visitor-keys': 8.58.0 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.58.0': + dependencies: + '@typescript-eslint/types': 8.58.0 + eslint-visitor-keys: 5.0.1 + + '@ungap/structured-clone@1.3.0': {} + + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + optional: true + + '@unrs/resolver-binding-android-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + optional: true + + '@urql/core@5.2.0': + dependencies: + '@0no-co/graphql.web': 1.2.0 + wonka: 6.3.6 + transitivePeerDependencies: + - graphql + + '@urql/exchange-retry@1.3.2(@urql/core@5.2.0)': + dependencies: + '@urql/core': 5.2.0 + wonka: 6.3.6 + + '@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-rc.3 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: 7.3.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + transitivePeerDependencies: + - supports-color + + '@vitest/coverage-v8@4.1.2(vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(jsdom@28.1.0)(vite@7.3.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.2 + ast-v8-to-istanbul: 1.0.0 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.2 + obug: 2.1.1 + std-env: 4.0.0 + tinyrainbow: 3.1.0 + vitest: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(jsdom@28.1.0)(vite@7.3.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + + '@vitest/expect@4.1.2': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.2 + '@vitest/utils': 4.1.2 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.2(vite@7.3.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + '@vitest/spy': 4.1.2 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + + '@vitest/pretty-format@4.1.2': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.2': + dependencies: + '@vitest/utils': 4.1.2 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.2': + dependencies: + '@vitest/pretty-format': 4.1.2 + '@vitest/utils': 4.1.2 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.2': {} + + '@vitest/utils@4.1.2': + dependencies: + '@vitest/pretty-format': 4.1.2 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + '@xmldom/xmldom@0.8.12': {} + + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn-walk@8.3.5: + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + agent-base@7.1.4: {} + + agentation@2.3.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + ajv@6.14.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + anser@1.4.10: {} + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-regex@4.1.1: {} + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@3.2.1: + dependencies: + color-convert: 1.9.3 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + ansi-styles@6.2.3: {} + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + arg@4.1.3: {} + + arg@5.0.2: {} + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-includes@3.1.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + array.prototype.findlast@1.2.5: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.findlastindex@1.2.6: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-shim-unscopables: 1.1.0 + + array.prototype.tosorted@1.1.4: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + asap@2.0.6: {} + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@1.0.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + async-function@1.0.0: {} + + async-limiter@1.0.1: {} + + async@3.2.6: {} + + asynckit@0.4.0: {} + + autoprefixer@10.4.27(postcss@8.5.8): + dependencies: + browserslist: 4.28.2 + caniuse-lite: 1.0.30001785 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + axios@0.21.4: + dependencies: + follow-redirects: 1.15.11 + transitivePeerDependencies: + - debug + + axios@1.14.0: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + + babel-jest@29.7.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@jest/transform': 29.7.0 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 29.6.3(@babel/core@7.29.0) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-istanbul@6.1.1: + dependencies: + '@babel/helper-plugin-utils': 7.28.6 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-instrument: 5.2.1 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-jest-hoist@29.6.3: + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + '@types/babel__core': 7.20.5 + '@types/babel__traverse': 7.28.0 + + babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.0): + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/core': 7.29.0 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-corejs3@0.13.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) + core-js-compat: 3.49.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-regenerator@0.6.8(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + babel-plugin-react-compiler@1.0.0: + dependencies: + '@babel/types': 7.29.0 + + babel-plugin-react-native-web@0.21.2: {} + + babel-plugin-syntax-hermes-parser@0.29.1: + dependencies: + hermes-parser: 0.29.1 + + babel-plugin-syntax-hermes-parser@0.32.0: + dependencies: + hermes-parser: 0.32.0 + + babel-plugin-transform-flow-enums@0.0.2(@babel/core@7.29.0): + dependencies: + '@babel/plugin-syntax-flow': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - '@babel/core' + + babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.0) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.0) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.0) + '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.0) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.0) + + babel-preset-expo@54.0.10(@babel/core@7.29.0)(@babel/runtime@7.29.2)(expo@54.0.33)(react-refresh@0.14.2): + dependencies: + '@babel/helper-module-imports': 7.28.6 + '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-syntax-export-default-from': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-class-static-block': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) + '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-runtime': 7.29.0(@babel/core@7.29.0) + '@babel/preset-react': 7.28.5(@babel/core@7.29.0) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) + '@react-native/babel-preset': 0.81.5(@babel/core@7.29.0) + babel-plugin-react-compiler: 1.0.0 + babel-plugin-react-native-web: 0.21.2 + babel-plugin-syntax-hermes-parser: 0.29.1 + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.0) + debug: 4.4.3 + react-refresh: 0.14.2 + resolve-from: 5.0.0 + optionalDependencies: + '@babel/runtime': 7.29.2 + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.15.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + transitivePeerDependencies: + - '@babel/core' + - supports-color + + babel-preset-jest@29.6.3(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + babel-plugin-jest-hoist: 29.6.3 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + + base64id@2.0.0: {} + + baseline-browser-mapping@2.10.14: {} + + better-opn@3.0.2: + dependencies: + open: 8.4.2 + + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + + big-integer@1.6.52: {} + + bintrees@1.0.2: {} + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.0 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + + boolbase@1.0.0: {} + + bplist-creator@0.1.0: + dependencies: + stream-buffers: 2.2.0 + + bplist-parser@0.3.1: + dependencies: + big-integer: 1.6.52 + + bplist-parser@0.3.2: + dependencies: + big-integer: 1.6.52 + + brace-expansion@1.1.13: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.3: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.14 + caniuse-lite: 1.0.30001785 + electron-to-chromium: 1.5.331 + node-releases: 2.0.37 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + bser@2.1.1: + dependencies: + node-int64: 0.4.0 + + buffer-from@1.1.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + bytes@3.1.2: {} + + c8@10.1.3: + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@istanbuljs/schema': 0.1.3 + find-up: 5.0.0 + foreground-child: 3.3.1 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + test-exclude: 7.0.2 + v8-to-istanbul: 9.3.0 + yargs: 17.7.2 + yargs-parser: 21.1.1 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.8: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + camelcase@5.3.1: {} + + camelcase@6.3.0: {} + + caniuse-lite@1.0.30001785: {} + + ccxt@4.5.46: + dependencies: + ws: 8.20.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + chai@6.2.2: {} + + chalk@2.4.2: + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chownr@3.0.0: {} + + chrome-launcher@0.15.2: + dependencies: + '@types/node': 24.12.2 + escape-string-regexp: 4.0.0 + is-wsl: 2.2.0 + lighthouse-logger: 1.4.2 + transitivePeerDependencies: + - supports-color + + chromium-edge-launcher@0.2.0: + dependencies: + '@types/node': 24.12.2 + escape-string-regexp: 4.0.0 + is-wsl: 2.2.0 + lighthouse-logger: 1.4.2 + mkdirp: 1.0.4 + rimraf: 3.0.2 + transitivePeerDependencies: + - supports-color + + ci-info@2.0.0: {} + + ci-info@3.9.0: {} + + cli-cursor@2.1.0: + dependencies: + restore-cursor: 2.0.0 + + cli-spinners@2.9.2: {} + + client-only@0.0.1: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone@1.0.4: {} + + clsx@2.1.1: {} + + color-convert@1.9.3: + dependencies: + color-name: 1.1.3 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-convert@3.1.3: + dependencies: + color-name: 2.1.0 + + color-name@1.1.3: {} + + color-name@1.1.4: {} + + color-name@2.1.0: {} + + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.4 + + color-string@2.1.4: + dependencies: + color-name: 2.1.0 + + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + + color@5.0.3: + dependencies: + color-convert: 3.1.3 + color-string: 2.1.4 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@12.1.0: {} + + commander@2.20.3: {} + + commander@4.1.1: {} + + commander@7.2.0: {} + + compressible@2.0.18: + dependencies: + mime-db: 1.54.0 + + compression@1.8.1: + dependencies: + bytes: 3.1.2 + compressible: 2.0.18 + debug: 2.6.9 + negotiator: 0.6.4 + on-headers: 1.1.0 + safe-buffer: 5.2.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + concat-map@0.0.1: {} + + connect@3.7.0: + dependencies: + debug: 2.6.9 + finalhandler: 1.1.2 + parseurl: 1.3.3 + utils-merge: 1.0.1 + transitivePeerDependencies: + - supports-color + + content-disposition@1.0.1: {} + + content-type@1.0.5: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + core-js-compat@3.49.0: + dependencies: + browserslist: 4.28.2 + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + create-require@1.1.1: {} + + cross-fetch@3.2.0: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-in-js-utils@3.1.0: + dependencies: + hyphenate-style-name: 1.1.0 + + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-tree@1.1.3: + dependencies: + mdn-data: 2.0.14 + source-map: 0.6.1 + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css-what@6.2.2: {} + + css.escape@1.5.1: {} + + cssstyle@6.2.0: + dependencies: + '@asamuzakjp/css-color': 5.1.5 + '@csstools/css-syntax-patches-for-csstree': 1.1.2(css-tree@3.2.1) + css-tree: 3.2.1 + lru-cache: 11.2.7 + + csstype@3.2.3: {} + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@3.2.7: + dependencies: + ms: 2.1.3 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js-light@2.5.1: {} + + decimal.js@10.6.0: {} + + decode-uri-component@0.2.2: {} + + deep-extend@0.6.0: {} + + deep-is@0.1.4: {} + + deepmerge@4.3.1: {} + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-lazy-prop@2.0.0: {} + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + + dequal@2.0.3: {} + + destroy@1.2.0: {} + + detect-libc@2.1.2: {} + + detect-node-es@1.1.0: {} + + diff@4.0.4: {} + + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dotenv-expand@11.0.7: + dependencies: + dotenv: 16.4.7 + + dotenv@16.4.7: {} + + dotenv@17.4.0: {} + + dotenv@6.2.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + eastasianwidth@0.2.0: {} + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.331: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + enabled@2.0.0: {} + + encodeurl@1.0.2: {} + + encodeurl@2.0.0: {} + + engine.io-client@6.6.4: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + engine.io-parser: 5.2.3 + ws: 8.18.3 + xmlhttprequest-ssl: 2.1.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + engine.io-parser@5.2.3: {} + + engine.io@6.6.6: + dependencies: + '@types/cors': 2.8.19 + '@types/node': 25.5.2 + '@types/ws': 8.18.1 + accepts: 1.3.8 + base64id: 2.0.0 + cookie: 0.7.2 + cors: 2.8.6 + debug: 4.4.3 + engine.io-parser: 5.2.3 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + enhanced-resolve@5.20.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.2 + + entities@4.5.0: {} + + entities@6.0.1: {} + + env-editor@0.4.2: {} + + error-stack-parser@2.1.4: + dependencies: + stackframe: 1.3.4 + + es-abstract@1.24.1: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.0 + function.prototype.name: 1.1.8 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.3 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.10 + string.prototype.trimend: 1.0.9 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.7 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.20 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-iterator-helpers@1.3.1: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-set-tostringtag: 2.1.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + iterator.prototype: 1.1.5 + math-intrinsics: 1.1.0 + safe-array-concat: 1.1.3 + + es-module-lexer@2.0.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.2 + + es-to-primitive@1.3.0: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + es-toolkit@1.45.1: {} + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@2.0.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-config-expo@10.0.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-expo: 1.0.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-react-hooks: 5.2.0(eslint@9.39.4(jiti@2.6.1)) + globals: 16.5.0 + transitivePeerDependencies: + - eslint-import-resolver-webpack + - eslint-plugin-import-x + - supports-color + - typescript + + eslint-import-resolver-node@0.3.10: + dependencies: + debug: 3.2.7 + is-core-module: 2.16.1 + resolve: 2.0.0-next.6 + transitivePeerDependencies: + - supports-color + + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)): + dependencies: + '@nolyfill/is-core-module': 1.0.39 + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) + get-tsconfig: 4.13.7 + is-bun-module: 2.0.0 + stable-hash: 0.0.5 + tinyglobby: 0.2.15 + unrs-resolver: 1.11.1 + optionalDependencies: + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + transitivePeerDependencies: + - supports-color + + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) + eslint-import-resolver-node: 0.3.10 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) + transitivePeerDependencies: + - supports-color + + eslint-plugin-expo@1.0.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/utils': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) + transitivePeerDependencies: + - supports-color + - typescript + + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.9 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 9.39.4(jiti@2.6.1) + eslint-import-resolver-node: 0.3.10 + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + hasown: 2.0.2 + is-core-module: 2.16.1 + is-glob: 4.0.3 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.9 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + + eslint-plugin-react-hooks@5.2.0(eslint@9.39.4(jiti@2.6.1)): + dependencies: + eslint: 9.39.4(jiti@2.6.1) + + eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@2.6.1)): + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.2 + eslint: 9.39.4(jiti@2.6.1) + hermes-parser: 0.25.1 + zod: 4.3.6 + zod-validation-error: 4.0.2(zod@4.3.6) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react-refresh@0.4.26(eslint@9.39.4(jiti@2.6.1)): + dependencies: + eslint: 9.39.4(jiti@2.6.1) + + eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@2.6.1)): + dependencies: + array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.3.1 + eslint: 9.39.4(jiti@2.6.1) + estraverse: 5.3.0 + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.5 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.6 + semver: 6.3.1 + string.prototype.matchall: 4.0.12 + string.prototype.repeat: 1.0.0 + + eslint-scope@7.2.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@8.57.1: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1) + '@eslint-community/regexpp': 4.12.2 + '@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.3.0 + ajv: 6.14.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + 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.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.24.0 + graphemer: 1.4.0 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + js-yaml: 4.1.1 + json-stable-stringify-without-jsonify: 1.0.1 + levn: 0.4.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + strip-ansi: 6.0.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + + eslint@9.39.4(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 + + espree@9.6.1: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 3.4.3 + + esprima@4.0.1: {} + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + esutils@2.0.3: {} + + etag@1.8.1: {} + + event-target-shim@5.0.1: {} + + eventemitter3@5.0.4: {} + + events@3.3.0: {} + + expect-type@1.3.0: {} + + expo-asset@12.0.12(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + '@expo/image-utils': 0.8.12 + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.15.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-constants: 18.0.13(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0)) + react: 19.1.0 + react-native: 0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + transitivePeerDependencies: + - supports-color + + expo-blur@15.0.8(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.15.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-native: 0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + + expo-camera@17.0.10(expo@54.0.33)(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.15.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + invariant: 2.2.4 + react: 19.1.0 + react-native: 0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + optionalDependencies: + react-native-web: 0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + + expo-constants@18.0.13(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0)): + dependencies: + '@expo/config': 12.0.13 + '@expo/env': 2.0.11 + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.15.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native: 0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + transitivePeerDependencies: + - supports-color + + expo-file-system@19.0.21(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0)): + dependencies: + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.15.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native: 0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + + expo-font@14.0.11(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.15.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + fontfaceobserver: 2.3.0 + react: 19.1.0 + react-native: 0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + + expo-haptics@15.0.8(expo@54.0.33): + dependencies: + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.15.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + + expo-keep-awake@15.0.8(expo@54.0.33)(react@19.1.0): + dependencies: + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.15.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react: 19.1.0 + + expo-linear-gradient@15.0.8(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.15.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-native: 0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + + expo-linking@8.0.11(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + expo-constants: 18.0.13(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0)) + invariant: 2.2.4 + react: 19.1.0 + react-native: 0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + transitivePeerDependencies: + - expo + - supports-color + + expo-modules-autolinking@3.0.24: + dependencies: + '@expo/spawn-async': 1.7.2 + chalk: 4.1.2 + commander: 7.2.0 + require-from-string: 2.0.2 + resolve-from: 5.0.0 + + expo-modules-core@3.0.29(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + invariant: 2.2.4 + react: 19.1.0 + react-native: 0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + + expo-router@6.0.23(68e2fe297303e98ef2913faa2068e740): + dependencies: + '@expo/metro-runtime': 6.1.2(expo@54.0.33)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@expo/schema-utils': 0.1.8 + '@radix-ui/react-slot': 1.2.0(@types/react@19.1.17)(react@19.1.0) + '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@react-navigation/bottom-tabs': 7.15.9(@react-navigation/native@7.2.2(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@react-navigation/native': 7.2.2(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@react-navigation/native-stack': 7.14.10(@react-navigation/native@7.2.2(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + client-only: 0.0.1 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.15.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-constants: 18.0.13(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0)) + expo-linking: 8.0.11(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-server: 1.0.5 + fast-deep-equal: 3.1.3 + invariant: 2.2.4 + nanoid: 3.3.11 + query-string: 7.1.3 + react: 19.1.0 + react-fast-compare: 3.2.2 + react-native: 0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + react-native-is-edge-to-edge: 1.3.1(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-safe-area-context: 5.6.2(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-screens: 4.16.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + semver: 7.6.3 + server-only: 0.0.1 + sf-symbols-typescript: 2.2.0 + shallowequal: 1.1.0 + use-latest-callback: 0.2.6(react@19.1.0) + vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + optionalDependencies: + react-dom: 19.1.0(react@19.1.0) + react-native-gesture-handler: 2.28.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-reanimated: 4.1.7(react-native-worklets@0.8.1(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-web: 0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + transitivePeerDependencies: + - '@react-native-masked-view/masked-view' + - '@types/react' + - '@types/react-dom' + - supports-color + + expo-server@1.0.5: {} + + expo-splash-screen@31.0.13(expo@54.0.33): + dependencies: + '@expo/prebuild-config': 54.0.8(expo@54.0.33) + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.15.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + transitivePeerDependencies: + - supports-color + + expo-status-bar@3.0.9(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-native: 0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + react-native-is-edge-to-edge: 1.3.1(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + + expo-symbols@1.0.8(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0)): + dependencies: + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.15.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native: 0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + sf-symbols-typescript: 2.2.0 + + expo-system-ui@6.0.9(expo@54.0.33)(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0)): + dependencies: + '@react-native/normalize-colors': 0.81.5 + debug: 4.4.3 + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.15.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native: 0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + optionalDependencies: + react-native-web: 0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + transitivePeerDependencies: + - supports-color + + expo-web-browser@15.0.10(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0)): + dependencies: + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.15.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native: 0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + + expo@54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.15.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + '@babel/runtime': 7.29.2 + '@expo/cli': 54.0.23(expo-router@6.0.23)(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0)) + '@expo/config': 12.0.13 + '@expo/config-plugins': 54.0.4 + '@expo/devtools': 0.1.8(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@expo/fingerprint': 0.15.4 + '@expo/metro': 54.2.0 + '@expo/metro-config': 54.0.14(expo@54.0.33) + '@expo/vector-icons': 15.1.1(expo-font@14.0.11(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@ungap/structured-clone': 1.3.0 + babel-preset-expo: 54.0.10(@babel/core@7.29.0)(@babel/runtime@7.29.2)(expo@54.0.33)(react-refresh@0.14.2) + expo-asset: 12.0.12(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-constants: 18.0.13(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0)) + expo-file-system: 19.0.21(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0)) + expo-font: 14.0.11(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-keep-awake: 15.0.8(expo@54.0.33)(react@19.1.0) + expo-modules-autolinking: 3.0.24 + expo-modules-core: 3.0.29(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + pretty-format: 29.7.0 + react: 19.1.0 + react-native: 0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + react-refresh: 0.14.2 + whatwg-url-without-unicode: 8.0.0-3 + optionalDependencies: + '@expo/metro-runtime': 6.1.2(expo@54.0.33)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-webview: 13.15.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + transitivePeerDependencies: + - '@babel/core' + - bufferutil + - expo-router + - graphql + - supports-color + - utf-8-validate + + exponential-backoff@3.1.3: {} + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + extend@2.0.2: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fb-watchman@2.0.2: + dependencies: + bser: 2.1.1 + + fbjs-css-vars@1.0.2: {} + + fbjs@3.0.5: + dependencies: + cross-fetch: 3.2.0 + fbjs-css-vars: 1.0.2 + loose-envify: 1.4.0 + object-assign: 4.1.1 + promise: 7.3.1 + setimmediate: 1.0.5 + ua-parser-js: 1.0.41 + transitivePeerDependencies: + - encoding + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fecha@4.2.3: {} + + file-entry-cache@6.0.1: + dependencies: + flat-cache: 3.2.0 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + filter-obj@1.1.0: {} + + finalhandler@1.1.2: + dependencies: + debug: 2.6.9 + encodeurl: 1.0.2 + escape-html: 1.0.3 + on-finished: 2.3.0 + parseurl: 1.3.3 + statuses: 1.5.0 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@3.2.0: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + rimraf: 3.0.2 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + flow-enums-runtime@0.0.6: {} + + fn.name@1.1.0: {} + + follow-redirects@1.15.11: {} + + fontfaceobserver@2.3.0: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + forwarded@0.2.0: {} + + fraction.js@5.3.4: {} + + freeport-async@2.0.0: {} + + fresh@0.5.2: {} + + fresh@2.0.0: {} + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + function.prototype.name@1.1.8: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + functions-have-names: 1.2.3 + hasown: 2.0.2 + is-callable: 1.2.7 + + functions-have-names@1.2.3: {} + + generator-function@2.0.1: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-intrinsic@1.3.0: + 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 + + get-nonce@1.0.1: {} + + get-package-type@0.1.0: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + get-tsconfig@4.13.7: + dependencies: + resolve-pkg-maps: 1.0.0 + + getenv@2.0.0: {} + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + globals@13.24.0: + dependencies: + type-fest: 0.20.2 + + globals@14.0.0: {} + + globals@16.5.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + graphemer@1.4.0: {} + + has-bigints@1.1.0: {} + + has-flag@3.0.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hermes-estree@0.25.1: {} + + hermes-estree@0.29.1: {} + + hermes-estree@0.32.0: {} + + hermes-estree@0.33.3: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + hermes-parser@0.29.1: + dependencies: + hermes-estree: 0.29.1 + + hermes-parser@0.32.0: + dependencies: + hermes-estree: 0.32.0 + + hermes-parser@0.33.3: + dependencies: + hermes-estree: 0.33.3 + + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + + hosted-git-info@7.0.2: + dependencies: + lru-cache: 10.4.3 + + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.0 + transitivePeerDependencies: + - '@noble/hashes' + + html-escaper@2.0.2: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + hyphenate-style-name@1.1.0: {} + + iceberg-js@0.8.1: {} + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + image-size@1.2.1: + dependencies: + queue: 6.0.2 + + immer@10.2.0: {} + + immer@11.1.4: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + ini@1.3.8: {} + + inline-style-prefixer@7.0.1: + dependencies: + css-in-js-utils: 3.1.0 + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.1.0 + + internmap@2.0.3: {} + + invariant@2.2.4: + dependencies: + loose-envify: 1.4.0 + + ipaddr.js@1.9.1: {} + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-arrayish@0.3.4: {} + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-bun-module@2.0.0: + dependencies: + semver: 7.7.4 + + is-callable@1.2.7: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-docker@2.2.1: {} + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-fullwidth-code-point@3.0.0: {} + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-path-inside@3.0.3: {} + + is-potential-custom-element-name@1.0.1: {} + + is-promise@4.0.0: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-stream@2.0.1: {} + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.20 + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-instrument@5.2.1: + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.2 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.2 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + iterator.prototype@1.1.5: + dependencies: + define-data-property: 1.1.4 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + has-symbols: 1.1.0 + set-function-name: 2.0.2 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jest-environment-node@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 24.12.2 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + jest-get-type@29.6.3: {} + + jest-haste-map@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/graceful-fs': 4.1.9 + '@types/node': 24.12.2 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + jest-worker: 29.7.0 + micromatch: 4.0.8 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + + jest-message-util@29.7.0: + dependencies: + '@babel/code-frame': 7.29.0 + '@jest/types': 29.6.3 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-mock@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 24.12.2 + jest-util: 29.7.0 + + jest-regex-util@29.6.3: {} + + jest-util@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 24.12.2 + chalk: 4.1.2 + ci-info: 3.9.0 + graceful-fs: 4.2.11 + picomatch: 2.3.2 + + jest-validate@29.7.0: + dependencies: + '@jest/types': 29.6.3 + camelcase: 6.3.0 + chalk: 4.1.2 + jest-get-type: 29.6.3 + leven: 3.1.0 + pretty-format: 29.7.0 + + jest-worker@29.7.0: + dependencies: + '@types/node': 24.12.2 + jest-util: 29.7.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jimp-compact@0.16.1: {} + + jiti@2.6.1: {} + + js-tokens@10.0.0: {} + + js-tokens@4.0.0: {} + + js-yaml@3.14.2: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsc-safe-url@0.2.4: {} + + jsdom@28.1.0: + dependencies: + '@acemir/cssom': 0.9.31 + '@asamuzakjp/dom-selector': 6.8.1 + '@bramus/specificity': 2.4.2 + '@exodus/bytes': 1.15.0 + cssstyle: 6.2.0 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.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.1 + undici: 7.24.7 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + - supports-color + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@1.0.2: + dependencies: + minimist: 1.2.8 + + json5@2.2.3: {} + + jsx-ast-utils@3.3.5: + dependencies: + array-includes: 3.1.9 + array.prototype.flat: 1.3.3 + object.assign: 4.1.7 + object.values: 1.2.1 + + just-extend@4.2.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kleur@3.0.3: {} + + kuler@2.0.0: {} + + lan-network@0.1.7: {} + + leven@3.1.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lighthouse-logger@1.4.2: + dependencies: + debug: 2.6.9 + marky: 1.3.0 + transitivePeerDependencies: + - supports-color + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lines-and-columns@1.2.4: {} + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.debounce@4.0.8: {} + + lodash.merge@4.6.2: {} + + lodash.throttle@4.1.1: {} + + lodash@4.18.1: {} + + log-symbols@2.2.0: + dependencies: + chalk: 2.4.2 + + logform@2.7.0: + dependencies: + '@colors/colors': 1.6.0 + '@types/triple-beam': 1.3.5 + fecha: 4.2.3 + ms: 2.1.3 + safe-stable-stringify: 2.5.0 + triple-beam: 1.4.1 + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@10.4.3: {} + + lru-cache@11.2.7: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react-native@0.544.0(react-native-svg@15.12.1(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-native: 0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + react-native-svg: 15.12.1(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + + lucide-react@0.562.0(react@19.2.4): + dependencies: + react: 19.2.4 + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.2: + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.4 + + make-error@1.3.6: {} + + makeerror@1.0.12: + dependencies: + tmpl: 1.0.5 + + marky@1.3.0: {} + + math-intrinsics@1.1.0: {} + + mdn-data@2.0.14: {} + + mdn-data@2.27.1: {} + + media-typer@1.1.0: {} + + memoize-one@5.2.1: {} + + memoize-one@6.0.0: {} + + merge-descriptors@2.0.0: {} + + merge-stream@2.0.0: {} + + metro-babel-transformer@0.83.3: + dependencies: + '@babel/core': 7.29.0 + flow-enums-runtime: 0.0.6 + hermes-parser: 0.32.0 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + + metro-babel-transformer@0.83.5: + dependencies: + '@babel/core': 7.29.0 + flow-enums-runtime: 0.0.6 + hermes-parser: 0.33.3 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + + metro-cache-key@0.83.3: + dependencies: + flow-enums-runtime: 0.0.6 + + metro-cache-key@0.83.5: + dependencies: + flow-enums-runtime: 0.0.6 + + metro-cache@0.83.3: + dependencies: + exponential-backoff: 3.1.3 + flow-enums-runtime: 0.0.6 + https-proxy-agent: 7.0.6 + metro-core: 0.83.3 + transitivePeerDependencies: + - supports-color + + metro-cache@0.83.5: + dependencies: + exponential-backoff: 3.1.3 + flow-enums-runtime: 0.0.6 + https-proxy-agent: 7.0.6 + metro-core: 0.83.5 + transitivePeerDependencies: + - supports-color + + metro-config@0.83.3: + dependencies: + connect: 3.7.0 + flow-enums-runtime: 0.0.6 + jest-validate: 29.7.0 + metro: 0.83.3 + metro-cache: 0.83.3 + metro-core: 0.83.3 + metro-runtime: 0.83.3 + yaml: 2.8.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + metro-config@0.83.5: + dependencies: + connect: 3.7.0 + flow-enums-runtime: 0.0.6 + jest-validate: 29.7.0 + metro: 0.83.5 + metro-cache: 0.83.5 + metro-core: 0.83.5 + metro-runtime: 0.83.5 + yaml: 2.8.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + metro-core@0.83.3: + dependencies: + flow-enums-runtime: 0.0.6 + lodash.throttle: 4.1.1 + metro-resolver: 0.83.3 + + metro-core@0.83.5: + dependencies: + flow-enums-runtime: 0.0.6 + lodash.throttle: 4.1.1 + metro-resolver: 0.83.5 + + metro-file-map@0.83.3: + dependencies: + debug: 4.4.3 + fb-watchman: 2.0.2 + flow-enums-runtime: 0.0.6 + graceful-fs: 4.2.11 + invariant: 2.2.4 + jest-worker: 29.7.0 + micromatch: 4.0.8 + nullthrows: 1.1.1 + walker: 1.0.8 + transitivePeerDependencies: + - supports-color + + metro-file-map@0.83.5: + dependencies: + debug: 4.4.3 + fb-watchman: 2.0.2 + flow-enums-runtime: 0.0.6 + graceful-fs: 4.2.11 + invariant: 2.2.4 + jest-worker: 29.7.0 + micromatch: 4.0.8 + nullthrows: 1.1.1 + walker: 1.0.8 + transitivePeerDependencies: + - supports-color + + metro-minify-terser@0.83.3: + dependencies: + flow-enums-runtime: 0.0.6 + terser: 5.46.1 + + metro-minify-terser@0.83.5: + dependencies: + flow-enums-runtime: 0.0.6 + terser: 5.46.1 + + metro-resolver@0.83.3: + dependencies: + flow-enums-runtime: 0.0.6 + + metro-resolver@0.83.5: + dependencies: + flow-enums-runtime: 0.0.6 + + metro-runtime@0.83.3: + dependencies: + '@babel/runtime': 7.29.2 + flow-enums-runtime: 0.0.6 + + metro-runtime@0.83.5: + dependencies: + '@babel/runtime': 7.29.2 + flow-enums-runtime: 0.0.6 + + metro-source-map@0.83.3: + dependencies: + '@babel/traverse': 7.29.0 + '@babel/traverse--for-generate-function-map': '@babel/traverse@7.29.0' + '@babel/types': 7.29.0 + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + metro-symbolicate: 0.83.3 + nullthrows: 1.1.1 + ob1: 0.83.3 + source-map: 0.5.7 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + + metro-source-map@0.83.5: + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + metro-symbolicate: 0.83.5 + nullthrows: 1.1.1 + ob1: 0.83.5 + source-map: 0.5.7 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + + metro-symbolicate@0.83.3: + dependencies: + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + metro-source-map: 0.83.3 + nullthrows: 1.1.1 + source-map: 0.5.7 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + + metro-symbolicate@0.83.5: + dependencies: + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + metro-source-map: 0.83.5 + nullthrows: 1.1.1 + source-map: 0.5.7 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + + metro-transform-plugins@0.83.3: + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + flow-enums-runtime: 0.0.6 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + + metro-transform-plugins@0.83.5: + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + flow-enums-runtime: 0.0.6 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + + metro-transform-worker@0.83.3: + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + flow-enums-runtime: 0.0.6 + metro: 0.83.3 + metro-babel-transformer: 0.83.3 + metro-cache: 0.83.3 + metro-cache-key: 0.83.3 + metro-minify-terser: 0.83.3 + metro-source-map: 0.83.3 + metro-transform-plugins: 0.83.3 + nullthrows: 1.1.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + metro-transform-worker@0.83.5: + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + flow-enums-runtime: 0.0.6 + metro: 0.83.5 + metro-babel-transformer: 0.83.5 + metro-cache: 0.83.5 + metro-cache-key: 0.83.5 + metro-minify-terser: 0.83.5 + metro-source-map: 0.83.5 + metro-transform-plugins: 0.83.5 + nullthrows: 1.1.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + metro@0.83.3: + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + accepts: 1.3.8 + chalk: 4.1.2 + ci-info: 2.0.0 + connect: 3.7.0 + debug: 4.4.3 + error-stack-parser: 2.1.4 + flow-enums-runtime: 0.0.6 + graceful-fs: 4.2.11 + hermes-parser: 0.32.0 + image-size: 1.2.1 + invariant: 2.2.4 + jest-worker: 29.7.0 + jsc-safe-url: 0.2.4 + lodash.throttle: 4.1.1 + metro-babel-transformer: 0.83.3 + metro-cache: 0.83.3 + metro-cache-key: 0.83.3 + metro-config: 0.83.3 + metro-core: 0.83.3 + metro-file-map: 0.83.3 + metro-resolver: 0.83.3 + metro-runtime: 0.83.3 + metro-source-map: 0.83.3 + metro-symbolicate: 0.83.3 + metro-transform-plugins: 0.83.3 + metro-transform-worker: 0.83.3 + mime-types: 2.1.35 + nullthrows: 1.1.1 + serialize-error: 2.1.0 + source-map: 0.5.7 + throat: 5.0.0 + ws: 7.5.10 + yargs: 17.7.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + metro@0.83.5: + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + accepts: 2.0.0 + chalk: 4.1.2 + ci-info: 2.0.0 + connect: 3.7.0 + debug: 4.4.3 + error-stack-parser: 2.1.4 + flow-enums-runtime: 0.0.6 + graceful-fs: 4.2.11 + hermes-parser: 0.33.3 + image-size: 1.2.1 + invariant: 2.2.4 + jest-worker: 29.7.0 + jsc-safe-url: 0.2.4 + lodash.throttle: 4.1.1 + metro-babel-transformer: 0.83.5 + metro-cache: 0.83.5 + metro-cache-key: 0.83.5 + metro-config: 0.83.5 + metro-core: 0.83.5 + metro-file-map: 0.83.5 + metro-resolver: 0.83.5 + metro-runtime: 0.83.5 + metro-source-map: 0.83.5 + metro-symbolicate: 0.83.5 + metro-transform-plugins: 0.83.5 + metro-transform-worker: 0.83.5 + mime-types: 3.0.2 + nullthrows: 1.1.1 + serialize-error: 2.1.0 + source-map: 0.5.7 + throat: 5.0.0 + ws: 7.5.10 + yargs: 17.7.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mime@1.6.0: {} + + mimic-fn@1.2.0: {} + + min-indent@1.0.1: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.13 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.0.3 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + + mkdirp@1.0.4: {} + + ms@2.0.0: {} + + ms@2.1.3: {} + + msgpack5@5.3.2: + dependencies: + bl: 4.1.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + safe-buffer: 5.2.1 + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.11: {} + + napi-postinstall@0.3.4: {} + + nats@1.4.12: + dependencies: + nuid: 1.1.6 + ts-nkeys: 1.0.16 + + natural-compare@1.4.0: {} + + negotiator@0.6.3: {} + + negotiator@0.6.4: {} + + negotiator@1.0.0: {} + + nested-error-stacks@2.0.1: {} + + node-exports-info@1.6.0: + dependencies: + array.prototype.flatmap: 1.3.3 + es-errors: 1.3.0 + object.entries: 1.1.9 + semver: 6.3.1 + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-forge@1.4.0: {} + + node-int64@0.4.0: {} + + node-releases@2.0.37: {} + + normalize-path@3.0.0: {} + + npm-package-arg@11.0.3: + dependencies: + hosted-git-info: 7.0.2 + proc-log: 4.2.0 + semver: 7.7.4 + validate-npm-package-name: 5.0.1 + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + nuid@1.1.6: {} + + nullthrows@1.1.1: {} + + ob1@0.83.3: + dependencies: + flow-enums-runtime: 0.0.6 + + ob1@0.83.5: + dependencies: + flow-enums-runtime: 0.0.6 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.entries@1.1.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + + object.groupby@1.0.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + + object.values@1.2.1: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + obug@2.1.1: {} + + on-finished@2.3.0: + dependencies: + ee-first: 1.1.1 + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + on-headers@1.1.0: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + one-time@1.0.0: + dependencies: + fn.name: 1.1.0 + + onetime@2.0.1: + dependencies: + mimic-fn: 1.2.0 + + open@7.4.2: + dependencies: + is-docker: 2.2.1 + is-wsl: 2.2.0 + + open@8.4.2: + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + ora@3.4.0: + dependencies: + chalk: 2.4.2 + cli-cursor: 2.1.0 + cli-spinners: 2.9.2 + log-symbols: 2.2.0 + strip-ansi: 5.2.0 + wcwidth: 1.0.1 + + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-try@2.2.0: {} + + package-json-from-dist@1.0.1: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-png@2.1.0: + dependencies: + pngjs: 3.4.0 + + parse5@8.0.0: + dependencies: + entities: 6.0.1 + + parseurl@1.3.3: {} + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.2.7 + minipass: 7.1.3 + + path-to-regexp@8.4.2: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@3.0.2: {} + + picomatch@4.0.4: {} + + pirates@4.0.7: {} + + plist@3.1.0: + dependencies: + '@xmldom/xmldom': 0.8.12 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + + pngjs@3.4.0: {} + + possible-typed-array-names@1.1.0: {} + + postcss-value-parser@4.2.0: {} + + postcss@8.4.49: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + pretty-bytes@5.6.0: {} + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + proc-log@4.2.0: {} + + progress@2.0.3: {} + + prom-client@15.1.3: + dependencies: + '@opentelemetry/api': 1.9.1 + tdigest: 0.1.2 + + promise@7.3.1: + dependencies: + asap: 2.0.6 + + promise@8.3.0: + dependencies: + asap: 2.0.6 + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + proxy-from-env@2.1.0: {} + + punycode@2.3.1: {} + + qrcode-terminal@0.11.0: {} + + qs@6.15.0: + dependencies: + side-channel: 1.1.0 + + query-string@7.1.3: + dependencies: + decode-uri-component: 0.2.2 + filter-obj: 1.1.0 + split-on-first: 1.1.0 + strict-uri-encode: 2.0.0 + + queue-microtask@1.2.3: {} + + queue@6.0.2: + dependencies: + inherits: 2.0.4 + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + react-devtools-core@6.1.5: + dependencies: + shell-quote: 1.8.3 + ws: 7.5.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + react-dom@19.1.0(react@19.1.0): + dependencies: + react: 19.1.0 + scheduler: 0.26.0 + + react-dom@19.2.4(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 + + react-fast-compare@3.2.2: {} + + react-freeze@1.0.4(react@19.1.0): + dependencies: + react: 19.1.0 + + react-is@16.13.1: {} + + react-is@17.0.2: {} + + react-is@18.3.1: {} + + react-is@19.2.4: {} + + react-native-gesture-handler@2.28.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + '@egjs/hammerjs': 2.0.17 + hoist-non-react-statics: 3.3.2 + invariant: 2.2.4 + react: 19.1.0 + react-native: 0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + + react-native-is-edge-to-edge@1.3.1(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-native: 0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + + react-native-reanimated@4.1.7(react-native-worklets@0.8.1(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-native: 0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + react-native-is-edge-to-edge: 1.3.1(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-worklets: 0.8.1(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + semver: 7.7.4 + + react-native-safe-area-context@5.6.2(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-native: 0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + + react-native-screens@4.16.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-freeze: 1.0.4(react@19.1.0) + react-native: 0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + react-native-is-edge-to-edge: 1.3.1(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + warn-once: 0.1.1 + + react-native-svg@15.12.1(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + css-select: 5.2.2 + css-tree: 1.1.3 + react: 19.1.0 + react-native: 0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + warn-once: 0.1.1 + + react-native-url-polyfill@2.0.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0)): + dependencies: + react-native: 0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + whatwg-url-without-unicode: 8.0.0-3 + + react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + '@babel/runtime': 7.29.2 + '@react-native/normalize-colors': 0.74.89 + fbjs: 3.0.5 + inline-style-prefixer: 7.0.1 + memoize-one: 6.0.0 + nullthrows: 1.1.1 + postcss-value-parser: 4.2.0 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + styleq: 0.1.3 + transitivePeerDependencies: + - encoding + + react-native-webview@13.15.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + escape-string-regexp: 4.0.0 + invariant: 2.2.4 + react: 19.1.0 + react-native: 0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + + react-native-worklets@0.8.1(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.0) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) + '@react-native/metro-config': 0.84.1(@babel/core@7.29.0) + convert-source-map: 2.0.0 + react: 19.1.0 + react-native: 0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + semver: 7.7.4 + transitivePeerDependencies: + - supports-color + + react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0): + dependencies: + '@jest/create-cache-key-function': 29.7.0 + '@react-native/assets-registry': 0.81.4 + '@react-native/codegen': 0.81.4(@babel/core@7.29.0) + '@react-native/community-cli-plugin': 0.81.4(@react-native/metro-config@0.84.1(@babel/core@7.29.0)) + '@react-native/gradle-plugin': 0.81.4 + '@react-native/js-polyfills': 0.81.4 + '@react-native/normalize-colors': 0.81.4 + '@react-native/virtualized-lists': 0.81.4(@types/react@19.1.17)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + abort-controller: 3.0.0 + anser: 1.4.10 + ansi-regex: 5.0.1 + babel-jest: 29.7.0(@babel/core@7.29.0) + babel-plugin-syntax-hermes-parser: 0.29.1 + base64-js: 1.5.1 + commander: 12.1.0 + flow-enums-runtime: 0.0.6 + glob: 7.2.3 + invariant: 2.2.4 + jest-environment-node: 29.7.0 + memoize-one: 5.2.1 + metro-runtime: 0.83.5 + metro-source-map: 0.83.5 + nullthrows: 1.1.1 + pretty-format: 29.7.0 + promise: 8.3.0 + react: 19.1.0 + react-devtools-core: 6.1.5 + react-refresh: 0.14.2 + regenerator-runtime: 0.13.11 + scheduler: 0.26.0 + semver: 7.7.4 + stacktrace-parser: 0.1.11 + whatwg-fetch: 3.6.20 + ws: 6.2.3 + yargs: 17.7.2 + optionalDependencies: + '@types/react': 19.1.17 + transitivePeerDependencies: + - '@babel/core' + - '@react-native-community/cli' + - '@react-native/metro-config' + - bufferutil + - supports-color + - utf-8-validate + + react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + redux: 5.0.1 + + react-refresh@0.14.2: {} + + react-refresh@0.18.0: {} + + react-remove-scroll-bar@2.3.8(@types/react@19.1.17)(react@19.1.0): + dependencies: + react: 19.1.0 + react-style-singleton: 2.2.3(@types/react@19.1.17)(react@19.1.0) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.1.17 + + react-remove-scroll@2.7.2(@types/react@19.1.17)(react@19.1.0): + dependencies: + react: 19.1.0 + react-remove-scroll-bar: 2.3.8(@types/react@19.1.17)(react@19.1.0) + react-style-singleton: 2.2.3(@types/react@19.1.17)(react@19.1.0) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.1.17)(react@19.1.0) + use-sidecar: 1.1.3(@types/react@19.1.17)(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.17 + + react-style-singleton@2.2.3(@types/react@19.1.17)(react@19.1.0): + dependencies: + get-nonce: 1.0.1 + react: 19.1.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.1.17 + + react@19.1.0: {} + + react@19.2.4: {} + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@19.2.4)(react@19.2.4)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.45.1 + eventemitter3: 5.0.4 + immer: 10.2.0 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-is: 19.2.4 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@19.2.4) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + regenerate-unicode-properties@10.2.2: + dependencies: + regenerate: 1.4.2 + + regenerate@1.4.2: {} + + regenerator-runtime@0.13.11: {} + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + regexpu-core@6.4.0: + dependencies: + regenerate: 1.4.2 + regenerate-unicode-properties: 10.2.2 + regjsgen: 0.8.0 + regjsparser: 0.13.0 + unicode-match-property-ecmascript: 2.0.0 + unicode-match-property-value-ecmascript: 2.2.1 + + regjsgen@0.8.0: {} + + regjsparser@0.13.0: + dependencies: + jsesc: 3.1.0 + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + requireg@0.2.2: + dependencies: + nested-error-stacks: 2.0.1 + rc: 1.2.8 + resolve: 1.7.1 + + reselect@5.1.1: {} + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + resolve-workspace-root@2.0.1: {} + + resolve.exports@2.0.3: {} + + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + resolve@1.7.1: + dependencies: + path-parse: 1.0.7 + + resolve@2.0.0-next.6: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.1 + node-exports-info: 1.6.0 + object-keys: 1.1.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@2.0.0: + dependencies: + onetime: 2.0.1 + signal-exit: 3.0.7 + + reusify@1.1.0: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + rollup@4.60.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.1 + '@rollup/rollup-android-arm64': 4.60.1 + '@rollup/rollup-darwin-arm64': 4.60.1 + '@rollup/rollup-darwin-x64': 4.60.1 + '@rollup/rollup-freebsd-arm64': 4.60.1 + '@rollup/rollup-freebsd-x64': 4.60.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.1 + '@rollup/rollup-linux-arm-musleabihf': 4.60.1 + '@rollup/rollup-linux-arm64-gnu': 4.60.1 + '@rollup/rollup-linux-arm64-musl': 4.60.1 + '@rollup/rollup-linux-loong64-gnu': 4.60.1 + '@rollup/rollup-linux-loong64-musl': 4.60.1 + '@rollup/rollup-linux-ppc64-gnu': 4.60.1 + '@rollup/rollup-linux-ppc64-musl': 4.60.1 + '@rollup/rollup-linux-riscv64-gnu': 4.60.1 + '@rollup/rollup-linux-riscv64-musl': 4.60.1 + '@rollup/rollup-linux-s390x-gnu': 4.60.1 + '@rollup/rollup-linux-x64-gnu': 4.60.1 + '@rollup/rollup-linux-x64-musl': 4.60.1 + '@rollup/rollup-openbsd-x64': 4.60.1 + '@rollup/rollup-openharmony-arm64': 4.60.1 + '@rollup/rollup-win32-arm64-msvc': 4.60.1 + '@rollup/rollup-win32-ia32-msvc': 4.60.1 + '@rollup/rollup-win32-x64-gnu': 4.60.1 + '@rollup/rollup-win32-x64-msvc': 4.60.1 + fsevents: 2.3.3 + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-array-concat@1.1.3: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-buffer@5.2.1: {} + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + safe-stable-stringify@2.5.0: {} + + safer-buffer@2.1.2: {} + + sax@1.6.0: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.26.0: {} + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.6.3: {} + + semver@7.7.4: {} + + send@0.19.2: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serialize-error@2.1.0: {} + + serve-static@1.16.3: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + server-only@0.0.1: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + + setimmediate@1.0.5: {} + + setprototypeof@1.2.0: {} + + sf-symbols-typescript@2.2.0: {} + + shallowequal@1.1.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shell-quote@1.8.3: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + simple-plist@1.3.1: + dependencies: + bplist-creator: 0.1.0 + bplist-parser: 0.3.1 + plist: 3.1.0 + + simple-swizzle@0.2.4: + dependencies: + is-arrayish: 0.3.4 + + sisteransi@1.0.5: {} + + slash@3.0.0: {} + + slugify@1.6.9: {} + + socket.io-adapter@2.5.6: + dependencies: + debug: 4.4.3 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-client@4.8.3: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + engine.io-client: 6.6.4 + socket.io-parser: 4.2.6 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-parser@4.2.6: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + socket.io@4.8.3: + dependencies: + accepts: 1.3.8 + base64id: 2.0.0 + cors: 2.8.6 + debug: 4.4.3 + engine.io: 6.6.6 + socket.io-adapter: 2.5.6 + socket.io-parser: 4.2.6 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + source-map-js@1.2.1: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.5.7: {} + + source-map@0.6.1: {} + + split-on-first@1.1.0: {} + + sprintf-js@1.0.3: {} + + stable-hash@0.0.5: {} + + stack-trace@0.0.10: {} + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + + stackback@0.0.2: {} + + stackframe@1.3.4: {} + + stacktrace-parser@0.1.11: + dependencies: + type-fest: 0.7.1 + + statuses@1.5.0: {} + + statuses@2.0.2: {} + + std-env@4.0.0: {} + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + stream-buffers@2.2.0: {} + + strict-uri-encode@2.0.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + string.prototype.matchall@4.0.12: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.0 + + string.prototype.repeat@1.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.24.1 + + string.prototype.trim@1.2.10: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + has-property-descriptors: 1.0.2 + + string.prototype.trimend@1.0.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@5.2.0: + dependencies: + ansi-regex: 4.1.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-bom@3.0.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + strip-json-comments@2.0.1: {} + + strip-json-comments@3.1.1: {} + + structured-headers@0.4.1: {} + + styleq@0.1.3: {} + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.15 + ts-interface-checker: 0.1.13 + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-hyperlinks@2.3.0: + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + symbol-tree@3.2.4: {} + + tailwindcss@4.2.2: {} + + tapable@2.3.2: {} + + tar@7.5.13: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + + tdigest@0.1.2: + dependencies: + bintrees: 1.0.2 + + terminal-link@2.1.1: + dependencies: + ansi-escapes: 4.3.2 + supports-hyperlinks: 2.3.0 + + terser@5.46.1: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.16.0 + commander: 2.20.3 + source-map-support: 0.5.21 + + test-exclude@6.0.0: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 7.2.3 + minimatch: 3.1.5 + + test-exclude@7.0.2: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 10.5.0 + minimatch: 10.2.5 + + text-hex@1.0.0: {} + + text-table@0.2.0: {} + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + throat@5.0.0: {} + + tiny-invariant@1.3.3: {} + + tinybench@2.9.0: {} + + tinyexec@1.0.4: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + tldts-core@7.0.27: {} + + tldts@7.0.27: + dependencies: + tldts-core: 7.0.27 + + tmpl@1.0.5: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.27 + + tr46@0.0.3: {} + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + + triple-beam@1.4.1: {} + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-interface-checker@0.1.13: {} + + ts-nkeys@1.0.16: + dependencies: + tweetnacl: 1.0.3 + + ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.12 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 25.5.2 + acorn: 8.16.0 + acorn-walk: 8.3.5 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.4 + make-error: 1.3.6 + typescript: 5.9.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + + tsconfig-paths@3.15.0: + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + tsx@4.21.0: + dependencies: + esbuild: 0.27.7 + get-tsconfig: 4.13.7 + optionalDependencies: + fsevents: 2.3.3 + + tweetnacl@1.0.3: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-detect@4.0.8: {} + + type-fest@0.20.2: {} + + type-fest@0.21.3: {} + + type-fest@0.7.1: {} + + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.7: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + typescript-eslint@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + ua-parser-js@1.0.41: {} + + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + undici-types@7.16.0: {} + + undici-types@7.18.2: {} + + undici@6.24.1: {} + + undici@7.24.7: {} + + unicode-canonical-property-names-ecmascript@2.0.1: {} + + unicode-match-property-ecmascript@2.0.0: + dependencies: + unicode-canonical-property-names-ecmascript: 2.0.1 + unicode-property-aliases-ecmascript: 2.2.0 + + unicode-match-property-value-ecmascript@2.2.1: {} + + unicode-property-aliases-ecmascript@2.2.0: {} + + unpipe@1.0.0: {} + + unrs-resolver@1.11.1: + dependencies: + napi-postinstall: 0.3.4 + optionalDependencies: + '@unrs/resolver-binding-android-arm-eabi': 1.11.1 + '@unrs/resolver-binding-android-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-x64': 1.11.1 + '@unrs/resolver-binding-freebsd-x64': 1.11.1 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-arm64-musl': 1.11.1 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-musl': 1.11.1 + '@unrs/resolver-binding-linux-s390x-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-musl': 1.11.1 + '@unrs/resolver-binding-wasm32-wasi': 1.11.1 + '@unrs/resolver-binding-win32-arm64-msvc': 1.11.1 + '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 + '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + urljoin@0.1.5: + dependencies: + extend: 2.0.2 + + use-callback-ref@1.3.3(@types/react@19.1.17)(react@19.1.0): + dependencies: + react: 19.1.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.1.17 + + use-latest-callback@0.2.6(react@19.1.0): + dependencies: + react: 19.1.0 + + use-sidecar@1.1.3(@types/react@19.1.17)(react@19.1.0): + dependencies: + detect-node-es: 1.1.0 + react: 19.1.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.1.17 + + use-sync-external-store@1.6.0(react@19.1.0): + dependencies: + react: 19.1.0 + + use-sync-external-store@1.6.0(react@19.2.4): + dependencies: + react: 19.2.4 + + util-deprecate@1.0.2: {} + + utils-merge@1.0.1: {} + + uuid@7.0.3: {} + + v8-compile-cache-lib@3.0.1: {} + + v8-to-istanbul@9.3.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@types/istanbul-lib-coverage': 2.0.6 + convert-source-map: 2.0.0 + + validate-npm-package-name@5.0.1: {} + + vary@1.1.2: {} + + vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + + vite@7.3.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): + dependencies: + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.8 + rollup: 4.60.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.12.2 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.32.0 + terser: 5.46.1 + tsx: 4.21.0 + yaml: 2.8.3 + + vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(jsdom@28.1.0)(vite@7.3.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + dependencies: + '@vitest/expect': 4.1.2 + '@vitest/mocker': 4.1.2(vite@7.3.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/pretty-format': 4.1.2 + '@vitest/runner': 4.1.2 + '@vitest/snapshot': 4.1.2 + '@vitest/spy': 4.1.2 + '@vitest/utils': 4.1.2 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.0.4 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 7.3.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 24.12.2 + jsdom: 28.1.0 + transitivePeerDependencies: + - msw + + vlq@1.0.1: {} + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + walker@1.0.8: + dependencies: + makeerror: 1.0.12 + + warn-once@0.1.1: {} + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + + webidl-conversions@3.0.1: {} + + webidl-conversions@5.0.0: {} + + webidl-conversions@8.0.1: {} + + whatwg-fetch@3.6.20: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url-without-unicode@8.0.0-3: + dependencies: + buffer: 5.7.1 + punycode: 2.3.1 + webidl-conversions: 5.0.0 + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.0 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.1.8 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.20 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.20: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + winston-transport@4.9.0: + dependencies: + logform: 2.7.0 + readable-stream: 3.6.2 + triple-beam: 1.4.1 + + winston@3.19.0: + dependencies: + '@colors/colors': 1.6.0 + '@dabh/diagnostics': 2.0.8 + async: 3.2.6 + is-stream: 2.0.1 + logform: 2.7.0 + one-time: 1.0.0 + readable-stream: 3.6.2 + safe-stable-stringify: 2.5.0 + stack-trace: 0.0.10 + triple-beam: 1.4.1 + winston-transport: 4.9.0 + + wonka@6.3.6: {} + + word-wrap@1.2.5: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + + write-file-atomic@4.0.2: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 3.0.7 + + ws@6.2.3: + dependencies: + async-limiter: 1.0.1 + + ws@7.5.10: {} + + ws@8.18.3: {} + + ws@8.20.0: {} + + xcode@3.0.1: + dependencies: + simple-plist: 1.3.1 + uuid: 7.0.3 + + xml-name-validator@5.0.0: {} + + xml2js@0.6.0: + dependencies: + sax: 1.6.0 + xmlbuilder: 11.0.1 + + xmlbuilder@11.0.1: {} + + xmlbuilder@15.1.1: {} + + xmlchars@2.2.0: {} + + xmlhttprequest-ssl@2.1.2: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yallist@5.0.0: {} + + yaml@2.8.3: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yn@3.1.1: {} + + yocto-queue@0.1.0: {} + + zod-validation-error@4.0.2(zod@4.3.6): + dependencies: + zod: 4.3.6 + + zod@4.3.6: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..f644d49 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +packages: + - backend + - web + - mobile diff --git a/scripts/verify.sh b/scripts/verify.sh new file mode 100644 index 0000000..14ba12c --- /dev/null +++ b/scripts/verify.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -eu + +echo "Running root verification for learning_ai_invt_trdg" +pnpm typecheck +pnpm test +pnpm build + diff --git a/shared/backend-control-config.ts b/shared/backend-control-config.ts new file mode 100644 index 0000000..87740b3 --- /dev/null +++ b/shared/backend-control-config.ts @@ -0,0 +1,27 @@ +import { getRuntimeEnvironment } from './runtime.js'; + +export interface BackendControlConfig { + productId: string; + platformApiUrl: string; + tradingApiUrl: string; + backendPort: number; + corsAllowedOrigins: string[]; +} + +export function getBackendControlConfig(): BackendControlConfig { + const runtime = getRuntimeEnvironment('backend'); + const port = Number(process.env.PORT || 4018); + const corsAllowedOrigins = String(process.env.CORS_ALLOWED_ORIGINS || '') + .split(',') + .map(value => value.trim()) + .filter(Boolean); + + return { + productId: runtime.productId, + platformApiUrl: runtime.platformApiUrl, + tradingApiUrl: runtime.tradingApiUrl, + backendPort: Number.isFinite(port) ? port : 4018, + corsAllowedOrigins, + }; +} + diff --git a/shared/control-plane.ts b/shared/control-plane.ts new file mode 100644 index 0000000..6f130f1 --- /dev/null +++ b/shared/control-plane.ts @@ -0,0 +1,58 @@ +export type ProductAccessibilityState = + | 'available' + | 'maintenance' + | 'product_disabled'; + +export type TradingBehaviorState = + | 'active' + | 'global_trade_halt' + | 'tenant_disabled' + | 'profile_disabled'; + +export interface ProductControlEnvelope { + accessibility: ProductAccessibilityState; + trading: TradingBehaviorState; + message?: string | null; + tenantId?: string | null; + profileId?: string | null; + updatedAt?: string; +} + +export interface TradingRuntimeSummary { + loopRunning: boolean; + reconciliationRunning: boolean; + lastLoopAt: string | null; + lastReconciliationAt: string | null; + health: 'healthy' | 'degraded' | 'paused' | 'error'; +} + +export interface AuthorizedTradingAction { + id: + | 'pause_trading' + | 'resume_trading' + | 'disable_profile' + | 'enable_profile' + | 'close_position' + | 'acknowledge_alert'; + platform: 'web' | 'mobile'; + role: 'user' | 'operator' | 'admin'; +} + +export function canExecuteTradingAction( + action: AuthorizedTradingAction['id'], + platform: AuthorizedTradingAction['platform'], + role: AuthorizedTradingAction['role'] +): boolean { + if (platform === 'mobile') { + return ( + role === 'admin' || + role === 'operator' || + action === 'acknowledge_alert' || + action === 'pause_trading' || + action === 'resume_trading' + ); + } + + return role === 'admin' || role === 'operator' || action === 'acknowledge_alert'; +} + diff --git a/shared/platform-clients.ts b/shared/platform-clients.ts new file mode 100644 index 0000000..c6d2c84 --- /dev/null +++ b/shared/platform-clients.ts @@ -0,0 +1,35 @@ +import { createKillSwitchClient } from '@bytelyst/kill-switch-client'; +import { createWebTelemetry } from '@bytelyst/telemetry-client'; +import { createRNPlatformSDK } from '@bytelyst/react-native-platform-sdk'; +import { getRuntimeEnvironment } from './runtime.js'; +import { productConfig } from './product.js'; + +export function createTradingKillSwitchClient(platform: 'web' | 'mobile') { + const runtime = getRuntimeEnvironment(platform); + return createKillSwitchClient({ + baseUrl: runtime.platformApiUrl, + productId: runtime.productId, + platform, + }); +} + +export function createTradingWebTelemetry() { + const runtime = getRuntimeEnvironment('web'); + return createWebTelemetry({ + productId: runtime.productId, + channel: 'invttrdg_web', + baseUrl: runtime.platformApiUrl, + appVersion: productConfig.version, + releaseChannel: process.env.NODE_ENV === 'production' ? 'prod' : 'dev', + }); +} + +export function createTradingMobileSdk(getAccessToken: () => string | null) { + const runtime = getRuntimeEnvironment('mobile'); + return createRNPlatformSDK({ + baseURL: runtime.platformApiUrl, + productId: runtime.productId, + getAccessToken, + }); +} + diff --git a/shared/platform-mobile.ts b/shared/platform-mobile.ts new file mode 100644 index 0000000..c822261 --- /dev/null +++ b/shared/platform-mobile.ts @@ -0,0 +1,21 @@ +import { createKillSwitchClient } from '@bytelyst/kill-switch-client'; +import { createRNPlatformSDK } from '@bytelyst/react-native-platform-sdk'; +import { getRuntimeEnvironment } from './runtime.js'; + +export function createTradingKillSwitchClient(platform: 'web' | 'mobile') { + const runtime = getRuntimeEnvironment(platform); + return createKillSwitchClient({ + baseUrl: runtime.platformApiUrl, + productId: runtime.productId, + platform, + }); +} + +export function createTradingMobileSdk(getAccessToken: () => string | null) { + const runtime = getRuntimeEnvironment('mobile'); + return createRNPlatformSDK({ + baseURL: runtime.platformApiUrl, + productId: runtime.productId, + getAccessToken, + }); +} diff --git a/shared/platform-web.ts b/shared/platform-web.ts new file mode 100644 index 0000000..7122835 --- /dev/null +++ b/shared/platform-web.ts @@ -0,0 +1,24 @@ +import { createKillSwitchClient } from '@bytelyst/kill-switch-client'; +import { createWebTelemetry } from '@bytelyst/telemetry-client'; +import { getRuntimeEnvironment } from './runtime.js'; +import { productConfig } from './product.js'; + +export function createTradingKillSwitchClient(platform: 'web' | 'mobile') { + const runtime = getRuntimeEnvironment(platform); + return createKillSwitchClient({ + baseUrl: runtime.platformApiUrl, + productId: runtime.productId, + platform, + }); +} + +export function createTradingWebTelemetry() { + const runtime = getRuntimeEnvironment('web'); + return createWebTelemetry({ + productId: runtime.productId, + channel: 'invttrdg_web', + baseUrl: runtime.platformApiUrl, + appVersion: productConfig.version, + releaseChannel: process.env.NODE_ENV === 'production' ? 'prod' : 'dev', + }); +} diff --git a/shared/product.json b/shared/product.json new file mode 100644 index 0000000..a1c0900 --- /dev/null +++ b/shared/product.json @@ -0,0 +1,20 @@ +{ + "productId": "invttrdg", + "displayName": "ByteLyst Trading", + "description": "AI-assisted trading operations across web, mobile, and backend", + "domain": "trading.bytelyst.ai", + "backendPort": 4018, + "bundleId": { + "ios": "com.bytelyst.trading", + "android": "com.bytelyst.trading" + }, + "platforms": ["web", "ios", "android"], + "primarySurface": "web", + "mobileCompanion": true, + "licensePrefix": "TRADING", + "configDirName": ".ByteLystTrading", + "envVarPrefix": "TRADING", + "packageName": "bytelyst-trading", + "version": "0.1.0" +} + diff --git a/shared/product.ts b/shared/product.ts new file mode 100644 index 0000000..000ddee --- /dev/null +++ b/shared/product.ts @@ -0,0 +1,22 @@ +export const productConfig = { + productId: 'invttrdg', + displayName: 'ByteLyst Trading', + description: 'AI-assisted trading operations across web, mobile, and backend', + domain: 'trading.bytelyst.ai', + backendPort: 4018, + bundleId: { + ios: 'com.bytelyst.trading', + android: 'com.bytelyst.trading', + }, + platforms: ['web', 'ios', 'android'], + primarySurface: 'web', + mobileCompanion: true, + licensePrefix: 'TRADING', + configDirName: '.ByteLystTrading', + envVarPrefix: 'TRADING', + packageName: 'bytelyst-trading', + version: '0.1.0', +} as const; + +export type ProductConfig = typeof productConfig; + diff --git a/shared/runtime.ts b/shared/runtime.ts new file mode 100644 index 0000000..da9a2ff --- /dev/null +++ b/shared/runtime.ts @@ -0,0 +1,44 @@ +import { productConfig } from './product.js'; + +export type ProductSurface = 'web' | 'mobile' | 'backend'; + +export interface RuntimeEnvironment { + productId: string; + platformApiUrl: string; + tradingApiUrl: string; +} + +function readEnv(key: string): string | undefined { + const value = typeof process !== 'undefined' ? process.env[key] : undefined; + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; +} + +export function getRuntimeEnvironment(surface: ProductSurface): RuntimeEnvironment { + const surfacePrefix = + surface === 'mobile' ? 'EXPO_PUBLIC_' : surface === 'web' ? 'NEXT_PUBLIC_' : ''; + + const productId = + readEnv(`${surfacePrefix}PRODUCT_ID`) ?? + readEnv('VITE_PRODUCT_ID') ?? + readEnv('PRODUCT_ID') ?? + productConfig.productId; + + const platformApiUrl = + readEnv(`${surfacePrefix}PLATFORM_URL`) ?? + readEnv('VITE_PLATFORM_URL') ?? + readEnv('PLATFORM_API_URL') ?? + 'http://localhost:4003/api'; + + const tradingApiUrl = + readEnv(`${surfacePrefix}TRADING_API_URL`) ?? + readEnv('VITE_TRADING_API_URL') ?? + readEnv('TRADING_API_URL') ?? + `http://localhost:${productConfig.backendPort}/api`; + + return { + productId, + platformApiUrl, + tradingApiUrl, + }; +} + diff --git a/shared/web-auth.tsx b/shared/web-auth.tsx new file mode 100644 index 0000000..938767d --- /dev/null +++ b/shared/web-auth.tsx @@ -0,0 +1,37 @@ +import { createAuthProvider, type BaseUser } from '@bytelyst/react-auth'; +import { getRuntimeEnvironment } from './runtime.js'; + +export interface TradingAuthUser extends BaseUser { + id: string; + plan?: string; +} + +const runtime = getRuntimeEnvironment('web'); + +export const tradingWebAuth = createAuthProvider({ + baseUrl: runtime.platformApiUrl, + productId: runtime.productId, + storagePrefix: 'invttrdg_web', + loginEndpoint: '/auth/login', + registerEndpoint: '/auth/register', + forgotPasswordEndpoint: '/auth/forgot-password', + changePasswordEndpoint: '/auth/change-password', + deleteAccountEndpoint: '/auth/account', + refreshEndpoint: '/auth/refresh', + mapLoginResponse: data => { + const response = data as { + user: TradingAuthUser; + accessToken: string; + refreshToken: string; + }; + return { + user: response.user, + accessToken: response.accessToken, + refreshToken: response.refreshToken, + }; + }, +}); + +export const TradingAuthProvider = tradingWebAuth.AuthProvider; +export const useTradingAuth = tradingWebAuth.useAuth; + diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..510ea89 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "baseUrl": "." + } +} + diff --git a/web/.dockerignore b/web/.dockerignore new file mode 100644 index 0000000..6cd4887 --- /dev/null +++ b/web/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +.git +.vscode +.env* +! .env.production diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..a95bd89 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,27 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +.vite +dist +dist-ssr +*.local +.env +.env.* + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/web/ADMIN_TRADE_CONTROL_UI.md b/web/ADMIN_TRADE_CONTROL_UI.md new file mode 100644 index 0000000..d610f57 --- /dev/null +++ b/web/ADMIN_TRADE_CONTROL_UI.md @@ -0,0 +1,532 @@ +# Admin Trade Control - Web UI Implementation + +## Overview + +This document describes the **frontend implementation** of the Admin Trade Control feature in the trading dashboard. The UI allows authorized administrators to pause and resume auto-trading with clear visual indicators. + +For backend implementation details, see the backend service documentation. + +--- + +## UI Components + +### 1. Header Status Badge (Global) + +**Location**: `src/App.tsx` (lines 195-231) + +**Visibility**: All pages, all users (read-only) + +**Features**: +- Shows current trading state: PAUSED or RUNNING +- Color-coded: Orange (paused) / Green (running) +- Icon indicators: ⏸️ (paused) / ▶️ (running) +- Tooltip with who paused and when +- Updates in real-time via WebSocket + +**Implementation**: +```tsx +{botState.health?.tradingControl && ( +

+ + {botState.health.tradingControl.mode === 'PAUSED' ? '⏸️' : '▶️'} + +
+ + {botState.health.tradingControl.mode === 'PAUSED' ? 'Trading Paused' : 'Trading Active'} + + + {botState.health.tradingControl.mode === 'PAUSED' ? 'No new entries' : 'Entries allowed'} + +
+
+)} +``` + +--- + +### 2. Admin Tab Controls (Admin Only) + +**Location**: `src/tabs/AdminTab.tsx` (lines 295-380) + +**Visibility**: Admin users only (role = 'admin') + +**Features**: +- Trading Control Panel section +- Status banner showing current mode +- Pause/Resume buttons +- Error display +- Safety notice +- Loading states +- Disabled states + +**State Management**: +```tsx +const [isControlLoading, setIsControlLoading] = React.useState(false); +const [controlError, setControlError] = React.useState(null); +const tradingControl = botState.health?.tradingControl; +const isPaused = tradingControl?.mode === 'PAUSED'; +``` + +**Pause Handler**: +```tsx +const handlePauseTrading = async () => { + setIsControlLoading(true); + setControlError(null); + try { + const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:5000'; + const { data: sessionData } = await supabase.auth.getSession(); + const accessToken = sessionData.session?.access_token; + if (!accessToken) { + throw new Error('Not authenticated'); + } + const res = await fetch(`${apiUrl}/internal/trading/pause`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ reason: 'Admin pause from dashboard' }) + }); + if (!res.ok) { + const errorData = await res.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(errorData.error || `HTTP ${res.status}`); + } + } catch (err: any) { + setControlError(err.message || 'Failed to pause trading'); + } finally { + setIsControlLoading(false); + } +}; +``` + +**Resume Handler**: +```tsx +const handleResumeTrading = async () => { + setIsControlLoading(true); + setControlError(null); + try { + const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:5000'; + const { data: sessionData } = await supabase.auth.getSession(); + const accessToken = sessionData.session?.access_token; + if (!accessToken) { + throw new Error('Not authenticated'); + } + const res = await fetch(`${apiUrl}/internal/trading/resume`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ reason: 'Admin resume from dashboard' }) + }); + if (!res.ok) { + const errorData = await res.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(errorData.error || `HTTP ${res.status}`); + } + } catch (err: any) { + setControlError(err.message || 'Failed to resume trading'); + } finally { + setIsControlLoading(false); + } +}; +``` + +**UI Rendering**: +```tsx +{/* Status Banner */} +
+
+ {isPaused ? ( + + ) : ( + + )} +
+

+ AUTO-TRADING: {isPaused ? 'PAUSED' : 'RUNNING'} +

+

+ {isPaused + ? 'No new positions will be opened. Existing positions are still managed.' + : 'Bot is actively monitoring and executing trades based on strategy rules.'} +

+
+
+ {tradingControl && ( +
+

Last Changed

+

+ {new Date(tradingControl.lastChangedAt).toLocaleString()} +

+

by {tradingControl.lastChangedBy}

+
+ )} +
+ +{/* Control Buttons */} +
+ + +
+ +{/* Error Display */} +{controlError && ( +
+ +

{controlError}

+
+)} + +{/* Safety Notice */} +
+ +

+ Safety Note: Pausing trading blocks new entries only. + Existing positions will continue to be monitored for exits, stop-losses, and take-profits. +

+
+``` + +--- + +### 3. Database Synchronization Control (Admin Only) + +**Location**: `src/tabs/AdminTab.tsx` (lines 409-480) + +**Features**: +- **State Snapshots Toggle**: Enable/Disable all database writes. +- **Sync Interval Slider**: Adjustable frequency (1m to 60m). +- **Audit Logging**: Saves settings to `bot_config` table. + +**Implementation**: +```tsx +const [dbSyncEnabled, setDbSyncEnabled] = React.useState(true); +const [dbSyncInterval, setDbSyncInterval] = React.useState(300000); + +const handleUpdateDbSync = async () => { + const updates = [ + { key: 'ENABLE_DB_SNAPSHOTS', value: String(dbSyncEnabled) }, + { key: 'DB_SNAPSHOT_INTERVAL_MS', value: String(dbSyncInterval) } + ]; + await supabase.from('bot_config').upsert(updates); +}; +``` + +--- + +### 4. WebSocket Integration + +**Location**: `src/hooks/useWebSocket.ts` + +**Purpose**: Receive real-time trading control state updates from backend + +**Implementation**: +```tsx +export interface TradingControlSnapshot { + mode: 'RUNNING' | 'PAUSED'; + lastChangedBy: string; + lastChangedAt: number; + reason?: string; +} + +export interface HealthSnapshot { + tradingLoopHealthy: boolean; + tradingLoopLastRun: number | null; + // ... other health metrics + tradingControl: TradingControlSnapshot; +} + +// In useWebSocket hook: +newSocket.on('health_update', (health: HealthSnapshot) => { + setBotState(prev => ({ + ...prev, + health + })); +}); +``` + +**Data Flow**: +1. Admin clicks "Pause" button +2. Frontend calls `POST /internal/trading/pause` +3. Backend updates state and broadcasts `health_update` event +4. WebSocket receives event +5. `botState.health.tradingControl` updates +6. UI re-renders with new state + +--- + +## UI States + +### Button States + +| Scenario | Pause Button | Resume Button | +|----------|--------------|---------------| +| Trading is RUNNING | Enabled | Disabled | +| Trading is PAUSED | Disabled | Enabled | +| API call in progress | Disabled (shows "Pausing...") | Disabled (shows "Resuming...") | +| API error | Re-enabled | Re-enabled | + +### Visual Indicators + +| State | Header Badge | Admin Panel Banner | +|-------|--------------|-------------------| +| RUNNING | ▶️ Trading Active (green) | AUTO-TRADING: RUNNING (green) | +| PAUSED | ⏸️ Trading Paused (orange) | AUTO-TRADING: PAUSED (orange) | + +--- + +## Error Handling + +### API Errors + +**Scenarios**: +- Network failure +- Authentication failure (401) +- Authorization failure (403) +- Server error (500) + +**Handling**: +```tsx +try { + const res = await fetch(/* ... */); + if (!res.ok) { + const errorData = await res.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(errorData.error || `HTTP ${res.status}`); + } +} catch (err: any) { + setControlError(err.message || 'Failed to pause trading'); +} +``` + +**Display**: +- Error message shown in red alert box +- User can retry by clicking button again +- Error clears on next successful action + +### WebSocket Disconnection + +**Behavior**: +- Last-known state remains visible +- Timestamp shows when state was last updated +- Connection status indicator in header shows "Reconnecting..." +- State updates when WebSocket reconnects + +--- + +## TypeScript Types + +```tsx +// From useWebSocket.ts +export interface TradingControlSnapshot { + mode: 'RUNNING' | 'PAUSED'; + lastChangedBy: string; + lastChangedAt: number; + reason?: string; +} + +export interface HealthSnapshot { + tradingLoopHealthy: boolean; + tradingLoopLastRun: number | null; + monitorLoopHealthy: boolean; + monitorLoopLastRun: number | null; + orderSyncHealthy: boolean; + orderSyncLastRun: number | null; + lockContentionCount: number; + reconciliationLoopHealthy: boolean; + reconciliationLoopLastRun: number | null; + capitalInvariantViolations: number; + tradingControl: TradingControlSnapshot; +} + +export interface BotState { + health: HealthSnapshot; + // ... other state properties +} +``` + +--- + +## Styling + +### Color Palette + +| State | Background | Border | Text | +|-------|-----------|--------|------| +| PAUSED | `rgba(255,149,0,0.15)` | `rgba(255,149,0,0.4)` | `#ff9500` | +| RUNNING | `rgba(52,199,89,0.08)` | `rgba(52,199,89,0.3)` | `#34c759` | +| ERROR | `rgba(255,59,48,0.06)` | `rgba(255,59,48,0.2)` | `#ff3b30` | +| INFO | `rgba(10,132,255,0.04)` | `rgba(10,132,255,0.1)` | `#0a84ff` | + +### Icons + +- Pause: `⏸️` or `` from lucide-react +- Play/Resume: `▶️` or `` from lucide-react +- Warning: `` from lucide-react + +--- + +## Testing + +### Component Tests + +**File**: `src/tabs/AdminTab.test.tsx` + +```tsx +describe('AdminTab - Trading Control', () => { + test('should show running status when mode is RUNNING', () => { + const mockBotState = { + health: { + tradingControl: { + mode: 'RUNNING', + lastChangedBy: 'system', + lastChangedAt: Date.now() + } + } + }; + render(); + expect(screen.getByText(/AUTO-TRADING: RUNNING/i)).toBeInTheDocument(); + }); + + test('should disable pause button when already paused', () => { + const pausedState = { + health: { + tradingControl: { mode: 'PAUSED', lastChangedBy: 'admin', lastChangedAt: Date.now() } + } + }; + render(); + const pauseButton = screen.getByText(/Pause Auto Trading/i); + expect(pauseButton).toBeDisabled(); + }); + + test('should show error when API call fails', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + json: () => Promise.resolve({ error: 'Unauthorized' }) + }) + ); + + render(); + const pauseButton = screen.getByText(/Pause Auto Trading/i); + fireEvent.click(pauseButton); + + await waitFor(() => { + expect(screen.getByText(/Unauthorized/i)).toBeInTheDocument(); + }); + }); +}); +``` + +--- + +## User Guide + +### For Admins + +**How to Pause Trading**: +1. Login as admin user +2. Navigate to **Admin** tab (🛡️ icon in header) +3. Scroll to **Trading Control** section +4. Click **"Pause Auto Trading"** button +5. Verify header badge shows "⏸️ Trading Paused" +6. Confirm status banner shows "AUTO-TRADING: PAUSED" + +**How to Resume Trading**: +1. Navigate to **Admin** tab +2. Click **"Resume Auto Trading"** button +3. Verify header badge shows "▶️ Trading Active" +4. Confirm status banner shows "AUTO-TRADING: RUNNING" + +**What Happens When Paused**: +- ❌ No new trade entries will be placed +- ✅ Existing positions continue to be managed +- ✅ Exit orders still execute +- ✅ Stop-loss monitoring continues +- ✅ Take-profit monitoring continues + +--- + +## Troubleshooting + +### Issue: Pause button not working + +**Check**: +1. User has `role = 'admin'` in profile +2. Valid JWT token in request (check Network tab) +3. Backend API is running +4. Browser console for errors + +### Issue: Status not updating in UI + +**Check**: +1. WebSocket connection active (header shows "Connected") +2. `health_update` events received (check browser console) +3. Refresh page to force sync +4. Check backend logs for broadcast + +### Issue: Error message displayed + +**Common Errors**: +- "Not authenticated" → Login again +- "Unauthorized" → User is not admin +- "Failed to fetch" → Backend API not running +- "HTTP 500" → Check backend logs + +--- + +## Files Modified + +1. ✅ `src/App.tsx` - Added header status badge +2. ✅ `src/tabs/AdminTab.tsx` - Added Trading Control Panel +3. ✅ `src/hooks/useWebSocket.ts` - Already had health_update handler + +--- + +## Related Documentation + +- **Backend Implementation**: See `bytelyst-trading-bot-service/docs/ADMIN_TRADE_CONTROL_IMPLEMENTATION.md` +- **State Persistence**: See `bytelyst-trading-bot-service/docs/TRADING_CONTROL_PERSISTENCE.md` +- **Test Plan**: See `bytelyst-trading-bot-service/docs/ADMIN_TRADE_CONTROL_TEST_PLAN.md` +- **Architecture**: See `bytelyst-trading-bot-service/docs/ADMIN_TRADE_CONTROL_ARCHITECTURE.md` diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..d8e1178 --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,34 @@ +# --- Stage 1: Build --- +FROM node:18-alpine AS builder + +WORKDIR /app + +# Install build dependencies +COPY package*.json ./ +RUN npm install + +# Copy source and build +# Copy source and build +COPY . . + +# Build-time environment variables +ARG VITE_SUPABASE_URL +ARG VITE_SUPABASE_ANON_KEY +ARG VITE_API_URL + +ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL +ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY +ENV VITE_API_URL=$VITE_API_URL + +RUN npm run build + +# --- Stage 2: Serve --- +FROM nginx:stable-alpine + +# Copy static assets from builder +COPY --from=builder /app/dist /usr/share/nginx/html + +# Expose port 80 +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/web/LIVE_PULSE_TICKER.md b/web/LIVE_PULSE_TICKER.md new file mode 100644 index 0000000..e7b8043 --- /dev/null +++ b/web/LIVE_PULSE_TICKER.md @@ -0,0 +1,79 @@ +# Live Pulse Ticker & Contextual Intelligence Panels + +> Added: 2026-02-20 + +## Overview + +Rather than showing the same market data widgets on every tab (which creates UI fatigue), the dashboard now uses a **contextual intelligence** architecture: + +- A **global Live Pulse Ticker** visible on all pages. +- **Tab-specific panels** that surface relevant data based on what the user is doing. + +--- + +## Global: Live Pulse Ticker + +**Component**: `src/components/LivePulseTicker.tsx` +**Location**: Persistent bar immediately below the sticky header (all tabs) +**Height**: 40px + +### Contents + +| Section | Description | +|:---|:---| +| **Market Pulse** (left) | Scrolling animated ticker of all tracked symbols with 24h % change (green = up, red = down) | +| **AI Top Picks** (right) | Top 3 symbols ranked by `AIAnalysisRule` confidence score | + +### Design Rationale +- Provides a "Bloomberg Terminal" feel — the app feels alive and real-time at all times. +- Non-intrusive 40px height — never competes with primary tab content. +- The AI section only appears if `AIAnalysisRule` data is available; otherwise shows "SCANNING...". + +--- + +## Marketplace Tab — Discovery Intelligence Panels + +**Component**: `src/tabs/MarketplaceTab.tsx` + +Two panels rendered above the strategy grid to help users find the right template: + +### 🧠 Best AI Setups Panel +- Shows top 5 symbols with the highest AI confidence scores. +- Includes a color-gradient progress bar (hue shifts from yellow → green as confidence rises). +- Intended message: *"These markets are high-confidence right now — find a strategy to match."* + +### 🔥 Top Volatile (24h) Panel +- Shows top 6 symbols sorted by absolute 24h % change. +- Displayed in a 2-column grid for compact scanning. +- Intended message: *"These markets are moving — match them with an aggressive or balanced strategy."* + +--- + +## My Strategies Tab — Operational Intelligence Panels + +**Component**: `src/tabs/MyStrategiesTab.tsx` + +Two panels rendered between the header and strategy cards: + +### Recent Activity Panel +- Shows the last 5 system `alerts` from `botState.alerts`. +- Each row: icon (🚀/⏰/⚠️/ℹ️) + symbol + message + relative time ("just now", "3m ago"). +- Intended message: *"Here's what just happened to your bots."* + +### Your Markets (24h) Panel +- Shows **only the symbols that the user's active strategies are currently trading**. +- Includes a mini bar chart showing the direction and magnitude of each move. +- Intended message: *"Here's how your specific markets are performing right now."* +- Personalized — empty state shows "Deploy a strategy to see its market data." + +--- + +## UX Design Rationale + +| Feature | Where | Why | +|:---|:---|:---| +| Live Pulse Ticker | All tabs | Keeps the app "alive" without cluttering content | +| 🧠 AI Setups | Marketplace only | Inspires strategy discovery | +| 🔥 Top Volatile | Marketplace only | Provides market context for template selection | +| Recent Activity | My Strategies only | Operational awareness without leaving the tab | +| Your Markets | My Strategies only | Personalized — only relevant symbols shown | diff --git a/web/NAVIGATION_ACCESS_CONTROL.md b/web/NAVIGATION_ACCESS_CONTROL.md new file mode 100644 index 0000000..cf6edb2 --- /dev/null +++ b/web/NAVIGATION_ACCESS_CONTROL.md @@ -0,0 +1,70 @@ +# Navigation Visibility & Tab Access Control + +> Added: 2026-02-20 + +## Design Philosophy + +The Bytelyst dashboard follows a **"Contextual Depth"** navigation model: + +- **Consumer-facing tabs** are simple, action-oriented, and confidence-building. +- **Technical/operational tabs** are restricted to admins — they contain raw signal data, rule pass/fail states, and engine internals that are misleading without deep engine knowledge. + +### Core Principle +> A rule failing (❌) is **normal and correct** — it's the filtering system working. But visually, ❌ icons look like errors. Customers without context interpret "fail" as "broken." This generates anxiety and unnecessary support tickets. + +--- + +## Tab Visibility Matrix + +| Tab | All Users | Admin Only | Rationale | +|:---|:---:|:---:|:---| +| Overview | ✅ | | High-level portfolio summary | +| My Strategies | ✅ | | Core user experience | +| ✨ Marketplace | ✅ | | Strategy discovery & adoption | +| 💎 Plans | ✅ | | Subscription conversion | +| 🛠️ Build Strategy | ✅ | | Guided self-service wizard | +| Trade History | ✅ | | User accountability & records | +| Positions & Orders | ✅ | | Current state awareness | +| Settings | ✅ | | Basic bot configuration | +| **Signals** | | ✅ | Raw technical rule/indicator data — misleading without engine knowledge | +| **Entries** | | ✅ | Internal watchlist/position engine state | +| **🛡️ Strategy Clusters** | | ✅ | Advanced multi-profile management | +| **⚙️ Admin Panel** | | ✅ | System operations & health | + +--- + +## Why Signals and Entries Are Admin-Only + +### Signals Tab +Shows raw per-symbol rule evaluation output: +- `TrendBiasRule`: EMA50 vs EMA200 comparison +- `SessionRule`: Active trading session detection +- `MomentumRule`: RSI state +- `AIAnalysisRule`: Confidence scores +- Rule pass/fail counts per profile + +**Consumer risk**: A customer sees "❌ TrendBiasRule Failed" and assumes the bot is broken, when in reality the bot is correctly waiting for a confirmed trend before entering. This contradiction between correct-behavior and perceived-failure creates support tickets and churn. + +### Entries Tab +Shows raw internal watchlist and position engine state — data structures that have no direct user-actionable interpretation without understanding the execution pipeline. + +--- + +## Consumer UX Strategy + +Instead of exposing raw signals, consumers receive intelligence through curated, translated layers: + +| Raw Technical Data | Consumer-Friendly Equivalent | +|:---|:---| +| `TrendBiasRule: ❌ No 4H trend` | "Bot is waiting for a confirmed market direction" | +| `SessionRule: ❌ TOK/SYD session` | "Bot is active only during London & New York hours" | +| `RSI: 57.73` | Not shown — irrelevant to consumer | +| `minRulePassRatio: 0.9` | "⚖️ Balanced Mode — requires 90% signal agreement" | + +This translation is handled by `src/lib/StrategyExplanationService.ts` and surfaced in the **Diagnostic Intelligence** section of each strategy card. + +--- + +## Implementation + +**File**: `src/App.tsx` — tab visibility is controlled by `profile?.role === 'admin'` guard on each sensitive tab button in the `