You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

582 lines
20 KiB
Python

"""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,
}