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.

310 lines
11 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""TGTC intraday scheduler daemon.
Invoked as: python -m apps.tgtc_trader.daemon
Reads active sessions from the TGTC DB and runs each session through the
daily phase sequence (pre_screen → collect_snapshot → finalize_candidates →
entry_check → stop_check → eod_exit → post_close).
The daemon is started by tgtc_service.ORBAutoScheduler-style controller
via subprocess.Popen, and communicates back via .tgtc_trigger_*.json files.
"""
from __future__ import annotations
import asyncio
import datetime as dt
import logging
import os
import signal
import sys
import time
from pathlib import Path
from zoneinfo import ZoneInfo
log = logging.getLogger(__name__)
_TZ_ET = ZoneInfo("America/New_York")
_DB_ENV = os.environ.get("TGTC_TRADER_DB", "data/paper/tgtc.db")
_DRY_RUN = os.environ.get("TGTC_DRY_RUN", "1") == "1"
_LOG_FILE = os.environ.get("TGTC_LOG_FILE", "data/paper/tgtc_scheduler.log")
# Start-now mode env vars
_START_NOW = os.environ.get("TGTC_START_NOW", "0") == "1"
_COLLECT_DURATION_SECS = int(os.environ.get("TGTC_COLLECT_DURATION_SECS", "300"))
_QUICK_END_AFTER_MINS = int(os.environ.get("TGTC_QUICK_END_AFTER_MINS", "60"))
# ── Schedule definition ───────────────────────────────────────────────────────
_PRE_SCREEN_OFFSET_MINS = -10 # 09:20 ET (10 min before market open)
_COLLECTION_START = dt.time(9, 29, 30)
_COLLECTION_END = dt.time(10, 0, 0)
_EOD_EXIT = dt.time(15, 55)
_POST_CLOSE = dt.time(16, 0)
_ENTRY_STOP_START = dt.time(10, 5) # first entry/stop check
_ENTRY_STOP_END = dt.time(15, 50)
_ENTRY_STOP_INTERVAL_MINS = 5
def _is_trading_day(date: dt.date) -> bool:
"""Weekdays only (exchange holiday check omitted for simplicity)."""
return date.weekday() < 5
def _now_et() -> dt.datetime:
return dt.datetime.now(tz=_TZ_ET)
def _sleep_until(target_et: dt.datetime) -> None:
now = _now_et()
delta = (target_et - now).total_seconds()
if delta > 0:
log.info("TGTC daemon: sleeping %.0f s until %s ET", delta, target_et.strftime("%H:%M:%S"))
time.sleep(delta)
def _today_et(time_of_day: dt.time) -> dt.datetime:
today = _now_et().date()
return dt.datetime(today.year, today.month, today.day,
time_of_day.hour, time_of_day.minute, time_of_day.second,
tzinfo=_TZ_ET)
# ── Main loop ─────────────────────────────────────────────────────────────────
async def run_daily(sessions: list, db_path: str) -> None:
"""Run one trading day for all sessions."""
from apps.tgtc_trader.engine import make_tgtc_engine
engines = {}
for sess in sessions:
try:
eng = make_tgtc_engine(
session_id=sess.session_id,
config_path=sess.config_path,
db_path=db_path,
)
engines[sess.session_id] = eng
except Exception as exc:
log.error("TGTC daemon: failed to init engine for %s: %s", sess.session_name, exc)
if not engines:
log.warning("TGTC daemon: no engines initialized, exiting")
return
today = _now_et().date()
date_str = today.isoformat()
# 09:20 ET — pre-screen
pre_screen_time = _today_et(dt.time(9, 20))
_sleep_until(pre_screen_time)
for sid, eng in engines.items():
log.info("TGTC daemon: pre_screen session %s", sid)
eng.run_pre_screen(date_str)
# 09:29:30 ET — start snapshot collection
col_start = _today_et(_COLLECTION_START)
_sleep_until(col_start)
log.info("TGTC daemon: starting snapshot collection")
# Collection is async; run all sessions concurrently
await asyncio.gather(*[eng.run_collect_snapshot(date_str) for eng in engines.values()])
# 10:00 ET — finalize candidates
for sid, eng in engines.items():
log.info("TGTC daemon: finalize_candidates session %s", sid)
eng.run_finalize_candidates(date_str)
# 10:0515:50 ET — entry + stop checks every 5 minutes
cursor = _today_et(_ENTRY_STOP_START)
eod_dt = _today_et(_EOD_EXIT)
while cursor < eod_dt:
_sleep_until(cursor)
now_et = _now_et()
if now_et >= eod_dt:
break
for sid, eng in engines.items():
eng.run_entry_check(date_str)
eng.run_stop_check(date_str)
cursor += dt.timedelta(minutes=_ENTRY_STOP_INTERVAL_MINS)
# 15:55 ET — EOD exit
_sleep_until(eod_dt)
for sid, eng in engines.items():
log.info("TGTC daemon: eod_exit session %s", sid)
eng.run_eod_exit(date_str)
# 16:00 ET — post close
post_close_dt = _today_et(_POST_CLOSE)
_sleep_until(post_close_dt)
for sid, eng in engines.items():
log.info("TGTC daemon: post_close session %s", sid)
eng.run_post_close(date_str)
log.info("TGTC daemon: daily cycle complete for %s", date_str)
async def run_daily_start_now(
sessions: list,
db_path: str,
collect_duration_secs: int = 300,
quick_end_after_mins: int = 60, # kept for backward compat — ignored
) -> None:
"""Run one trading day immediately (START_NOW mode).
Difference from normal schedule: collection starts immediately for
collect_duration_secs, then finalize runs. After that, entry/stop loop
runs on the same 5-min cadence until 15:55 ET (EOD), just like run_daily.
Phases:
1. pre_screen — immediately
2. collect — immediately for collect_duration_secs seconds
3. finalize — immediately after collection ends
4. entry/stop — every 5 min until 15:55 ET (same as normal schedule)
5. eod_exit — 15:55 ET
6. post_close — 16:00 ET
"""
from apps.tgtc_trader.engine import make_tgtc_engine
import datetime as _dt
log.info(
"TGTC daemon: starting in START_NOW mode (collect=%ds, loop until %s ET)",
collect_duration_secs, _EOD_EXIT.strftime("%H:%M"),
)
engines = {}
for sess in sessions:
try:
eng = make_tgtc_engine(
session_id=sess.session_id,
config_path=sess.config_path,
db_path=db_path,
)
engines[sess.session_id] = eng
except Exception as exc:
log.error("TGTC daemon: failed to init engine for %s: %s", sess.session_name, exc)
if not engines:
log.warning("TGTC daemon: no engines initialized, exiting")
return
today = _now_et().date()
date_str = today.isoformat()
# 1. pre_screen — immediately
for sid, eng in engines.items():
log.info("TGTC daemon: pre_screen session %s", sid)
eng.run_pre_screen(date_str)
# 2. Override collection end time to now + collect_duration_secs, then collect
collect_end_et = _now_et() + _dt.timedelta(seconds=collect_duration_secs)
collect_end_str = collect_end_et.strftime("%H:%M")
log.info("TGTC daemon: collect_snapshot starting (end=%s ET)", collect_end_str)
for eng in engines.values():
eng._params.collection.end_et = collect_end_str
await asyncio.gather(*[eng.run_collect_snapshot(date_str) for eng in engines.values()])
# 3. finalize_candidates — immediately after collection
for sid, eng in engines.items():
log.info("TGTC daemon: finalize_candidates session %s", sid)
eng.run_finalize_candidates(date_str)
# 4. entry + stop checks every 5 min until 15:55 ET (same as normal schedule)
eod_dt = _today_et(_EOD_EXIT)
cursor = _now_et() + _dt.timedelta(minutes=_ENTRY_STOP_INTERVAL_MINS)
log.info(
"TGTC daemon: entry/stop loop until %s ET (EOD)",
eod_dt.strftime("%H:%M:%S"),
)
while cursor < eod_dt:
_sleep_until(cursor)
now_et = _now_et()
if now_et >= eod_dt:
break
for sid, eng in engines.items():
eng.run_entry_check(date_str)
eng.run_stop_check(date_str)
cursor += _dt.timedelta(minutes=_ENTRY_STOP_INTERVAL_MINS)
# 5. eod_exit at 15:55 ET
_sleep_until(eod_dt)
for sid, eng in engines.items():
log.info("TGTC daemon: eod_exit session %s", sid)
eng.run_eod_exit(date_str)
# 6. post_close at 16:00 ET
post_close_dt = _today_et(_POST_CLOSE)
_sleep_until(post_close_dt)
for sid, eng in engines.items():
log.info("TGTC daemon: post_close session %s", sid)
eng.run_post_close(date_str)
log.info("TGTC daemon: START_NOW daily cycle complete for %s", date_str)
def main() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler(_LOG_FILE, mode="a"),
],
)
# Resolve DB path relative to project root
project_root = Path(__file__).parent.parent.parent
db_path = _DB_ENV if Path(_DB_ENV).is_absolute() else str(project_root / _DB_ENV)
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
os.chdir(project_root)
# ── PID file: write own PID so TGTCDaemonController.running can detect us ──
pid_file = Path(db_path).parent / ".tgtc_scheduler.pid"
try:
pid_file.write_text(str(os.getpid()))
except Exception as exc:
log.warning("TGTC daemon: could not write PID file: %s", exc)
def _cleanup_pid(*_: object) -> None:
try:
if pid_file.exists():
pid_file.unlink()
except Exception:
pass
# Clean up PID file on SIGTERM
signal.signal(signal.SIGTERM, lambda s, f: (_cleanup_pid(), sys.exit(0)))
try:
from apps.tgtc_trader.state import TGTCStateManager
state = TGTCStateManager(db_path)
sessions = [s for s in state.list_sessions() if s.status == "active"]
if not sessions:
log.warning("TGTC daemon: no active sessions, exiting")
return
log.info(
"TGTC daemon: starting with %d sessions, dry_run=%s, start_now=%s",
len(sessions), _DRY_RUN, _START_NOW,
)
today = _now_et().date()
if not _is_trading_day(today) and not _START_NOW:
log.info("TGTC daemon: %s is not a trading day, exiting", today)
return
if _START_NOW:
asyncio.run(run_daily_start_now(
sessions, db_path,
collect_duration_secs=_COLLECT_DURATION_SECS,
quick_end_after_mins=_QUICK_END_AFTER_MINS,
))
else:
asyncio.run(run_daily(sessions, db_path))
finally:
_cleanup_pid()
if __name__ == "__main__":
main()