651 lines
22 KiB
Python
651 lines
22 KiB
Python
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(),
|
|
}
|