379 lines
14 KiB
Python
379 lines
14 KiB
Python
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"),
|
|
},
|
|
}
|