121 lines
4.0 KiB
Python
121 lines
4.0 KiB
Python
from __future__ import annotations
|
|
|
|
import threading
|
|
from datetime import datetime, timezone
|
|
|
|
from indian_paper_trading_strategy.engine import execution, ledger
|
|
|
|
|
|
def test_claim_execution_window_allows_only_one_winner(monkeypatch):
|
|
claims: set[tuple[str, str, datetime]] = set()
|
|
lock = threading.Lock()
|
|
|
|
class FakeCursor:
|
|
def __init__(self):
|
|
self._result = None
|
|
|
|
def execute(self, sql, params):
|
|
assert "INSERT INTO execution_claim" in sql
|
|
key = (params[1], params[2], params[4])
|
|
with lock:
|
|
if key in claims:
|
|
self._result = None
|
|
else:
|
|
claims.add(key)
|
|
self._result = (params[0],)
|
|
|
|
def fetchone(self):
|
|
return self._result
|
|
|
|
monkeypatch.setattr(ledger, "get_context", lambda user_id=None, run_id=None: ("user-a", "run-a"))
|
|
monkeypatch.setattr(ledger, "run_with_retry", lambda op, retries=None, delay=None: op(FakeCursor(), None))
|
|
|
|
logical_time = datetime(2026, 4, 8, 9, 15, tzinfo=timezone.utc)
|
|
results: list[bool] = []
|
|
|
|
def attempt():
|
|
results.append(ledger.claim_execution_window(logical_time, mode="LIVE"))
|
|
|
|
threads = [threading.Thread(target=attempt) for _ in range(2)]
|
|
for thread in threads:
|
|
thread.start()
|
|
for thread in threads:
|
|
thread.join()
|
|
|
|
assert sorted(results) == [False, True]
|
|
|
|
|
|
def test_try_execute_sip_live_emits_one_order_batch_when_claim_is_lost(monkeypatch):
|
|
claim_lock = threading.Lock()
|
|
claimed = False
|
|
order_calls: list[tuple[str, str, float]] = []
|
|
|
|
class FakeBroker:
|
|
external_orders = True
|
|
|
|
def get_funds(self):
|
|
return {"cash": 10_000.0}
|
|
|
|
def place_order(self, symbol, side, qty, price, logical_time=None):
|
|
order_calls.append((symbol, side, qty))
|
|
return {
|
|
"id": f"{symbol}-{len(order_calls)}",
|
|
"symbol": symbol,
|
|
"side": side,
|
|
"status": "COMPLETE",
|
|
"filled_qty": qty,
|
|
"average_price": price,
|
|
"price": price,
|
|
}
|
|
|
|
def fake_claim_execution_window(logical_time, *, mode=None, cur=None, user_id=None, run_id=None):
|
|
nonlocal claimed
|
|
with claim_lock:
|
|
if claimed:
|
|
return False
|
|
claimed = True
|
|
return True
|
|
|
|
monkeypatch.setattr(execution, "load_state", lambda *args, **kwargs: {"last_sip_ts": None, "last_run": None})
|
|
monkeypatch.setattr(
|
|
execution,
|
|
"_resolve_timing",
|
|
lambda state, now_ts, sip_interval: (True, None, datetime(2026, 4, 8, 9, 15, tzinfo=timezone.utc)),
|
|
)
|
|
monkeypatch.setattr(execution, "event_exists", lambda *args, **kwargs: False)
|
|
monkeypatch.setattr(execution, "claim_execution_window", fake_claim_execution_window)
|
|
monkeypatch.setattr(execution, "log_event", lambda *args, **kwargs: None)
|
|
monkeypatch.setattr(execution, "run_with_retry", lambda op, retries=None, delay=None: op(object(), None))
|
|
monkeypatch.setattr(
|
|
execution,
|
|
"_finalize_live_execution",
|
|
lambda **kwargs: ({"last_run": kwargs["now_ts"].isoformat()}, bool(kwargs["orders"])),
|
|
)
|
|
|
|
results: list[bool] = []
|
|
|
|
def attempt():
|
|
_state, executed = execution._try_execute_sip_live(
|
|
now=datetime(2026, 4, 8, 14, 45, tzinfo=timezone.utc),
|
|
market_open=True,
|
|
sip_interval=120,
|
|
sip_amount=1000.0,
|
|
sp_price=125.0,
|
|
gd_price=250.0,
|
|
eq_w=0.5,
|
|
gd_w=0.5,
|
|
broker=FakeBroker(),
|
|
mode="LIVE",
|
|
)
|
|
results.append(executed)
|
|
|
|
threads = [threading.Thread(target=attempt) for _ in range(2)]
|
|
for thread in threads:
|
|
thread.start()
|
|
for thread in threads:
|
|
thread.join()
|
|
|
|
assert sorted(results) == [False, True]
|
|
assert len(order_calls) == 2
|
|
assert {call[0] for call in order_calls} == {"NIFTYBEES.NS", "GOLDBEES.NS"}
|