SIP_GoldBees_Backend/backend/tests/test_order_reconciliation.py

338 lines
12 KiB
Python

from datetime import datetime, timezone
def _base_state():
return {
"nifty_units": 0.0,
"gold_units": 0.0,
"total_invested": 0.0,
"last_run": None,
"last_sip_ts": None,
}
class _Broker:
def __init__(self, payload):
self.payload = payload
def refresh_order_status(self, order):
merged = dict(order)
merged.update(self.payload)
return merged
def test_reconciliation_applies_late_fill_once(monkeypatch):
import indian_paper_trading_strategy.engine.execution as execution
logical_time = datetime(2026, 4, 9, 4, 0, tzinfo=timezone.utc)
checked_at = datetime(2026, 4, 9, 4, 1, tzinfo=timezone.utc)
state = _base_state()
saved = {}
pending_orders = [
{
"local_order_id": "order-1",
"logical_time": logical_time,
"broker": "ZERODHA",
"symbol": "NIFTYBEES.NS",
"side": "BUY",
"requested_qty": 1.0,
"requested_price": 250.0,
"status": "PENDING",
"created_at": checked_at,
}
]
cycle_orders = [
{
"local_order_id": "order-1",
"symbol": "NIFTYBEES.NS",
"side": "BUY",
"requested_qty": 1.0,
"filled_qty": 1.0,
"accounted_fill_qty": 1.0,
"requested_price": 250.0,
"average_price": 251.0,
"status": execution.LOCAL_ORDER_FILLED,
"needs_reconciliation": False,
}
]
monkeypatch.setattr(execution, "_normalize_now", lambda value: value)
monkeypatch.setattr(execution, "run_with_retry", lambda op, retries=None, delay=None: op(object(), None))
monkeypatch.setattr(execution, "_list_orders_to_reconcile", lambda cur, checked_before: list(pending_orders))
monkeypatch.setattr(execution, "load_state", lambda mode=None, cur=None, for_update=False: state)
monkeypatch.setattr(execution, "save_state", lambda snapshot, **kwargs: saved.update(snapshot))
monkeypatch.setattr(execution, "insert_engine_event", lambda *args, **kwargs: None)
monkeypatch.setattr(execution, "log_event", lambda *args, **kwargs: None)
monkeypatch.setattr(execution, "event_exists", lambda *args, **kwargs: False)
monkeypatch.setattr(
execution,
"_upsert_broker_order_state",
lambda cur, broker_name, logical_time, order, checked_at: {
"local_order_id": "order-1",
"symbol": "NIFTYBEES.NS",
"requested_qty": 1.0,
"filled_qty": 1.0,
"requested_price": 250.0,
"average_price": 251.0,
"status": execution.LOCAL_ORDER_FILLED,
"accounted_fill_qty": 0.0,
"delta_fill_qty": 1.0,
"needs_reconciliation": False,
},
)
monkeypatch.setattr(execution, "_mark_accounted_fill", lambda *args, **kwargs: None)
monkeypatch.setattr(execution, "_load_cycle_order_rows", lambda cur, logical_time: list(cycle_orders))
result = execution.reconcile_live_orders(
broker=_Broker({"status": "FILLED", "filled_qty": 1.0, "average_price": 251.0}),
mode="LIVE",
now_ts=checked_at,
)
assert result["blocked"] is False
assert state["nifty_units"] == 1.0
assert state["total_invested"] == 251.0
assert state["last_sip_ts"] == checked_at.isoformat()
assert saved["last_sip_ts"] == checked_at.isoformat()
def test_partial_fill_remains_partial_and_does_not_advance_cycle(monkeypatch):
import indian_paper_trading_strategy.engine.execution as execution
logical_time = datetime(2026, 4, 9, 4, 0, tzinfo=timezone.utc)
checked_at = datetime(2026, 4, 9, 4, 1, tzinfo=timezone.utc)
state = _base_state()
monkeypatch.setattr(execution, "_normalize_now", lambda value: value)
monkeypatch.setattr(execution, "run_with_retry", lambda op, retries=None, delay=None: op(object(), None))
monkeypatch.setattr(
execution,
"_list_orders_to_reconcile",
lambda cur, checked_before: [
{
"local_order_id": "order-2",
"logical_time": logical_time,
"broker": "ZERODHA",
"symbol": "GOLDBEES.NS",
"side": "BUY",
"requested_qty": 2.0,
"requested_price": 100.0,
"status": "PENDING",
"created_at": checked_at,
}
],
)
monkeypatch.setattr(execution, "load_state", lambda mode=None, cur=None, for_update=False: state)
monkeypatch.setattr(execution, "save_state", lambda *args, **kwargs: None)
monkeypatch.setattr(execution, "insert_engine_event", lambda *args, **kwargs: None)
monkeypatch.setattr(execution, "log_event", lambda *args, **kwargs: None)
monkeypatch.setattr(execution, "event_exists", lambda *args, **kwargs: False)
monkeypatch.setattr(
execution,
"_upsert_broker_order_state",
lambda cur, broker_name, logical_time, order, checked_at: {
"local_order_id": "order-2",
"symbol": "GOLDBEES.NS",
"requested_qty": 2.0,
"filled_qty": 1.0,
"requested_price": 100.0,
"average_price": 101.0,
"status": execution.LOCAL_ORDER_PARTIAL,
"accounted_fill_qty": 0.0,
"delta_fill_qty": 1.0,
"needs_reconciliation": False,
},
)
monkeypatch.setattr(execution, "_mark_accounted_fill", lambda *args, **kwargs: None)
monkeypatch.setattr(
execution,
"_load_cycle_order_rows",
lambda cur, logical_time: [
{
"local_order_id": "order-2",
"symbol": "GOLDBEES.NS",
"requested_qty": 2.0,
"filled_qty": 1.0,
"requested_price": 100.0,
"average_price": 101.0,
"status": execution.LOCAL_ORDER_PARTIAL,
"needs_reconciliation": False,
}
],
)
result = execution.reconcile_live_orders(
broker=_Broker({"status": "CANCELLED", "filled_qty": 1.0, "average_price": 101.0}),
mode="LIVE",
now_ts=checked_at,
)
assert result["blocked"] is False
assert state["gold_units"] == 1.0
assert state["last_sip_ts"] is None
assert state["last_run"] == checked_at.isoformat()
def test_rejected_order_does_not_mark_cycle_executed(monkeypatch):
import indian_paper_trading_strategy.engine.execution as execution
logical_time = datetime(2026, 4, 9, 4, 0, tzinfo=timezone.utc)
checked_at = datetime(2026, 4, 9, 4, 1, tzinfo=timezone.utc)
state = _base_state()
monkeypatch.setattr(execution, "_normalize_now", lambda value: value)
monkeypatch.setattr(execution, "run_with_retry", lambda op, retries=None, delay=None: op(object(), None))
monkeypatch.setattr(
execution,
"_list_orders_to_reconcile",
lambda cur, checked_before: [
{
"local_order_id": "order-3",
"logical_time": logical_time,
"broker": "ZERODHA",
"symbol": "NIFTYBEES.NS",
"side": "BUY",
"requested_qty": 1.0,
"requested_price": 250.0,
"status": "PENDING",
"created_at": checked_at,
}
],
)
monkeypatch.setattr(execution, "load_state", lambda mode=None, cur=None, for_update=False: state)
monkeypatch.setattr(execution, "save_state", lambda *args, **kwargs: None)
monkeypatch.setattr(execution, "insert_engine_event", lambda *args, **kwargs: None)
monkeypatch.setattr(execution, "log_event", lambda *args, **kwargs: None)
monkeypatch.setattr(execution, "event_exists", lambda *args, **kwargs: False)
monkeypatch.setattr(
execution,
"_upsert_broker_order_state",
lambda cur, broker_name, logical_time, order, checked_at: {
"local_order_id": "order-3",
"symbol": "NIFTYBEES.NS",
"requested_qty": 1.0,
"filled_qty": 0.0,
"requested_price": 250.0,
"average_price": 0.0,
"status": execution.LOCAL_ORDER_REJECTED,
"accounted_fill_qty": 0.0,
"delta_fill_qty": 0.0,
"needs_reconciliation": False,
},
)
monkeypatch.setattr(execution, "_mark_accounted_fill", lambda *args, **kwargs: None)
monkeypatch.setattr(
execution,
"_load_cycle_order_rows",
lambda cur, logical_time: [
{
"local_order_id": "order-3",
"symbol": "NIFTYBEES.NS",
"requested_qty": 1.0,
"filled_qty": 0.0,
"requested_price": 250.0,
"average_price": 0.0,
"status": execution.LOCAL_ORDER_REJECTED,
"needs_reconciliation": False,
}
],
)
result = execution.reconcile_live_orders(
broker=_Broker({"status": "REJECTED", "filled_qty": 0.0}),
mode="LIVE",
now_ts=checked_at,
)
assert result["blocked"] is False
assert state["nifty_units"] == 0.0
assert state["last_sip_ts"] is None
assert state["last_run"] == checked_at.isoformat()
def test_reconciliation_is_repeat_safe(monkeypatch):
import indian_paper_trading_strategy.engine.execution as execution
logical_time = datetime(2026, 4, 9, 4, 0, tzinfo=timezone.utc)
first_check = datetime(2026, 4, 9, 4, 1, tzinfo=timezone.utc)
second_check = datetime(2026, 4, 9, 4, 2, tzinfo=timezone.utc)
state = _base_state()
upserts = [
{
"local_order_id": "order-4",
"symbol": "NIFTYBEES.NS",
"requested_qty": 1.0,
"filled_qty": 1.0,
"requested_price": 250.0,
"average_price": 250.0,
"status": execution.LOCAL_ORDER_FILLED,
"accounted_fill_qty": 0.0,
"delta_fill_qty": 1.0,
"needs_reconciliation": False,
},
{
"local_order_id": "order-4",
"symbol": "NIFTYBEES.NS",
"requested_qty": 1.0,
"filled_qty": 1.0,
"requested_price": 250.0,
"average_price": 250.0,
"status": execution.LOCAL_ORDER_FILLED,
"accounted_fill_qty": 1.0,
"delta_fill_qty": 0.0,
"needs_reconciliation": False,
},
]
monkeypatch.setattr(execution, "_normalize_now", lambda value: value)
monkeypatch.setattr(execution, "run_with_retry", lambda op, retries=None, delay=None: op(object(), None))
monkeypatch.setattr(
execution,
"_list_orders_to_reconcile",
lambda cur, checked_before: [
{
"local_order_id": "order-4",
"logical_time": logical_time,
"broker": "ZERODHA",
"symbol": "NIFTYBEES.NS",
"side": "BUY",
"requested_qty": 1.0,
"requested_price": 250.0,
"status": "PENDING",
"created_at": first_check,
}
],
)
monkeypatch.setattr(execution, "load_state", lambda mode=None, cur=None, for_update=False: state)
monkeypatch.setattr(execution, "save_state", lambda *args, **kwargs: None)
monkeypatch.setattr(execution, "insert_engine_event", lambda *args, **kwargs: None)
monkeypatch.setattr(execution, "log_event", lambda *args, **kwargs: None)
monkeypatch.setattr(execution, "event_exists", lambda *args, **kwargs: False)
monkeypatch.setattr(execution, "_mark_accounted_fill", lambda *args, **kwargs: None)
monkeypatch.setattr(execution, "_load_cycle_order_rows", lambda cur, logical_time: [
{
"local_order_id": "order-4",
"symbol": "NIFTYBEES.NS",
"requested_qty": 1.0,
"filled_qty": 1.0,
"requested_price": 250.0,
"average_price": 250.0,
"status": execution.LOCAL_ORDER_FILLED,
"needs_reconciliation": False,
}
])
monkeypatch.setattr(
execution,
"_upsert_broker_order_state",
lambda cur, broker_name, logical_time, order, checked_at: upserts.pop(0),
)
broker = _Broker({"status": "FILLED", "filled_qty": 1.0, "average_price": 250.0})
execution.reconcile_live_orders(broker=broker, mode="LIVE", now_ts=first_check)
execution.reconcile_live_orders(broker=broker, mode="LIVE", now_ts=second_check)
assert state["nifty_units"] == 1.0
assert state["total_invested"] == 250.0