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