forexsmartbot 3.3.0__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.
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/PKG-INFO +1 -1
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/__init__.py +1 -1
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/cloud/__init__.py +8 -1
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/cloud/companion_api.py +256 -2
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/cloud/remote_monitor.py +209 -12
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/monitoring/strategy_monitor.py +53 -16
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot.egg-info/PKG-INFO +1 -1
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/pyproject.toml +1 -1
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/tests/test_companion_q2.py +124 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/LICENSE +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/README.md +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/adapters/__init__.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/adapters/brokers/__init__.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/adapters/brokers/ib_tws_broker.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/adapters/brokers/mt4_broker.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/adapters/brokers/paper_broker.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/adapters/brokers/rest_broker.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/adapters/data/__init__.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/adapters/data/alpha_vantage_provider.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/adapters/data/config.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/adapters/data/csv_provider.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/adapters/data/dummy_provider.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/adapters/data/mt4_provider.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/adapters/data/multi_provider.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/adapters/data/oanda_provider.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/adapters/data/stooq_provider.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/adapters/data/twelve_data_provider.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/adapters/data/yfinance_provider.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/analytics/__init__.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/analytics/chart_patterns.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/analytics/correlation_matrix.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/analytics/economic_calendar.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/analytics/enhanced_chart.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/analytics/live_metrics.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/analytics/market_depth.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/analytics/performance_attribution.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/analytics/portfolio_analytics.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/analytics/risk_analytics.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/analytics/strategy_comparison.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/analytics/trade_journal.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/brokers/mt4_bridge.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/builder/__init__.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/builder/code_generator.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/builder/strategy_builder.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/builder/strategy_template.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/cloud/api_client.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/cloud/api_server.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/cloud/auth_utils.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/cloud/cloud_sync.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/cloud/error_handler.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/cloud/mobile_api.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/cloud/security.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/core/backtester.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/core/config.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/core/interfaces.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/core/paper_broker.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/core/portfolio.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/core/risk.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/core/risk_engine.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/core/strategy.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/core/trade_manager.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/gui/main_window.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/gui/theme.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/marketplace/__init__.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/marketplace/marketplace.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/marketplace/strategy_rating.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/monitoring/__init__.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/monitoring/health_check.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/monitoring/performance_tracker.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/optimization/__init__.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/optimization/adaptive_parameters.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/optimization/genetic_optimizer.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/optimization/hyperparameter_optimizer.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/optimization/monte_carlo.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/optimization/multi_objective_optimizer.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/optimization/parameter_sensitivity.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/optimization/walk_forward.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/services/__init__.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/services/backtest.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/services/calculation_engine.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/services/controller.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/services/data_streaming.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/services/enhanced_backtest.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/services/fast_trading_controller.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/services/gpu_backtest.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/services/language_manager.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/services/notification_service.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/services/persistence.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/services/startup_manager.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/strategies/__init__.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/strategies/adaptive_trend_flow.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/strategies/breakout_atr.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/strategies/ensemble_ml_strategy.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/strategies/fear_index.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/strategies/lstm_strategy.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/strategies/mean_reversion.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/strategies/ml_adaptive_supertrend.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/strategies/momentum_breakout.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/strategies/multi_timeframe_strategy.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/strategies/news_trading.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/strategies/rl_strategy.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/strategies/rsi_reversion.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/strategies/scalping_ma.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/strategies/sma_crossover.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/strategies/svm_strategy.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/strategies/transformer_strategy.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/testing/__init__.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/testing/ci_cd_pipeline.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/testing/paper_account_tester.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/testing/strategy_sandbox.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/ui/__init__.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/ui/analytics_dialog.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/ui/analytics_widget.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/ui/backtest_dialog.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/ui/charts.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/ui/cloud_dialog.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/ui/enhanced_charts.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/ui/enhanced_main_window.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/ui/export_dialog.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/ui/main_window.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/ui/marketplace_dialog.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/ui/monitoring_dialog.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/ui/monitoring_widget.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/ui/optimization_dialog.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/ui/settings_dialog.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/ui/simple_chart.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/ui/strategy_builder_dialog.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/ui/theme.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/ui/tradingview_chart.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/utils/__init__.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/utils/backtest_utils.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/utils/gpu_numpy.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/utils/gpu_utils.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot/utils/native_indicators.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot.egg-info/SOURCES.txt +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot.egg-info/dependency_links.txt +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot.egg-info/entry_points.txt +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot.egg-info/requires.txt +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/forexsmartbot.egg-info/top_level.txt +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/setup.cfg +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/tests/test_backtest_dialog_ui.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/tests/test_cloud_hardening.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/tests/test_config.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/tests/test_live_metrics.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/tests/test_multi_timeframe_strategy.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/tests/test_native_indicators.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/tests/test_risk.py +0 -0
- {forexsmartbot-3.3.0 → forexsmartbot-3.3.1}/tests/test_strategies.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
# ForexSmartBot Package
|
|
2
|
-
__version__ = "3.3.
|
|
2
|
+
__version__ = "3.3.1"
|
|
@@ -7,7 +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
|
|
10
|
+
from .companion_api import (
|
|
11
|
+
CompanionAPI,
|
|
12
|
+
CompanionActionQueue,
|
|
13
|
+
CompanionPairingStore,
|
|
14
|
+
WebhookNotifier,
|
|
15
|
+
)
|
|
11
16
|
from .security import SecurityManager, RateLimiter
|
|
12
17
|
from .api_client import APIClient, WebSocketClient
|
|
13
18
|
from .error_handler import CloudErrorHandler, CloudHealthMonitor, retry_call
|
|
@@ -21,6 +26,8 @@ __all__ = [
|
|
|
21
26
|
'MobileAppAPI',
|
|
22
27
|
'PushNotificationService',
|
|
23
28
|
'CompanionAPI',
|
|
29
|
+
'CompanionActionQueue',
|
|
30
|
+
'CompanionPairingStore',
|
|
24
31
|
'WebhookNotifier',
|
|
25
32
|
'SecurityManager',
|
|
26
33
|
'RateLimiter',
|
|
@@ -8,9 +8,12 @@ Broker keys and order routing stay on the desktop host.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import json
|
|
11
12
|
import logging
|
|
13
|
+
import secrets
|
|
12
14
|
import threading
|
|
13
|
-
|
|
15
|
+
import uuid
|
|
16
|
+
from datetime import datetime, timedelta
|
|
14
17
|
from typing import Any, Callable, Dict, List, Optional
|
|
15
18
|
from urllib.parse import urlparse
|
|
16
19
|
|
|
@@ -30,6 +33,127 @@ logger = logging.getLogger(__name__)
|
|
|
30
33
|
PortfolioProvider = Callable[[], Dict[str, Any]]
|
|
31
34
|
AlertsProvider = Callable[[], List[Dict[str, Any]]]
|
|
32
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
|
+
}
|
|
33
157
|
|
|
34
158
|
|
|
35
159
|
def _normalize_portfolio(raw: Dict[str, Any]) -> Dict[str, Any]:
|
|
@@ -143,7 +267,10 @@ class CompanionAPI:
|
|
|
143
267
|
portfolio_provider: Optional[PortfolioProvider] = None,
|
|
144
268
|
alerts_provider: Optional[AlertsProvider] = None,
|
|
145
269
|
strategies_provider: Optional[StrategiesProvider] = None,
|
|
270
|
+
history_provider: Optional[HistoryProvider] = None,
|
|
146
271
|
allow_control: bool = False,
|
|
272
|
+
public_base_url: Optional[str] = None,
|
|
273
|
+
pin_sha256: Optional[str] = None,
|
|
147
274
|
url_prefix: str = "/api/v1/companion",
|
|
148
275
|
):
|
|
149
276
|
self.app = app or Flask(__name__)
|
|
@@ -154,13 +281,18 @@ class CompanionAPI:
|
|
|
154
281
|
require_api_key = not is_insecure_secret(self.api_key)
|
|
155
282
|
self.require_api_key = require_api_key
|
|
156
283
|
self.allow_control = allow_control
|
|
284
|
+
self.public_base_url = (public_base_url or "").rstrip("/")
|
|
285
|
+
self.pin_sha256 = (pin_sha256 or "").strip()
|
|
157
286
|
self.url_prefix = url_prefix.rstrip("/") or "/api/v1/companion"
|
|
158
287
|
|
|
159
288
|
self._portfolio_provider = portfolio_provider or (lambda: {})
|
|
160
289
|
self._alerts_provider = alerts_provider or (lambda: [])
|
|
161
290
|
self._strategies_provider = strategies_provider or (lambda: {"strategies": {}})
|
|
291
|
+
self._history_provider = history_provider
|
|
162
292
|
self.webhook_notifier = WebhookNotifier()
|
|
163
293
|
self.control_callbacks: Dict[str, Callable[[], bool]] = {}
|
|
294
|
+
self.action_queue = CompanionActionQueue()
|
|
295
|
+
self.pairing_store = CompanionPairingStore()
|
|
164
296
|
|
|
165
297
|
self.setup_routes()
|
|
166
298
|
|
|
@@ -173,8 +305,14 @@ class CompanionAPI:
|
|
|
173
305
|
def set_strategies_provider(self, provider: StrategiesProvider) -> None:
|
|
174
306
|
self._strategies_provider = provider
|
|
175
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
|
+
|
|
176
314
|
def register_control_callback(self, action: str, callback: Callable[[], bool]) -> None:
|
|
177
|
-
"""Register optional start/stop callbacks (
|
|
315
|
+
"""Register optional start/stop callbacks (used on approve or allow_control)."""
|
|
178
316
|
self.control_callbacks[action] = callback
|
|
179
317
|
|
|
180
318
|
def _auth_response(self):
|
|
@@ -210,7 +348,10 @@ class CompanionAPI:
|
|
|
210
348
|
"portfolio": True,
|
|
211
349
|
"alerts": True,
|
|
212
350
|
"strategies": True,
|
|
351
|
+
"history": self._history_provider is not None,
|
|
213
352
|
"webhooks": True,
|
|
353
|
+
"pairing": True,
|
|
354
|
+
"actions": True,
|
|
214
355
|
"control": self.allow_control,
|
|
215
356
|
},
|
|
216
357
|
"timestamp": datetime.now().isoformat(),
|
|
@@ -271,6 +412,32 @@ class CompanionAPI:
|
|
|
271
412
|
except Exception as exc:
|
|
272
413
|
return jsonify({"error": str(exc)}), 500
|
|
273
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
|
+
|
|
274
441
|
@self.app.route(f"{prefix}/devices/webhook", methods=["POST"])
|
|
275
442
|
def register_webhook():
|
|
276
443
|
denied = self._auth_response()
|
|
@@ -301,6 +468,93 @@ class CompanionAPI:
|
|
|
301
468
|
removed = self.webhook_notifier.unregister(url)
|
|
302
469
|
return jsonify({"success": removed})
|
|
303
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
|
+
|
|
304
558
|
if self.allow_control:
|
|
305
559
|
|
|
306
560
|
@self.app.route(f"{prefix}/control/start", methods=["POST"])
|
|
@@ -192,6 +192,22 @@ class RemoteMonitorServer:
|
|
|
192
192
|
return denied
|
|
193
193
|
return jsonify(self._strategies_payload())
|
|
194
194
|
|
|
195
|
+
@self.app.route("/api/v1/strategies/history", methods=["GET"])
|
|
196
|
+
def get_strategies_history():
|
|
197
|
+
"""All strategy metrics history for charts (authenticated)."""
|
|
198
|
+
denied = self._auth_response()
|
|
199
|
+
if denied:
|
|
200
|
+
return denied
|
|
201
|
+
return jsonify(self._history_payload(None))
|
|
202
|
+
|
|
203
|
+
@self.app.route("/api/v1/strategies/<strategy_name>/history", methods=["GET"])
|
|
204
|
+
def get_strategy_history(strategy_name: str):
|
|
205
|
+
"""Single strategy metrics history (authenticated)."""
|
|
206
|
+
denied = self._auth_response()
|
|
207
|
+
if denied:
|
|
208
|
+
return denied
|
|
209
|
+
return jsonify(self._history_payload(strategy_name))
|
|
210
|
+
|
|
195
211
|
@self.app.route("/api/v1/strategies/<strategy_name>", methods=["GET"])
|
|
196
212
|
def get_strategy(strategy_name: str):
|
|
197
213
|
"""Single strategy health (authenticated)."""
|
|
@@ -205,7 +221,8 @@ class RemoteMonitorServer:
|
|
|
205
221
|
return jsonify({"strategy": strategies[strategy_name], "timestamp": payload.get("timestamp")})
|
|
206
222
|
|
|
207
223
|
def _mount_companion(self) -> None:
|
|
208
|
-
"""Mount
|
|
224
|
+
"""Mount companion API on the same Flask app."""
|
|
225
|
+
base = f"http://{self.host}:{self.port}"
|
|
209
226
|
self.companion = CompanionAPI(
|
|
210
227
|
app=self.app,
|
|
211
228
|
api_key=self.api_key,
|
|
@@ -213,12 +230,30 @@ class RemoteMonitorServer:
|
|
|
213
230
|
portfolio_provider=lambda: self.trading_data,
|
|
214
231
|
alerts_provider=self._get_recent_alerts,
|
|
215
232
|
strategies_provider=self._strategies_payload,
|
|
233
|
+
history_provider=lambda name, limit: self._history_payload(name, limit),
|
|
216
234
|
allow_control=False,
|
|
235
|
+
public_base_url=base,
|
|
217
236
|
)
|
|
218
237
|
|
|
219
238
|
def set_strategy_monitor(self, monitor: Any) -> None:
|
|
220
239
|
"""Attach a StrategyMonitor for /api/v1/strategies and companion clients."""
|
|
221
240
|
self.strategy_monitor = monitor
|
|
241
|
+
if self.companion is not None:
|
|
242
|
+
self.companion.set_history_provider(
|
|
243
|
+
lambda name, limit: self._history_payload(name, limit)
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
def register_control_callback(self, action: str, callback: Callable):
|
|
247
|
+
"""
|
|
248
|
+
Register callback for remote control actions.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
action: Action name ('start_trading', 'stop_trading', etc.)
|
|
252
|
+
callback: Callback function
|
|
253
|
+
"""
|
|
254
|
+
self.control_callbacks[action] = callback
|
|
255
|
+
if self.companion is not None:
|
|
256
|
+
self.companion.register_control_callback(action, callback)
|
|
222
257
|
|
|
223
258
|
def _strategies_payload(self) -> Dict:
|
|
224
259
|
"""Build strategy health JSON for dashboard / companion."""
|
|
@@ -239,6 +274,34 @@ class RemoteMonitorServer:
|
|
|
239
274
|
payload["timestamp"] = datetime.now().isoformat()
|
|
240
275
|
return payload
|
|
241
276
|
|
|
277
|
+
def _history_payload(
|
|
278
|
+
self, strategy_name: Optional[str] = None, limit: int = 100
|
|
279
|
+
) -> Dict:
|
|
280
|
+
"""Build strategy history JSON for charts."""
|
|
281
|
+
try:
|
|
282
|
+
from flask import has_request_context
|
|
283
|
+
|
|
284
|
+
if has_request_context() and request.args.get("limit") is not None:
|
|
285
|
+
limit = int(request.args.get("limit"))
|
|
286
|
+
except (TypeError, ValueError):
|
|
287
|
+
pass
|
|
288
|
+
if self.strategy_monitor is not None and hasattr(
|
|
289
|
+
self.strategy_monitor, "get_history_payload"
|
|
290
|
+
):
|
|
291
|
+
return self.strategy_monitor.get_history_payload(strategy_name, limit=limit)
|
|
292
|
+
if strategy_name:
|
|
293
|
+
return {
|
|
294
|
+
"strategy": strategy_name,
|
|
295
|
+
"history": [],
|
|
296
|
+
"count": 0,
|
|
297
|
+
"timestamp": datetime.now().isoformat(),
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
"histories": {},
|
|
301
|
+
"count": 0,
|
|
302
|
+
"timestamp": datetime.now().isoformat(),
|
|
303
|
+
}
|
|
304
|
+
|
|
242
305
|
def _auth_response(self):
|
|
243
306
|
"""Return 401 response tuple when auth fails, else None."""
|
|
244
307
|
if not self._check_auth():
|
|
@@ -312,12 +375,19 @@ class RemoteMonitorServer:
|
|
|
312
375
|
.health-critical { color: #f44336; }
|
|
313
376
|
.health-unknown { color: #9e9e9e; }
|
|
314
377
|
.badge { display:inline-block; padding:2px 8px; border-radius:4px; background:#444; font-size:12px; }
|
|
378
|
+
.chart-grid { display:grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap:12px; margin-top:12px; }
|
|
379
|
+
.chart-card { background:#3d3d3d; padding:12px; border-radius:6px; }
|
|
380
|
+
.chart-card h3 { margin:0 0 8px; font-size:14px; color:#ccc; }
|
|
381
|
+
canvas.spark { width:100%; height:80px; background:#2a2a2a; border-radius:4px; }
|
|
382
|
+
#pairing-qr { background:#fff; padding:12px; display:inline-block; border-radius:8px; }
|
|
383
|
+
#pairing-text { width:100%; min-height:72px; font-family:monospace; font-size:12px; }
|
|
384
|
+
.action-row button { margin-right:6px; }
|
|
315
385
|
</style>
|
|
316
386
|
</head>
|
|
317
387
|
<body>
|
|
318
388
|
<div class="container">
|
|
319
389
|
<h1>ForexSmartBot Remote Monitor</h1>
|
|
320
|
-
<p style="color:#aaa;">Strategy health
|
|
390
|
+
<p style="color:#aaa;">Strategy health + history charts + companion pairing / approve-reject at <code>/api/v1/companion/*</code></p>
|
|
321
391
|
|
|
322
392
|
<div class="status-card">
|
|
323
393
|
<h2>API Key</h2>
|
|
@@ -372,6 +442,23 @@ class RemoteMonitorServer:
|
|
|
372
442
|
</thead>
|
|
373
443
|
<tbody id="strategies-body"></tbody>
|
|
374
444
|
</table>
|
|
445
|
+
<h2 style="margin-top:20px;">History Charts</h2>
|
|
446
|
+
<div id="history-charts" class="chart-grid"></div>
|
|
447
|
+
</div>
|
|
448
|
+
|
|
449
|
+
<div class="status-card">
|
|
450
|
+
<h2>Companion Pairing (QR)</h2>
|
|
451
|
+
<p style="color:#aaa;">Create a short-lived session; scan or paste on the Android companion.</p>
|
|
452
|
+
<button class="btn btn-start" onclick="createPairing()">Create pairing QR</button>
|
|
453
|
+
<div id="pairing-meta" style="color:#aaa;margin:8px 0;"></div>
|
|
454
|
+
<div id="pairing-qr"></div>
|
|
455
|
+
<textarea id="pairing-text" readonly placeholder="QR JSON payload appears here"></textarea>
|
|
456
|
+
</div>
|
|
457
|
+
|
|
458
|
+
<div class="status-card">
|
|
459
|
+
<h2>Pending Companion Actions</h2>
|
|
460
|
+
<p style="color:#aaa;">Phone-requested start/stop/pause — approve or reject on this host.</p>
|
|
461
|
+
<div id="actions-list" style="color:#aaa;">No pending actions</div>
|
|
375
462
|
</div>
|
|
376
463
|
|
|
377
464
|
<div class="status-card">
|
|
@@ -397,6 +484,7 @@ class RemoteMonitorServer:
|
|
|
397
484
|
</div>
|
|
398
485
|
</div>
|
|
399
486
|
|
|
487
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
|
400
488
|
<script>
|
|
401
489
|
function authHeaders() {
|
|
402
490
|
const key = sessionStorage.getItem('fsb_api_key') || '';
|
|
@@ -423,6 +511,59 @@ class RemoteMonitorServer:
|
|
|
423
511
|
return 'health-unknown';
|
|
424
512
|
}
|
|
425
513
|
|
|
514
|
+
function drawSpark(canvas, series, key, color) {
|
|
515
|
+
const ctx = canvas.getContext('2d');
|
|
516
|
+
const w = canvas.width = canvas.clientWidth * (window.devicePixelRatio || 1);
|
|
517
|
+
const h = canvas.height = 80 * (window.devicePixelRatio || 1);
|
|
518
|
+
ctx.clearRect(0, 0, w, h);
|
|
519
|
+
const vals = (series || []).map(p => Number(p[key] || 0));
|
|
520
|
+
if (vals.length < 2) {
|
|
521
|
+
ctx.fillStyle = '#666';
|
|
522
|
+
ctx.font = '12px sans-serif';
|
|
523
|
+
ctx.fillText('Not enough samples', 8, h/2);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
const min = Math.min(...vals), max = Math.max(...vals);
|
|
527
|
+
const span = (max - min) || 1;
|
|
528
|
+
ctx.strokeStyle = color;
|
|
529
|
+
ctx.lineWidth = 2;
|
|
530
|
+
ctx.beginPath();
|
|
531
|
+
vals.forEach((v, i) => {
|
|
532
|
+
const x = (i / (vals.length - 1)) * (w - 4) + 2;
|
|
533
|
+
const y = h - 4 - ((v - min) / span) * (h - 8);
|
|
534
|
+
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
|
535
|
+
});
|
|
536
|
+
ctx.stroke();
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function updateHistoryCharts() {
|
|
540
|
+
fetch('/api/v1/strategies/history?limit=80', {headers: authHeaders()})
|
|
541
|
+
.then(async r => {
|
|
542
|
+
if (r.status === 401) throw new Error('unauthorized');
|
|
543
|
+
return r.json();
|
|
544
|
+
})
|
|
545
|
+
.then(data => {
|
|
546
|
+
const histories = data.histories || {};
|
|
547
|
+
const root = document.getElementById('history-charts');
|
|
548
|
+
const names = Object.keys(histories);
|
|
549
|
+
if (!names.length) {
|
|
550
|
+
root.innerHTML = '<div style="color:#888;">No history yet — signals populate charts</div>';
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
root.innerHTML = names.map(name => {
|
|
554
|
+
const id = 'spark-' + name.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
555
|
+
return '<div class="chart-card"><h3>' + name + ' · signals</h3>' +
|
|
556
|
+
'<canvas class="spark" id="' + id + '"></canvas></div>';
|
|
557
|
+
}).join('');
|
|
558
|
+
names.forEach(name => {
|
|
559
|
+
const id = 'spark-' + name.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
560
|
+
const el = document.getElementById(id);
|
|
561
|
+
if (el) drawSpark(el, histories[name], 'signal_count', '#4CAF50');
|
|
562
|
+
});
|
|
563
|
+
})
|
|
564
|
+
.catch(() => {});
|
|
565
|
+
}
|
|
566
|
+
|
|
426
567
|
function updateStrategies() {
|
|
427
568
|
fetch('/api/v1/strategies', {headers: authHeaders()})
|
|
428
569
|
.then(async r => {
|
|
@@ -456,6 +597,72 @@ class RemoteMonitorServer:
|
|
|
456
597
|
}).join('');
|
|
457
598
|
})
|
|
458
599
|
.catch(() => {});
|
|
600
|
+
updateHistoryCharts();
|
|
601
|
+
updateActions();
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function createPairing() {
|
|
605
|
+
fetch('/api/v1/companion/pairing/session', {
|
|
606
|
+
method: 'POST',
|
|
607
|
+
headers: authHeaders(),
|
|
608
|
+
body: JSON.stringify({}),
|
|
609
|
+
})
|
|
610
|
+
.then(async r => {
|
|
611
|
+
if (r.status === 401) throw new Error('unauthorized');
|
|
612
|
+
return r.json();
|
|
613
|
+
})
|
|
614
|
+
.then(data => {
|
|
615
|
+
const text = data.qr_text || JSON.stringify(data.qr_payload || {});
|
|
616
|
+
document.getElementById('pairing-text').value = text;
|
|
617
|
+
document.getElementById('pairing-meta').textContent =
|
|
618
|
+
'Code ' + data.pairing_code + ' · expires ' + data.expires_at +
|
|
619
|
+
' · TTL ' + data.ttl_seconds + 's';
|
|
620
|
+
const box = document.getElementById('pairing-qr');
|
|
621
|
+
box.innerHTML = '';
|
|
622
|
+
if (window.QRCode) {
|
|
623
|
+
new QRCode(box, { text: text, width: 192, height: 192 });
|
|
624
|
+
} else {
|
|
625
|
+
box.textContent = 'QR library unavailable — copy JSON below';
|
|
626
|
+
}
|
|
627
|
+
})
|
|
628
|
+
.catch(err => alert('Pairing failed: ' + err));
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function updateActions() {
|
|
632
|
+
fetch('/api/v1/companion/actions', {headers: authHeaders()})
|
|
633
|
+
.then(async r => {
|
|
634
|
+
if (r.status === 401) throw new Error('unauthorized');
|
|
635
|
+
return r.json();
|
|
636
|
+
})
|
|
637
|
+
.then(data => {
|
|
638
|
+
const pending = data.pending || [];
|
|
639
|
+
const root = document.getElementById('actions-list');
|
|
640
|
+
if (!pending.length) {
|
|
641
|
+
root.innerHTML = 'No pending actions';
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
root.innerHTML = pending.map(a =>
|
|
645
|
+
'<div class="action-row" style="margin:8px 0;">' +
|
|
646
|
+
'<span class="badge">' + a.action + '</span> ' +
|
|
647
|
+
'<span style="color:#aaa;">' + a.id.slice(0,8) + ' · ' + a.requested_by + ' · ' + a.created_at + '</span> ' +
|
|
648
|
+
'<button class="btn btn-start" onclick="resolveAction(\\'' + a.id + '\\', true)">Approve</button>' +
|
|
649
|
+
'<button class="btn btn-stop" onclick="resolveAction(\\'' + a.id + '\\', false)">Reject</button>' +
|
|
650
|
+
'</div>'
|
|
651
|
+
).join('');
|
|
652
|
+
})
|
|
653
|
+
.catch(() => {});
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function resolveAction(id, approve) {
|
|
657
|
+
const path = approve ? 'approve' : 'reject';
|
|
658
|
+
fetch('/api/v1/companion/actions/' + id + '/' + path, {
|
|
659
|
+
method: 'POST',
|
|
660
|
+
headers: authHeaders(),
|
|
661
|
+
body: JSON.stringify(approve ? {} : {reason: 'rejected from dashboard'}),
|
|
662
|
+
})
|
|
663
|
+
.then(r => r.json())
|
|
664
|
+
.then(() => updateActions())
|
|
665
|
+
.catch(err => alert(err));
|
|
459
666
|
}
|
|
460
667
|
|
|
461
668
|
function updateStatus() {
|
|
@@ -535,16 +742,6 @@ class RemoteMonitorServer:
|
|
|
535
742
|
self.trading_data.update(data)
|
|
536
743
|
self.trading_data["last_update"] = datetime.now().isoformat()
|
|
537
744
|
|
|
538
|
-
def register_control_callback(self, action: str, callback: Callable):
|
|
539
|
-
"""
|
|
540
|
-
Register callback for remote control actions.
|
|
541
|
-
|
|
542
|
-
Args:
|
|
543
|
-
action: Action name ('start_trading', 'stop_trading', etc.)
|
|
544
|
-
callback: Callback function
|
|
545
|
-
"""
|
|
546
|
-
self.control_callbacks[action] = callback
|
|
547
|
-
|
|
548
745
|
def register_alert_callback(self, callback: Callable):
|
|
549
746
|
"""
|
|
550
747
|
Register callback for alerts.
|