pytrader-sdk 1.0.1__py3-none-any.whl
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.
- pytrader/__init__.py +104 -0
- pytrader/auth.py +376 -0
- pytrader/cli/__init__.py +5 -0
- pytrader/cli/paper_trading.py +202 -0
- pytrader/client.py +558 -0
- pytrader/config.py +95 -0
- pytrader/dashboard/__init__.py +4 -0
- pytrader/dashboard/runtime.py +108 -0
- pytrader/dashboard/server.py +202 -0
- pytrader/dashboard/state.py +263 -0
- pytrader/dashboard/state_loader.py +379 -0
- pytrader/data/__init__.py +23 -0
- pytrader/data/bar_aggregator.py +241 -0
- pytrader/data/cache/__init__.py +6 -0
- pytrader/data/cache/sqlite_cache.py +138 -0
- pytrader/data/csv_provider.py +341 -0
- pytrader/data/data_mode.py +44 -0
- pytrader/data/local_store.py +504 -0
- pytrader/data/order_queue.py +243 -0
- pytrader/data/paper_spread.py +79 -0
- pytrader/data/provider.py +76 -0
- pytrader/data/psx_provider.py +82 -0
- pytrader/data/psx_terminal_service.py +930 -0
- pytrader/data/pypsx_service.py +510 -0
- pytrader/data/smart_price_fetcher.py +495 -0
- pytrader/data/sqlite_cache.py +132 -0
- pytrader/data/websocket_client.py +599 -0
- pytrader/data/websocket_data_service.py +229 -0
- pytrader/data/websocket_stream_provider.py +607 -0
- pytrader/indicators.py +148 -0
- pytrader/py.typed +1 -0
- pytrader/sdk.py +227 -0
- pytrader/strategy.py +490 -0
- pytrader/strategy_adapter.py +320 -0
- pytrader/strategy_loader.py +262 -0
- pytrader/streamer.py +57 -0
- pytrader/telemetry.py +327 -0
- pytrader/trader.py +691 -0
- pytrader/trader_core/__init__.py +35 -0
- pytrader/trader_core/backtesting/__init__.py +6 -0
- pytrader/trader_core/backtesting/engine.py +923 -0
- pytrader/trader_core/execution/__init__.py +15 -0
- pytrader/trader_core/execution/_websocket_handlers.py +7 -0
- pytrader/trader_core/execution/live_engine.py +4802 -0
- pytrader/trader_core/execution/paper_account.py +156 -0
- pytrader/trader_core/execution/session_manager.py +251 -0
- pytrader/trader_core/execution/signal_queue.py +291 -0
- pytrader/trader_core/execution/telemetry.py +473 -0
- pytrader/trader_core/portfolio/__init__.py +7 -0
- pytrader/trader_core/portfolio/metrics.py +225 -0
- pytrader/trader_core/portfolio/service.py +643 -0
- pytrader/trader_core/strategies/__init__.py +34 -0
- pytrader/trader_core/strategies/base.py +45 -0
- pytrader/trader_core/strategies/mean_reversion.py +83 -0
- pytrader/trader_core/strategies/momentum.py +181 -0
- pytrader/trader_core/strategies/testbots.py +248 -0
- pytrader/trader_core/utils/__init__.py +19 -0
- pytrader/trader_core/utils/metrics_csv.py +256 -0
- pytrader/trader_core/utils/risk.py +123 -0
- pytrader/trading_config.py +38 -0
- pytrader/utils/__init__.py +32 -0
- pytrader/utils/bot_manager.py +249 -0
- pytrader/utils/cleanup.py +31 -0
- pytrader/utils/currency.py +41 -0
- pytrader/utils/enums.py +40 -0
- pytrader/utils/exceptions.py +37 -0
- pytrader/utils/execution.py +138 -0
- pytrader/utils/id_generator.py +27 -0
- pytrader/utils/logger.py +358 -0
- pytrader/utils/market_hours.py +323 -0
- pytrader/utils/market_hours_service.py +168 -0
- pytrader/utils/time_utils.py +125 -0
- pytrader_sdk-1.0.1.dist-info/METADATA +260 -0
- pytrader_sdk-1.0.1.dist-info/RECORD +77 -0
- pytrader_sdk-1.0.1.dist-info/WHEEL +5 -0
- pytrader_sdk-1.0.1.dist-info/entry_points.txt +2 -0
- pytrader_sdk-1.0.1.dist-info/top_level.txt +1 -0
pytrader/__init__.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PyPSX SDK - run strategies locally, stream telemetry to the backend, and
|
|
3
|
+
monitor everything from the dashboard.
|
|
4
|
+
|
|
5
|
+
Bots execute entirely on the founder's machines via the SDK. The backend acts
|
|
6
|
+
as an authenticated data + telemetry hub (no remote strategy execution).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
# Primary client - dual-endpoint REST client (doesn't trigger trader_core)
|
|
10
|
+
from .client import TradingClient, PyPSXClient, PyTrader
|
|
11
|
+
|
|
12
|
+
# Authentication (doesn't trigger trader_core)
|
|
13
|
+
from .auth import AuthenticationError, require_token, validate_token
|
|
14
|
+
|
|
15
|
+
# Export indicators (doesn't trigger trader_core)
|
|
16
|
+
from . import indicators
|
|
17
|
+
|
|
18
|
+
# Deprecated: Trader class for local execution (kept for backward compatibility)
|
|
19
|
+
# Lazy imports to avoid loading trader_core (which imports backtesting engine)
|
|
20
|
+
# when backend only needs data services like PyPSXService
|
|
21
|
+
def __getattr__(name: str):
|
|
22
|
+
"""Lazy import for Trader and related classes to avoid loading trader_core on package import."""
|
|
23
|
+
import sys
|
|
24
|
+
|
|
25
|
+
# Get the current module
|
|
26
|
+
module = sys.modules[__name__]
|
|
27
|
+
|
|
28
|
+
if name == "Trader":
|
|
29
|
+
from .trader import Trader
|
|
30
|
+
# Store in module dict so 'from pytrader import Trader' works after first access
|
|
31
|
+
setattr(module, "Trader", Trader)
|
|
32
|
+
return Trader
|
|
33
|
+
if name == "Strategy":
|
|
34
|
+
from .strategy import Strategy
|
|
35
|
+
setattr(module, "Strategy", Strategy)
|
|
36
|
+
return Strategy
|
|
37
|
+
if name == "load_strategy":
|
|
38
|
+
from .strategy_loader import load_strategy
|
|
39
|
+
setattr(module, "load_strategy", load_strategy)
|
|
40
|
+
return load_strategy
|
|
41
|
+
if name == "list_strategies":
|
|
42
|
+
from .strategy_loader import list_strategies
|
|
43
|
+
setattr(module, "list_strategies", list_strategies)
|
|
44
|
+
return list_strategies
|
|
45
|
+
if name == "register_strategy":
|
|
46
|
+
from .strategy_loader import register_strategy
|
|
47
|
+
setattr(module, "register_strategy", register_strategy)
|
|
48
|
+
return register_strategy
|
|
49
|
+
if name == "start_dashboard":
|
|
50
|
+
from .dashboard import start_dashboard
|
|
51
|
+
setattr(module, "start_dashboard", start_dashboard)
|
|
52
|
+
return start_dashboard
|
|
53
|
+
if name == "run_backtest":
|
|
54
|
+
from .sdk import run_backtest
|
|
55
|
+
setattr(module, "run_backtest", run_backtest)
|
|
56
|
+
return run_backtest
|
|
57
|
+
if name == "start_paper_trading":
|
|
58
|
+
from .sdk import start_paper_trading
|
|
59
|
+
setattr(module, "start_paper_trading", start_paper_trading)
|
|
60
|
+
return start_paper_trading
|
|
61
|
+
if name == "Streamer":
|
|
62
|
+
from .streamer import Streamer
|
|
63
|
+
setattr(module, "Streamer", Streamer)
|
|
64
|
+
return Streamer
|
|
65
|
+
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
|
|
66
|
+
|
|
67
|
+
# BACKEND-ONLY: These components are for backend internal use only.
|
|
68
|
+
# SDK users should NOT import these - they are not part of the public API.
|
|
69
|
+
# They are kept in the package for backend to use, but not exported to SDK users.
|
|
70
|
+
# from .trader_core import (
|
|
71
|
+
# BacktestEngine,
|
|
72
|
+
# BacktestConfig,
|
|
73
|
+
# TradingEngine,
|
|
74
|
+
# EngineConfig,
|
|
75
|
+
# TradeMetrics,
|
|
76
|
+
# PortfolioService,
|
|
77
|
+
# )
|
|
78
|
+
|
|
79
|
+
__version__ = "2.1.1"
|
|
80
|
+
|
|
81
|
+
__all__ = [
|
|
82
|
+
# Primary API - account-aware client
|
|
83
|
+
"TradingClient",
|
|
84
|
+
"PyPSXClient",
|
|
85
|
+
"PyTrader",
|
|
86
|
+
# Authentication
|
|
87
|
+
"AuthenticationError",
|
|
88
|
+
"require_token",
|
|
89
|
+
"validate_token",
|
|
90
|
+
# Deprecated - kept for backward compatibility
|
|
91
|
+
"Trader",
|
|
92
|
+
"Strategy",
|
|
93
|
+
"run_backtest",
|
|
94
|
+
"start_paper_trading",
|
|
95
|
+
"start_dashboard",
|
|
96
|
+
"load_strategy",
|
|
97
|
+
"list_strategies",
|
|
98
|
+
"register_strategy",
|
|
99
|
+
"Streamer",
|
|
100
|
+
# Utilities
|
|
101
|
+
"indicators",
|
|
102
|
+
# NOTE: Backend-only components (BacktestEngine, TradingEngine, etc.) are NOT exported
|
|
103
|
+
# SDK users must use PyTrader client only. Backend components are for internal use.
|
|
104
|
+
]
|
pytrader/auth.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Token authentication for PyTrader SDK.
|
|
3
|
+
|
|
4
|
+
Validates API tokens against the backend service. If the backend is unreachable,
|
|
5
|
+
an allow-listed set of "trusted" tokens can still run locally for development
|
|
6
|
+
and paper trading scenarios.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Optional, Set, Dict, Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
import warnings
|
|
17
|
+
from datetime import datetime, timezone, timedelta
|
|
18
|
+
|
|
19
|
+
from .config import settings
|
|
20
|
+
|
|
21
|
+
DEFAULT_BACKEND_URL = settings.backend_url
|
|
22
|
+
TRUSTED_DEFAULT_TOKENS = {
|
|
23
|
+
"ahmer-token",
|
|
24
|
+
"amaan-token",
|
|
25
|
+
"sadiq-token",
|
|
26
|
+
"iba-token",
|
|
27
|
+
"demo-token",
|
|
28
|
+
"dev-token",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AuthenticationError(Exception):
|
|
33
|
+
"""Raised when token authentication fails."""
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class BackendUnavailableError(AuthenticationError):
|
|
38
|
+
"""Raised when the backend cannot be reached for validation."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class AccountContext:
|
|
43
|
+
account_id: str
|
|
44
|
+
mode: str
|
|
45
|
+
bot_id: str
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def is_paper(self) -> bool:
|
|
49
|
+
return self.mode == "PAPER"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def resolve_account_context(
|
|
53
|
+
*,
|
|
54
|
+
account_id: Optional[str] = None,
|
|
55
|
+
bot_id: Optional[str] = None,
|
|
56
|
+
mode: str = "PAPER",
|
|
57
|
+
) -> AccountContext:
|
|
58
|
+
normalized_mode = mode.upper()
|
|
59
|
+
|
|
60
|
+
if account_id:
|
|
61
|
+
if account_id == "live-brokerage":
|
|
62
|
+
return AccountContext(account_id="live-brokerage", mode="LIVE", bot_id="")
|
|
63
|
+
if account_id == "paper-main":
|
|
64
|
+
return AccountContext(account_id="paper-main", mode="PAPER", bot_id="manual")
|
|
65
|
+
if account_id.startswith("PYPSX-"):
|
|
66
|
+
# New immutable paper account ids (e.g., PYPSX-366XBGVOO4).
|
|
67
|
+
return AccountContext(account_id=account_id, mode="PAPER", bot_id="manual")
|
|
68
|
+
if account_id.startswith("paper-bot:"):
|
|
69
|
+
resolved_bot_id = account_id.split("paper-bot:", 1)[1].strip()
|
|
70
|
+
if not resolved_bot_id:
|
|
71
|
+
raise ValueError("paper-bot account_id must include a bot identifier.")
|
|
72
|
+
return AccountContext(account_id=account_id, mode="PAPER", bot_id=resolved_bot_id)
|
|
73
|
+
raise ValueError(
|
|
74
|
+
"Unsupported account_id. Expected one of: live-brokerage, PYPSX-<id>, paper-main, paper-bot:<id>."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
normalized_bot_id = (bot_id or "").strip()
|
|
78
|
+
if normalized_mode == "LIVE":
|
|
79
|
+
return AccountContext(account_id="live-brokerage", mode="LIVE", bot_id="")
|
|
80
|
+
if not normalized_bot_id or normalized_bot_id == "manual":
|
|
81
|
+
return AccountContext(account_id="paper-main", mode="PAPER", bot_id="manual")
|
|
82
|
+
return AccountContext(
|
|
83
|
+
account_id=f"paper-bot:{normalized_bot_id}",
|
|
84
|
+
mode="PAPER",
|
|
85
|
+
bot_id=normalized_bot_id,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _load_trusted_tokens() -> Set[str]:
|
|
90
|
+
from_env = {
|
|
91
|
+
token.strip()
|
|
92
|
+
for token in os.getenv("PYTRADER_TRUSTED_TOKENS", "").split(",")
|
|
93
|
+
if token.strip()
|
|
94
|
+
}
|
|
95
|
+
return TRUSTED_DEFAULT_TOKENS.union(from_env)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def validate_token(api_token: str, backend_url: str) -> bool:
|
|
99
|
+
"""
|
|
100
|
+
Validate an API token against the backend service.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
api_token: API token to validate
|
|
104
|
+
backend_url: Backend API URL (MANDATORY)
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
True if token is valid, False otherwise
|
|
108
|
+
|
|
109
|
+
Raises:
|
|
110
|
+
AuthenticationError: If validation fails, backend is unreachable, or returns 401/403
|
|
111
|
+
"""
|
|
112
|
+
if not api_token:
|
|
113
|
+
raise AuthenticationError("API token is required")
|
|
114
|
+
|
|
115
|
+
if not backend_url:
|
|
116
|
+
raise BackendUnavailableError(
|
|
117
|
+
"Backend URL is required. Set PYTRADER_BACKEND_URL environment variable."
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
response = httpx.get(
|
|
122
|
+
f"{backend_url.rstrip('/')}/health",
|
|
123
|
+
headers={"X-PyTrader-Token": api_token},
|
|
124
|
+
timeout=5.0,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Hard error on 401/403
|
|
128
|
+
if response.status_code == 401:
|
|
129
|
+
raise AuthenticationError("Invalid API token (401 Unauthorized). Please check your token and try again.")
|
|
130
|
+
if response.status_code == 403:
|
|
131
|
+
raise AuthenticationError("Access forbidden (403). Your token may not have permission for this operation.")
|
|
132
|
+
|
|
133
|
+
# 5xx responses mean the backend itself is unavailable / suspended —
|
|
134
|
+
# treat them as BackendUnavailableError so callers can apply
|
|
135
|
+
# trusted-token fallback logic rather than failing hard.
|
|
136
|
+
if response.status_code >= 500:
|
|
137
|
+
raise BackendUnavailableError(
|
|
138
|
+
f"Backend at {backend_url} returned {response.status_code} "
|
|
139
|
+
f"(service unavailable). "
|
|
140
|
+
f"Response: {response.text[:200] if response.text else 'No response body'}"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Hard error on any other non-200 status
|
|
144
|
+
if response.status_code != 200:
|
|
145
|
+
raise AuthenticationError(
|
|
146
|
+
f"Backend returned error status {response.status_code}. "
|
|
147
|
+
f"Response: {response.text[:200] if response.text else 'No response body'}"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Verify response can be parsed
|
|
151
|
+
try:
|
|
152
|
+
data = response.json()
|
|
153
|
+
if not isinstance(data, dict):
|
|
154
|
+
raise AuthenticationError("Backend returned invalid response format. Expected JSON object.")
|
|
155
|
+
except Exception as e:
|
|
156
|
+
raise AuthenticationError(f"Backend response cannot be verified: {e}")
|
|
157
|
+
|
|
158
|
+
return True
|
|
159
|
+
|
|
160
|
+
except httpx.RequestError as e:
|
|
161
|
+
raise BackendUnavailableError(
|
|
162
|
+
f"Backend is unreachable at {backend_url}. "
|
|
163
|
+
f"Cannot validate token. Error: {e}. "
|
|
164
|
+
f"Please ensure the backend is running and PYTRADER_BACKEND_URL is correct."
|
|
165
|
+
) from e
|
|
166
|
+
except AuthenticationError:
|
|
167
|
+
# Re-raise authentication errors
|
|
168
|
+
raise
|
|
169
|
+
except Exception as e:
|
|
170
|
+
# Hard error on any other exception
|
|
171
|
+
raise AuthenticationError(f"Token validation error: {e}") from e
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def require_token(api_token: Optional[str] = None, backend_url: Optional[str] = None) -> str:
|
|
175
|
+
"""
|
|
176
|
+
Require and validate an API token. Backend URL is MANDATORY.
|
|
177
|
+
|
|
178
|
+
NO FALLBACKS. NO WARNINGS. NO OFFLINE EXECUTION.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
api_token: API token (can be from parameter, PYTRADER_API_TOKEN, or PYTRADER_TOKEN env var)
|
|
182
|
+
backend_url: Backend API URL (can be from parameter or PYTRADER_BACKEND_URL env var)
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
Validated token string
|
|
186
|
+
|
|
187
|
+
Raises:
|
|
188
|
+
AuthenticationError: If token is missing, backend URL is missing, backend is unreachable,
|
|
189
|
+
or token validation fails
|
|
190
|
+
"""
|
|
191
|
+
# Get token from parameter or env var (support both old and new env var names)
|
|
192
|
+
resolved_token = api_token or os.getenv("PYTRADER_API_TOKEN") or os.getenv("PYTRADER_TOKEN")
|
|
193
|
+
|
|
194
|
+
if not resolved_token:
|
|
195
|
+
raise AuthenticationError(
|
|
196
|
+
"API token is required. "
|
|
197
|
+
"Please provide a token: "
|
|
198
|
+
"1. Pass api_token='your-token' to the function, "
|
|
199
|
+
"2. Set PYTRADER_API_TOKEN environment variable, "
|
|
200
|
+
"3. Contact your administrator to get a token"
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# Get backend URL from parameter, env var, or default deployment
|
|
204
|
+
resolved_backend_url = backend_url or os.getenv("PYTRADER_BACKEND_URL") or DEFAULT_BACKEND_URL
|
|
205
|
+
trusted_tokens = _load_trusted_tokens()
|
|
206
|
+
is_trusted_token = resolved_token in trusted_tokens
|
|
207
|
+
|
|
208
|
+
if not resolved_backend_url:
|
|
209
|
+
if is_trusted_token:
|
|
210
|
+
warnings.warn(
|
|
211
|
+
"Backend URL is not configured; continuing in trusted-token mode.",
|
|
212
|
+
RuntimeWarning,
|
|
213
|
+
stacklevel=2,
|
|
214
|
+
)
|
|
215
|
+
return resolved_token
|
|
216
|
+
raise AuthenticationError(
|
|
217
|
+
"Backend URL is required. "
|
|
218
|
+
"Please set PYTRADER_BACKEND_URL environment variable or pass backend_url parameter. "
|
|
219
|
+
"The SDK cannot validate untrusted tokens without a backend connection."
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
validate_token(resolved_token, resolved_backend_url)
|
|
224
|
+
except BackendUnavailableError as exc:
|
|
225
|
+
if is_trusted_token:
|
|
226
|
+
warnings.warn(
|
|
227
|
+
f"{exc} Proceeding because the token is in the trusted allow list.",
|
|
228
|
+
RuntimeWarning,
|
|
229
|
+
stacklevel=2,
|
|
230
|
+
)
|
|
231
|
+
else:
|
|
232
|
+
raise
|
|
233
|
+
|
|
234
|
+
return resolved_token
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class BotAuthSession:
|
|
238
|
+
"""
|
|
239
|
+
Manages bot authentication lifecycle (bot_api_key -> JWT access/refresh tokens).
|
|
240
|
+
|
|
241
|
+
The application only ever deals with this class; it never sees the JWTs directly.
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
def __init__(
|
|
245
|
+
self,
|
|
246
|
+
*,
|
|
247
|
+
user_id: str,
|
|
248
|
+
bot_id: str,
|
|
249
|
+
bot_api_key: str,
|
|
250
|
+
backend_url: Optional[str] = None,
|
|
251
|
+
timeout: float = 10.0,
|
|
252
|
+
) -> None:
|
|
253
|
+
self.user_id = user_id
|
|
254
|
+
self.bot_id = bot_id
|
|
255
|
+
self.bot_api_key = bot_api_key
|
|
256
|
+
self.backend_url = (backend_url or DEFAULT_BACKEND_URL).rstrip("/")
|
|
257
|
+
self._client = httpx.Client(timeout=timeout)
|
|
258
|
+
self._access_token: Optional[str] = None
|
|
259
|
+
self._refresh_token: Optional[str] = None
|
|
260
|
+
self._api_token: Optional[str] = None
|
|
261
|
+
self._access_expires_at: Optional[datetime] = None
|
|
262
|
+
self._refresh_expires_at: Optional[datetime] = None
|
|
263
|
+
|
|
264
|
+
# ------------------------------------------------------------------
|
|
265
|
+
# Public API
|
|
266
|
+
# ------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
def close(self) -> None:
|
|
269
|
+
self._client.close()
|
|
270
|
+
|
|
271
|
+
def login(self) -> None:
|
|
272
|
+
"""
|
|
273
|
+
Perform the initial handshake with POST /auth/bot/login.
|
|
274
|
+
"""
|
|
275
|
+
if not self.backend_url:
|
|
276
|
+
raise BackendUnavailableError("Backend URL is required for bot login.")
|
|
277
|
+
|
|
278
|
+
payload = {
|
|
279
|
+
"user_id": self.user_id,
|
|
280
|
+
"bot_id": self.bot_id,
|
|
281
|
+
"bot_api_key": self.bot_api_key,
|
|
282
|
+
}
|
|
283
|
+
resp = self._client.post(f"{self.backend_url}/auth/bot/login", json=payload)
|
|
284
|
+
try:
|
|
285
|
+
resp.raise_for_status()
|
|
286
|
+
except httpx.HTTPStatusError as e:
|
|
287
|
+
raise AuthenticationError(
|
|
288
|
+
f"Bot login failed ({e.response.status_code}): {e.response.text[:200]}"
|
|
289
|
+
) from e
|
|
290
|
+
|
|
291
|
+
data = resp.json()
|
|
292
|
+
self._set_tokens_from_response(data)
|
|
293
|
+
|
|
294
|
+
def refresh(self) -> None:
|
|
295
|
+
"""
|
|
296
|
+
Refresh the access token using POST /auth/bot/refresh.
|
|
297
|
+
"""
|
|
298
|
+
if not self._refresh_token:
|
|
299
|
+
raise AuthenticationError("No refresh token available for bot session.")
|
|
300
|
+
if not self.backend_url:
|
|
301
|
+
raise BackendUnavailableError("Backend URL is required for bot refresh.")
|
|
302
|
+
|
|
303
|
+
payload = {"refresh_token": self._refresh_token}
|
|
304
|
+
resp = self._client.post(f"{self.backend_url}/auth/bot/refresh", json=payload)
|
|
305
|
+
try:
|
|
306
|
+
resp.raise_for_status()
|
|
307
|
+
except httpx.HTTPStatusError as e:
|
|
308
|
+
raise AuthenticationError(
|
|
309
|
+
f"Bot token refresh failed ({e.response.status_code}): {e.response.text[:200]}"
|
|
310
|
+
) from e
|
|
311
|
+
|
|
312
|
+
data = resp.json()
|
|
313
|
+
self._set_tokens_from_response(data)
|
|
314
|
+
|
|
315
|
+
def auth_headers(self) -> Dict[str, str]:
|
|
316
|
+
"""
|
|
317
|
+
Return Authorization header, performing proactive refresh if needed.
|
|
318
|
+
"""
|
|
319
|
+
self._ensure_token_fresh()
|
|
320
|
+
if not self._access_token:
|
|
321
|
+
raise AuthenticationError("Access token unavailable after login/refresh.")
|
|
322
|
+
headers = {
|
|
323
|
+
"Authorization": f"Bearer {self._access_token}",
|
|
324
|
+
"x-trading-mode": "paper",
|
|
325
|
+
}
|
|
326
|
+
if self._api_token:
|
|
327
|
+
headers["X-PyTrader-Token"] = self._api_token
|
|
328
|
+
return headers
|
|
329
|
+
|
|
330
|
+
def handle_401_and_retry(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
|
|
331
|
+
"""
|
|
332
|
+
Helper to transparently refresh on 401 and retry once.
|
|
333
|
+
"""
|
|
334
|
+
base_headers = dict(kwargs.pop("headers", {}) or {})
|
|
335
|
+
request_headers = dict(base_headers)
|
|
336
|
+
request_headers.update(self.auth_headers())
|
|
337
|
+
resp = self._client.request(method, url, headers=request_headers, **kwargs)
|
|
338
|
+
if resp.status_code != 401:
|
|
339
|
+
return resp
|
|
340
|
+
|
|
341
|
+
# Attempt silent refresh once
|
|
342
|
+
self.refresh()
|
|
343
|
+
retry_headers = dict(base_headers)
|
|
344
|
+
retry_headers.update(self.auth_headers())
|
|
345
|
+
resp = self._client.request(method, url, headers=retry_headers, **kwargs)
|
|
346
|
+
return resp
|
|
347
|
+
|
|
348
|
+
# ------------------------------------------------------------------
|
|
349
|
+
# Internal helpers
|
|
350
|
+
# ------------------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
def _set_tokens_from_response(self, data: Dict[str, Any]) -> None:
|
|
353
|
+
self._access_token = data.get("access_token")
|
|
354
|
+
self._refresh_token = data.get("refresh_token")
|
|
355
|
+
self._api_token = data.get("api_token")
|
|
356
|
+
|
|
357
|
+
now = datetime.now(timezone.utc)
|
|
358
|
+
# access_token expiry (seconds)
|
|
359
|
+
access_expires_in = int(data.get("expires_in", 0))
|
|
360
|
+
refresh_expires_in = int(data.get("refresh_expires_in", 0))
|
|
361
|
+
|
|
362
|
+
# Subtract 5 minutes from access expiry for proactive refresh window
|
|
363
|
+
self._access_expires_at = now + timedelta(seconds=max(0, access_expires_in - 300))
|
|
364
|
+
self._refresh_expires_at = now + timedelta(seconds=refresh_expires_in) if refresh_expires_in else None
|
|
365
|
+
|
|
366
|
+
def _ensure_token_fresh(self) -> None:
|
|
367
|
+
now = datetime.now(timezone.utc)
|
|
368
|
+
if self._access_token and self._access_expires_at and now < self._access_expires_at:
|
|
369
|
+
return
|
|
370
|
+
# If access token is missing/expired but refresh is still valid, use it
|
|
371
|
+
if self._refresh_token and (self._refresh_expires_at is None or now < self._refresh_expires_at):
|
|
372
|
+
self.refresh()
|
|
373
|
+
return
|
|
374
|
+
# Otherwise, do a full login again
|
|
375
|
+
self.login()
|
|
376
|
+
|
pytrader/cli/__init__.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, Iterable, List, Optional
|
|
9
|
+
|
|
10
|
+
from ..strategy_loader import BUILTIN_STRATEGIES
|
|
11
|
+
from ..trader import Trader
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _parse_symbols(raw: str) -> List[str]:
|
|
15
|
+
parts: List[str] = []
|
|
16
|
+
for token in (segment.strip().upper() for segment in raw.replace(",", " ").split()):
|
|
17
|
+
if token:
|
|
18
|
+
parts.append(token)
|
|
19
|
+
if not parts:
|
|
20
|
+
raise argparse.ArgumentTypeError("At least one symbol must be provided.")
|
|
21
|
+
return parts
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _load_strategy_config(config_arg: Optional[str]) -> Dict[str, Any]:
|
|
25
|
+
if not config_arg:
|
|
26
|
+
return {}
|
|
27
|
+
|
|
28
|
+
config_arg = config_arg.strip()
|
|
29
|
+
path = Path(config_arg)
|
|
30
|
+
try:
|
|
31
|
+
if path.exists() and path.is_file():
|
|
32
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
33
|
+
except OSError as exc:
|
|
34
|
+
raise argparse.ArgumentTypeError(f"Unable to read config file: {exc}") from exc
|
|
35
|
+
except json.JSONDecodeError as exc:
|
|
36
|
+
raise argparse.ArgumentTypeError(f"Invalid JSON in config file: {exc}") from exc
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
return json.loads(config_arg)
|
|
40
|
+
except json.JSONDecodeError as exc:
|
|
41
|
+
raise argparse.ArgumentTypeError(f"Invalid JSON config string: {exc}") from exc
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _build_strategy(name: str, config: Dict[str, Any]):
|
|
45
|
+
key = name.lower().strip()
|
|
46
|
+
strategy_cls = BUILTIN_STRATEGIES.get(key)
|
|
47
|
+
if not strategy_cls:
|
|
48
|
+
raise SystemExit(
|
|
49
|
+
f"Unknown strategy '{name}'. "
|
|
50
|
+
f"Available templates: {', '.join(sorted(BUILTIN_STRATEGIES.keys()))}"
|
|
51
|
+
)
|
|
52
|
+
try:
|
|
53
|
+
return strategy_cls(**config)
|
|
54
|
+
except TypeError as exc:
|
|
55
|
+
raise SystemExit(f"Invalid configuration for '{name}': {exc}") from exc
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _resolve_log_path(base: Optional[str], suffix: str, bot_id: str) -> Path:
|
|
59
|
+
if base:
|
|
60
|
+
path = Path(base).expanduser()
|
|
61
|
+
else:
|
|
62
|
+
log_dir = Path("logs") / "paper_cli"
|
|
63
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
65
|
+
path = log_dir / f"{bot_id}-{timestamp}-{suffix}"
|
|
66
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
return path
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
71
|
+
parser = argparse.ArgumentParser(
|
|
72
|
+
prog="pytrader-paper",
|
|
73
|
+
description="Local terminal-only paper trading runner for PyPSX strategies.",
|
|
74
|
+
)
|
|
75
|
+
parser.add_argument(
|
|
76
|
+
"--strategy",
|
|
77
|
+
required=True,
|
|
78
|
+
help="Strategy template name (e.g., sma_momentum, dual_sma_momentum, vwap_reversion).",
|
|
79
|
+
choices=sorted(BUILTIN_STRATEGIES.keys()),
|
|
80
|
+
)
|
|
81
|
+
parser.add_argument(
|
|
82
|
+
"--symbols",
|
|
83
|
+
required=True,
|
|
84
|
+
help="Comma or space separated list of PSX symbols (e.g., OGDC, HBL, MEBL).",
|
|
85
|
+
)
|
|
86
|
+
parser.add_argument(
|
|
87
|
+
"--config",
|
|
88
|
+
help="Strategy configuration as JSON string or path to JSON file.",
|
|
89
|
+
)
|
|
90
|
+
parser.add_argument(
|
|
91
|
+
"--token",
|
|
92
|
+
required=True,
|
|
93
|
+
help="API token for PyPSX data access (kept locally, never sent to cloud).",
|
|
94
|
+
)
|
|
95
|
+
parser.add_argument(
|
|
96
|
+
"--capital",
|
|
97
|
+
type=float,
|
|
98
|
+
default=1_000_000.0,
|
|
99
|
+
help="Initial virtual cash for the portfolio (default: 1,000,000).",
|
|
100
|
+
)
|
|
101
|
+
parser.add_argument(
|
|
102
|
+
"--position-notional",
|
|
103
|
+
type=float,
|
|
104
|
+
default=100_000.0,
|
|
105
|
+
help="Target notional allocated per trade (default: 100,000).",
|
|
106
|
+
)
|
|
107
|
+
parser.add_argument(
|
|
108
|
+
"--cycle-minutes",
|
|
109
|
+
type=int,
|
|
110
|
+
default=15,
|
|
111
|
+
help="Polling cycle duration in minutes (default: 15).",
|
|
112
|
+
)
|
|
113
|
+
parser.add_argument(
|
|
114
|
+
"--max-cycles",
|
|
115
|
+
type=int,
|
|
116
|
+
default=None,
|
|
117
|
+
help="Optional limit on cycles to run before exiting (default: run indefinitely).",
|
|
118
|
+
)
|
|
119
|
+
parser.add_argument(
|
|
120
|
+
"--bot-id",
|
|
121
|
+
help="Optional identifier used for log filenames. Defaults to strategy name.",
|
|
122
|
+
)
|
|
123
|
+
parser.add_argument(
|
|
124
|
+
"--metrics-path",
|
|
125
|
+
help="Optional path for metrics CSV output. Defaults to logs/paper_cli/<bot>_metrics.csv.",
|
|
126
|
+
)
|
|
127
|
+
parser.add_argument(
|
|
128
|
+
"--trades-path",
|
|
129
|
+
help="Optional path for trades CSV output. Defaults to logs/paper_cli/<bot>_trades.csv.",
|
|
130
|
+
)
|
|
131
|
+
parser.add_argument(
|
|
132
|
+
"--warm-start",
|
|
133
|
+
action="store_true",
|
|
134
|
+
help="Replay from market open using previously saved state (explicit opt-in).",
|
|
135
|
+
)
|
|
136
|
+
parser.add_argument(
|
|
137
|
+
"--cold-start",
|
|
138
|
+
action="store_true",
|
|
139
|
+
help="(Deprecated) Force cold start. Cold start is now the default.",
|
|
140
|
+
)
|
|
141
|
+
parser.add_argument(
|
|
142
|
+
"--detailed",
|
|
143
|
+
action="store_true",
|
|
144
|
+
help="Enable verbose per-cycle logging in the terminal.",
|
|
145
|
+
)
|
|
146
|
+
parser.add_argument(
|
|
147
|
+
"--log-dir",
|
|
148
|
+
help="Directory for additional log artifacts (default: logs/).",
|
|
149
|
+
)
|
|
150
|
+
return parser
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def main(argv: Optional[Iterable[str]] = None) -> int:
|
|
154
|
+
parser = build_parser()
|
|
155
|
+
args = parser.parse_args(argv)
|
|
156
|
+
|
|
157
|
+
symbols = _parse_symbols(args.symbols)
|
|
158
|
+
config = _load_strategy_config(args.config)
|
|
159
|
+
strategy = _build_strategy(args.strategy, config)
|
|
160
|
+
|
|
161
|
+
bot_id = args.bot_id or f"paper-{args.strategy}"
|
|
162
|
+
metrics_path = _resolve_log_path(args.metrics_path, "metrics.csv", bot_id)
|
|
163
|
+
trades_path = _resolve_log_path(args.trades_path, "trades.csv", bot_id)
|
|
164
|
+
|
|
165
|
+
trader = Trader(
|
|
166
|
+
strategy=strategy,
|
|
167
|
+
symbols=symbols,
|
|
168
|
+
cycle_minutes=args.cycle_minutes,
|
|
169
|
+
initial_cash=args.capital,
|
|
170
|
+
position_notional=args.position_notional,
|
|
171
|
+
bot_id=bot_id,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
warm_start = args.warm_start
|
|
175
|
+
if args.cold_start:
|
|
176
|
+
warm_start = False
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
log_dir = Path(args.log_dir).expanduser() if args.log_dir else Path("logs")
|
|
180
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
181
|
+
trader.run_paper_trading(
|
|
182
|
+
token=args.token,
|
|
183
|
+
warm_start=warm_start,
|
|
184
|
+
max_cycles=args.max_cycles,
|
|
185
|
+
metrics_path=metrics_path,
|
|
186
|
+
trades_path=trades_path,
|
|
187
|
+
log_dir=log_dir,
|
|
188
|
+
detailed_logs=args.detailed,
|
|
189
|
+
)
|
|
190
|
+
return 0
|
|
191
|
+
except KeyboardInterrupt:
|
|
192
|
+
print("\nPaper trading stopped by user.")
|
|
193
|
+
return 0
|
|
194
|
+
except Exception as exc: # pragma: no cover - CLI guardrail
|
|
195
|
+
print(f"Paper trading failed: {exc}", file=sys.stderr)
|
|
196
|
+
return 1
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
if __name__ == "__main__":
|
|
200
|
+
raise SystemExit(main())
|
|
201
|
+
|
|
202
|
+
|