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:
2026-06-04 17:46:17 +09:00
co-authored by Claude Opus 4.8
parent d60d720bc7
commit 97d58318db
29 changed files with 1456 additions and 35 deletions
@@ -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·연결 풀링은 후속 인계.
+1
View File
@@ -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/) |
+1
View File
@@ -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).
+1
View File
@@ -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).
+13 -9
View File
@@ -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).
+11
View File
@@ -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 재참조로 충분.
+7 -8
View File
@@ -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)
View File
+77
View File
@@ -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., 장 미개장/잔고부족)
+50
View File
@@ -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"
+90
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
"""Broker adapters (REQ-011/012). Implement application.broker_port.BrokerPort."""
+189
View File
@@ -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
+84
View File
@@ -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:
+64
View File
@@ -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."""
...
+125
View File
@@ -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] = {}
+29 -1
View File
@@ -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,
) )
+43 -10
View File
@@ -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
)
execution.reconcile() # restart recovery before serving (REQ-014)
worker = threading.Thread(target=_loop, name="trex-execution", daemon=True)
worker.start()
try:
yield yield
# Shutdown: connection-per-call adapters hold no pooled state to close. 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