forexsmartbot 3.2.5__tar.gz → 3.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/PKG-INFO +1 -1
  2. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/__init__.py +1 -1
  3. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/cloud/__init__.py +10 -0
  4. forexsmartbot-3.3.1/forexsmartbot/cloud/companion_api.py +578 -0
  5. forexsmartbot-3.3.1/forexsmartbot/cloud/mobile_api.py +184 -0
  6. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/cloud/remote_monitor.py +346 -12
  7. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/monitoring/strategy_monitor.py +90 -29
  8. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot.egg-info/PKG-INFO +1 -1
  9. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot.egg-info/SOURCES.txt +2 -0
  10. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/pyproject.toml +1 -1
  11. forexsmartbot-3.3.1/tests/test_companion_q2.py +314 -0
  12. forexsmartbot-3.2.5/forexsmartbot/cloud/mobile_api.py +0 -213
  13. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/LICENSE +0 -0
  14. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/README.md +0 -0
  15. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/adapters/__init__.py +0 -0
  16. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/adapters/brokers/__init__.py +0 -0
  17. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/adapters/brokers/ib_tws_broker.py +0 -0
  18. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/adapters/brokers/mt4_broker.py +0 -0
  19. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/adapters/brokers/paper_broker.py +0 -0
  20. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/adapters/brokers/rest_broker.py +0 -0
  21. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/adapters/data/__init__.py +0 -0
  22. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/adapters/data/alpha_vantage_provider.py +0 -0
  23. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/adapters/data/config.py +0 -0
  24. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/adapters/data/csv_provider.py +0 -0
  25. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/adapters/data/dummy_provider.py +0 -0
  26. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/adapters/data/mt4_provider.py +0 -0
  27. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/adapters/data/multi_provider.py +0 -0
  28. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/adapters/data/oanda_provider.py +0 -0
  29. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/adapters/data/stooq_provider.py +0 -0
  30. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/adapters/data/twelve_data_provider.py +0 -0
  31. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/adapters/data/yfinance_provider.py +0 -0
  32. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/analytics/__init__.py +0 -0
  33. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/analytics/chart_patterns.py +0 -0
  34. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/analytics/correlation_matrix.py +0 -0
  35. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/analytics/economic_calendar.py +0 -0
  36. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/analytics/enhanced_chart.py +0 -0
  37. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/analytics/live_metrics.py +0 -0
  38. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/analytics/market_depth.py +0 -0
  39. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/analytics/performance_attribution.py +0 -0
  40. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/analytics/portfolio_analytics.py +0 -0
  41. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/analytics/risk_analytics.py +0 -0
  42. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/analytics/strategy_comparison.py +0 -0
  43. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/analytics/trade_journal.py +0 -0
  44. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/brokers/mt4_bridge.py +0 -0
  45. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/builder/__init__.py +0 -0
  46. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/builder/code_generator.py +0 -0
  47. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/builder/strategy_builder.py +0 -0
  48. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/builder/strategy_template.py +0 -0
  49. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/cloud/api_client.py +0 -0
  50. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/cloud/api_server.py +0 -0
  51. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/cloud/auth_utils.py +0 -0
  52. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/cloud/cloud_sync.py +0 -0
  53. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/cloud/error_handler.py +0 -0
  54. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/cloud/security.py +0 -0
  55. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/core/backtester.py +0 -0
  56. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/core/config.py +0 -0
  57. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/core/interfaces.py +0 -0
  58. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/core/paper_broker.py +0 -0
  59. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/core/portfolio.py +0 -0
  60. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/core/risk.py +0 -0
  61. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/core/risk_engine.py +0 -0
  62. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/core/strategy.py +0 -0
  63. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/core/trade_manager.py +0 -0
  64. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/gui/main_window.py +0 -0
  65. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/gui/theme.py +0 -0
  66. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/marketplace/__init__.py +0 -0
  67. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/marketplace/marketplace.py +0 -0
  68. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/marketplace/strategy_rating.py +0 -0
  69. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/monitoring/__init__.py +0 -0
  70. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/monitoring/health_check.py +0 -0
  71. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/monitoring/performance_tracker.py +0 -0
  72. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/optimization/__init__.py +0 -0
  73. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/optimization/adaptive_parameters.py +0 -0
  74. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/optimization/genetic_optimizer.py +0 -0
  75. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/optimization/hyperparameter_optimizer.py +0 -0
  76. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/optimization/monte_carlo.py +0 -0
  77. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/optimization/multi_objective_optimizer.py +0 -0
  78. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/optimization/parameter_sensitivity.py +0 -0
  79. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/optimization/walk_forward.py +0 -0
  80. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/services/__init__.py +0 -0
  81. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/services/backtest.py +0 -0
  82. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/services/calculation_engine.py +0 -0
  83. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/services/controller.py +0 -0
  84. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/services/data_streaming.py +0 -0
  85. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/services/enhanced_backtest.py +0 -0
  86. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/services/fast_trading_controller.py +0 -0
  87. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/services/gpu_backtest.py +0 -0
  88. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/services/language_manager.py +0 -0
  89. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/services/notification_service.py +0 -0
  90. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/services/persistence.py +0 -0
  91. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/services/startup_manager.py +0 -0
  92. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/strategies/__init__.py +0 -0
  93. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/strategies/adaptive_trend_flow.py +0 -0
  94. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/strategies/breakout_atr.py +0 -0
  95. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/strategies/ensemble_ml_strategy.py +0 -0
  96. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/strategies/fear_index.py +0 -0
  97. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/strategies/lstm_strategy.py +0 -0
  98. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/strategies/mean_reversion.py +0 -0
  99. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/strategies/ml_adaptive_supertrend.py +0 -0
  100. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/strategies/momentum_breakout.py +0 -0
  101. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/strategies/multi_timeframe_strategy.py +0 -0
  102. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/strategies/news_trading.py +0 -0
  103. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/strategies/rl_strategy.py +0 -0
  104. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/strategies/rsi_reversion.py +0 -0
  105. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/strategies/scalping_ma.py +0 -0
  106. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/strategies/sma_crossover.py +0 -0
  107. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/strategies/svm_strategy.py +0 -0
  108. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/strategies/transformer_strategy.py +0 -0
  109. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/testing/__init__.py +0 -0
  110. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/testing/ci_cd_pipeline.py +0 -0
  111. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/testing/paper_account_tester.py +0 -0
  112. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/testing/strategy_sandbox.py +0 -0
  113. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/ui/__init__.py +0 -0
  114. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/ui/analytics_dialog.py +0 -0
  115. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/ui/analytics_widget.py +0 -0
  116. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/ui/backtest_dialog.py +0 -0
  117. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/ui/charts.py +0 -0
  118. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/ui/cloud_dialog.py +0 -0
  119. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/ui/enhanced_charts.py +0 -0
  120. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/ui/enhanced_main_window.py +0 -0
  121. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/ui/export_dialog.py +0 -0
  122. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/ui/main_window.py +0 -0
  123. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/ui/marketplace_dialog.py +0 -0
  124. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/ui/monitoring_dialog.py +0 -0
  125. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/ui/monitoring_widget.py +0 -0
  126. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/ui/optimization_dialog.py +0 -0
  127. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/ui/settings_dialog.py +0 -0
  128. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/ui/simple_chart.py +0 -0
  129. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/ui/strategy_builder_dialog.py +0 -0
  130. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/ui/theme.py +0 -0
  131. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/ui/tradingview_chart.py +0 -0
  132. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/utils/__init__.py +0 -0
  133. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/utils/backtest_utils.py +0 -0
  134. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/utils/gpu_numpy.py +0 -0
  135. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/utils/gpu_utils.py +0 -0
  136. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot/utils/native_indicators.py +0 -0
  137. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot.egg-info/dependency_links.txt +0 -0
  138. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot.egg-info/entry_points.txt +0 -0
  139. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot.egg-info/requires.txt +0 -0
  140. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/forexsmartbot.egg-info/top_level.txt +0 -0
  141. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/setup.cfg +0 -0
  142. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/tests/test_backtest_dialog_ui.py +0 -0
  143. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/tests/test_cloud_hardening.py +0 -0
  144. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/tests/test_config.py +0 -0
  145. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/tests/test_live_metrics.py +0 -0
  146. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/tests/test_multi_timeframe_strategy.py +0 -0
  147. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/tests/test_native_indicators.py +0 -0
  148. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/tests/test_risk.py +0 -0
  149. {forexsmartbot-3.2.5 → forexsmartbot-3.3.1}/tests/test_strategies.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forexsmartbot
