SIP_GoldBees_Backend/app/services/system_service.py
2026-02-01 13:06:44 +00:00

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"),
},
}