diff --git a/docs/batches/bat-059_20260727_intent-venue-fanin/batch.md b/docs/batches/bat-059_20260727_intent-venue-fanin/batch.md new file mode 100644 index 0000000..207638c --- /dev/null +++ b/docs/batches/bat-059_20260727_intent-venue-fanin/batch.md @@ -0,0 +1,9 @@ +# BAT-059 intent venue fan-in + +- Profile: standard +- Status: release-candidate +- Discovery: dcy-039 +- Included REQ: REQ-124 + +전략 wire의 venue를 슬롯과 브로커 주문·취소까지 additive로 연결한다. +배포와 실주문은 이 batch 범위가 아니다. diff --git a/docs/batches/bat-059_20260727_intent-venue-fanin/design.md b/docs/batches/bat-059_20260727_intent-venue-fanin/design.md new file mode 100644 index 0000000..dbd99aa --- /dev/null +++ b/docs/batches/bat-059_20260727_intent-venue-fanin/design.md @@ -0,0 +1,8 @@ +# BAT-059 Design + +`Slot.venue`는 현재 pending 주문의 거래소 구분값이다. admission이 허용값을 +검증하고 pending 전이에 저장한다. HOLDING에는 마지막 주문값이 남지만 sell intent가 +현재 청산 venue로 덮어쓴다. INACTIVE에서는 KRX로 초기화한다. + +기본 KRX는 기존 전략의 wire·stub·운영 동작을 보존한다. NXT 취소만 venue keyword를 +명시해 이전 테스트 double과의 하위 호환도 유지한다. diff --git a/docs/batches/bat-059_20260727_intent-venue-fanin/implementation.md b/docs/batches/bat-059_20260727_intent-venue-fanin/implementation.md new file mode 100644 index 0000000..59006bd --- /dev/null +++ b/docs/batches/bat-059_20260727_intent-venue-fanin/implementation.md @@ -0,0 +1,7 @@ +# BAT-059 Implementation + +- `BuyIntent`/`SellIntent`와 handler venue 추가 +- `Slot.venue`, migration 017, PostgreSQL mapping 추가 +- `ExecutionService` 주문·취소 venue 전달 +- invalid venue 사전 거부 +- 전용 NXT flow 테스트 추가 diff --git a/docs/batches/bat-059_20260727_intent-venue-fanin/planning.md b/docs/batches/bat-059_20260727_intent-venue-fanin/planning.md new file mode 100644 index 0000000..a7f84c0 --- /dev/null +++ b/docs/batches/bat-059_20260727_intent-venue-fanin/planning.md @@ -0,0 +1,7 @@ +# BAT-059 Planning + +1. venue wire·domain 계약 추가 +2. 슬롯 persistence와 migration 추가 +3. execution submit/cancel 전달 +4. NXT 신규 테스트와 전체 KRX 회귀 +5. interface/data/runtime 문서 동기화 diff --git a/docs/batches/bat-059_20260727_intent-venue-fanin/verification.md b/docs/batches/bat-059_20260727_intent-venue-fanin/verification.md new file mode 100644 index 0000000..94fba25 --- /dev/null +++ b/docs/batches/bat-059_20260727_intent-venue-fanin/verification.md @@ -0,0 +1,10 @@ +# BAT-059 Verification + +- Status: PASS +- Targeted: 32 passed +- Full: 837 passed, 47 skipped +- 실계좌·실주문: 실행하지 않음 +- 기존 runtime: 변경하지 않음 + +검증 범위는 wire→slot→broker submit/cancel, invalid venue, INACTIVE reset, +PostgreSQL row mapping, 전체 회귀다. diff --git a/docs/batches/index.md b/docs/batches/index.md index 51abdae..bc1ea35 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -64,3 +64,4 @@ | BAT-054 | standard | in-progress | REQ-113 (AC-2/AC-4 보강), REQ-115 (AC-1) | dcy-036 | [bat-054](./bat-054_20260628_daystart-credential-kis-inject/) | | BAT-057 | standard | release-candidate | REQ-119, REQ-120, REQ-121, REQ-122 | dcy-037 | [bat-057](./bat-057_20260714_operator-broker-truth-hotfix/) | | BAT-058 | standard | release-candidate | REQ-123 | dcy-038 | [bat-058](./bat-058_20260716_cost-basis-safe-sync-seed/) | +| BAT-059 | standard | release-candidate | REQ-124 | dcy-039 | [bat-059](./bat-059_20260727_intent-venue-fanin/) | diff --git a/docs/data-model.md b/docs/data-model.md index ffdd971..c41c25f 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -49,6 +49,7 @@ - limit_price :: numeric? :: 현재 pending 주문 지정가(side 무관, dcy-012/REQ-002 — 과거 `entry_price`에서 rename). pending 진입(매수/매도 지정가) 시 set, pending 이탈(→holding/→closed) 시 None 으로 clear → 매수 지정가가 매도로 누수되지 않음(issue #10). None 이면 dispatch 시 시장가. - allocated_capital :: numeric? :: 배정 자본 - order_id / broker_order_id :: str? :: 주문 식별자(자유 주석 / 브로커 핸들) +- venue :: TEXT NOT NULL DEFAULT 'KRX' :: 현재 주문 거래소 구분(KRX/NXT/SOR). pending 주문·취소까지 보존하고 INACTIVE 진입 시 KRX 초기화. - strategy_id / sell_strategy_id :: str? :: 진입/청산 전략 식별 - portfolio_id :: str? :: 포트폴리오 식별 - forced_close_at :: datetime? :: 강제 청산 마감선 diff --git a/docs/discovery/dcy-039_20260727_intent-venue-end-to-end-propagation.md b/docs/discovery/dcy-039_20260727_intent-venue-end-to-end-propagation.md new file mode 100644 index 0000000..26a4bd5 --- /dev/null +++ b/docs/discovery/dcy-039_20260727_intent-venue-end-to-end-propagation.md @@ -0,0 +1,32 @@ +# dcy-039: intent venue end-to-end propagation + +- Status: confirmed +- Date: 2026-07-27 +- Owner: trex-core-dev + +## Problem + +`OrderRequest`와 KIS adapter는 KRX/NXT venue를 지원하지만 전략 intent의 +`venue`가 슬롯에 저장되지 않아 execution이 항상 기본 KRX로 주문한다. +08:00 NXT 전략은 이 단절 때문에 계정 연결 준비 상태가 아니다. + +## Decision + +buy/sell wire에 선택적 `venue`를 추가하고 기본값은 KRX로 유지한다. 허용값은 +KRX/NXT/SOR다. admission에서 검증한 venue를 pending 슬롯에 저장해 주문·취소에 +전달하고, INACTIVE 복귀 시 KRX로 초기화한다. + +## Scope + +- intent DTO와 RabbitMQ handler +- slot 필드·PostgreSQL migration +- execution submit/cancel 전달 +- 기존 KRX 회귀와 NXT 왕복 테스트 + +실계좌 연결, 실주문, 배포, 기존 runtime 변경은 제외한다. + +## Evidence + +- 신규 venue 흐름 테스트 4건 +- 전체 회귀 `837 passed, 47 skipped` +- 별도 worktree `codex/top50-venue-aware-intents` diff --git a/docs/discovery/index.md b/docs/discovery/index.md index 2d6c41d..943846c 100644 --- a/docs/discovery/index.md +++ b/docs/discovery/index.md @@ -43,3 +43,4 @@ | dcy-036_20260628_daystart-credential-fetch-failsafe-trading-load | 20260628 | 그날-시작 KIS 인증정보 fetch(portfolio-hub) + fail-safe 거래 로드 — 진실원천 부재 시 신규 거래 미개시, 장중 무중단 (교차 발원 portfolio-hub dcy-010) | [Discovery](./dcy-036_20260628_daystart-credential-fetch-failsafe-trading-load.md) | confirmed | | dcy-037_20260714_operator-broker-truth-and-managed-slot-divergence-hotfix | 20260714 | HOTFIX — fetched credential 운영자 broker truth 정합 및 managed non-holding divergence 분리 (#39, #40, #41) | [Discovery](./dcy-037_20260714_operator-broker-truth-and-managed-slot-divergence-hotfix.md) | confirmed | | dcy-038_20260716_cost-basis-response-contract-and-safe-sync-seeding | 20260716 | P1 — portfolio-hub cost-basis response contract 정합 및 안전한 sync 평균가 seed | [Discovery](./dcy-038_20260716_cost-basis-response-contract-and-safe-sync-seeding.md) | confirmed | +| dcy-039_20260727_intent-venue-end-to-end-propagation | 20260727 | 전략 intent venue를 슬롯·주문·취소까지 전달 | [Discovery](./dcy-039_20260727_intent-venue-end-to-end-propagation.md) | confirmed | diff --git a/docs/interface-contract.md b/docs/interface-contract.md index fc6242e..b4499ab 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -415,7 +415,7 @@ resume_kill_switch 요청·응답 (REQ-046): ### Additive Notes (REQ-117) - `venue` 필드는 BAT-056에서 additive 추가. 기존 orb/tsl 미지정 호출 → 기본값 `"KRX"` 자동 적용 → 하위 호환. -- `OrderRequest`는 AMQP wire 위를 직접 흐르지 않으므로 wire 계약 영향 없음. intent 메시지에서 venue를 전달하는 것은 tvr 팬-인 batch(후속) 범위. +- `OrderRequest`는 AMQP wire 위를 직접 흐르지 않는다. BAT-059부터 buy/sell intent의 선택적 `venue`(기본 KRX)를 슬롯에 보존해 이 DTO까지 전달한다. 허용값 외에는 `invalid_venue`로 admission 전 거부한다. - KIS 어댑터(REQ-118)는 `venue` → `EXCG_ID_DVSN_CD` 로 변환해 KIS HTTP 요청에 항상 명시 전송. ## `BrokerPort` Protocol 메서드 (BAT-056 / REQ-118) diff --git a/docs/runtime-flows.md b/docs/runtime-flows.md index 70edc9b..6ac694b 100644 --- a/docs/runtime-flows.md +++ b/docs/runtime-flows.md @@ -264,7 +264,7 @@ - 슬롯 `limit_price` 생애주기(dcy-012/REQ-002): INACTIVE→PENDING_BUY(매수 지정가 set) → PENDING_BUY→HOLDING(clear) → HOLDING→PENDING_SELL(매도 지정가 set 또는 None=시장가) → PENDING_SELL→INACTIVE(clear). 롤백(→INACTIVE) 전이에서도 clear. 외부 취소 후 holding 복귀에서도 clear 되어 재매도는 시장가. - **실행 정책 생애주기(dcy-022/REQ-068)**: `timeout_sec`·`slippage_pct` 는 `limit_price` 와 동일한 생애주기(PENDING_* 진입 시 set, HOLDING/INACTIVE 탈출 시 clear). 슬롯의 `updated_at` 이 dispatch 시각 proxy 로 사용된다(mark_dispatched 가 갱신, stale-alert 와 동일 방법). - 어댑터: paper(결정적 async 더블) / KIS(모의투자 검증, 실계좌 게이팅). -- **venue 전달 경로(BAT-056/REQ-117/118)**: `OrderRequest.venue`(기본 `"KRX"`) → KIS 어댑터가 `EXCG_ID_DVSN_CD`로 변환해 submit_order/cancel_order body에 항상 명시 전송. NXT 주문이면 `venue="NXT"` 지정 → `EXCG_ID_DVSN_CD=NXT`. 기존 orb/tsl은 venue 미지정(기본 KRX) → 이전 대비 EXCG 필드 추가만 다름(동작 동일). intent 메시지 → venue 전달은 tvr 팬-인 batch(후속) 범위 — 현재 wire 계약 불변. +- **venue 전달 경로(BAT-056/REQ-117/118, BAT-059/REQ-124)**: buy/sell intent `venue`(기본 KRX) → pending `Slot.venue` → `OrderRequest.venue`와 취소 venue → KIS `EXCG_ID_DVSN_CD`. 허용값은 KRX/NXT/SOR이며 invalid는 슬롯 전이 전 거부한다. INACTIVE 복귀 시 KRX로 초기화한다. ### 운영자 cancel 백스톱 (dcy-022/REQ-071) diff --git a/docs/srs/Integration/req-124_intent-venue-persistence-and-execution.md b/docs/srs/Integration/req-124_intent-venue-persistence-and-execution.md new file mode 100644 index 0000000..3e5625f --- /dev/null +++ b/docs/srs/Integration/req-124_intent-venue-persistence-and-execution.md @@ -0,0 +1,23 @@ +# REQ-124: intent venue persistence and execution + +- Type: Integration +- Status: Implemented +- Priority: High +- Source: dcy-039 + +## Requirements + +1. buy/sell intent는 `venue`를 선택적으로 받으며 미지정은 KRX다. +2. KRX/NXT/SOR 외 값은 슬롯 전이 전에 `invalid_venue`로 거부한다. +3. venue는 intent log, pending slot, `OrderRequest`, timeout/operator cancel에 + 동일하게 전달한다. +4. INACTIVE 복귀 시 venue는 KRX로 초기화한다. +5. 기존 venue 미지정 전략과 KRX 테스트는 변경 없이 통과한다. +6. PostgreSQL migration은 기존 행을 KRX로 안전하게 backfill한다. + +## Acceptance Evidence + +- `tests/application/test_venue_intent_flow.py` +- `tests/domain/slot/test_contract.py` +- `tests/adapters/persistence/test_postgresql_slot_repository_parity.py` +- 전체 회귀 837 passed, 47 skipped diff --git a/docs/srs/index.md b/docs/srs/index.md index 6c67c52..3e55a42 100644 --- a/docs/srs/index.md +++ b/docs/srs/index.md @@ -204,9 +204,12 @@ | REQ-121 | managed non-holding divergence 분류와 slot 존재 조회 계약 | Data | Implemented | High | trex-core-dev | [req-121](./Data/req-121_managed-non-holding-divergence-classification-query.md) | | REQ-122 | operator broker truth hotfix baseline 문서 정합 | Documentation | Implemented | High | trex-core-dev | [req-122](./Documentation/req-122_operator-broker-truth-hotfix-baseline-alignment.md) | | REQ-123 | cost-basis canonical parse 및 WAC unavailable 안전 sync seed | Integration | Approved | High | trex-core-dev | [req-123](./Integration/req-123_cost-basis-canonical-parse-and-safe-sync-seeding.md) | +| REQ-124 | intent venue persistence and execution | Integration | Implemented | High | trex-core-dev | [req-124](./Integration/req-124_intent-venue-persistence-and-execution.md) | ## Recent Change Log Summary +- 2026-07-27 :: REQ-124 Implemented: strategy intent venue를 pending slot과 broker submit/cancel까지 additive 전달. 전체 회귀 837 passed, 47 skipped. 배포·실주문 제외. + - 2026-07-16 :: REQ-123 **confirm-req 승인(Approved, lead confirm)**: dcy-038 OQ-1~3 완료 기준으로 제약(scope 제외 항목: API/schema 변경/credential/config 변경/live sync/배포), OQ-2/OQ-3 결정 반영, fail-closed WAC unavail no-write 정책, canonical parsing 우선순위/legacy fallback 규칙 및 dry-run diagnostics가 일치함을 확인해 `REQ-123`을 Approved 전환. - 2026-07-16 :: REQ-123 **신규 생성(Proposed, draft-req, dcy-038)**: portfolio-hub canonical `cost_basis_per_unit` consumer parse, legacy alias precedence, WAC unavailable fail-closed sync/no-write, dry-run diagnostics, response-contract/regression tests, observability 및 rollback/release gate. portfolio-hub API/schema/auth·token/config·DB migration/direct edit·live sync·배포 제외. - 2026-07-15 :: REQ-119~122 **`Approved` → `Implemented`** (`confirm-req-implemented`, dcy-037/BAT-057): PR #42 merged to `main` (94e1489). Independent QC PASS-WITH-NOTES (acceptance blocker 0); 806 passed/47 skipped/1 deselected. `sync --live`, deployment, and production remediation remain separate release-approval work. diff --git a/tests/adapters/persistence/test_postgresql_slot_repository_parity.py b/tests/adapters/persistence/test_postgresql_slot_repository_parity.py index b295eb2..df812cf 100644 --- a/tests/adapters/persistence/test_postgresql_slot_repository_parity.py +++ b/tests/adapters/persistence/test_postgresql_slot_repository_parity.py @@ -35,7 +35,7 @@ _SLOT_COLUMNS = ( "order_id, strategy_id, broker_order_id, portfolio_id, " "sell_strategy_id, forced_close_at, stop_loss_price, take_profit_price, " "created_at, updated_at, instance_key, retired_at, " - "timeout_sec, slippage_pct, occupancy_seq" + "timeout_sec, slippage_pct, occupancy_seq, venue" ) _MANAGED_SLOT_SQL = ( f"SELECT {_SLOT_COLUMNS} FROM daily_slot " @@ -145,6 +145,7 @@ def _row(slot: Slot) -> tuple: slot.timeout_sec, Decimal(str(slot.slippage_pct)), slot.occupancy_seq, + slot.venue, ) diff --git a/tests/application/test_venue_intent_flow.py b/tests/application/test_venue_intent_flow.py new file mode 100644 index 0000000..d70fed6 --- /dev/null +++ b/tests/application/test_venue_intent_flow.py @@ -0,0 +1,152 @@ +"""Venue propagation from the strategy wire contract to broker execution.""" +from __future__ import annotations + +from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore +from trex_core.application.broker_port import BalanceItem, FillStatus, OrderAck, OrderRequest +from trex_core.application.execution import ExecutionService +from trex_core.application.intents import IntentService +from trex_core.application.messaging import RpcRequest +from trex_core.application.messaging_handlers import make_intent_rpc_handler +from trex_core.domain.slot import SlotService, SlotState +from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository + + +class RecordingBroker: + def __init__(self) -> None: + self.orders: list[OrderRequest] = [] + self.cancel_venues: list[str] = [] + + def submit_order(self, req: OrderRequest) -> OrderAck: + self.orders.append(req) + return OrderAck(accepted=True, broker_order_id=f"order-{len(self.orders)}") + + def poll_fill(self, broker_order_id: str, *, side: str, symbol: str) -> FillStatus: + return FillStatus(state="accepted") + + def cancel_order( + self, + broker_order_id: str, + *, + side: str, + symbol: str, + qty: int, + venue: str = "KRX", + ) -> bool: + self.cancel_venues.append(venue) + return True + + def get_balance(self) -> list[BalanceItem]: + return [] + + +def _wire() -> tuple[ + InMemorySlotRepository, + IntentService, + ExecutionService, + RecordingBroker, +]: + repo = InMemorySlotRepository() + service = SlotService(repo) + intents = IntentService( + service, + repo, + InMemoryIntentStore(), + resolve_symbol=lambda portfolio_id: "005930", + ) + repo.reconcile_universe("default", {"PF-1": "005930"}) + broker = RecordingBroker() + return repo, intents, ExecutionService(service, repo, broker), broker + + +def test_nxt_buy_wire_value_reaches_broker_order() -> None: + repo, intents, execution, broker = _wire() + handler = make_intent_rpc_handler(intents) + + response = handler( + RpcRequest( + payload={ + "intent_type": "buy", + "intent_id": "nxt-buy", + "portfolio_id": "PF-1", + "qty": 1, + "venue": "NXT", + }, + correlation_id="nxt-buy", + ) + ) + + assert response.payload["status"] == "admitted" + pending = next(slot for slot in repo.list_slots("default") if slot.status is SlotState.PENDING_BUY) + assert pending.venue == "NXT" + execution.run_once() + assert broker.orders[0].venue == "NXT" + + +def test_invalid_venue_is_rejected_before_slot_transition() -> None: + repo, intents, _, _ = _wire() + handler = make_intent_rpc_handler(intents) + + response = handler( + RpcRequest( + payload={ + "intent_type": "buy", + "intent_id": "bad-venue", + "portfolio_id": "PF-1", + "qty": 1, + "venue": "UNKNOWN", + }, + correlation_id="bad-venue", + ) + ) + + assert response.payload["status"] == "rejected" + assert response.payload["reason"] == "invalid_venue" + assert repo.list_slots("default")[0].status is SlotState.INACTIVE + + +def test_inactive_transition_resets_venue_to_krx() -> None: + repo, intents, _, _ = _wire() + handler = make_intent_rpc_handler(intents) + handler( + RpcRequest( + payload={ + "intent_type": "buy", + "intent_id": "nxt-buy-reset", + "portfolio_id": "PF-1", + "qty": 1, + "venue": "NXT", + }, + correlation_id="nxt-buy-reset", + ) + ) + slot = repo.list_slots("default")[0] + service = SlotService(repo) + reset = service.transit_slot( + slot.slot_id, + SlotState.PENDING_BUY, + SlotState.INACTIVE, + reason="rollback", + actor="test", + ) + assert reset.venue == "KRX" + + +def test_operator_cancel_uses_original_nxt_venue() -> None: + repo, intents, execution, broker = _wire() + handler = make_intent_rpc_handler(intents) + response = handler( + RpcRequest( + payload={ + "intent_type": "buy", + "intent_id": "nxt-buy-cancel", + "portfolio_id": "PF-1", + "qty": 1, + "venue": "NXT", + }, + correlation_id="nxt-buy-cancel", + ) + ) + execution.run_once() + + assert execution.cancel_pending_slot(response.payload["slot_id"]) == "ok" + assert broker.cancel_venues == ["NXT"] diff --git a/trex_core/adapters/persistence/postgresql.py b/trex_core/adapters/persistence/postgresql.py index d1614d9..1016bc2 100644 --- a/trex_core/adapters/persistence/postgresql.py +++ b/trex_core/adapters/persistence/postgresql.py @@ -37,7 +37,7 @@ _SLOT_COLUMNS = ( "order_id, strategy_id, broker_order_id, portfolio_id, " "sell_strategy_id, forced_close_at, stop_loss_price, take_profit_price, " "created_at, updated_at, instance_key, retired_at, " - "timeout_sec, slippage_pct, occupancy_seq" + "timeout_sec, slippage_pct, occupancy_seq, venue" ) @@ -216,7 +216,7 @@ class PostgreSQLSlotRepository: "broker_order_id=%s, portfolio_id=%s, sell_strategy_id=%s, " "forced_close_at=%s, stop_loss_price=%s, take_profit_price=%s, " "instance_key=%s, retired_at=%s, timeout_sec=%s, slippage_pct=%s, " - "occupancy_seq=%s, updated_at=%s WHERE slot_id=%s", + "occupancy_seq=%s, venue=%s, updated_at=%s WHERE slot_id=%s", ( slot.status.value, slot.symbol, @@ -236,6 +236,7 @@ class PostgreSQLSlotRepository: slot.timeout_sec, slot.slippage_pct, slot.occupancy_seq, + slot.venue, slot.updated_at or datetime.now(tz=timezone.utc), slot.slot_id, ), @@ -361,7 +362,7 @@ class PostgreSQLSlotRepository: # forced_close_at(11) stop_loss_price(12) take_profit_price(13) # created_at(14) updated_at(15) instance_key(16) retired_at(17) # timeout_sec(18) slippage_pct(19) [dcy-022/REQ-068] - # occupancy_seq(20) [dcy-028/REQ-081] + # occupancy_seq(20) [dcy-028/REQ-081], venue(21) return Slot( slot_id=row[0] if isinstance(row[0], UUID) else UUID(str(row[0])), status=SlotState(row[1]), @@ -384,6 +385,7 @@ class PostgreSQLSlotRepository: timeout_sec=int(row[18]) if row[18] is not None else None, slippage_pct=f(row[19]), occupancy_seq=int(row[20]) if len(row) > 20 and row[20] is not None else 0, + venue=str(row[21]) if len(row) > 21 and row[21] is not None else "KRX", ) diff --git a/trex_core/adapters/persistence/sql/017_add_slot_order_venue.sql b/trex_core/adapters/persistence/sql/017_add_slot_order_venue.sql new file mode 100644 index 0000000..a6571df --- /dev/null +++ b/trex_core/adapters/persistence/sql/017_add_slot_order_venue.sql @@ -0,0 +1,11 @@ +-- Preserve the admitted intent venue until dispatch/cancel. Existing rows are +-- explicitly backward compatible with the historical KRX-only execution path. +ALTER TABLE daily_slot + ADD COLUMN IF NOT EXISTS venue TEXT NOT NULL DEFAULT 'KRX'; + +ALTER TABLE daily_slot + DROP CONSTRAINT IF EXISTS daily_slot_venue_check; + +ALTER TABLE daily_slot + ADD CONSTRAINT daily_slot_venue_check + CHECK (venue IN ('KRX', 'NXT', 'SOR')); diff --git a/trex_core/application/execution.py b/trex_core/application/execution.py index 6e828e1..0987cee 100644 --- a/trex_core/application/execution.py +++ b/trex_core/application/execution.py @@ -197,6 +197,7 @@ class ExecutionService: qty=qty, limit_price=limit, order_type="00" if limit is not None else "01", + venue=slot.venue, ) ) if ack.accepted and ack.broker_order_id: @@ -548,12 +549,14 @@ class ExecutionService: return qty = int(slot.qty or 0) try: - accepted = self._broker.cancel_order( - slot.broker_order_id, - side=side, - symbol=slot.symbol or "", - qty=qty, - ) + cancel_kwargs = { + "side": side, + "symbol": slot.symbol or "", + "qty": qty, + } + if slot.venue != "KRX": + cancel_kwargs["venue"] = slot.venue + accepted = self._broker.cancel_order(slot.broker_order_id, **cancel_kwargs) r.timeout_cancelled += 1 _log.warning( "timeout cancel sent: slot=%s status=%s broker_order_id=%s " @@ -665,12 +668,14 @@ class ExecutionService: side = "buy" if slot.status is SlotState.PENDING_BUY else "sell" qty = int(slot.qty or 0) try: - self._broker.cancel_order( - slot.broker_order_id, - side=side, - symbol=slot.symbol or "", - qty=qty, - ) + cancel_kwargs = { + "side": side, + "symbol": slot.symbol or "", + "qty": qty, + } + if slot.venue != "KRX": + cancel_kwargs["venue"] = slot.venue + self._broker.cancel_order(slot.broker_order_id, **cancel_kwargs) except Exception: # noqa: BLE001 - best-effort _log.warning( "operator cancel failed: slot=%s broker_order_id=%s", diff --git a/trex_core/application/intents.py b/trex_core/application/intents.py index f97f0c2..50d9b99 100644 --- a/trex_core/application/intents.py +++ b/trex_core/application/intents.py @@ -75,6 +75,7 @@ class BuyIntent: # slippage_pct: effective limit = entry_price*(1+pct/100) buy / *(1-pct/100) sell. timeout_sec: Optional[int] = None slippage_pct: Optional[float] = None + venue: str = "KRX" extra: dict[str, Any] = field(default_factory=dict) @@ -93,6 +94,7 @@ class SellIntent: # dcy-022 / REQ-068: execution policy (optional, backward-compat). timeout_sec: Optional[int] = None slippage_pct: Optional[float] = None + venue: str = "KRX" extra: dict[str, Any] = field(default_factory=dict) @@ -152,6 +154,7 @@ class IntentService: "entry_price": intent.entry_price, "strategy_id": intent.strategy_id, "sell_strategy_id": intent.sell_strategy_id, + "venue": intent.venue, } prior = self._dedup(intent.intent_id, "buy", payload) if prior is not None: @@ -169,6 +172,7 @@ class IntentService: "reason": intent.reason, "sell_strategy_id": intent.sell_strategy_id, "qty": intent.qty, + "venue": intent.venue, } prior = self._dedup(intent.intent_id, "sell", payload) if prior is not None: @@ -207,6 +211,9 @@ class IntentService: dcy-036 / REQ-114: entry gate — if entry_allowed_fn returns False, BuyIntent is rejected before any slot lookup.""" + if intent.venue not in {"KRX", "NXT", "SOR"}: + return IntentDecision(intent.intent_id, "rejected", None, "invalid_venue") + # dcy-036 / REQ-114: day-start entry gate (fail-closed). # None or True → pass; False → reject new entry. # SellIntent is NOT checked (청산 경로는 차단하지 않음 AC-5). @@ -292,6 +299,7 @@ class IntentService: take_profit_price=intent.take_profit_price, timeout_sec=effective_timeout, slippage_pct=intent.slippage_pct, + venue=intent.venue, extra={"intent_id": intent.intent_id, "symbol": symbol}, ) except SlotStateMismatchError: @@ -300,6 +308,9 @@ class IntentService: return IntentDecision(intent.intent_id, "rejected", None, "already_active") def _admit_sell(self, intent: SellIntent) -> IntentDecision: + if intent.venue not in {"KRX", "NXT", "SOR"}: + return IntentDecision(intent.intent_id, "rejected", None, "invalid_venue") + slot = self._repo.fetch_slot(intent.slot_id) if slot is None: return IntentDecision(intent.intent_id, "rejected", None, "slot_not_found") @@ -342,6 +353,7 @@ class IntentService: qty=intent.qty, timeout_sec=effective_timeout, slippage_pct=intent.slippage_pct, + venue=intent.venue, extra={"intent_id": intent.intent_id}, ) except SlotStateMismatchError: diff --git a/trex_core/application/messaging_handlers.py b/trex_core/application/messaging_handlers.py index 3a50b75..2a4f3e6 100644 --- a/trex_core/application/messaging_handlers.py +++ b/trex_core/application/messaging_handlers.py @@ -117,6 +117,7 @@ def make_intent_rpc_handler( # dcy-022 / REQ-068: execution policy (optional, backward-compat). timeout_sec=_opt_int(body.get("timeout_sec")), slippage_pct=_opt_float(body.get("slippage_pct")), + venue=_opt_str(body.get("venue")) or "KRX", ) d = intent_service.submit_buy(buy) elif intent_type == "sell": @@ -132,6 +133,7 @@ def make_intent_rpc_handler( # dcy-022 / REQ-068: execution policy (optional, backward-compat). timeout_sec=_opt_int(body.get("timeout_sec")), slippage_pct=_opt_float(body.get("slippage_pct")), + venue=_opt_str(body.get("venue")) or "KRX", ) d = intent_service.submit_sell(sell) else: @@ -626,4 +628,3 @@ def _parse_date(v) -> date_cls: def _today() -> date_cls: return datetime.now(tz=timezone.utc).date() - diff --git a/trex_core/domain/slot/models.py b/trex_core/domain/slot/models.py index f6f2410..1b584e2 100644 --- a/trex_core/domain/slot/models.py +++ b/trex_core/domain/slot/models.py @@ -56,6 +56,9 @@ class Slot: # holding episode. Used by the strategy-state CAS to reject writes computed # against a prior occupancy of a reused slot (point-ABA guard, dcy-028 R-1). occupancy_seq: int = 0 + # Order venue carried from the admitted intent through dispatch/cancel. + # It is reset to KRX when the permanent asset slot returns to INACTIVE. + venue: str = "KRX" # Canonical column contract for `Slot` (dcy-001 OQ-2 / FR-5). @@ -84,6 +87,7 @@ EXPECTED_SLOT_FIELDS: frozenset[str] = frozenset({ "timeout_sec", # dcy-022 / REQ-068 (BAT-032) "slippage_pct", # dcy-022 / REQ-068 (BAT-032) "occupancy_seq", # dcy-028 / REQ-081 (BAT-042) + "venue", }) diff --git a/trex_core/domain/slot/service.py b/trex_core/domain/slot/service.py index 55bf362..80ca97d 100644 --- a/trex_core/domain/slot/service.py +++ b/trex_core/domain/slot/service.py @@ -85,6 +85,7 @@ class SlotService: # dcy-022 / REQ-068: execution policy (set on PENDING entry, cleared on exit). timeout_sec: Optional[int] = None, slippage_pct: Optional[float] = None, + venue: Optional[str] = None, extra: Optional[dict[str, Any]] = None, ) -> Slot: """Transition slot from one state to another atomically. @@ -133,6 +134,7 @@ class SlotService: take_profit_price=take_profit_price, timeout_sec=timeout_sec, slippage_pct=slippage_pct, + venue=venue, ) self._repo.update_slot(updated) @@ -173,6 +175,7 @@ class SlotService: take_profit_price: Optional[Decimal] = None, timeout_sec: Optional[int] = None, slippage_pct: Optional[float] = None, + venue: Optional[str] = None, ) -> Slot: """Compute the post-transition Slot row. @@ -201,6 +204,7 @@ class SlotService: take_profit_price=None, timeout_sec=None, slippage_pct=None, + venue="KRX", updated_at=datetime.now(tz=timezone.utc), ) @@ -267,6 +271,7 @@ class SlotService: timeout_sec=new_timeout_sec, slippage_pct=new_slippage_pct, occupancy_seq=new_occupancy_seq, + venue=venue if venue is not None else slot.venue, updated_at=datetime.now(tz=timezone.utc), )