3
- Version: 3.2.5
3
+ Version: 3.3.1
4
4
  Summary: Professional forex trading bot with advanced risk management and strategy plugins
5
5
  Author-email: VoxHash <contact@voxhash.dev>
6
6
  License: MIT
@@ -1,2 +1,2 @@
1
1
  # ForexSmartBot Package
2
- __version__ = "3.2.5"
2
+ __version__ = "3.3.1"
@@ -7,6 +7,12 @@ from .cloud_sync import CloudSyncManager
7
7
  from .remote_monitor import RemoteMonitorServer
8
8
  from .api_server import APIServer, WebSocketServer
9
9
  from .mobile_api import MobileAppAPI, PushNotificationService
10
+ from .companion_api import (
11
+ CompanionAPI,
12
+ CompanionActionQueue,
13
+ CompanionPairingStore,
14
+ WebhookNotifier,
15
+ )
10
16
  from .security import SecurityManager, RateLimiter
11
17
  from .api_client import APIClient, WebSocketClient
12
18
  from .error_handler import CloudErrorHandler, CloudHealthMonitor, retry_call
@@ -19,6 +25,10 @@ __all__ = [
19
25
  'WebSocketServer',
20
26
  'MobileAppAPI',
21
27
  'PushNotificationService',
28
+ 'CompanionAPI',
29
+ 'CompanionActionQueue',
30
+ 'CompanionPairingStore',
31
+ 'WebhookNotifier',
22
32
  'SecurityManager',
23
33
  'RateLimiter',
24
34
  'APIClient',
@@ -0,0 +1,578 @@
1
+ """
2
+ Read-only companion API for mobile / web monitoring clients.
3
+
4
+ Designed for GrapheneOS-friendly clients: Bearer / X-API-Key auth, local-first
5
+ polling, optional HTTP webhook alerts (no Google Play Services / FCM required).
6
+ Broker keys and order routing stay on the desktop host.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import secrets
14
+ import threading
15
+ import uuid
16
+ from datetime import datetime, timedelta
17
+ from typing import Any, Callable, Dict, List, Optional
18
+ from urllib.parse import urlparse
19
+
20
+ import requests
21
+ from flask import Flask, jsonify, request
22
+
23
+ from .auth_utils import (
24
+ constant_time_equals,
25
+ extract_bearer_or_api_key,
26
+ is_insecure_secret,
27
+ package_version,
28
+ resolve_api_key,
29
+ )
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ PortfolioProvider = Callable[[], Dict[str, Any]]
34
+ AlertsProvider = Callable[[], List[Dict[str, Any]]]
35
+ StrategiesProvider = Callable[[], Dict[str, Any]]
36
+ HistoryProvider = Callable[[Optional[str], int], Dict[str, Any]]
37
+
38
+ PAIRING_SCHEME = "fsb-companion"
39
+ PAIRING_TTL_SECONDS = 300
40
+ ALLOWED_ACTIONS = frozenset({"start_trading", "stop_trading", "pause_trading"})
41
+
42
+
43
+ class CompanionActionQueue:
44
+ """Pending high-level companion actions awaiting host approve/reject."""
45
+
46
+ def __init__(self, *, max_pending: int = 64):
47
+ self.max_pending = max_pending
48
+ self._lock = threading.Lock()
49
+ self._items: Dict[str, Dict[str, Any]] = {}
50
+
51
+ def request(self, action: str, *, requested_by: str = "companion") -> Optional[Dict[str, Any]]:
52
+ action = (action or "").strip()
53
+ if action not in ALLOWED_ACTIONS:
54
+ return None
55
+ with self._lock:
56
+ pending = [i for i in self._items.values() if i["status"] == "pending"]
57
+ if len(pending) >= self.max_pending:
58
+ return None
59
+ item_id = uuid.uuid4().hex
60
+ item = {
61
+ "id": item_id,
62
+ "action": action,
63
+ "status": "pending",
64
+ "requested_by": requested_by,
65
+ "created_at": datetime.now().isoformat(),
66
+ "resolved_at": None,
67
+ "reason": None,
68
+ }
69
+ self._items[item_id] = item
70
+ return dict(item)
71
+
72
+ def list_pending(self) -> List[Dict[str, Any]]:
73
+ with self._lock:
74
+ return [dict(i) for i in self._items.values() if i["status"] == "pending"]
75
+
76
+ def get(self, action_id: str) -> Optional[Dict[str, Any]]:
77
+ with self._lock:
78
+ item = self._items.get(action_id)
79
+ return dict(item) if item else None
80
+
81
+ def resolve(
82
+ self, action_id: str, *, approve: bool, reason: Optional[str] = None
83
+ ) -> Optional[Dict[str, Any]]:
84
+ with self._lock:
85
+ item = self._items.get(action_id)
86
+ if not item or item["status"] != "pending":
87
+ return None
88
+ item["status"] = "approved" if approve else "rejected"
89
+ item["resolved_at"] = datetime.now().isoformat()
90
+ item["reason"] = reason
91
+ return dict(item)
92
+
93
+
94
+ class CompanionPairingStore:
95
+ """Short-lived pairing sessions for QR onboarding (phone redeems without prior key)."""
96
+
97
+ def __init__(self, *, ttl_seconds: int = PAIRING_TTL_SECONDS):
98
+ self.ttl_seconds = ttl_seconds
99
+ self._lock = threading.Lock()
100
+ self._sessions: Dict[str, Dict[str, Any]] = {}
101
+
102
+ def create(
103
+ self,
104
+ *,
105
+ base_url: str,
106
+ api_key: str,
107
+ pin_sha256: Optional[str] = None,
108
+ ) -> Dict[str, Any]:
109
+ code = secrets.token_urlsafe(12)
110
+ expires = datetime.now() + timedelta(seconds=self.ttl_seconds)
111
+ payload = {
112
+ "v": 1,
113
+ "scheme": PAIRING_SCHEME,
114
+ "base_url": base_url.rstrip("/"),
115
+ "pairing_code": code,
116
+ "expires_at": expires.isoformat(),
117
+ "pin_sha256": pin_sha256 or "",
118
+ }
119
+ with self._lock:
120
+ self._sessions[code] = {
121
+ "api_key": api_key,
122
+ "base_url": base_url.rstrip("/"),
123
+ "pin_sha256": pin_sha256 or "",
124
+ "expires_at": expires,
125
+ "redeemed": False,
126
+ }
127
+ # Drop expired
128
+ now = datetime.now()
129
+ dead = [k for k, v in self._sessions.items() if v["expires_at"] < now]
130
+ for k in dead:
131
+ del self._sessions[k]
132
+ return {
133
+ "pairing_code": code,
134
+ "expires_at": expires.isoformat(),
135
+ "ttl_seconds": self.ttl_seconds,
136
+ "qr_payload": payload,
137
+ "qr_text": json.dumps(payload, separators=(",", ":")),
138
+ }
139
+
140
+ def redeem(self, pairing_code: str) -> Optional[Dict[str, Any]]:
141
+ code = (pairing_code or "").strip()
142
+ with self._lock:
143
+ session = self._sessions.get(code)
144
+ if not session:
145
+ return None
146
+ if session["expires_at"] < datetime.now() or session["redeemed"]:
147
+ self._sessions.pop(code, None)
148
+ return None
149
+ session["redeemed"] = True
150
+ return {
151
+ "scheme": PAIRING_SCHEME,
152
+ "base_url": session["base_url"],
153
+ "api_key": session["api_key"],
154
+ "pin_sha256": session["pin_sha256"],
155
+ "redeemed_at": datetime.now().isoformat(),
156
+ }
157
+
158
+
159
+ def _normalize_portfolio(raw: Dict[str, Any]) -> Dict[str, Any]:
160
+ """Normalize host trading snapshot for companion clients."""
161
+ positions = raw.get("positions") or []
162
+ if isinstance(positions, dict):
163
+ positions = list(positions.values())
164
+ trades = raw.get("trades") or []
165
+ return {
166
+ "balance": float(raw.get("balance") or 0.0),
167
+ "equity": float(raw.get("equity") or raw.get("balance") or 0.0),
168
+ "status": str(raw.get("status") or "disconnected"),
169
+ "positions_count": len(positions),
170
+ "trades_count": len(trades),
171
+ "positions": positions,
172
+ "last_update": raw.get("last_update"),
173
+ "timestamp": datetime.now().isoformat(),
174
+ "read_only": True,
175
+ }
176
+
177
+
178
+ class WebhookNotifier:
179
+ """
180
+ Deliver alert payloads to registered HTTPS (or localhost HTTP) webhook URLs.
181
+
182
+ Prefer this over FCM on hardened Android / GrapheneOS builds.
183
+ """
184
+
185
+ def __init__(self, *, timeout: float = 5.0, max_endpoints: int = 32):
186
+ self.timeout = timeout
187
+ self.max_endpoints = max_endpoints
188
+ self._endpoints: List[str] = []
189
+ self._lock = threading.Lock()
190
+
191
+ @staticmethod
192
+ def _validate_url(url: str) -> Optional[str]:
193
+ cleaned = (url or "").strip()
194
+ if not cleaned or len(cleaned) > 2048:
195
+ return None
196
+ parsed = urlparse(cleaned)
197
+ if parsed.scheme not in {"https", "http"}:
198
+ return None
199
+ if parsed.scheme == "http":
200
+ host = (parsed.hostname or "").lower()
201
+ if host not in {"127.0.0.1", "localhost", "::1"}:
202
+ return None
203
+ if not parsed.netloc:
204
+ return None
205
+ return cleaned
206
+
207
+ def register(self, url: str) -> bool:
208
+ """Register a webhook endpoint. Returns False if invalid or at capacity."""
209
+ cleaned = self._validate_url(url)
210
+ if not cleaned:
211
+ return False
212
+ with self._lock:
213
+ if cleaned in self._endpoints:
214
+ return True
215
+ if len(self._endpoints) >= self.max_endpoints:
216
+ return False
217
+ self._endpoints.append(cleaned)
218
+ return True
219
+
220
+ def unregister(self, url: str) -> bool:
221
+ cleaned = (url or "").strip()
222
+ with self._lock:
223
+ if cleaned in self._endpoints:
224
+ self._endpoints.remove(cleaned)
225
+ return True
226
+ return False
227
+
228
+ def list_endpoints(self) -> List[str]:
229
+ with self._lock:
230
+ return list(self._endpoints)
231
+
232
+ def notify(self, title: str, body: str, data: Optional[Dict[str, Any]] = None) -> int:
233
+ """POST JSON alert to all endpoints. Returns success count."""
234
+ payload = {
235
+ "title": title,
236
+ "body": body,
237
+ "data": data or {},
238
+ "timestamp": datetime.now().isoformat(),
239
+ "source": "forexsmartbot.companion",
240
+ }
241
+ ok = 0
242
+ for url in self.list_endpoints():
243
+ try:
244
+ resp = requests.post(url, json=payload, timeout=self.timeout)
245
+ if 200 <= resp.status_code < 300:
246
+ ok += 1
247
+ else:
248
+ logger.warning("Webhook %s returned %s", url, resp.status_code)
249
+ except Exception as exc:
250
+ logger.warning("Webhook notify failed for %s: %s", url, exc)
251
+ return ok
252
+
253
+
254
+ class CompanionAPI:
255
+ """
256
+ Read-only companion REST surface for phones and external dashboards.
257
+
258
+ Mounts under ``/api/v1/companion/*`` on an existing or new Flask app.
259
+ """
260
+
261
+ def __init__(
262
+ self,
263
+ app: Optional[Flask] = None,
264
+ *,
265
+ api_key: Optional[str] = None,
266
+ require_api_key: Optional[bool] = None,
267
+ portfolio_provider: Optional[PortfolioProvider] = None,
268
+ alerts_provider: Optional[AlertsProvider] = None,
269
+ strategies_provider: Optional[StrategiesProvider] = None,
270
+ history_provider: Optional[HistoryProvider] = None,
271
+ allow_control: bool = False,
272
+ public_base_url: Optional[str] = None,
273
+ pin_sha256: Optional[str] = None,
274
+ url_prefix: str = "/api/v1/companion",
275
+ ):
276
+ self.app = app or Flask(__name__)
277
+ self.api_key = resolve_api_key(
278
+ api_key, env_names=("API_KEY", "REMOTE_MONITOR_API_KEY", "COMPANION_API_KEY")
279
+ )
280
+ if require_api_key is None:
281
+ require_api_key = not is_insecure_secret(self.api_key)
282
+ self.require_api_key = require_api_key
283
+ self.allow_control = allow_control
284
+ self.public_base_url = (public_base_url or "").rstrip("/")
285
+ self.pin_sha256 = (pin_sha256 or "").strip()
286
+ self.url_prefix = url_prefix.rstrip("/") or "/api/v1/companion"
287
+
288
+ self._portfolio_provider = portfolio_provider or (lambda: {})
289
+ self._alerts_provider = alerts_provider or (lambda: [])
290
+ self._strategies_provider = strategies_provider or (lambda: {"strategies": {}})
291
+ self._history_provider = history_provider
292
+ self.webhook_notifier = WebhookNotifier()
293
+ self.control_callbacks: Dict[str, Callable[[], bool]] = {}
294
+ self.action_queue = CompanionActionQueue()
295
+ self.pairing_store = CompanionPairingStore()
296
+
297
+ self.setup_routes()
298
+
299
+ def set_portfolio_provider(self, provider: PortfolioProvider) -> None:
300
+ self._portfolio_provider = provider
301
+
302
+ def set_alerts_provider(self, provider: AlertsProvider) -> None:
303
+ self._alerts_provider = provider
304
+
305
+ def set_strategies_provider(self, provider: StrategiesProvider) -> None:
306
+ self._strategies_provider = provider
307
+
308
+ def set_history_provider(self, provider: HistoryProvider) -> None:
309
+ self._history_provider = provider
310
+
311
+ def set_public_base_url(self, base_url: str) -> None:
312
+ self.public_base_url = (base_url or "").rstrip("/")
313
+
314
+ def register_control_callback(self, action: str, callback: Callable[[], bool]) -> None:
315
+ """Register optional start/stop callbacks (used on approve or allow_control)."""
316
+ self.control_callbacks[action] = callback
317
+
318
+ def _auth_response(self):
319
+ if not self._check_auth():
320
+ return jsonify({"error": "Unauthorized"}), 401
321
+ return None
322
+
323
+ def _check_auth(self) -> bool:
324
+ if not self.require_api_key:
325
+ return True
326
+ if is_insecure_secret(self.api_key):
327
+ return False
328
+ token = extract_bearer_or_api_key(
329
+ request.headers.get("Authorization", ""),
330
+ request.headers.get("X-API-Key", ""),
331
+ )
332
+ return constant_time_equals(token, self.api_key)
333
+
334
+ def setup_routes(self) -> None:
335
+ prefix = self.url_prefix
336
+
337
+ @self.app.route(f"{prefix}/health", methods=["GET"])
338
+ def companion_health():
339
+ """Public companion health (no trading secrets)."""
340
+ return jsonify(
341
+ {
342
+ "status": "healthy",
343
+ "service": "companion",
344
+ "version": package_version(),
345
+ "auth_required": self.require_api_key,
346
+ "read_only": not self.allow_control,
347
+ "features": {
348
+ "portfolio": True,
349
+ "alerts": True,
350
+ "strategies": True,
351
+ "history": self._history_provider is not None,
352
+ "webhooks": True,
353
+ "pairing": True,
354
+ "actions": True,
355
+ "control": self.allow_control,
356
+ },
357
+ "timestamp": datetime.now().isoformat(),
358
+ }
359
+ )
360
+
361
+ @self.app.route(f"{prefix}/portfolio", methods=["GET"])
362
+ def companion_portfolio():
363
+ denied = self._auth_response()
364
+ if denied:
365
+ return denied
366
+ try:
367
+ return jsonify(_normalize_portfolio(self._portfolio_provider() or {}))
368
+ except Exception as exc:
369
+ logger.exception("Companion portfolio failed")
370
+ return jsonify({"error": str(exc)}), 500
371
+
372
+ @self.app.route(f"{prefix}/positions", methods=["GET"])
373
+ def companion_positions():
374
+ denied = self._auth_response()
375
+ if denied:
376
+ return denied
377
+ try:
378
+ snapshot = _normalize_portfolio(self._portfolio_provider() or {})
379
+ return jsonify(
380
+ {
381
+ "positions": snapshot["positions"],
382
+ "count": snapshot["positions_count"],
383
+ "timestamp": snapshot["timestamp"],
384
+ "read_only": True,
385
+ }
386
+ )
387
+ except Exception as exc:
388
+ return jsonify({"error": str(exc)}), 500
389
+
390
+ @self.app.route(f"{prefix}/alerts", methods=["GET"])
391
+ def companion_alerts():
392
+ denied = self._auth_response()
393
+ if denied:
394
+ return denied
395
+ try:
396
+ alerts = self._alerts_provider() or []
397
+ return jsonify({"alerts": list(alerts), "count": len(alerts)})
398
+ except Exception as exc:
399
+ return jsonify({"error": str(exc)}), 500
400
+
401
+ @self.app.route(f"{prefix}/strategies", methods=["GET"])
402
+ def companion_strategies():
403
+ denied = self._auth_response()
404
+ if denied:
405
+ return denied
406
+ try:
407
+ payload = self._strategies_provider() or {}
408
+ if "strategies" not in payload:
409
+ payload = {"strategies": payload}
410
+ payload["timestamp"] = datetime.now().isoformat()
411
+ return jsonify(payload)
412
+ except Exception as exc:
413
+ return jsonify({"error": str(exc)}), 500
414
+
415
+ @self.app.route(f"{prefix}/strategies/history", methods=["GET"])
416
+ def companion_strategies_history():
417
+ denied = self._auth_response()
418
+ if denied:
419
+ return denied
420
+ if not self._history_provider:
421
+ return jsonify({"error": "History not available"}), 404
422
+ try:
423
+ limit = int(request.args.get("limit", 100))
424
+ return jsonify(self._history_provider(None, limit))
425
+ except Exception as exc:
426
+ return jsonify({"error": str(exc)}), 500
427
+
428
+ @self.app.route(f"{prefix}/strategies/<strategy_name>/history", methods=["GET"])
429
+ def companion_strategy_history(strategy_name: str):
430
+ denied = self._auth_response()
431
+ if denied:
432
+ return denied
433
+ if not self._history_provider:
434
+ return jsonify({"error": "History not available"}), 404
435
+ try:
436
+ limit = int(request.args.get("limit", 100))
437
+ return jsonify(self._history_provider(strategy_name, limit))
438
+ except Exception as exc:
439
+ return jsonify({"error": str(exc)}), 500
440
+
441
+ @self.app.route(f"{prefix}/devices/webhook", methods=["POST"])
442
+ def register_webhook():
443
+ denied = self._auth_response()
444
+ if denied:
445
+ return denied
446
+ data = request.get_json(silent=True) or {}
447
+ url = str(data.get("url") or data.get("webhook_url") or "").strip()
448
+ if not self.webhook_notifier.register(url):
449
+ return jsonify(
450
+ {
451
+ "error": "Invalid webhook URL. Use https://… or http://127.0.0.1/…",
452
+ }
453
+ ), 400
454
+ return jsonify(
455
+ {
456
+ "success": True,
457
+ "endpoints": self.webhook_notifier.list_endpoints(),
458
+ }
459
+ ), 201
460
+
461
+ @self.app.route(f"{prefix}/devices/webhook", methods=["DELETE"])
462
+ def unregister_webhook():
463
+ denied = self._auth_response()
464
+ if denied:
465
+ return denied
466
+ data = request.get_json(silent=True) or {}
467
+ url = str(data.get("url") or data.get("webhook_url") or "").strip()
468
+ removed = self.webhook_notifier.unregister(url)
469
+ return jsonify({"success": removed})
470
+
471
+ @self.app.route(f"{prefix}/pairing/session", methods=["POST"])
472
+ def pairing_session():
473
+ """Create QR pairing payload (authenticated host)."""
474
+ denied = self._auth_response()
475
+ if denied:
476
+ return denied
477
+ data = request.get_json(silent=True) or {}
478
+ base = str(
479
+ data.get("base_url")
480
+ or self.public_base_url
481
+ or request.host_url.rstrip("/")
482
+ ).rstrip("/")
483
+ pin = str(data.get("pin_sha256") or self.pin_sha256 or "").strip()
484
+ session = self.pairing_store.create(
485
+ base_url=base, api_key=self.api_key, pin_sha256=pin or None
486
+ )
487
+ return jsonify(session), 201
488
+
489
+ @self.app.route(f"{prefix}/pairing/redeem", methods=["POST"])
490
+ def pairing_redeem():
491
+ """Phone redeems short-lived pairing code (no prior API key)."""
492
+ data = request.get_json(silent=True) or {}
493
+ code = str(data.get("pairing_code") or data.get("code") or "").strip()
494
+ result = self.pairing_store.redeem(code)
495
+ if not result:
496
+ return jsonify({"error": "Invalid or expired pairing code"}), 400
497
+ return jsonify(result)
498
+
499
+ @self.app.route(f"{prefix}/actions", methods=["GET"])
500
+ def list_actions():
501
+ denied = self._auth_response()
502
+ if denied:
503
+ return denied
504
+ pending = self.action_queue.list_pending()
505
+ return jsonify({"pending": pending, "count": len(pending)})
506
+
507
+ @self.app.route(f"{prefix}/actions", methods=["POST"])
508
+ def request_action():
509
+ """Phone requests a high-level action (queued until host approves)."""
510
+ denied = self._auth_response()
511
+ if denied:
512
+ return denied
513
+ data = request.get_json(silent=True) or {}
514
+ action = str(data.get("action") or "").strip()
515
+ requested_by = str(data.get("requested_by") or "companion").strip() or "companion"
516
+ item = self.action_queue.request(action, requested_by=requested_by)
517
+ if not item:
518
+ return jsonify(
519
+ {
520
+ "error": "Invalid action or queue full",
521
+ "allowed": sorted(ALLOWED_ACTIONS),
522
+ }
523
+ ), 400
524
+ return jsonify({"action": item}), 201
525
+
526
+ @self.app.route(f"{prefix}/actions/<action_id>/approve", methods=["POST"])
527
+ def approve_action(action_id: str):
528
+ denied = self._auth_response()
529
+ if denied:
530
+ return denied
531
+ item = self.action_queue.resolve(action_id, approve=True)
532
+ if not item:
533
+ return jsonify({"error": "Action not found or already resolved"}), 404
534
+ cb = self.control_callbacks.get(item["action"])
535
+ executed = False
536
+ if cb:
537
+ try:
538
+ executed = bool(cb())
539
+ except Exception as exc:
540
+ logger.exception("Action callback failed")
541
+ return jsonify(
542
+ {"action": item, "executed": False, "error": str(exc)}
543
+ ), 500
544
+ return jsonify({"action": item, "executed": executed})
545
+
546
+ @self.app.route(f"{prefix}/actions/<action_id>/reject", methods=["POST"])
547
+ def reject_action(action_id: str):
548
+ denied = self._auth_response()
549
+ if denied:
550
+ return denied
551
+ data = request.get_json(silent=True) or {}
552
+ reason = str(data.get("reason") or "").strip() or None
553
+ item = self.action_queue.resolve(action_id, approve=False, reason=reason)
554
+ if not item:
555
+ return jsonify({"error": "Action not found or already resolved"}), 404
556
+ return jsonify({"action": item})
557
+
558
+ if self.allow_control:
559
+
560
+ @self.app.route(f"{prefix}/control/start", methods=["POST"])
561
+ def companion_start():
562
+ denied = self._auth_response()
563
+ if denied:
564
+ return denied
565
+ cb = self.control_callbacks.get("start_trading")
566
+ if not cb:
567
+ return jsonify({"error": "Control not available"}), 400
568
+ return jsonify({"success": bool(cb())})
569
+
570
+ @self.app.route(f"{prefix}/control/stop", methods=["POST"])
571
+ def companion_stop():
572
+ denied = self._auth_response()
573
+ if denied:
574
+ return denied
575
+ cb = self.control_callbacks.get("stop_trading")
576
+ if not cb:
577
+ return jsonify({"error": "Control not available"}), 400
578
+ return jsonify({"success": bool(cb())})