import hashlib import json import os from datetime import datetime, timezone from psycopg2.extras import Json from app.broker_store import get_user_broker, set_broker_auth_state from app.services.db import db_connection from app.services.run_lifecycle import RunLifecycleError, RunLifecycleManager from app.services.strategy_service import compute_next_eligible, resume_running_runs from app.services.zerodha_service import KiteTokenError, fetch_funds from app.services.zerodha_storage import get_session def _hash_value(value: str | None) -> str | None: if value is None: return None return hashlib.sha256(value.encode("utf-8")).hexdigest() def _parse_frequency(raw_value): if raw_value is None: return None if isinstance(raw_value, dict): return raw_value if isinstance(raw_value, str): text = raw_value.strip() if not text: return None try: return json.loads(text) except Exception: return None return None def _resolve_sip_frequency(row: dict): value = row.get("sip_frequency_value") unit = row.get("sip_frequency_unit") if value is not None and unit: return {"value": int(value), "unit": unit} frequency = _parse_frequency(row.get("frequency")) if isinstance(frequency, dict): freq_value = frequency.get("value") freq_unit = frequency.get("unit") if freq_value is not None and freq_unit: return {"value": int(freq_value), "unit": freq_unit} fallback_value = row.get("frequency_days") fallback_unit = row.get("unit") or "days" if fallback_value is not None: return {"value": int(fallback_value), "unit": fallback_unit} return None def _parse_ts(value: str | None): if not value: return None try: return datetime.fromisoformat(value) except ValueError: return None def _validate_broker_session(user_id: str): session = get_session(user_id) if not session: return False if os.getenv("BROKER_VALIDATION_MODE", "").strip().lower() == "skip": return True try: fetch_funds(session["api_key"], session["access_token"]) except KiteTokenError: set_broker_auth_state(user_id, "EXPIRED") return False return True def arm_system(user_id: str, client_ip: str | None = None): if not _validate_broker_session(user_id): return { "ok": False, "code": "BROKER_AUTH_REQUIRED", "redirect_url": "/api/broker/login", } now = datetime.now(timezone.utc) armed_runs = [] failed_runs = [] next_runs = [] with db_connection() as conn: with conn: with conn.cursor() as cur: cur.execute( """ SELECT sr.run_id, sr.status, sr.strategy, sr.mode, sr.broker, sc.active, sc.sip_frequency_value, sc.sip_frequency_unit, sc.frequency, sc.frequency_days, sc.unit, sc.next_run FROM strategy_run sr LEFT JOIN strategy_config sc ON sc.user_id = sr.user_id AND sc.run_id = sr.run_id WHERE sr.user_id = %s AND COALESCE(sc.active, false) = true ORDER BY sr.created_at DESC """, (user_id,), ) rows = cur.fetchall() cur.execute("SELECT username FROM app_user WHERE id = %s", (user_id,)) user_row = cur.fetchone() username = user_row[0] if user_row else None for row in rows: run = { "run_id": row[0], "status": row[1], "strategy": row[2], "mode": row[3], "broker": row[4], "active": row[5], "sip_frequency_value": row[6], "sip_frequency_unit": row[7], "frequency": row[8], "frequency_days": row[9], "unit": row[10], "next_run": row[11], } status = (run["status"] or "").strip().upper() if status == "RUNNING": armed_runs.append( { "run_id": run["run_id"], "status": status, "already_running": True, } ) if run.get("next_run"): next_runs.append(run["next_run"]) continue if status == "ERROR": failed_runs.append( { "run_id": run["run_id"], "status": status, "reason": "ERROR", } ) continue try: RunLifecycleManager.assert_can_arm(status) except RunLifecycleError as exc: failed_runs.append( { "run_id": run["run_id"], "status": status, "reason": str(exc), } ) continue sip_frequency = _resolve_sip_frequency(run) last_run = now.isoformat() next_run = compute_next_eligible(last_run, sip_frequency) next_run_dt = _parse_ts(next_run) cur.execute( """ UPDATE strategy_run SET status = 'RUNNING', started_at = COALESCE(started_at, %s), stopped_at = NULL, meta = COALESCE(meta, '{}'::jsonb) || %s WHERE user_id = %s AND run_id = %s """, ( now, Json({"armed_at": now.isoformat()}), user_id, run["run_id"], ), ) 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["run_id"], "RUNNING", now), ) if (run.get("mode") or "").strip().upper() == "PAPER": cur.execute( """ INSERT INTO engine_state_paper (user_id, run_id, last_run) VALUES (%s, %s, %s) ON CONFLICT (user_id, run_id) DO UPDATE SET last_run = EXCLUDED.last_run """, (user_id, run["run_id"], now), ) else: cur.execute( """ INSERT INTO engine_state (user_id, run_id, last_run) VALUES (%s, %s, %s) ON CONFLICT (user_id, run_id) DO UPDATE SET last_run = EXCLUDED.last_run """, (user_id, run["run_id"], now), ) cur.execute( """ UPDATE strategy_config SET next_run = %s WHERE user_id = %s AND run_id = %s """, (next_run_dt, user_id, run["run_id"]), ) logical_time = now.replace(microsecond=0) cur.execute( """ INSERT INTO engine_event (user_id, run_id, ts, event, message, meta) VALUES (%s, %s, %s, %s, %s, %s) """, ( user_id, run["run_id"], now, "SYSTEM_ARMED", "System armed", Json({"next_run": next_run}), ), ) cur.execute( """ INSERT INTO engine_event (user_id, run_id, ts, event, message, meta) VALUES (%s, %s, %s, %s, %s, %s) """, ( user_id, run["run_id"], now, "RUN_REARMED", "Run re-armed", Json({"next_run": next_run}), ), ) cur.execute( """ INSERT INTO event_ledger ( user_id, run_id, timestamp, logical_time, event ) VALUES (%s, %s, %s, %s, %s) ON CONFLICT (user_id, run_id, event, logical_time) DO NOTHING """, ( user_id, run["run_id"], now, logical_time, "SYSTEM_ARMED", ), ) cur.execute( """ INSERT INTO event_ledger ( user_id, run_id, timestamp, logical_time, event ) VALUES (%s, %s, %s, %s, %s) ON CONFLICT (user_id, run_id, event, logical_time) DO NOTHING """, ( user_id, run["run_id"], now, logical_time, "RUN_REARMED", ), ) armed_runs.append( { "run_id": run["run_id"], "status": "RUNNING", "next_run": next_run, } ) if next_run_dt: next_runs.append(next_run_dt) audit_meta = { "run_count": len(armed_runs), "ip": client_ip, } cur.execute( """ INSERT INTO admin_audit_log (actor_user_hash, target_user_hash, target_username_hash, action, meta) VALUES (%s, %s, %s, %s, %s) """, ( _hash_value(user_id), _hash_value(user_id), _hash_value(username), "SYSTEM_ARM", Json(audit_meta), ), ) try: resume_running_runs() except Exception: pass broker_state = get_user_broker(user_id) or {} next_execution = min(next_runs).isoformat() if next_runs else None return { "ok": True, "armed_runs": armed_runs, "failed_runs": failed_runs, "next_execution": next_execution, "broker_state": { "connected": bool(broker_state.get("connected")), "auth_state": broker_state.get("auth_state"), "broker": broker_state.get("broker"), "user_name": broker_state.get("user_name"), }, } def system_status(user_id: str): broker_state = get_user_broker(user_id) or {} with db_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT sr.run_id, sr.status, sr.strategy, sr.mode, sr.broker, sc.next_run, sc.active FROM strategy_run sr LEFT JOIN strategy_config sc ON sc.user_id = sr.user_id AND sc.run_id = sr.run_id WHERE sr.user_id = %s ORDER BY sr.created_at DESC """, (user_id,), ) rows = cur.fetchall() runs = [ { "run_id": row[0], "status": row[1], "strategy": row[2], "mode": row[3], "broker": row[4], "next_run": row[5].isoformat() if row[5] else None, "active": bool(row[6]) if row[6] is not None else False, "lifecycle": row[1], } for row in rows ] return { "runs": runs, "broker_state": { "connected": bool(broker_state.get("connected")), "auth_state": broker_state.get("auth_state"), "broker": broker_state.get("broker"), "user_name": broker_state.get("user_name"), }, }