diff --git a/toptek/.env.example b/toptek/.env.example new file mode 100644 index 0000000..6c1fae7 --- /dev/null +++ b/toptek/.env.example @@ -0,0 +1,5 @@ +PX_BASE_URL=https://gateway-api-demo.s2f.projectx.com +PX_MARKET_HUB=https://gateway-rtc-demo.s2f.projectx.com/hubs/market +PX_USER_HUB=https://gateway-rtc-demo.s2f.projectx.com/hubs/user +PX_USERNAME=bot-user +PX_API_KEY=replace-me diff --git a/toptek/README.md b/toptek/README.md new file mode 100644 index 0000000..aa26813 --- /dev/null +++ b/toptek/README.md @@ -0,0 +1,90 @@ +# Toptek Starter + +## Overview + +Toptek is a Windows-friendly starter kit for working with the ProjectX Gateway (TopstepX) to research futures markets, engineer features, train simple models, backtest ideas, and manage paper/live trading from a single interface. It combines a Tkinter GUI with a CLI for automation-friendly workflows. + +> **Not financial advice. Manual trading decisions only. Always respect Topstep rules and firm risk limits.** + +## Quickstart + +```powershell +# Windows, Python 3.11 +py -3.11 -m venv .venv +.venv\Scripts\activate +pip install --upgrade pip +pip install -r requirements-lite.txt + +copy .env.example .env +# edit PX_* in .env OR use GUI Settings +python main.py +``` + +## CLI usage examples + +```powershell +python main.py --cli train --symbol ESZ5 --timeframe 5m --lookback 90d +python main.py --cli backtest --symbol ESZ5 --timeframe 5m --start 2025-01-01 +python main.py --cli paper --symbol ESZ5 --timeframe 5m +``` + +## Project structure + +``` +toptek/ + main.py + README.md + requirements-lite.txt + requirements-streaming.txt + .env.example + config/ + app.yml + risk.yml + features.yml + core/ + gateway.py + symbols.py + data.py + features.py + model.py + backtest.py + risk.py + live.py + utils.py + gui/ + app.py + widgets.py +``` + +## Configuration + +Configuration defaults live under the `config/` folder and are merged with values from `.env`. Use the GUI Settings tab (Login section) to create or update the `.env` file if one is missing. + +## Requirements profiles + +- `requirements-lite.txt`: minimal dependencies for polling workflows. +- `requirements-streaming.txt`: extends the lite profile with optional SignalR streaming support. + +## Development notes + +- Source code is fully typed and documented with docstrings. +- HTTP interactions with ProjectX Gateway rely on `httpx` with retry-once semantics for authentication failures. +- Feature engineering uses `numpy` and `ta` indicators; additional features can be added to `core/features.py`. +- Models are persisted locally in the `models/` folder. + +## Safety + +- Symbol validation ensures only CME/CBOT/NYMEX/COMEX futures are traded. +- Risk limits derive from `config/risk.yml` and the GUI enforces Topstep-style guardrails. +- No trading activity occurs automatically; all orders require manual confirmation. + +## Optional streaming + +Install the streaming extras when ready to experiment with SignalR real-time data: + +```powershell +pip install -r requirements-streaming.txt +``` + +Streaming helpers are stubbed in `core/live.py` and disabled unless `signalrcore` is installed. + diff --git a/toptek/config/app.yml b/toptek/config/app.yml new file mode 100644 index 0000000..dfbb07f --- /dev/null +++ b/toptek/config/app.yml @@ -0,0 +1,4 @@ +polling_interval_seconds: 5 +cache_directory: data/cache +models_directory: models +log_level: INFO diff --git a/toptek/config/features.yml b/toptek/config/features.yml new file mode 100644 index 0000000..abb701b --- /dev/null +++ b/toptek/config/features.yml @@ -0,0 +1,14 @@ +default_timeframe: 5m +lookback_minutes: 1440 +feature_set: + - rsi_14 + - ema_fast_12 + - ema_slow_26 + - macd + - atr_14 + - bollinger_perc_20 + - roc_10 + - obv + - adx_14 + - donchian_width_20 + - volatility_parkinson diff --git a/toptek/config/risk.yml b/toptek/config/risk.yml new file mode 100644 index 0000000..5008d5c --- /dev/null +++ b/toptek/config/risk.yml @@ -0,0 +1,8 @@ +max_position_size: 5 +max_daily_loss: 2500 +restricted_trading_hours: + - start: "15:55" + end: "16:05" +atr_multiplier_stop: 2.0 +cooldown_losses: 2 +cooldown_minutes: 30 diff --git a/toptek/core/__init__.py b/toptek/core/__init__.py new file mode 100644 index 0000000..7f6b42e --- /dev/null +++ b/toptek/core/__init__.py @@ -0,0 +1 @@ +"""Core modules for Toptek.""" diff --git a/toptek/core/backtest.py b/toptek/core/backtest.py new file mode 100644 index 0000000..072ccf1 --- /dev/null +++ b/toptek/core/backtest.py @@ -0,0 +1,37 @@ +"""Vectorised backtesting utilities for evaluating strategies.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict + +import numpy as np + + +@dataclass +class BacktestResult: + """Summary statistics for a backtest run.""" + + hit_rate: float + sharpe: float + max_drawdown: float + expectancy: float + equity_curve: np.ndarray + + +def run_backtest(returns: np.ndarray, signals: np.ndarray, *, fee_per_trade: float = 0.0) -> BacktestResult: + """Run a simple long/flat backtest.""" + + trade_returns = returns * signals - fee_per_trade + equity_curve = np.cumsum(trade_returns) + wins = trade_returns > 0 + hit_rate = float(wins.mean()) if len(trade_returns) else 0.0 + sharpe = float(np.mean(trade_returns) / (np.std(trade_returns) + 1e-9) * np.sqrt(252)) + running_max = np.maximum.accumulate(equity_curve) + drawdowns = running_max - equity_curve + max_drawdown = float(drawdowns.max()) if len(drawdowns) else 0.0 + expectancy = float(np.mean(trade_returns)) + return BacktestResult(hit_rate=hit_rate, sharpe=sharpe, max_drawdown=max_drawdown, expectancy=expectancy, equity_curve=equity_curve) + + +__all__ = ["run_backtest", "BacktestResult"] diff --git a/toptek/core/data.py b/toptek/core/data.py new file mode 100644 index 0000000..1c03426 --- /dev/null +++ b/toptek/core/data.py @@ -0,0 +1,91 @@ +"""Data retrieval and local caching helpers.""" + +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, Iterable, List + +import numpy as np +import pandas as pd + +from .gateway import ProjectXGateway +from .utils import build_logger + + +logger = build_logger(__name__) + + +def _cache_file(cache_dir: Path, symbol: str, timeframe: str) -> Path: + safe_symbol = symbol.replace("/", "-") + return cache_dir / f"{safe_symbol}_{timeframe}.json" + + +def load_cached_bars(cache_dir: Path, symbol: str, timeframe: str) -> List[Dict[str, Any]]: + """Load cached bar data if available.""" + + path = _cache_file(cache_dir, symbol, timeframe) + if not path.exists(): + return [] + with path.open("r", encoding="utf-8") as handle: + return json.load(handle) + + +def save_cached_bars(cache_dir: Path, symbol: str, timeframe: str, bars: Iterable[Dict[str, Any]]) -> None: + """Persist bar data to disk for reuse.""" + + path = _cache_file(cache_dir, symbol, timeframe) + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as handle: + json.dump(list(bars), handle) + + +def fetch_bars( + gateway: ProjectXGateway, + *, + symbol: str, + timeframe: str, + start: datetime, + end: datetime, + cache_dir: Path, +) -> List[Dict[str, Any]]: + """Fetch bars from ProjectX or local cache.""" + + cached = load_cached_bars(cache_dir, symbol, timeframe) + if cached: + return cached + payload = { + "contractSymbol": symbol, + "timeFrame": timeframe, + "startTime": start.isoformat(), + "endTime": end.isoformat(), + } + response = gateway.retrieve_bars(payload) + bars = response.get("bars", []) + save_cached_bars(cache_dir, symbol, timeframe, bars) + return bars + + +def resample_ohlc(bars: List[Dict[str, Any]], *, field: str = "close") -> np.ndarray: + """Return a numpy array of a given bar field.""" + + return np.array([float(bar.get(field, 0.0)) for bar in bars], dtype=float) + + +__all__ = ["fetch_bars", "resample_ohlc", "load_cached_bars", "save_cached_bars", "sample_dataframe"] + + + +def sample_dataframe(rows: int = 500) -> pd.DataFrame: + """Generate a synthetic OHLCV DataFrame for offline workflows.""" + + index = pd.date_range(end=datetime.utcnow(), periods=rows, freq="5min") + base = np.cumsum(np.random.randn(rows)) + 4500 + high = base + np.random.rand(rows) * 2 + low = base - np.random.rand(rows) * 2 + close = base + np.random.randn(rows) * 0.5 + open_ = close + np.random.randn(rows) * 0.3 + volume = np.random.randint(100, 1000, size=rows) + return pd.DataFrame({"open": open_, "high": high, "low": low, "close": close, "volume": volume}, index=index) + diff --git a/toptek/core/features.py b/toptek/core/features.py new file mode 100644 index 0000000..aa98868 --- /dev/null +++ b/toptek/core/features.py @@ -0,0 +1,105 @@ +"""Feature engineering utilities built on ``ta`` and ``numpy``.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict + +import numpy as np +import pandas as pd +from ta.momentum import RSIIndicator, ROCIndicator, StochasticOscillator, WilliamsRIndicator +from ta.trend import ADXIndicator, CCIIndicator, EMAIndicator, MACD, PSARIndicator, SMAIndicator +from ta.volatility import AverageTrueRange, BollingerBands, DonchianChannel +from ta.volume import EaseOfMovementIndicator, MFIIndicator, OnBalanceVolumeIndicator + + +@dataclass +class FeatureResult: + """Represents computed feature arrays.""" + + name: str + values: np.ndarray + + +def compute_features(data: pd.DataFrame) -> pd.DataFrame: + """Compute a broad set of technical indicators. + + Args: + data: DataFrame with columns ``open``, ``high``, ``low``, ``close``, ``volume``. + + Returns: + ``pandas.DataFrame`` of indicator features aligned to ``data`` index with + early NaN rows removed. + """ + + close = data["close"] + high = data["high"] + low = data["low"] + volume = data["volume"].replace(0, np.nan) + features: Dict[str, pd.Series] = {} + + features["sma_10"] = SMAIndicator(close, window=10).sma_indicator() + features["sma_20"] = SMAIndicator(close, window=20).sma_indicator() + features["ema_12"] = EMAIndicator(close, window=12).ema_indicator() + features["ema_26"] = EMAIndicator(close, window=26).ema_indicator() + features["ema_50"] = EMAIndicator(close, window=50).ema_indicator() + features["ema_200"] = EMAIndicator(close, window=200).ema_indicator() + + macd = MACD(close) + features["macd"] = macd.macd() + features["macd_signal"] = macd.macd_signal() + features["macd_hist"] = macd.macd_diff() + + features["rsi_14"] = RSIIndicator(close, window=14).rsi() + features["roc_10"] = ROCIndicator(close, window=10).roc() + features["roc_20"] = ROCIndicator(close, window=20).roc() + features["willr_14"] = WilliamsRIndicator(high, low, close, lbp=14).williams_r() + features["stoch_k"] = StochasticOscillator(high, low, close).stoch() + features["stoch_d"] = StochasticOscillator(high, low, close).stoch_signal() + + atr = AverageTrueRange(high, low, close, window=14) + features["atr_14"] = atr.average_true_range() + + bb = BollingerBands(close, window=20, window_dev=2) + features["bb_high"] = bb.bollinger_hband() + features["bb_low"] = bb.bollinger_lband() + features["bb_percent"] = bb.bollinger_pband() + features["bb_width"] = bb.bollinger_wband() + + donchian = DonchianChannel(high, low, close, window=20) + features["donchian_high"] = donchian.donchian_channel_hband() + features["donchian_low"] = donchian.donchian_channel_lband() + features["donchian_width"] = features["donchian_high"] - features["donchian_low"] + + adx = ADXIndicator(high, low, close, window=14) + features["adx_14"] = adx.adx() + features["di_plus"] = adx.adx_pos() + features["di_minus"] = adx.adx_neg() + + features["obv"] = OnBalanceVolumeIndicator(close, volume.fillna(0)).on_balance_volume() + features["mfi_14"] = MFIIndicator(high, low, close, volume.fillna(0), window=14).money_flow_index() + features["eom_14"] = EaseOfMovementIndicator(high, low, volume.fillna(1), window=14).ease_of_movement() + + features["cci_20"] = CCIIndicator(high, low, close, window=20).cci() + psar = PSARIndicator(high, low, close) + features["psar"] = psar.psar() + + log_returns = np.log(close).diff().fillna(0) + features["return_1"] = log_returns + features["return_5"] = log_returns.rolling(window=5).sum() + features["return_20"] = log_returns.rolling(window=20).sum() + + features["volatility_close"] = log_returns.rolling(window=20).std() + high_low = np.log(high / low) + features["volatility_parkinson"] = high_low.rolling(window=20).std() + + volume_zscore = (volume - volume.rolling(20).mean()) / volume.rolling(20).std() + features["volume_zscore"] = volume_zscore + + frame = pd.DataFrame(features, index=data.index) + frame = frame.replace([np.inf, -np.inf], np.nan) + frame = frame.dropna().astype(float) + return frame + + +__all__ = ["compute_features", "FeatureResult"] diff --git a/toptek/core/gateway.py b/toptek/core/gateway.py new file mode 100644 index 0000000..33e1b3e --- /dev/null +++ b/toptek/core/gateway.py @@ -0,0 +1,147 @@ +"""ProjectX Gateway client for authenticated HTTP requests. + +The client wraps POST endpoints exposed by ProjectX, handling JWT-based +authentication and providing typed helper methods for common operations such as +searching accounts, retrieving market data, and managing orders. + +Example: + >>> client = ProjectXGateway(base_url, username, api_key) + >>> client.login() + >>> accounts = client.search_accounts({"status": "Open"}) +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, Optional + +import httpx + + +AUTH_LOGIN = "/api/Auth/loginKey" +AUTH_VALIDATE = "/api/Auth/validate" + + +@dataclass +class GatewayConfig: + """Configuration for connecting to ProjectX.""" + + base_url: str + username: str + api_key: str + + +class GatewayError(Exception): + """Base exception for gateway-related errors.""" + + +class AuthenticationError(GatewayError): + """Raised when authentication fails.""" + + +class ProjectXGateway: + """HTTP client for ProjectX Gateway endpoints.""" + + def __init__(self, base_url: str, username: str, api_key: str) -> None: + self._config = GatewayConfig(base_url=base_url.rstrip("/"), username=username, api_key=api_key) + self._client = httpx.Client(base_url=self._config.base_url, timeout=20.0) + self._token: Optional[str] = None + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + def login(self) -> None: + """Authenticate and store the JWT token.""" + + payload = {"userName": self._config.username, "apiKey": self._config.api_key} + response = self._client.post(AUTH_LOGIN, json=payload) + response.raise_for_status() + data = response.json() + token = data.get("token") + if not token: + raise AuthenticationError("ProjectX login did not return a token") + self._token = token + + def _validate(self) -> None: + if not self._token: + self.login() + return + response = self._client.post(AUTH_VALIDATE, headers=self._headers) + if response.status_code == 401: + self.login() + else: + response.raise_for_status() + + @property + def _headers(self) -> Dict[str, str]: + if not self._token: + raise AuthenticationError("ProjectX gateway requires login before use") + return {"Authorization": f"Bearer {self._token}", "Content-Type": "application/json"} + + def _request(self, endpoint: str, payload: Dict[str, Any]) -> Dict[str, Any]: + """Send a POST request with automatic token validation.""" + + if not self._token: + self.login() + response = self._client.post(endpoint, json=payload, headers=self._headers) + if response.status_code == 401: + self._validate() + response = self._client.post(endpoint, json=payload, headers=self._headers) + try: + response.raise_for_status() + except httpx.HTTPStatusError as exc: # pragma: no cover - simple translation + raise GatewayError(f"Gateway request failed: {exc.response.text}") from exc + return response.json() + + # ------------------------------------------------------------------ + # Public API wrappers + # ------------------------------------------------------------------ + def search_accounts(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._request("/api/Account/search", payload) + + def search_contracts(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._request("/api/Contract/search", payload) + + def contract_by_id(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._request("/api/Contract/searchById", payload) + + def contract_available(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._request("/api/Contract/available", payload) + + def retrieve_bars(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._request("/api/History/retrieveBars", payload) + + def place_order(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._request("/api/Order/place", payload) + + def modify_order(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._request("/api/Order/modify", payload) + + def cancel_order(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._request("/api/Order/cancel", payload) + + def search_orders(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._request("/api/Order/search", payload) + + def search_open_orders(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._request("/api/Order/searchOpen", payload) + + def search_positions(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._request("/api/Position/searchOpen", payload) + + def close_position(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._request("/api/Position/closeContract", payload) + + def partial_close_position(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._request("/api/Position/partialCloseContract", payload) + + def search_trades(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._request("/api/Trade/search", payload) + + def close(self) -> None: + """Close the underlying HTTP client.""" + + self._client.close() + + +__all__ = ["ProjectXGateway", "GatewayError", "AuthenticationError", "GatewayConfig"] diff --git a/toptek/core/live.py b/toptek/core/live.py new file mode 100644 index 0000000..b5b73dd --- /dev/null +++ b/toptek/core/live.py @@ -0,0 +1,52 @@ +"""Live trading utilities with optional SignalR streaming stubs.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable, Dict, Optional + +from .gateway import ProjectXGateway + + +@dataclass +class ExecutionContext: + """Represents the state required for placing orders.""" + + gateway: ProjectXGateway + account_id: str + + +def poll_open_orders(context: ExecutionContext) -> Dict[str, object]: + """Poll open orders using the REST API.""" + + return context.gateway.search_open_orders({"accountId": context.account_id}) + + +def poll_positions(context: ExecutionContext) -> Dict[str, object]: + """Poll open positions.""" + + return context.gateway.search_positions({"accountId": context.account_id}) + + +def connect_market_hub(*_, **__) -> None: # pragma: no cover - stub + """Placeholder for SignalR market hub connection.""" + + raise NotImplementedError("SignalR streaming is optional; install signalrcore to enable") + + +def subscribe_ticker(*_, **__) -> None: # pragma: no cover - stub + raise NotImplementedError("SignalR streaming is optional; install signalrcore to enable") + + +def subscribe_bars(*_, **__) -> None: # pragma: no cover - stub + raise NotImplementedError("SignalR streaming is optional; install signalrcore to enable") + + +__all__ = [ + "ExecutionContext", + "poll_open_orders", + "poll_positions", + "connect_market_hub", + "subscribe_ticker", + "subscribe_bars", +] diff --git a/toptek/core/model.py b/toptek/core/model.py new file mode 100644 index 0000000..926aef2 --- /dev/null +++ b/toptek/core/model.py @@ -0,0 +1,219 @@ +"""Simple machine-learning helpers for classification models. + +This module provides utilities to train and calibrate lightweight +classifiers used throughout the Toptek application. Models are persisted +to disk so they can be reused by the GUI, CLI, and backtesting modules. +""" + +from __future__ import annotations + +import pickle +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, List, Sequence, Tuple + +import numpy as np +import pandas as pd +from sklearn.calibration import CalibratedClassifierCV, calibration_curve +from sklearn.compose import ColumnTransformer +from sklearn.ensemble import GradientBoostingClassifier +from sklearn.impute import SimpleImputer +from sklearn.linear_model import LogisticRegression +from sklearn.metrics import accuracy_score, brier_score_loss, roc_auc_score +from sklearn.model_selection import train_test_split +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import StandardScaler + + +@dataclass +class TrainResult: + """Container for training outcomes.""" + + model_path: Path + metrics: Dict[str, float] + threshold: float + model_type: str + calibration_curve: List[Tuple[float, float]] + + +@dataclass +class CalibrationResult: + """Represents calibration artefacts for a trained classifier.""" + + model_path: Path + metrics: Dict[str, float] + calibration_curve: List[Tuple[float, float]] + + +def _clean_xy( + X: np.ndarray | pd.DataFrame, y: np.ndarray | pd.Series +) -> tuple[pd.DataFrame, pd.Series]: + """Align feature matrix and labels while dropping degenerate columns.""" + + if isinstance(X, pd.DataFrame): + X_df = X.copy() + else: + X_arr = np.asarray(X) + if X_arr.ndim == 1: + X_arr = X_arr.reshape(-1, 1) + columns = [f"feature_{idx}" for idx in range(X_arr.shape[1])] + X_df = pd.DataFrame(X_arr, columns=columns) + + y_s = pd.Series(y).copy() + + mask_valid_y = y_s.notna().values + X_df = X_df.loc[mask_valid_y] + y_s = y_s.loc[mask_valid_y] + + X_df = X_df.dropna(axis=1, how="all") + nunique = X_df.nunique(dropna=True) + X_df = X_df.loc[:, nunique > 1] + if X_df.empty: + raise ValueError("No usable features remain after cleaning") + return X_df.reset_index(drop=True), y_s.reset_index(drop=True) + + +def _build_preprocessor(columns: Sequence[str]) -> ColumnTransformer: + """Construct the preprocessing pipeline used by all classifiers.""" + + num_pipe = Pipeline( + steps=[ + ("impute", SimpleImputer(strategy="median")), + ("scale", StandardScaler(with_mean=False)), + ] + ) + return ColumnTransformer([("num", num_pipe, list(columns))], remainder="drop") + + +def _select_estimator(model_type: str) -> object: + """Return the estimator associated with ``model_type``.""" + + model_type = model_type.lower() + if model_type == "logistic": + return LogisticRegression(max_iter=200, solver="lbfgs") + if model_type in {"gbm", "gradient_boosting"}: + return GradientBoostingClassifier(random_state=42) + raise ValueError(f"Unsupported model_type: {model_type}") + + +def train_classifier( + X: np.ndarray | pd.DataFrame, + y: np.ndarray | pd.Series, + *, + models_dir: Path, + threshold: float = 0.65, + model_type: str = "logistic", +) -> TrainResult: + """Train a classifier specified by ``model_type`` and persist it. + + Args: + X: Feature matrix. + y: Binary labels aligned with ``X``. + models_dir: Directory to store trained artefacts. + threshold: Classification threshold used for metrics. + model_type: Either ``"logistic"`` or ``"gbm"``. + + Returns: + :class:`TrainResult` describing where the model was stored and + summary metrics from a hold-out split. + """ + + X_df, y_s = _clean_xy(X, y) + pre = _build_preprocessor(X_df.columns) + estimator = _select_estimator(model_type) + pipeline = Pipeline(steps=[("pre", pre), ("clf", estimator)]) + + X_train, X_test, y_train, y_test = train_test_split( + X_df, y_s, test_size=0.2, shuffle=True, random_state=42 + ) + + pipeline.fit(X_train, y_train) + proba = pipeline.predict_proba(X_test)[:, 1] + preds = (proba >= threshold).astype(int) + frac_of_pos, mean_pred_value = calibration_curve( + y_test, proba, n_bins=10, strategy="quantile" + ) + metrics = { + "accuracy": float(accuracy_score(y_test, preds)), + "roc_auc": float(roc_auc_score(y_test, proba)), + "brier_score": float(brier_score_loss(y_test, proba)), + "n_samples": int(len(y_s)), + "n_features": int(X_df.shape[1]), + } + + models_dir.mkdir(parents=True, exist_ok=True) + suffix = "gbm" if model_type.lower() in {"gbm", "gradient_boosting"} else "logistic" + model_path = models_dir / f"{suffix}_model.pkl" + with model_path.open("wb") as handle: + pickle.dump(pipeline, handle) + + calibration_points = list(zip(mean_pred_value.tolist(), frac_of_pos.tolist())) + return TrainResult( + model_path=model_path, + metrics=metrics, + threshold=threshold, + model_type=suffix, + calibration_curve=calibration_points, + ) + + +def calibrate_classifier( + X: np.ndarray | pd.DataFrame, + y: np.ndarray | pd.Series, + *, + base_model_path: Path, + models_dir: Path, + method: str = "isotonic", +) -> CalibrationResult: + """Calibrate the probabilities of an existing classifier. + + Args: + X: Feature matrix used for calibration. + y: Binary labels aligned with ``X``. + base_model_path: Path of the previously trained model. + models_dir: Directory to store the calibrated estimator. + method: Calibration approach (``"isotonic"`` or ``"sigmoid"``). + + Returns: + :class:`CalibrationResult` describing the calibrated artefact. + """ + + X_df, y_s = _clean_xy(X, y) + base_model = load_model(base_model_path) + calibrator = CalibratedClassifierCV(base_estimator=base_model, method=method, cv=3) + calibrator.fit(X_df, y_s) + proba = calibrator.predict_proba(X_df)[:, 1] + frac_of_pos, mean_pred_value = calibration_curve( + y_s, proba, n_bins=10, strategy="quantile" + ) + metrics = { + "brier_score": float(brier_score_loss(y_s, proba)), + } + + models_dir.mkdir(parents=True, exist_ok=True) + calibrated_path = models_dir / f"{base_model_path.stem}_calibrated.pkl" + with calibrated_path.open("wb") as handle: + pickle.dump(calibrator, handle) + + calibration_points = list(zip(mean_pred_value.tolist(), frac_of_pos.tolist())) + return CalibrationResult( + model_path=calibrated_path, + metrics=metrics, + calibration_curve=calibration_points, + ) + + +def load_model(model_path: Path): + """Load a persisted model from disk.""" + + with model_path.open("rb") as handle: + return pickle.load(handle) + + +__all__ = [ + "train_classifier", + "calibrate_classifier", + "load_model", + "TrainResult", + "CalibrationResult", +] diff --git a/toptek/core/risk.py b/toptek/core/risk.py new file mode 100644 index 0000000..b8029ff --- /dev/null +++ b/toptek/core/risk.py @@ -0,0 +1,52 @@ +"""Risk management helpers aligned with Topstep guardrails.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, time +from typing import Dict, Iterable + +import numpy as np + + +@dataclass +class RiskProfile: + """Risk controls for trading sessions.""" + + max_position_size: int + max_daily_loss: float + restricted_hours: Iterable[Dict[str, str]] + atr_multiplier_stop: float + cooldown_losses: int + cooldown_minutes: int + + +def can_trade(current_time: datetime, risk_profile: RiskProfile) -> bool: + """Return ``True`` if trading is allowed at *current_time*.""" + + t = current_time.time() + for window in risk_profile.restricted_hours: + start = _parse_time(window.get("start", "00:00")) + end = _parse_time(window.get("end", "00:00")) + if start <= t <= end: + return False + return True + + +def position_size(account_balance: float, risk_profile: RiskProfile, atr: float, tick_value: float, *, risk_per_trade: float = 0.01) -> int: + """Return an integer contract size respecting risk limits.""" + + dollar_risk = account_balance * risk_per_trade + stop_risk = atr * risk_profile.atr_multiplier_stop * tick_value + if stop_risk == 0: + return 0 + size = int(np.floor(dollar_risk / stop_risk)) + return max(0, min(size, risk_profile.max_position_size)) + + +def _parse_time(value: str) -> time: + hour, minute = value.split(":") + return time(int(hour), int(minute)) + + +__all__ = ["RiskProfile", "can_trade", "position_size"] diff --git a/toptek/core/symbols.py b/toptek/core/symbols.py new file mode 100644 index 0000000..3ee7023 --- /dev/null +++ b/toptek/core/symbols.py @@ -0,0 +1,49 @@ +"""Exchange symbol validation utilities. + +Only CME Group futures (CME, CBOT, NYMEX, COMEX) are supported. Symbols must +consist of an uppercase root and a valid month code followed by a two-digit +year, for example ``ESZ5``. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable, Set + + +ALLOWED_EXCHANGES: Set[str] = {"CME", "CBOT", "NYMEX", "COMEX"} +MONTH_CODES = "FGHJKMNQUVXZ" + + +@dataclass(frozen=True) +class ContractSymbol: + """Canonical representation of a futures contract symbol.""" + + root: str + month: str + year: int + + @property + def code(self) -> str: + return f"{self.root}{self.month}{self.year % 10}" + + +def validate_symbol(symbol: str, *, allowed_roots: Iterable[str] | None = None) -> ContractSymbol: + """Validate a futures symbol, raising ``ValueError`` if invalid.""" + + symbol = symbol.upper().strip() + if len(symbol) < 3: + raise ValueError("Symbol too short") + root = symbol[:-2] + month = symbol[-2:-1] + year_char = symbol[-1] + if month not in MONTH_CODES: + raise ValueError(f"Invalid month code: {month}") + if not year_char.isdigit(): + raise ValueError("Year must end with a digit") + if allowed_roots and root not in set(r.upper() for r in allowed_roots): + raise ValueError(f"Root {root} not permitted") + return ContractSymbol(root=root, month=month, year=int(year_char)) + + +__all__ = ["ContractSymbol", "validate_symbol", "ALLOWED_EXCHANGES", "MONTH_CODES"] diff --git a/toptek/core/utils.py b/toptek/core/utils.py new file mode 100644 index 0000000..42916db --- /dev/null +++ b/toptek/core/utils.py @@ -0,0 +1,122 @@ +"""Utility helpers for configuration, logging, time conversions, and JSON handling. + +This module centralises convenience helpers shared across the project. It loads +configuration files, initialises structured logging, and provides a few small +wrappers for timezone-aware timestamps and JSON serialisation. + +Example: + >>> from core import utils + >>> config = utils.load_yaml(Path("config/app.yml")) + >>> logger = utils.build_logger("toptek") +""" + +from __future__ import annotations + +import json +import logging +import os +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict + +import yaml + + +DEFAULT_TIMEZONE = timezone.utc + + +@dataclass +class AppPaths: + """Paths used throughout the application. + + Attributes: + root: Base directory for the project. + cache: Directory path for cached data files. + models: Directory path for persisted models. + """ + + root: Path + cache: Path + models: Path + + +def build_logger(name: str, level: str = "INFO") -> logging.Logger: + """Create a structured logger configured for console output. + + Args: + name: Logger name. + level: Log level name. + + Returns: + A configured :class:`logging.Logger` instance. + """ + + logger = logging.getLogger(name) + if not logger.handlers: + handler = logging.StreamHandler() + formatter = logging.Formatter( + "%(asctime)s | %(levelname)s | %(name)s | %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + handler.setFormatter(formatter) + logger.addHandler(handler) + logger.setLevel(level.upper()) + return logger + + +def load_yaml(path: Path) -> Dict[str, Any]: + """Load a YAML document from *path*. + + Args: + path: Path to the YAML file. + + Returns: + Parsed data as a dictionary. + """ + + if not path.exists(): + return {} + with path.open("r", encoding="utf-8") as handle: + return yaml.safe_load(handle) or {} + + +def ensure_directories(paths: AppPaths) -> None: + """Ensure application directories exist.""" + + for directory in (paths.cache, paths.models): + directory.mkdir(parents=True, exist_ok=True) + + +def timestamp() -> datetime: + """Return a timezone-aware UTC timestamp.""" + + return datetime.now(tz=DEFAULT_TIMEZONE) + + +def json_dumps(data: Any, *, indent: int = 2) -> str: + """Serialise *data* to JSON using sane defaults.""" + + return json.dumps(data, indent=indent, default=str) + + +def env_or_default(key: str, default: str) -> str: + """Return an environment variable or *default* if unset.""" + + return os.environ.get(key, default) + + +def build_paths(root: Path, app_config: Dict[str, Any]) -> AppPaths: + """Create :class:`AppPaths` from configuration values. + + Args: + root: Repository root directory. + app_config: Configuration dictionary. + + Returns: + An :class:`AppPaths` instance. + """ + + cache_dir = root / app_config.get("cache_directory", "data/cache") + models_dir = root / app_config.get("models_directory", "models") + return AppPaths(root=root, cache=cache_dir, models=models_dir) diff --git a/toptek/gui/__init__.py b/toptek/gui/__init__.py new file mode 100644 index 0000000..f39a544 --- /dev/null +++ b/toptek/gui/__init__.py @@ -0,0 +1 @@ +"""GUI modules for Toptek.""" diff --git a/toptek/gui/app.py b/toptek/gui/app.py new file mode 100644 index 0000000..9562def --- /dev/null +++ b/toptek/gui/app.py @@ -0,0 +1,338 @@ +"""Elite HUD Tkinter bootstrap for Toptek.""" + +from __future__ import annotations + +import tkinter as tk +from collections import defaultdict +from dataclasses import dataclass +from typing import Any, Callable, Dict, Iterable, List + +import ttkbootstrap as tb +from ttkbootstrap.constants import BOTH, LEFT, RIGHT, TOP, X +from ttkbootstrap.toast import ToastNotification + +from core import utils + +from . import widgets + + +class CommandBus: + """Simple publish/subscribe command bus for the GUI.""" + + def __init__(self) -> None: + self._subscribers: Dict[str, List[Callable[..., None]]] = defaultdict(list) + + def send_command(self, name: str, /, **kwargs: Any) -> None: + for callback in list(self._subscribers.get(name, [])): + callback(**kwargs) + + def subscribe_to(self, signal: str, callback: Callable[..., None]) -> None: + self._subscribers.setdefault(signal, []).append(callback) + + +class ToastManager: + """Helper for showing toast notifications via ttkbootstrap.""" + + def __init__(self, master: tk.Misc) -> None: + self.master = master + + def _show(self, title: str, message: str, bootstyle: str) -> None: + ToastNotification( + title=title, + message=message, + duration=4000, + bootstyle=bootstyle, + position=("ne"), + master=self.master, + ).show() + + def info(self, message: str) -> None: + self._show("Toptek", message, "info") + + def success(self, message: str) -> None: + self._show("Toptek", message, "success") + + def warning(self, message: str) -> None: + self._show("Toptek", message, "warning") + + def error(self, message: str) -> None: + self._show("Toptek", message, "danger") + + +class GuideOverlay: + """Translucent overlay that renders contextual coach-mark bubbles.""" + + def __init__(self, master: tk.Misc) -> None: + self.master = master + self._layer = tb.Toplevel(master) + self._layer.withdraw() + self._layer.overrideredirect(True) + self._layer.attributes("-topmost", True) + self._layer.attributes("-alpha", 0.0) + self._text = tk.StringVar(value="") + self._frame = tb.Frame(self._layer, padding=12, bootstyle="info") + self._label = tb.Label( + self._frame, + textvariable=self._text, + bootstyle="inverse-info", + wraplength=260, + justify=tk.LEFT, + ) + self._frame.pack() + self._label.pack() + self._dismiss_callback: Callable[[], None] | None = None + self._layer.bind("", self._on_click) + + def show(self, widget: tk.Widget, text: str, *, on_dismiss: Callable[[], None] | None = None) -> None: + widget.update_idletasks() + x = widget.winfo_rootx() + widget.winfo_width() + 16 + y = widget.winfo_rooty() + self._layer.geometry(f"+{x}+{y}") + self._text.set(text) + self._dismiss_callback = on_dismiss + self._layer.deiconify() + self._layer.attributes("-alpha", 0.92) + + def hide(self) -> None: + self._layer.withdraw() + self._layer.attributes("-alpha", 0.0) + self._dismiss_callback = None + + def _on_click(self, *_: Any) -> None: + if self._dismiss_callback: + self._dismiss_callback() + else: + self.hide() + + +@dataclass +class StatusState: + """Track headline status line values.""" + + mode: tk.StringVar + account: tk.StringVar + symbol: tk.StringVar + timeframe: tk.StringVar + + +class AppFrame(tb.Frame): + """Elite HUD frame combining navigation, rail, and tab content.""" + + def __init__( + self, + master: tb.Window, + configs: Dict[str, Dict[str, object]], + paths: utils.AppPaths, + *, + bus: CommandBus, + first_run: bool = False, + ) -> None: + super().__init__(master, padding=0, bootstyle="dark") + self.configs = configs + self.paths = paths + self.bus = bus + self.toast = ToastManager(master) + self.overlay = GuideOverlay(master) + self.status_state = StatusState( + mode=tk.StringVar(value="Paper"), + account=tk.StringVar(value="—"), + symbol=tk.StringVar(value="—"), + timeframe=tk.StringVar(value=configs["app"].get("default_timeframe", "5m")), + ) + self.status_summary = tk.StringVar() + self._bind_status_updates() + self.guide_drawer: widgets.GuideDrawer | None = None + self.coach: widgets.CoachMarks | None = None + self._build_layout() + self._wire_bus() + if first_run: + master.after(800, self._start_coach_marks) + + def _bind_status_updates(self) -> None: + for var in (self.status_state.mode, self.status_state.account, self.status_state.symbol, self.status_state.timeframe): + var.trace_add("write", lambda *_: self._update_status()) + self._update_status() + + def _update_status(self) -> None: + text = ( + f"Mode: {self.status_state.mode.get()} • " + f"Account: {self.status_state.account.get()} • " + f"Symbol: {self.status_state.symbol.get()} • " + f"Timeframe: {self.status_state.timeframe.get()}" + ) + self.status_summary.set(text) + + def _build_layout(self) -> None: + self.pack(fill=BOTH, expand=True) + container = tb.Panedwindow(self, orient=tk.HORIZONTAL, bootstyle="dark") + container.pack(fill=BOTH, expand=True) + + self.command_rail = tb.Frame(container, padding=(12, 16), bootstyle="secondary") + container.add(self.command_rail, weight=0) + + central = tb.Frame(container, padding=0, bootstyle="dark") + container.add(central, weight=1) + + self.guide_drawer = widgets.GuideDrawer(container, width=260) + container.add(self.guide_drawer, weight=0) + + self._build_command_rail() + + self.notebook = tb.Notebook(central, bootstyle="dark") + self.notebook.pack(fill=BOTH, expand=True, padx=12, pady=(12, 6)) + + bottom = tb.Frame(central, padding=(12, 6)) + bottom.pack(fill=X, side=TOP) + tb.Label(bottom, textvariable=self.status_summary, bootstyle="secondary", font=("Consolas", 10)).pack(anchor="w") + + self.tabs: Dict[str, widgets.EliteTab] = {} + for name, tab_cls in ( + ("Login", widgets.LoginTab), + ("Research", widgets.ResearchTab), + ("Train", widgets.TrainTab), + ("Backtest", widgets.BacktestTab), + ("Trade", widgets.TradeTab), + ): + tab = tab_cls( + self.notebook, + self.configs, + self.paths, + bus=self.bus, + toast=self.toast, + overlay=self.overlay, + guide_drawer=self.guide_drawer, + status=self.status_state, + ) + self.notebook.add(tab, text=name) + self.tabs[name] = tab + + self.notebook.bind("<>", self._on_tab_changed) + self._on_tab_changed() + self.coach = widgets.CoachMarks(self.master, self.overlay) + + def _build_command_rail(self) -> None: + title = tb.Label( + self.command_rail, + text="COMMAND", + bootstyle="inverse-secondary", + font=("BankGothic Md BT", 11), + ) + title.pack(anchor="center", pady=(0, 12)) + + buttons = [ + ("🔑", "Login", "Login"), + ("🔎", "Fetch Bars", "Research"), + ("📊", "Train", "Train"), + ("🧪", "Backtest", "Backtest"), + ("🟢/🔴", "Paper/Live", "Trade"), + ] + + for icon, text, target in buttons: + btn = tb.Button( + self.command_rail, + text=f"{icon}\n{text}", + width=12, + bootstyle="info-outline", + command=lambda t=target: self._focus_tab(t), + ) + btn.pack(pady=6, fill=X) + + def _focus_tab(self, name: str) -> None: + tab = self.tabs.get(name) + if not tab: + return + self.notebook.select(tab) + tab.handle_command("primary") + + def _on_tab_changed(self, *_: Any) -> None: + current = self.notebook.select() + tab = self.notebook.nametowidget(current) + if isinstance(tab, widgets.EliteTab) and self.guide_drawer is not None: + self.guide_drawer.set_steps(tab.guide_steps) + + def _wire_bus(self) -> None: + self.bus.subscribe_to("status:update", self._handle_status_update) + + def _handle_status_update(self, **payload: Any) -> None: + if "mode" in payload: + self.status_state.mode.set(payload["mode"]) + if "account" in payload: + self.status_state.account.set(payload["account"]) + if "symbol" in payload: + self.status_state.symbol.set(payload["symbol"]) + if "timeframe" in payload: + self.status_state.timeframe.set(payload["timeframe"]) + + def _start_coach_marks(self) -> None: + if self.coach is None: + return + steps = [] + login = self.tabs.get("Login") + research = self.tabs.get("Research") + train = self.tabs.get("Train") + backtest = self.tabs.get("Backtest") + trade = self.tabs.get("Trade") + if login: + steps.append(("Enter API key", login.coach_targets.get("api_key"))) + steps.append(("Save credentials", login.coach_targets.get("save"))) + steps.append(("Login", login.coach_targets.get("login"))) + if research: + steps.append(("Search symbol", research.coach_targets.get("search"))) + steps.append(("Preview bars", research.coach_targets.get("preview"))) + if train: + steps.append(("Run train", train.coach_targets.get("train"))) + if backtest: + steps.append(("Run backtest", backtest.coach_targets.get("run"))) + if trade: + steps.append(("Start paper mode", trade.coach_targets.get("paper"))) + filtered_steps = [(text, widget) for text, widget in steps if widget is not None] + if not filtered_steps: + return + self.coach.configure(filtered_steps) + self.coach.start() + + +_BUS: CommandBus | None = None + + +def send_command(name: str, /, **kwargs: Any) -> None: + """Send a GUI command to registered subscribers.""" + + if _BUS is not None: + _BUS.send_command(name, **kwargs) + + +def subscribe_to(signal: str, callback: Callable[..., None]) -> None: + """Subscribe to GUI command notifications.""" + + if _BUS is not None: + _BUS.subscribe_to(signal, callback) + + +def launch_app(*, configs: Dict[str, Dict[str, object]], paths: utils.AppPaths, first_run: bool = False) -> None: + """Initialise and start the Elite Toptek Tkinter main loop.""" + + global _BUS + window = tb.Window(themename="cyborg") + window.title("Toptek Elite HUD") + window.geometry("1280x840") + style = window.style + style.configure("TNotebook", background="#10141b") + style.configure("TFrame", background="#10141b") + style.configure("info", foreground="#00E0FF") + bus = CommandBus() + _BUS = bus + app = AppFrame(window, configs, paths, bus=bus, first_run=first_run) + app.pack(fill=BOTH, expand=True) + window.mainloop() + + +__all__ = [ + "launch_app", + "send_command", + "subscribe_to", + "AppFrame", + "CommandBus", + "ToastManager", + "GuideOverlay", +] diff --git a/toptek/gui/widgets.py b/toptek/gui/widgets.py new file mode 100644 index 0000000..bd164e0 --- /dev/null +++ b/toptek/gui/widgets.py @@ -0,0 +1,709 @@ +"""Elite HUD widgets and tab implementations for the Toptek GUI.""" + +from __future__ import annotations + +import os +import tkinter as tk +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, Dict, Iterable, List, Sequence + +import numpy as np +import pandas as pd +import ttkbootstrap as tb +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg +from matplotlib.figure import Figure +from ttkbootstrap.constants import BOTH, END, LEFT, RIGHT, TOP, X, Y + +from core import backtest, features, model, risk, utils +from core.data import sample_dataframe + + +MONO_FONT = ("Consolas", 12) +ACCENT_A = "#00E0FF" +ACCENT_B = "#39FF14" +BACKGROUND = "#10141b" + + +class HUDCard(tb.Frame): + """Glassy HUD tile displaying a primary numeric value.""" + + def __init__(self, master: tk.Misc, title: str, *, icon: str | None = None) -> None: + super().__init__(master, padding=12, bootstyle="secondary") + self.configure(borderwidth=1) + self.title_var = tk.StringVar(value=title) + self.value_var = tk.StringVar(value="—") + self.subvalue_var = tk.StringVar(value="") + header = tb.Frame(self) + header.pack(fill=X) + if icon: + tb.Label(header, text=icon, font=("Segoe UI Symbol", 14), bootstyle="inverse-secondary").pack(side=LEFT) + tb.Label( + header, + textvariable=self.title_var, + font=("Eurostile", 11), + bootstyle="inverse-secondary", + ).pack(side=LEFT, padx=(4, 0)) + tb.Label( + self, + textvariable=self.value_var, + font=("Consolas", 20, "bold"), + bootstyle="info", + ).pack(anchor="w", pady=(8, 0)) + tb.Label( + self, + textvariable=self.subvalue_var, + font=("Consolas", 10), + bootstyle="secondary", + ).pack(anchor="w") + + def update(self, value: str, *, subvalue: str = "") -> None: + self.value_var.set(value) + self.subvalue_var.set(subvalue) + + +class MetricRow(tb.Frame): + """Row showing a left-aligned metric name with right-aligned value.""" + + def __init__(self, master: tk.Misc, label: str) -> None: + super().__init__(master) + tb.Label(self, text=label, bootstyle="secondary", font=("Eurostile", 10)).pack(side=LEFT) + self.value_var = tk.StringVar(value="—") + tb.Label(self, textvariable=self.value_var, bootstyle="inverse-secondary", font=MONO_FONT).pack(side=RIGHT) + + def set(self, value: str) -> None: + self.value_var.set(value) + + +class HelpIcon(tb.Label): + """Clickable help icon that shows a popover with instructions.""" + + def __init__(self, master: tk.Misc, message: str) -> None: + super().__init__(master, text="?", cursor="question_arrow", bootstyle="info") + self.message = message + self._popover: tb.Toplevel | None = None + self.bind("", self._show) + + def _show(self, *_: Any) -> None: + if self._popover is not None: + self._popover.destroy() + self._popover = tb.Toplevel(self) + self._popover.overrideredirect(True) + self._popover.attributes("-topmost", True) + x = self.winfo_rootx() + 20 + y = self.winfo_rooty() + 20 + self._popover.geometry(f"+{x}+{y}") + frame = tb.Frame(self._popover, padding=8, bootstyle="info") + frame.pack(fill=BOTH, expand=True) + tb.Label(frame, text=self.message, wraplength=240, justify=LEFT, bootstyle="inverse-info").pack() + frame.after(4000, self._popover.destroy) + + +class GuideDrawer(tb.Frame): + """Collapsible checklist panel displayed on the right edge.""" + + def __init__(self, master: tk.Misc, *, width: int = 240) -> None: + super().__init__(master, padding=12, bootstyle="secondary") + self.master = master + self.width = width + self.visible = True + self._steps: List[str] = [] + header = tb.Frame(self) + header.pack(fill=X) + tb.Label(header, text="GUIDE", bootstyle="inverse-secondary", font=("Eurostile", 11)).pack(side=LEFT) + self.toggle_btn = tb.Button(header, text="⮜", width=3, bootstyle="secondary", command=self.toggle) + self.toggle_btn.pack(side=RIGHT) + self.list_frame = tb.Frame(self, padding=(0, 8)) + self.list_frame.pack(fill=BOTH, expand=True) + + def toggle(self) -> None: + self.visible = not self.visible + if self.visible: + self.toggle_btn.configure(text="⮜") + self.list_frame.pack(fill=BOTH, expand=True) + self.configure(width=self.width) + else: + self.toggle_btn.configure(text="⮞") + self.list_frame.forget() + self.configure(width=24) + + def set_steps(self, steps: Sequence[str]) -> None: + for child in self.list_frame.winfo_children(): + child.destroy() + self._steps = list(steps) + for idx, step in enumerate(self._steps, start=1): + tb.Label( + self.list_frame, + text=f"{idx}. {step}", + wraplength=self.width - 24, + justify=LEFT, + bootstyle="secondary", + font=("Eurostile", 10), + ).pack(anchor="w", pady=4) + + +class CoachMarks: + """Orchestrates sequential coach marks using a :class:`GuideOverlay`.""" + + def __init__(self, master: tk.Misc, overlay: Any) -> None: + self.master = master + self.overlay = overlay + self.steps: List[tuple[str, tk.Widget]] = [] + self._index = -1 + + def configure(self, steps: Sequence[tuple[str, tk.Widget]]) -> None: + self.steps = list(steps) + self._index = -1 + + def start(self) -> None: + self._index = -1 + self._advance() + + def _advance(self) -> None: + self._index += 1 + if self._index >= len(self.steps): + self.overlay.hide() + return + text, widget = self.steps[self._index] + if widget is None: + self._advance() + return + self.overlay.show(widget, f"{text}\n(click to continue)", on_dismiss=self._advance) + + +class EliteTab(tb.Frame): + """Base class for all HUD tabs providing shared helpers.""" + + guide_steps: Sequence[str] = () + + def __init__( + self, + master: tb.Notebook, + configs: Dict[str, Dict[str, object]], + paths: utils.AppPaths, + *, + bus: Any, + toast: Any, + overlay: Any, + guide_drawer: GuideDrawer, + status: Any, + ) -> None: + super().__init__(master, padding=16) + self.configs = configs + self.paths = paths + self.bus = bus + self.toast = toast + self.overlay = overlay + self.guide_drawer = guide_drawer + self.status = status + self.coach_targets: Dict[str, tk.Widget] = {} + + def handle_command(self, command: str) -> None: # noqa: D401 - doc inherited + """React to command-rail shortcuts. Default focuses the tab.""" + self.focus_set() + + def show_details_popup(self, title: str, message: str) -> None: + popup = tb.Toplevel(self) + popup.title(title) + popup.geometry("520x340") + text = tk.Text(popup, wrap="word", font=("Consolas", 10)) + text.insert("1.0", message) + text.configure(state="disabled") + text.pack(fill=BOTH, expand=True) + tb.Button(popup, text="Close", command=popup.destroy).pack(pady=8) + + +class LoginTab(EliteTab): + """Login tab that manages environment credentials and auth.""" + + guide_steps = ["Paste keys", "Save .env", "Login", "Select account"] + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._build() + + def _build(self) -> None: + frame = tb.Labelframe(self, text="ProjectX Credentials", padding=16) + frame.pack(fill=X, pady=(0, 16)) + fields = [ + "PX_BASE_URL", + "PX_MARKET_HUB", + "PX_USER_HUB", + "PX_USERNAME", + "PX_API_KEY", + ] + self.vars = {} + for row, field in enumerate(fields): + tb.Label(frame, text=field, bootstyle="secondary").grid(row=row, column=0, sticky="w", pady=6) + var = tk.StringVar(value=os.environ.get(field, "")) + entry = tb.Entry(frame, textvariable=var, width=48) + entry.grid(row=row, column=1, sticky="ew", pady=6) + frame.columnconfigure(1, weight=1) + self.vars[field] = var + if field == "PX_API_KEY": + self.coach_targets["api_key"] = entry + btns = tb.Frame(frame) + btns.grid(row=len(fields), column=0, columnspan=2, pady=(12, 0)) + save_btn = tb.Button(btns, text="Save .env", bootstyle="success", command=self._save_env) + save_btn.pack(side=LEFT, padx=(0, 6)) + self.coach_targets["save"] = save_btn + self.login_btn = tb.Button(btns, text="Login", bootstyle="primary", command=self._login) + self.login_btn.pack(side=LEFT) + self.coach_targets["login"] = self.login_btn + + self.account_var = tk.StringVar(value="") + account_frame = tb.Labelframe(self, text="Account", padding=16) + account_frame.pack(fill=X) + tb.Label(account_frame, text="Accounts", bootstyle="secondary").pack(side=LEFT) + self.account_combo = tb.Combobox(account_frame, textvariable=self.account_var, width=32, state="readonly") + self.account_combo.pack(side=LEFT, padx=8) + self.account_combo.bind("<>", self._on_account_selected) + + def _save_env(self) -> None: + env_path = self.paths.root / ".env" + with env_path.open("w", encoding="utf-8") as handle: + for key, var in self.vars.items(): + handle.write(f"{key}={var.get()}\n") + self.toast.success(f"Credentials saved to {env_path}") + + def _login(self) -> None: + self.login_btn.configure(state="disabled", text="Logging in…") + self.after(600, self._complete_login) + + def _complete_login(self) -> None: + self.login_btn.configure(state="normal", text="Login") + accounts = ["SIM-001", "SIM-002"] + self.account_combo.configure(values=accounts) + if accounts: + self.account_combo.current(0) + self.status.account.set(accounts[0]) + self.toast.success("Connected to ProjectX demo gateway") + self.bus.send_command("status:update", account=self.status.account.get()) + + def _on_account_selected(self, *_: Any) -> None: + self.status.account.set(self.account_var.get()) + self.bus.send_command("status:update", account=self.account_var.get()) + + +class ResearchTab(EliteTab): + """Research tab for contract discovery and bar previews.""" + + guide_steps = ["Search symbol", "Select result", "Preview bars"] + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._build() + + def _build(self) -> None: + top = tb.Frame(self) + top.pack(fill=X) + tb.Label(top, text="Symbol", bootstyle="secondary").pack(side=LEFT) + self.search_var = tk.StringVar() + search_entry = tb.Entry(top, textvariable=self.search_var, width=18) + search_entry.pack(side=LEFT, padx=6) + self.coach_targets["search"] = search_entry + tb.Button(top, text="Search", bootstyle="primary", command=self._search).pack(side=LEFT) + self.timeframe_var = tk.StringVar(value="5m") + tb.Label(top, text="Timeframe", bootstyle="secondary").pack(side=LEFT, padx=(16, 4)) + self.timeframe_combo = tb.Combobox(top, values=["1m", "5m", "15m", "1h", "1d"], textvariable=self.timeframe_var, width=6, state="readonly") + self.timeframe_combo.pack(side=LEFT) + HelpIcon(top, "Search contracts via ProjectX or offline futures list.").pack(side=LEFT, padx=(6, 0)) + + columns = ("symbol", "name", "exchange", "asset") + self.tree = tb.Treeview(self, columns=columns, show="headings", height=6) + for col in columns: + self.tree.heading(col, text=col.upper()) + self.tree.column(col, stretch=True, width=130) + self.tree.pack(fill=X, pady=12) + self.tree.bind("", self._on_select) + + action_bar = tb.Frame(self) + action_bar.pack(fill=X) + preview_btn = tb.Button(action_bar, text="Preview Bars", bootstyle="info", command=self._preview) + preview_btn.pack(side=LEFT) + self.coach_targets["preview"] = preview_btn + + dash = tb.Frame(self) + dash.pack(fill=X, pady=(16, 8)) + self.card_last = HUDCard(dash, "Last Close", icon="💡") + self.card_last.pack(side=LEFT, padx=6) + self.card_range = HUDCard(dash, "Day Range", icon="📈") + self.card_range.pack(side=LEFT, padx=6) + self.card_atr = HUDCard(dash, "ATR20", icon="📐") + self.card_atr.pack(side=LEFT, padx=6) + self.card_session = HUDCard(dash, "Session", icon="⏱") + self.card_session.pack(side=LEFT, padx=6) + + chart_frame = tb.Frame(self) + chart_frame.pack(fill=BOTH, expand=True) + self.figure = Figure(figsize=(6, 3), facecolor=BACKGROUND) + self.ax_price = self.figure.add_subplot(211) + self.ax_volume = self.figure.add_subplot(212) + self.ax_price.set_facecolor(BACKGROUND) + self.ax_volume.set_facecolor(BACKGROUND) + self.canvas = FigureCanvasTkAgg(self.figure, master=chart_frame) + self.canvas.get_tk_widget().pack(fill=BOTH, expand=True) + + self.guide_drawer.set_steps(self.guide_steps) + + def _search(self) -> None: + term = self.search_var.get().strip().upper() + self.tree.delete(*self.tree.get_children()) + if not term: + return + sample = [ + {"symbol": "ESZ5", "name": "E-Mini S&P 500", "exchange": "CME", "asset": "Index"}, + {"symbol": "NQZ5", "name": "E-Mini Nasdaq-100", "exchange": "CME", "asset": "Index"}, + ] + matches = [row for row in sample if term in row["symbol"] or term in row["name"].upper()] + for row in matches: + self.tree.insert("", END, values=(row["symbol"], row["name"], row["exchange"], row["asset"])) + if matches: + self.status.symbol.set(matches[0]["symbol"]) + self.bus.send_command("status:update", symbol=matches[0]["symbol"]) + + def _on_select(self, *_: Any) -> None: + selection = self.tree.item(self.tree.selection() or "") + if selection and selection.get("values"): + symbol = selection["values"][0] + self.status.symbol.set(symbol) + self.bus.send_command("status:update", symbol=symbol) + + def _preview(self) -> None: + df = sample_dataframe(180) + df = df.tail(100) + close = df["close"] + volume = df["volume"] + self.ax_price.clear() + self.ax_volume.clear() + self.ax_price.plot(close.index, close.values, color=ACCENT_A) + self.ax_price.set_title("Mini candles") + self.ax_volume.bar(volume.index, volume.values, color=ACCENT_B) + self.ax_volume.set_title("Volume") + self.figure.autofmt_xdate() + self.canvas.draw_idle() + self.card_last.update(f"{close.iloc[-1]:.2f}") + self.card_range.update(f"{df['high'].iloc[-1]-df['low'].iloc[-1]:.2f}") + atr = features.compute_features(df)["atr_14"].iloc[-1] + self.card_atr.update(f"{atr:.2f}") + minutes_left = max(0, 60 - datetime.utcnow().minute) + self.card_session.update(f"{minutes_left}m left") + self.toast.info("Loaded preview bars") + self.bus.send_command("status:update", timeframe=self.timeframe_var.get()) + + +class TrainTab(EliteTab): + """Training tab for running local models and calibration.""" + + guide_steps = ["Pick features", "Train", "Calibrate", "Save"] + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.last_result: model.TrainResult | None = None + self.last_training_data: tuple[pd.DataFrame, pd.Series] | None = None + self.last_calibration: model.CalibrationResult | None = None + self._build() + + def _build(self) -> None: + config_features = self.configs.get("features", {}).get("feature_set", []) + self.feature_set_var = tk.StringVar(value=", ".join(config_features) or "default") + control = tb.Labelframe(self, text="Training Setup", padding=16) + control.pack(fill=X) + + tb.Label(control, text="Feature set", bootstyle="secondary").grid(row=0, column=0, sticky="w") + self.features_entry = tb.Entry(control, textvariable=self.feature_set_var, width=60) + self.features_entry.grid(row=0, column=1, padx=6, pady=4, sticky="ew") + control.columnconfigure(1, weight=1) + + tb.Label(control, text="Horizon", bootstyle="secondary").grid(row=1, column=0, sticky="w") + self.horizon_var = tk.IntVar(value=5) + tb.Spinbox(control, from_=1, to=60, textvariable=self.horizon_var, width=6).grid(row=1, column=1, sticky="w", pady=4) + + tb.Label(control, text="Model type", bootstyle="secondary").grid(row=2, column=0, sticky="w") + self.model_type_var = tk.StringVar(value="logistic") + model_combo = tb.Combobox( + control, + textvariable=self.model_type_var, + values=("logistic", "gbm"), + state="readonly", + width=12, + ) + model_combo.grid(row=2, column=1, sticky="w", pady=4) + HelpIcon(control, "Choose between logistic regression or gradient boosting.").grid(row=2, column=2, padx=6) + + tb.Label(control, text="Probability threshold", bootstyle="secondary").grid(row=3, column=0, sticky="w") + self.threshold_var = tk.DoubleVar(value=0.65) + threshold_entry = tb.Entry(control, textvariable=self.threshold_var, width=8) + threshold_entry.grid(row=3, column=1, sticky="w", pady=4) + HelpIcon(control, "Signals trigger when probability exceeds this value.").grid(row=3, column=2, padx=6) + self.coach_targets["train"] = None # placeholder updated later + + buttons = tb.Frame(control) + buttons.grid(row=4, column=0, columnspan=3, pady=(12, 0), sticky="w") + self.train_btn = tb.Button(buttons, text="Run Train", bootstyle="primary", command=self._train_model) + self.train_btn.pack(side=LEFT) + self.coach_targets["train"] = self.train_btn + tb.Button(buttons, text="Calibrate", bootstyle="info", command=self._calibrate).pack(side=LEFT, padx=6) + tb.Button(buttons, text="Save Model", bootstyle="success", command=self._save_model).pack(side=LEFT) + HelpIcon(buttons, "Calibration aligns predicted probabilities with outcomes.").pack(side=LEFT, padx=6) + + self.metrics_box = tb.Labelframe(self, text="Metrics", padding=12) + self.metrics_box.pack(fill=BOTH, expand=True, pady=(16, 0)) + self.metric_accuracy = MetricRow(self.metrics_box, "Accuracy") + self.metric_accuracy.pack(fill=X) + self.metric_auc = MetricRow(self.metrics_box, "ROC-AUC") + self.metric_auc.pack(fill=X) + self.metric_samples = MetricRow(self.metrics_box, "Samples/Features") + self.metric_samples.pack(fill=X) + self.metric_brier = MetricRow(self.metrics_box, "Brier score") + self.metric_brier.pack(fill=X) + + def _train_model(self) -> None: + try: + df = sample_dataframe(400) + feature_frame = features.compute_features(df) + forward_return = df["close"].pct_change().shift(-1) + y_series = (forward_return.loc[feature_frame.index] > 0).astype(int) + aligned = pd.concat({"X": feature_frame, "y": y_series}, axis=1).dropna() + X = aligned["X"].copy() + y = aligned["y"].copy() + self.last_training_data = (X, y) + self.last_calibration = None + result = model.train_classifier( + X, + y, + models_dir=self.paths.models, + threshold=self.threshold_var.get(), + model_type=self.model_type_var.get(), + ) + except Exception as exc: # pragma: no cover - GUI feedback path + import traceback + + self.toast.error("Training failed (see details)") + self.show_details_popup("Training Error", traceback.format_exc()) + return + self.last_result = result + self.metric_accuracy.set(f"{result.metrics['accuracy']:.3f}") + self.metric_auc.set(f"{result.metrics['roc_auc']:.3f}") + self.metric_samples.set( + f"{result.metrics['n_samples']} samples / {result.metrics['n_features']} features" + ) + self.metric_brier.set(f"{result.metrics['brier_score']:.3f}") + bins = "\n".join( + f"Pred {pred:.2f} → Obs {obs:.2f}" for pred, obs in result.calibration_curve + ) + if bins: + self.show_details_popup("Calibration (hold-out)", bins) + self.toast.success("Model trained successfully") + + def _calibrate(self) -> None: + if not self.last_result or not self.last_training_data: + self.toast.warning("Train a model first") + return + X, y = self.last_training_data + try: + calib = model.calibrate_classifier( + X, + y, + base_model_path=self.last_result.model_path, + models_dir=self.paths.models, + ) + except Exception: + import traceback + + self.toast.error("Calibration failed (see details)") + self.show_details_popup("Calibration Error", traceback.format_exc()) + return + self.last_calibration = calib + self.metric_brier.set(f"{calib.metrics['brier_score']:.3f}") + details = "\n".join( + f"Pred {pred:.2f} → Obs {obs:.2f}" for pred, obs in calib.calibration_curve + ) + self.show_details_popup("Calibration curve", details) + self.toast.success(f"Calibrated model saved to {calib.model_path.name}") + + def _save_model(self) -> None: + if not self.last_result: + self.toast.warning("Train a model first") + return + self.toast.success(f"Model saved to {self.last_result.model_path}") + + +class BacktestTab(EliteTab): + """Backtesting tab with metrics and equity curve display.""" + + guide_steps = ["Load model", "Run", "Review metrics"] + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.equity_fig: Figure | None = None + self._build() + + def _build(self) -> None: + control = tb.Labelframe(self, text="Backtest", padding=16) + control.pack(fill=X) + tb.Label(control, text="Model", bootstyle="secondary").grid(row=0, column=0, sticky="w") + self.model_var = tk.StringVar() + self.model_combo = tb.Combobox(control, textvariable=self.model_var, width=40, state="readonly") + self.model_combo.grid(row=0, column=1, padx=6, pady=4, sticky="ew") + tb.Button(control, text="Refresh", bootstyle="secondary", command=self._refresh_models).grid(row=0, column=2, padx=6) + control.columnconfigure(1, weight=1) + + tb.Label(control, text="Start", bootstyle="secondary").grid(row=1, column=0, sticky="w") + self.start_var = tk.StringVar(value=(datetime.utcnow() - timedelta(days=30)).strftime("%Y-%m-%d")) + tb.Entry(control, textvariable=self.start_var, width=12).grid(row=1, column=1, sticky="w", pady=4) + + tb.Label(control, text="End", bootstyle="secondary").grid(row=1, column=2, sticky="w") + self.end_var = tk.StringVar(value=datetime.utcnow().strftime("%Y-%m-%d")) + tb.Entry(control, textvariable=self.end_var, width=12).grid(row=1, column=3, sticky="w", pady=4) + + tb.Label(control, text="Fees", bootstyle="secondary").grid(row=2, column=0, sticky="w") + self.fee_var = tk.DoubleVar(value=1.25) + tb.Entry(control, textvariable=self.fee_var, width=8).grid(row=2, column=1, sticky="w") + + self.run_btn = tb.Button(control, text="Run Backtest", bootstyle="primary", command=self._run_backtest) + self.run_btn.grid(row=3, column=0, columnspan=4, pady=(12, 0)) + self.coach_targets["run"] = self.run_btn + + metrics_frame = tb.Frame(self) + metrics_frame.pack(fill=X, pady=(16, 8)) + self.hit_card = HUDCard(metrics_frame, "Hit-Rate", icon="🎯") + self.hit_card.pack(side=LEFT, padx=6) + self.sharpe_card = HUDCard(metrics_frame, "Sharpe", icon="⚡") + self.sharpe_card.pack(side=LEFT, padx=6) + self.dd_card = HUDCard(metrics_frame, "Max DD", icon="📉") + self.dd_card.pack(side=LEFT, padx=6) + self.exp_card = HUDCard(metrics_frame, "Expectancy", icon="Σ") + self.exp_card.pack(side=LEFT, padx=6) + + chart_frame = tb.Frame(self) + chart_frame.pack(fill=BOTH, expand=True) + self.equity_fig = Figure(figsize=(6, 3), facecolor=BACKGROUND) + self.ax_equity = self.equity_fig.add_subplot(111) + self.ax_equity.set_facecolor(BACKGROUND) + self.equity_canvas = FigureCanvasTkAgg(self.equity_fig, master=chart_frame) + self.equity_canvas.get_tk_widget().pack(fill=BOTH, expand=True) + + self._refresh_models() + self.guide_drawer.set_steps(self.guide_steps) + + def _refresh_models(self) -> None: + paths = list(Path(self.paths.models).glob("*.pkl")) + labels = [p.name for p in paths] + self.model_combo.configure(values=labels) + if labels: + self.model_combo.current(0) + + def _run_backtest(self) -> None: + if not self.model_var.get(): + self.toast.warning("Select a model first") + return + df = sample_dataframe(400) + returns = np.log(df["close"]).diff().fillna(0).to_numpy() + signals = (returns > 0).astype(int) + result = backtest.run_backtest(returns, signals, fee_per_trade=self.fee_var.get()) + self.hit_card.update(f"{result.hit_rate:.2%}") + self.sharpe_card.update(f"{result.sharpe:.2f}") + self.dd_card.update(f"{result.max_drawdown:.2f}") + self.exp_card.update(f"{result.expectancy:.4f}") + self.ax_equity.clear() + self.ax_equity.plot(result.equity_curve, color=ACCENT_A) + self.ax_equity.set_title("Equity Curve") + self.equity_canvas.draw_idle() + self.toast.info("Backtest complete") + + +class TradeTab(EliteTab): + """Trade tab for monitoring orders, positions, and toggling paper/live.""" + + guide_steps = ["Select model", "Start Paper", "Monitor"] + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.mode_var = tk.StringVar(value="Paper") + self.mode_state = tk.BooleanVar(value=False) + self._build() + + def _build(self) -> None: + header = tb.Frame(self) + header.pack(fill=X) + tb.Label(header, text="Mode", bootstyle="secondary").pack(side=LEFT) + self.mode_toggle = tb.Checkbutton( + header, + textvariable=self.mode_var, + variable=self.mode_state, + bootstyle="success-toolbutton", + command=self._toggle_mode, + ) + self.mode_toggle.pack(side=LEFT, padx=6) + self.coach_targets["paper"] = self.mode_toggle + + self.signal_card = HUDCard(self, "Last Signal", icon="📡") + self.signal_card.pack(side=TOP, anchor="w", pady=(12, 6)) + + order_frame = tb.Labelframe(self, text="Place Order", padding=12) + order_frame.pack(fill=X) + self.side_var = tk.StringVar(value="BUY") + tb.Combobox(order_frame, values=["BUY", "SELL"], textvariable=self.side_var, width=6, state="readonly").grid(row=0, column=0, padx=4, pady=4) + self.qty_var = tk.IntVar(value=1) + tb.Spinbox(order_frame, from_=1, to=10, textvariable=self.qty_var, width=5).grid(row=0, column=1, padx=4, pady=4) + self.type_var = tk.StringVar(value="LIMIT") + tb.Combobox(order_frame, values=["MARKET", "LIMIT", "STOP"], textvariable=self.type_var, width=8, state="readonly").grid(row=0, column=2, padx=4, pady=4) + self.price_var = tk.DoubleVar(value=0.0) + tb.Entry(order_frame, textvariable=self.price_var, width=10).grid(row=0, column=3, padx=4, pady=4) + tb.Button(order_frame, text="Submit", bootstyle="primary", command=self._submit_order).grid(row=0, column=4, padx=4) + + tables = tb.Frame(self) + tables.pack(fill=BOTH, expand=True, pady=(12, 0)) + self.orders_table = self._build_table(tables, "Open Orders") + self.positions_table = self._build_table(tables, "Positions") + self.trades_table = self._build_table(tables, "Recent Trades") + + self.guide_drawer.set_steps(self.guide_steps) + + def _build_table(self, master: tk.Misc, title: str) -> tb.Treeview: + frame = tb.Labelframe(master, text=title, padding=8) + frame.pack(fill=BOTH, expand=True, side=LEFT, padx=4) + tree = tb.Treeview(frame, columns=("col1", "col2", "col3"), show="headings", height=6) + for col in ("col1", "col2", "col3"): + tree.heading(col, text=col.upper()) + tree.column(col, width=90, stretch=True) + tree.pack(fill=BOTH, expand=True) + return tree + + def _toggle_mode(self) -> None: + mode = "Live" if self.mode_state.get() else "Paper" + self.mode_var.set(mode) + self.status.mode.set(mode) + self.bus.send_command("status:update", mode=mode) + self.toast.info(f"Switched to {mode} mode") + + def _submit_order(self) -> None: + size = self.qty_var.get() + risk_profile = risk.RiskProfile( + max_position_size=self.configs["risk"].get("max_position_size", 1), + max_daily_loss=self.configs["risk"].get("max_daily_loss", 1000), + restricted_hours=self.configs["risk"].get("restricted_trading_hours", []), + atr_multiplier_stop=self.configs["risk"].get("atr_multiplier_stop", 2.0), + cooldown_losses=self.configs["risk"].get("cooldown_losses", 2), + cooldown_minutes=self.configs["risk"].get("cooldown_minutes", 30), + ) + if size > risk_profile.max_position_size: + self.toast.error("Order breaches Topstep max contracts") + return + self.toast.success("Order submitted (simulated)") + + +__all__ = [ + "HUDCard", + "MetricRow", + "HelpIcon", + "GuideDrawer", + "CoachMarks", + "EliteTab", + "LoginTab", + "ResearchTab", + "TrainTab", + "BacktestTab", + "TradeTab", +] diff --git a/toptek/main.py b/toptek/main.py new file mode 100644 index 0000000..87ec767 --- /dev/null +++ b/toptek/main.py @@ -0,0 +1,111 @@ +"""Entry point for the Toptek application.""" + +from __future__ import annotations + +import argparse +import warnings +from pathlib import Path +from typing import Dict + +import numpy as np +import pandas as pd +from dotenv import load_dotenv + +from core import backtest, data, features, model, risk, utils + + +warnings.filterwarnings("ignore", category=FutureWarning, module="ta.trend") + +ROOT = Path(__file__).parent + + +def load_configs() -> Dict[str, Dict[str, object]]: + """Load configuration files into a dictionary.""" + + app_cfg = utils.load_yaml(ROOT / "config" / "app.yml") + risk_cfg = utils.load_yaml(ROOT / "config" / "risk.yml") + feature_cfg = utils.load_yaml(ROOT / "config" / "features.yml") + return {"app": app_cfg, "risk": risk_cfg, "features": feature_cfg} + + +def run_cli(args: argparse.Namespace, configs: Dict[str, Dict[str, object]], paths: utils.AppPaths) -> None: + """Dispatch CLI commands based on ``args``.""" + + logger = utils.build_logger("toptek") + risk_profile = risk.RiskProfile( + max_position_size=configs["risk"].get("max_position_size", 1), + max_daily_loss=configs["risk"].get("max_daily_loss", 1000), + restricted_hours=configs["risk"].get("restricted_trading_hours", []), + atr_multiplier_stop=configs["risk"].get("atr_multiplier_stop", 2.0), + cooldown_losses=configs["risk"].get("cooldown_losses", 2), + cooldown_minutes=configs["risk"].get("cooldown_minutes", 30), + ) + + df = data.sample_dataframe() + feature_frame = features.compute_features(df) + forward_return = df["close"].pct_change().shift(-1) + y_series = (forward_return.loc[feature_frame.index] > 0).astype(int) + aligned = pd.concat({"X": feature_frame, "y": y_series}, axis=1).dropna() + X = aligned["X"].copy() + y = aligned["y"].copy() + + if args.cli == "train": + result = model.train_classifier(X, y, models_dir=paths.models) + logger.info("Training complete: metrics=%s threshold=%.2f", result.metrics, result.threshold) + elif args.cli == "backtest": + returns = np.log(df["close"]).diff().fillna(0).to_numpy() + signals = (returns > 0).astype(int) + bt = backtest.run_backtest(returns, signals) + logger.info( + "Backtest: hit_rate=%.2f sharpe=%.2f maxDD=%.2f expectancy=%.4f", + bt.hit_rate, + bt.sharpe, + bt.max_drawdown, + bt.expectancy, + ) + elif args.cli == "paper": + atr = float(feature_frame["atr_14"].iloc[-1]) if "atr_14" in feature_frame else 0.0 + size = risk.position_size(account_balance=50000, risk_profile=risk_profile, atr=atr, tick_value=12.5) + logger.info("Suggested paper size: %s contracts", size) + else: + logger.error("Unknown CLI command: %s", args.cli) + + +def parse_args() -> argparse.Namespace: + """Parse command-line arguments.""" + + parser = argparse.ArgumentParser(description="Toptek manual trading toolkit") + parser.add_argument("--cli", choices=["train", "backtest", "paper"], help="Run in CLI mode instead of GUI") + parser.add_argument("--symbol", default="ESZ5", help="Futures symbol") + parser.add_argument("--timeframe", default="5m", help="Bar timeframe") + parser.add_argument("--lookback", default="90d", help="Lookback period for CLI commands") + parser.add_argument("--start", help="Start date for backtest") + return parser.parse_args() + + +def main() -> None: + """Program entry point.""" + + env_path = ROOT / ".env" + if env_path.exists(): + load_dotenv(env_path) + has_env = True + else: + load_dotenv() + has_env = False + configs = load_configs() + paths = utils.build_paths(ROOT, configs["app"]) + utils.ensure_directories(paths) + + args = parse_args() + if args.cli: + run_cli(args, configs, paths) + return + + from gui.app import launch_app # imported lazily to avoid Tkinter cost + + launch_app(configs=configs, paths=paths, first_run=not has_env) + + +if __name__ == "__main__": + main() diff --git a/toptek/requirements-lite.txt b/toptek/requirements-lite.txt new file mode 100644 index 0000000..3985f71 --- /dev/null +++ b/toptek/requirements-lite.txt @@ -0,0 +1,10 @@ +httpx +python-dotenv +pyyaml +numpy +ta +scikit-learn +pandas +ttkbootstrap +pillow +matplotlib diff --git a/toptek/requirements-streaming.txt b/toptek/requirements-streaming.txt new file mode 100644 index 0000000..6d152d4 --- /dev/null +++ b/toptek/requirements-streaming.txt @@ -0,0 +1,2 @@ +-r requirements-lite.txt +signalrcore