fix(execution): cancel SQL nullable type error, KIS child cancel detection, worker exception logging

Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-09-01 03:33:38 +00:00
committed by GitHub
co-authored by genichin
parent 2b412fd786
commit f0ac909958
6 changed files with 258 additions and 12 deletions
+111
View File
@@ -130,3 +130,114 @@ def test_full_fill_unaffected_by_cancel_flag(monkeypatch):
f"06010:{ODNO}", side="sell", symbol="005930", order_date="20260608"
)
assert fs.is_filled and fs.filled_qty == 10 and not fs.is_partial_cancel
# ── Regression: KIS child cancel row (orgn_odno) ─────────────────────────────
def _payload_with_child_cancel(parent_odno: str, child_odno: str, ord_qty: str = "89"):
"""Simulate KIS returning original row (cncl_yn='') + separate cancel child."""
parent = {
"odno": parent_odno,
"orgn_odno": "",
"ord_qty": ord_qty,
"tot_ccld_qty": "0",
"avg_prvs": "0",
"cncl_yn": "",
"rjct_qty": "0",
"rmn_qty": "0",
"ord_dt": "20260901",
"ord_gno_brno": "06010",
}
child = {
"odno": child_odno,
"orgn_odno": parent_odno,
"ord_qty": ord_qty,
"tot_ccld_qty": "0",
"avg_prvs": "0",
"cncl_yn": "Y",
"rjct_qty": "0",
"rmn_qty": "0",
"ord_dt": "20260901",
"ord_gno_brno": "06010",
}
return {"rt_cd": "0", "output1": [parent, child]}
def test_child_cancel_row_orgn_odno_maps_to_cancelled(monkeypatch):
"""KIS child row with orgn_odno=<original> + cncl_yn=Y → full cancel."""
payload = _payload_with_child_cancel(ODNO, "0000123457")
monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(payload))
fs = _broker().poll_fill(
f"06010:{ODNO}", side="buy", symbol="225190", order_date="20260901"
)
assert fs.state == "cancelled", f"expected cancelled, got {fs.state}"
assert fs.is_terminal_reject
def test_correction_child_row_not_misclassified_as_cancel(monkeypatch):
"""Correction child (cncl_yn='N') must not be treated as a cancel."""
parent = {
"odno": ODNO,
"orgn_odno": "",
"ord_qty": "10",
"tot_ccld_qty": "0",
"avg_prvs": "0",
"cncl_yn": "",
"rjct_qty": "0",
"ord_dt": "20260901",
"ord_gno_brno": "06010",
}
correction_child = {
"odno": "0000123458",
"orgn_odno": ODNO,
"ord_qty": "10",
"tot_ccld_qty": "0",
"avg_prvs": "0",
"cncl_yn": "N", # correction, not cancel
"rjct_qty": "0",
"ord_dt": "20260901",
"ord_gno_brno": "06010",
}
monkeypatch.setattr(
kis_mod.httpx, "get", _fake_get({"rt_cd": "0", "output1": [parent, correction_child]})
)
fs = _broker().poll_fill(
f"06010:{ODNO}", side="buy", symbol="225190", order_date="20260901"
)
# Correction child must not infer cancellation; original has no fill evidence
assert fs.state == "accepted", f"expected accepted, got {fs.state}"
def test_partial_fill_with_child_cancel_row(monkeypatch):
"""Partial fill on parent + cancel child → partial_cancel."""
parent = {
"odno": ODNO,
"orgn_odno": "",
"ord_qty": "10",
"tot_ccld_qty": "4",
"avg_prvs": "70000",
"cncl_yn": "",
"rjct_qty": "0",
"ord_dt": "20260901",
"ord_gno_brno": "06010",
}
child = {
"odno": "0000123459",
"orgn_odno": ODNO,
"ord_qty": "6",
"tot_ccld_qty": "0",
"avg_prvs": "0",
"cncl_yn": "Y",
"rjct_qty": "0",
"ord_dt": "20260901",
"ord_gno_brno": "06010",
}
monkeypatch.setattr(
kis_mod.httpx, "get", _fake_get({"rt_cd": "0", "output1": [parent, child]})
)
fs = _broker().poll_fill(
f"06010:{ODNO}", side="sell", symbol="225190", order_date="20260901"
)
assert fs.state == "partially_filled"
assert fs.cancelled and fs.is_partial_cancel
assert fs.filled_qty == 4
@@ -179,3 +179,49 @@ def test_operator_cancel_is_sent_once_across_repeat_and_restart():
assert restarted.cancel_pending_slot(sid) == "cancel_already_recorded"
assert broker.cancel_calls == 1
assert repo.fetch_slot(sid).cancel_state == CancelState.REQUESTED.value
# ── Regression: first-cancel reservation (retry_before=None) ─────────────────
def test_first_cancel_reservation_with_no_retry_before_succeeds():
"""First cancel reservation with retry_before=None must reserve exactly once (fix for
PostgreSQL IndeterminateDatatype; the in-memory repo mirrors the corrected SQL path)."""
broker = ProgrammableBroker()
repo, svc, intents, execution, _, _ = _wire(broker)
sid = _buy_to_holding(intents, execution)
intents.submit_sell(SellIntent(intent_id="s1", slot_id=sid))
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once() # dispatch
# First operator cancel — retry_before=None path
result = execution.cancel_pending_slot(sid)
assert result == "ok", f"expected ok, got {result}"
assert broker.cancel_calls == 1
# Second call must be idempotent (cancel_state already set)
result2 = execution.cancel_pending_slot(sid)
assert result2 == "cancel_already_recorded"
assert broker.cancel_calls == 1 # no second broker call
def test_timeout_cancel_first_attempt_with_retry_before_none():
"""Timeout cancel's first attempt passes retry_before=None; must call broker once."""
broker = ProgrammableBroker()
repo, svc, intents, execution, _, _ = _wire(broker)
sid = _buy_to_holding(intents, execution)
intents.submit_sell(SellIntent(intent_id="s1", slot_id=sid))
broker.responder = lambda side: FillStatus(
state="accepted",
raw={"order_open_confirmed": True},
)
execution.run_once() # dispatch
# Simulate timeout: set order_submitted_at well in the past
s = repo.fetch_slot(sid)
repo._slots[sid] = replace(
s,
order_submitted_at=datetime.now(tz=timezone.utc) - timedelta(seconds=120),
order_date="20260901",
timeout_sec=30,
)
execution.run_once() # should trigger timeout cancel
assert broker.cancel_calls == 1
@@ -0,0 +1,53 @@
"""Regression: execution loop exceptions must be logged, not silently swallowed.
dcy-031 / issue #81: exceptions from execution.run_once() were silently ignored
via bare `continue`, making it impossible to observe repository or adapter errors.
"""
from __future__ import annotations
import logging
import threading
import types
import pytest
def test_execution_loop_logs_exception(caplog):
"""When execution.run_once() raises, the error is logged and the loop continues."""
import trex_core.runtime.daemon as daemon_mod
call_count = 0
stop_event = threading.Event()
class _FakeExecution:
def run_once(self):
nonlocal call_count
call_count += 1
if call_count == 1:
raise RuntimeError("simulated repo error: IndeterminateDatatype")
stop_event.set()
class _FakePublisher:
def keepalive_tick(self):
pass
class _FakeConfig:
execution_interval_sec = 0.001
# Build a minimal stub Daemon without invoking its real __init__, so we can
# inject only the attributes _execution_loop reads.
daemon = object.__new__(daemon_mod.Daemon)
daemon.stop = stop_event
daemon.config = _FakeConfig()
daemon.event_publisher = _FakePublisher()
daemon.execution = _FakeExecution()
with caplog.at_level(logging.ERROR, logger="trex_core.runtime.daemon"):
t = threading.Thread(target=daemon._execution_loop, daemon=True)
t.start()
t.join(timeout=2.0)
assert not t.is_alive(), "execution loop did not exit cleanly"
assert any(
"execution loop error" in r.message for r in caplog.records
), f"expected 'execution loop error' log, got: {[r.message for r in caplog.records]}"