forexsmartbot 3.2.5__tar.gz → 3.3.0__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.2.5 → forexsmartbot-3.3.0}/PKG-INFO +1 -1
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/__init__.py +1 -1
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/cloud/__init__.py +3 -0
- forexsmartbot-3.3.0/forexsmartbot/cloud/companion_api.py +324 -0
- forexsmartbot-3.3.0/forexsmartbot/cloud/mobile_api.py +184 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/cloud/remote_monitor.py +139 -2
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/monitoring/strategy_monitor.py +37 -13
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot.egg-info/PKG-INFO +1 -1
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot.egg-info/SOURCES.txt +2 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/pyproject.toml +1 -1
- forexsmartbot-3.3.0/tests/test_companion_q2.py +190 -0
- forexsmartbot-3.2.5/forexsmartbot/cloud/mobile_api.py +0 -213
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/LICENSE +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/README.md +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/adapters/__init__.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/adapters/brokers/__init__.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/adapters/brokers/ib_tws_broker.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/adapters/brokers/mt4_broker.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/adapters/brokers/paper_broker.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/adapters/brokers/rest_broker.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/adapters/data/__init__.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/adapters/data/alpha_vantage_provider.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/adapters/data/config.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/adapters/data/csv_provider.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/adapters/data/dummy_provider.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/adapters/data/mt4_provider.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/adapters/data/multi_provider.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/adapters/data/oanda_provider.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/adapters/data/stooq_provider.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/adapters/data/twelve_data_provider.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/adapters/data/yfinance_provider.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/analytics/__init__.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/analytics/chart_patterns.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/analytics/correlation_matrix.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/analytics/economic_calendar.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/analytics/enhanced_chart.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/analytics/live_metrics.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/analytics/market_depth.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/analytics/performance_attribution.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/analytics/portfolio_analytics.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/analytics/risk_analytics.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/analytics/strategy_comparison.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/analytics/trade_journal.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/brokers/mt4_bridge.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/builder/__init__.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/builder/code_generator.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/builder/strategy_builder.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/builder/strategy_template.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/cloud/api_client.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/cloud/api_server.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/cloud/auth_utils.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/cloud/cloud_sync.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/cloud/error_handler.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/cloud/security.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/core/backtester.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/core/config.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/core/interfaces.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/core/paper_broker.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/core/portfolio.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/core/risk.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/core/risk_engine.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/core/strategy.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/core/trade_manager.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/gui/main_window.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/gui/theme.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/marketplace/__init__.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/marketplace/marketplace.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/marketplace/strategy_rating.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/monitoring/__init__.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/monitoring/health_check.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/monitoring/performance_tracker.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/optimization/__init__.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/optimization/adaptive_parameters.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/optimization/genetic_optimizer.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/optimization/hyperparameter_optimizer.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/optimization/monte_carlo.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/optimization/multi_objective_optimizer.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/optimization/parameter_sensitivity.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/optimization/walk_forward.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/services/__init__.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/services/backtest.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/services/calculation_engine.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/services/controller.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/services/data_streaming.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/services/enhanced_backtest.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/services/fast_trading_controller.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/services/gpu_backtest.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/services/language_manager.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/services/notification_service.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/services/persistence.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/services/startup_manager.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/strategies/__init__.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/strategies/adaptive_trend_flow.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/strategies/breakout_atr.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/strategies/ensemble_ml_strategy.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/strategies/fear_index.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/strategies/lstm_strategy.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/strategies/mean_reversion.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/strategies/ml_adaptive_supertrend.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/strategies/momentum_breakout.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/strategies/multi_timeframe_strategy.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/strategies/news_trading.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/strategies/rl_strategy.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/strategies/rsi_reversion.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/strategies/scalping_ma.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/strategies/sma_crossover.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/strategies/svm_strategy.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/strategies/transformer_strategy.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/testing/__init__.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/testing/ci_cd_pipeline.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/testing/paper_account_tester.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/testing/strategy_sandbox.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/ui/__init__.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/ui/analytics_dialog.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/ui/analytics_widget.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/ui/backtest_dialog.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/ui/charts.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/ui/cloud_dialog.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/ui/enhanced_charts.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/ui/enhanced_main_window.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/ui/export_dialog.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/ui/main_window.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/ui/marketplace_dialog.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/ui/monitoring_dialog.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/ui/monitoring_widget.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/ui/optimization_dialog.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/ui/settings_dialog.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/ui/simple_chart.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/ui/strategy_builder_dialog.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/ui/theme.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/ui/tradingview_chart.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/utils/__init__.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/utils/backtest_utils.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/utils/gpu_numpy.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/utils/gpu_utils.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot/utils/native_indicators.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot.egg-info/dependency_links.txt +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot.egg-info/entry_points.txt +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot.egg-info/requires.txt +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/forexsmartbot.egg-info/top_level.txt +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/setup.cfg +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/tests/test_backtest_dialog_ui.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/tests/test_cloud_hardening.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/tests/test_config.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/tests/test_live_metrics.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/tests/test_multi_timeframe_strategy.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/tests/test_native_indicators.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/tests/test_risk.py +0 -0
- {forexsmartbot-3.2.5 → forexsmartbot-3.3.0}/tests/test_strategies.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
# ForexSmartBot Package
|
|
2
|
-
__version__ = "3.
|
|
2
|
+
__version__ = "3.3.0"
|
|
@@ -7,6 +7,7 @@ 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
11
|
from .security import SecurityManager, RateLimiter
|
|
11
12
|
from .api_client import APIClient, WebSocketClient
|
|
12
13
|
from .error_handler import CloudErrorHandler, CloudHealthMonitor, retry_call
|
|
@@ -19,6 +20,8 @@ __all__ = [
|
|
|
19
20
|
'WebSocketServer',
|
|
20
21
|
'MobileAppAPI',
|
|
21
22
|
'PushNotificationService',
|
|
23
|
+
'CompanionAPI',
|
|
24
|
+
'WebhookNotifier',
|
|
22
25
|
'SecurityManager',
|
|
23
26
|
'RateLimiter',
|
|
24
27
|
'APIClient',
|
|
@@ -0,0 +1,324 @@
|
|
|
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 logging
|
|
12
|
+
import threading
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
15
|
+
from urllib.parse import urlparse
|
|
16
|
+
|
|
17
|
+
import requests
|
|
18
|
+
from flask import Flask, jsonify, request
|
|
19
|
+
|
|
20
|
+
from .auth_utils import (
|
|
21
|
+
constant_time_equals,
|
|
22
|
+
extract_bearer_or_api_key,
|
|
23
|
+
is_insecure_secret,
|
|
24
|
+
package_version,
|
|
25
|
+
resolve_api_key,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
PortfolioProvider = Callable[[], Dict[str, Any]]
|
|
31
|
+
AlertsProvider = Callable[[], List[Dict[str, Any]]]
|
|
32
|
+
StrategiesProvider = Callable[[], Dict[str, Any]]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _normalize_portfolio(raw: Dict[str, Any]) -> Dict[str, Any]:
|
|
36
|
+
"""Normalize host trading snapshot for companion clients."""
|
|
37
|
+
positions = raw.get("positions") or []
|
|
38
|
+
if isinstance(positions, dict):
|
|
39
|
+
positions = list(positions.values())
|
|
40
|
+
trades = raw.get("trades") or []
|
|
41
|
+
return {
|
|
42
|
+
"balance": float(raw.get("balance") or 0.0),
|
|
43
|
+
"equity": float(raw.get("equity") or raw.get("balance") or 0.0),
|
|
44
|
+
"status": str(raw.get("status") or "disconnected"),
|
|
45
|
+
"positions_count": len(positions),
|
|
46
|
+
"trades_count": len(trades),
|
|
47
|
+
"positions": positions,
|
|
48
|
+
"last_update": raw.get("last_update"),
|
|
49
|
+
"timestamp": datetime.now().isoformat(),
|
|
50
|
+
"read_only": True,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class WebhookNotifier:
|
|
55
|
+
"""
|
|
56
|
+
Deliver alert payloads to registered HTTPS (or localhost HTTP) webhook URLs.
|
|
57
|
+
|
|
58
|
+
Prefer this over FCM on hardened Android / GrapheneOS builds.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, *, timeout: float = 5.0, max_endpoints: int = 32):
|
|
62
|
+
self.timeout = timeout
|
|
63
|
+
self.max_endpoints = max_endpoints
|
|
64
|
+
self._endpoints: List[str] = []
|
|
65
|
+
self._lock = threading.Lock()
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def _validate_url(url: str) -> Optional[str]:
|
|
69
|
+
cleaned = (url or "").strip()
|
|
70
|
+
if not cleaned or len(cleaned) > 2048:
|
|
71
|
+
return None
|
|
72
|
+
parsed = urlparse(cleaned)
|
|
73
|
+
if parsed.scheme not in {"https", "http"}:
|
|
74
|
+
return None
|
|
75
|
+
if parsed.scheme == "http":
|
|
76
|
+
host = (parsed.hostname or "").lower()
|
|
77
|
+
if host not in {"127.0.0.1", "localhost", "::1"}:
|
|
78
|
+
return None
|
|
79
|
+
if not parsed.netloc:
|
|
80
|
+
return None
|
|
81
|
+
return cleaned
|
|
82
|
+
|
|
83
|
+
def register(self, url: str) -> bool:
|
|
84
|
+
"""Register a webhook endpoint. Returns False if invalid or at capacity."""
|
|
85
|
+
cleaned = self._validate_url(url)
|
|
86
|
+
if not cleaned:
|
|
87
|
+
return False
|
|
88
|
+
with self._lock:
|
|
89
|
+
if cleaned in self._endpoints:
|
|
90
|
+
return True
|
|
91
|
+
if len(self._endpoints) >= self.max_endpoints:
|
|
92
|
+
return False
|
|
93
|
+
self._endpoints.append(cleaned)
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
def unregister(self, url: str) -> bool:
|
|
97
|
+
cleaned = (url or "").strip()
|
|
98
|
+
with self._lock:
|
|
99
|
+
if cleaned in self._endpoints:
|
|
100
|
+
self._endpoints.remove(cleaned)
|
|
101
|
+
return True
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
def list_endpoints(self) -> List[str]:
|
|
105
|
+
with self._lock:
|
|
106
|
+
return list(self._endpoints)
|
|
107
|
+
|
|
108
|
+
def notify(self, title: str, body: str, data: Optional[Dict[str, Any]] = None) -> int:
|
|
109
|
+
"""POST JSON alert to all endpoints. Returns success count."""
|
|
110
|
+
payload = {
|
|
111
|
+
"title": title,
|
|
112
|
+
"body": body,
|
|
113
|
+
"data": data or {},
|
|
114
|
+
"timestamp": datetime.now().isoformat(),
|
|
115
|
+
"source": "forexsmartbot.companion",
|
|
116
|
+
}
|
|
117
|
+
ok = 0
|
|
118
|
+
for url in self.list_endpoints():
|
|
119
|
+
try:
|
|
120
|
+
resp = requests.post(url, json=payload, timeout=self.timeout)
|
|
121
|
+
if 200 <= resp.status_code < 300:
|
|
122
|
+
ok += 1
|
|
123
|
+
else:
|
|
124
|
+
logger.warning("Webhook %s returned %s", url, resp.status_code)
|
|
125
|
+
except Exception as exc:
|
|
126
|
+
logger.warning("Webhook notify failed for %s: %s", url, exc)
|
|
127
|
+
return ok
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class CompanionAPI:
|
|
131
|
+
"""
|
|
132
|
+
Read-only companion REST surface for phones and external dashboards.
|
|
133
|
+
|
|
134
|
+
Mounts under ``/api/v1/companion/*`` on an existing or new Flask app.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
def __init__(
|
|
138
|
+
self,
|
|
139
|
+
app: Optional[Flask] = None,
|
|
140
|
+
*,
|
|
141
|
+
api_key: Optional[str] = None,
|
|
142
|
+
require_api_key: Optional[bool] = None,
|
|
143
|
+
portfolio_provider: Optional[PortfolioProvider] = None,
|
|
144
|
+
alerts_provider: Optional[AlertsProvider] = None,
|
|
145
|
+
strategies_provider: Optional[StrategiesProvider] = None,
|
|
146
|
+
allow_control: bool = False,
|
|
147
|
+
url_prefix: str = "/api/v1/companion",
|
|
148
|
+
):
|
|
149
|
+
self.app = app or Flask(__name__)
|
|
150
|
+
self.api_key = resolve_api_key(
|
|
151
|
+
api_key, env_names=("API_KEY", "REMOTE_MONITOR_API_KEY", "COMPANION_API_KEY")
|
|
152
|
+
)
|
|
153
|
+
if require_api_key is None:
|
|
154
|
+
require_api_key = not is_insecure_secret(self.api_key)
|
|
155
|
+
self.require_api_key = require_api_key
|
|
156
|
+
self.allow_control = allow_control
|
|
157
|
+
self.url_prefix = url_prefix.rstrip("/") or "/api/v1/companion"
|
|
158
|
+
|
|
159
|
+
self._portfolio_provider = portfolio_provider or (lambda: {})
|
|
160
|
+
self._alerts_provider = alerts_provider or (lambda: [])
|
|
161
|
+
self._strategies_provider = strategies_provider or (lambda: {"strategies": {}})
|
|
162
|
+
self.webhook_notifier = WebhookNotifier()
|
|
163
|
+
self.control_callbacks: Dict[str, Callable[[], bool]] = {}
|
|
164
|
+
|
|
165
|
+
self.setup_routes()
|
|
166
|
+
|
|
167
|
+
def set_portfolio_provider(self, provider: PortfolioProvider) -> None:
|
|
168
|
+
self._portfolio_provider = provider
|
|
169
|
+
|
|
170
|
+
def set_alerts_provider(self, provider: AlertsProvider) -> None:
|
|
171
|
+
self._alerts_provider = provider
|
|
172
|
+
|
|
173
|
+
def set_strategies_provider(self, provider: StrategiesProvider) -> None:
|
|
174
|
+
self._strategies_provider = provider
|
|
175
|
+
|
|
176
|
+
def register_control_callback(self, action: str, callback: Callable[[], bool]) -> None:
|
|
177
|
+
"""Register optional start/stop callbacks (disabled unless allow_control)."""
|
|
178
|
+
self.control_callbacks[action] = callback
|
|
179
|
+
|
|
180
|
+
def _auth_response(self):
|
|
181
|
+
if not self._check_auth():
|
|
182
|
+
return jsonify({"error": "Unauthorized"}), 401
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
def _check_auth(self) -> bool:
|
|
186
|
+
if not self.require_api_key:
|
|
187
|
+
return True
|
|
188
|
+
if is_insecure_secret(self.api_key):
|
|
189
|
+
return False
|
|
190
|
+
token = extract_bearer_or_api_key(
|
|
191
|
+
request.headers.get("Authorization", ""),
|
|
192
|
+
request.headers.get("X-API-Key", ""),
|
|
193
|
+
)
|
|
194
|
+
return constant_time_equals(token, self.api_key)
|
|
195
|
+
|
|
196
|
+
def setup_routes(self) -> None:
|
|
197
|
+
prefix = self.url_prefix
|
|
198
|
+
|
|
199
|
+
@self.app.route(f"{prefix}/health", methods=["GET"])
|
|
200
|
+
def companion_health():
|
|
201
|
+
"""Public companion health (no trading secrets)."""
|
|
202
|
+
return jsonify(
|
|
203
|
+
{
|
|
204
|
+
"status": "healthy",
|
|
205
|
+
"service": "companion",
|
|
206
|
+
"version": package_version(),
|
|
207
|
+
"auth_required": self.require_api_key,
|
|
208
|
+
"read_only": not self.allow_control,
|
|
209
|
+
"features": {
|
|
210
|
+
"portfolio": True,
|
|
211
|
+
"alerts": True,
|
|
212
|
+
"strategies": True,
|
|
213
|
+
"webhooks": True,
|
|
214
|
+
"control": self.allow_control,
|
|
215
|
+
},
|
|
216
|
+
"timestamp": datetime.now().isoformat(),
|
|
217
|
+
}
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
@self.app.route(f"{prefix}/portfolio", methods=["GET"])
|
|
221
|
+
def companion_portfolio():
|
|
222
|
+
denied = self._auth_response()
|
|
223
|
+
if denied:
|
|
224
|
+
return denied
|
|
225
|
+
try:
|
|
226
|
+
return jsonify(_normalize_portfolio(self._portfolio_provider() or {}))
|
|
227
|
+
except Exception as exc:
|
|
228
|
+
logger.exception("Companion portfolio failed")
|
|
229
|
+
return jsonify({"error": str(exc)}), 500
|
|
230
|
+
|
|
231
|
+
@self.app.route(f"{prefix}/positions", methods=["GET"])
|
|
232
|
+
def companion_positions():
|
|
233
|
+
denied = self._auth_response()
|
|
234
|
+
if denied:
|
|
235
|
+
return denied
|
|
236
|
+
try:
|
|
237
|
+
snapshot = _normalize_portfolio(self._portfolio_provider() or {})
|
|
238
|
+
return jsonify(
|
|
239
|
+
{
|
|
240
|
+
"positions": snapshot["positions"],
|
|
241
|
+
"count": snapshot["positions_count"],
|
|
242
|
+
"timestamp": snapshot["timestamp"],
|
|
243
|
+
"read_only": True,
|
|
244
|
+
}
|
|
245
|
+
)
|
|
246
|
+
except Exception as exc:
|
|
247
|
+
return jsonify({"error": str(exc)}), 500
|
|
248
|
+
|
|
249
|
+
@self.app.route(f"{prefix}/alerts", methods=["GET"])
|
|
250
|
+
def companion_alerts():
|
|
251
|
+
denied = self._auth_response()
|
|
252
|
+
if denied:
|
|
253
|
+
return denied
|
|
254
|
+
try:
|
|
255
|
+
alerts = self._alerts_provider() or []
|
|
256
|
+
return jsonify({"alerts": list(alerts), "count": len(alerts)})
|
|
257
|
+
except Exception as exc:
|
|
258
|
+
return jsonify({"error": str(exc)}), 500
|
|
259
|
+
|
|
260
|
+
@self.app.route(f"{prefix}/strategies", methods=["GET"])
|
|
261
|
+
def companion_strategies():
|
|
262
|
+
denied = self._auth_response()
|
|
263
|
+
if denied:
|
|
264
|
+
return denied
|
|
265
|
+
try:
|
|
266
|
+
payload = self._strategies_provider() or {}
|
|
267
|
+
if "strategies" not in payload:
|
|
268
|
+
payload = {"strategies": payload}
|
|
269
|
+
payload["timestamp"] = datetime.now().isoformat()
|
|
270
|
+
return jsonify(payload)
|
|
271
|
+
except Exception as exc:
|
|
272
|
+
return jsonify({"error": str(exc)}), 500
|
|
273
|
+
|
|
274
|
+
@self.app.route(f"{prefix}/devices/webhook", methods=["POST"])
|
|
275
|
+
def register_webhook():
|
|
276
|
+
denied = self._auth_response()
|
|
277
|
+
if denied:
|
|
278
|
+
return denied
|
|
279
|
+
data = request.get_json(silent=True) or {}
|
|
280
|
+
url = str(data.get("url") or data.get("webhook_url") or "").strip()
|
|
281
|
+
if not self.webhook_notifier.register(url):
|
|
282
|
+
return jsonify(
|
|
283
|
+
{
|
|
284
|
+
"error": "Invalid webhook URL. Use https://… or http://127.0.0.1/…",
|
|
285
|
+
}
|
|
286
|
+
), 400
|
|
287
|
+
return jsonify(
|
|
288
|
+
{
|
|
289
|
+
"success": True,
|
|
290
|
+
"endpoints": self.webhook_notifier.list_endpoints(),
|
|
291
|
+
}
|
|
292
|
+
), 201
|
|
293
|
+
|
|
294
|
+
@self.app.route(f"{prefix}/devices/webhook", methods=["DELETE"])
|
|
295
|
+
def unregister_webhook():
|
|
296
|
+
denied = self._auth_response()
|
|
297
|
+
if denied:
|
|
298
|
+
return denied
|
|
299
|
+
data = request.get_json(silent=True) or {}
|
|
300
|
+
url = str(data.get("url") or data.get("webhook_url") or "").strip()
|
|
301
|
+
removed = self.webhook_notifier.unregister(url)
|
|
302
|
+
return jsonify({"success": removed})
|
|
303
|
+
|
|
304
|
+
if self.allow_control:
|
|
305
|
+
|
|
306
|
+
@self.app.route(f"{prefix}/control/start", methods=["POST"])
|
|
307
|
+
def companion_start():
|
|
308
|
+
denied = self._auth_response()
|
|
309
|
+
if denied:
|
|
310
|
+
return denied
|
|
311
|
+
cb = self.control_callbacks.get("start_trading")
|
|
312
|
+
if not cb:
|
|
313
|
+
return jsonify({"error": "Control not available"}), 400
|
|
314
|
+
return jsonify({"success": bool(cb())})
|
|
315
|
+
|
|
316
|
+
@self.app.route(f"{prefix}/control/stop", methods=["POST"])
|
|
317
|
+
def companion_stop():
|
|
318
|
+
denied = self._auth_response()
|
|
319
|
+
if denied:
|
|
320
|
+
return denied
|
|
321
|
+
cb = self.control_callbacks.get("stop_trading")
|
|
322
|
+
if not cb:
|
|
323
|
+
return jsonify({"error": "Control not available"}), 400
|
|
324
|
+
return jsonify({"success": bool(cb())})
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Mobile companion helpers.
|
|
3
|
+
|
|
4
|
+
Prefer :class:`forexsmartbot.cloud.companion_api.CompanionAPI` for new code.
|
|
5
|
+
This module keeps a thin compatibility layer and optional FCM push support.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
from typing import Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
from dotenv import load_dotenv
|
|
16
|
+
from flask import Flask
|
|
17
|
+
|
|
18
|
+
from .companion_api import CompanionAPI, WebhookNotifier
|
|
19
|
+
|
|
20
|
+
load_dotenv(override=False)
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MobileAppAPI(CompanionAPI):
|
|
26
|
+
"""
|
|
27
|
+
Backward-compatible mobile API wrapper.
|
|
28
|
+
|
|
29
|
+
Defaults to **read-only** companion endpoints. Pass ``allow_control=True``
|
|
30
|
+
only when high-level start/stop from a trusted device is intentional.
|
|
31
|
+
Legacy ``/api/v1/mobile/*`` routes are not registered; use
|
|
32
|
+
``/api/v1/companion/*`` (also mounted by RemoteMonitorServer).
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, app: Flask = None, **kwargs):
|
|
36
|
+
kwargs.setdefault("allow_control", False)
|
|
37
|
+
kwargs.setdefault("url_prefix", "/api/v1/companion")
|
|
38
|
+
super().__init__(app=app, **kwargs)
|
|
39
|
+
self.push_notification_service: Optional[PushNotificationService] = None
|
|
40
|
+
self.trading_controller = None
|
|
41
|
+
self.broker = None
|
|
42
|
+
|
|
43
|
+
def set_trading_controller(self, controller):
|
|
44
|
+
"""Set trading controller reference (optional control path)."""
|
|
45
|
+
self.trading_controller = controller
|
|
46
|
+
|
|
47
|
+
def start():
|
|
48
|
+
if hasattr(controller, "start"):
|
|
49
|
+
controller.start()
|
|
50
|
+
return True
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
def stop():
|
|
54
|
+
if hasattr(controller, "stop"):
|
|
55
|
+
controller.stop()
|
|
56
|
+
return True
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
self.register_control_callback("start_trading", start)
|
|
60
|
+
self.register_control_callback("stop_trading", stop)
|
|
61
|
+
|
|
62
|
+
def set_broker(self, broker):
|
|
63
|
+
"""Bind a live broker as the portfolio provider."""
|
|
64
|
+
self.broker = broker
|
|
65
|
+
|
|
66
|
+
def portfolio():
|
|
67
|
+
if not broker:
|
|
68
|
+
return {}
|
|
69
|
+
positions = []
|
|
70
|
+
try:
|
|
71
|
+
raw = broker.get_positions() or {}
|
|
72
|
+
for symbol, pos in raw.items():
|
|
73
|
+
positions.append(
|
|
74
|
+
{
|
|
75
|
+
"symbol": getattr(pos, "symbol", symbol),
|
|
76
|
+
"side": "Long" if getattr(pos, "side", 0) > 0 else "Short",
|
|
77
|
+
"quantity": getattr(pos, "quantity", 0),
|
|
78
|
+
"entry_price": getattr(pos, "entry_price", 0),
|
|
79
|
+
"current_price": getattr(pos, "current_price", 0),
|
|
80
|
+
"pnl": getattr(pos, "pnl", 0),
|
|
81
|
+
"stop_loss": getattr(pos, "stop_loss", None),
|
|
82
|
+
"take_profit": getattr(pos, "take_profit", None),
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
logger.warning("Broker positions failed: %s", exc)
|
|
87
|
+
balance = 0.0
|
|
88
|
+
equity = 0.0
|
|
89
|
+
try:
|
|
90
|
+
balance = float(broker.get_balance())
|
|
91
|
+
equity = (
|
|
92
|
+
float(broker.get_equity())
|
|
93
|
+
if hasattr(broker, "get_equity")
|
|
94
|
+
else balance
|
|
95
|
+
)
|
|
96
|
+
except Exception as exc:
|
|
97
|
+
logger.warning("Broker balance failed: %s", exc)
|
|
98
|
+
status = "disconnected"
|
|
99
|
+
try:
|
|
100
|
+
status = "connected" if broker.is_connected() else "disconnected"
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
return {
|
|
104
|
+
"balance": balance,
|
|
105
|
+
"equity": equity,
|
|
106
|
+
"positions": positions,
|
|
107
|
+
"trades": [],
|
|
108
|
+
"status": status,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
self.set_portfolio_provider(portfolio)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class PushNotificationService:
|
|
115
|
+
"""
|
|
116
|
+
Optional FCM push (requires Google infrastructure).
|
|
117
|
+
|
|
118
|
+
For GrapheneOS / de-Googled devices prefer :class:`WebhookNotifier`.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def __init__(self, fcm_server_key: Optional[str] = None):
|
|
122
|
+
self.fcm_server_key = fcm_server_key or os.getenv("FCM_SERVER_KEY", "")
|
|
123
|
+
self.fcm_url = "https://fcm.googleapis.com/fcm/send"
|
|
124
|
+
self.device_tokens: List[str] = []
|
|
125
|
+
self.webhook_notifier = WebhookNotifier()
|
|
126
|
+
|
|
127
|
+
def register_device(self, token: str):
|
|
128
|
+
if token and token not in self.device_tokens:
|
|
129
|
+
self.device_tokens.append(token)
|
|
130
|
+
|
|
131
|
+
def unregister_device(self, token: str):
|
|
132
|
+
if token in self.device_tokens:
|
|
133
|
+
self.device_tokens.remove(token)
|
|
134
|
+
|
|
135
|
+
def register_webhook(self, url: str) -> bool:
|
|
136
|
+
"""GrapheneOS-friendly alert delivery (no FCM)."""
|
|
137
|
+
return self.webhook_notifier.register(url)
|
|
138
|
+
|
|
139
|
+
def send_notification(self, title: str, body: str, data: Optional[Dict] = None):
|
|
140
|
+
webhook_ok = self.webhook_notifier.notify(title, body, data)
|
|
141
|
+
if not self.fcm_server_key or not self.device_tokens:
|
|
142
|
+
return webhook_ok > 0
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
for token in self.device_tokens:
|
|
146
|
+
payload = {
|
|
147
|
+
"to": token,
|
|
148
|
+
"notification": {
|
|
149
|
+
"title": title,
|
|
150
|
+
"body": body,
|
|
151
|
+
"sound": "default",
|
|
152
|
+
},
|
|
153
|
+
"data": data or {},
|
|
154
|
+
}
|
|
155
|
+
headers = {
|
|
156
|
+
"Authorization": f"key={self.fcm_server_key}",
|
|
157
|
+
"Content-Type": "application/json",
|
|
158
|
+
}
|
|
159
|
+
response = requests.post(
|
|
160
|
+
self.fcm_url, json=payload, headers=headers, timeout=5
|
|
161
|
+
)
|
|
162
|
+
if response.status_code != 200:
|
|
163
|
+
logger.warning("FCM failed: %s", response.status_code)
|
|
164
|
+
return True
|
|
165
|
+
except Exception as exc:
|
|
166
|
+
logger.warning("FCM error: %s", exc)
|
|
167
|
+
return webhook_ok > 0
|
|
168
|
+
|
|
169
|
+
def send_trade_alert(
|
|
170
|
+
self, trade_type: str, symbol: str, price: float, pnl: Optional[float] = None
|
|
171
|
+
):
|
|
172
|
+
title = f"Trade {trade_type}: {symbol}"
|
|
173
|
+
body = f"Price: {price:.4f}"
|
|
174
|
+
if pnl is not None:
|
|
175
|
+
body += f", PnL: ${pnl:.2f}"
|
|
176
|
+
data = {"type": "trade", "symbol": symbol, "price": price, "pnl": pnl}
|
|
177
|
+
return self.send_notification(title, body, data)
|
|
178
|
+
|
|
179
|
+
def send_alert(self, alert_type: str, message: str):
|
|
180
|
+
return self.send_notification(
|
|
181
|
+
f"Alert: {alert_type}",
|
|
182
|
+
message,
|
|
183
|
+
{"type": "alert", "alert_type": alert_type, "message": message},
|
|
184
|
+
)
|