|
|
"""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:05–15: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()
|