338 lines
12 KiB
Python
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
|