import json import os import sys import threading from datetime import datetime, timedelta, timezone from pathlib import Path ENGINE_ROOT = Path(__file__).resolve().parents[3] if str(ENGINE_ROOT) not in sys.path: sys.path.append(str(ENGINE_ROOT)) from indian_paper_trading_strategy.engine.market import is_market_open, align_to_market_open from indian_paper_trading_strategy.engine.runner import start_engine, stop_engine from indian_paper_trading_strategy.engine.state import init_paper_state, load_state from indian_paper_trading_strategy.engine.broker import PaperBroker from indian_paper_trading_strategy.engine.time_utils import frequency_to_timedelta from indian_paper_trading_strategy.engine.db import engine_context from app.services.db import db_connection from app.services.run_service import ( create_strategy_run, get_active_run_id, get_running_run_id, update_run_status, ) from app.services.auth_service import get_user_by_id from app.services.email_service import send_email from psycopg2.extras import Json from psycopg2 import errors SEQ_LOCK = threading.Lock() SEQ = 0 LAST_WAIT_LOG_TS = {} WAIT_LOG_INTERVAL = timedelta(seconds=60) def init_log_state(): global SEQ with db_connection() as conn: with conn.cursor() as cur: cur.execute("SELECT COALESCE(MAX(seq), 0) FROM strategy_log") row = cur.fetchone() SEQ = row[0] if row and row[0] is not None else 0 def start_new_run(user_id: str, run_id: str): LAST_WAIT_LOG_TS.pop(run_id, None) emit_event( user_id=user_id, run_id=run_id, event="STRATEGY_STARTED", message="Strategy started", meta={}, ) def stop_run(user_id: str, run_id: str, reason="user_request"): emit_event( user_id=user_id, run_id=run_id, event="STRATEGY_STOPPED", message="Strategy stopped", meta={"reason": reason}, ) def emit_event( *, user_id: str, run_id: str, event: str, message: str, level: str = "INFO", category: str = "ENGINE", meta: dict | None = None ): global SEQ, LAST_WAIT_LOG_TS if not user_id or not run_id: return now = datetime.now(timezone.utc) if event == "SIP_WAITING": last_ts = LAST_WAIT_LOG_TS.get(run_id) if last_ts and (now - last_ts) < WAIT_LOG_INTERVAL: return LAST_WAIT_LOG_TS[run_id] = now with SEQ_LOCK: SEQ += 1 seq = SEQ evt = { "seq": seq, "ts": now.isoformat().replace("+00:00", "Z"), "level": level, "category": category, "event": event, "message": message, "run_id": run_id, "meta": meta or {} } with db_connection() as conn: with conn: with conn.cursor() as cur: cur.execute( """ INSERT INTO strategy_log ( seq, ts, level, category, event, message, user_id, run_id, meta ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) ON CONFLICT (seq) DO NOTHING """, ( evt["seq"], now, evt["level"], evt["category"], evt["event"], evt["message"], user_id, evt["run_id"], Json(evt["meta"]), ), ) def _maybe_parse_json(value): if value is None: return None if not isinstance(value, str): return value text = value.strip() if not text: return None try: return json.loads(text) except Exception: return value def _local_tz(): return datetime.now().astimezone().tzinfo def _format_local_ts(value: datetime | None): if value is None: return None return value.astimezone(_local_tz()).replace(tzinfo=None).isoformat() def _load_config(user_id: str, run_id: str): with db_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT strategy, sip_amount, sip_frequency_value, sip_frequency_unit, mode, broker, active, frequency, frequency_days, unit, next_run FROM strategy_config WHERE user_id = %s AND run_id = %s LIMIT 1 """, (user_id, run_id), ) row = cur.fetchone() if not row: return {} cfg = { "strategy": row[0], "sip_amount": float(row[1]) if row[1] is not None else None, "mode": row[4], "broker": row[5], "active": row[6], "frequency": _maybe_parse_json(row[7]), "frequency_days": row[8], "unit": row[9], "next_run": _format_local_ts(row[10]), } if row[2] is not None or row[3] is not None: cfg["sip_frequency"] = { "value": row[2], "unit": row[3], } return cfg def _save_config(cfg, user_id: str, run_id: str): sip_frequency = cfg.get("sip_frequency") sip_value = None sip_unit = None if isinstance(sip_frequency, dict): sip_value = sip_frequency.get("value") sip_unit = sip_frequency.get("unit") frequency = cfg.get("frequency") if not isinstance(frequency, str) and frequency is not None: frequency = json.dumps(frequency) next_run = cfg.get("next_run") next_run_dt = None if isinstance(next_run, str): try: parsed = datetime.fromisoformat(next_run) if parsed.tzinfo is None: parsed = parsed.replace(tzinfo=_local_tz()) next_run_dt = parsed except ValueError: next_run_dt = None with db_connection() as conn: with conn: with conn.cursor() as cur: cur.execute( """ INSERT INTO strategy_config ( user_id, run_id, strategy, sip_amount, sip_frequency_value, sip_frequency_unit, mode, broker, active, frequency, frequency_days, unit, next_run ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) ON CONFLICT (user_id, run_id) DO UPDATE SET strategy = EXCLUDED.strategy, sip_amount = EXCLUDED.sip_amount, sip_frequency_value = EXCLUDED.sip_frequency_value, sip_frequency_unit = EXCLUDED.sip_frequency_unit, mode = EXCLUDED.mode, broker = EXCLUDED.broker, active = EXCLUDED.active, frequency = EXCLUDED.frequency, frequency_days = EXCLUDED.frequency_days, unit = EXCLUDED.unit, next_run = EXCLUDED.next_run """, ( user_id, run_id, cfg.get("strategy"), cfg.get("sip_amount"), sip_value, sip_unit, cfg.get("mode"), cfg.get("broker"), cfg.get("active"), frequency, cfg.get("frequency_days"), cfg.get("unit"), next_run_dt, ), ) def save_strategy_config(cfg, user_id: str, run_id: str): _save_config(cfg, user_id, run_id) def deactivate_strategy_config(user_id: str, run_id: str): cfg = _load_config(user_id, run_id) cfg["active"] = False _save_config(cfg, user_id, run_id) def _write_status(user_id: str, run_id: str, status): now_local = datetime.now().astimezone() with db_connection() as conn: with conn: with conn.cursor() as cur: cur.execute( """ INSERT INTO engine_status (user_id, run_id, status, last_updated) VALUES (%s, %s, %s, %s) ON CONFLICT (user_id, run_id) DO UPDATE SET status = EXCLUDED.status, last_updated = EXCLUDED.last_updated """, (user_id, run_id, status, now_local), ) def validate_frequency(freq: dict, mode: str): if not isinstance(freq, dict): raise ValueError("Frequency payload is required") value = int(freq.get("value", 0)) unit = freq.get("unit") if unit not in {"minutes", "days"}: raise ValueError(f"Unsupported frequency unit: {unit}") if unit == "minutes": if mode != "PAPER": raise ValueError("Minute-level frequency allowed only in PAPER mode") if value < 1: raise ValueError("Minimum frequency is 1 minute") if unit == "days" and value < 1: raise ValueError("Minimum frequency is 1 day") def compute_next_eligible(last_run: str | None, sip_frequency: dict | None): if not last_run or not sip_frequency: return None try: last_dt = datetime.fromisoformat(last_run) except ValueError: return None try: delta = frequency_to_timedelta(sip_frequency) except ValueError: return None next_dt = last_dt + delta next_dt = align_to_market_open(next_dt) return next_dt.isoformat() def start_strategy(req, user_id: str): engine_external = os.getenv("ENGINE_EXTERNAL", "").strip().lower() in {"1", "true", "yes"} running_run_id = get_running_run_id(user_id) if running_run_id: if engine_external: return {"status": "already_running", "run_id": running_run_id} engine_config = _build_engine_config(user_id, running_run_id, req) if engine_config: started = start_engine(engine_config) if started: _write_status(user_id, running_run_id, "RUNNING") return {"status": "restarted", "run_id": running_run_id} return {"status": "already_running", "run_id": running_run_id} mode = (req.mode or "PAPER").strip().upper() if mode != "PAPER": return {"status": "unsupported_mode"} frequency_payload = req.sip_frequency.dict() if hasattr(req.sip_frequency, "dict") else dict(req.sip_frequency) validate_frequency(frequency_payload, mode) initial_cash = float(req.initial_cash) if req.initial_cash is not None else 1_000_000.0 try: run_id = create_strategy_run( user_id, strategy=req.strategy_name, mode=mode, broker="paper", meta={ "sip_amount": req.sip_amount, "sip_frequency": frequency_payload, "initial_cash": initial_cash, }, ) except errors.UniqueViolation: return {"status": "already_running"} with engine_context(user_id, run_id): init_paper_state(initial_cash, frequency_payload) with db_connection() as conn: with conn: with conn.cursor() as cur: cur.execute( """ INSERT INTO paper_broker_account (user_id, run_id, cash) VALUES (%s, %s, %s) ON CONFLICT (user_id, run_id) DO UPDATE SET cash = EXCLUDED.cash """, (user_id, run_id, initial_cash), ) PaperBroker(initial_cash=initial_cash) config = { "strategy": req.strategy_name, "sip_amount": req.sip_amount, "sip_frequency": frequency_payload, "mode": mode, "broker": "paper", "active": True, } save_strategy_config(config, user_id, run_id) start_new_run(user_id, run_id) _write_status(user_id, run_id, "RUNNING") if not engine_external: def emit_event_cb(*, event: str, message: str, level: str = "INFO", category: str = "ENGINE", meta: dict | None = None): emit_event( user_id=user_id, run_id=run_id, event=event, message=message, level=level, category=category, meta=meta, ) engine_config = dict(config) engine_config["initial_cash"] = initial_cash engine_config["run_id"] = run_id engine_config["user_id"] = user_id engine_config["emit_event"] = emit_event_cb start_engine(engine_config) try: user = get_user_by_id(user_id) if user: body = ( "Your strategy has been started.\n\n" f"Strategy: {req.strategy_name}\n" f"Mode: {mode}\n" f"Run ID: {run_id}\n" ) send_email(user["username"], "Strategy started", body) except Exception: pass return {"status": "started", "run_id": run_id} def _build_engine_config(user_id: str, run_id: str, req=None): cfg = _load_config(user_id, run_id) sip_frequency = cfg.get("sip_frequency") if not isinstance(sip_frequency, dict) and req is not None: sip_frequency = req.sip_frequency.dict() if hasattr(req.sip_frequency, "dict") else dict(req.sip_frequency) if not isinstance(sip_frequency, dict): sip_frequency = {"value": cfg.get("frequency_days") or 1, "unit": cfg.get("unit") or "days"} sip_amount = cfg.get("sip_amount") if sip_amount is None and req is not None: sip_amount = req.sip_amount mode = (cfg.get("mode") or (req.mode if req is not None else "PAPER") or "PAPER").strip().upper() broker = cfg.get("broker") or "paper" strategy_name = cfg.get("strategy") or cfg.get("strategy_name") or (req.strategy_name if req is not None else None) with engine_context(user_id, run_id): state = load_state(mode=mode) initial_cash = float(state.get("initial_cash") or 1_000_000.0) def emit_event_cb(*, event: str, message: str, level: str = "INFO", category: str = "ENGINE", meta: dict | None = None): emit_event( user_id=user_id, run_id=run_id, event=event, message=message, level=level, category=category, meta=meta, ) return { "strategy": strategy_name or "Golden Nifty", "sip_amount": sip_amount or 0, "sip_frequency": sip_frequency, "mode": mode, "broker": broker, "active": cfg.get("active", True), "initial_cash": initial_cash, "user_id": user_id, "run_id": run_id, "emit_event": emit_event_cb, } def resume_running_runs(): engine_external = os.getenv("ENGINE_EXTERNAL", "").strip().lower() in {"1", "true", "yes"} if engine_external: return with db_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT user_id, run_id FROM strategy_run WHERE status = 'RUNNING' ORDER BY created_at DESC """ ) runs = cur.fetchall() for user_id, run_id in runs: engine_config = _build_engine_config(user_id, run_id, None) if not engine_config: continue started = start_engine(engine_config) if started: _write_status(user_id, run_id, "RUNNING") def stop_strategy(user_id: str): run_id = get_active_run_id(user_id) engine_external = os.getenv("ENGINE_EXTERNAL", "").strip().lower() in {"1", "true", "yes"} if not engine_external: stop_engine(user_id, run_id, timeout=15.0) deactivate_strategy_config(user_id, run_id) stop_run(user_id, run_id, reason="user_request") _write_status(user_id, run_id, "STOPPED") update_run_status(user_id, run_id, "STOPPED", meta={"reason": "user_request"}) try: user = get_user_by_id(user_id) if user: body = "Your strategy has been stopped." send_email(user["username"], "Strategy stopped", body) except Exception: pass return {"status": "stopped"} def get_strategy_status(user_id: str): run_id = get_active_run_id(user_id) with db_connection() as conn: with conn.cursor() as cur: cur.execute( "SELECT status, last_updated FROM engine_status WHERE user_id = %s AND run_id = %s", (user_id, run_id), ) row = cur.fetchone() if not row: status = {"status": "IDLE", "last_updated": None} else: status = { "status": row[0], "last_updated": _format_local_ts(row[1]), } if status.get("status") == "RUNNING": cfg = _load_config(user_id, run_id) mode = (cfg.get("mode") or "LIVE").strip().upper() with engine_context(user_id, run_id): state = load_state(mode=mode) last_execution_ts = state.get("last_run") or state.get("last_sip_ts") sip_frequency = cfg.get("sip_frequency") if not isinstance(sip_frequency, dict): frequency = cfg.get("frequency") unit = cfg.get("unit") if isinstance(frequency, dict): unit = frequency.get("unit", unit) frequency = frequency.get("value") if frequency is None and cfg.get("frequency_days") is not None: frequency = cfg.get("frequency_days") unit = unit or "days" if frequency is not None and unit: sip_frequency = {"value": frequency, "unit": unit} next_eligible = compute_next_eligible(last_execution_ts, sip_frequency) status["last_execution_ts"] = last_execution_ts status["next_eligible_ts"] = next_eligible if next_eligible: try: parsed_next = datetime.fromisoformat(next_eligible) now_cmp = datetime.now(parsed_next.tzinfo) if parsed_next.tzinfo else datetime.now() if parsed_next > now_cmp: status["status"] = "WAITING" except ValueError: pass return status def get_engine_status(user_id: str): run_id = get_active_run_id(user_id) status = { "state": "STOPPED", "run_id": run_id, "user_id": user_id, "last_heartbeat_ts": None, } with db_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT status, last_updated FROM engine_status WHERE user_id = %s AND run_id = %s ORDER BY last_updated DESC LIMIT 1 """, (user_id, run_id), ) row = cur.fetchone() if row: status["state"] = row[0] last_updated = row[1] if last_updated is not None: status["last_heartbeat_ts"] = ( last_updated.astimezone(timezone.utc) .isoformat() .replace("+00:00", "Z") ) cfg = _load_config(user_id, run_id) mode = (cfg.get("mode") or "LIVE").strip().upper() with engine_context(user_id, run_id): state = load_state(mode=mode) last_execution_ts = state.get("last_run") or state.get("last_sip_ts") sip_frequency = cfg.get("sip_frequency") if isinstance(sip_frequency, dict): sip_frequency = { "value": sip_frequency.get("value"), "unit": sip_frequency.get("unit"), } else: frequency = cfg.get("frequency") unit = cfg.get("unit") if isinstance(frequency, dict): unit = frequency.get("unit", unit) frequency = frequency.get("value") if frequency is None and cfg.get("frequency_days") is not None: frequency = cfg.get("frequency_days") unit = unit or "days" if frequency is not None and unit: sip_frequency = {"value": frequency, "unit": unit} status["last_execution_ts"] = last_execution_ts status["next_eligible_ts"] = compute_next_eligible(last_execution_ts, sip_frequency) status["run_id"] = run_id return status def get_strategy_logs(user_id: str, since_seq: int): run_id = get_active_run_id(user_id) with db_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT seq, ts, level, category, event, message, run_id, meta FROM strategy_log WHERE user_id = %s AND run_id = %s AND seq > %s ORDER BY seq """, (user_id, run_id, since_seq), ) rows = cur.fetchall() events = [] for row in rows: ts = row[1] if ts is not None: ts_str = ts.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") else: ts_str = None events.append( { "seq": row[0], "ts": ts_str, "level": row[2], "category": row[3], "event": row[4], "message": row[5], "run_id": row[6], "meta": row[7] if isinstance(row[7], dict) else {}, } ) cur.execute( "SELECT COALESCE(MAX(seq), 0) FROM strategy_log WHERE user_id = %s AND run_id = %s", (user_id, run_id), ) latest_seq = cur.fetchone()[0] return {"events": events, "latest_seq": latest_seq} def get_market_status(): now = datetime.now() return { "status": "OPEN" if is_market_open(now) else "CLOSED", "checked_at": now.isoformat(), }