"""TGTC trading in-process service layer. Provides: - TGTCAutoScheduler: asyncio-based intraday scheduler - tgtc_auto_scheduler: module-level singleton - Daemon launched as subprocess (python -m apps.tgtc_trader.daemon) - State persistence to .tgtc_auto_state.json """ from __future__ import annotations import asyncio import datetime as dt import json import logging import os import signal import subprocess import sys import time import traceback from pathlib import Path from typing import Any from zoneinfo import ZoneInfo log = logging.getLogger(__name__) _TZ_ET = ZoneInfo("America/New_York") _DEFAULT_DB = "data/paper/tgtc.db" _MARKET_OPEN = dt.time(9, 30) _EOD_EXIT = dt.time(15, 55) _POST_CLOSE = dt.time(16, 0) # ── Saved-state helpers ─────────────────────────────────────────────────────── def _state_file_path(db_path: str) -> Path: return Path(db_path).parent / ".tgtc_auto_state.json" def load_tgtc_saved_state(db_path: str) -> dict[str, Any] | None: p = _state_file_path(db_path) if not p.exists(): return None try: return json.loads(p.read_text()) except Exception: return None def _save_state(db_path: str, running: bool, sessions: list[str], dry_run: bool) -> None: try: _state_file_path(db_path).write_text(json.dumps({ "running": running, "sessions": sessions, "dry_run": dry_run, "db_path": db_path, })) except Exception: pass def _clear_state(db_path: str) -> None: try: p = _state_file_path(db_path) if p.exists(): p.unlink() except Exception: pass # ── ORB-style daemon controller (subprocess) ────────────────────────────────── _PROJECT_ROOT = Path(__file__).parent.parent.parent class TGTCDaemonController: """Mirrors ORBDaemonController: manages TGTC daemon as a subprocess. Uses a PID file (.tgtc_scheduler.pid) as the authoritative liveness signal so uvicorn --reload hot-reloads cannot spawn duplicate daemons — a fresh controller instance checks the PID file before starting a new subprocess. Communication files: .tgtc_scheduler.pid — daemon writes its PID on startup .tgtc_auto_state.json — sessions / dry_run / db_path (written by controller) tgtc_scheduler.log — human-readable log (written by daemon) """ def __init__(self) -> None: self._process: subprocess.Popen | None = None # type: ignore[type-arg] self._db_path: str = _DEFAULT_DB self._sessions: list[str] = [] self._dry_run: bool = True def _pid_file(self) -> Path: return Path(self._db_path).parent / ".tgtc_scheduler.pid" def _log_file(self) -> Path: return Path(self._db_path).parent / "tgtc_scheduler.log" @property def running(self) -> bool: """True if the daemon process is alive (PID file + kill-0 check, zombie-safe).""" pid_file = self._pid_file() if not pid_file.exists(): return False try: pid = int(pid_file.read_text().strip()) os.kill(pid, 0) # signal 0: probe without sending anything # kill-0 succeeds for zombie processes too — check ps to exclude zombies try: result = subprocess.run( ["ps", "-p", str(pid), "-o", "stat="], capture_output=True, text=True, timeout=2, ) stat = result.stdout.strip() if stat.startswith("Z"): # Zombie — treat as dead, clean up PID file try: pid_file.unlink() except Exception: pass return False except Exception: pass # if ps fails, trust kill-0 result return True except (ValueError, ProcessLookupError, PermissionError, OSError): # Process dead or PID stale — remove the file try: pid_file.unlink() except Exception: pass return False def start( self, sessions: list[str], db_path: str, dry_run: bool = True, start_now: bool = False, collect_duration_secs: int = 300, quick_end_after_mins: int = 60, ) -> None: self._db_path = db_path # set FIRST so _pid_file() uses the right dir if self.running: raise RuntimeError("TGTCDaemonController already running") self._sessions = sessions self._dry_run = dry_run _save_state(db_path, True, sessions, dry_run) env = os.environ.copy() env["TGTC_TRADER_DB"] = db_path env["TGTC_DRY_RUN"] = "1" if dry_run else "0" env["TGTC_LOG_FILE"] = str(self._log_file()) env["TGTC_START_NOW"] = "1" if start_now else "0" env["TGTC_COLLECT_DURATION_SECS"] = str(collect_duration_secs) env["TGTC_QUICK_END_AFTER_MINS"] = str(quick_end_after_mins) self._process = subprocess.Popen( [sys.executable, "-m", "apps.tgtc_trader.daemon"], env=env, cwd=str(_PROJECT_ROOT), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, ) log.info("TGTC daemon started (pid=%d, dry_run=%s)", self._process.pid, dry_run) # Wait up to 3 s for daemon to write its PID file for _ in range(30): time.sleep(0.1) if self._pid_file().exists(): break def stop(self) -> None: """Send SIGTERM to daemon and clean up state files.""" pid_file = self._pid_file() if pid_file.exists(): try: pid = int(pid_file.read_text().strip()) os.kill(pid, signal.SIGTERM) except Exception: pass try: pid_file.unlink() except Exception: pass elif self._process and self._process.poll() is None: self._process.terminate() try: self._process.wait(timeout=5) except subprocess.TimeoutExpired: self._process.kill() self._process = None _clear_state(self._db_path) def shutdown(self) -> None: """Server shutdown — keep state so daemon can outlive the web server.""" pass # daemon runs in its own session, survives server restart def get_log(self, lines: int = 200) -> str: try: lf = self._log_file() if lf.exists(): all_lines = lf.read_text(encoding="utf-8").splitlines() return "\n".join(all_lines[-lines:]) except Exception: pass return "" def clear_log(self) -> None: try: lf = self._log_file() if lf.exists(): lf.unlink() except Exception: pass def get_status(self) -> dict[str, Any]: pid_file = self._pid_file() pid: int | None = None if pid_file.exists(): try: pid = int(pid_file.read_text().strip()) except Exception: pass elif self._process: pid = self._process.pid return { "running": self.running, "pid": pid, "sessions": self._sessions, "dry_run": self._dry_run, "db_path": self._db_path, } tgtc_auto_scheduler = TGTCDaemonController() # ── Backtest task registry (in-process) ────────────────────────────────────── import threading _bt_tasks: dict[str, dict[str, Any]] = {} _bt_lock = threading.Lock() _BT_TASKS_DIR = Path("runs/.tgtc_bt_tasks") def _save_bt_task(task_id: str, data: dict[str, Any]) -> None: try: _BT_TASKS_DIR.mkdir(parents=True, exist_ok=True) (_BT_TASKS_DIR / f"{task_id}.task.json").write_text(json.dumps(data, default=str)) except Exception: pass def _load_bt_tasks() -> dict[str, dict[str, Any]]: tasks = {} try: for p in _BT_TASKS_DIR.glob("*.task.json"): tid = p.stem.replace(".task", "") try: tasks[tid] = json.loads(p.read_text()) except Exception: pass except Exception: pass return tasks def get_bt_tasks() -> list[dict[str, Any]]: with _bt_lock: disk = _load_bt_tasks() merged = {**disk, **_bt_tasks} return sorted(merged.values(), key=lambda t: t.get("created_at", ""), reverse=True) def get_bt_task(task_id: str) -> dict[str, Any] | None: with _bt_lock: if task_id in _bt_tasks: return _bt_tasks[task_id] p = _BT_TASKS_DIR / f"{task_id}.task.json" if p.exists(): try: return json.loads(p.read_text()) except Exception: pass return None async def submit_backtest( date_str: str, config_path: str, universe: str | None = None, end_date_str: str | None = None, ) -> str: """Submit a TGTC backtest (single-day or multi-day). Returns task_id.""" import uuid as _uuid task_id = _uuid.uuid4().hex[:8] now = dt.datetime.now(tz=dt.timezone.utc).isoformat() task: dict[str, Any] = { "task_id": task_id, "date": date_str, "end_date": end_date_str or date_str, "config_path": config_path, "universe": universe, "status": "pending", "created_at": now, "result": None, "error": None, } with _bt_lock: _bt_tasks[task_id] = task _save_bt_task(task_id, task) asyncio.create_task(_run_backtest_task(task_id, task)) return task_id async def _run_backtest_task(task_id: str, task: dict[str, Any]) -> None: with _bt_lock: _bt_tasks[task_id]["status"] = "running" try: multiday = task.get("end_date") and task["end_date"] != task["date"] runner = _run_multiday_backtest_sync if multiday else _run_backtest_sync result = await asyncio.to_thread(runner, task) with _bt_lock: _bt_tasks[task_id]["status"] = "completed" _bt_tasks[task_id]["result"] = result _save_bt_task(task_id, _bt_tasks[task_id]) except Exception as exc: with _bt_lock: _bt_tasks[task_id]["status"] = "failed" _bt_tasks[task_id]["error"] = str(exc) _save_bt_task(task_id, _bt_tasks[task_id]) log.error("TGTC backtest task %s failed: %s", task_id, exc, exc_info=True) def _run_backtest_sync(task: dict[str, Any]) -> dict[str, Any]: """Synchronous backtest execution (runs in thread pool).""" import datetime as _dt date_str = task["date"] config_path = task["config_path"] universe_override = task.get("universe") from libs.tgtc.domain import load_tgtc_config from libs.tgtc.simulator import run_tgtc_simulation from libs.intraday.cache import IntradayCache, DailyBarCache from libs.intraday.features import enrich_daily_bars from apps.orb_trader.screener import load_universe from libs.tgtc.gainers_reconstruct import _bar_ts_naive_utc cfg = load_tgtc_config(config_path) if universe_override: cfg.universe = universe_override date = _dt.date.fromisoformat(date_str) tickers = load_universe(cfg.universe) intraday_cache = IntradayCache() daily_cache = DailyBarCache() # ── Load today's 5m bars ────────────────────────────────────────────────── bars_by_symbol: dict[str, list[dict]] = {} # Always include QQQ for regime filter, even if not in universe for sym in set(list(tickers) + ["QQQ"]): try: bars = intraday_cache.get(sym, date_str) if bars: bars_by_symbol[sym] = bars except Exception: pass # ── prev_close: from previous trading day's 5m bars ────────────────────── # Scan up to 5 calendar days back to find the last day with cached bars. prev_closes: dict[str, float] = {} close_16et_utc_hour = 20 # 16:00 EDT = 20:00 UTC for delta in range(1, 6): prev_date = (date - _dt.timedelta(days=delta)).isoformat() missing = [s for s in bars_by_symbol if s not in prev_closes] if not missing: break for sym in missing: try: prev_bars = intraday_cache.get(sym, prev_date) if not prev_bars: continue # Use last bar at or before 16:00 ET as official close cutoff = _dt.datetime( *[int(x) for x in prev_date.split("-")], close_16et_utc_hour, 0 ) close_bar = None for b in prev_bars: if _bar_ts_naive_utc(b) <= cutoff: close_bar = b if close_bar: prev_closes[sym] = float(close_bar["close"]) except Exception: pass # ── Enrichment: ATR + avg_dollar_vol from daily cache ──────────────────── enrichment: dict[str, dict] = {} start_daily = (date - _dt.timedelta(days=60)).isoformat() raw_daily: dict[str, list[dict]] = {} for sym in list(bars_by_symbol.keys()): try: rows, _ = daily_cache.get_with_tail(sym, start_daily, date_str) if rows: raw_daily[sym] = rows except Exception: pass if raw_daily: for sym, rows in raw_daily.items(): # Sort and take only rows strictly before date_str (no lookahead) prior = sorted([r for r in rows if r.get("date", "") < date_str], key=lambda r: r["date"]) if not prior: continue # ATR-14 (simplified: avg of high-low over last 14 bars) last14 = prior[-14:] atr14 = (sum(float(b["high"]) - float(b["low"]) for b in last14) / len(last14) if last14 else None) # avg dollar vol 30d last30 = prior[-30:] avg_dv = (sum(float(b["close"]) * float(b.get("volume", 0) or 0) for b in last30) / len(last30) if last30 else None) # prev_close = last available daily close daily_prev = float(prior[-1]["close"]) enrichment[sym] = { "atr_14": atr14, "avg_dollar_vol_30d": avg_dv, "avg_dollar_vol_20d": avg_dv, } if daily_prev > 0 and sym not in prev_closes: prev_closes[sym] = daily_prev # ── QQQ pct change at 10:00 ET ─────────────────────────────────────────── qqq_pct = None qqq_prev = prev_closes.get("QQQ") qqq_bars = bars_by_symbol.get("QQQ", []) if qqq_prev and qqq_prev > 0 and qqq_bars: cutoff = _dt.datetime(date.year, date.month, date.day, 14, 0) # 10:00 EDT = 14:00 UTC bar10 = None for b in qqq_bars: if _bar_ts_naive_utc(b) <= cutoff: bar10 = b if bar10: qqq_pct = (float(bar10["close"]) - qqq_prev) / qqq_prev sim = run_tgtc_simulation( date=date, bars_by_symbol=bars_by_symbol, prev_closes=prev_closes, enrichment=enrichment, qqq_pct_change_at_10=qqq_pct, cfg=cfg, ) trades_out = [] for t in sim.trades: trades_out.append({ "symbol": t.symbol, "entry_price": t.entry_price, "exit_price": t.exit_price, "stop_price": t.stop_price, "shares": t.shares, "entry_bar_idx": t.entry_bar_idx, "exit_bar_idx": t.exit_bar_idx, "exit_reason": t.exit_reason, "pnl": t.pnl, "r_multiple": t.r_multiple, "status": t.status, "tp_collision": getattr(t, "tp_collision", False), "side": getattr(t, "side", "long"), "entry_dt_utc": t.entry_dt_utc.isoformat() if t.entry_dt_utc else None, # Candidate metadata for segmentation analysis "score": getattr(t, "score", 0.0), "rank_persistence": getattr(t, "rank_persistence", 0.0), "rank_velocity": getattr(t, "rank_velocity", 0.0), "price_structure": getattr(t, "price_structure", 0.0), "volume_quality": getattr(t, "volume_quality", 0.0), "relative_strength": getattr(t, "relative_strength", 0.0), "pct_change_at_10": getattr(t, "pct_change_at_10", 0.0), "dollar_volume_20d": getattr(t, "dollar_volume_20d", 0.0), }) return { "date": date_str, "n_trades": sim.n_trades, "total_pnl": round(sim.total_pnl, 2), "total_return_pct": round(sim.total_return_pct * 100, 2), "win_rate": round(sim.win_rate * 100, 1), "initial_equity": sim.initial_equity, "final_equity": sim.final_equity, "n_candidates": len(sim.candidates), "trades": trades_out, "equity_curve": sim.equity_curve, "candidates": [ {k: v for k, v in c.items() if k not in ("bar_idx_10",)} for c in sim.candidates[:20] ], } def _run_multiday_backtest_sync(task: dict[str, Any]) -> dict[str, Any]: """Loop over weekdays in [date, end_date], run single-day sim for each.""" import datetime as _dt start = _dt.date.fromisoformat(task["date"]) end = _dt.date.fromisoformat(task["end_date"]) per_day: list[dict[str, Any]] = [] all_trades: list[dict[str, Any]] = [] equity = 10_000.0 # starting equity for cumulative curve # Read initial_equity from config if available try: from libs.tgtc.domain import load_tgtc_config cfg = load_tgtc_config(task["config_path"]) equity = float(getattr(cfg, "initial_equity", 10_000.0)) except Exception: pass initial_equity = equity equity_curve: list[dict[str, Any]] = [] current = start while current <= end: if current.weekday() >= 5: # skip weekends current += _dt.timedelta(days=1) continue day_task = {**task, "date": current.isoformat(), "end_date": current.isoformat()} try: day_result = _run_backtest_sync(day_task) daily_pnl = day_result.get("total_pnl", 0.0) equity += daily_pnl per_day.append({ "date": current.isoformat(), "n_candidates": day_result.get("n_candidates", 0), "n_trades": day_result.get("n_trades", 0), "pnl": round(daily_pnl, 2), "return_pct": round(day_result.get("total_return_pct", 0.0), 2), "win_rate": round(day_result.get("win_rate", 0.0), 1), "equity": round(equity, 2), }) for t in day_result.get("trades", []): all_trades.append({**t, "date": current.isoformat()}) equity_curve.append({"date": current.isoformat(), "equity": round(equity, 2)}) except Exception as exc: log.warning("TGTC multi-day: skipping %s — %s", current.isoformat(), exc) current += _dt.timedelta(days=1) n_trades = sum(d["n_trades"] for d in per_day) total_pnl = sum(d["pnl"] for d in per_day) wins = sum(1 for t in all_trades if t.get("pnl", 0) > 0) win_rate = round(wins / n_trades * 100, 1) if n_trades else 0.0 return { "type": "multiday", "start_date": task["date"], "end_date": task["end_date"], "n_days": len(per_day), "n_trades": n_trades, "total_pnl": round(total_pnl, 2), "total_return_pct": round((equity - initial_equity) / initial_equity * 100, 2), "win_rate": win_rate, "initial_equity": initial_equity, "final_equity": round(equity, 2), "per_day": per_day, "trades": all_trades, "equity_curve": equity_curve, }