2026-03-24 21:59:17 +05:30

477 lines
14 KiB
Python

# engine/execution.py
from datetime import datetime, timezone
from indian_paper_trading_strategy.engine.state import load_state, save_state
from indian_paper_trading_strategy.engine.broker import Broker, BrokerAuthExpired
from indian_paper_trading_strategy.engine.ledger import log_event, event_exists
from indian_paper_trading_strategy.engine.db import insert_engine_event, run_with_retry
from indian_paper_trading_strategy.engine.time_utils import compute_logical_time
def _as_float(value):
if hasattr(value, "item"):
try:
return float(value.item())
except Exception:
pass
if hasattr(value, "iloc"):
try:
return float(value.iloc[-1])
except Exception:
pass
return float(value)
def _local_tz():
return datetime.now().astimezone().tzinfo
def _normalize_now(now):
if now.tzinfo is None:
return now.replace(tzinfo=_local_tz())
return now
def _resolve_timing(state, now_ts, sip_interval):
force_execute = state.get("last_sip_ts") is None
last = state.get("last_sip_ts") or state.get("last_run")
if last and not force_execute:
try:
last_dt = datetime.fromisoformat(last)
except ValueError:
last_dt = None
if last_dt:
if last_dt.tzinfo is None:
last_dt = last_dt.replace(tzinfo=_local_tz())
if now_ts.tzinfo and last_dt.tzinfo and last_dt.tzinfo != now_ts.tzinfo:
last_dt = last_dt.astimezone(now_ts.tzinfo)
if last_dt and (now_ts - last_dt).total_seconds() < sip_interval:
return False, last, None
logical_time = compute_logical_time(now_ts, last, sip_interval)
return True, last, logical_time
def _order_fill(order):
if not isinstance(order, dict):
return 0.0, 0.0
filled_qty = float(order.get("filled_qty") or 0.0)
average_price = float(order.get("average_price") or order.get("price") or 0.0)
return filled_qty, average_price
def _apply_filled_orders_to_state(state, orders):
nifty_filled = 0.0
gold_filled = 0.0
total_spent = 0.0
for order in orders:
filled_qty, average_price = _order_fill(order)
if filled_qty <= 0:
continue
symbol = (order.get("symbol") or "").upper()
if symbol.startswith("NIFTYBEES"):
nifty_filled += filled_qty
elif symbol.startswith("GOLDBEES"):
gold_filled += filled_qty
total_spent += filled_qty * average_price
if nifty_filled:
state["nifty_units"] += nifty_filled
if gold_filled:
state["gold_units"] += gold_filled
if total_spent:
state["total_invested"] += total_spent
return {
"nifty_units": nifty_filled,
"gold_units": gold_filled,
"amount": total_spent,
}
def _record_live_order_events(cur, orders, event_ts):
for order in orders:
insert_engine_event(cur, "ORDER_PLACED", data=order, ts=event_ts)
filled_qty, _average_price = _order_fill(order)
status = (order.get("status") or "").upper()
if filled_qty > 0:
insert_engine_event(
cur,
"TRADE_EXECUTED",
data={
"order_id": order.get("id"),
"symbol": order.get("symbol"),
"side": order.get("side"),
"qty": filled_qty,
"price": order.get("average_price") or order.get("price"),
},
ts=event_ts,
)
insert_engine_event(cur, "ORDER_FILLED", data={"order_id": order.get("id")}, ts=event_ts)
elif status == "REJECTED":
insert_engine_event(cur, "ORDER_REJECTED", data=order, ts=event_ts)
elif status == "CANCELLED":
insert_engine_event(cur, "ORDER_CANCELLED", data=order, ts=event_ts)
elif status == "PENDING":
insert_engine_event(cur, "ORDER_PENDING", data=order, ts=event_ts)
def _try_execute_sip_paper(
now,
market_open,
sip_interval,
sip_amount,
sp_price,
gd_price,
eq_w,
gd_w,
broker: Broker | None,
mode: str | None,
):
def _op(cur, _conn):
now_ts = _normalize_now(now)
event_ts = now_ts
log_event("DEBUG_ENTER_TRY_EXECUTE", {"now": now_ts.isoformat()}, cur=cur, ts=event_ts)
state = load_state(mode=mode, cur=cur, for_update=True)
if not market_open:
return state, False
should_run, _last, logical_time = _resolve_timing(state, now_ts, sip_interval)
if not should_run:
return state, False
if event_exists("SIP_EXECUTED", logical_time, cur=cur):
return state, False
sp_price_val = _as_float(sp_price)
gd_price_val = _as_float(gd_price)
eq_w_val = _as_float(eq_w)
gd_w_val = _as_float(gd_w)
sip_amount_val = _as_float(sip_amount)
nifty_qty = (sip_amount_val * eq_w_val) / sp_price_val
gold_qty = (sip_amount_val * gd_w_val) / gd_price_val
if broker is None:
return state, False
funds = broker.get_funds(cur=cur)
cash = funds.get("cash")
if cash is not None and float(cash) < sip_amount_val:
return state, False
log_event(
"DEBUG_EXECUTION_DECISION",
{
"last_sip_ts": state.get("last_sip_ts"),
"now": now_ts.isoformat(),
},
cur=cur,
ts=event_ts,
)
orders = [
broker.place_order(
"NIFTYBEES.NS",
"BUY",
nifty_qty,
sp_price_val,
cur=cur,
logical_time=logical_time,
),
broker.place_order(
"GOLDBEES.NS",
"BUY",
gold_qty,
gd_price_val,
cur=cur,
logical_time=logical_time,
),
]
applied = _apply_filled_orders_to_state(state, orders)
executed = applied["amount"] > 0
if not executed:
return state, False
funds_after = broker.get_funds(cur=cur)
cash_after = funds_after.get("cash")
if cash_after is not None:
state["cash"] = float(cash_after)
state["last_sip_ts"] = now_ts.isoformat()
state["last_run"] = now_ts.isoformat()
save_state(
state,
mode=mode,
cur=cur,
emit_event=True,
event_meta={"source": "sip"},
)
log_event(
"SIP_EXECUTED",
{
"nifty_units": applied["nifty_units"],
"gold_units": applied["gold_units"],
"nifty_price": sp_price_val,
"gold_price": gd_price_val,
"amount": applied["amount"],
},
cur=cur,
ts=event_ts,
logical_time=logical_time,
)
return state, True
return run_with_retry(_op)
def _prepare_live_execution(now_ts, sip_interval, sip_amount_val, sp_price_val, gd_price_val, nifty_qty, gold_qty, mode):
def _op(cur, _conn):
state = load_state(mode=mode, cur=cur, for_update=True)
should_run, _last, logical_time = _resolve_timing(state, now_ts, sip_interval)
if not should_run:
return {"ready": False, "state": state}
if event_exists("SIP_EXECUTED", logical_time, cur=cur):
return {"ready": False, "state": state}
if event_exists("SIP_ORDER_ATTEMPTED", logical_time, cur=cur):
return {"ready": False, "state": state}
log_event(
"SIP_ORDER_ATTEMPTED",
{
"nifty_units": nifty_qty,
"gold_units": gold_qty,
"nifty_price": sp_price_val,
"gold_price": gd_price_val,
"amount": sip_amount_val,
},
cur=cur,
ts=now_ts,
logical_time=logical_time,
)
return {"ready": True, "state": state, "logical_time": logical_time}
return run_with_retry(_op)
def _finalize_live_execution(
*,
now_ts,
mode,
logical_time,
orders,
funds_after,
sp_price_val,
gd_price_val,
auth_failed: bool = False,
failure_reason: str | None = None,
):
def _op(cur, _conn):
state = load_state(mode=mode, cur=cur, for_update=True)
_record_live_order_events(cur, orders, now_ts)
applied = _apply_filled_orders_to_state(state, orders)
executed = applied["amount"] > 0
if funds_after is not None:
cash_after = funds_after.get("cash")
if cash_after is not None:
state["cash"] = float(cash_after)
if executed:
state["last_run"] = now_ts.isoformat()
state["last_sip_ts"] = now_ts.isoformat()
save_state(
state,
mode=mode,
cur=cur,
emit_event=True,
event_meta={"source": "sip_live"},
)
if executed:
log_event(
"SIP_EXECUTED",
{
"nifty_units": applied["nifty_units"],
"gold_units": applied["gold_units"],
"nifty_price": sp_price_val,
"gold_price": gd_price_val,
"amount": applied["amount"],
},
cur=cur,
ts=now_ts,
logical_time=logical_time,
)
else:
insert_engine_event(
cur,
"SIP_NO_FILL",
data={
"reason": failure_reason or ("broker_auth_expired" if auth_failed else "no_fill"),
"orders": orders,
},
ts=now_ts,
)
return state, executed
return run_with_retry(_op)
def _try_execute_sip_live(
now,
market_open,
sip_interval,
sip_amount,
sp_price,
gd_price,
eq_w,
gd_w,
broker: Broker | None,
mode: str | None,
):
now_ts = _normalize_now(now)
if not market_open or broker is None:
return load_state(mode=mode), False
sp_price_val = _as_float(sp_price)
gd_price_val = _as_float(gd_price)
eq_w_val = _as_float(eq_w)
gd_w_val = _as_float(gd_w)
sip_amount_val = _as_float(sip_amount)
nifty_qty = (sip_amount_val * eq_w_val) / sp_price_val
gold_qty = (sip_amount_val * gd_w_val) / gd_price_val
prepared = _prepare_live_execution(
now_ts,
sip_interval,
sip_amount_val,
sp_price_val,
gd_price_val,
nifty_qty,
gold_qty,
mode,
)
if not prepared.get("ready"):
return prepared.get("state") or load_state(mode=mode), False
logical_time = prepared["logical_time"]
orders = []
funds_after = None
failure_reason = None
auth_failed = False
try:
funds_before = broker.get_funds()
cash = funds_before.get("cash")
if cash is not None and float(cash) < sip_amount_val:
failure_reason = "insufficient_funds"
else:
if nifty_qty > 0:
orders.append(
broker.place_order(
"NIFTYBEES.NS",
"BUY",
nifty_qty,
sp_price_val,
logical_time=logical_time,
)
)
if gold_qty > 0:
orders.append(
broker.place_order(
"GOLDBEES.NS",
"BUY",
gold_qty,
gd_price_val,
logical_time=logical_time,
)
)
funds_after = broker.get_funds()
except BrokerAuthExpired:
auth_failed = True
try:
funds_after = broker.get_funds()
except Exception:
funds_after = None
except Exception as exc:
failure_reason = str(exc)
try:
funds_after = broker.get_funds()
except Exception:
funds_after = None
state, _executed = _finalize_live_execution(
now_ts=now_ts,
mode=mode,
logical_time=logical_time,
orders=orders,
funds_after=funds_after,
sp_price_val=sp_price_val,
gd_price_val=gd_price_val,
auth_failed=False,
failure_reason=failure_reason,
)
raise
state, executed = _finalize_live_execution(
now_ts=now_ts,
mode=mode,
logical_time=logical_time,
orders=orders,
funds_after=funds_after,
sp_price_val=sp_price_val,
gd_price_val=gd_price_val,
auth_failed=auth_failed,
failure_reason=failure_reason,
)
if auth_failed:
raise BrokerAuthExpired("Broker session expired during live order execution")
return state, executed
def try_execute_sip(
now,
market_open,
sip_interval,
sip_amount,
sp_price,
gd_price,
eq_w,
gd_w,
broker: Broker | None = None,
mode: str | None = "LIVE",
):
if broker is None:
return load_state(mode=mode), False
if getattr(broker, "external_orders", False):
return _try_execute_sip_live(
now,
market_open,
sip_interval,
sip_amount,
sp_price,
gd_price,
eq_w,
gd_w,
broker,
mode,
)
return _try_execute_sip_paper(
now,
market_open,
sip_interval,
sip_amount,
sp_price,
gd_price,
eq_w,
gd_w,
broker,
mode,
)