feat: propagate intent venue through execution

This commit is contained in:
jordan
2026-07-29 16:55:20 +09:00
parent 7084304c9f
commit 479cb39a7e
22 changed files with 314 additions and 19 deletions
@@ -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 범위가 아니다.
@@ -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과의 하위 호환도 유지한다.
@@ -0,0 +1,7 @@
# BAT-059 Implementation
- `BuyIntent`/`SellIntent`와 handler venue 추가
- `Slot.venue`, migration 017, PostgreSQL mapping 추가
- `ExecutionService` 주문·취소 venue 전달
- invalid venue 사전 거부
- 전용 NXT flow 테스트 추가
@@ -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 문서 동기화
@@ -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, 전체 회귀다.
+1
View File
@@ -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-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-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-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/) |
+1
View File
@@ -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 시 시장가. - 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? :: 배정 자본 - allocated_capital :: numeric? :: 배정 자본
- order_id / broker_order_id :: str? :: 주문 식별자(자유 주석 / 브로커 핸들) - order_id / broker_order_id :: str? :: 주문 식별자(자유 주석 / 브로커 핸들)
- venue :: TEXT NOT NULL DEFAULT 'KRX' :: 현재 주문 거래소 구분(KRX/NXT/SOR). pending 주문·취소까지 보존하고 INACTIVE 진입 시 KRX 초기화.
- strategy_id / sell_strategy_id :: str? :: 진입/청산 전략 식별 - strategy_id / sell_strategy_id :: str? :: 진입/청산 전략 식별
- portfolio_id :: str? :: 포트폴리오 식별 - portfolio_id :: str? :: 포트폴리오 식별
- forced_close_at :: datetime? :: 강제 청산 마감선 - forced_close_at :: datetime? :: 강제 청산 마감선
@@ -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`
+1
View File
@@ -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-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-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-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 |
+1 -1
View File
@@ -415,7 +415,7 @@ resume_kill_switch 요청·응답 (REQ-046):
### Additive Notes (REQ-117) ### Additive Notes (REQ-117)
- `venue` 필드는 BAT-056에서 additive 추가. 기존 orb/tsl 미지정 호출 → 기본값 `"KRX"` 자동 적용 → 하위 호환. - `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 요청에 항상 명시 전송. - KIS 어댑터(REQ-118)는 `venue` → `EXCG_ID_DVSN_CD` 로 변환해 KIS HTTP 요청에 항상 명시 전송.
## `BrokerPort` Protocol 메서드 (BAT-056 / REQ-118) ## `BrokerPort` Protocol 메서드 (BAT-056 / REQ-118)
+1 -1
View File
@@ -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 되어 재매도는 시장가. - 슬롯 `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 와 동일 방법). - **실행 정책 생애주기(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(모의투자 검증, 실계좌 게이팅). - 어댑터: 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) ### 운영자 cancel 백스톱 (dcy-022/REQ-071)
@@ -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
+3
View File
@@ -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-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-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-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 ## 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 **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-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. - 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.
@@ -35,7 +35,7 @@ _SLOT_COLUMNS = (
"order_id, strategy_id, broker_order_id, portfolio_id, " "order_id, strategy_id, broker_order_id, portfolio_id, "
"sell_strategy_id, forced_close_at, stop_loss_price, take_profit_price, " "sell_strategy_id, forced_close_at, stop_loss_price, take_profit_price, "
"created_at, updated_at, instance_key, retired_at, " "created_at, updated_at, instance_key, retired_at, "
"timeout_sec, slippage_pct, occupancy_seq" "timeout_sec, slippage_pct, occupancy_seq, venue"
) )
_MANAGED_SLOT_SQL = ( _MANAGED_SLOT_SQL = (
f"SELECT {_SLOT_COLUMNS} FROM daily_slot " f"SELECT {_SLOT_COLUMNS} FROM daily_slot "
@@ -145,6 +145,7 @@ def _row(slot: Slot) -> tuple:
slot.timeout_sec, slot.timeout_sec,
Decimal(str(slot.slippage_pct)), Decimal(str(slot.slippage_pct)),
slot.occupancy_seq, slot.occupancy_seq,
slot.venue,
) )
+152
View File
@@ -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"]
+5 -3
View File
@@ -37,7 +37,7 @@ _SLOT_COLUMNS = (
"order_id, strategy_id, broker_order_id, portfolio_id, " "order_id, strategy_id, broker_order_id, portfolio_id, "
"sell_strategy_id, forced_close_at, stop_loss_price, take_profit_price, " "sell_strategy_id, forced_close_at, stop_loss_price, take_profit_price, "
"created_at, updated_at, instance_key, retired_at, " "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, " "broker_order_id=%s, portfolio_id=%s, sell_strategy_id=%s, "
"forced_close_at=%s, stop_loss_price=%s, take_profit_price=%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, " "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.status.value,
slot.symbol, slot.symbol,
@@ -236,6 +236,7 @@ class PostgreSQLSlotRepository:
slot.timeout_sec, slot.timeout_sec,
slot.slippage_pct, slot.slippage_pct,
slot.occupancy_seq, slot.occupancy_seq,
slot.venue,
slot.updated_at or datetime.now(tz=timezone.utc), slot.updated_at or datetime.now(tz=timezone.utc),
slot.slot_id, slot.slot_id,
), ),
@@ -361,7 +362,7 @@ class PostgreSQLSlotRepository:
# forced_close_at(11) stop_loss_price(12) take_profit_price(13) # forced_close_at(11) stop_loss_price(12) take_profit_price(13)
# created_at(14) updated_at(15) instance_key(16) retired_at(17) # created_at(14) updated_at(15) instance_key(16) retired_at(17)
# timeout_sec(18) slippage_pct(19) [dcy-022/REQ-068] # 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( return Slot(
slot_id=row[0] if isinstance(row[0], UUID) else UUID(str(row[0])), slot_id=row[0] if isinstance(row[0], UUID) else UUID(str(row[0])),
status=SlotState(row[1]), status=SlotState(row[1]),
@@ -384,6 +385,7 @@ class PostgreSQLSlotRepository:
timeout_sec=int(row[18]) if row[18] is not None else None, timeout_sec=int(row[18]) if row[18] is not None else None,
slippage_pct=f(row[19]), slippage_pct=f(row[19]),
occupancy_seq=int(row[20]) if len(row) > 20 and row[20] is not None else 0, 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",
) )
@@ -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'));
+17 -12
View File
@@ -197,6 +197,7 @@ class ExecutionService:
qty=qty, qty=qty,
limit_price=limit, limit_price=limit,
order_type="00" if limit is not None else "01", order_type="00" if limit is not None else "01",
venue=slot.venue,
) )
) )
if ack.accepted and ack.broker_order_id: if ack.accepted and ack.broker_order_id:
@@ -548,12 +549,14 @@ class ExecutionService:
return return
qty = int(slot.qty or 0) qty = int(slot.qty or 0)
try: try:
accepted = self._broker.cancel_order( cancel_kwargs = {
slot.broker_order_id, "side": side,
side=side, "symbol": slot.symbol or "",
symbol=slot.symbol or "", "qty": qty,
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 r.timeout_cancelled += 1
_log.warning( _log.warning(
"timeout cancel sent: slot=%s status=%s broker_order_id=%s " "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" side = "buy" if slot.status is SlotState.PENDING_BUY else "sell"
qty = int(slot.qty or 0) qty = int(slot.qty or 0)
try: try:
self._broker.cancel_order( cancel_kwargs = {
slot.broker_order_id, "side": side,
side=side, "symbol": slot.symbol or "",
symbol=slot.symbol or "", "qty": qty,
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 except Exception: # noqa: BLE001 - best-effort
_log.warning( _log.warning(
"operator cancel failed: slot=%s broker_order_id=%s", "operator cancel failed: slot=%s broker_order_id=%s",
+12
View File
@@ -75,6 +75,7 @@ class BuyIntent:
# slippage_pct: effective limit = entry_price*(1+pct/100) buy / *(1-pct/100) sell. # slippage_pct: effective limit = entry_price*(1+pct/100) buy / *(1-pct/100) sell.
timeout_sec: Optional[int] = None timeout_sec: Optional[int] = None
slippage_pct: Optional[float] = None slippage_pct: Optional[float] = None
venue: str = "KRX"
extra: dict[str, Any] = field(default_factory=dict) extra: dict[str, Any] = field(default_factory=dict)
@@ -93,6 +94,7 @@ class SellIntent:
# dcy-022 / REQ-068: execution policy (optional, backward-compat). # dcy-022 / REQ-068: execution policy (optional, backward-compat).
timeout_sec: Optional[int] = None timeout_sec: Optional[int] = None
slippage_pct: Optional[float] = None slippage_pct: Optional[float] = None
venue: str = "KRX"
extra: dict[str, Any] = field(default_factory=dict) extra: dict[str, Any] = field(default_factory=dict)
@@ -152,6 +154,7 @@ class IntentService:
"entry_price": intent.entry_price, "entry_price": intent.entry_price,
"strategy_id": intent.strategy_id, "strategy_id": intent.strategy_id,
"sell_strategy_id": intent.sell_strategy_id, "sell_strategy_id": intent.sell_strategy_id,
"venue": intent.venue,
} }
prior = self._dedup(intent.intent_id, "buy", payload) prior = self._dedup(intent.intent_id, "buy", payload)
if prior is not None: if prior is not None:
@@ -169,6 +172,7 @@ class IntentService:
"reason": intent.reason, "reason": intent.reason,
"sell_strategy_id": intent.sell_strategy_id, "sell_strategy_id": intent.sell_strategy_id,
"qty": intent.qty, "qty": intent.qty,
"venue": intent.venue,
} }
prior = self._dedup(intent.intent_id, "sell", payload) prior = self._dedup(intent.intent_id, "sell", payload)
if prior is not None: if prior is not None:
@@ -207,6 +211,9 @@ class IntentService:
dcy-036 / REQ-114: entry gate — if entry_allowed_fn returns False, dcy-036 / REQ-114: entry gate — if entry_allowed_fn returns False,
BuyIntent is rejected before any slot lookup.""" 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). # dcy-036 / REQ-114: day-start entry gate (fail-closed).
# None or True → pass; False → reject new entry. # None or True → pass; False → reject new entry.
# SellIntent is NOT checked (청산 경로는 차단하지 않음 AC-5). # SellIntent is NOT checked (청산 경로는 차단하지 않음 AC-5).
@@ -292,6 +299,7 @@ class IntentService:
take_profit_price=intent.take_profit_price, take_profit_price=intent.take_profit_price,
timeout_sec=effective_timeout, timeout_sec=effective_timeout,
slippage_pct=intent.slippage_pct, slippage_pct=intent.slippage_pct,
venue=intent.venue,
extra={"intent_id": intent.intent_id, "symbol": symbol}, extra={"intent_id": intent.intent_id, "symbol": symbol},
) )
except SlotStateMismatchError: except SlotStateMismatchError:
@@ -300,6 +308,9 @@ class IntentService:
return IntentDecision(intent.intent_id, "rejected", None, "already_active") return IntentDecision(intent.intent_id, "rejected", None, "already_active")
def _admit_sell(self, intent: SellIntent) -> IntentDecision: 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) slot = self._repo.fetch_slot(intent.slot_id)
if slot is None: if slot is None:
return IntentDecision(intent.intent_id, "rejected", None, "slot_not_found") return IntentDecision(intent.intent_id, "rejected", None, "slot_not_found")
@@ -342,6 +353,7 @@ class IntentService:
qty=intent.qty, qty=intent.qty,
timeout_sec=effective_timeout, timeout_sec=effective_timeout,
slippage_pct=intent.slippage_pct, slippage_pct=intent.slippage_pct,
venue=intent.venue,
extra={"intent_id": intent.intent_id}, extra={"intent_id": intent.intent_id},
) )
except SlotStateMismatchError: except SlotStateMismatchError:
+2 -1
View File
@@ -117,6 +117,7 @@ def make_intent_rpc_handler(
# dcy-022 / REQ-068: execution policy (optional, backward-compat). # dcy-022 / REQ-068: execution policy (optional, backward-compat).
timeout_sec=_opt_int(body.get("timeout_sec")), timeout_sec=_opt_int(body.get("timeout_sec")),
slippage_pct=_opt_float(body.get("slippage_pct")), slippage_pct=_opt_float(body.get("slippage_pct")),
venue=_opt_str(body.get("venue")) or "KRX",
) )
d = intent_service.submit_buy(buy) d = intent_service.submit_buy(buy)
elif intent_type == "sell": elif intent_type == "sell":
@@ -132,6 +133,7 @@ def make_intent_rpc_handler(
# dcy-022 / REQ-068: execution policy (optional, backward-compat). # dcy-022 / REQ-068: execution policy (optional, backward-compat).
timeout_sec=_opt_int(body.get("timeout_sec")), timeout_sec=_opt_int(body.get("timeout_sec")),
slippage_pct=_opt_float(body.get("slippage_pct")), slippage_pct=_opt_float(body.get("slippage_pct")),
venue=_opt_str(body.get("venue")) or "KRX",
) )
d = intent_service.submit_sell(sell) d = intent_service.submit_sell(sell)
else: else:
@@ -626,4 +628,3 @@ def _parse_date(v) -> date_cls:
def _today() -> date_cls: def _today() -> date_cls:
return datetime.now(tz=timezone.utc).date() return datetime.now(tz=timezone.utc).date()
+4
View File
@@ -56,6 +56,9 @@ class Slot:
# holding episode. Used by the strategy-state CAS to reject writes computed # 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). # against a prior occupancy of a reused slot (point-ABA guard, dcy-028 R-1).
occupancy_seq: int = 0 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). # 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) "timeout_sec", # dcy-022 / REQ-068 (BAT-032)
"slippage_pct", # dcy-022 / REQ-068 (BAT-032) "slippage_pct", # dcy-022 / REQ-068 (BAT-032)
"occupancy_seq", # dcy-028 / REQ-081 (BAT-042) "occupancy_seq", # dcy-028 / REQ-081 (BAT-042)
"venue",
}) })
+5
View File
@@ -85,6 +85,7 @@ class SlotService:
# dcy-022 / REQ-068: execution policy (set on PENDING entry, cleared on exit). # dcy-022 / REQ-068: execution policy (set on PENDING entry, cleared on exit).
timeout_sec: Optional[int] = None, timeout_sec: Optional[int] = None,
slippage_pct: Optional[float] = None, slippage_pct: Optional[float] = None,
venue: Optional[str] = None,
extra: Optional[dict[str, Any]] = None, extra: Optional[dict[str, Any]] = None,
) -> Slot: ) -> Slot:
"""Transition slot from one state to another atomically. """Transition slot from one state to another atomically.
@@ -133,6 +134,7 @@ class SlotService:
take_profit_price=take_profit_price, take_profit_price=take_profit_price,
timeout_sec=timeout_sec, timeout_sec=timeout_sec,
slippage_pct=slippage_pct, slippage_pct=slippage_pct,
venue=venue,
) )
self._repo.update_slot(updated) self._repo.update_slot(updated)
@@ -173,6 +175,7 @@ class SlotService:
take_profit_price: Optional[Decimal] = None, take_profit_price: Optional[Decimal] = None,
timeout_sec: Optional[int] = None, timeout_sec: Optional[int] = None,
slippage_pct: Optional[float] = None, slippage_pct: Optional[float] = None,
venue: Optional[str] = None,
) -> Slot: ) -> Slot:
"""Compute the post-transition Slot row. """Compute the post-transition Slot row.
@@ -201,6 +204,7 @@ class SlotService:
take_profit_price=None, take_profit_price=None,
timeout_sec=None, timeout_sec=None,
slippage_pct=None, slippage_pct=None,
venue="KRX",
updated_at=datetime.now(tz=timezone.utc), updated_at=datetime.now(tz=timezone.utc),
) )
@@ -267,6 +271,7 @@ class SlotService:
timeout_sec=new_timeout_sec, timeout_sec=new_timeout_sec,
slippage_pct=new_slippage_pct, slippage_pct=new_slippage_pct,
occupancy_seq=new_occupancy_seq, occupancy_seq=new_occupancy_seq,
venue=venue if venue is not None else slot.venue,
updated_at=datetime.now(tz=timezone.utc), updated_at=datetime.now(tz=timezone.utc),
) )