feat(slot): BAT-030 — 자산 중심 cutover 완성 (dcy-020 BAT-δ, REQ-056·057·062·064·065)
M1 literal inactive 전면화: SlotState 5-state(idle/closed 별칭 제거), 마이그레이션 010(CHECK 교체+행 rewrite), ops_checks 를 transition_log 기반 재작성. M2 집행 매도 수량 브로커 정본(D3): dispatch 매도 qty=broker hldg_qty, 미러 fallback 없음, 브로커 0 → skip+WARN, 리스트 기반 dispatch(head-block 방지). M3 컬럼 drop + legacy pool 제거: 마이그레이션 011(trading_date·entry_count DROP), repo 계약 instance 스코프 개편(집행 큐 포함), preallocation 폐기(데몬 7스레드), admission 자산 중심 단일 경로(slot_id 정본), capacity 재정의(available=M−active), daily_symbol_entries 코드 경로 제거. M4 cutover 도구(D1·D4): trex-core-cutover(전체 rebuild, preflight/브로커 carry-over/ --allow-unmapped/--promote-unique/dry-run) + PG 리허설 테스트 8건. M5 baseline 마감: data-model/runtime-flows/project-structure/interface-contract 자산 중심 최종 갱신 + run-trex-core-daemon 스킬 정합. 검증: 단위 367 passed + ruff clean; PG-gated 29 passed(010/011 round-trip·드리프트 가드·cutover 리허설); RabbitMQ 21 passed; run-the-app e2e(test DB+실 MQ, M=1): reconcile 슬롯 생성→slot_id 매수→dedup→capacity_full→fill→already_active→ 매도→inactive(자산 바인딩 보존)→영구 슬롯 재활성 전 구간 green. 실 cutover 는 운영자 실행(REL 단계, D4). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,186 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
"""End-to-end smoke for trex-core (BAT-007).
|
||||
"""End-to-end smoke for trex-core (BAT-030 asset-centric).
|
||||
|
||||
Assumes the daemon is already running (`.venv/bin/trex-core-daemon &`).
|
||||
Drives it through every RabbitMQ surface:
|
||||
Assumes the daemon is already running with `--config <toml>` (see SKILL.md).
|
||||
Drives every RabbitMQ surface of the asset-centric model:
|
||||
|
||||
1. snapshot RPC (view=all) — daemon answers
|
||||
2. buy intent RPC — admission + slot transition
|
||||
3. duplicate buy (same correlation_id) — intent_log dedup gate (REQ-023 AC-1)
|
||||
4. wait for execution tick + fill event
|
||||
5. sell intent RPC — slot → closed + sell event
|
||||
1. snapshot RPC (view=all) — daemon answers; slots[] = permanent
|
||||
asset slots created by the universe
|
||||
reconcile (status `inactive`)
|
||||
2. buy intent RPC keyed by slot_id — admission + slot → pending_buy
|
||||
(REQ-061 canonical key; portfolio_id is the legacy dual-accept)
|
||||
3. duplicate buy (same intent_id) — intent_log dedup gate (REQ-023 AC-1)
|
||||
4. wait for execution tick + fill — slot → holding
|
||||
5. sell intent RPC — slot → pending_sell → fill →
|
||||
`inactive` (occupancy cleared,
|
||||
asset binding kept; NOT 'closed')
|
||||
|
||||
Trading_date defaults to UTC today (matches what the daemon preallocates).
|
||||
Pass the instance key as argv[1] (default: "default"). Env from config/.env.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(".env")
|
||||
|
||||
import pika # noqa: E402
|
||||
load_dotenv("config/.env")
|
||||
|
||||
from trex_core.adapters.messaging import topology # noqa: E402
|
||||
from trex_core.adapters.messaging.rabbitmq import RabbitMQTransport # noqa: E402
|
||||
|
||||
URL = os.environ.get("TREX_RABBITMQ_URL", "amqp://admin:admin@localhost:5672/")
|
||||
TODAY = datetime.now(tz=timezone.utc).date()
|
||||
STRATEGY = "STRAT-A"
|
||||
SYMBOL = "005930"
|
||||
|
||||
|
||||
def _subscribe_events(received: list, stop: threading.Event, queue: str) -> threading.Thread:
|
||||
"""Background event consumer bound BEFORE we trigger intents."""
|
||||
def loop() -> None:
|
||||
t = RabbitMQTransport(URL, timeout=5.0)
|
||||
try:
|
||||
t.consume_events_forever(
|
||||
queue,
|
||||
topology.strategy_event_binding_pattern(STRATEGY),
|
||||
lambda rk, p: received.append((rk, p)),
|
||||
stop,
|
||||
)
|
||||
finally:
|
||||
t.close()
|
||||
th = threading.Thread(target=loop, name="smoke-events", daemon=True)
|
||||
th.start()
|
||||
return th
|
||||
|
||||
|
||||
def _cleanup_queue(queue: str) -> None:
|
||||
try:
|
||||
c = pika.BlockingConnection(pika.URLParameters(URL))
|
||||
try:
|
||||
c.channel().queue_delete(queue)
|
||||
finally:
|
||||
c.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _print_slots(slots: list) -> None:
|
||||
for s in slots:
|
||||
print(f" slot {s['slot_id'][:8]}.. status={s['status']:<12} "
|
||||
f"symbol={s['symbol']} strategy_id={s['strategy_id']} qty={s['qty']} "
|
||||
f"avg_price={s['avg_price']}")
|
||||
INST = sys.argv[1] if len(sys.argv) > 1 else "default"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ev_queue = f"trex.events.strategy.{STRATEGY}.smoke.{uuid.uuid4().hex[:6]}"
|
||||
ev_received: list[tuple[str, dict]] = []
|
||||
ev_stop = threading.Event()
|
||||
ev_thread = _subscribe_events(ev_received, ev_stop, ev_queue)
|
||||
time.sleep(0.6) # let queue bind before triggering events
|
||||
mq = RabbitMQTransport(URL, timeout=10.0)
|
||||
snap_q = topology.snapshot_queue(INST)
|
||||
intent_q = topology.intent_queue(INST)
|
||||
|
||||
client = RabbitMQTransport(URL, timeout=5.0)
|
||||
try:
|
||||
# 1) snapshot RPC ---------------------------------------------------
|
||||
print(f"=== 1) snapshot RPC (view=all, day={TODAY.isoformat()}) ===")
|
||||
resp = client.call_rpc(
|
||||
topology.SNAPSHOT_REQ_QUEUE,
|
||||
{"view": "all", "trading_date": TODAY.isoformat()},
|
||||
correlation_id=f"snap-{uuid.uuid4().hex[:8]}",
|
||||
timeout=5.0,
|
||||
def snap():
|
||||
return mq.call_rpc(
|
||||
snap_q,
|
||||
{"view": "all", "actor": "operator", "touch_watchdog": False},
|
||||
correlation_id=str(uuid.uuid4()), timeout=8.0,
|
||||
)
|
||||
print(f"slots={len(resp['slots'])} capacity={resp['capacity']}")
|
||||
if resp["capacity"]["idle_count"] == 0:
|
||||
print("\n!! no idle slots for today — restart the daemon to preallocate, "
|
||||
"or check `date -u`")
|
||||
return 2
|
||||
|
||||
# 2) buy intent RPC ------------------------------------------------
|
||||
print(f"\n=== 2) buy intent RPC ({SYMBOL}, qty=10@60000, "
|
||||
f"strategy={STRATEGY}, portfolio=PF-smoke) ===")
|
||||
cid = f"smoke-buy-{uuid.uuid4().hex[:8]}"
|
||||
resp = client.call_rpc(
|
||||
topology.INTENT_REQ_QUEUE,
|
||||
{
|
||||
"intent_type": "buy", "intent_id": cid,
|
||||
"trading_date": TODAY.isoformat(), "symbol": SYMBOL,
|
||||
"qty": 10, "entry_price": 60000,
|
||||
"strategy_id": STRATEGY, "portfolio_id": "PF-smoke",
|
||||
},
|
||||
correlation_id=cid, timeout=5.0,
|
||||
)
|
||||
print(f"admission: {json.dumps(resp, ensure_ascii=False)}")
|
||||
if resp["status"] != "admitted":
|
||||
print("\n!! buy not admitted — aborting"); return 3
|
||||
def intent(payload):
|
||||
cid = payload.get("intent_id") or str(uuid.uuid4())
|
||||
return mq.call_rpc(intent_q, payload, correlation_id=cid, timeout=8.0)
|
||||
|
||||
# 3) duplicate -----------------------------------------------------
|
||||
print(f"\n=== 3) duplicate buy (same correlation_id={cid[-8:]}…) ===")
|
||||
resp_dup = client.call_rpc(
|
||||
topology.INTENT_REQ_QUEUE,
|
||||
{
|
||||
"intent_type": "buy", "intent_id": cid,
|
||||
"trading_date": TODAY.isoformat(), "symbol": SYMBOL,
|
||||
"qty": 10, "strategy_id": STRATEGY, "portfolio_id": "PF-smoke",
|
||||
},
|
||||
correlation_id=cid, timeout=5.0,
|
||||
)
|
||||
print(f"identical: {resp == resp_dup} (REQ-023 AC-1 intent_log dedup)")
|
||||
print("=== 1) snapshot RPC ===")
|
||||
s = snap()
|
||||
slots = s["slots"]
|
||||
print(f"assets={len(s['assets'])} slots={len(slots)} "
|
||||
f"available_slots={s['available_slots']}")
|
||||
inactive = [x for x in slots if x["status"] == "inactive"
|
||||
and not x["universe_detached"]]
|
||||
if not inactive:
|
||||
print("no admittable (inactive) asset slot — universe reconcile not "
|
||||
"done yet, or all assets active")
|
||||
return 1
|
||||
target = inactive[0]
|
||||
sid = target["slot_id"]
|
||||
print(f"target slot_id={sid} portfolio_id={target['portfolio_id']}")
|
||||
|
||||
# 4) wait for fill + event -----------------------------------------
|
||||
print(f"\n=== 4) wait for execution tick + fill event (paper broker) ===")
|
||||
deadline = time.monotonic() + 10
|
||||
while time.monotonic() < deadline:
|
||||
resp = client.call_rpc(
|
||||
topology.SNAPSHOT_REQ_QUEUE,
|
||||
{"view": "open", "trading_date": TODAY.isoformat()},
|
||||
correlation_id=f"snap-{uuid.uuid4().hex[:8]}", timeout=5.0,
|
||||
)
|
||||
holdings = [s for s in resp["slots"] if s["status"] == "holding"]
|
||||
if holdings:
|
||||
_print_slots(holdings)
|
||||
break
|
||||
time.sleep(0.5)
|
||||
else:
|
||||
print("\n!! no holding slot appeared in 10s — execution loop stuck?"); return 4
|
||||
print("=== 2) buy by slot_id ===")
|
||||
iid = str(uuid.uuid4())
|
||||
d = intent({"intent_type": "buy", "intent_id": iid, "slot_id": sid,
|
||||
"qty": 10, "entry_price": 1000, "strategy_id": "STRAT-A"})
|
||||
print(f"-> {d['status']} reason={d['reason']}")
|
||||
if d["status"] != "admitted":
|
||||
return 1
|
||||
|
||||
print(f"buy events captured so far: "
|
||||
f"{[rk for rk, _ in ev_received if rk.startswith('slot_filled_buy.')]}")
|
||||
print("=== 3) duplicate buy (same intent_id) ===")
|
||||
d2 = intent({"intent_type": "buy", "intent_id": iid, "slot_id": sid, "qty": 10})
|
||||
print(f"identical: {d2 == d}")
|
||||
|
||||
# 5) sell intent ---------------------------------------------------
|
||||
sid = holdings[0]["slot_id"]
|
||||
scid = f"smoke-sell-{uuid.uuid4().hex[:8]}"
|
||||
print(f"\n=== 5) sell intent on slot {sid[:8]}.. ===")
|
||||
resp_sell = client.call_rpc(
|
||||
topology.INTENT_REQ_QUEUE,
|
||||
{"intent_type": "sell", "intent_id": scid, "slot_id": sid},
|
||||
correlation_id=scid, timeout=5.0,
|
||||
)
|
||||
print(f"admission: {json.dumps(resp_sell, ensure_ascii=False)}")
|
||||
print("=== 4) wait for fill ===")
|
||||
for _ in range(20):
|
||||
time.sleep(0.5)
|
||||
st = next(x for x in snap()["slots"] if x["slot_id"] == sid)
|
||||
if st["status"] == "holding":
|
||||
break
|
||||
print(f"slot status={st['status']} qty={st['qty']} avg_price={st['avg_price']}")
|
||||
|
||||
# Wait for the sell fill event
|
||||
deadline = time.monotonic() + 10
|
||||
while time.monotonic() < deadline and not any(
|
||||
rk.startswith("slot_filled_sell.") for rk, _ in ev_received
|
||||
):
|
||||
time.sleep(0.3)
|
||||
print("=== 5) sell intent ===")
|
||||
d3 = intent({"intent_type": "sell", "intent_id": str(uuid.uuid4()),
|
||||
"slot_id": sid})
|
||||
print(f"-> {d3['status']}")
|
||||
for _ in range(20):
|
||||
time.sleep(0.5)
|
||||
st = next(x for x in snap()["slots"] if x["slot_id"] == sid)
|
||||
if st["status"] == "inactive":
|
||||
break
|
||||
print(f"slot status={st['status']} qty={st['qty']} "
|
||||
f"portfolio_id={st['portfolio_id']} (asset binding kept)")
|
||||
|
||||
print(f"\nall events captured ({len(ev_received)}):")
|
||||
for rk, payload in ev_received:
|
||||
print(f" rk={rk} status={payload.get('status'):<8} "
|
||||
f"qty={payload.get('qty')} sequence_at={payload.get('sequence_at')}")
|
||||
|
||||
print("\nDONE")
|
||||
return 0
|
||||
finally:
|
||||
ev_stop.set()
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
ev_thread.join(timeout=2.0)
|
||||
_cleanup_queue(ev_queue)
|
||||
mq.close()
|
||||
print("DONE")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
raise SystemExit(main())
|
||||
|
||||
Reference in New Issue
Block a user