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.
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/PKG-INFO +1 -1
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/__init__.py +1 -1
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/cloud/__init__.py +12 -1
- forexsmartbot-3.3.2/forexsmartbot/cloud/companion_api.py +606 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/cloud/remote_monitor.py +316 -12
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/monitoring/strategy_monitor.py +53 -16
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot.egg-info/PKG-INFO +1 -1
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/pyproject.toml +1 -1
- forexsmartbot-3.3.2/tests/test_companion_q2.py +362 -0
- forexsmartbot-3.3.0/forexsmartbot/cloud/companion_api.py +0 -324
- forexsmartbot-3.3.0/tests/test_companion_q2.py +0 -190
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/LICENSE +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/README.md +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/adapters/__init__.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/adapters/brokers/__init__.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/adapters/brokers/ib_tws_broker.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/adapters/brokers/mt4_broker.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/adapters/brokers/paper_broker.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/adapters/brokers/rest_broker.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/adapters/data/__init__.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/adapters/data/alpha_vantage_provider.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/adapters/data/config.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/adapters/data/csv_provider.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/adapters/data/dummy_provider.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/adapters/data/mt4_provider.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/adapters/data/multi_provider.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/adapters/data/oanda_provider.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/adapters/data/stooq_provider.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/adapters/data/twelve_data_provider.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/adapters/data/yfinance_provider.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/analytics/__init__.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/analytics/chart_patterns.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/analytics/correlation_matrix.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/analytics/economic_calendar.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/analytics/enhanced_chart.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/analytics/live_metrics.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/analytics/market_depth.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/analytics/performance_attribution.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/analytics/portfolio_analytics.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/analytics/risk_analytics.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/analytics/strategy_comparison.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/analytics/trade_journal.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/brokers/mt4_bridge.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/builder/__init__.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/builder/code_generator.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/builder/strategy_builder.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/builder/strategy_template.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/cloud/api_client.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/cloud/api_server.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/cloud/auth_utils.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/cloud/cloud_sync.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/cloud/error_handler.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/cloud/mobile_api.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/cloud/security.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/core/backtester.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/core/config.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/core/interfaces.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/core/paper_broker.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/core/portfolio.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/core/risk.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/core/risk_engine.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/core/strategy.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/core/trade_manager.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/gui/main_window.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/gui/theme.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/marketplace/__init__.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/marketplace/marketplace.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/marketplace/strategy_rating.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/monitoring/__init__.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/monitoring/health_check.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/monitoring/performance_tracker.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/optimization/__init__.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/optimization/adaptive_parameters.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/optimization/genetic_optimizer.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/optimization/hyperparameter_optimizer.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/optimization/monte_carlo.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/optimization/multi_objective_optimizer.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/optimization/parameter_sensitivity.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/optimization/walk_forward.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/services/__init__.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/services/backtest.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/services/calculation_engine.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/services/controller.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/services/data_streaming.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/services/enhanced_backtest.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/services/fast_trading_controller.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/services/gpu_backtest.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/services/language_manager.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/services/notification_service.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/services/persistence.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/services/startup_manager.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/strategies/__init__.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/strategies/adaptive_trend_flow.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/strategies/breakout_atr.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/strategies/ensemble_ml_strategy.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/strategies/fear_index.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/strategies/lstm_strategy.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/strategies/mean_reversion.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/strategies/ml_adaptive_supertrend.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/strategies/momentum_breakout.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/strategies/multi_timeframe_strategy.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/strategies/news_trading.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/strategies/rl_strategy.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/strategies/rsi_reversion.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/strategies/scalping_ma.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/strategies/sma_crossover.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/strategies/svm_strategy.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/strategies/transformer_strategy.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/testing/__init__.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/testing/ci_cd_pipeline.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/testing/paper_account_tester.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/testing/strategy_sandbox.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/ui/__init__.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/ui/analytics_dialog.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/ui/analytics_widget.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/ui/backtest_dialog.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/ui/charts.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/ui/cloud_dialog.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/ui/enhanced_charts.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/ui/enhanced_main_window.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/ui/export_dialog.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/ui/main_window.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/ui/marketplace_dialog.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/ui/monitoring_dialog.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/ui/monitoring_widget.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/ui/optimization_dialog.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/ui/settings_dialog.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/ui/simple_chart.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/ui/strategy_builder_dialog.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/ui/theme.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/ui/tradingview_chart.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/utils/__init__.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/utils/backtest_utils.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/utils/gpu_numpy.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/utils/gpu_utils.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot/utils/native_indicators.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot.egg-info/SOURCES.txt +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot.egg-info/dependency_links.txt +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot.egg-info/entry_points.txt +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot.egg-info/requires.txt +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/forexsmartbot.egg-info/top_level.txt +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/setup.cfg +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/tests/test_backtest_dialog_ui.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/tests/test_cloud_hardening.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/tests/test_config.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/tests/test_live_metrics.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/tests/test_multi_timeframe_strategy.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/tests/test_native_indicators.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/tests/test_risk.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.2}/tests/test_strategies.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
# ForexSmartBot Package
|
|
2
|
-
__version__ = "3.3.
|
|
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
|
|
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())})
|