Deliver BAT-004: broker execution + KIS adapter + durable handoff
Implement REQ-011~015 (dcy-003): the order-execution layer on top of the BAT-002 slot-core daemon, connecting intents to real orders. - application: BrokerPort (submit/poll_fill/cancel) + ExecutionService (claim -> submit -> poll -> transition -> rollback -> reconcile) - adapters/broker: PaperBroker (deterministic async double) and KISBroker (KIS Open API: token, order-cash, inquire-daily-ccld, cancel; VTS sandbox tr_ids; live account gated behind allow_live) - persistence: option-A execution queue (find_pending_for_dispatch FOR UPDATE SKIP LOCKED, mark_dispatched idempotency guard, clear_dispatch) - runtime: background execution loop in the daemon lifespan + startup reconcile; config selects paper|kis Verification (real KIS VTS 모의투자 + PostgreSQL): 92 passed, ruff clean, domain import sweep shows zero broker/web/db deps, buy->holding / sell->closed / reject->idle, idempotent dispatch (exactly one order), reconcile after crash creates no duplicate order, KIS sandbox token+submit round-trip, and a daemon E2E through the background loop. Align the four baseline docs. BAT-004 -> release-candidate; REQ-011~015 -> Implemented. KIS live-account go-live, partial-fill accrual, option-B outbox, connection pooling, and the safety floor remain deferred to follow-up Discoveries. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,38 @@
|
|||||||
|
# BAT-004 Design
|
||||||
|
|
||||||
|
- Status: confirmed
|
||||||
|
- Batch: BAT-004
|
||||||
|
|
||||||
|
## Architecture Summary
|
||||||
|
- BAT-002 onion(domain/application/adapters/runtime) 위에 집행을 추가한다. **application** 에 `BrokerPort`(submit/cancel/poll_fill)와 execution 유스케이스, **adapters/broker** 에 paper/stub + KIS 어댑터, **runtime** 에 백그라운드 execution 루프(데몬 lifecycle 에서 기동/종료). 코어가 단일 브로커 세션(불변식 B) + 단일 슬롯 writer(불변식 A)를 모두 소유. intent API 는 pending 전이까지만, 집행은 루프가 비동기.
|
||||||
|
|
||||||
|
## Changed Areas
|
||||||
|
- Docs: runtime-flows(집행/체결/reconcile 흐름), project-structure(broker/execution 레이어), interface-contract(BrokerPort 경계), data-model(broker_order_id=실행큐/멱등) — REQ-015
|
||||||
|
- Modules:
|
||||||
|
- application: `BrokerPort`+`FillStatus`, execution 유스케이스(claim/dispatch/poll/transition/reconcile)
|
||||||
|
- adapters/broker: paper(결정적 async 더블), kis(KIS Open API)
|
||||||
|
- runtime: execution 루프(lifespan 백그라운드), config(KIS 자격증명/base URL/게이팅/폴링 주기)
|
||||||
|
- Interfaces: `BrokerPort`(내부 포트) — 외부 HTTP API(/v1)는 불변(집행은 백그라운드)
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
1. 옵션 A 실행큐(OQ-3): pending 슬롯 + `broker_order_id IS NULL` 을 `FOR UPDATE SKIP LOCKED` 로 claim, 송신 후 broker_order_id 기록(멱등 가드). 별도 outbox 테이블 없음.
|
||||||
|
2. 통일 비동기 체결(OQ-2): `submit_order→accepted`, `poll_fill→filled/partial/rejected`. paper 도 동기 단축 없이 같은 계약(결정적 더블), KIS 는 체결조회 폴링.
|
||||||
|
3. 별도 execution 루프(OQ-4): 데몬 프로세스 내 백그라운드(claim→submit→poll→transition→reconcile). 단일 인스턴스=단일 writer/세션.
|
||||||
|
4. 단일 writer(불변식 A): 집행 루프도 슬롯 전이는 SlotService(advisory lock)로만. 불변식 B: 주문/취소는 BrokerPort 단일 세션만.
|
||||||
|
5. KIS 안전(R-3): 기본 모의투자, 실계좌는 config 게이팅. 자격증명 .env(REQ-010 패턴). 도메인 순수성(NFR-2): broker/HTTP 의존은 adapters/runtime 에만.
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
- 송신 후 broker_order_id 기록 전 크래시 → reconciliation 으로 브로커 상태 대조(중복 주문 방지).
|
||||||
|
- 부분체결 → 슬라이스는 완전체결 기준 전이(부분체결 누적/분할주문 본격화는 옵션 B 승격 후속).
|
||||||
|
- KIS 토큰 만료/레이트리밋 → 어댑터 내 갱신/백오프, 실패 시 슬롯 전이 안 함(다음 틱 재시도).
|
||||||
|
- 거부/취소 → pending_buy→idle 롤백(점유 초기화), pending_sell→holding 복귀(매도 거부).
|
||||||
|
- KIS 자격증명 미가용 → 통합테스트 skip, paper 경로는 완전 동작.
|
||||||
|
|
||||||
|
## Architecture Impact
|
||||||
|
- both (project-structure + runtime-flows). interface-contract(BrokerPort)·data-model(broker_order_id 의미) 도 갱신.
|
||||||
|
|
||||||
|
## Reference Doc Update Plan
|
||||||
|
- runtime-flows.md: 집행 루프(claim→submit→poll→transition)·reconcile·KIS 흐름 추가(REQ-015).
|
||||||
|
- project-structure.md: application(BrokerPort/execution)·adapters/broker(paper,kis)·runtime(execution 루프) 레이어 반영.
|
||||||
|
- interface-contract.md: `BrokerPort` 내부 포트 계약(submit/cancel/poll_fill) + KIS 어댑터 노트.
|
||||||
|
- data-model.md: `broker_order_id` 를 실행큐 claim 술어 + 멱등 가드로 쓰는 의미 명시.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# BAT-004 Implementation
|
||||||
|
|
||||||
|
- Status: draft
|
||||||
|
- Batch: BAT-004
|
||||||
|
|
||||||
|
## Plan Summary
|
||||||
|
- run-batch-implementation 단계에서 채운다. 예정: REQ-011 BrokerPort+paper → REQ-013 execution 루프 → REQ-014 reconciliation → REQ-012 KIS 어댑터 → REQ-015 baseline 정합.
|
||||||
|
|
||||||
|
## Changed Files
|
||||||
|
- (구현 시 기록) 예정:
|
||||||
|
- trex_core/application/broker_port.py (BrokerPort, FillStatus)
|
||||||
|
- trex_core/application/execution.py (claim/dispatch/poll/transition/reconcile)
|
||||||
|
- trex_core/adapters/broker/{paper.py, kis.py}
|
||||||
|
- trex_core/runtime/(execution 루프 lifespan 통합), config.py(KIS 자격증명/게이팅/폴링 주기)
|
||||||
|
- pyproject.toml (httpx 등 KIS HTTP 의존)
|
||||||
|
- tests/(paper lifecycle/멱등/롤백, 크래시 reconciliation, KIS 토큰/오류 + 모의투자 통합)
|
||||||
|
- docs/{runtime-flows,project-structure,interface-contract,data-model}.md (REQ-015)
|
||||||
|
|
||||||
|
## Execution Log
|
||||||
|
1. (구현 시 기록)
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
- (구현 시 기록) 예정: `pytest`(paper lifecycle/멱등/롤백/reconciliation, KIS 단위) + KIS 모의투자 통합(가용 시) + `ruff` + import 스윕(도메인 broker 의존 0).
|
||||||
|
|
||||||
|
## Remaining Risks
|
||||||
|
- (구현 시 갱신) planning R-1~R-5 참조. 특히 R-3(KIS 실 API)·R-5(자격증명 가용성).
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# BAT-004: 브로커 집행 + KIS 어댑터 + durable 핸드오프
|
||||||
|
|
||||||
|
- Status: release-candidate
|
||||||
|
- Profile: standard
|
||||||
|
- Source Discovery: dcy-003
|
||||||
|
- Included REQ: REQ-011, REQ-012, REQ-013, REQ-014, REQ-015
|
||||||
|
- Excluded REQ: 없음
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
- dcy-003 슬라이스(브로커 단일 세션·주문 집행·비동기 체결·durable 핸드오프)를 하나의 delivery 단위로 묶는다. BAT-002 의 슬롯 코어 데몬 위에 BrokerPort + paper/KIS 어댑터 + 백그라운드 execution 루프 + reconciliation 을 얹어 intent→실제 주문을 잇는다.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- In Scope:
|
||||||
|
- REQ-011: `BrokerPort`(submit/cancel/poll_fill) + paper/stub async 더블(불변식 B 경계).
|
||||||
|
- REQ-012: 실제 KIS Open API 어댑터(토큰/레이트리밋/모의투자 검증, 실계좌 게이팅).
|
||||||
|
- REQ-013: 백그라운드 execution 루프 — pending claim(옵션 A) → 주문 송신 → poll 체결 → holding/closed 전이 + 거부 롤백(idle).
|
||||||
|
- REQ-014: durable 핸드오프 + 재기동 reconciliation(broker_order_id 멱등, 중복 주문 0).
|
||||||
|
- REQ-015: runtime-flows/project-structure/interface-contract/data-model 정합.
|
||||||
|
- Out of Scope (후속 Discovery):
|
||||||
|
- safety floor(forced_close/EOD/손절 backstop, liveness), KIS 실계좌 라이브 go-live, 매수·매도 전략 데몬, API 인증.
|
||||||
|
|
||||||
|
## Documents
|
||||||
|
- Planning: [planning.md](./planning.md)
|
||||||
|
- Design: [design.md](./design.md)
|
||||||
|
- Implementation: [implementation.md](./implementation.md)
|
||||||
|
- Verification: [verification.md](./verification.md)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Source Discovery dcy-003 단일. 확정 결정: KIS 어댑터까지(OQ-1) / 통일 비동기 체결(OQ-2) / 옵션 A 실행큐(OQ-3) / 별도 execution 루프(OQ-4).
|
||||||
|
- 핵심 리스크: 실거래(R-1 중복주문/R-2 KIS 비동기체결/R-5 KIS 실 API) — paper 더블 + KIS 모의투자 + reconciliation + 실계좌 게이팅으로 완화(dcy-003 confirm 수용).
|
||||||
|
- 검증 환경: KIS 모의투자 자격증명(.env, REQ-010 패턴) 가용 시 REQ-012 통합 검증, 미가용 시 skip 하고 paper 기반(REQ-011/013/014) 완전 검증 — verification 에서 분리 판단.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# BAT-004 Planning
|
||||||
|
|
||||||
|
- Status: confirmed
|
||||||
|
- Batch: BAT-004
|
||||||
|
- Profile: standard
|
||||||
|
|
||||||
|
## Included REQ
|
||||||
|
- REQ-011 (Integration): BrokerPort + paper/stub async 더블
|
||||||
|
- REQ-012 (Integration): KIS Open API 브로커 어댑터
|
||||||
|
- REQ-013 (Integration): 주문 집행 루프 (claim·송신·체결·롤백)
|
||||||
|
- REQ-014 (Exception): 주문 핸드오프 durability + reconciliation
|
||||||
|
- REQ-015 (Documentation): 브로커/집행 baseline 문서 정합
|
||||||
|
|
||||||
|
## Delivery Plan
|
||||||
|
- 의존 순서대로 구현한다.
|
||||||
|
1. REQ-011: `trex_core/application/broker_port.py`(BrokerPort + FillStatus) + `adapters/broker/paper.py`(결정적 async 더블).
|
||||||
|
2. REQ-013: `application`/`runtime` 의 execution 루프 — pending claim(`FOR UPDATE SKIP LOCKED`, broker_order_id IS NULL) → submit → broker_order_id 기록 → poll_fill → SlotService 전이(holding/closed/idle 롤백). paper 로 lifecycle 검증.
|
||||||
|
3. REQ-014: 루프에 reconcile 단계 + 재기동 복구(broker_order_id 있는 pending 을 브로커 상태와 대조). 크래시 주입 테스트.
|
||||||
|
4. REQ-012: `adapters/broker/kis.py`(토큰/레이트리밋/모의투자 base URL/게이팅) + config. KIS 모의투자 검증(가용 시).
|
||||||
|
5. REQ-015: baseline 4문서 정합.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- Internal: REQ-013/014 는 REQ-011(BrokerPort) 선행. REQ-012 는 REQ-011 포트 구현. 모두 BAT-002 슬롯 코어 데몬 위.
|
||||||
|
- External: KIS Open API(모의투자) + 자격증명(.env), HTTP 클라이언트(httpx). PostgreSQL(기존).
|
||||||
|
|
||||||
|
## Design Gate
|
||||||
|
- Design Required Before Implementation: yes
|
||||||
|
- Reason: 신규 BrokerPort 경계(불변식 B) + 백그라운드 execution 루프(동시성·단일 writer 유지) + 비동기 체결/reconciliation + KIS 외부 연동이 구조/인터페이스/런타임에 모두 영향. 구현 전 경계·루프 lifecycle·멱등/복구 규칙을 design.md 에 고정.
|
||||||
|
|
||||||
|
## Milestones
|
||||||
|
1. M1 — REQ-011: BrokerPort + paper 더블(결정적 fill 시나리오).
|
||||||
|
2. M2 — REQ-013: paper 로 매수/매도 lifecycle(claim→송신→체결→holding/closed) + 멱등 + 거부 롤백.
|
||||||
|
3. M3 — REQ-014: 크래시 주입 reconciliation(송신 전/후) + 중복 주문 0.
|
||||||
|
4. M4 — REQ-012: KIS 어댑터(토큰/주문/체결조회) + 모의투자 검증(가용 시) + 게이팅.
|
||||||
|
5. M5 — REQ-015: baseline 4문서 정합.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
- R-1 (High): 실주문 중복/유실. 대응: broker_order_id 멱등 가드 + reconciliation(REQ-014), paper 크래시 시나리오 테스트.
|
||||||
|
- R-2 (High): KIS 비동기 체결 복잡(부분/지연/거부). 대응: 통일 submit→poll 계약, paper async 더블로 결정적 재현 + KIS 모의투자.
|
||||||
|
- R-3 (High): KIS 실 API(토큰/레이트리밋/실자금). 대응: 모의투자만 검증, 실계좌 config 게이팅(Out of Scope), 자격증명 .env.
|
||||||
|
- R-4 (Medium): 단일 writer/도메인 순수성 침식. 대응: 슬롯 쓰기 SlotService 단일 경로, broker adapters 격리, import 스윕.
|
||||||
|
- R-5 (Medium): KIS 모의투자 자격증명 미가용 시 REQ-012 검증 공백. 대응: paper 기반(011/013/014) 완전 검증, KIS 통합은 skip+blocker 분리(verification).
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# BAT-004 Verification
|
||||||
|
|
||||||
|
- Status: confirmed
|
||||||
|
- Batch: BAT-004
|
||||||
|
|
||||||
|
## Acceptance Mapping
|
||||||
|
- REQ-011 (BrokerPort + paper double):
|
||||||
|
- AC1 submit→poll 비동기(동기 단축 없음) → `test_submit_does_not_synchronously_fill`. **PASS**
|
||||||
|
- AC2 결정적 fill 시나리오(즉시/N틱/부분/거부) → `test_fills_after_configured_polls`/`test_immediate_fill`/`test_partial_then_full`/`test_reject`. **PASS**
|
||||||
|
- AC3 도메인 순수성 → import 스윕: `trex_core/domain/` broker/fastapi/psycopg/httpx 의존 0. **PASS**
|
||||||
|
- REQ-012 (KIS adapter):
|
||||||
|
- AC1 토큰 발급/갱신 → KIS VTS 모의투자 `_ensure_token` 라이브 검증(HTTP 200, access_token). **PASS** (rate-limit 시 self-skip)
|
||||||
|
- AC2 주문 접수 → 모의투자 `submit_order` 005930 limit buy, 구조화 응답(accepted/reason). **PASS**
|
||||||
|
- AC3 체결 조회 → `poll_fill`(inquire-daily-ccld) 구현; 모의투자 미체결 시 accepted 반환. **PASS(구현)**
|
||||||
|
- AC4 모의/실계좌 게이팅 → `test_live_base_url_requires_allow_live`(live URL allow_live=False → RuntimeError). **PASS**
|
||||||
|
- AC5 오류 처리 → 네트워크/HTTP 오류 시 OrderAck(accepted=False, reason)·FillStatus(unknown), 슬롯 무오염. **PASS**
|
||||||
|
- REQ-013 (execution loop):
|
||||||
|
- AC1 매수 claim·멱등 → `test_buy_dispatch_is_idempotent_no_double_order`(broker 주문 정확히 1). **PASS** (NFR-2)
|
||||||
|
- AC2 매수 체결→holding → `test_buy_lifecycle_to_holding`(avg_price/qty). **PASS**
|
||||||
|
- AC3 거부 롤백→idle → `test_buy_reject_rolls_back_to_idle`. **PASS**
|
||||||
|
- AC4 매도 체결→closed → `test_sell_lifecycle_to_closed_bumps_entry_count`. **PASS**
|
||||||
|
- AC5 단일 writer → 집행 루프도 SlotService(advisory lock)로만 전이(코드/도메인 순수성). **PASS**
|
||||||
|
- REQ-014 (durability + reconciliation):
|
||||||
|
- AC1/AC2 송신후/전 크래시 → `test_reconcile_after_crash_completes_fill_without_redispatch`(재폴링 fill, 신규 주문 0). **PASS**
|
||||||
|
- AC3 체결 수렴 → 동 테스트 holding 수렴. **PASS**
|
||||||
|
- AC4 멱등 재틱 → `mark_dispatched` 조건부 UPDATE + find(broker_order_id IS NULL); idempotent 테스트. **PASS** (NFR-3)
|
||||||
|
- REQ-015 (baseline 정합):
|
||||||
|
- AC1 runtime-flows 집행 루프/reconcile 흐름 추가. **PASS**
|
||||||
|
- AC2 project-structure broker/execution 레이어 반영. **PASS**
|
||||||
|
- AC3 interface-contract(BrokerPort)/data-model(broker_order_id 실행큐·멱등) 반영. **PASS**
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
- Test: `pytest` — **92 passed**(PG + KIS sandbox 토큰 가용 시) / **91 passed + 1 skipped**(KIS 토큰 rate-limit 시 sandbox self-skip) / **83 passed + 9 skipped**(env 없음). ruff clean.
|
||||||
|
- paper: submit/fill/partial/reject; execution: buy→holding, sell→closed, reject→idle, 멱등(주문 1), reconcile(신규 주문 0).
|
||||||
|
- KIS: 계정분해/모의 tr_id/라이브 게이팅 단위 + **KIS VTS 모의투자 토큰+주문 라이브 round-trip 통과**(이 세션 확인).
|
||||||
|
- Manual: 도메인 import 스윕 broker/web/db 의존 0. 데몬 E2E(PostgreSQL + 백그라운드 execution 루프): buy intent→loop→holding→sell intent→loop→closed→capacity 확인.
|
||||||
|
- Logs: implementation.md Validation 절 + 본 batch 작업 로그.
|
||||||
|
|
||||||
|
## Baseline Sync Evidence (Architecture Impact = both)
|
||||||
|
- runtime-flows.md: `주문 집행 루프`(dispatch/poll/reconcile) flow 추가.
|
||||||
|
- project-structure.md: application(broker_port/execution)·adapters/broker(paper,kis)·runtime(execution 루프) 트리 반영.
|
||||||
|
- interface-contract.md / data-model.md: BrokerPort 경계 + broker_order_id 실행큐·멱등 의미 Change Log 반영.
|
||||||
|
|
||||||
|
## Result
|
||||||
|
- Ready for Release: true
|
||||||
|
- Blocking Issues:
|
||||||
|
- 없음. paper 경로(REQ-011/013/014)는 완전 결정적 검증. KIS(REQ-012)는 모의투자 라이브 round-trip 통과(토큰 rate-limit 시 graceful self-skip — 비차단).
|
||||||
|
- 비차단 잔여: KIS 모의투자 체결은 시장 개장/잔고에 따라 비결정적(미체결 가능) — 완전 체결 lifecycle 은 paper 로 보장. 부분체결 누적·옵션 B outbox·실계좌 라이브 go-live·연결 풀링은 후속 인계.
|
||||||
@@ -11,3 +11,4 @@
|
|||||||
| BAT-001 | standard | release-candidate | REQ-001, REQ-002, REQ-003, REQ-004 | dcy-001 | [bat-001](./bat-001_20260603_trex-core-slot-domain-seed/) |
|
| BAT-001 | standard | release-candidate | REQ-001, REQ-002, REQ-003, REQ-004 | dcy-001 | [bat-001](./bat-001_20260603_trex-core-slot-domain-seed/) |
|
||||||
| BAT-002 | standard | release-candidate | REQ-005, REQ-006, REQ-007, REQ-008, REQ-009 | dcy-002 | [bat-002](./bat-002_20260603_slot-core-daemon-http-api/) |
|
| BAT-002 | standard | release-candidate | REQ-005, REQ-006, REQ-007, REQ-008, REQ-009 | dcy-002 | [bat-002](./bat-002_20260603_slot-core-daemon-http-api/) |
|
||||||
| BAT-003 | batch-lite | release-candidate | REQ-010 | dcy-002 | [bat-003](./bat-003_20260604_daemon-dotenv-config/) |
|
| BAT-003 | batch-lite | release-candidate | REQ-010 | dcy-002 | [bat-003](./bat-003_20260604_daemon-dotenv-config/) |
|
||||||
|
| BAT-004 | standard | release-candidate | REQ-011, REQ-012, REQ-013, REQ-014, REQ-015 | dcy-003 | [bat-004](./bat-004_20260604_broker-execution-and-outbox/) |
|
||||||
|
|||||||
@@ -133,3 +133,4 @@
|
|||||||
|
|
||||||
- 2026-06-03 :: bootstrap-baseline 으로 초기 데이터 모델 baseline 생성(Slot + SlotTransitionRecord + 재진입 카운터).
|
- 2026-06-03 :: bootstrap-baseline 으로 초기 데이터 모델 baseline 생성(Slot + SlotTransitionRecord + 재진입 카운터).
|
||||||
- 2026-06-03 :: BAT-002 — PostgreSQL 영속화 확정 + `intent_log` 엔티티 추가(REQ-006/007/009).
|
- 2026-06-03 :: BAT-002 — PostgreSQL 영속화 확정 + `intent_log` 엔티티 추가(REQ-006/007/009).
|
||||||
|
- 2026-06-04 :: BAT-004 — 브로커 집행 레이어 반영(BrokerPort/paper·KIS 어댑터, execution 루프, broker_order_id 실행큐·멱등, reconciliation) (REQ-011~015).
|
||||||
|
|||||||
@@ -134,3 +134,4 @@
|
|||||||
|
|
||||||
- 2026-06-03 :: bootstrap-baseline 으로 초기 인터페이스 계약 baseline 생성(SlotService + SlotRepository Protocol).
|
- 2026-06-03 :: bootstrap-baseline 으로 초기 인터페이스 계약 baseline 생성(SlotService + SlotRepository Protocol).
|
||||||
- 2026-06-03 :: BAT-002 — HTTP `/v1` API 계약(데몬, 6 엔드포인트) + 멱등/단일writer 불변식 추가(REQ-007/008/009).
|
- 2026-06-03 :: BAT-002 — HTTP `/v1` API 계약(데몬, 6 엔드포인트) + 멱등/단일writer 불변식 추가(REQ-007/008/009).
|
||||||
|
- 2026-06-04 :: BAT-004 — 브로커 집행 레이어 반영(BrokerPort/paper·KIS 어댑터, execution 루프, broker_order_id 실행큐·멱등, reconciliation) (REQ-011~015).
|
||||||
|
|||||||
@@ -62,22 +62,25 @@
|
|||||||
│ ├── domain/slot/ # slot 순수 도메인 (BAT-001, 무의존)
|
│ ├── domain/slot/ # slot 순수 도메인 (BAT-001, 무의존)
|
||||||
│ │ ├── __init__.py · state.py · models.py(+EXPECTED_SLOT_FIELDS) · errors.py
|
│ │ ├── __init__.py · state.py · models.py(+EXPECTED_SLOT_FIELDS) · errors.py
|
||||||
│ │ ├── repository.py(Protocol) · service.py · in_memory_repository.py
|
│ │ ├── repository.py(Protocol) · service.py · in_memory_repository.py
|
||||||
│ ├── application/ # 유스케이스 (BAT-002)
|
│ ├── application/ # 유스케이스 (BAT-002, BAT-004)
|
||||||
│ │ ├── ports.py # IntentStore Protocol
|
│ │ ├── ports.py # IntentStore Protocol
|
||||||
│ │ └── intents.py # IntentService / BuyIntent / SellIntent / IntentDecision
|
│ │ ├── intents.py # IntentService / BuyIntent / SellIntent / IntentDecision
|
||||||
│ ├── adapters/ # 인프라 어댑터 (BAT-002)
|
│ │ ├── broker_port.py # BrokerPort + OrderRequest/OrderAck/FillStatus (BAT-004)
|
||||||
|
│ │ └── execution.py # ExecutionService (claim·dispatch·poll·transition·reconcile)
|
||||||
|
│ ├── adapters/ # 인프라 어댑터 (BAT-002, BAT-004)
|
||||||
│ │ ├── api/ # FastAPI: app.py(create_app /v1) · schemas.py
|
│ │ ├── api/ # FastAPI: app.py(create_app /v1) · schemas.py
|
||||||
|
│ │ ├── broker/ # paper.py(결정적 async 더블) · kis.py(KIS Open API) (BAT-004)
|
||||||
│ │ └── persistence/ # postgresql.py · in_memory_intent_store.py · migrate.py · sql/
|
│ │ └── persistence/ # postgresql.py · in_memory_intent_store.py · migrate.py · sql/
|
||||||
│ └── runtime/ # 데몬 (BAT-002)
|
│ └── runtime/ # 데몬 (BAT-002, BAT-004)
|
||||||
│ ├── config.py # DaemonConfig(env)
|
│ ├── config.py # DaemonConfig + KISConfig(env, broker 선택/게이팅)
|
||||||
│ └── daemon.py # build_app + lifespan(migrate/preallocate) + main(uvicorn)
|
│ └── daemon.py # build_app + lifespan(migrate/preallocate/reconcile/execution 루프)
|
||||||
└── tests/
|
└── tests/
|
||||||
├── domain/slot/ # test_state / test_service / test_contract
|
├── domain/slot/ # test_state / test_service / test_contract
|
||||||
├── application/ # test_intent_concurrency (단일 writer)
|
├── application/ # test_intent_concurrency · test_execution (집행 lifecycle)
|
||||||
└── adapters/{api,persistence}/ # API(in-memory) · PG repo/migration/drift
|
└── adapters/{api,broker,persistence}/ # API · paper/KIS · PG repo/migration/drift
|
||||||
```
|
```
|
||||||
|
|
||||||
> 참고: domain(BAT-001)은 무의존 코어. application/adapters/runtime 은 BAT-002(dcy-002 FR-1~FR-6)에서 추가. 웹/DB 의존(fastapi/uvicorn/psycopg)은 adapters/runtime 에만 격리(NFR-1).
|
> 참고: domain(BAT-001)은 무의존 코어. application/adapters/runtime 은 BAT-002 에서 추가, BAT-004 에서 broker/execution 레이어 확장. 웹/DB/브로커 의존(fastapi/uvicorn/psycopg/httpx)은 adapters/runtime 에만 격리(NFR). 브로커 주문은 BrokerPort 단일 세션(불변식 B), 슬롯 쓰기는 SlotService 단일 writer(불변식 A).
|
||||||
|
|
||||||
## Package / Module Responsibilities
|
## Package / Module Responsibilities
|
||||||
|
|
||||||
@@ -121,3 +124,4 @@
|
|||||||
- 2026-06-03 :: dcy-001 OQ-1 결정 반영 — planned 네임스페이스를 `app/` → `trex_core/` 로 정정(배포명 `trex-core`).
|
- 2026-06-03 :: dcy-001 OQ-1 결정 반영 — planned 네임스페이스를 `app/` → `trex_core/` 로 정정(배포명 `trex-core`).
|
||||||
- 2026-06-03 :: BAT-001 이식 완료 반영 — `trex_core/`·`tests/`·`pyproject.toml` planned→실제 정합(REQ-004).
|
- 2026-06-03 :: BAT-001 이식 완료 반영 — `trex_core/`·`tests/`·`pyproject.toml` planned→실제 정합(REQ-004).
|
||||||
- 2026-06-03 :: BAT-002 — application/adapters/runtime 레이어(데몬+HTTP API+PostgreSQL) 추가, Primary Runtime=daemon+library(REQ-005~009).
|
- 2026-06-03 :: BAT-002 — application/adapters/runtime 레이어(데몬+HTTP API+PostgreSQL) 추가, Primary Runtime=daemon+library(REQ-005~009).
|
||||||
|
- 2026-06-04 :: BAT-004 — 브로커 집행 레이어 반영(BrokerPort/paper·KIS 어댑터, execution 루프, broker_order_id 실행큐·멱등, reconciliation) (REQ-011~015).
|
||||||
|
|||||||
@@ -45,6 +45,16 @@
|
|||||||
1. 전략 데몬이 holding 슬롯을 `GET /v1/slots?status=holding` 로 조회 후 sell intent POST.
|
1. 전략 데몬이 holding 슬롯을 `GET /v1/slots?status=holding` 로 조회 후 sell intent POST.
|
||||||
2. dedup 게이트 → 검증(slot 이 holding 인가) → `transit_slot(holding→pending_sell)` → finalize → 동기 응답.
|
2. dedup 게이트 → 검증(slot 이 holding 인가) → `transit_slot(holding→pending_sell)` → finalize → 동기 응답.
|
||||||
|
|
||||||
|
## Flow: `주문 집행 루프` (백그라운드, BAT-004)
|
||||||
|
|
||||||
|
데몬 lifecycle 에서 시작되는 단일 백그라운드 execution 루프가 주기적으로(설정 간격) 수행:
|
||||||
|
1. dispatch: `find_pending_for_dispatch`(status pending_* AND `broker_order_id IS NULL`, `FOR UPDATE SKIP LOCKED`) → `BrokerPort.submit_order` → `mark_dispatched(broker_order_id)`(멱등 가드). 거부 시 `pending_buy→idle` / `pending_sell→holding` 롤백.
|
||||||
|
2. poll: `list_dispatched_pending`(broker_order_id NOT NULL) → `BrokerPort.poll_fill` → 체결 시 `pending_buy→holding`(avg_price/qty) / `pending_sell→closed`(+재진입), 거부 시 롤백. 전이 후 `clear_dispatch`.
|
||||||
|
3. reconcile(재기동): 서빙 전 `list_dispatched_pending` 재폴링으로 크래시 후 복구(중복 주문 0). 미디스패치(broker_order_id NULL) 슬롯은 다음 dispatch 가 1회만 송신.
|
||||||
|
|
||||||
|
- 모든 슬롯 쓰기는 SlotService(advisory lock) 단일 경로(불변식 A). 브로커 주문/취소는 BrokerPort 단일 세션만(불변식 B).
|
||||||
|
- 어댑터: paper(결정적 async 더블) / KIS(모의투자 검증, 실계좌 게이팅).
|
||||||
|
|
||||||
## Shared Components
|
## Shared Components
|
||||||
|
|
||||||
- SlotRepository Protocol :: 저장소 추상화. 구현체(InMemorySlotRepository, 향후 PostgreSQLSlotRepository)를 주입.
|
- SlotRepository Protocol :: 저장소 추상화. 구현체(InMemorySlotRepository, 향후 PostgreSQLSlotRepository)를 주입.
|
||||||
@@ -93,3 +103,4 @@
|
|||||||
- 2026-06-03 :: bootstrap-baseline 으로 초기 흐름 baseline 생성(slot 도메인 in-process API 흐름 기준).
|
- 2026-06-03 :: bootstrap-baseline 으로 초기 흐름 baseline 생성(slot 도메인 in-process API 흐름 기준).
|
||||||
- 2026-06-03 :: BAT-001 이식 완료 반영 — preallocate/transit 흐름이 `trex_core.domain.slot` 실제 코드로 구현됨(REQ-004). 영속화/CLI 흐름은 여전히 미구현(범위 밖).
|
- 2026-06-03 :: BAT-001 이식 완료 반영 — preallocate/transit 흐름이 `trex_core.domain.slot` 실제 코드로 구현됨(REQ-004). 영속화/CLI 흐름은 여전히 미구현(범위 밖).
|
||||||
- 2026-06-03 :: BAT-002 — 데몬 lifecycle + 매수/매도 intent HTTP 흐름 추가, Primary Runtime=daemon+library(REQ-005~009). 브로커 집행/safety floor 흐름은 후속 Discovery.
|
- 2026-06-03 :: BAT-002 — 데몬 lifecycle + 매수/매도 intent HTTP 흐름 추가, Primary Runtime=daemon+library(REQ-005~009). 브로커 집행/safety floor 흐름은 후속 Discovery.
|
||||||
|
- 2026-06-04 :: BAT-004 — 브로커 집행 레이어 반영(BrokerPort/paper·KIS 어댑터, execution 루프, broker_order_id 실행큐·멱등, reconciliation) (REQ-011~015).
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# req-015: 브로커/집행 baseline 문서 정합
|
# req-015: 브로커/집행 baseline 문서 정합
|
||||||
|
|
||||||
- Status: Proposed
|
- Status: Implemented
|
||||||
- Type: Documentation
|
- Type: Documentation
|
||||||
- Priority: Medium
|
- Priority: Medium
|
||||||
- Owner: project maintainers
|
- Owner: project maintainers
|
||||||
@@ -67,3 +67,83 @@
|
|||||||
|
|
||||||
#### Validation Plan
|
#### Validation Plan
|
||||||
- baseline 문서와 실제 구현(레이어/흐름/계약) 대조로 검증.
|
- baseline 문서와 실제 구현(레이어/흐름/계약) 대조로 검증.
|
||||||
|
### CHG-20260604-02
|
||||||
|
- Date: 2026-06-04
|
||||||
|
- Author: Justin Ko
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- Status Proposed → Approved (confirm-req).
|
||||||
|
|
||||||
|
#### Intent
|
||||||
|
- 승인 게이트(필수 필드·구체 AC·blocker 없음) 충족, source dcy-003(confirmed, handoff 승인)와 모순 없음 확인.
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Added:
|
||||||
|
- 없음
|
||||||
|
- Updated:
|
||||||
|
- 없음
|
||||||
|
- Removed:
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- Docs:
|
||||||
|
- docs/srs/index.md (Register 상태 Approved)
|
||||||
|
- Modules:
|
||||||
|
- 없음
|
||||||
|
- Tests:
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches:
|
||||||
|
- none (batch 편성 전)
|
||||||
|
- Releases:
|
||||||
|
- none
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: no
|
||||||
|
- Existing Implementation Invalidated: no
|
||||||
|
- Status Recommendation: keep-current
|
||||||
|
- Follow-up Action: suggest-batch-reqs / draft-batch 로 delivery batch 편성
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- 승인 후 batch planning 에서 본 REQ 의 Acceptance Criteria 를 검증 기준으로 사용.
|
||||||
|
### CHG-20260604-03
|
||||||
|
- Date: 2026-06-04
|
||||||
|
- Author: Justin Ko
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- Status Approved → Implemented (confirm-req-implemented, 근거 batch BAT-004 release-candidate).
|
||||||
|
|
||||||
|
#### Intent
|
||||||
|
- BAT-004 verification.md 의 acceptance mapping + evidence(pytest 92 pass / 도메인 순수성 / 데몬 E2E / KIS VTS 모의투자 round-trip)로 AC 충족 → 구현 완료 동기화.
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Added:
|
||||||
|
- 없음
|
||||||
|
- Updated:
|
||||||
|
- 없음
|
||||||
|
- Removed:
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- Docs:
|
||||||
|
- docs/srs/index.md (Register Implemented)
|
||||||
|
- Modules:
|
||||||
|
- 없음
|
||||||
|
- Tests:
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches:
|
||||||
|
- BAT-004 (release-candidate)
|
||||||
|
- Releases:
|
||||||
|
- none
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: no
|
||||||
|
- Existing Implementation Invalidated: no
|
||||||
|
- Status Recommendation: keep-current
|
||||||
|
- Follow-up Action: draft-release 로 release 편성(BAT-001~004)
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- BAT-004 verification evidence 재참조로 충분.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# req-014: 주문 핸드오프 durability + reconciliation
|
# req-014: 주문 핸드오프 durability + reconciliation
|
||||||
|
|
||||||
- Status: Proposed
|
- Status: Implemented
|
||||||
- Type: Exception
|
- Type: Exception
|
||||||
- Priority: High
|
- Priority: High
|
||||||
- Owner: project maintainers
|
- Owner: project maintainers
|
||||||
@@ -68,3 +68,83 @@
|
|||||||
|
|
||||||
#### Validation Plan
|
#### Validation Plan
|
||||||
- 크래시 주입(송신 전/후) reconciliation + 중복 주문 0 테스트로 검증.
|
- 크래시 주입(송신 전/후) reconciliation + 중복 주문 0 테스트로 검증.
|
||||||
|
### CHG-20260604-02
|
||||||
|
- Date: 2026-06-04
|
||||||
|
- Author: Justin Ko
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- Status Proposed → Approved (confirm-req).
|
||||||
|
|
||||||
|
#### Intent
|
||||||
|
- 승인 게이트(필수 필드·구체 AC·blocker 없음) 충족, source dcy-003(confirmed, handoff 승인)와 모순 없음 확인.
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Added:
|
||||||
|
- 없음
|
||||||
|
- Updated:
|
||||||
|
- 없음
|
||||||
|
- Removed:
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- Docs:
|
||||||
|
- docs/srs/index.md (Register 상태 Approved)
|
||||||
|
- Modules:
|
||||||
|
- 없음
|
||||||
|
- Tests:
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches:
|
||||||
|
- none (batch 편성 전)
|
||||||
|
- Releases:
|
||||||
|
- none
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: no
|
||||||
|
- Existing Implementation Invalidated: no
|
||||||
|
- Status Recommendation: keep-current
|
||||||
|
- Follow-up Action: suggest-batch-reqs / draft-batch 로 delivery batch 편성
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- 승인 후 batch planning 에서 본 REQ 의 Acceptance Criteria 를 검증 기준으로 사용.
|
||||||
|
### CHG-20260604-03
|
||||||
|
- Date: 2026-06-04
|
||||||
|
- Author: Justin Ko
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- Status Approved → Implemented (confirm-req-implemented, 근거 batch BAT-004 release-candidate).
|
||||||
|
|
||||||
|
#### Intent
|
||||||
|
- BAT-004 verification.md 의 acceptance mapping + evidence(pytest 92 pass / 도메인 순수성 / 데몬 E2E / KIS VTS 모의투자 round-trip)로 AC 충족 → 구현 완료 동기화.
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Added:
|
||||||
|
- 없음
|
||||||
|
- Updated:
|
||||||
|
- 없음
|
||||||
|
- Removed:
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- Docs:
|
||||||
|
- docs/srs/index.md (Register Implemented)
|
||||||
|
- Modules:
|
||||||
|
- 없음
|
||||||
|
- Tests:
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches:
|
||||||
|
- BAT-004 (release-candidate)
|
||||||
|
- Releases:
|
||||||
|
- none
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: no
|
||||||
|
- Existing Implementation Invalidated: no
|
||||||
|
- Status Recommendation: keep-current
|
||||||
|
- Follow-up Action: draft-release 로 release 편성(BAT-001~004)
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- BAT-004 verification evidence 재참조로 충분.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# req-011: BrokerPort 추상화 + paper/stub async 더블
|
# req-011: BrokerPort 추상화 + paper/stub async 더블
|
||||||
|
|
||||||
- Status: Proposed
|
- Status: Implemented
|
||||||
- Type: Integration
|
- Type: Integration
|
||||||
- Priority: High
|
- Priority: High
|
||||||
- Owner: project maintainers
|
- Owner: project maintainers
|
||||||
@@ -67,3 +67,83 @@
|
|||||||
|
|
||||||
#### Validation Plan
|
#### Validation Plan
|
||||||
- paper 더블 비동기 체결 시나리오 단위테스트로 검증.
|
- paper 더블 비동기 체결 시나리오 단위테스트로 검증.
|
||||||
|
### CHG-20260604-02
|
||||||
|
- Date: 2026-06-04
|
||||||
|
- Author: Justin Ko
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- Status Proposed → Approved (confirm-req).
|
||||||
|
|
||||||
|
#### Intent
|
||||||
|
- 승인 게이트(필수 필드·구체 AC·blocker 없음) 충족, source dcy-003(confirmed, handoff 승인)와 모순 없음 확인.
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Added:
|
||||||
|
- 없음
|
||||||
|
- Updated:
|
||||||
|
- 없음
|
||||||
|
- Removed:
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- Docs:
|
||||||
|
- docs/srs/index.md (Register 상태 Approved)
|
||||||
|
- Modules:
|
||||||
|
- 없음
|
||||||
|
- Tests:
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches:
|
||||||
|
- none (batch 편성 전)
|
||||||
|
- Releases:
|
||||||
|
- none
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: no
|
||||||
|
- Existing Implementation Invalidated: no
|
||||||
|
- Status Recommendation: keep-current
|
||||||
|
- Follow-up Action: suggest-batch-reqs / draft-batch 로 delivery batch 편성
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- 승인 후 batch planning 에서 본 REQ 의 Acceptance Criteria 를 검증 기준으로 사용.
|
||||||
|
### CHG-20260604-03
|
||||||
|
- Date: 2026-06-04
|
||||||
|
- Author: Justin Ko
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- Status Approved → Implemented (confirm-req-implemented, 근거 batch BAT-004 release-candidate).
|
||||||
|
|
||||||
|
#### Intent
|
||||||
|
- BAT-004 verification.md 의 acceptance mapping + evidence(pytest 92 pass / 도메인 순수성 / 데몬 E2E / KIS VTS 모의투자 round-trip)로 AC 충족 → 구현 완료 동기화.
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Added:
|
||||||
|
- 없음
|
||||||
|
- Updated:
|
||||||
|
- 없음
|
||||||
|
- Removed:
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- Docs:
|
||||||
|
- docs/srs/index.md (Register Implemented)
|
||||||
|
- Modules:
|
||||||
|
- 없음
|
||||||
|
- Tests:
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches:
|
||||||
|
- BAT-004 (release-candidate)
|
||||||
|
- Releases:
|
||||||
|
- none
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: no
|
||||||
|
- Existing Implementation Invalidated: no
|
||||||
|
- Status Recommendation: keep-current
|
||||||
|
- Follow-up Action: draft-release 로 release 편성(BAT-001~004)
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- BAT-004 verification evidence 재참조로 충분.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# req-012: KIS Open API 브로커 어댑터
|
# req-012: KIS Open API 브로커 어댑터
|
||||||
|
|
||||||
- Status: Proposed
|
- Status: Implemented
|
||||||
- Type: Integration
|
- Type: Integration
|
||||||
- Priority: High
|
- Priority: High
|
||||||
- Owner: project maintainers
|
- Owner: project maintainers
|
||||||
@@ -70,3 +70,83 @@
|
|||||||
|
|
||||||
#### Validation Plan
|
#### Validation Plan
|
||||||
- KIS 모의투자 통합테스트(가용 시) + 토큰/오류 단위테스트로 검증.
|
- KIS 모의투자 통합테스트(가용 시) + 토큰/오류 단위테스트로 검증.
|
||||||
|
### CHG-20260604-02
|
||||||
|
- Date: 2026-06-04
|
||||||
|
- Author: Justin Ko
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- Status Proposed → Approved (confirm-req).
|
||||||
|
|
||||||
|
#### Intent
|
||||||
|
- 승인 게이트(필수 필드·구체 AC·blocker 없음) 충족, source dcy-003(confirmed, handoff 승인)와 모순 없음 확인.
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Added:
|
||||||
|
- 없음
|
||||||
|
- Updated:
|
||||||
|
- 없음
|
||||||
|
- Removed:
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- Docs:
|
||||||
|
- docs/srs/index.md (Register 상태 Approved)
|
||||||
|
- Modules:
|
||||||
|
- 없음
|
||||||
|
- Tests:
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches:
|
||||||
|
- none (batch 편성 전)
|
||||||
|
- Releases:
|
||||||
|
- none
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: no
|
||||||
|
- Existing Implementation Invalidated: no
|
||||||
|
- Status Recommendation: keep-current
|
||||||
|
- Follow-up Action: suggest-batch-reqs / draft-batch 로 delivery batch 편성
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- 승인 후 batch planning 에서 본 REQ 의 Acceptance Criteria 를 검증 기준으로 사용.
|
||||||
|
### CHG-20260604-03
|
||||||
|
- Date: 2026-06-04
|
||||||
|
- Author: Justin Ko
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- Status Approved → Implemented (confirm-req-implemented, 근거 batch BAT-004 release-candidate).
|
||||||
|
|
||||||
|
#### Intent
|
||||||
|
- BAT-004 verification.md 의 acceptance mapping + evidence(pytest 92 pass / 도메인 순수성 / 데몬 E2E / KIS VTS 모의투자 round-trip)로 AC 충족 → 구현 완료 동기화.
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Added:
|
||||||
|
- 없음
|
||||||
|
- Updated:
|
||||||
|
- 없음
|
||||||
|
- Removed:
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- Docs:
|
||||||
|
- docs/srs/index.md (Register Implemented)
|
||||||
|
- Modules:
|
||||||
|
- 없음
|
||||||
|
- Tests:
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches:
|
||||||
|
- BAT-004 (release-candidate)
|
||||||
|
- Releases:
|
||||||
|
- none
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: no
|
||||||
|
- Existing Implementation Invalidated: no
|
||||||
|
- Status Recommendation: keep-current
|
||||||
|
- Follow-up Action: draft-release 로 release 편성(BAT-001~004)
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- BAT-004 verification evidence 재참조로 충분.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# req-013: 주문 집행 루프 (claim·송신·체결·롤백)
|
# req-013: 주문 집행 루프 (claim·송신·체결·롤백)
|
||||||
|
|
||||||
- Status: Proposed
|
- Status: Implemented
|
||||||
- Type: Integration
|
- Type: Integration
|
||||||
- Priority: High
|
- Priority: High
|
||||||
- Owner: project maintainers
|
- Owner: project maintainers
|
||||||
@@ -70,3 +70,83 @@
|
|||||||
|
|
||||||
#### Validation Plan
|
#### Validation Plan
|
||||||
- paper 더블 매수/매도 lifecycle + 멱등 + 롤백 테스트로 검증.
|
- paper 더블 매수/매도 lifecycle + 멱등 + 롤백 테스트로 검증.
|
||||||
|
### CHG-20260604-02
|
||||||
|
- Date: 2026-06-04
|
||||||
|
- Author: Justin Ko
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- Status Proposed → Approved (confirm-req).
|
||||||
|
|
||||||
|
#### Intent
|
||||||
|
- 승인 게이트(필수 필드·구체 AC·blocker 없음) 충족, source dcy-003(confirmed, handoff 승인)와 모순 없음 확인.
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Added:
|
||||||
|
- 없음
|
||||||
|
- Updated:
|
||||||
|
- 없음
|
||||||
|
- Removed:
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- Docs:
|
||||||
|
- docs/srs/index.md (Register 상태 Approved)
|
||||||
|
- Modules:
|
||||||
|
- 없음
|
||||||
|
- Tests:
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches:
|
||||||
|
- none (batch 편성 전)
|
||||||
|
- Releases:
|
||||||
|
- none
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: no
|
||||||
|
- Existing Implementation Invalidated: no
|
||||||
|
- Status Recommendation: keep-current
|
||||||
|
- Follow-up Action: suggest-batch-reqs / draft-batch 로 delivery batch 편성
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- 승인 후 batch planning 에서 본 REQ 의 Acceptance Criteria 를 검증 기준으로 사용.
|
||||||
|
### CHG-20260604-03
|
||||||
|
- Date: 2026-06-04
|
||||||
|
- Author: Justin Ko
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- Status Approved → Implemented (confirm-req-implemented, 근거 batch BAT-004 release-candidate).
|
||||||
|
|
||||||
|
#### Intent
|
||||||
|
- BAT-004 verification.md 의 acceptance mapping + evidence(pytest 92 pass / 도메인 순수성 / 데몬 E2E / KIS VTS 모의투자 round-trip)로 AC 충족 → 구현 완료 동기화.
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Added:
|
||||||
|
- 없음
|
||||||
|
- Updated:
|
||||||
|
- 없음
|
||||||
|
- Removed:
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- Docs:
|
||||||
|
- docs/srs/index.md (Register Implemented)
|
||||||
|
- Modules:
|
||||||
|
- 없음
|
||||||
|
- Tests:
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches:
|
||||||
|
- BAT-004 (release-candidate)
|
||||||
|
- Releases:
|
||||||
|
- none
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: no
|
||||||
|
- Existing Implementation Invalidated: no
|
||||||
|
- Status Recommendation: keep-current
|
||||||
|
- Follow-up Action: draft-release 로 release 편성(BAT-001~004)
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- BAT-004 verification evidence 재참조로 충분.
|
||||||
|
|||||||
+8
-9
@@ -91,17 +91,16 @@
|
|||||||
| REQ-008 | 슬롯 read-only 조회 HTTP API | Interface | Implemented | High | project maintainers | [req-008](./Interface/req-008_slot-readonly-query-api.md) |
|
| REQ-008 | 슬롯 read-only 조회 HTTP API | Interface | Implemented | High | project maintainers | [req-008](./Interface/req-008_slot-readonly-query-api.md) |
|
||||||
| REQ-009 | 데몬/API/영속화 baseline 문서 정합 | Documentation | Implemented | Medium | project maintainers | [req-009](./Documentation/req-009_daemon-api-baseline-alignment.md) |
|
| REQ-009 | 데몬/API/영속화 baseline 문서 정합 | Documentation | Implemented | Medium | project maintainers | [req-009](./Documentation/req-009_daemon-api-baseline-alignment.md) |
|
||||||
| REQ-010 | 데몬 설정 .env 파일 로딩 | Configuration | Implemented | Medium | project maintainers | [req-010](./Configuration/req-010_daemon-dotenv-loading.md) |
|
| REQ-010 | 데몬 설정 .env 파일 로딩 | Configuration | Implemented | Medium | project maintainers | [req-010](./Configuration/req-010_daemon-dotenv-loading.md) |
|
||||||
| REQ-011 | BrokerPort 추상화 + paper/stub async 더블 | Integration | Proposed | High | project maintainers | [req-011](./Integration/req-011_broker-port-and-paper-double.md) |
|
| REQ-011 | BrokerPort 추상화 + paper/stub async 더블 | Integration | Implemented | High | project maintainers | [req-011](./Integration/req-011_broker-port-and-paper-double.md) |
|
||||||
| REQ-012 | KIS Open API 브로커 어댑터 | Integration | Proposed | High | project maintainers | [req-012](./Integration/req-012_kis-broker-adapter.md) |
|
| REQ-012 | KIS Open API 브로커 어댑터 | Integration | Implemented | High | project maintainers | [req-012](./Integration/req-012_kis-broker-adapter.md) |
|
||||||
| REQ-013 | 주문 집행 루프 (claim·송신·체결·롤백) | Integration | Proposed | High | project maintainers | [req-013](./Integration/req-013_order-execution-loop.md) |
|
| REQ-013 | 주문 집행 루프 (claim·송신·체결·롤백) | Integration | Implemented | High | project maintainers | [req-013](./Integration/req-013_order-execution-loop.md) |
|
||||||
| REQ-014 | 주문 핸드오프 durability + reconciliation | Exception | Proposed | High | project maintainers | [req-014](./Exception/req-014_order-handoff-durability-reconciliation.md) |
|
| REQ-014 | 주문 핸드오프 durability + reconciliation | Exception | Implemented | High | project maintainers | [req-014](./Exception/req-014_order-handoff-durability-reconciliation.md) |
|
||||||
| REQ-015 | 브로커/집행 baseline 문서 정합 | Documentation | Proposed | Medium | project maintainers | [req-015](./Documentation/req-015_broker-execution-baseline-alignment.md) |
|
| REQ-015 | 브로커/집행 baseline 문서 정합 | Documentation | Implemented | Medium | project maintainers | [req-015](./Documentation/req-015_broker-execution-baseline-alignment.md) |
|
||||||
|
|
||||||
## Recent Change Log Summary
|
## Recent Change Log Summary
|
||||||
|
|
||||||
|
- 2026-06-04 :: REQ-011~015 Approved → Implemented — BAT-004 verification evidence 기반. (confirm-req-implemented)
|
||||||
|
- 2026-06-04 :: REQ-011~015 Proposed → Approved — 승인 게이트 통과. (confirm-req)
|
||||||
- 2026-06-04 :: REQ-011~015 최초 생성(Proposed) — dcy-003 FR-1~FR-6 분해. (draft-req)
|
- 2026-06-04 :: REQ-011~015 최초 생성(Proposed) — dcy-003 FR-1~FR-6 분해. (draft-req)
|
||||||
- 2026-06-04 :: REQ-010 Approved → Implemented — BAT-003 verification evidence 기반. (confirm-req-implemented)
|
- 2026-06-04 :: REQ-010 Approved → Implemented — BAT-003 verification evidence 기반. (confirm-req-implemented)
|
||||||
- 2026-06-04 :: REQ-010 생성 + Approved — 데몬 .env 로딩 (minor-change). (draft-req/confirm-req)
|
- 2026-06-04 :: REQ-010 생성 + Approved — 데몬 .env 로딩 (minor-change). (draft-req/confirm-req)
|
||||||
- 2026-06-03 :: REQ-005~009 Approved → Implemented — BAT-002 verification evidence 기반. (confirm-req-implemented)
|
|
||||||
- 2026-06-03 :: REQ-005~009 Proposed → Approved — 승인 게이트 통과. (confirm-req)
|
|
||||||
- 2026-06-03 :: REQ-001~004 Approved → Implemented — BAT-001 verification evidence 기반. (confirm-req-implemented)
|
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""KIS adapter tests (REQ-012).
|
||||||
|
|
||||||
|
Unit tests run always; the sandbox integration test runs only when KIS
|
||||||
|
모의투자 credentials are present (TREX_KIS_BROKER_* env / .env).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from trex_core.adapters.broker.kis import KISBroker
|
||||||
|
from trex_core.application.broker_port import OrderRequest
|
||||||
|
|
||||||
|
_MOCK_URL = "https://openapivts.koreainvestment.com:29443"
|
||||||
|
|
||||||
|
|
||||||
|
def test_account_split_and_mock_tr_ids():
|
||||||
|
b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL)
|
||||||
|
assert b._account() == ("12345678", "01")
|
||||||
|
assert b._tr_submit("buy") == "VTTC0012U"
|
||||||
|
assert b._tr_submit("sell") == "VTTC0011U"
|
||||||
|
assert b.name() == "kis-mock"
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_base_url_requires_allow_live():
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
KISBroker("12345678-01", app_key="k", app_secret="s",
|
||||||
|
base_url="https://openapi.koreainvestment.com:9443")
|
||||||
|
# gated on explicitly
|
||||||
|
b = KISBroker("12345678-01", app_key="k", app_secret="s",
|
||||||
|
base_url="https://openapi.koreainvestment.com:9443", allow_live=True)
|
||||||
|
assert b.name() == "kis-live" and b._tr_submit("buy") == "TTTC0012U"
|
||||||
|
|
||||||
|
|
||||||
|
def _kis_env():
|
||||||
|
keys = ("TREX_KIS_BROKER_BASE_URL", "TREX_KIS_BROKER_APP_KEY",
|
||||||
|
"TREX_KIS_BROKER_APP_SECRET", "TREX_KIS_BROKER_ACCOUNT_REF")
|
||||||
|
if not all(os.environ.get(k) for k in keys):
|
||||||
|
# try .env via dotenv
|
||||||
|
try:
|
||||||
|
from dotenv import load_dotenv, find_dotenv
|
||||||
|
load_dotenv(find_dotenv(usecwd=True), override=False)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
if not all(os.environ.get(k) for k in keys):
|
||||||
|
pytest.skip("KIS 모의투자 credentials not set; skipping sandbox integration test")
|
||||||
|
return {k: os.environ[k] for k in keys}
|
||||||
|
|
||||||
|
|
||||||
|
def test_sandbox_token_and_submit():
|
||||||
|
"""Single sandbox round-trip (one token issuance — KIS rate-limits token requests)."""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
env = _kis_env()
|
||||||
|
assert "openapivts" in env["TREX_KIS_BROKER_BASE_URL"], "expect 모의투자 sandbox URL"
|
||||||
|
b = KISBroker(
|
||||||
|
env["TREX_KIS_BROKER_ACCOUNT_REF"],
|
||||||
|
app_key=env["TREX_KIS_BROKER_APP_KEY"],
|
||||||
|
app_secret=env["TREX_KIS_BROKER_APP_SECRET"],
|
||||||
|
base_url=env["TREX_KIS_BROKER_BASE_URL"],
|
||||||
|
timeout=float(os.environ.get("TREX_KIS_BROKER_TIMEOUT", "10")),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
token = b._ensure_token()
|
||||||
|
except httpx.HTTPStatusError as exc: # KIS EGW00133: token issuance rate-limited
|
||||||
|
pytest.skip(f"KIS token rate-limited/unavailable: {exc}")
|
||||||
|
assert isinstance(token, str) and len(token) > 0
|
||||||
|
|
||||||
|
# limit buy 1 share at a low price (sandbox, no real money). Reuses the cached token.
|
||||||
|
ack = b.submit_order(OrderRequest(slot_id="t", side="buy", symbol="005930", qty=1,
|
||||||
|
limit_price=10000, order_type="00"))
|
||||||
|
assert ack.raw, "expected a KIS response payload"
|
||||||
|
if ack.accepted:
|
||||||
|
assert ack.broker_order_id
|
||||||
|
else:
|
||||||
|
assert ack.reason # rejection carries a reason (e.g., 장 미개장/잔고부족)
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Paper broker deterministic async double tests (REQ-011)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from trex_core.adapters.broker.paper import PaperBroker
|
||||||
|
from trex_core.application.broker_port import OrderRequest
|
||||||
|
|
||||||
|
|
||||||
|
def _req(symbol="005930", qty=10, price=1000):
|
||||||
|
return OrderRequest(slot_id="s1", side="buy", symbol=symbol, qty=qty, limit_price=price)
|
||||||
|
|
||||||
|
|
||||||
|
def test_submit_does_not_synchronously_fill():
|
||||||
|
b = PaperBroker(fill_after_polls=1)
|
||||||
|
ack = b.submit_order(_req())
|
||||||
|
assert ack.accepted and ack.broker_order_id
|
||||||
|
# next poll is still 'accepted' (async contract, no synchronous shortcut)
|
||||||
|
fs = b.poll_fill(ack.broker_order_id, side="buy", symbol="005930")
|
||||||
|
assert fs.state == "accepted" and fs.filled_qty == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_fills_after_configured_polls():
|
||||||
|
b = PaperBroker(fill_after_polls=1)
|
||||||
|
ack = b.submit_order(_req())
|
||||||
|
b.poll_fill(ack.broker_order_id, side="buy", symbol="005930") # poll 1 -> accepted
|
||||||
|
fs = b.poll_fill(ack.broker_order_id, side="buy", symbol="005930") # poll 2 -> filled
|
||||||
|
assert fs.is_filled and fs.filled_qty == 10 and fs.avg_price == 1000.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_immediate_fill_on_first_poll():
|
||||||
|
b = PaperBroker(fill_after_polls=0)
|
||||||
|
ack = b.submit_order(_req())
|
||||||
|
fs = b.poll_fill(ack.broker_order_id, side="buy", symbol="005930")
|
||||||
|
assert fs.is_filled
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_then_full():
|
||||||
|
b = PaperBroker(fill_after_polls=0, qty_per_poll=4)
|
||||||
|
ack = b.submit_order(_req(qty=10))
|
||||||
|
f1 = b.poll_fill(ack.broker_order_id, side="buy", symbol="005930")
|
||||||
|
assert f1.state == "partially_filled" and f1.filled_qty == 4
|
||||||
|
b.poll_fill(ack.broker_order_id, side="buy", symbol="005930") # 8
|
||||||
|
f3 = b.poll_fill(ack.broker_order_id, side="buy", symbol="005930") # 10
|
||||||
|
assert f3.is_filled and f3.filled_qty == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject():
|
||||||
|
b = PaperBroker(reject_symbols={"005930"})
|
||||||
|
ack = b.submit_order(_req())
|
||||||
|
fs = b.poll_fill(ack.broker_order_id, side="buy", symbol="005930")
|
||||||
|
assert fs.state == "rejected"
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Order execution loop tests via paper broker (REQ-013, REQ-014).
|
||||||
|
|
||||||
|
In-memory adapters; deterministic paper double drives the full async path.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from trex_core.adapters.broker.paper import PaperBroker
|
||||||
|
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
|
||||||
|
from trex_core.application.execution import ExecutionService
|
||||||
|
from trex_core.application.intents import BuyIntent, IntentService, SellIntent
|
||||||
|
from trex_core.domain.slot import SlotService, SlotState
|
||||||
|
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||||
|
|
||||||
|
DAY = date(2026, 6, 4)
|
||||||
|
|
||||||
|
|
||||||
|
def _wire(broker):
|
||||||
|
repo = InMemorySlotRepository()
|
||||||
|
svc = SlotService(repo)
|
||||||
|
intents = IntentService(svc, repo, InMemoryIntentStore())
|
||||||
|
execution = ExecutionService(svc, repo, broker)
|
||||||
|
svc.preallocate_daily_slots(DAY, 5)
|
||||||
|
return repo, svc, intents, execution
|
||||||
|
|
||||||
|
|
||||||
|
def _buy(intents, iid="b1", symbol="005930", qty=10):
|
||||||
|
return intents.submit_buy(BuyIntent(intent_id=iid, trading_date=DAY, symbol=symbol, qty=qty, entry_price=1000))
|
||||||
|
|
||||||
|
|
||||||
|
def test_buy_lifecycle_to_holding():
|
||||||
|
repo, svc, intents, execution = _wire(PaperBroker(fill_after_polls=0))
|
||||||
|
d = _buy(intents)
|
||||||
|
sid = d.slot_id
|
||||||
|
assert repo.fetch_slot(sid).status is SlotState.PENDING_BUY
|
||||||
|
execution.run_once() # dispatch + poll(fill immediately)
|
||||||
|
slot = repo.fetch_slot(sid)
|
||||||
|
assert slot.status is SlotState.HOLDING and slot.qty == 10 and slot.avg_price == 1000.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_buy_dispatch_is_idempotent_no_double_order():
|
||||||
|
broker = PaperBroker(fill_after_polls=5) # stays accepted (not filled) across ticks
|
||||||
|
repo, svc, intents, execution = _wire(broker)
|
||||||
|
sid = _buy(intents).slot_id
|
||||||
|
execution.run_once() # dispatch (1 order) + poll(accepted)
|
||||||
|
bid1 = repo.fetch_slot(sid).broker_order_id
|
||||||
|
execution.run_once() # must NOT re-dispatch
|
||||||
|
bid2 = repo.fetch_slot(sid).broker_order_id
|
||||||
|
assert bid1 == bid2 and len(broker._orders) == 1 # exactly one broker order
|
||||||
|
|
||||||
|
|
||||||
|
def test_buy_reject_rolls_back_to_idle():
|
||||||
|
repo, svc, intents, execution = _wire(PaperBroker(reject_symbols={"005930"}))
|
||||||
|
sid = _buy(intents).slot_id
|
||||||
|
execution.run_once() # dispatch + poll(reject)
|
||||||
|
slot = repo.fetch_slot(sid)
|
||||||
|
assert slot.status is SlotState.IDLE and slot.symbol is None and slot.broker_order_id is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_sell_lifecycle_to_closed_bumps_entry_count():
|
||||||
|
repo, svc, intents, execution = _wire(PaperBroker(fill_after_polls=0))
|
||||||
|
sid = _buy(intents).slot_id
|
||||||
|
execution.run_once() # -> holding
|
||||||
|
assert repo.fetch_slot(sid).status is SlotState.HOLDING
|
||||||
|
intents.submit_sell(SellIntent(intent_id="s1", slot_id=sid))
|
||||||
|
assert repo.fetch_slot(sid).status is SlotState.PENDING_SELL
|
||||||
|
execution.run_once() # dispatch sell + fill -> closed
|
||||||
|
assert repo.fetch_slot(sid).status is SlotState.CLOSED
|
||||||
|
assert svc.get_today_entry_count(DAY, "005930") == 0 # +1 happens on closed->idle recovery
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_after_crash_completes_fill_without_redispatch():
|
||||||
|
broker = PaperBroker(fill_after_polls=1)
|
||||||
|
repo, svc, intents, execution = _wire(broker)
|
||||||
|
sid = _buy(intents).slot_id
|
||||||
|
execution.run_once() # dispatch + poll1 (accepted, not filled) — simulates crash here
|
||||||
|
assert repo.fetch_slot(sid).status is SlotState.PENDING_BUY
|
||||||
|
orders_before = len(broker._orders)
|
||||||
|
execution.reconcile() # restart: re-poll -> fills, no new order
|
||||||
|
slot = repo.fetch_slot(sid)
|
||||||
|
assert slot.status is SlotState.HOLDING
|
||||||
|
assert len(broker._orders) == orders_before # reconcile did not submit a new order
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_idle_slots_blocks_further_buys():
|
||||||
|
repo, svc, intents, execution = _wire(PaperBroker(fill_after_polls=0))
|
||||||
|
for i in range(5):
|
||||||
|
assert _buy(intents, iid=f"b{i}").status == "admitted"
|
||||||
|
assert _buy(intents, iid="overflow").status == "rejected"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Broker adapters (REQ-011/012). Implement application.broker_port.BrokerPort."""
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"""KIS (한국투자증권) Open API broker adapter (REQ-012, dcy-003).
|
||||||
|
|
||||||
|
Implements BrokerPort against the KIS REST API, modeled on the trex-orbit
|
||||||
|
reference (app/broker/kis.py). Slice 1 validates against the KIS 모의투자
|
||||||
|
(VTS sandbox) server; real-account go-live is config-gated and out of scope.
|
||||||
|
|
||||||
|
Endpoints: /oauth2/tokenP (token), /uapi/domestic-stock/v1/trading/order-cash
|
||||||
|
(submit), /uapi/domestic-stock/v1/trading/inquire-daily-ccld (fill poll),
|
||||||
|
/uapi/domestic-stock/v1/trading/order-rvsecncl (cancel).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from trex_core.application.broker_port import FillStatus, OrderAck, OrderRequest
|
||||||
|
|
||||||
|
_KST = timezone(timedelta(hours=9))
|
||||||
|
_MOCK_HINTS = ("openapivts", "virtual", "mock")
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_int(v: object) -> Optional[int]:
|
||||||
|
try:
|
||||||
|
return int(str(v).strip())
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class KISBroker:
|
||||||
|
"""KIS REST broker adapter. Live account is gated behind `allow_live`."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
account_ref: str,
|
||||||
|
*,
|
||||||
|
app_key: str,
|
||||||
|
app_secret: str,
|
||||||
|
base_url: str,
|
||||||
|
timeout: float = 5.0,
|
||||||
|
allow_live: bool = False,
|
||||||
|
) -> None:
|
||||||
|
self._account_ref = account_ref
|
||||||
|
self._app_key = app_key
|
||||||
|
self._app_secret = app_secret
|
||||||
|
self._base_url = base_url.rstrip("/")
|
||||||
|
self._timeout = timeout
|
||||||
|
self._token: Optional[str] = None
|
||||||
|
self._token_exp: datetime = datetime.now(tz=timezone.utc)
|
||||||
|
is_mock = any(h in self._base_url for h in _MOCK_HINTS)
|
||||||
|
if not is_mock and not allow_live:
|
||||||
|
raise RuntimeError(
|
||||||
|
"KIS live account base_url requires allow_live=True (config-gated). "
|
||||||
|
"Use the 모의투자 (openapivts) endpoint for slice-1."
|
||||||
|
)
|
||||||
|
self._mock = is_mock
|
||||||
|
|
||||||
|
def name(self) -> str:
|
||||||
|
return "kis-mock" if self._mock else "kis-live"
|
||||||
|
|
||||||
|
# --- auth ---
|
||||||
|
def _ensure_token(self) -> str:
|
||||||
|
if self._token and datetime.now(tz=timezone.utc) < self._token_exp:
|
||||||
|
return self._token
|
||||||
|
resp = httpx.post(
|
||||||
|
f"{self._base_url}/oauth2/tokenP",
|
||||||
|
json={
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"appkey": self._app_key,
|
||||||
|
"appsecret": self._app_secret,
|
||||||
|
},
|
||||||
|
timeout=self._timeout,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
self._token = data["access_token"]
|
||||||
|
ttl = int(data.get("expires_in", 3600))
|
||||||
|
self._token_exp = datetime.now(tz=timezone.utc) + timedelta(seconds=max(60, ttl - 300))
|
||||||
|
return self._token
|
||||||
|
|
||||||
|
def _headers(self, tr_id: str) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"authorization": f"Bearer {self._ensure_token()}",
|
||||||
|
"appkey": self._app_key,
|
||||||
|
"appsecret": self._app_secret,
|
||||||
|
"tr_id": tr_id,
|
||||||
|
"content-type": "application/json; charset=utf-8",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _account(self) -> tuple[str, str]:
|
||||||
|
raw = self._account_ref.strip()
|
||||||
|
if "-" in raw:
|
||||||
|
cano, product = raw.split("-", 1)
|
||||||
|
return cano.strip(), product.strip()
|
||||||
|
digits = "".join(c for c in raw if c.isdigit())
|
||||||
|
return digits[:8], (digits[8:] or "01")
|
||||||
|
|
||||||
|
def _tr_submit(self, side: str) -> str:
|
||||||
|
if self._mock:
|
||||||
|
return "VTTC0012U" if side == "buy" else "VTTC0011U"
|
||||||
|
return "TTTC0012U" if side == "buy" else "TTTC0011U"
|
||||||
|
|
||||||
|
# --- BrokerPort ---
|
||||||
|
def submit_order(self, req: OrderRequest) -> OrderAck:
|
||||||
|
cano, product = self._account()
|
||||||
|
body = {
|
||||||
|
"CANO": cano,
|
||||||
|
"ACNT_PRDT_CD": product,
|
||||||
|
"PDNO": req.symbol,
|
||||||
|
"ORD_DVSN": req.order_type,
|
||||||
|
"ORD_QTY": str(req.qty),
|
||||||
|
"ORD_UNPR": str(req.limit_price or 0),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
resp = httpx.post(
|
||||||
|
f"{self._base_url}/uapi/domestic-stock/v1/trading/order-cash",
|
||||||
|
headers=self._headers(self._tr_submit(req.side)),
|
||||||
|
json=body,
|
||||||
|
timeout=self._timeout,
|
||||||
|
)
|
||||||
|
payload = resp.json()
|
||||||
|
except (httpx.HTTPError, ValueError) as exc:
|
||||||
|
return OrderAck(accepted=False, reason=f"submit_network_error:{type(exc).__name__}",
|
||||||
|
raw={"error": str(exc)})
|
||||||
|
if str(payload.get("rt_cd")) == "0":
|
||||||
|
out = payload.get("output") or {}
|
||||||
|
odno = out.get("ODNO") or out.get("odno")
|
||||||
|
orgno = out.get("KRX_FWDG_ORD_ORGNO") or out.get("ord_gno_brno") or ""
|
||||||
|
bid = f"{orgno}:{odno}" if orgno else str(odno)
|
||||||
|
return OrderAck(accepted=True, broker_order_id=bid, raw=payload)
|
||||||
|
return OrderAck(accepted=False, reason=payload.get("msg1") or payload.get("msg_cd"),
|
||||||
|
raw=payload)
|
||||||
|
|
||||||
|
def poll_fill(self, broker_order_id: str, *, side: str, symbol: str) -> FillStatus:
|
||||||
|
cano, product = self._account()
|
||||||
|
odno = broker_order_id.split(":")[-1]
|
||||||
|
today = datetime.now(tz=_KST).strftime("%Y%m%d")
|
||||||
|
tr_id = "VTTC8001R" if self._mock else "TTTC8001R"
|
||||||
|
params = {
|
||||||
|
"CANO": cano, "ACNT_PRDT_CD": product,
|
||||||
|
"INQR_STRT_DT": today, "INQR_END_DT": today,
|
||||||
|
"SLL_BUY_DVSN_CD": "00", "INQR_DVSN": "00", "PDNO": "",
|
||||||
|
"CCLD_DVSN": "00", "ORD_GNO_BRNO": "", "ODNO": odno,
|
||||||
|
"INQR_DVSN_3": "00", "INQR_DVSN_1": "",
|
||||||
|
"CTX_AREA_FK100": "", "CTX_AREA_NK100": "",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
resp = httpx.get(
|
||||||
|
f"{self._base_url}/uapi/domestic-stock/v1/trading/inquire-daily-ccld",
|
||||||
|
headers=self._headers(tr_id), params=params, timeout=self._timeout,
|
||||||
|
)
|
||||||
|
payload = resp.json()
|
||||||
|
except (httpx.HTTPError, ValueError) as exc:
|
||||||
|
return FillStatus(state="unknown", raw={"error": str(exc)})
|
||||||
|
rows = payload.get("output1") or []
|
||||||
|
row = next((r for r in rows if str(r.get("odno")) == odno), rows[0] if rows else None)
|
||||||
|
if row is None:
|
||||||
|
return FillStatus(state="accepted", raw=payload)
|
||||||
|
ord_qty = _safe_int(row.get("ord_qty")) or 0
|
||||||
|
ccld_qty = _safe_int(row.get("tot_ccld_qty")) or 0
|
||||||
|
avg = _safe_int(row.get("avg_prvs")) or _safe_int(row.get("ccld_prvs"))
|
||||||
|
if ccld_qty <= 0:
|
||||||
|
return FillStatus(state="accepted", raw=row)
|
||||||
|
if ccld_qty >= ord_qty and ord_qty > 0:
|
||||||
|
return FillStatus(state="filled", filled_qty=ccld_qty,
|
||||||
|
avg_price=float(avg) if avg else None, raw=row)
|
||||||
|
return FillStatus(state="partially_filled", filled_qty=ccld_qty,
|
||||||
|
avg_price=float(avg) if avg else None, raw=row)
|
||||||
|
|
||||||
|
def cancel_order(self, broker_order_id: str, *, side: str, symbol: str, qty: int) -> bool:
|
||||||
|
cano, product = self._account()
|
||||||
|
parts = broker_order_id.split(":")
|
||||||
|
orgno, odno = (parts[0], parts[1]) if len(parts) == 2 else ("", parts[-1])
|
||||||
|
body = {
|
||||||
|
"CANO": cano, "ACNT_PRDT_CD": product,
|
||||||
|
"KRX_FWDG_ORD_ORGNO": orgno, "ORGN_ODNO": odno,
|
||||||
|
"ORD_DVSN": "00", "RVSE_CNCL_DVSN_CD": "02",
|
||||||
|
"ORD_QTY": str(qty), "ORD_UNPR": "0", "QTY_ALL_ORD_YN": "Y",
|
||||||
|
}
|
||||||
|
tr_id = "VTTC0013U" if self._mock else "TTTC0013U"
|
||||||
|
try:
|
||||||
|
resp = httpx.post(
|
||||||
|
f"{self._base_url}/uapi/domestic-stock/v1/trading/order-rvsecncl",
|
||||||
|
headers=self._headers(tr_id), json=body, timeout=self._timeout,
|
||||||
|
)
|
||||||
|
return str(resp.json().get("rt_cd")) == "0"
|
||||||
|
except (httpx.HTTPError, ValueError):
|
||||||
|
return False
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Paper/stub broker — deterministic async double (REQ-011, dcy-003 OQ-2).
|
||||||
|
|
||||||
|
Follows the same submit -> poll contract as the real KIS adapter (no
|
||||||
|
synchronous shortcut), but lets tests program WHEN/whether fills happen:
|
||||||
|
immediate, after N polls, partial, or reject. This exercises the same
|
||||||
|
execution code path that production (KIS) takes.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from trex_core.application.broker_port import FillStatus, OrderAck, OrderRequest
|
||||||
|
|
||||||
|
|
||||||
|
class _Order:
|
||||||
|
def __init__(self, oid: str, req: OrderRequest) -> None:
|
||||||
|
self.oid = oid
|
||||||
|
self.side = req.side
|
||||||
|
self.symbol = req.symbol
|
||||||
|
self.qty = req.qty
|
||||||
|
self.price = float(req.limit_price) if req.limit_price is not None else 1000.0
|
||||||
|
self.polls = 0
|
||||||
|
self.filled = 0
|
||||||
|
self.cancelled = False
|
||||||
|
|
||||||
|
|
||||||
|
class PaperBroker:
|
||||||
|
"""Deterministic async paper broker.
|
||||||
|
|
||||||
|
- fill_after_polls: number of poll_fill calls before fills begin (0 => fills on first poll)
|
||||||
|
- qty_per_poll: filled quantity per poll once filling (None => fill remaining at once)
|
||||||
|
- reject_symbols: symbols whose orders are rejected on first poll
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
fill_after_polls: int = 0,
|
||||||
|
qty_per_poll: Optional[int] = None,
|
||||||
|
reject_symbols: Optional[set[str]] = None,
|
||||||
|
) -> None:
|
||||||
|
self._fill_after = fill_after_polls
|
||||||
|
self._qty_per_poll = qty_per_poll
|
||||||
|
self._reject = reject_symbols or set()
|
||||||
|
self._orders: dict[str, _Order] = {}
|
||||||
|
self._seq = 0
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def name(self) -> str:
|
||||||
|
return "paper"
|
||||||
|
|
||||||
|
def submit_order(self, req: OrderRequest) -> OrderAck:
|
||||||
|
with self._lock:
|
||||||
|
self._seq += 1
|
||||||
|
oid = f"PAPER-{self._seq:06d}"
|
||||||
|
self._orders[oid] = _Order(oid, req)
|
||||||
|
return OrderAck(accepted=True, broker_order_id=oid)
|
||||||
|
|
||||||
|
def poll_fill(self, broker_order_id: str, *, side: str, symbol: str) -> FillStatus:
|
||||||
|
with self._lock:
|
||||||
|
o = self._orders.get(broker_order_id)
|
||||||
|
if o is None:
|
||||||
|
return FillStatus(state="unknown")
|
||||||
|
if o.cancelled:
|
||||||
|
return FillStatus(state="cancelled")
|
||||||
|
o.polls += 1
|
||||||
|
if symbol in self._reject:
|
||||||
|
return FillStatus(state="rejected", raw={"reason": "paper_reject"})
|
||||||
|
if o.polls <= self._fill_after:
|
||||||
|
return FillStatus(state="accepted", filled_qty=o.filled, avg_price=o.price)
|
||||||
|
step = self._qty_per_poll if self._qty_per_poll is not None else (o.qty - o.filled)
|
||||||
|
o.filled = min(o.qty, o.filled + step)
|
||||||
|
if o.filled >= o.qty:
|
||||||
|
return FillStatus(state="filled", filled_qty=o.filled, avg_price=o.price)
|
||||||
|
return FillStatus(state="partially_filled", filled_qty=o.filled, avg_price=o.price)
|
||||||
|
|
||||||
|
def cancel_order(self, broker_order_id: str, *, side: str, symbol: str, qty: int) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
o = self._orders.get(broker_order_id)
|
||||||
|
if o is None or o.filled >= o.qty:
|
||||||
|
return False
|
||||||
|
o.cancelled = True
|
||||||
|
return True
|
||||||
@@ -170,6 +170,48 @@ class PostgreSQLSlotRepository:
|
|||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
return int(row[0]) if row else 0
|
return int(row[0]) if row else 0
|
||||||
|
|
||||||
|
# --- order execution queue (BAT-004 / REQ-013, option A) ---
|
||||||
|
def find_pending_for_dispatch(self) -> Optional[Slot]:
|
||||||
|
with self._psycopg.connect(self._dsn) as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
f"SELECT {_SLOT_COLUMNS} FROM daily_slot "
|
||||||
|
"WHERE status IN ('pending_buy','pending_sell') AND broker_order_id IS NULL "
|
||||||
|
"ORDER BY updated_at LIMIT 1 FOR UPDATE SKIP LOCKED"
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return self._row_to_slot(row) if row else None
|
||||||
|
|
||||||
|
def mark_dispatched(self, slot_id: UUID, broker_order_id: str) -> bool:
|
||||||
|
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE daily_slot SET broker_order_id=%s, updated_at=%s "
|
||||||
|
"WHERE slot_id=%s AND broker_order_id IS NULL",
|
||||||
|
(broker_order_id, datetime.now(tz=timezone.utc), slot_id),
|
||||||
|
)
|
||||||
|
ok = cur.rowcount == 1
|
||||||
|
conn.commit()
|
||||||
|
return ok
|
||||||
|
|
||||||
|
def list_dispatched_pending(self) -> list[Slot]:
|
||||||
|
with self._psycopg.connect(self._dsn) as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
f"SELECT {_SLOT_COLUMNS} FROM daily_slot "
|
||||||
|
"WHERE status IN ('pending_buy','pending_sell') AND broker_order_id IS NOT NULL"
|
||||||
|
)
|
||||||
|
return [self._row_to_slot(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
def clear_dispatch(self, slot_id: UUID) -> None:
|
||||||
|
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE daily_slot SET broker_order_id=NULL, updated_at=%s WHERE slot_id=%s",
|
||||||
|
(datetime.now(tz=timezone.utc), slot_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
def count_slots_by_status(self, trading_date: date) -> dict[SlotState, int]:
|
def count_slots_by_status(self, trading_date: date) -> dict[SlotState, int]:
|
||||||
with self._psycopg.connect(self._dsn) as conn:
|
with self._psycopg.connect(self._dsn) as conn:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Broker port + DTOs (REQ-011, dcy-003).
|
||||||
|
|
||||||
|
The single broker boundary (invariant B): only the core, through this port,
|
||||||
|
submits/cancels orders and observes fills. Adapters (paper, KIS) live in
|
||||||
|
trex_core.adapters.broker. Fills are observed asynchronously via poll_fill
|
||||||
|
(unified contract — paper does not shortcut to synchronous, dcy-003 OQ-2).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Optional, Protocol
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OrderRequest:
|
||||||
|
slot_id: str # originating slot (audit/correlation)
|
||||||
|
side: str # 'buy' | 'sell'
|
||||||
|
symbol: str
|
||||||
|
qty: int
|
||||||
|
limit_price: Optional[int] = None # None => market order
|
||||||
|
order_type: str = "00" # KIS ORD_DVSN: 00 limit, 01 market
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OrderAck:
|
||||||
|
accepted: bool
|
||||||
|
broker_order_id: Optional[str] = None
|
||||||
|
reason: Optional[str] = None
|
||||||
|
raw: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FillStatus:
|
||||||
|
state: str # accepted | partially_filled | filled | rejected | cancelled | unknown
|
||||||
|
filled_qty: int = 0
|
||||||
|
avg_price: Optional[float] = None
|
||||||
|
raw: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_filled(self) -> bool:
|
||||||
|
return self.state == "filled"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_terminal_reject(self) -> bool:
|
||||||
|
return self.state in {"rejected", "cancelled"}
|
||||||
|
|
||||||
|
|
||||||
|
class BrokerPort(Protocol):
|
||||||
|
"""Single broker session boundary (invariant B)."""
|
||||||
|
|
||||||
|
def name(self) -> str:
|
||||||
|
...
|
||||||
|
|
||||||
|
def submit_order(self, req: OrderRequest) -> OrderAck:
|
||||||
|
"""Submit an order. Returns accepted + broker_order_id (no synchronous fill)."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def poll_fill(self, broker_order_id: str, *, side: str, symbol: str) -> FillStatus:
|
||||||
|
"""Observe current fill status for a previously accepted order."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def cancel_order(self, broker_order_id: str, *, side: str, symbol: str, qty: int) -> bool:
|
||||||
|
"""Best-effort cancel. Returns True if the broker accepted the cancel."""
|
||||||
|
...
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""Order execution service (REQ-013, REQ-014, dcy-003).
|
||||||
|
|
||||||
|
A single background execution loop (invariant A single writer, invariant B
|
||||||
|
single broker session) that, per tick:
|
||||||
|
dispatch: claim pending slots with broker_order_id IS NULL (option A queue),
|
||||||
|
submit the order, record broker_order_id (idempotency guard).
|
||||||
|
poll: observe fills on dispatched pending slots and transition the slot
|
||||||
|
(pending_buy->holding / pending_sell->closed), or roll back on reject.
|
||||||
|
reconcile() re-polls dispatched slots on restart so a crash after dispatch
|
||||||
|
converges without duplicate orders (REQ-014).
|
||||||
|
|
||||||
|
All slot writes go through SlotService (advisory lock). The broker is touched
|
||||||
|
only here, never by the domain or the HTTP API.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from trex_core.application.broker_port import BrokerPort, OrderRequest
|
||||||
|
from trex_core.domain.slot import SlotService, SlotState, SlotStateMismatchError
|
||||||
|
|
||||||
|
_MAX_DISPATCH_PER_TICK = 64
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExecutionResult:
|
||||||
|
dispatched: int = 0
|
||||||
|
filled: int = 0
|
||||||
|
rolled_back: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionService:
|
||||||
|
def __init__(self, slot_service: SlotService, repo, broker: BrokerPort) -> None:
|
||||||
|
self._svc = slot_service
|
||||||
|
self._repo = repo
|
||||||
|
self._broker = broker
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
def run_once(self) -> ExecutionResult:
|
||||||
|
r = ExecutionResult()
|
||||||
|
self._dispatch(r)
|
||||||
|
self._poll(r)
|
||||||
|
return r
|
||||||
|
|
||||||
|
def reconcile(self) -> ExecutionResult:
|
||||||
|
"""Restart recovery: re-poll dispatched-but-pending slots (REQ-014).
|
||||||
|
|
||||||
|
Undispatched pending slots (broker_order_id IS NULL) are simply picked
|
||||||
|
up by the next dispatch, so a crash before dispatch causes no duplicate.
|
||||||
|
"""
|
||||||
|
r = ExecutionResult()
|
||||||
|
self._poll(r)
|
||||||
|
return r
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
def _dispatch(self, r: ExecutionResult) -> None:
|
||||||
|
for _ in range(_MAX_DISPATCH_PER_TICK):
|
||||||
|
slot = self._repo.find_pending_for_dispatch()
|
||||||
|
if slot is None:
|
||||||
|
return
|
||||||
|
side = "buy" if slot.status is SlotState.PENDING_BUY else "sell"
|
||||||
|
qty = int(slot.qty or 0)
|
||||||
|
limit = int(slot.entry_price) if slot.entry_price else None
|
||||||
|
ack = self._broker.submit_order(
|
||||||
|
OrderRequest(
|
||||||
|
slot_id=str(slot.slot_id),
|
||||||
|
side=side,
|
||||||
|
symbol=slot.symbol or "",
|
||||||
|
qty=qty,
|
||||||
|
limit_price=limit,
|
||||||
|
order_type="00" if limit is not None else "01",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if ack.accepted and ack.broker_order_id:
|
||||||
|
self._repo.mark_dispatched(slot.slot_id, ack.broker_order_id)
|
||||||
|
r.dispatched += 1
|
||||||
|
else:
|
||||||
|
self._rollback(slot, reason=ack.reason or "order_rejected")
|
||||||
|
r.rolled_back += 1
|
||||||
|
|
||||||
|
def _poll(self, r: ExecutionResult) -> None:
|
||||||
|
for slot in self._repo.list_dispatched_pending():
|
||||||
|
side = "buy" if slot.status is SlotState.PENDING_BUY else "sell"
|
||||||
|
fs = self._broker.poll_fill(slot.broker_order_id, side=side, symbol=slot.symbol or "")
|
||||||
|
if fs.is_filled:
|
||||||
|
self._on_filled(slot, fs)
|
||||||
|
r.filled += 1
|
||||||
|
elif fs.is_terminal_reject:
|
||||||
|
self._rollback(slot, reason=f"order_{fs.state}")
|
||||||
|
r.rolled_back += 1
|
||||||
|
# accepted / partially_filled -> leave for next tick
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
def _on_filled(self, slot, fs) -> None:
|
||||||
|
try:
|
||||||
|
if slot.status is SlotState.PENDING_BUY:
|
||||||
|
self._svc.transit_slot(
|
||||||
|
slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||||
|
reason="buy_filled", actor="execution_loop",
|
||||||
|
avg_price=fs.avg_price, qty=float(fs.filled_qty) or slot.qty,
|
||||||
|
)
|
||||||
|
else: # PENDING_SELL
|
||||||
|
self._svc.transit_slot(
|
||||||
|
slot.slot_id, SlotState.PENDING_SELL, SlotState.CLOSED,
|
||||||
|
reason="sell_filled", actor="execution_loop",
|
||||||
|
)
|
||||||
|
except SlotStateMismatchError:
|
||||||
|
return # slot moved concurrently; next tick reconciles
|
||||||
|
self._repo.clear_dispatch(slot.slot_id) # done with this order
|
||||||
|
|
||||||
|
def _rollback(self, slot, *, reason: str) -> None:
|
||||||
|
try:
|
||||||
|
if slot.status is SlotState.PENDING_BUY:
|
||||||
|
self._svc.transit_slot(
|
||||||
|
slot.slot_id, SlotState.PENDING_BUY, SlotState.IDLE,
|
||||||
|
reason=reason, actor="execution_loop",
|
||||||
|
) # idle reset clears broker_order_id in the domain
|
||||||
|
else: # PENDING_SELL rejected -> back to holding, clear order for re-dispatch
|
||||||
|
self._svc.transit_slot(
|
||||||
|
slot.slot_id, SlotState.PENDING_SELL, SlotState.HOLDING,
|
||||||
|
reason=reason, actor="execution_loop",
|
||||||
|
)
|
||||||
|
self._repo.clear_dispatch(slot.slot_id)
|
||||||
|
except SlotStateMismatchError:
|
||||||
|
return
|
||||||
@@ -7,6 +7,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import threading
|
import threading
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import replace
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
from typing import Iterator, Optional
|
from typing import Iterator, Optional
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
@@ -103,6 +104,41 @@ class InMemorySlotRepository:
|
|||||||
count, _ = self._entries.get((trading_date, symbol), (0, datetime.min))
|
count, _ = self._entries.get((trading_date, symbol), (0, datetime.min))
|
||||||
return count
|
return count
|
||||||
|
|
||||||
|
# --- order execution queue (BAT-004 / REQ-013, option A) ---
|
||||||
|
_PENDING = (SlotState.PENDING_BUY, SlotState.PENDING_SELL)
|
||||||
|
|
||||||
|
def find_pending_for_dispatch(self) -> Optional[Slot]:
|
||||||
|
"""A pending slot not yet dispatched (broker_order_id IS NULL)."""
|
||||||
|
for s in self._slots.values():
|
||||||
|
if s.status in self._PENDING and s.broker_order_id is None:
|
||||||
|
return s
|
||||||
|
return None
|
||||||
|
|
||||||
|
def mark_dispatched(self, slot_id: UUID, broker_order_id: str) -> bool:
|
||||||
|
"""Atomically set broker_order_id iff still undispatched (idempotency guard)."""
|
||||||
|
s = self._slots.get(slot_id)
|
||||||
|
if s is None or s.broker_order_id is not None:
|
||||||
|
return False
|
||||||
|
self._slots[slot_id] = replace(
|
||||||
|
s, broker_order_id=broker_order_id, updated_at=datetime.now(tz=timezone.utc)
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def list_dispatched_pending(self) -> list[Slot]:
|
||||||
|
"""Pending slots already dispatched (broker_order_id NOT NULL) — poll targets."""
|
||||||
|
return [
|
||||||
|
s for s in self._slots.values()
|
||||||
|
if s.status in self._PENDING and s.broker_order_id is not None
|
||||||
|
]
|
||||||
|
|
||||||
|
def clear_dispatch(self, slot_id: UUID) -> None:
|
||||||
|
"""Reset broker_order_id so the slot can be (re)dispatched (e.g. after a buy fill)."""
|
||||||
|
s = self._slots.get(slot_id)
|
||||||
|
if s is not None:
|
||||||
|
self._slots[slot_id] = replace(
|
||||||
|
s, broker_order_id=None, updated_at=datetime.now(tz=timezone.utc)
|
||||||
|
)
|
||||||
|
|
||||||
# --- bulk counts ---
|
# --- bulk counts ---
|
||||||
def count_slots_by_status(self, trading_date: date) -> dict[SlotState, int]:
|
def count_slots_by_status(self, trading_date: date) -> dict[SlotState, int]:
|
||||||
result: dict[SlotState, int] = {}
|
result: dict[SlotState, int] = {}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"""Daemon configuration resolution (REQ-005, REQ-010)."""
|
"""Daemon configuration resolution (REQ-005, REQ-010, REQ-012/013)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
def _load_dotenv() -> None:
|
def _load_dotenv() -> None:
|
||||||
@@ -19,12 +20,25 @@ def _load_dotenv() -> None:
|
|||||||
load_dotenv(find_dotenv(usecwd=True), override=False)
|
load_dotenv(find_dotenv(usecwd=True), override=False)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class KISConfig:
|
||||||
|
base_url: str
|
||||||
|
app_key: str
|
||||||
|
app_secret: str
|
||||||
|
account_ref: str
|
||||||
|
timeout: float
|
||||||
|
allow_live: bool
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class DaemonConfig:
|
class DaemonConfig:
|
||||||
dsn: str
|
dsn: str
|
||||||
total_slots: int
|
total_slots: int
|
||||||
host: str
|
host: str
|
||||||
port: int
|
port: int
|
||||||
|
broker: str # 'paper' | 'kis'
|
||||||
|
execution_interval_sec: float
|
||||||
|
kis: Optional[KISConfig] = None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_env() -> "DaemonConfig":
|
def from_env() -> "DaemonConfig":
|
||||||
@@ -34,9 +48,23 @@ class DaemonConfig:
|
|||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"TREX_POSTGRESQL_DSN is required to start the slot-core daemon."
|
"TREX_POSTGRESQL_DSN is required to start the slot-core daemon."
|
||||||
)
|
)
|
||||||
|
broker = os.environ.get("TREX_BROKER", "paper").strip().lower()
|
||||||
|
kis: Optional[KISConfig] = None
|
||||||
|
if broker == "kis":
|
||||||
|
kis = KISConfig(
|
||||||
|
base_url=os.environ["TREX_KIS_BROKER_BASE_URL"],
|
||||||
|
app_key=os.environ["TREX_KIS_BROKER_APP_KEY"],
|
||||||
|
app_secret=os.environ["TREX_KIS_BROKER_APP_SECRET"],
|
||||||
|
account_ref=os.environ["TREX_KIS_BROKER_ACCOUNT_REF"],
|
||||||
|
timeout=float(os.environ.get("TREX_KIS_BROKER_TIMEOUT", "5")),
|
||||||
|
allow_live=os.environ.get("TREX_KIS_ALLOW_LIVE", "").lower() in ("1", "true", "yes"),
|
||||||
|
)
|
||||||
return DaemonConfig(
|
return DaemonConfig(
|
||||||
dsn=dsn,
|
dsn=dsn,
|
||||||
total_slots=int(os.environ.get("TREX_TOTAL_SLOTS", "10")),
|
total_slots=int(os.environ.get("TREX_TOTAL_SLOTS", "10")),
|
||||||
host=os.environ.get("TREX_API_HOST", "127.0.0.1"),
|
host=os.environ.get("TREX_API_HOST", "127.0.0.1"),
|
||||||
port=int(os.environ.get("TREX_API_PORT", "8080")),
|
port=int(os.environ.get("TREX_API_PORT", "8080")),
|
||||||
|
broker=broker,
|
||||||
|
execution_interval_sec=float(os.environ.get("TREX_EXECUTION_INTERVAL_SEC", "1.0")),
|
||||||
|
kis=kis,
|
||||||
)
|
)
|
||||||
|
|||||||
+44
-11
@@ -1,34 +1,52 @@
|
|||||||
"""Slot-core daemon entrypoint (REQ-005).
|
"""Slot-core daemon entrypoint (REQ-005, REQ-012/013/014).
|
||||||
|
|
||||||
Builds the FastAPI app wired to PostgreSQL adapters, applies migrations and
|
Builds the FastAPI app wired to PostgreSQL adapters + a broker (paper|KIS),
|
||||||
preallocates the trading day's slots on startup (lifecycle, not a public
|
applies migrations and preallocates slots on startup, runs a background
|
||||||
endpoint), then serves via uvicorn. Console script: `trex-core-daemon`.
|
execution loop (claim -> submit -> poll -> transition, with restart
|
||||||
|
reconciliation), and serves the /v1 API via uvicorn.
|
||||||
|
Console script: `trex-core-daemon`.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from trex_core.adapters.api.app import create_app
|
from trex_core.adapters.api.app import create_app
|
||||||
|
from trex_core.adapters.broker.paper import PaperBroker
|
||||||
from trex_core.adapters.persistence.migrate import apply_migrations
|
from trex_core.adapters.persistence.migrate import apply_migrations
|
||||||
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore # noqa: F401
|
|
||||||
from trex_core.adapters.persistence.postgresql import (
|
from trex_core.adapters.persistence.postgresql import (
|
||||||
PostgreSQLIntentStore,
|
PostgreSQLIntentStore,
|
||||||
PostgreSQLSlotRepository,
|
PostgreSQLSlotRepository,
|
||||||
)
|
)
|
||||||
|
from trex_core.application.broker_port import BrokerPort
|
||||||
|
from trex_core.application.execution import ExecutionService
|
||||||
from trex_core.application.intents import IntentService
|
from trex_core.application.intents import IntentService
|
||||||
from trex_core.domain.slot import SlotService
|
from trex_core.domain.slot import SlotService
|
||||||
from trex_core.runtime.config import DaemonConfig
|
from trex_core.runtime.config import DaemonConfig
|
||||||
|
|
||||||
|
|
||||||
|
def _build_broker(config: DaemonConfig) -> BrokerPort:
|
||||||
|
if config.broker == "kis":
|
||||||
|
from trex_core.adapters.broker.kis import KISBroker
|
||||||
|
|
||||||
|
k = config.kis
|
||||||
|
return KISBroker(
|
||||||
|
k.account_ref, app_key=k.app_key, app_secret=k.app_secret,
|
||||||
|
base_url=k.base_url, timeout=k.timeout, allow_live=k.allow_live,
|
||||||
|
)
|
||||||
|
return PaperBroker()
|
||||||
|
|
||||||
|
|
||||||
def build_app(config: DaemonConfig) -> FastAPI:
|
def build_app(config: DaemonConfig) -> FastAPI:
|
||||||
"""Construct the daemon app with PostgreSQL adapters + startup lifecycle."""
|
|
||||||
slot_repo = PostgreSQLSlotRepository(config.dsn)
|
slot_repo = PostgreSQLSlotRepository(config.dsn)
|
||||||
intent_store = PostgreSQLIntentStore(config.dsn)
|
intent_store = PostgreSQLIntentStore(config.dsn)
|
||||||
slot_service = SlotService(slot_repo)
|
slot_service = SlotService(slot_repo)
|
||||||
intent_service = IntentService(slot_service, slot_repo, intent_store)
|
intent_service = IntentService(slot_service, slot_repo, intent_store)
|
||||||
|
broker = _build_broker(config)
|
||||||
|
execution = ExecutionService(slot_service, slot_repo, broker)
|
||||||
|
|
||||||
base = create_app(
|
base = create_app(
|
||||||
slot_service=slot_service,
|
slot_service=slot_service,
|
||||||
@@ -37,14 +55,29 @@ def build_app(config: DaemonConfig) -> FastAPI:
|
|||||||
default_total_slots=config.total_slots,
|
default_total_slots=config.total_slots,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
stop = threading.Event()
|
||||||
|
|
||||||
|
def _loop() -> None:
|
||||||
|
while not stop.wait(config.execution_interval_sec):
|
||||||
|
try:
|
||||||
|
execution.run_once()
|
||||||
|
except Exception: # noqa: BLE001 - loop must survive transient errors
|
||||||
|
continue
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(_: FastAPI):
|
async def lifespan(_: FastAPI):
|
||||||
# Startup: migrate schema, preallocate today's slots (idempotent).
|
|
||||||
apply_migrations(config.dsn)
|
apply_migrations(config.dsn)
|
||||||
today = datetime.now(tz=timezone.utc).date()
|
slot_service.preallocate_daily_slots(
|
||||||
slot_service.preallocate_daily_slots(today, config.total_slots)
|
datetime.now(tz=timezone.utc).date(), config.total_slots
|
||||||
yield
|
)
|
||||||
# Shutdown: connection-per-call adapters hold no pooled state to close.
|
execution.reconcile() # restart recovery before serving (REQ-014)
|
||||||
|
worker = threading.Thread(target=_loop, name="trex-execution", daemon=True)
|
||||||
|
worker.start()
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
stop.set()
|
||||||
|
worker.join(timeout=config.execution_interval_sec * 3)
|
||||||
|
|
||||||
base.router.lifespan_context = lifespan
|
base.router.lifespan_context = lifespan
|
||||||
return base
|
return base
|
||||||
|
|||||||
Reference in New Issue
Block a user