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.
Files changed (77) hide show
  1. pytrader/__init__.py +104 -0
  2. pytrader/auth.py +376 -0
  3. pytrader/cli/__init__.py +5 -0
  4. pytrader/cli/paper_trading.py +202 -0
  5. pytrader/client.py +558 -0
  6. pytrader/config.py +95 -0
  7. pytrader/dashboard/__init__.py +4 -0
  8. pytrader/dashboard/runtime.py +108 -0
  9. pytrader/dashboard/server.py +202 -0
  10. pytrader/dashboard/state.py +263 -0
  11. pytrader/dashboard/state_loader.py +379 -0
  12. pytrader/data/__init__.py +23 -0
  13. pytrader/data/bar_aggregator.py +241 -0
  14. pytrader/data/cache/__init__.py +6 -0
  15. pytrader/data/cache/sqlite_cache.py +138 -0
  16. pytrader/data/csv_provider.py +341 -0
  17. pytrader/data/data_mode.py +44 -0
  18. pytrader/data/local_store.py +504 -0
  19. pytrader/data/order_queue.py +243 -0
  20. pytrader/data/paper_spread.py +79 -0
  21. pytrader/data/provider.py +76 -0
  22. pytrader/data/psx_provider.py +82 -0
  23. pytrader/data/psx_terminal_service.py +930 -0
  24. pytrader/data/pypsx_service.py +510 -0
  25. pytrader/data/smart_price_fetcher.py +495 -0
  26. pytrader/data/sqlite_cache.py +132 -0
  27. pytrader/data/websocket_client.py +599 -0
  28. pytrader/data/websocket_data_service.py +229 -0
  29. pytrader/data/websocket_stream_provider.py +607 -0
  30. pytrader/indicators.py +148 -0
  31. pytrader/py.typed +1 -0
  32. pytrader/sdk.py +227 -0
  33. pytrader/strategy.py +490 -0
  34. pytrader/strategy_adapter.py +320 -0
  35. pytrader/strategy_loader.py +262 -0
  36. pytrader/streamer.py +57 -0
  37. pytrader/telemetry.py +327 -0
  38. pytrader/trader.py +691 -0
  39. pytrader/trader_core/__init__.py +35 -0
  40. pytrader/trader_core/backtesting/__init__.py +6 -0
  41. pytrader/trader_core/backtesting/engine.py +923 -0
  42. pytrader/trader_core/execution/__init__.py +15 -0
  43. pytrader/trader_core/execution/_websocket_handlers.py +7 -0
  44. pytrader/trader_core/execution/live_engine.py +4802 -0
  45. pytrader/trader_core/execution/paper_account.py +156 -0
  46. pytrader/trader_core/execution/session_manager.py +251 -0
  47. pytrader/trader_core/execution/signal_queue.py +291 -0
  48. pytrader/trader_core/execution/telemetry.py +473 -0
  49. pytrader/trader_core/portfolio/__init__.py +7 -0
  50. pytrader/trader_core/portfolio/metrics.py +225 -0
  51. pytrader/trader_core/portfolio/service.py +643 -0
  52. pytrader/trader_core/strategies/__init__.py +34 -0
  53. pytrader/trader_core/strategies/base.py +45 -0
  54. pytrader/trader_core/strategies/mean_reversion.py +83 -0
  55. pytrader/trader_core/strategies/momentum.py +181 -0
  56. pytrader/trader_core/strategies/testbots.py +248 -0
  57. pytrader/trader_core/utils/__init__.py +19 -0
  58. pytrader/trader_core/utils/metrics_csv.py +256 -0
  59. pytrader/trader_core/utils/risk.py +123 -0
  60. pytrader/trading_config.py +38 -0
  61. pytrader/utils/__init__.py +32 -0
  62. pytrader/utils/bot_manager.py +249 -0
  63. pytrader/utils/cleanup.py +31 -0
  64. pytrader/utils/currency.py +41 -0
  65. pytrader/utils/enums.py +40 -0
  66. pytrader/utils/exceptions.py +37 -0
  67. pytrader/utils/execution.py +138 -0
  68. pytrader/utils/id_generator.py +27 -0
  69. pytrader/utils/logger.py +358 -0
  70. pytrader/utils/market_hours.py +323 -0
  71. pytrader/utils/market_hours_service.py +168 -0
  72. pytrader/utils/time_utils.py +125 -0
  73. pytrader_sdk-1.0.1.dist-info/METADATA +260 -0
  74. pytrader_sdk-1.0.1.dist-info/RECORD +77 -0
  75. pytrader_sdk-1.0.1.dist-info/WHEEL +5 -0
  76. pytrader_sdk-1.0.1.dist-info/entry_points.txt +2 -0
  77. 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
+
@@ -0,0 +1,5 @@
1
+ """Command-line interfaces for the PyTrader SDK."""
2
+
3
+ __all__ = ["paper_trading"]
4
+
5
+
@@ -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
+