fix(reconcile): symbol을 슬롯 생성 시 함께 저장 + NULL 슬롯 backfill

reconcile_universe 시그니처를 set[str] → dict[str, str] (portfolio_id→symbol)로 변경.
기존 symbol=NULL 슬롯은 다음 reconcile 실행 시 자동 backfill.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 21:08:46 +09:00
co-authored by Claude Sonnet 4.6
parent 221a16342e
commit 24a6017457
29 changed files with 92 additions and 68 deletions
@@ -24,7 +24,7 @@ def _wire(max_active):
def test_activates_asset_slot():
repo, svc, intents = _wire(max_active=2)
repo.reconcile_universe(INST, {"pf-a", "pf-b"})
repo.reconcile_universe(INST, {"pf-a": "SYM", "pf-b": "SYM"})
d = intents.submit_buy(BuyIntent(intent_id="b1", portfolio_id="pf-a"))
assert d.status == "admitted"
slot = repo.find_slot_by_portfolio(INST, "pf-a")
@@ -34,7 +34,7 @@ def test_activates_asset_slot():
def test_activates_by_slot_id():
# dcy-020/REQ-061: slot_id is the canonical buy contract key.
repo, svc, intents = _wire(max_active=2)
repo.reconcile_universe(INST, {"pf-a"})
repo.reconcile_universe(INST, {"pf-a": "SYM"})
slot = repo.find_slot_by_portfolio(INST, "pf-a")
d = intents.submit_buy(BuyIntent(intent_id="b1", slot_id=slot.slot_id))
assert d.status == "admitted" and d.slot_id == slot.slot_id
@@ -45,14 +45,14 @@ def test_activates_by_slot_id():
def test_unknown_slot_for_bad_slot_id():
from uuid import uuid4
repo, svc, intents = _wire(max_active=2)
repo.reconcile_universe(INST, {"pf-a"})
repo.reconcile_universe(INST, {"pf-a": "SYM"})
d = intents.submit_buy(BuyIntent(intent_id="b1", slot_id=uuid4()))
assert d.status == "rejected" and d.reason == "unknown_slot"
def test_already_active_is_rejected():
repo, svc, intents = _wire(max_active=2)
repo.reconcile_universe(INST, {"pf-a", "pf-b"})
repo.reconcile_universe(INST, {"pf-a": "SYM", "pf-b": "SYM"})
intents.submit_buy(BuyIntent(intent_id="b1", portfolio_id="pf-a"))
d2 = intents.submit_buy(BuyIntent(intent_id="b2", portfolio_id="pf-a"))
assert d2.status == "rejected" and d2.reason == "already_active"
@@ -60,7 +60,7 @@ def test_already_active_is_rejected():
def test_capacity_full_at_M_no_eviction():
repo, svc, intents = _wire(max_active=1)
repo.reconcile_universe(INST, {"pf-a", "pf-b"})
repo.reconcile_universe(INST, {"pf-a": "SYM", "pf-b": "SYM"})
d1 = intents.submit_buy(BuyIntent(intent_id="b1", portfolio_id="pf-a"))
assert d1.status == "admitted"
d2 = intents.submit_buy(BuyIntent(intent_id="b2", portfolio_id="pf-b"))
@@ -71,7 +71,7 @@ def test_capacity_full_at_M_no_eviction():
def test_unknown_portfolio_when_no_asset_slot():
repo, svc, intents = _wire(max_active=2)
repo.reconcile_universe(INST, {"pf-a"})
repo.reconcile_universe(INST, {"pf-a": "SYM"})
# pf-c is resolvable to a symbol but has no reconciled asset slot
d = intents.submit_buy(BuyIntent(intent_id="b1", portfolio_id="pf-c"))
assert d.status == "rejected" and d.reason == "unknown_portfolio"
@@ -80,7 +80,7 @@ def test_unknown_portfolio_when_no_asset_slot():
def test_over_cap_blocks_new_keeps_existing():
# M=2: fill 2, then lower M to 1 (over-cap) -> new rejected, existing kept
repo, svc, intents = _wire(max_active=2)
repo.reconcile_universe(INST, {"pf-a", "pf-b", "pf-c"})
repo.reconcile_universe(INST, {"pf-a": "SYM", "pf-b": "SYM", "pf-c": "SYM"})
intents.submit_buy(BuyIntent(intent_id="b1", portfolio_id="pf-a"))
intents.submit_buy(BuyIntent(intent_id="b2", portfolio_id="pf-b"))
assert repo.count_active(INST) == 2
@@ -94,7 +94,7 @@ def test_over_cap_blocks_new_keeps_existing():
def test_no_cap_admits_without_capacity_check():
# BAT-030: max_active_slots=None -> no capacity cap (asset-centric only path)
repo, svc, intents = _wire(max_active=None)
repo.reconcile_universe(INST, {"pf-a", "pf-b", "pf-c"})
repo.reconcile_universe(INST, {"pf-a": "SYM", "pf-b": "SYM", "pf-c": "SYM"})
for i, pid in enumerate(sorted(_SYMS)):
d = intents.submit_buy(BuyIntent(intent_id=f"b{i}", portfolio_id=pid))
assert d.status == "admitted"
+1 -1
View File
@@ -24,7 +24,7 @@ def _wire(broker, max_active_slots=None):
instance_key=INST, max_active_slots=max_active_slots,
)
execution = ExecutionService(svc, repo, broker, instance_key=INST)
repo.reconcile_universe(INST, set(SYMBOLS)) # permanent asset slots
repo.reconcile_universe(INST, SYMBOLS) # permanent asset slots
return repo, svc, intents, execution
+1 -1
View File
@@ -69,7 +69,7 @@ def _wire(broker, *, stale_sec=None):
stale_pending_threshold_sec=stale_sec,
)
writer = LedgerWriter(outbox, ledger)
repo.reconcile_universe("default", {"PF-1"})
repo.reconcile_universe("default", {"PF-1": "SYM"})
return repo, svc, intents, execution, ledger, writer
+1 -1
View File
@@ -33,7 +33,7 @@ def _wire(broker, *, ledger=None, clock=None):
LedgerWriter(outbox, ledger or InMemoryLedger(), clock=clock)
if clock else LedgerWriter(outbox, ledger or InMemoryLedger())
)
repo.reconcile_universe("default", {"PF-1"})
repo.reconcile_universe("default", {"PF-1": "SYM"})
return repo, svc, intents, execution, outbox, writer
+1 -1
View File
@@ -62,7 +62,7 @@ def _wire(broker):
intents = IntentService(svc, repo, InMemoryIntentStore(),
resolve_symbol=lambda pid: "005930")
execution = ExecutionService(svc, repo, broker)
repo.reconcile_universe("default", {"PF-1"})
repo.reconcile_universe("default", {"PF-1": "SYM"})
return repo, svc, intents, execution
@@ -28,7 +28,7 @@ def _wire():
svc = SlotService(repo)
intents = IntentService(svc, repo, InMemoryIntentStore(),
resolve_symbol=lambda pid: "005930")
repo.reconcile_universe("default", {"PF-1"})
repo.reconcile_universe("default", {"PF-1": "SYM"})
return repo, svc, intents
@@ -145,7 +145,7 @@ class TestDefaultOrderTimeoutSec:
resolve_symbol=lambda pid: "005930",
default_order_timeout_sec=default_timeout,
)
repo.reconcile_universe("default", {"PF-1"})
repo.reconcile_universe("default", {"PF-1": "SYM"})
return repo, svc, intents
def test_config_default_applied_when_intent_has_no_timeout(self):
+1 -1
View File
@@ -22,7 +22,7 @@ class FakeBroker:
def _hold(repo, svc, pid, symbol, qty):
repo.reconcile_universe(INST, {pid})
repo.reconcile_universe(INST, {pid: "SYM"})
slot = repo.find_slot_by_portfolio(INST, pid)
svc.transit_slot(slot.slot_id, slot.status, SlotState.PENDING_BUY,
reason="r", actor="a", symbol=symbol, qty=qty)
+1 -1
View File
@@ -24,7 +24,7 @@ def _wire(n_assets: int, max_active: int | None):
resolve_symbol=lambda pid: f"SYM-{pid}",
instance_key=INST, max_active_slots=max_active,
)
repo.reconcile_universe(INST, {f"PF-{i}" for i in range(n_assets)})
repo.reconcile_universe(INST, {f"PF-{i}": "SYM" for i in range(n_assets)})
return repo, svc, intents
@@ -36,7 +36,7 @@ def ks(repo, service, store):
def _holding(service, repo, *, portfolio_id, symbol="005930"):
repo.reconcile_universe("default", {portfolio_id})
repo.reconcile_universe("default", {portfolio_id: "SYM"})
slot = repo.find_slot_by_portfolio("default", portfolio_id)
pending = service.transit_slot(
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
+1 -1
View File
@@ -105,7 +105,7 @@ def test_apply_cash_estimate_overlays_pending_delta():
outbox.enqueue(_trade(execution_id="b", side="buy", qty=10, price=1000.0, fee=1.0))
store = _store_with(pid="PF-1", cash=1_000_000.0, qty=0.0)
repo = InMemorySlotRepository()
repo.reconcile_universe("default", {"PF-1"})
repo.reconcile_universe("default", {"PF-1": "SYM"})
handler = make_slots_rpc_handler(repo, snapshot_store=store, ledger_outbox=outbox)
resp = handler(RpcRequest(payload={"filter": "all"}, correlation_id="test"))
slots = resp.payload["slots"]
@@ -25,7 +25,7 @@ def _wire(transport):
outbox = InMemoryLedgerOutbox()
ledger = InMemoryLedger()
writer = LedgerWriter(outbox, ledger)
repo.reconcile_universe("default", {"PF-1"})
repo.reconcile_universe("default", {"PF-1": "SYM"})
def emitter(event_type: str, slot) -> None:
rk = topology.event_routing_key(event_type, slot.strategy_id or "_", slot.symbol or "_")
@@ -22,7 +22,7 @@ def _wire(max_active=3):
intents = IntentService(svc, repo, InMemoryIntentStore(),
resolve_symbol=lambda pid: "005930",
max_active_slots=max_active)
repo.reconcile_universe("default", {"PF-1", "PF-2", "PF-3", "PF-9"})
repo.reconcile_universe("default", {"PF-1": "SYM", "PF-2": "SYM", "PF-3": "SYM", "PF-9": "SYM"})
return repo, svc, intents
@@ -49,7 +49,7 @@ def _wire(**cfg_over):
def _holding(svc, repo, *, symbol="005930", **fields):
from uuid import uuid4
pid = f"pf-{uuid4().hex[:8]}"
repo.reconcile_universe("inst-1", {pid})
repo.reconcile_universe("inst-1", {pid: "SYM"})
slot = repo.find_slot_by_portfolio("inst-1", pid)
p = svc.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="t", symbol=symbol, **fields)
+1 -1
View File
@@ -72,7 +72,7 @@ def _make_holding(service, repo, *, symbol="005930", forced_close_at=None,
stop_loss_price=None, take_profit_price=None):
from uuid import uuid4
pid = f"pf-{uuid4().hex[:8]}"
repo.reconcile_universe("default", {pid})
repo.reconcile_universe("default", {pid: "SYM"})
slot = repo.find_slot_by_portfolio("default", pid)
pending = service.transit_slot(
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
+1 -1
View File
@@ -35,7 +35,7 @@ def _wire(broker):
intents = IntentService(svc, repo, InMemoryIntentStore(),
resolve_symbol=lambda pid: "005930")
execution = ExecutionService(svc, repo, broker)
repo.reconcile_universe("default", {"PF-1"})
repo.reconcile_universe("default", {"PF-1": "SYM"})
return repo, svc, intents, execution
+2 -2
View File
@@ -17,7 +17,7 @@ from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
def _wire(items=None, *, max_active_slots=None):
repo = InMemorySlotRepository()
svc = SlotService(repo)
repo.reconcile_universe("default", {"pf-A", "pf-B"})
repo.reconcile_universe("default", {"pf-A": "SYM", "pf-B": "SYM"})
store = SnapshotStore()
store.swap(items or [
PortfolioItem(portfolio_id="pf-A", symbol="005930",
@@ -146,7 +146,7 @@ def test_warned_true_when_qty_diverges():
PortfolioItem(portfolio_id="pf-A", symbol="005930",
cash_balance=1_000_000, asset_quantity=3),
])
repo.reconcile_universe("default", {"pf-A"})
repo.reconcile_universe("default", {"pf-A": "SYM"})
slot_a = repo.find_slot_by_portfolio("default", "pf-A")
pending = svc.transit_slot(slot_a.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="test", symbol="005930")
@@ -45,7 +45,7 @@ def _holding(service, repo, *, symbol="005930", stop_loss_price=None,
take_profit_price=None, forced_close_at=None):
from uuid import uuid4
pid = f"pf-{uuid4().hex[:8]}"
repo.reconcile_universe("default", {pid})
repo.reconcile_universe("default", {pid: "SYM"})
slot = repo.find_slot_by_portfolio("default", pid)
pending = service.transit_slot(
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
@@ -111,7 +111,7 @@ def test_explicit_null_clears(handler, service, repo):
def test_non_holding_ignored(handler, service, repo, watchdog):
"""AC-6: non-holding slot → ignored + not_holding reason, no watchdog reset."""
repo.reconcile_universe("default", {"pf-inactive"})
repo.reconcile_universe("default", {"pf-inactive": "SYM"})
inactive = repo.find_slot_by_portfolio("default", "pf-inactive")
resp = handler(_req(slot_id=str(inactive.slot_id), stop_loss_price=50000))
assert resp.payload["status"] == "ignored"