diff --git a/toptek/config/app.yml b/toptek/config/app.yml index dfbb07f..1c90135 100644 --- a/toptek/config/app.yml +++ b/toptek/config/app.yml @@ -1,4 +1,5 @@ polling_interval_seconds: 5 cache_directory: data/cache models_directory: models +user_data_directory: data/user log_level: INFO diff --git a/toptek/core/model.py b/toptek/core/model.py index eb006f6..1a3fb17 100644 --- a/toptek/core/model.py +++ b/toptek/core/model.py @@ -10,9 +10,12 @@ import numpy as np from sklearn.calibration import CalibratedClassifierCV from sklearn.ensemble import GradientBoostingClassifier +from sklearn.impute import SimpleImputer from sklearn.linear_model import LogisticRegression from sklearn.metrics import accuracy_score, roc_auc_score from sklearn.model_selection import train_test_split +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import StandardScaler @dataclass @@ -34,12 +37,27 @@ def train_classifier( ) -> TrainResult: """Train a basic classifier and persist it to ``models_dir``.""" + X = np.asarray(X, dtype=float) + X = np.where(np.isfinite(X), X, np.nan) + y = np.asarray(y) + X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, shuffle=True, random_state=42) if model_type == "logistic": - model = LogisticRegression(max_iter=1000) + model = Pipeline( + [ + ("imputer", SimpleImputer(strategy="median")), + ("scaler", StandardScaler()), + ("clf", LogisticRegression(max_iter=1000)), + ] + ) elif model_type == "gbm": - model = GradientBoostingClassifier() + model = Pipeline( + [ + ("imputer", SimpleImputer(strategy="median")), + ("clf", GradientBoostingClassifier()), + ] + ) else: raise ValueError("Unknown model type") @@ -92,7 +110,7 @@ def calibrate_classifier( X_cal, y_cal = calibration_data pipeline = load_model(model_path) - calibrator = CalibratedClassifierCV(base_estimator=pipeline, method=method, cv="prefit") + calibrator = CalibratedClassifierCV(estimator=pipeline, method=method, cv="prefit") calibrator.fit(X_cal, y_cal) target_path = output_path or model_path.with_name(f"{model_path.stem}_calibrated.pkl") with target_path.open("wb") as handle: diff --git a/toptek/core/storage.py b/toptek/core/storage.py new file mode 100644 index 0000000..328d234 --- /dev/null +++ b/toptek/core/storage.py @@ -0,0 +1,133 @@ +"""Persistent user storage for GUI state and activity history. + +This module provides a light-weight persistence layer for the GUI so that +session preferences, trained model summaries, and activity history survive +across program restarts. Data is written to JSON on disk and exposed via a +simple publish/subscribe API for real-time UI updates. + +Example: + >>> from pathlib import Path + >>> from core.storage import UserStorage + >>> store = UserStorage(Path("data/user/state.json")) + >>> store.update_section("preferences", {"theme": "dark"}) + >>> store.append_history("login", "Signed in", {"username": "demo"}) +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from threading import RLock +from typing import Any, Callable, Dict, List, MutableMapping + + +@dataclass +class UserEvent: + """Represents a persisted user event.""" + + timestamp: str + event_type: str + message: str + payload: Dict[str, Any] + + +class UserStorage: + """Manage durable user state and publish updates to listeners.""" + + def __init__(self, state_path: Path, *, history_limit: int = 200) -> None: + self.state_path = state_path + self.history_limit = history_limit + self.state_path.parent.mkdir(parents=True, exist_ok=True) + self._lock = RLock() + self._listeners: List[Callable[[Dict[str, Any]], None]] = [] + self._state: Dict[str, Any] = {"sections": {}, "history": []} + self._last_loaded = 0.0 + self._load() + + def subscribe(self, callback: Callable[[Dict[str, Any]], None]) -> None: + """Register *callback* to receive state snapshots when changes occur.""" + + with self._lock: + self._listeners.append(callback) + callback(self.snapshot()) + + def update_section(self, name: str, data: MutableMapping[str, Any]) -> None: + """Persist a structured section under *name*.""" + + with self._lock: + self._state.setdefault("sections", {})[name] = dict(data) + self._persist_locked() + self._broadcast() + + def append_history(self, event_type: str, message: str, payload: Dict[str, Any] | None = None) -> None: + """Append a history event with metadata.""" + + event = UserEvent( + timestamp=datetime.now(tz=timezone.utc).isoformat(), + event_type=event_type, + message=message, + payload=payload or {}, + ) + with self._lock: + history: List[Dict[str, Any]] = self._state.setdefault("history", []) + history.append(event.__dict__) + if len(history) > self.history_limit: + del history[: len(history) - self.history_limit] + self._persist_locked() + self._broadcast() + + def snapshot(self) -> Dict[str, Any]: + """Return a shallow copy of the storage state.""" + + with self._lock: + return json.loads(json.dumps(self._state)) + + def get_section(self, name: str, default: Dict[str, Any] | None = None) -> Dict[str, Any] | None: + """Retrieve a stored section.""" + + with self._lock: + sections = self._state.get("sections", {}) + if name not in sections: + return default + return json.loads(json.dumps(sections[name])) + + def reload_if_changed(self) -> bool: + """Reload state from disk when the backing file changes.""" + + try: + mtime = self.state_path.stat().st_mtime + except FileNotFoundError: + return False + if mtime <= self._last_loaded: + return False + self._load() + self._broadcast() + return True + + # Internal helpers ------------------------------------------------- + + def _broadcast(self) -> None: + snapshot = self.snapshot() + for listener in list(self._listeners): + listener(snapshot) + + def _load(self) -> None: + if self.state_path.exists(): + with self.state_path.open("r", encoding="utf-8") as handle: + try: + self._state = json.load(handle) + except json.JSONDecodeError: + self._state = {"sections": {}, "history": []} + self._last_loaded = self.state_path.stat().st_mtime + else: + self._persist_locked() + + def _persist_locked(self) -> None: + with self.state_path.open("w", encoding="utf-8") as handle: + json.dump(self._state, handle, indent=2) + self._last_loaded = self.state_path.stat().st_mtime + + +__all__ = ["UserStorage", "UserEvent"] diff --git a/toptek/core/utils.py b/toptek/core/utils.py index 42916db..748bcb0 100644 --- a/toptek/core/utils.py +++ b/toptek/core/utils.py @@ -34,11 +34,13 @@ class AppPaths: root: Base directory for the project. cache: Directory path for cached data files. models: Directory path for persisted models. + user_data: Directory path for long-lived user state and history. """ root: Path cache: Path models: Path + user_data: Path def build_logger(name: str, level: str = "INFO") -> logging.Logger: @@ -84,7 +86,7 @@ def load_yaml(path: Path) -> Dict[str, Any]: def ensure_directories(paths: AppPaths) -> None: """Ensure application directories exist.""" - for directory in (paths.cache, paths.models): + for directory in (paths.cache, paths.models, paths.user_data): directory.mkdir(parents=True, exist_ok=True) @@ -119,4 +121,5 @@ def build_paths(root: Path, app_config: Dict[str, Any]) -> AppPaths: 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) + user_dir = root / app_config.get("user_data_directory", "data/user") + return AppPaths(root=root, cache=cache_dir, models=models_dir, user_data=user_dir) diff --git a/toptek/gui/app.py b/toptek/gui/app.py index b8b6d5f..84acb8a 100644 --- a/toptek/gui/app.py +++ b/toptek/gui/app.py @@ -4,43 +4,159 @@ import tkinter as tk from tkinter import ttk -from typing import Dict +from typing import Dict, List -from core import utils +from core import storage as storage_mod, utils class ToptekApp(ttk.Notebook): """Main application notebook containing all tabs.""" - def __init__(self, master: tk.Tk, configs: Dict[str, Dict[str, object]], paths: utils.AppPaths) -> None: + def __init__( + self, + master: tk.Misc, + configs: Dict[str, Dict[str, object]], + paths: utils.AppPaths, + storage: storage_mod.UserStorage, + *, + on_tab_change: callable | None = None, + ) -> None: super().__init__(master) self.configs = configs self.paths = paths + self.storage = storage + self._on_tab_change = on_tab_change + self._tab_names: List[str] = [] + self._tab_guidance: Dict[str, str] = {} self._build_tabs() + self.bind("<>", self._handle_tab_change) def _build_tabs(self) -> None: from . import widgets tabs = { - "Login": widgets.LoginTab, - "Research": widgets.ResearchTab, - "Train": widgets.TrainTab, - "Backtest": widgets.BacktestTab, - "Trade": widgets.TradeTab, + "Login": ( + widgets.LoginTab, + "Step 1 · Secure your API keys and verify environment access.", + ), + "Research": ( + widgets.ResearchTab, + "Step 2 · Explore market structure and feature snapshots.", + ), + "Train": ( + widgets.TrainTab, + "Step 3 · Fit and calibrate models before risking capital.", + ), + "Backtest": ( + widgets.BacktestTab, + "Step 4 · Validate expectancy and drawdown resilience.", + ), + "Trade": ( + widgets.TradeTab, + "Step 5 · Check Topstep guardrails and plan manual execution.", + ), } - for name, cls in tabs.items(): - frame = cls(self, self.configs, self.paths) + for name, (cls, guidance) in tabs.items(): + frame = cls(self, self.configs, self.paths, self.storage) self.add(frame, text=name) + self._tab_names.append(name) + self._tab_guidance[name] = guidance + + def initialise_guidance(self) -> None: + """Invoke the guidance callback for the default tab.""" + + if not self._tab_names: + return + self._dispatch_tab_change(0) + + def _handle_tab_change(self, _: tk.Event) -> None: + index = self.index("current") + self._dispatch_tab_change(index) + + def _dispatch_tab_change(self, index: int) -> None: + if self._on_tab_change is None: + return + name = self._tab_names[index] + guidance = self._tab_guidance.get(name, "") + self._on_tab_change(index, name, guidance) def launch_app(*, configs: Dict[str, Dict[str, object]], paths: utils.AppPaths) -> None: """Initialise and start the Tkinter main loop.""" root = tk.Tk() - root.title("Toptek Starter") - root.geometry("900x600") - notebook = ToptekApp(root, configs, paths) + root.title("Toptek Mission Control") + root.geometry("1024x680") + + storage = storage_mod.UserStorage(paths.user_data / "user_state.json") + + style = ttk.Style() + try: + style.theme_use("clam") + except tk.TclError: + # ``clam`` is widely available, but gracefully fallback if missing. + pass + style.configure("Header.TLabel", font=("Segoe UI", 20, "bold")) + style.configure("SubHeader.TLabel", font=("Segoe UI", 12)) + style.configure("Step.TLabel", font=("Segoe UI", 11)) + style.configure("Guidance.TLabelframe", padding=(12, 10)) + style.configure("Guidance.TLabelframe.Label", font=("Segoe UI", 11, "bold")) + style.configure("TNotebook.Tab", padding=(14, 8)) + + container = ttk.Frame(root, padding=16) + container.pack(fill=tk.BOTH, expand=True) + + header = ttk.Frame(container) + header.pack(fill=tk.X, pady=(0, 12)) + ttk.Label(header, text="Project X · Manual Trading Copilot", style="Header.TLabel").pack(anchor=tk.W) + ttk.Label( + header, + text="Follow the guided workflow from credentials to Topstep-compliant trade plans.", + style="SubHeader.TLabel", + ).pack(anchor=tk.W) + + guidance_card = ttk.Labelframe(container, text="Mission Checklist", style="Guidance.TLabelframe") + guidance_card.pack(fill=tk.X, pady=(0, 12)) + + step_label = ttk.Label(guidance_card, style="Step.TLabel") + step_label.pack(anchor=tk.W) + progress = ttk.Progressbar(guidance_card, maximum=4, mode="determinate", length=220) + progress.pack(anchor=tk.W, pady=(8, 0)) + + def handle_tab_change(index: int, name: str, guidance: str) -> None: + step_label.config(text=f"{guidance}\n→ Current focus: {name} tab") + progress["value"] = index + + history_card = ttk.Labelframe(container, text="Activity timeline", padding=12) + history_card.pack(fill=tk.BOTH, expand=False, pady=(0, 12)) + history_tree = ttk.Treeview(history_card, columns=("time", "event", "detail"), show="headings", height=6) + history_tree.heading("time", text="Timestamp (UTC)") + history_tree.heading("event", text="Event") + history_tree.heading("detail", text="Message") + history_tree.column("time", width=190, anchor=tk.W) + history_tree.column("event", width=120, anchor=tk.W) + history_tree.column("detail", width=520, anchor=tk.W) + history_tree.pack(fill=tk.BOTH, expand=True) + + def sync_history(state: Dict[str, object]) -> None: + history = state.get("history", []) if isinstance(state, dict) else [] + history_tree.delete(*history_tree.get_children()) + for item in history[-12:]: + history_tree.insert("", tk.END, values=(item.get("timestamp", ""), item.get("event_type", ""), item.get("message", ""))) + + storage.subscribe(sync_history) + + def poll_storage() -> None: + storage.reload_if_changed() + interval_ms = int(configs.get("app", {}).get("polling_interval_seconds", 5) * 1000) + root.after(max(1000, interval_ms), poll_storage) + + poll_storage() + + notebook = ToptekApp(container, configs, paths, storage, on_tab_change=handle_tab_change) notebook.pack(fill=tk.BOTH, expand=True) + notebook.initialise_guidance() + root.mainloop() diff --git a/toptek/gui/widgets.py b/toptek/gui/widgets.py index cee161f..4e49053 100644 --- a/toptek/gui/widgets.py +++ b/toptek/gui/widgets.py @@ -9,7 +9,7 @@ import numpy as np -from core import backtest, features, model, risk, utils +from core import backtest, features, model, risk, storage as storage_mod, utils from core.data import sample_dataframe from core.utils import json_dumps @@ -17,34 +17,74 @@ class BaseTab(ttk.Frame): """Base class providing convenience utilities for tabs.""" - def __init__(self, master: ttk.Notebook, configs: Dict[str, Dict[str, object]], paths: utils.AppPaths) -> None: + def __init__( + self, + master: ttk.Notebook, + configs: Dict[str, Dict[str, object]], + paths: utils.AppPaths, + storage: storage_mod.UserStorage, + ) -> None: super().__init__(master) self.configs = configs self.paths = paths + self.storage = storage self.logger = utils.build_logger(self.__class__.__name__) + # Persistence helpers ------------------------------------------------- + + def log_event(self, event_type: str, message: str, payload: Dict[str, object] | None = None) -> None: + """Record an event in the persistent history feed.""" + + self.storage.append_history(event_type, message, payload or {}) + + def update_section(self, name: str, data: Dict[str, object]) -> None: + """Persist structured data for the tab.""" + + self.storage.update_section(name, data) + class LoginTab(BaseTab): """Login tab that manages .env configuration.""" - def __init__(self, master: ttk.Notebook, configs: Dict[str, Dict[str, object]], paths: utils.AppPaths) -> None: - super().__init__(master, configs, paths) + def __init__( + self, + master: ttk.Notebook, + configs: Dict[str, Dict[str, object]], + paths: utils.AppPaths, + storage: storage_mod.UserStorage, + ) -> None: + super().__init__(master, configs, paths, storage) self._build() def _build(self) -> None: + intro = ttk.LabelFrame(self, text="Step 1 · Secure your environment", padding=12) + intro.pack(fill=tk.X, padx=10, pady=(12, 6)) + ttk.Label( + intro, + text=( + "Paste sandbox credentials or API keys. Nothing leaves your machine. " + "Use the guided Save + Verify buttons to confirm readiness before moving on." + ), + wraplength=520, + justify=tk.LEFT, + ).pack(anchor=tk.W) + form = ttk.Frame(self) - form.pack(padx=10, pady=10, fill=tk.X) - self.vars = { - "PX_BASE_URL": tk.StringVar(value=self._env_value("PX_BASE_URL")), - "PX_MARKET_HUB": tk.StringVar(value=self._env_value("PX_MARKET_HUB")), - "PX_USER_HUB": tk.StringVar(value=self._env_value("PX_USER_HUB")), - "PX_USERNAME": tk.StringVar(value=self._env_value("PX_USERNAME")), - "PX_API_KEY": tk.StringVar(value=self._env_value("PX_API_KEY")), - } + form.pack(padx=10, pady=6, fill=tk.X) + cached = self.storage.get_section("credentials", {}) or {} + self.vars = {} + for key in ("PX_BASE_URL", "PX_MARKET_HUB", "PX_USER_HUB", "PX_USERNAME", "PX_API_KEY"): + value = cached.get(key, self._env_value(key)) + self.vars[key] = tk.StringVar(value=value) for row, (label, var) in enumerate(self.vars.items()): ttk.Label(form, text=label).grid(row=row, column=0, sticky=tk.W, padx=4, pady=4) ttk.Entry(form, textvariable=var, width=60).grid(row=row, column=1, padx=4, pady=4) - ttk.Button(form, text="Save .env", command=self._save_env).grid(row=len(self.vars), column=0, columnspan=2, pady=10) + actions = ttk.Frame(self) + actions.pack(fill=tk.X, padx=10, pady=(0, 12)) + ttk.Button(actions, text="Save .env", command=self._save_env).pack(side=tk.LEFT, padx=(0, 6)) + ttk.Button(actions, text="Verify entries", command=self._verify_env).pack(side=tk.LEFT) + self.status = ttk.Label(actions, text="Awaiting verification", foreground="#1d4ed8") + self.status.pack(side=tk.LEFT, padx=12) def _env_value(self, key: str) -> str: return os.environ.get(key, "") @@ -54,86 +94,353 @@ def _save_env(self) -> None: 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.update_section("credentials", {key: var.get() for key, var in self.vars.items()}) + self.log_event("credentials_saved", "Updated .env credentials", {"path": str(env_path)}) messagebox.showinfo("Settings", f"Saved credentials to {env_path}") + self.status.config(text="Saved. Run verification to confirm access.", foreground="#166534") + + def _verify_env(self) -> None: + missing = [key for key, var in self.vars.items() if not var.get().strip()] + if missing: + details = ", ".join(missing) + self.status.config(text=f"Missing: {details}", foreground="#b91c1c") + messagebox.showwarning("Verification", f"Provide values for: {details}") + self.log_event("verification_failed", "Missing credentials during verification", {"missing": missing}) + return + self.status.config(text="All keys present. Proceed to Research ▶", foreground="#15803d") + messagebox.showinfo("Verification", "Environment entries look complete. Continue to the next tab.") + self.log_event("verification_passed", "Credentials verified locally", {"keys": list(self.vars.keys())}) class ResearchTab(BaseTab): """Research tab to preview sample data.""" - def __init__(self, master: ttk.Notebook, configs: Dict[str, Dict[str, object]], paths: utils.AppPaths) -> None: - super().__init__(master, configs, paths) + def __init__( + self, + master: ttk.Notebook, + configs: Dict[str, Dict[str, object]], + paths: utils.AppPaths, + storage: storage_mod.UserStorage, + ) -> None: + super().__init__(master, configs, paths, storage) self._build() def _build(self) -> None: - ttk.Button(self, text="Load sample bars", command=self._load_sample).pack(pady=10) - self.text = tk.Text(self, height=25) - self.text.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) + controls = ttk.LabelFrame(self, text="Step 2 · Research console", padding=12) + controls.pack(fill=tk.X, padx=10, pady=(12, 6)) + + ttk.Label( + controls, + text="1) Choose your focus market and timeframe. 2) Pull sample data to inspect structure and features.", + wraplength=520, + justify=tk.LEFT, + ).grid(row=0, column=0, columnspan=4, sticky=tk.W, pady=(0, 8)) + + saved = self.storage.get_section("research_preferences", {}) or {} + self.symbol_var = tk.StringVar(value=saved.get("symbol", "ES=F")) + self.timeframe_var = tk.StringVar(value=saved.get("timeframe", "5m")) + self.bars_var = tk.IntVar(value=saved.get("bars", 240)) + + ttk.Label(controls, text="Symbol").grid(row=1, column=0, sticky=tk.W, padx=(0, 6)) + ttk.Entry(controls, textvariable=self.symbol_var, width=12).grid(row=1, column=1, sticky=tk.W) + ttk.Label(controls, text="Timeframe").grid(row=1, column=2, sticky=tk.W, padx=(12, 6)) + ttk.Combobox( + controls, + textvariable=self.timeframe_var, + values=("1m", "5m", "15m", "1h", "4h", "1d"), + state="readonly", + width=8, + ).grid(row=1, column=3, sticky=tk.W) + + ttk.Label(controls, text="Bars").grid(row=2, column=0, sticky=tk.W, padx=(0, 6), pady=(6, 0)) + ttk.Spinbox(controls, from_=60, to=1000, increment=60, textvariable=self.bars_var, width=10).grid( + row=2, column=1, sticky=tk.W, pady=(6, 0) + ) + ttk.Button(controls, text="Load sample bars", command=self._load_sample).grid(row=2, column=3, padx=(12, 0), pady=(6, 0)) + + controls.columnconfigure(1, weight=1) + + self.text = tk.Text(self, height=18) + self.text.pack(fill=tk.BOTH, expand=True, padx=10, pady=(6, 4)) + self.summary = ttk.Label(self, anchor=tk.W, justify=tk.LEFT) + self.summary.pack(fill=tk.X, padx=12, pady=(0, 12)) def _load_sample(self) -> None: - df = sample_dataframe(120) + try: + bars = int(self.bars_var.get()) + except (TypeError, ValueError): + bars = 240 + bars = max(60, min(bars, 1000)) + df = sample_dataframe(bars) self.text.delete("1.0", tk.END) - self.text.insert(tk.END, df.tail(10).to_string()) + self.text.insert(tk.END, df.tail(12).to_string()) + + feat_map = features.compute_features(df) + latest = -1 + atr = float(np.nan_to_num(feat_map.get("atr_14", np.array([0.0]))[latest], nan=0.0)) + rsi = float(np.nan_to_num(feat_map.get("rsi_14", np.array([50.0]))[latest], nan=50.0)) + vol = float(np.nan_to_num(feat_map.get("volatility_close", np.array([0.0]))[latest], nan=0.0)) + trend = "uptrend" if df["close"].tail(30).mean() > df["close"].tail(90).mean() else "down/sideways" + self.summary.config( + text=( + f"Symbol {self.symbol_var.get()} · {self.timeframe_var.get()} — ATR14 {atr:.2f} · RSI14 {rsi:.1f} · " + f"20-bar vol {vol:.4f}\nRegime hint: {trend}. Move to Train when the setup looks promising." + ) + ) + pref_payload = { + "symbol": self.symbol_var.get(), + "timeframe": self.timeframe_var.get(), + "bars": bars, + "summary": { + "atr": atr, + "rsi": rsi, + "volatility": vol, + "trend": trend, + }, + } + self.update_section("research_preferences", pref_payload) + self.log_event( + "research_snapshot", + f"Loaded sample data for {self.symbol_var.get()} {self.timeframe_var.get()}", + pref_payload, + ) class TrainTab(BaseTab): """Training tab for running local models.""" - def __init__(self, master: ttk.Notebook, configs: Dict[str, Dict[str, object]], paths: utils.AppPaths) -> None: - super().__init__(master, configs, paths) + def __init__( + self, + master: ttk.Notebook, + configs: Dict[str, Dict[str, object]], + paths: utils.AppPaths, + storage: storage_mod.UserStorage, + ) -> None: + super().__init__(master, configs, paths, storage) self._build() def _build(self) -> None: - ttk.Button(self, text="Train logistic model", command=self._train_model).pack(pady=10) - self.output = tk.Text(self, height=10) - self.output.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) + config = ttk.LabelFrame(self, text="Step 3 · Model lab", padding=12) + config.pack(fill=tk.X, padx=10, pady=(12, 6)) + + ttk.Label( + config, + text="Select a model, choose lookback and optionally calibrate probabilities before saving the artefact.", + wraplength=520, + justify=tk.LEFT, + ).grid(row=0, column=0, columnspan=4, sticky=tk.W) + + saved = self.storage.get_section("training", {}) or {} + self.model_type = tk.StringVar(value=saved.get("model", "logistic")) + self.calibrate_var = tk.BooleanVar(value=saved.get("calibrate", True)) + self.lookback_var = tk.IntVar(value=saved.get("lookback", 480)) + + ttk.Label(config, text="Model").grid(row=1, column=0, sticky=tk.W, pady=(8, 0)) + ttk.Radiobutton(config, text="Logistic", value="logistic", variable=self.model_type).grid( + row=1, column=1, sticky=tk.W, pady=(8, 0) + ) + ttk.Radiobutton(config, text="Gradient Boosting", value="gbm", variable=self.model_type).grid( + row=1, column=2, sticky=tk.W, pady=(8, 0) + ) + + ttk.Label(config, text="Lookback bars").grid(row=2, column=0, sticky=tk.W, pady=(8, 0)) + ttk.Spinbox(config, from_=240, to=2000, increment=120, textvariable=self.lookback_var, width=10).grid( + row=2, column=1, sticky=tk.W, pady=(8, 0) + ) + ttk.Checkbutton(config, text="Calibrate probabilities", variable=self.calibrate_var).grid( + row=2, column=2, sticky=tk.W, pady=(8, 0) + ) + ttk.Button(config, text="Train + Score", command=self._train_model).grid(row=2, column=3, padx=(12, 0), pady=(8, 0)) + + config.columnconfigure(1, weight=1) + + self.output = tk.Text(self, height=12) + self.output.pack(fill=tk.BOTH, expand=True, padx=10, pady=(6, 4)) + self.status = ttk.Label(self, text="Awaiting training run", anchor=tk.W) + self.status.pack(fill=tk.X, padx=12, pady=(0, 12)) def _train_model(self) -> None: - df = sample_dataframe() + try: + lookback = int(self.lookback_var.get()) + except (TypeError, ValueError): + lookback = 480 + lookback = max(240, min(lookback, 2000)) + df = sample_dataframe(lookback) feat_map = features.compute_features(df) X = np.column_stack(list(feat_map.values())) y = (np.diff(df["close"], prepend=df["close"].iloc[0]) > 0).astype(int) - result = model.train_classifier(X, y, models_dir=self.paths.models) + try: + result = model.train_classifier(X, y, model_type=self.model_type.get(), models_dir=self.paths.models) + except ValueError as exc: + messagebox.showerror("Training failed", f"{exc}") + self.status.config(text="Training failed — review logs", foreground="#b91c1c") + self.log_event( + "training_error", + "Model training failed due to invalid data", + {"error": str(exc), "model": self.model_type.get()}, + ) + return + calibrate_report = "skipped" + if self.calibrate_var.get() and len(X) > 60: + cal_size = max(60, int(len(X) * 0.2)) + X_cal = X[-cal_size:] + y_cal = y[-cal_size:] + calibrated_path = model.calibrate_classifier(result.model_path, (X_cal, y_cal)) + calibrate_report = f"calibrated → {calibrated_path.name}" self.output.delete("1.0", tk.END) - self.output.insert(tk.END, json_dumps(result.metrics)) + payload = { + "model": self.model_type.get(), + "metrics": result.metrics, + "threshold": result.threshold, + "calibration": calibrate_report, + "lookback": lookback, + } + self.output.insert(tk.END, json_dumps(payload)) + self.status.config(text="Model artefact refreshed. Continue to Backtest ▶") + training_state = { + "model": self.model_type.get(), + "calibrate": self.calibrate_var.get(), + "lookback": lookback, + "metrics": result.metrics, + "calibration": calibrate_report, + "model_path": str(result.model_path), + } + self.update_section("training", training_state) + self.log_event( + "training_complete", + f"Trained {self.model_type.get()} model", + training_state, + ) class BacktestTab(BaseTab): """Backtesting tab with a simple equity curve display.""" - def __init__(self, master: ttk.Notebook, configs: Dict[str, Dict[str, object]], paths: utils.AppPaths) -> None: - super().__init__(master, configs, paths) + def __init__( + self, + master: ttk.Notebook, + configs: Dict[str, Dict[str, object]], + paths: utils.AppPaths, + storage: storage_mod.UserStorage, + ) -> None: + super().__init__(master, configs, paths, storage) self._build() def _build(self) -> None: - ttk.Button(self, text="Run sample backtest", command=self._run_backtest).pack(pady=10) - self.output = tk.Text(self, height=15) - self.output.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) + controls = ttk.LabelFrame(self, text="Step 4 · Backtest", padding=12) + controls.pack(fill=tk.X, padx=10, pady=(12, 6)) + + ttk.Label( + controls, + text="Stress test expectancy against synthetic regimes before taking ideas live.", + wraplength=520, + justify=tk.LEFT, + ).grid(row=0, column=0, columnspan=4, sticky=tk.W) + + saved = self.storage.get_section("backtest", {}) or {} + self.sample_var = tk.IntVar(value=saved.get("sample", 720)) + self.strategy_var = tk.StringVar(value=saved.get("strategy", "momentum")) + + ttk.Label(controls, text="Sample bars").grid(row=1, column=0, sticky=tk.W, pady=(8, 0)) + ttk.Spinbox(controls, from_=240, to=5000, increment=240, textvariable=self.sample_var, width=10).grid( + row=1, column=1, sticky=tk.W, pady=(8, 0) + ) + ttk.Label(controls, text="Playbook").grid(row=1, column=2, sticky=tk.W, pady=(8, 0)) + ttk.Combobox( + controls, + textvariable=self.strategy_var, + values=("momentum", "mean_reversion"), + state="readonly", + width=16, + ).grid(row=1, column=3, sticky=tk.W, pady=(8, 0)) + ttk.Button(controls, text="Run sample backtest", command=self._run_backtest).grid( + row=2, column=3, padx=(12, 0), pady=(8, 0) + ) + + controls.columnconfigure(1, weight=1) + + self.output = tk.Text(self, height=14) + self.output.pack(fill=tk.BOTH, expand=True, padx=10, pady=(6, 4)) + self.status = ttk.Label(self, text="No simulations yet", anchor=tk.W) + self.status.pack(fill=tk.X, padx=12, pady=(0, 12)) def _run_backtest(self) -> None: - df = sample_dataframe() + try: + sample = int(self.sample_var.get()) + except (TypeError, ValueError): + sample = 720 + sample = max(240, min(sample, 5000)) + df = sample_dataframe(sample) returns = np.log(df["close"]).diff().fillna(0).to_numpy() - signals = (returns > 0).astype(int) + if self.strategy_var.get() == "momentum": + signals = (returns > 0).astype(int) + playbook = "Momentum bias — follow strength" + else: + signals = (returns < 0).astype(int) + playbook = "Mean reversion — fade spikes" result = backtest.run_backtest(returns, signals) payload = { "hit_rate": result.hit_rate, "sharpe": result.sharpe, "max_drawdown": result.max_drawdown, "expectancy": result.expectancy, + "playbook": playbook, + "sample": sample, + "strategy": self.strategy_var.get(), } self.output.delete("1.0", tk.END) self.output.insert(tk.END, json_dumps(payload)) + self.status.config(text="Sim complete. If expectancy holds, draft a manual trade plan ▶") + self.update_section("backtest", payload) + self.log_event( + "backtest_run", + f"Backtest {self.strategy_var.get()} on {sample} bars", + payload, + ) class TradeTab(BaseTab): """Trade tab placeholder for polling order/position data.""" - def __init__(self, master: ttk.Notebook, configs: Dict[str, Dict[str, object]], paths: utils.AppPaths) -> None: - super().__init__(master, configs, paths) + def __init__( + self, + master: ttk.Notebook, + configs: Dict[str, Dict[str, object]], + paths: utils.AppPaths, + storage: storage_mod.UserStorage, + ) -> None: + saved = storage.get_section("trade_guard", {}) or {} + self.guard_status = tk.StringVar(value=saved.get("status_label", "Topstep Guard: pending review")) + super().__init__(master, configs, paths, storage) self._build() def _build(self) -> None: - ttk.Label(self, text="Trade management is available once API credentials are configured.").pack(pady=10) - ttk.Button(self, text="Show risk guard", command=self._show_risk).pack(pady=5) + intro = ttk.LabelFrame(self, text="Step 5 · Execution guard", padding=12) + intro.pack(fill=tk.X, padx=10, pady=(12, 6)) + ttk.Label( + intro, + text=( + "Final pre-flight checks before you place manual orders. Refresh the guard summary to confirm " + "position limits, drawdown caps, and cooldown status." + ), + wraplength=520, + justify=tk.LEFT, + ).pack(anchor=tk.W) + + ttk.Label(intro, textvariable=self.guard_status, foreground="#1d4ed8").pack(anchor=tk.W, pady=(8, 0)) + + ttk.Button(self, text="Refresh Topstep guard", command=self._show_risk).pack(pady=(6, 0)) + self.output = tk.Text(self, height=12) + self.output.pack(fill=tk.BOTH, expand=True, padx=10, pady=(6, 12)) + saved_payload = self.storage.get_section("trade_guard", {}) or {} + if saved_payload: + self.output.insert(tk.END, json_dumps(saved_payload)) + else: + self.output.insert( + tk.END, + "Manual execution only. Awaiting guard refresh...\n" + "Use insights from earlier tabs to justify every trade — and always log rationale.", + ) def _show_risk(self) -> None: profile = risk.RiskProfile( @@ -144,7 +451,28 @@ def _show_risk(self) -> None: cooldown_losses=self.configs["risk"].get("cooldown_losses", 2), cooldown_minutes=self.configs["risk"].get("cooldown_minutes", 30), ) - messagebox.showinfo("Risk", json_dumps(profile.__dict__)) + sample_size = risk.position_size(50000, profile, atr=3.5, tick_value=12.5, risk_per_trade=0.01) + guard = "OK" if sample_size > 0 else "DEFENSIVE_MODE" + self.guard_status.set(f"Topstep Guard: {guard}") + payload = { + "profile": profile.__dict__, + "suggested_contracts": sample_size, + "account_balance_assumed": 50000, + "cooldown_policy": { + "losses": profile.cooldown_losses, + "minutes": profile.cooldown_minutes, + }, + "next_steps": "If guard shows DEFENSIVE_MODE, stand down and review journal before trading.", + "status_label": f"Topstep Guard: {guard}", + } + self.output.delete("1.0", tk.END) + self.output.insert(tk.END, json_dumps(payload)) + self.update_section("trade_guard", payload) + self.log_event( + "guard_refresh", + f"Topstep guard refreshed → {guard}", + payload, + ) __all__ = [