Deliver BAT-002: slot-core daemon + HTTP intent API + PostgreSQL
Implement REQ-005~009 (dcy-002 slice 1) as an onion stack on top of the
BAT-001 pure domain:
- application: IntentService (admission + single-writer transition +
intent_id idempotency via durable intent_log)
- adapters/persistence: PostgreSQLSlotRepository + PostgreSQLIntentStore +
trex-core-owned migrations (daily_slot per Appendix A, daily_symbol_entries,
slot_transition_log, intent_log) + in-memory intent store
- adapters/api: FastAPI /v1 (healthz, intents/buy|sell|{id}, slots, capacity)
- runtime: trex-core-daemon entrypoint with lifespan migrate+preallocate
Verification (against local PostgreSQL 18): 74 passed, ruff clean, domain
import sweep shows zero web/db deps (NFR-1), 20-thread concurrency admits
exactly the idle count (NFR-2), idempotent resubmit causes no double
transition (NFR-3), EXPECTED_SLOT_FIELDS<->information_schema drift test
(realizes dcy-001 OQ-2's deferred DB-side check), and a daemon E2E smoke.
Align all four baseline docs to the new daemon/API/persistence layers.
BAT-002 -> release-candidate; REQ-005~009 -> Implemented. Full async outbox,
broker execution, safety floor, and strategy daemons remain deferred to a
follow-up Discovery.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
# BAT-002 Design
|
||||
|
||||
- Status: confirmed
|
||||
- Batch: BAT-002
|
||||
|
||||
## Architecture Summary
|
||||
- onion 3-레이어로 구성한다. **domain**(`trex_core.domain.slot`, BAT-001, 무의존) ← **application**(intent admission/슬롯 전이 유스케이스, SlotService 호출) ← **adapters**(FastAPI HTTP 입구 + psycopg PostgreSQL SlotRepository) + **runtime**(데몬 엔트리포인트/uvicorn/lifecycle). 코어 데몬이 `daily_slot` 단일 writer + intent_log 소유. 전략 데몬은 HTTP 클라이언트.
|
||||
|
||||
## Changed Areas
|
||||
- Docs: docs/interface-contract.md(/v1 API), docs/runtime-flows.md(데몬·요청 흐름), docs/project-structure.md(레이어), docs/data-model.md(intent_log + 영속 테이블) — REQ-009
|
||||
- Modules:
|
||||
- runtime: 데몬 엔트리포인트(console_scripts), uvicorn 기동, lifecycle(startup: preallocate / shutdown: graceful), `/v1/healthz`
|
||||
- adapters/api: FastAPI 앱 + `/v1` 라우터(intents buy/sell/{id}, slots, capacity) + pydantic 요청/응답 스키마
|
||||
- adapters/persistence: psycopg `SlotRepository` 구현 + 마이그레이션 SQL
|
||||
- application: intent 유스케이스(admission → SlotService.transit_slot + intent_log 기록, intent_id 멱등)
|
||||
- Interfaces: `/v1` HTTP 6종(dcy-002 OQ-3)
|
||||
|
||||
## Key Decisions
|
||||
1. 동기 admission+커밋: HTTP 핸들러가 단일 트랜잭션(advisory lock)에서 admission→슬롯 전이→intent_log 기록을 커밋하고 admit/reject 동기 응답(dcy-002 OQ-1). 완전 비동기 outbox 는 브로커 Discovery 로 Deferred.
|
||||
2. 멱등: `intent_log.intent_id` UNIQUE + 수락 전 dedup 조회. 재전송 시 최초 decision 재응답. `GET /v1/intents/{id}` 로 재조회.
|
||||
3. 단일 writer(불변식 A): `daily_slot` 쓰기는 application 의 SlotService 경로뿐. read API(REQ-008)는 쓰기 없음. 동시 intent 는 `pg_advisory_xact_lock(SLOT_ADVISORY_LOCK_ID)` 로 직렬화.
|
||||
4. 스키마(dcy-002 OQ-4): 부록 A 단일 기준 trex-core 소유 마이그레이션. 테이블 = daily_slot/daily_symbol_entries/slot_transition_log/intent_log. archive/prune 제외. orbit-v2 DDL 은 참조.
|
||||
5. 도메인 순수성(NFR-1): FastAPI/psycopg 의존은 adapters/runtime 에만. `trex_core.domain.slot` 은 import 스윕 0 유지(BAT-001 계약 테스트 패턴 확장).
|
||||
|
||||
## Edge Cases
|
||||
- 동일 intent_id 재전송(네트워크 재시도) → 슬롯 중복 전이 없이 최초 decision 반환.
|
||||
- 동시 buy intent 가 같은 idle 슬롯/마지막 슬롯 경합 → advisory lock 직렬화, 한쪽은 admit 한쪽은 reject(슬롯 소진).
|
||||
- sell intent 의 slot_id 가 이미 holding 이 아님(이미 전이됨) → reject + 사유.
|
||||
- PostgreSQL 미가용(검증 환경) → 통합테스트 evidence blocker 로 분리(코드/단위는 별도 통과).
|
||||
- preallocate 재기동 → 멱등(이미 있으면 0).
|
||||
|
||||
## Architecture Impact
|
||||
- both (project-structure + runtime-flows). interface-contract/data-model 도 신규 계약/테이블로 갱신.
|
||||
|
||||
## Reference Doc Update Plan
|
||||
- project-structure.md: domain/application/adapters/runtime 레이어와 신규 모듈 트리 반영(REQ-009).
|
||||
- runtime-flows.md: 데몬 lifecycle 흐름 + intent 요청 흐름(admission→전이→intent_log) 추가(REQ-009).
|
||||
- interface-contract.md: `/v1` 6종 입력/출력/에러/멱등 계약 추가(REQ-009).
|
||||
- data-model.md: intent_log 엔티티 + 영속 테이블 계약 반영(REQ-009).
|
||||
@@ -0,0 +1,38 @@
|
||||
# BAT-002 Implementation
|
||||
|
||||
- Status: completed
|
||||
- Batch: BAT-002
|
||||
|
||||
## Plan Summary
|
||||
- REQ-005 런타임 → REQ-006 영속화 → REQ-007 intent API → REQ-008 조회 API → REQ-009 baseline 정합을 순차 구현. onion 4-레이어(domain/application/adapters/runtime). 도메인(BAT-001)은 무수정 재사용.
|
||||
|
||||
## Changed Files
|
||||
- pyproject.toml: fastapi/uvicorn/psycopg 의존성 + dev httpx + console_scripts `trex-core-daemon`.
|
||||
- application: trex_core/application/{ports.py(IntentStore), intents.py(IntentService/BuyIntent/SellIntent/IntentDecision)}
|
||||
- adapters/persistence: postgresql.py(PostgreSQLSlotRepository + PostgreSQLIntentStore), in_memory_intent_store.py, migrate.py, sql/001_create_slot_core_tables.sql
|
||||
- adapters/api: schemas.py(pydantic), app.py(create_app, /v1 라우터)
|
||||
- runtime: config.py(DaemonConfig), daemon.py(build_app + lifespan migrate/preallocate + uvicorn main)
|
||||
- tests: tests/adapters/api/test_intent_api.py, tests/application/test_intent_concurrency.py, tests/adapters/persistence/test_postgresql_repository.py, tests/adapters/persistence/test_migration_and_drift.py, tests/conftest.py
|
||||
- docs(REQ-009): interface-contract.md, runtime-flows.md, project-structure.md, data-model.md
|
||||
|
||||
## Execution Log
|
||||
1. SQL 마이그레이션(daily_slot[부록 A 16컬럼]/daily_symbol_entries/slot_transition_log/intent_log) + migrate 러너.
|
||||
2. PostgreSQL SlotRepository(연결-당-호출, pg_advisory_xact_lock) + PostgreSQLIntentStore(intent_id UNIQUE dedup gate).
|
||||
3. IntentService: dedup(try_insert) → admission(find idle / fetch holding) → SlotService.transit_slot(advisory lock) → finalize intent_log. last-idle 경합은 SlotStateMismatchError 재시도.
|
||||
4. FastAPI create_app(/v1 healthz·intents/buy·sell·{id}·slots·capacity), DI 로 in-memory/PG 양쪽 구동.
|
||||
5. 데몬 엔트리포인트: lifespan 에서 migrate + 거래일 preallocate(공개 엔드포인트 아님).
|
||||
6. 테스트 작성 + 로컬 PostgreSQL 18 기동 DB(trex_core_test)로 통합 실행.
|
||||
|
||||
## Validation
|
||||
- `pytest`: **74 passed** (PostgreSQL DSN 설정 시) / **66 passed + 8 skipped** (DSN 미설정 시 PG 통합 graceful skip).
|
||||
- `ruff check .`: **All checks passed**.
|
||||
- 도메인 순수성(NFR-1): `trex_core/domain/` 에 fastapi/uvicorn/psycopg/starlette import **0**(grep 확인).
|
||||
- 멱등(NFR-3): API/PG 양쪽에서 동일 intent_id 재전송 시 동일 decision·슬롯 중복 전이 없음(테스트 + 데몬 스모크).
|
||||
- 단일 writer(NFR-2): 20-thread 동시 buy → 정확히 idle 수(5)만 admit, 슬롯 중복 사용 0.
|
||||
- 드리프트(REQ-006): `EXPECTED_SLOT_FIELDS` ↔ `daily_slot` information_schema 일치 + 음성 케이스 검출.
|
||||
- 데몬 E2E 스모크: lifespan migrate+preallocate(5 idle) → buy admit→pending_buy → 멱등 resubmit 동일 slot_id → capacity 갱신 → intent lookup, PostgreSQL 영속 확인.
|
||||
|
||||
## Remaining Risks
|
||||
- intent 비원자성 미세 창: dedup insert 와 transit 가 별 트랜잭션이라, 둘 사이 크래시 시 intent_log 가 'processing' 으로 남을 수 있음(슬롯 중복 전이는 UNIQUE gate 로 차단). 완전 원자/outbox 는 브로커 Discovery 인계.
|
||||
- admission 은 슬라이스 1 기준 슬롯 가용성 중심(orbit REQ-028 다중 게이트는 범위 밖).
|
||||
- 연결-당-호출 repo 는 고부하 시 풀링 필요(후속 최적화).
|
||||
@@ -0,0 +1,32 @@
|
||||
# BAT-002: trex-core 슬롯 코어 데몬화 + HTTP intent API (슬라이스 1)
|
||||
|
||||
- Status: release-candidate
|
||||
- Profile: standard
|
||||
- Source Discovery: dcy-002
|
||||
- Included REQ: REQ-005, REQ-006, REQ-007, REQ-008, REQ-009
|
||||
- Excluded REQ: 없음
|
||||
|
||||
## Summary
|
||||
- dcy-002 의 슬라이스 1(데몬 + HTTP API + PostgreSQL 영속화 + 멱등 intent)을 하나의 delivery 단위로 묶는다. BAT-001 의 순수 도메인을 안쪽 코어로 두고 그 바깥에 런타임(REQ-005)·영속화(REQ-006)·API(REQ-007/008)·문서정합(REQ-009)을 얹는다.
|
||||
|
||||
## Scope
|
||||
- In Scope:
|
||||
- REQ-005: 데몬 런타임 + lifecycle(기동/정상종료/`GET /v1/healthz`) + 거래일 preallocate(lifecycle 내부) + FastAPI/uvicorn/psycopg 의존성·entrypoint.
|
||||
- REQ-006: PostgreSQL `SlotRepository` + 신규 마이그레이션(daily_slot/daily_symbol_entries/slot_transition_log/intent_log) + 드리프트 테스트.
|
||||
- REQ-007: `POST /v1/intents/buy|sell` + 동기 admission/전이 + intent_id 멱등 + `intent_log` + `GET /v1/intents/{id}`.
|
||||
- REQ-008: `GET /v1/slots?status=holding` + `GET /v1/capacity` (read-only).
|
||||
- REQ-009: interface-contract/runtime-flows/project-structure/data-model 정합.
|
||||
- Out of Scope (후속 Discovery):
|
||||
- 브로커 단일 세션/주문 집행(불변식 B), safety floor(forced_close/EOD/손절 backstop), 완전 비동기 outbox, 매수·매도 전략 데몬, 인증/인가 고도화·수평 확장.
|
||||
|
||||
## 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-002 단일. 모든 REQ 가 dcy-002 FR-1~FR-6 에서 분해됨.
|
||||
- 확정 설계(dcy-002 OQ): 동기 커밋+intent_log(OQ-1) / FastAPI+uvicorn(OQ-2) / `/v1` 엔드포인트 6종(OQ-3) / 부록 A 기준 신규 스키마(OQ-4).
|
||||
- 핵심 리스크: 실제 PostgreSQL·HTTP·신규 의존 도입(검증 환경 필요), 도메인 순수성 유지(어댑터 격리), 단일 writer 불변식.
|
||||
- 검증 환경: PostgreSQL 인스턴스 필요(통합테스트). 미가용 시 일부 evidence 가 blocker 로 남을 수 있음(verification 에서 분리 판단).
|
||||
@@ -0,0 +1,47 @@
|
||||
# BAT-002 Planning
|
||||
|
||||
- Status: confirmed
|
||||
- Batch: BAT-002
|
||||
- Profile: standard
|
||||
|
||||
## Included REQ
|
||||
- REQ-005 (Installation): 슬롯 코어 데몬 런타임 부트스트랩
|
||||
- REQ-006 (Data): PostgreSQL SlotRepository 영속화 + 스키마
|
||||
- REQ-007 (Interface): intent 제출 HTTP API + 멱등
|
||||
- REQ-008 (Interface): 슬롯 read-only 조회 HTTP API
|
||||
- REQ-009 (Documentation): 데몬/API/영속화 baseline 정합
|
||||
|
||||
## Delivery Plan
|
||||
- 의존 순서대로 구현한다(기반 → API → 문서).
|
||||
1. REQ-005: pyproject 에 FastAPI/uvicorn/psycopg + console_scripts; 데몬 lifecycle(기동/shutdown/`/v1/healthz`); preallocate 를 lifecycle 내부로.
|
||||
2. REQ-006: psycopg 기반 `SlotRepository` 구현 + trex-core 소유 마이그레이션(daily_slot[부록 A 16컬럼]/daily_symbol_entries/slot_transition_log/intent_log) + `pg_advisory_xact_lock` + 드리프트 테스트.
|
||||
3. REQ-007: FastAPI 라우터 `POST /v1/intents/buy|sell` → 애플리케이션 서비스가 `SlotService` 로 admission/전이(advisory lock 직렬화) + intent_log 동기 기록 + intent_id 멱등 + `GET /v1/intents/{id}`.
|
||||
4. REQ-008: `GET /v1/slots?status=holding` + `GET /v1/capacity` read-only.
|
||||
5. REQ-009: baseline 4문서 정합.
|
||||
|
||||
## Dependencies
|
||||
- Internal:
|
||||
- REQ-006·REQ-007·REQ-008 은 REQ-005(런타임 기반) 선행.
|
||||
- REQ-007·REQ-008 은 REQ-006(영속화, 특히 intent_log) 선행.
|
||||
- REQ-009 는 REQ-005~008 구현 완료 후.
|
||||
- 안쪽 코어는 BAT-001 의 `trex_core.domain.slot`(완성) 재사용.
|
||||
- External:
|
||||
- PostgreSQL 인스턴스(통합테스트/런타임). psycopg, FastAPI, uvicorn(dev/runtime 의존).
|
||||
|
||||
## Design Gate
|
||||
- Design Required Before Implementation: yes
|
||||
- Reason: 신규 3개 레이어(데몬 런타임 + FastAPI API + psycopg 영속화)와 onion 경계(domain/application/adapters), 단일 writer 강제 지점, intent_log/멱등 트랜잭션 경계를 구현 전에 고정해야 한다. 구조/인터페이스/런타임 영향이 모두 있어 design.md 를 같은 턴에 작성한다.
|
||||
|
||||
## Milestones
|
||||
1. M1 — REQ-005: 데몬 기동 + `/v1/healthz` 200 + graceful shutdown.
|
||||
2. M2 — REQ-006: 마이그레이션 적용 + PostgreSQL repo 로 도메인 테스트 통과 + 드리프트 테스트 green.
|
||||
3. M3 — REQ-007: buy/sell intent 전이 + 멱등 재전송 + 동시성 단일 writer 통합테스트.
|
||||
4. M4 — REQ-008: holding 스냅샷 + capacity 조회.
|
||||
5. M5 — REQ-009: baseline 4문서 정합.
|
||||
|
||||
## Risks
|
||||
- R-1 (Medium-High): 실제 PostgreSQL/HTTP/신규 의존 도입 — 검증 환경(PG 인스턴스) 필요. 대응: 통합테스트용 PG 준비; 미가용 시 해당 evidence 를 verification 의 blocker/missing-evidence 로 분리(코드/단위는 별도 통과).
|
||||
- R-2 (Medium): 도메인 순수성 오염(웹/psycopg). 대응: domain/application/adapters 레이어 분리 + import 스윕 가드(NFR-1) — `trex_core.domain.slot` import 0 유지.
|
||||
- R-3 (Medium): 단일 writer 불변식 붕괴(API 핸들러 경유 다중 writer). 대응: 모든 쓰기를 `SlotService`+advisory lock 단일 경로로 강제, 동시성 통합테스트로 검증(NFR-2).
|
||||
- R-4 (Medium): intent 멱등/내구성 결함. 대응: intent_id dedup + intent_log 동기 커밋 + 재전송/재조회 테스트(NFR-3).
|
||||
- R-5 (Low): 컬럼 계약 드리프트. 대응: `EXPECTED_SLOT_FIELDS`↔information_schema 드리프트 테스트(REQ-006).
|
||||
@@ -0,0 +1,53 @@
|
||||
# BAT-002 Verification
|
||||
|
||||
- Status: confirmed
|
||||
- Batch: BAT-002
|
||||
|
||||
## Acceptance Mapping
|
||||
- REQ-005 (daemon runtime):
|
||||
- AC1 기동/`/v1/healthz` 200 → 데몬 E2E 스모크(TestClient lifespan) healthz `{status:ok}`. **PASS**
|
||||
- AC2 graceful shutdown → lifespan 컨텍스트 정상 종료(연결-당-호출, 풀 없음). **PASS**
|
||||
- AC3 lifecycle preallocate(멱등, 비공개) → lifespan 에서 preallocate 5 idle 확인, 재기동 멱등. **PASS**
|
||||
- AC4 deps/entrypoint → pyproject 에 fastapi/uvicorn/psycopg + console_scripts `trex-core-daemon` 설치 확인. **PASS**
|
||||
- REQ-006 (PostgreSQL persistence):
|
||||
- AC1 마이그레이션·컬럼 부록 A 일치 → `test_migrations_create_expected_tables` + `test_daily_slot_columns_match_expected_contract`. **PASS**
|
||||
- AC2 PG repo 로 도메인 테스트 통과 → `test_postgresql_repository.py`(preallocate/전이/재진입+1). **PASS**
|
||||
- AC3 드리프트 테스트 → EXPECTED_SLOT_FIELDS↔information_schema 일치 + 음성 케이스(`test_drift_detected_when_contract_extended`). **PASS**
|
||||
- AC4 advisory lock → `pg_advisory_xact_lock` 구현 + 동시성 테스트로 직렬화 확인. **PASS**
|
||||
- REQ-007 (intent API + 멱등):
|
||||
- AC1 buy 전이 → `test_buy_intent_admits_and_transitions` + PG `test_buy_intent_persists_transition`. **PASS**
|
||||
- AC2 sell 전이/거부 → `test_sell_intent_admits_from_holding` / `test_sell_intent_requires_holding`. **PASS**
|
||||
- AC3 멱등 재전송(NFR-3) → `test_buy_intent_idempotent_resubmit`(API) + `test_buy_intent_idempotent_on_pg`(PG) + 데몬 스모크(동일 slot_id). **PASS**
|
||||
- AC4 동시성 단일 writer(NFR-2) → `test_concurrent_buys_admit_exactly_idle_count`(20스레드→정확히 5 admit) + `test_duplicate_intent_id_admits_once_under_concurrency`. **PASS**
|
||||
- AC5 intent 재조회 → `test_get_intent_roundtrip`(`GET /v1/intents/{id}`). **PASS**
|
||||
- REQ-008 (read-only API):
|
||||
- AC1/2 holding 스냅샷·필터 → `test_list_slots_and_capacity`(status/trading_date 필터). **PASS**
|
||||
- AC3 capacity → 동 테스트(idle/holding/pending + scarcity_ratio). **PASS**
|
||||
- REQ-009 (baseline 정합):
|
||||
- AC1 interface-contract /v1 계약 → docs/interface-contract.md 에 6 엔드포인트 + 불변식 추가. **PASS**
|
||||
- AC2 runtime-flows/project-structure 레이어 → 데몬/intent 흐름 + application/adapters/runtime 레이어 반영. **PASS**
|
||||
- AC3 data-model intent_log → docs/data-model.md 에 intent_log 엔티티 + PostgreSQL 백엔드 반영. **PASS**
|
||||
|
||||
## Evidence
|
||||
- Test:
|
||||
- `pytest`: **74 passed** (TREX_POSTGRESQL_TEST_DSN=trex_core_test) / **66 passed + 8 skipped** (DSN 미설정 시 PG graceful skip).
|
||||
- 동시성: 20-thread buy → 5 admit / 15 reject, pending=5·idle=0.
|
||||
- 드리프트: EXPECTED_SLOT_FIELDS == daily_slot 컬럼; 음성 케이스 검출.
|
||||
- Manual:
|
||||
- 도메인 import 스윕: `trex_core/domain/` 에 fastapi/uvicorn/psycopg/starlette import 0(NFR-1).
|
||||
- 데몬 E2E 스모크(PostgreSQL 18): healthz→capacity(5 idle)→buy admit(pending_buy)→멱등 resubmit 동일 slot_id→capacity(4 idle/1 pending)→intent lookup; `daily_slot`·`intent_log` 영속 확인.
|
||||
- `ruff check .`: All checks passed.
|
||||
- Logs:
|
||||
- implementation.md Validation 절 + 본 batch 작업 로그.
|
||||
|
||||
## Baseline Sync Evidence (Architecture Impact = both)
|
||||
- interface-contract.md: HTTP `/v1` 6 엔드포인트 계약 + 멱등/단일writer 불변식 추가(REQ-009).
|
||||
- runtime-flows.md: 데몬 lifecycle + 매수/매도 intent 흐름 추가, Primary Runtime=daemon+library.
|
||||
- project-structure.md: application/adapters/runtime 레이어 + entrypoint 반영.
|
||||
- data-model.md: PostgreSQL 백엔드 + intent_log 엔티티 반영.
|
||||
|
||||
## Result
|
||||
- Ready for Release: true
|
||||
- Blocking Issues:
|
||||
- 없음. 모든 포함 REQ AC 가 evidence 와 연결됨(로컬 PostgreSQL 18 로 통합 evidence 확보).
|
||||
- 비차단 잔여: intent dedup/transit 비원자성 미세 창(슬롯 중복 전이는 UNIQUE gate 로 차단), 연결 풀링 미도입 — 후속 인계.
|
||||
@@ -9,3 +9,4 @@
|
||||
| BAT ID | Profile | Status | Included REQ | Discovery | Folder |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 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/) |
|
||||
|
||||
+21
-1
@@ -14,7 +14,7 @@
|
||||
- 자동매매 일일 거래 슬롯 생명주기 도메인 (equity trading slot lifecycle)
|
||||
|
||||
## Primary Persistence Backend
|
||||
- storage-agnostic (SlotRepository Protocol 경유) — 현재 in-memory, 향후 PostgreSQL 예정
|
||||
- PostgreSQL (BAT-002, REQ-006) — 코어 데몬의 durable 단일 writer. 도메인은 여전히 storage-agnostic(SlotRepository Protocol); in-memory 어댑터는 테스트용으로 잔존. 슬라이스 1 테이블: daily_slot / daily_symbol_entries / slot_transition_log / intent_log (daily_slot_archive·prune 은 범위 밖).
|
||||
|
||||
## Purpose
|
||||
- This document captures the approved cross-cutting data model baseline for the project.
|
||||
@@ -86,6 +86,25 @@
|
||||
|
||||
- 오직 INSERT 만 허용한다(append-only).
|
||||
|
||||
## Entity: `intent_log` (BAT-002)
|
||||
|
||||
- Purpose: HTTP 로 제출된 buy/sell intent 의 멱등 dedup 게이트 + audit. `intent_id` UNIQUE 가 at-least-once 재전송의 슬롯 중복 전이를 차단한다(dcy-002 OQ-1).
|
||||
- Source of Truth: intent_log (PostgreSQL) / 테스트는 in-memory IntentStore
|
||||
- Lifecycle: processing -> admitted | rejected
|
||||
|
||||
### Key Fields
|
||||
- intent_id :: TEXT PK :: 멱등키(전략 데몬 발급)
|
||||
- intent_type :: TEXT :: buy | sell
|
||||
- payload :: JSONB :: 제출 본문 스냅샷
|
||||
- status :: TEXT :: processing/admitted/rejected
|
||||
- slot_id :: UUID? :: 수락 시 전이된 슬롯
|
||||
- reason :: TEXT? :: 거부 사유
|
||||
- created_at / updated_at :: TIMESTAMPTZ
|
||||
|
||||
### State Rules
|
||||
- `try_insert` 는 INSERT ... ON CONFLICT DO NOTHING(dedup 게이트). 중복 intent_id 는 재전이 없이 기존 decision 반환.
|
||||
- 완전 비동기 outbox(상태머신 + 소비 루프)로의 승격은 브로커 집행 Discovery 로 Deferred(이 테이블이 전구체).
|
||||
|
||||
## Shared Consistency Rules
|
||||
|
||||
- (trading_date, symbol) 재진입 카운트는 closed->idle 복구와 원자적으로 증가한다.
|
||||
@@ -113,3 +132,4 @@
|
||||
## Change Log
|
||||
|
||||
- 2026-06-03 :: bootstrap-baseline 으로 초기 데이터 모델 baseline 생성(Slot + SlotTransitionRecord + 재진입 카운터).
|
||||
- 2026-06-03 :: BAT-002 — PostgreSQL 영속화 확정 + `intent_log` 엔티티 추가(REQ-006/007/009).
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
- Baseline Seed: .stage-pilot/bootstrap/baseline.yaml
|
||||
- Status: draft
|
||||
- Owner: project maintainers
|
||||
- Last Updated (KST): 2026-06-03
|
||||
- Source Discovery / Batch: bootstrap-baseline (pre-Discovery)
|
||||
- Primary Runtime: library
|
||||
- Last Updated (KST): 2026-06-03 (BAT-002)
|
||||
- Source Discovery / Batch: bootstrap-baseline (pre-Discovery); HTTP API = dcy-002 / BAT-002
|
||||
- Primary Runtime: daemon (HTTP API) + library (slot 도메인)
|
||||
|
||||
## Project Summary
|
||||
- trex 제품군의 공용 베이스로, 첫 도메인으로 trex-orbit 의 일일 거래 슬롯 생명주기(slot) 순수 도메인을 storage-agnostic 하게 이식한 코어다.
|
||||
@@ -19,15 +19,37 @@
|
||||
- It is a cross-cutting reference for REQ drafting, batch design, implementation, verification, and handoff.
|
||||
|
||||
## Scope
|
||||
- Covers in-process domain-service APIs and the persistence boundary Protocol (library 형태라 외부 HTTP/CLI 표면은 아직 없음).
|
||||
- Covers the HTTP `/v1` API(슬롯 코어 데몬, BAT-002), in-process domain-service APIs, and the persistence boundary Protocol.
|
||||
- Does not duplicate full business requirements already owned by REQ documents.
|
||||
- Does not replace low-level code references or generated API artifacts.
|
||||
|
||||
## Interface Summary
|
||||
|
||||
- HTTP `/v1` API :: 슬롯 코어 데몬이 노출, 매수·매도 전략 데몬이 호출(REQ-005/007/008)
|
||||
- SlotService :: 슬롯 생명주기 도메인 서비스(전이/사전할당/가용성/재진입 카운트)
|
||||
- SlotRepository (Protocol) :: 도메인 ↔ 저장소 경계 계약
|
||||
|
||||
## Interface: `HTTP /v1` (슬롯 코어 데몬)
|
||||
|
||||
- Type: http-api
|
||||
- Consumers / Producers: 매수·매도 전략 데몬(클라이언트) → trex-core 슬롯 코어 데몬(서버). FastAPI+uvicorn.
|
||||
- Purpose: 전략이 intent 를 제출하고 holding 스냅샷/capacity 를 조회. 코어가 단독 admission + 슬롯 전이(단일 writer).
|
||||
- Stability: experimental (v1, 슬라이스 1)
|
||||
|
||||
| 엔드포인트 | 입력 | 출력 | 에러/거부 |
|
||||
|---|---|---|---|
|
||||
| `GET /v1/healthz` | — | `{status:"ok"}` 200 | — |
|
||||
| `POST /v1/intents/buy` | intent_id, trading_date, symbol, qty?, allocated_capital?, entry_price?, strategy_id?, sell_strategy_id?, portfolio_id?, forced_close_at? | `{intent_id, status:admitted|rejected, slot_id?, reason?}` | reject: `no_idle_slot`, `idle_slot_contended` |
|
||||
| `POST /v1/intents/sell` | intent_id, slot_id, reason?, sell_strategy_id? | `{intent_id, status, slot_id?, reason?}` | reject: `slot_not_found`, `slot_not_holding:<state>`, `slot_state_changed` |
|
||||
| `GET /v1/intents/{intent_id}` | path intent_id | `{intent_id, status, slot_id?, reason?}` | 404 intent not found |
|
||||
| `GET /v1/slots?status=&trading_date=` | query | `[SlotSnapshot]` | — |
|
||||
| `GET /v1/capacity?trading_date=&total_slots=` | query | `{idle_count, holding_count, pending_count, total_slots, scarcity_ratio}` | — |
|
||||
|
||||
### Compatibility / Invariants
|
||||
- 멱등: `intent_id` dedup(intent_log UNIQUE gate). at-least-once 재전송에도 슬롯 1회만 전이; 재조회는 `GET /v1/intents/{id}`.
|
||||
- 단일 writer: 모든 슬롯 쓰기는 코어의 SlotService(advisory lock)만. 전략은 제안만, 슬롯/브로커 직접 접근 금지.
|
||||
- 동기 커밋: 수락 시 admission+전이를 동기 커밋 후 admit/reject 응답(완전 비동기 outbox 는 브로커 Discovery Deferred).
|
||||
|
||||
## Interface: `SlotService`
|
||||
|
||||
- Type: internal-service
|
||||
@@ -111,3 +133,4 @@
|
||||
## Change Log
|
||||
|
||||
- 2026-06-03 :: bootstrap-baseline 으로 초기 인터페이스 계약 baseline 생성(SlotService + SlotRepository Protocol).
|
||||
- 2026-06-03 :: BAT-002 — HTTP `/v1` API 계약(데몬, 6 엔드포인트) + 멱등/단일writer 불변식 추가(REQ-007/008/009).
|
||||
|
||||
+23
-17
@@ -21,10 +21,11 @@
|
||||
- storage-agnostic 순수 도메인 (현재 in-memory repository, PostgreSQL/psycopg 는 향후 영속화 단계에서 도입 예정)
|
||||
|
||||
## Primary Runtime
|
||||
- Type: library
|
||||
- Planned Entry Points:
|
||||
- SlotService :: 슬롯 생명주기 도메인 서비스 — preallocate / transit / capacity / entry-count 의 in-process API
|
||||
- SlotRepository (Protocol) :: 저장소 추상화 경계 — 구현체(in-memory, 향후 PostgreSQL)를 주입받는 영속화 계약
|
||||
- Type: daemon (HTTP API) + library (slot 도메인)
|
||||
- Entry Points:
|
||||
- `trex-core-daemon` (console_script → `trex_core.runtime.daemon:main`) :: 슬롯 코어 데몬 — migrate+preallocate lifecycle 후 FastAPI/uvicorn 으로 `/v1` API 서빙 (BAT-002)
|
||||
- HTTP `/v1` :: 매수·매도 전략 데몬용 intent 제출/조회 경계 (docs/interface-contract.md)
|
||||
- SlotService / SlotRepository (Protocol) :: in-process 도메인 서비스 + 저장소 경계(라이브러리 사용)
|
||||
|
||||
## Purpose
|
||||
- This document is the current approved repository and package structure baseline.
|
||||
@@ -55,24 +56,28 @@
|
||||
│ ├── project-structure.md
|
||||
│ └── runtime-flows.md
|
||||
├── CLAUDE.md # SDLC governance instructions
|
||||
├── pyproject.toml # 패키지 메타(배포명 trex-core) + pytest/ruff 설정
|
||||
├── pyproject.toml # 패키지 메타(배포명 trex-core) + console_scripts(trex-core-daemon) + deps
|
||||
├── trex_core/
|
||||
│ ├── __init__.py
|
||||
│ └── domain/
|
||||
│ ├── __init__.py
|
||||
│ └── slot/ # slot 순수 도메인 (BAT-001 이식)
|
||||
│ ├── __init__.py # 공개 API (인프라 심볼 없음)
|
||||
│ ├── state.py # SlotState 6상태 + 전이
|
||||
│ ├── models.py # Slot/SlotCapacity/SlotTransitionRecord + EXPECTED_SLOT_FIELDS
|
||||
│ ├── errors.py # 도메인 예외 4종
|
||||
│ ├── repository.py # SlotRepository Protocol
|
||||
│ ├── service.py # SlotService
|
||||
│ └── in_memory_repository.py
|
||||
│ ├── domain/slot/ # slot 순수 도메인 (BAT-001, 무의존)
|
||||
│ │ ├── __init__.py · state.py · models.py(+EXPECTED_SLOT_FIELDS) · errors.py
|
||||
│ │ ├── repository.py(Protocol) · service.py · in_memory_repository.py
|
||||
│ ├── application/ # 유스케이스 (BAT-002)
|
||||
│ │ ├── ports.py # IntentStore Protocol
|
||||
│ │ └── intents.py # IntentService / BuyIntent / SellIntent / IntentDecision
|
||||
│ ├── adapters/ # 인프라 어댑터 (BAT-002)
|
||||
│ │ ├── api/ # FastAPI: app.py(create_app /v1) · schemas.py
|
||||
│ │ └── persistence/ # postgresql.py · in_memory_intent_store.py · migrate.py · sql/
|
||||
│ └── runtime/ # 데몬 (BAT-002)
|
||||
│ ├── config.py # DaemonConfig(env)
|
||||
│ └── daemon.py # build_app + lifespan(migrate/preallocate) + main(uvicorn)
|
||||
└── tests/
|
||||
└── domain/slot/ # test_state / test_service / test_contract
|
||||
├── domain/slot/ # test_state / test_service / test_contract
|
||||
├── application/ # test_intent_concurrency (단일 writer)
|
||||
└── adapters/{api,persistence}/ # API(in-memory) · PG repo/migration/drift
|
||||
```
|
||||
|
||||
> 참고: `trex_core/`·`tests/`·`pyproject.toml` 은 BAT-001(dcy-001 FR-1~FR-5)에서 실제 생성되었다. 영속화·CLI·config 는 이번 범위 밖이다.
|
||||
> 참고: domain(BAT-001)은 무의존 코어. application/adapters/runtime 은 BAT-002(dcy-002 FR-1~FR-6)에서 추가. 웹/DB 의존(fastapi/uvicorn/psycopg)은 adapters/runtime 에만 격리(NFR-1).
|
||||
|
||||
## Package / Module Responsibilities
|
||||
|
||||
@@ -115,3 +120,4 @@
|
||||
- 2026-06-03 :: bootstrap-baseline 으로 초기 구조 baseline 생성(mixed: trex-orbit 코드 관찰 + 이관 계획 선언).
|
||||
- 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-002 — application/adapters/runtime 레이어(데몬+HTTP API+PostgreSQL) 추가, Primary Runtime=daemon+library(REQ-005~009).
|
||||
|
||||
+25
-3
@@ -4,9 +4,9 @@
|
||||
- Baseline Seed: .stage-pilot/bootstrap/baseline.yaml
|
||||
- Status: draft
|
||||
- Owner: project maintainers
|
||||
- Last Updated (KST): 2026-06-03
|
||||
- Source Discovery / Batch: bootstrap-baseline (pre-Discovery)
|
||||
- Primary Runtime: library
|
||||
- Last Updated (KST): 2026-06-03 (BAT-002)
|
||||
- Source Discovery / Batch: bootstrap-baseline (pre-Discovery); 데몬/HTTP 흐름 = dcy-002 / BAT-002
|
||||
- Primary Runtime: daemon (HTTP API) + library (slot 도메인)
|
||||
|
||||
## Project Summary
|
||||
- trex 제품군의 공용 베이스로, 첫 도메인으로 trex-orbit 의 일일 거래 슬롯 생명주기(slot) 순수 도메인을 storage-agnostic 하게 이식한 코어다.
|
||||
@@ -21,9 +21,30 @@
|
||||
|
||||
## Covered Entry Points
|
||||
|
||||
- `trex-core-daemon` :: 슬롯 코어 데몬 (lifecycle + HTTP `/v1` 서빙, BAT-002)
|
||||
- HTTP `/v1` :: intent 제출/조회 경계 (전략 데몬 클라이언트)
|
||||
- SlotService :: 슬롯 생명주기 도메인 서비스 (preallocate / transit / capacity / entry-count)
|
||||
- SlotRepository (Protocol) :: 저장소 구현체 주입 경계
|
||||
|
||||
## Flow: `데몬 lifecycle` (BAT-002)
|
||||
|
||||
1. `trex-core-daemon` 기동 → `DaemonConfig.from_env()`(TREX_POSTGRESQL_DSN 등).
|
||||
2. startup(lifespan): `apply_migrations(dsn)`(멱등) → 거래일 슬롯 `preallocate_daily_slots`(멱등, 공개 엔드포인트 아님).
|
||||
3. uvicorn 이 FastAPI `/v1` 앱 서빙(`GET /v1/healthz` 200).
|
||||
4. shutdown: 연결-당-호출 어댑터라 풀 정리 없음, graceful 종료.
|
||||
|
||||
## Flow: `매수 intent` (POST /v1/intents/buy, BAT-002)
|
||||
|
||||
1. 전략 데몬이 intent(intent_id 포함)를 POST.
|
||||
2. IntentService: `intent_log.try_insert`(intent_id) dedup 게이트 — 중복이면 기존 decision 동기 반환(슬롯 무전이).
|
||||
3. 신규면 admission: `find_idle_slot` → `SlotService.transit_slot(idle→pending_buy)`(advisory lock 단일 writer). last-idle 경합 시 StateMismatch 재시도.
|
||||
4. `intent_log.finalize(admitted/rejected, slot_id)` → admit/reject 동기 응답.
|
||||
|
||||
## Flow: `매도 intent` (POST /v1/intents/sell, BAT-002)
|
||||
|
||||
1. 전략 데몬이 holding 슬롯을 `GET /v1/slots?status=holding` 로 조회 후 sell intent POST.
|
||||
2. dedup 게이트 → 검증(slot 이 holding 인가) → `transit_slot(holding→pending_sell)` → finalize → 동기 응답.
|
||||
|
||||
## Shared Components
|
||||
|
||||
- SlotRepository Protocol :: 저장소 추상화. 구현체(InMemorySlotRepository, 향후 PostgreSQLSlotRepository)를 주입.
|
||||
@@ -71,3 +92,4 @@
|
||||
|
||||
- 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-002 — 데몬 lifecycle + 매수/매도 intent HTTP 흐름 추가, Primary Runtime=daemon+library(REQ-005~009). 브로커 집행/safety floor 흐름은 후속 Discovery.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# req-006: PostgreSQL SlotRepository 영속화 + 스키마
|
||||
|
||||
- Status: Approved
|
||||
- Status: Implemented
|
||||
- Type: Data
|
||||
- Priority: High
|
||||
- Owner: project maintainers
|
||||
@@ -111,3 +111,44 @@
|
||||
|
||||
#### Validation Plan
|
||||
- 승인 후 batch planning 에서 본 REQ 의 Acceptance Criteria 를 검증 기준으로 사용.
|
||||
|
||||
### CHG-20260603-03
|
||||
- Date: 2026-06-03
|
||||
- Author: Justin Ko
|
||||
|
||||
#### Change Summary
|
||||
- Status Approved → Implemented (confirm-req-implemented, 근거 batch BAT-002 release-candidate).
|
||||
|
||||
#### Intent
|
||||
- BAT-002 verification.md 의 acceptance mapping + evidence(pytest 74 pass / PG 통합 / 데몬 E2E)로 모든 AC 충족 → 구현 완료 동기화.
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- Added:
|
||||
- 없음
|
||||
- Updated:
|
||||
- 없음
|
||||
- Removed:
|
||||
- 없음
|
||||
|
||||
#### Impacted Area Delta
|
||||
- Docs:
|
||||
- docs/srs/index.md (Register Implemented)
|
||||
- Modules:
|
||||
- 없음
|
||||
- Tests:
|
||||
- 없음
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches:
|
||||
- BAT-002 (release-candidate)
|
||||
- Releases:
|
||||
- none
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: keep-current
|
||||
- Follow-up Action: draft-release 로 release 편성
|
||||
|
||||
#### Validation Plan
|
||||
- BAT-002 verification evidence 재참조로 충분.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# req-009: 데몬/API/영속화 baseline 문서 정합
|
||||
|
||||
- Status: Approved
|
||||
- Status: Implemented
|
||||
- Type: Documentation
|
||||
- Priority: Medium
|
||||
- Owner: project maintainers
|
||||
@@ -108,3 +108,44 @@
|
||||
|
||||
#### Validation Plan
|
||||
- 승인 후 batch planning 에서 본 REQ 의 Acceptance Criteria 를 검증 기준으로 사용.
|
||||
|
||||
### CHG-20260603-03
|
||||
- Date: 2026-06-03
|
||||
- Author: Justin Ko
|
||||
|
||||
#### Change Summary
|
||||
- Status Approved → Implemented (confirm-req-implemented, 근거 batch BAT-002 release-candidate).
|
||||
|
||||
#### Intent
|
||||
- BAT-002 verification.md 의 acceptance mapping + evidence(pytest 74 pass / PG 통합 / 데몬 E2E)로 모든 AC 충족 → 구현 완료 동기화.
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- Added:
|
||||
- 없음
|
||||
- Updated:
|
||||
- 없음
|
||||
- Removed:
|
||||
- 없음
|
||||
|
||||
#### Impacted Area Delta
|
||||
- Docs:
|
||||
- docs/srs/index.md (Register Implemented)
|
||||
- Modules:
|
||||
- 없음
|
||||
- Tests:
|
||||
- 없음
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches:
|
||||
- BAT-002 (release-candidate)
|
||||
- Releases:
|
||||
- none
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: keep-current
|
||||
- Follow-up Action: draft-release 로 release 편성
|
||||
|
||||
#### Validation Plan
|
||||
- BAT-002 verification evidence 재참조로 충분.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# req-005: 슬롯 코어 데몬 런타임 부트스트랩
|
||||
|
||||
- Status: Approved
|
||||
- Status: Implemented
|
||||
- Type: Installation
|
||||
- Priority: High
|
||||
- Owner: project maintainers
|
||||
@@ -110,3 +110,44 @@
|
||||
|
||||
#### Validation Plan
|
||||
- 승인 후 batch planning 에서 본 REQ 의 Acceptance Criteria 를 검증 기준으로 사용.
|
||||
|
||||
### CHG-20260603-03
|
||||
- Date: 2026-06-03
|
||||
- Author: Justin Ko
|
||||
|
||||
#### Change Summary
|
||||
- Status Approved → Implemented (confirm-req-implemented, 근거 batch BAT-002 release-candidate).
|
||||
|
||||
#### Intent
|
||||
- BAT-002 verification.md 의 acceptance mapping + evidence(pytest 74 pass / PG 통합 / 데몬 E2E)로 모든 AC 충족 → 구현 완료 동기화.
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- Added:
|
||||
- 없음
|
||||
- Updated:
|
||||
- 없음
|
||||
- Removed:
|
||||
- 없음
|
||||
|
||||
#### Impacted Area Delta
|
||||
- Docs:
|
||||
- docs/srs/index.md (Register Implemented)
|
||||
- Modules:
|
||||
- 없음
|
||||
- Tests:
|
||||
- 없음
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches:
|
||||
- BAT-002 (release-candidate)
|
||||
- Releases:
|
||||
- none
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: keep-current
|
||||
- Follow-up Action: draft-release 로 release 편성
|
||||
|
||||
#### Validation Plan
|
||||
- BAT-002 verification evidence 재참조로 충분.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# req-007: intent 제출 HTTP API + 멱등
|
||||
|
||||
- Status: Approved
|
||||
- Status: Implemented
|
||||
- Type: Interface
|
||||
- Priority: High
|
||||
- Owner: project maintainers
|
||||
@@ -112,3 +112,44 @@
|
||||
|
||||
#### Validation Plan
|
||||
- 승인 후 batch planning 에서 본 REQ 의 Acceptance Criteria 를 검증 기준으로 사용.
|
||||
|
||||
### CHG-20260603-03
|
||||
- Date: 2026-06-03
|
||||
- Author: Justin Ko
|
||||
|
||||
#### Change Summary
|
||||
- Status Approved → Implemented (confirm-req-implemented, 근거 batch BAT-002 release-candidate).
|
||||
|
||||
#### Intent
|
||||
- BAT-002 verification.md 의 acceptance mapping + evidence(pytest 74 pass / PG 통합 / 데몬 E2E)로 모든 AC 충족 → 구현 완료 동기화.
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- Added:
|
||||
- 없음
|
||||
- Updated:
|
||||
- 없음
|
||||
- Removed:
|
||||
- 없음
|
||||
|
||||
#### Impacted Area Delta
|
||||
- Docs:
|
||||
- docs/srs/index.md (Register Implemented)
|
||||
- Modules:
|
||||
- 없음
|
||||
- Tests:
|
||||
- 없음
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches:
|
||||
- BAT-002 (release-candidate)
|
||||
- Releases:
|
||||
- none
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: keep-current
|
||||
- Follow-up Action: draft-release 로 release 편성
|
||||
|
||||
#### Validation Plan
|
||||
- BAT-002 verification evidence 재참조로 충분.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# req-008: 슬롯 read-only 조회 HTTP API
|
||||
|
||||
- Status: Approved
|
||||
- Status: Implemented
|
||||
- Type: Interface
|
||||
- Priority: High
|
||||
- Owner: project maintainers
|
||||
@@ -109,3 +109,44 @@
|
||||
|
||||
#### Validation Plan
|
||||
- 승인 후 batch planning 에서 본 REQ 의 Acceptance Criteria 를 검증 기준으로 사용.
|
||||
|
||||
### CHG-20260603-03
|
||||
- Date: 2026-06-03
|
||||
- Author: Justin Ko
|
||||
|
||||
#### Change Summary
|
||||
- Status Approved → Implemented (confirm-req-implemented, 근거 batch BAT-002 release-candidate).
|
||||
|
||||
#### Intent
|
||||
- BAT-002 verification.md 의 acceptance mapping + evidence(pytest 74 pass / PG 통합 / 데몬 E2E)로 모든 AC 충족 → 구현 완료 동기화.
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- Added:
|
||||
- 없음
|
||||
- Updated:
|
||||
- 없음
|
||||
- Removed:
|
||||
- 없음
|
||||
|
||||
#### Impacted Area Delta
|
||||
- Docs:
|
||||
- docs/srs/index.md (Register Implemented)
|
||||
- Modules:
|
||||
- 없음
|
||||
- Tests:
|
||||
- 없음
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches:
|
||||
- BAT-002 (release-candidate)
|
||||
- Releases:
|
||||
- none
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: keep-current
|
||||
- Follow-up Action: draft-release 로 release 편성
|
||||
|
||||
#### Validation Plan
|
||||
- BAT-002 verification evidence 재참조로 충분.
|
||||
|
||||
+7
-7
@@ -85,16 +85,16 @@
|
||||
| REQ-002 | slot 순수 도메인 이식 및 공개 API 확정 | Interface | Implemented | High | project maintainers | [req-002](./Interface/req-002_slot-pure-domain-port.md) |
|
||||
| REQ-003 | slot 도메인 단위테스트 이식 및 컬럼 계약 가드 | Testing | Implemented | High | project maintainers | [req-003](./Testing/req-003_slot-domain-tests-and-contract-guard.md) |
|
||||
| REQ-004 | baseline 문서 planned→실제 정합 | Documentation | Implemented | Medium | project maintainers | [req-004](./Documentation/req-004_baseline-doc-alignment.md) |
|
||||
| REQ-005 | 슬롯 코어 데몬 런타임 부트스트랩 | Installation | Approved | High | project maintainers | [req-005](./Installation/req-005_slot-core-daemon-runtime.md) |
|
||||
| REQ-006 | PostgreSQL SlotRepository 영속화 + 스키마 | Data | Approved | High | project maintainers | [req-006](./Data/req-006_postgresql-slot-persistence.md) |
|
||||
| REQ-007 | intent 제출 HTTP API + 멱등 | Interface | Approved | High | project maintainers | [req-007](./Interface/req-007_intent-submission-api.md) |
|
||||
| REQ-008 | 슬롯 read-only 조회 HTTP API | Interface | Approved | High | project maintainers | [req-008](./Interface/req-008_slot-readonly-query-api.md) |
|
||||
| REQ-009 | 데몬/API/영속화 baseline 문서 정합 | Documentation | Approved | Medium | project maintainers | [req-009](./Documentation/req-009_daemon-api-baseline-alignment.md) |
|
||||
| REQ-005 | 슬롯 코어 데몬 런타임 부트스트랩 | Installation | Implemented | High | project maintainers | [req-005](./Installation/req-005_slot-core-daemon-runtime.md) |
|
||||
| REQ-006 | PostgreSQL SlotRepository 영속화 + 스키마 | Data | Implemented | High | project maintainers | [req-006](./Data/req-006_postgresql-slot-persistence.md) |
|
||||
| REQ-007 | intent 제출 HTTP API + 멱등 | Interface | Implemented | High | project maintainers | [req-007](./Interface/req-007_intent-submission-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) |
|
||||
|
||||
## Recent Change Log Summary
|
||||
|
||||
- 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-005~009 최초 생성(Proposed) — dcy-002 FR-1~FR-6 분해. (draft-req)
|
||||
- 2026-06-03 :: REQ-001~004 Approved → Implemented — BAT-001 verification evidence 기반. (confirm-req-implemented)
|
||||
- 2026-06-03 :: REQ-001~004 Proposed → Approved — 승인 게이트 통과. (confirm-req)
|
||||
- 2026-06-03 :: REQ-001~004 최초 생성(Proposed) — dcy-001 FR-1~FR-5 분해. (draft-req)
|
||||
- 2026-06-03 :: REQ-001~004 Proposed → Approved — 승인 게이트 통과. (confirm-req)
|
||||
+9
-1
@@ -3,14 +3,22 @@ name = "trex-core"
|
||||
version = "0.1.0"
|
||||
description = "Shared core for the trex product family; first domain: slot lifecycle (storage-agnostic pure domain)"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = []
|
||||
dependencies = [
|
||||
"fastapi>=0.110",
|
||||
"uvicorn>=0.29",
|
||||
"psycopg[binary]>=3.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8",
|
||||
"ruff>=0.4",
|
||||
"httpx>=0.27",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
trex-core-daemon = "trex_core.runtime.daemon:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""HTTP /v1 API tests via in-memory adapters (REQ-005/007/008).
|
||||
|
||||
No PostgreSQL needed: the app is wired with the in-memory slot repo + intent
|
||||
store, exercising the full request → admission → transition → response path.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from trex_core.adapters.api.app import create_app
|
||||
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
|
||||
from trex_core.application.intents import IntentService
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
DAY = date(2026, 6, 3)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctx():
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
intents = IntentService(svc, repo, InMemoryIntentStore())
|
||||
svc.preallocate_daily_slots(DAY, 3)
|
||||
app = create_app(
|
||||
slot_service=svc, slot_repo=repo, intent_service=intents, default_total_slots=3
|
||||
)
|
||||
return repo, svc, intents, TestClient(app)
|
||||
|
||||
|
||||
def _buy(client, intent_id, symbol="005930"):
|
||||
return client.post(
|
||||
"/v1/intents/buy",
|
||||
json={"intent_id": intent_id, "trading_date": DAY.isoformat(), "symbol": symbol, "qty": 10},
|
||||
)
|
||||
|
||||
|
||||
def test_healthz(ctx):
|
||||
_, _, _, client = ctx
|
||||
r = client.get("/v1/healthz")
|
||||
assert r.status_code == 200 and r.json()["status"] == "ok"
|
||||
|
||||
|
||||
def test_buy_intent_admits_and_transitions(ctx):
|
||||
repo, _, _, client = ctx
|
||||
r = _buy(client, "buy-1")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["status"] == "admitted" and body["slot_id"]
|
||||
slot = repo.fetch_slot(__import__("uuid").UUID(body["slot_id"]))
|
||||
assert slot.status is SlotState.PENDING_BUY and slot.symbol == "005930"
|
||||
|
||||
|
||||
def test_buy_intent_idempotent_resubmit(ctx):
|
||||
repo, _, _, client = ctx
|
||||
r1 = _buy(client, "buy-dup")
|
||||
r2 = _buy(client, "buy-dup") # same intent_id
|
||||
assert r1.json() == r2.json() # identical decision
|
||||
# only ONE slot moved out of idle
|
||||
pending = [s for s in repo.list_slots(DAY) if s.status is SlotState.PENDING_BUY]
|
||||
assert len(pending) == 1
|
||||
|
||||
|
||||
def test_buy_intent_rejected_when_no_idle(ctx):
|
||||
_, _, _, client = ctx
|
||||
for i in range(3):
|
||||
assert _buy(client, f"buy-fill-{i}").json()["status"] == "admitted"
|
||||
r = _buy(client, "buy-overflow")
|
||||
assert r.json()["status"] == "rejected" and r.json()["reason"] == "no_idle_slot"
|
||||
|
||||
|
||||
def test_sell_intent_requires_holding(ctx):
|
||||
repo, _, _, client = ctx
|
||||
# a freshly bought slot is pending_buy, not holding -> sell rejected
|
||||
slot_id = _buy(client, "buy-for-sell").json()["slot_id"]
|
||||
r = client.post("/v1/intents/sell", json={"intent_id": "sell-1", "slot_id": slot_id})
|
||||
assert r.json()["status"] == "rejected" and r.json()["reason"].startswith("slot_not_holding")
|
||||
|
||||
|
||||
def test_sell_intent_admits_from_holding(ctx):
|
||||
repo, svc, _, client = ctx
|
||||
import uuid
|
||||
|
||||
slot_id = uuid.UUID(_buy(client, "buy-h").json()["slot_id"])
|
||||
svc.transit_slot(slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, reason="filled", actor="t")
|
||||
r = client.post("/v1/intents/sell", json={"intent_id": "sell-h", "slot_id": str(slot_id)})
|
||||
assert r.json()["status"] == "admitted"
|
||||
assert repo.fetch_slot(slot_id).status is SlotState.PENDING_SELL
|
||||
|
||||
|
||||
def test_get_intent_roundtrip(ctx):
|
||||
_, _, _, client = ctx
|
||||
_buy(client, "buy-q")
|
||||
r = client.get("/v1/intents/buy-q")
|
||||
assert r.status_code == 200 and r.json()["status"] == "admitted"
|
||||
assert client.get("/v1/intents/does-not-exist").status_code == 404
|
||||
|
||||
|
||||
def test_list_slots_and_capacity(ctx):
|
||||
_, _, _, client = ctx
|
||||
_buy(client, "buy-c")
|
||||
slots = client.get(
|
||||
"/v1/slots", params={"status": "pending_buy", "trading_date": DAY.isoformat()}
|
||||
).json()
|
||||
assert len(slots) == 1 and slots[0]["status"] == "pending_buy"
|
||||
cap = client.get(
|
||||
"/v1/capacity", params={"total_slots": 3, "trading_date": DAY.isoformat()}
|
||||
).json()
|
||||
assert cap["idle_count"] == 2 and cap["pending_count"] == 1 and cap["total_slots"] == 3
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Migration + column-contract drift test (REQ-006).
|
||||
|
||||
Realizes the DB-side drift check dcy-001 OQ-2 deferred to the persistence
|
||||
iteration: EXPECTED_SLOT_FIELDS (code contract) vs daily_slot actual columns
|
||||
(information_schema). Skipped without TREX_POSTGRESQL_TEST_DSN.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import psycopg
|
||||
|
||||
from trex_core.adapters.persistence.migrate import apply_migrations, migration_files
|
||||
from trex_core.domain.slot import EXPECTED_SLOT_FIELDS
|
||||
|
||||
_EXPECTED_TABLES = {"daily_slot", "daily_symbol_entries", "slot_transition_log", "intent_log"}
|
||||
|
||||
|
||||
def _columns(dsn: str, table: str) -> set[str]:
|
||||
with psycopg.connect(dsn) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_schema = 'public' AND table_name = %s",
|
||||
(table,),
|
||||
)
|
||||
return {r[0] for r in cur.fetchall()}
|
||||
|
||||
|
||||
def test_migrations_create_expected_tables(pg_clean):
|
||||
assert migration_files(), "no migration files found"
|
||||
with psycopg.connect(pg_clean) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT table_name FROM information_schema.tables WHERE table_schema='public'"
|
||||
)
|
||||
tables = {r[0] for r in cur.fetchall()}
|
||||
assert _EXPECTED_TABLES <= tables
|
||||
|
||||
|
||||
def test_daily_slot_columns_match_expected_contract(pg_clean):
|
||||
"""AC: daily_slot columns == EXPECTED_SLOT_FIELDS (dcy-001 Appendix A)."""
|
||||
actual = _columns(pg_clean, "daily_slot")
|
||||
assert actual == set(EXPECTED_SLOT_FIELDS)
|
||||
|
||||
|
||||
def test_drift_detected_when_contract_extended(pg_clean):
|
||||
"""Negative case: a contract field absent from the table is caught."""
|
||||
actual = _columns(pg_clean, "daily_slot")
|
||||
drifted_contract = set(EXPECTED_SLOT_FIELDS) | {"phantom_column"}
|
||||
assert actual != drifted_contract
|
||||
|
||||
|
||||
def test_apply_migrations_is_idempotent(pg_dsn):
|
||||
# applying twice must not error (CREATE ... IF NOT EXISTS)
|
||||
apply_migrations(pg_dsn)
|
||||
apply_migrations(pg_dsn)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""PostgreSQL integration tests (REQ-006). Skipped without TREX_POSTGRESQL_TEST_DSN."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore # noqa: F401
|
||||
from trex_core.adapters.persistence.postgresql import (
|
||||
PostgreSQLIntentStore,
|
||||
PostgreSQLSlotRepository,
|
||||
)
|
||||
from trex_core.application.intents import BuyIntent, IntentService, SellIntent
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
|
||||
DAY = date(2026, 6, 3)
|
||||
|
||||
|
||||
def _wire(dsn):
|
||||
repo = PostgreSQLSlotRepository(dsn)
|
||||
svc = SlotService(repo)
|
||||
intents = IntentService(svc, repo, PostgreSQLIntentStore(dsn))
|
||||
return repo, svc, intents
|
||||
|
||||
|
||||
def test_preallocate_and_capacity(pg_clean):
|
||||
repo, svc, _ = _wire(pg_clean)
|
||||
assert svc.preallocate_daily_slots(DAY, 10) == 10
|
||||
assert svc.preallocate_daily_slots(DAY, 10) == 0 # idempotent
|
||||
cap = svc.compute_slot_capacity(DAY, 10)
|
||||
assert cap.idle_count == 10 and cap.scarcity_ratio == 0.0
|
||||
|
||||
|
||||
def test_buy_intent_persists_transition(pg_clean):
|
||||
repo, svc, intents = _wire(pg_clean)
|
||||
svc.preallocate_daily_slots(DAY, 3)
|
||||
d = intents.submit_buy(BuyIntent(intent_id="pg-buy-1", trading_date=DAY, symbol="005930", qty=10))
|
||||
assert d.admitted
|
||||
slot = repo.fetch_slot(d.slot_id)
|
||||
assert slot.status is SlotState.PENDING_BUY and slot.symbol == "005930"
|
||||
|
||||
|
||||
def test_buy_intent_idempotent_on_pg(pg_clean):
|
||||
repo, svc, intents = _wire(pg_clean)
|
||||
svc.preallocate_daily_slots(DAY, 3)
|
||||
d1 = intents.submit_buy(BuyIntent(intent_id="pg-dup", trading_date=DAY, symbol="005930"))
|
||||
d2 = intents.submit_buy(BuyIntent(intent_id="pg-dup", trading_date=DAY, symbol="005930"))
|
||||
assert d1.slot_id == d2.slot_id and d2.status == "admitted"
|
||||
pending = [s for s in repo.list_slots(DAY) if s.status is SlotState.PENDING_BUY]
|
||||
assert len(pending) == 1 # no double transition
|
||||
|
||||
|
||||
def test_full_lifecycle_recovery_bumps_entry_count(pg_clean):
|
||||
repo, svc, intents = _wire(pg_clean)
|
||||
svc.preallocate_daily_slots(DAY, 1)
|
||||
d = intents.submit_buy(BuyIntent(intent_id="pg-life", trading_date=DAY, symbol="005930"))
|
||||
sid = d.slot_id
|
||||
svc.transit_slot(sid, SlotState.PENDING_BUY, SlotState.HOLDING, reason="filled", actor="t")
|
||||
intents.submit_sell(SellIntent(intent_id="pg-sell", slot_id=sid))
|
||||
svc.transit_slot(sid, SlotState.PENDING_SELL, SlotState.CLOSED, reason="filled", actor="t")
|
||||
assert repo.get_symbol_entry_count(DAY, "005930") == 0
|
||||
svc.transit_slot(sid, SlotState.CLOSED, SlotState.IDLE, reason="recovery", actor="t")
|
||||
assert repo.get_symbol_entry_count(DAY, "005930") == 1
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Concurrency / single-writer test for IntentService (REQ-007 NFR-2/NFR-3).
|
||||
|
||||
Many threads submit buy intents competing for a limited number of idle slots;
|
||||
exactly that many must be admitted and no slot may be double-used.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from datetime import date
|
||||
|
||||
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
|
||||
from trex_core.application.intents import BuyIntent, IntentService
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
DAY = date(2026, 6, 3)
|
||||
|
||||
|
||||
def test_concurrent_buys_admit_exactly_idle_count():
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
intents = IntentService(svc, repo, InMemoryIntentStore())
|
||||
svc.preallocate_daily_slots(DAY, 5) # only 5 idle slots
|
||||
|
||||
results: list[str] = []
|
||||
results_lock = threading.Lock()
|
||||
|
||||
def worker(i: int) -> None:
|
||||
d = intents.submit_buy(BuyIntent(intent_id=f"buy-{i}", trading_date=DAY, symbol="005930", qty=1))
|
||||
with results_lock:
|
||||
results.append(d.status)
|
||||
|
||||
threads = [threading.Thread(target=worker, args=(i,)) for i in range(20)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
admitted = [s for s in results if s == "admitted"]
|
||||
rejected = [s for s in results if s == "rejected"]
|
||||
assert len(admitted) == 5 # exactly the idle slots
|
||||
assert len(rejected) == 15
|
||||
# every non-idle slot is pending_buy exactly once (no double-use)
|
||||
pending = [s for s in repo.list_slots(DAY) if s.status is SlotState.PENDING_BUY]
|
||||
idle = [s for s in repo.list_slots(DAY) if s.status is SlotState.IDLE]
|
||||
assert len(pending) == 5 and len(idle) == 0
|
||||
|
||||
|
||||
def test_duplicate_intent_id_admits_once_under_concurrency():
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
intents = IntentService(svc, repo, InMemoryIntentStore())
|
||||
svc.preallocate_daily_slots(DAY, 5)
|
||||
|
||||
statuses: list[str] = []
|
||||
lock = threading.Lock()
|
||||
|
||||
def worker() -> None:
|
||||
d = intents.submit_buy(BuyIntent(intent_id="same-id", trading_date=DAY, symbol="005930"))
|
||||
with lock:
|
||||
statuses.append(d.status)
|
||||
|
||||
threads = [threading.Thread(target=worker) for _ in range(10)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# same intent_id submitted 10x concurrently -> exactly ONE slot transitioned
|
||||
# (the core idempotency guarantee). Racing duplicates observe the winner's
|
||||
# decision, which may still be 'processing' before finalize; never 'rejected'.
|
||||
pending = [s for s in repo.list_slots(DAY) if s.status is SlotState.PENDING_BUY]
|
||||
assert len(pending) == 1
|
||||
assert all(s in ("admitted", "processing") for s in statuses)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Shared test fixtures.
|
||||
|
||||
PostgreSQL integration tests are gated on TREX_POSTGRESQL_TEST_DSN; when it
|
||||
is unset they are skipped (unit/API tests still run via in-memory adapters).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
_PG_TABLES = ("intent_log", "slot_transition_log", "daily_symbol_entries", "daily_slot")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pg_dsn() -> str:
|
||||
dsn = os.environ.get("TREX_POSTGRESQL_TEST_DSN")
|
||||
if not dsn:
|
||||
pytest.skip("TREX_POSTGRESQL_TEST_DSN not set; skipping PostgreSQL integration test")
|
||||
return dsn
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pg_clean(pg_dsn: str):
|
||||
"""Apply migrations and truncate slot-core tables before each PG test."""
|
||||
import psycopg
|
||||
|
||||
from trex_core.adapters.persistence.migrate import apply_migrations
|
||||
|
||||
apply_migrations(pg_dsn)
|
||||
with psycopg.connect(pg_dsn, autocommit=True) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"TRUNCATE {', '.join(_PG_TABLES)}")
|
||||
return pg_dsn
|
||||
@@ -0,0 +1,114 @@
|
||||
"""FastAPI app factory for the slot-core daemon (REQ-005/007/008).
|
||||
|
||||
`create_app` wires the HTTP /v1 surface to injected services so the same app
|
||||
runs against the in-memory adapters (tests) or PostgreSQL adapters (daemon).
|
||||
All slot writes go through IntentService → SlotService (single writer).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date as date_cls
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
|
||||
from trex_core.adapters.api.schemas import (
|
||||
BuyIntentRequest,
|
||||
CapacityResponse,
|
||||
HealthResponse,
|
||||
IntentResponse,
|
||||
SellIntentRequest,
|
||||
SlotSnapshot,
|
||||
)
|
||||
from trex_core.application.intents import BuyIntent, IntentService, SellIntent
|
||||
from trex_core.domain.slot import SlotRepository, SlotService
|
||||
|
||||
|
||||
def create_app(
|
||||
*,
|
||||
slot_service: SlotService,
|
||||
slot_repo: SlotRepository,
|
||||
intent_service: IntentService,
|
||||
default_total_slots: int,
|
||||
) -> FastAPI:
|
||||
app = FastAPI(title="trex-core slot daemon", version="0.1.0")
|
||||
|
||||
@app.get("/v1/healthz", response_model=HealthResponse)
|
||||
def healthz() -> HealthResponse:
|
||||
return HealthResponse()
|
||||
|
||||
@app.post("/v1/intents/buy", response_model=IntentResponse)
|
||||
def submit_buy(req: BuyIntentRequest) -> IntentResponse:
|
||||
d = intent_service.submit_buy(
|
||||
BuyIntent(
|
||||
intent_id=req.intent_id,
|
||||
trading_date=req.trading_date,
|
||||
symbol=req.symbol,
|
||||
qty=req.qty,
|
||||
allocated_capital=req.allocated_capital,
|
||||
entry_price=req.entry_price,
|
||||
strategy_id=req.strategy_id,
|
||||
sell_strategy_id=req.sell_strategy_id,
|
||||
portfolio_id=req.portfolio_id,
|
||||
forced_close_at=req.forced_close_at,
|
||||
)
|
||||
)
|
||||
return IntentResponse(intent_id=d.intent_id, status=d.status, slot_id=d.slot_id, reason=d.reason)
|
||||
|
||||
@app.post("/v1/intents/sell", response_model=IntentResponse)
|
||||
def submit_sell(req: SellIntentRequest) -> IntentResponse:
|
||||
d = intent_service.submit_sell(
|
||||
SellIntent(
|
||||
intent_id=req.intent_id,
|
||||
slot_id=req.slot_id,
|
||||
reason=req.reason,
|
||||
sell_strategy_id=req.sell_strategy_id,
|
||||
)
|
||||
)
|
||||
return IntentResponse(intent_id=d.intent_id, status=d.status, slot_id=d.slot_id, reason=d.reason)
|
||||
|
||||
@app.get("/v1/intents/{intent_id}", response_model=IntentResponse)
|
||||
def get_intent(intent_id: str) -> IntentResponse:
|
||||
d = intent_service.lookup(intent_id)
|
||||
if d is None:
|
||||
raise HTTPException(status_code=404, detail="intent not found")
|
||||
return IntentResponse(intent_id=d.intent_id, status=d.status, slot_id=d.slot_id, reason=d.reason)
|
||||
|
||||
@app.get("/v1/slots", response_model=list[SlotSnapshot])
|
||||
def list_slots(
|
||||
status: Optional[str] = Query(default=None),
|
||||
trading_date: Optional[date_cls] = Query(default=None),
|
||||
) -> list[SlotSnapshot]:
|
||||
day = trading_date or _today()
|
||||
slots = slot_repo.list_slots(day)
|
||||
if status is not None:
|
||||
slots = [s for s in slots if s.status.value == status]
|
||||
return [
|
||||
SlotSnapshot(
|
||||
slot_id=s.slot_id, trading_date=s.trading_date, status=s.status.value,
|
||||
symbol=s.symbol, qty=s.qty, avg_price=s.avg_price, entry_price=s.entry_price,
|
||||
allocated_capital=s.allocated_capital, strategy_id=s.strategy_id,
|
||||
sell_strategy_id=s.sell_strategy_id, forced_close_at=s.forced_close_at,
|
||||
)
|
||||
for s in slots
|
||||
]
|
||||
|
||||
@app.get("/v1/capacity", response_model=CapacityResponse)
|
||||
def capacity(
|
||||
trading_date: Optional[date_cls] = Query(default=None),
|
||||
total_slots: Optional[int] = Query(default=None),
|
||||
) -> CapacityResponse:
|
||||
day = trading_date or _today()
|
||||
cap = slot_service.compute_slot_capacity(day, total_slots or default_total_slots)
|
||||
return CapacityResponse(
|
||||
idle_count=cap.idle_count, holding_count=cap.holding_count,
|
||||
pending_count=cap.pending_count, total_slots=cap.total_slots,
|
||||
scarcity_ratio=cap.scarcity_ratio,
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _today() -> date_cls:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return datetime.now(tz=timezone.utc).date()
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Pydantic request/response schemas for the /v1 HTTP API (REQ-007/008)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class BuyIntentRequest(BaseModel):
|
||||
intent_id: str
|
||||
trading_date: date
|
||||
symbol: str
|
||||
qty: Optional[float] = None
|
||||
allocated_capital: Optional[float] = None
|
||||
entry_price: Optional[float] = None
|
||||
strategy_id: Optional[str] = None
|
||||
sell_strategy_id: Optional[str] = None
|
||||
portfolio_id: Optional[str] = None
|
||||
forced_close_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class SellIntentRequest(BaseModel):
|
||||
intent_id: str
|
||||
slot_id: UUID
|
||||
reason: str = "discretionary_sell"
|
||||
sell_strategy_id: Optional[str] = None
|
||||
|
||||
|
||||
class IntentResponse(BaseModel):
|
||||
intent_id: str
|
||||
status: str
|
||||
slot_id: Optional[UUID] = None
|
||||
reason: Optional[str] = None
|
||||
|
||||
|
||||
class SlotSnapshot(BaseModel):
|
||||
slot_id: UUID
|
||||
trading_date: date
|
||||
status: str
|
||||
symbol: Optional[str] = None
|
||||
qty: Optional[float] = None
|
||||
avg_price: Optional[float] = None
|
||||
entry_price: Optional[float] = None
|
||||
allocated_capital: Optional[float] = None
|
||||
strategy_id: Optional[str] = None
|
||||
sell_strategy_id: Optional[str] = None
|
||||
forced_close_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class CapacityResponse(BaseModel):
|
||||
idle_count: int
|
||||
holding_count: int
|
||||
pending_count: int
|
||||
total_slots: int
|
||||
scarcity_ratio: float
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str = "ok"
|
||||
@@ -0,0 +1,45 @@
|
||||
"""In-memory IntentStore — for API tests without PostgreSQL."""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from trex_core.application.ports import IntentRecord
|
||||
|
||||
|
||||
class _Rec:
|
||||
def __init__(self, intent_id: str, intent_type: str) -> None:
|
||||
self.intent_id = intent_id
|
||||
self.intent_type = intent_type
|
||||
self.status = "processing"
|
||||
self.slot_id: Optional[UUID] = None
|
||||
self.reason: Optional[str] = None
|
||||
|
||||
|
||||
class InMemoryIntentStore:
|
||||
"""Thread-safe in-memory intent dedup/audit store."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._rows: dict[str, _Rec] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def try_insert(self, intent_id: str, intent_type: str, payload: dict) -> bool:
|
||||
with self._lock:
|
||||
if intent_id in self._rows:
|
||||
return False
|
||||
self._rows[intent_id] = _Rec(intent_id, intent_type)
|
||||
return True
|
||||
|
||||
def get(self, intent_id: str) -> Optional[IntentRecord]:
|
||||
return self._rows.get(intent_id)
|
||||
|
||||
def finalize(
|
||||
self, intent_id: str, status: str, slot_id: Optional[UUID], reason: Optional[str]
|
||||
) -> None:
|
||||
with self._lock:
|
||||
rec = self._rows.get(intent_id)
|
||||
if rec is not None:
|
||||
rec.status = status
|
||||
rec.slot_id = slot_id
|
||||
rec.reason = reason
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Apply slot-core SQL migrations in sorted order (REQ-006).
|
||||
|
||||
Idempotent: every migration uses CREATE TABLE IF NOT EXISTS / CREATE INDEX
|
||||
IF NOT EXISTS, so re-running against an existing database is a no-op.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_SQL_DIR = Path(__file__).resolve().parent / "sql"
|
||||
|
||||
|
||||
def migration_files() -> list[Path]:
|
||||
return sorted(_SQL_DIR.glob("*.sql"))
|
||||
|
||||
|
||||
def apply_migrations(dsn: str, *, psycopg_module: Any = None) -> list[str]:
|
||||
"""Apply all migrations to `dsn`. Returns the list of applied filenames."""
|
||||
if psycopg_module is None:
|
||||
import psycopg # type: ignore[import-not-found]
|
||||
|
||||
psycopg_module = psycopg
|
||||
applied: list[str] = []
|
||||
with psycopg_module.connect(dsn, autocommit=False) as conn:
|
||||
for path in migration_files():
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(path.read_text())
|
||||
applied.append(path.name)
|
||||
conn.commit()
|
||||
return applied
|
||||
@@ -0,0 +1,260 @@
|
||||
"""PostgreSQL persistence adapters (REQ-006).
|
||||
|
||||
- PostgreSQLSlotRepository: implements the domain SlotRepository protocol
|
||||
(single-writer, pg_advisory_xact_lock serialization).
|
||||
- PostgreSQLIntentStore: implements the application IntentStore port
|
||||
(intent_log dedup gate + audit).
|
||||
|
||||
psycopg (v3) is imported lazily so the domain/test paths that use the
|
||||
in-memory adapters don't require it. External I/O lives here, never in the
|
||||
domain layer (NFR-1).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from contextlib import contextmanager
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Any, Iterator, Optional
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from trex_core.application.ports import IntentRecord
|
||||
from trex_core.domain.slot import Slot, SlotNotFoundError, SlotState, SlotTransitionRecord
|
||||
|
||||
_SLOT_COLUMNS = (
|
||||
"slot_id, trading_date, status, symbol, qty, avg_price, entry_price, "
|
||||
"allocated_capital, order_id, strategy_id, broker_order_id, portfolio_id, "
|
||||
"sell_strategy_id, forced_close_at, created_at, updated_at"
|
||||
)
|
||||
|
||||
|
||||
def _require_psycopg(module: Any = None) -> Any:
|
||||
if module is not None:
|
||||
return module
|
||||
try:
|
||||
import psycopg # type: ignore[import-not-found]
|
||||
except ImportError as exc: # pragma: no cover - environment guard
|
||||
raise RuntimeError(
|
||||
"psycopg is required for PostgreSQL adapters. Install 'trex-core[postgres]'."
|
||||
) from exc
|
||||
return psycopg
|
||||
|
||||
|
||||
class PostgreSQLSlotRepository:
|
||||
"""psycopg-backed SlotRepository. Connection-per-call (orbit-proven)."""
|
||||
|
||||
def __init__(self, dsn: str, *, psycopg_module: Any = None) -> None:
|
||||
self._psycopg = _require_psycopg(psycopg_module)
|
||||
self._dsn = dsn
|
||||
|
||||
@contextmanager
|
||||
def transactional_advisory_lock(self, lock_id: int) -> Iterator[None]:
|
||||
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT pg_advisory_xact_lock(%s)", (lock_id,))
|
||||
yield
|
||||
conn.commit()
|
||||
except BaseException:
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
def pre_allocate_slots(self, trading_date: date, count: int) -> int:
|
||||
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) FROM daily_slot WHERE trading_date = %s", (trading_date,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row and row[0] > 0:
|
||||
conn.commit()
|
||||
return 0
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
rows = [
|
||||
(uuid4(), trading_date, SlotState.IDLE.value, now, now) for _ in range(count)
|
||||
]
|
||||
cur.executemany(
|
||||
"INSERT INTO daily_slot (slot_id, trading_date, status, created_at, updated_at) "
|
||||
"VALUES (%s, %s, %s, %s, %s)",
|
||||
rows,
|
||||
)
|
||||
conn.commit()
|
||||
return count
|
||||
|
||||
def list_slots(self, trading_date: date) -> 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 trading_date = %s",
|
||||
(trading_date,),
|
||||
)
|
||||
return [self._row_to_slot(r) for r in cur.fetchall()]
|
||||
|
||||
def fetch_slot(self, slot_id: UUID) -> 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 slot_id = %s", (slot_id,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return self._row_to_slot(row) if row else None
|
||||
|
||||
def find_idle_slot(self, trading_date: date) -> 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 trading_date = %s AND status = 'idle' LIMIT 1",
|
||||
(trading_date,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return self._row_to_slot(row) if row else None
|
||||
|
||||
def update_slot(self, slot: Slot) -> None:
|
||||
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE daily_slot SET status=%s, symbol=%s, qty=%s, avg_price=%s, "
|
||||
"entry_price=%s, allocated_capital=%s, order_id=%s, strategy_id=%s, "
|
||||
"broker_order_id=%s, portfolio_id=%s, sell_strategy_id=%s, "
|
||||
"forced_close_at=%s, updated_at=%s WHERE slot_id=%s",
|
||||
(
|
||||
slot.status.value, slot.symbol, slot.qty, slot.avg_price,
|
||||
slot.entry_price, slot.allocated_capital, slot.order_id,
|
||||
slot.strategy_id, slot.broker_order_id, slot.portfolio_id,
|
||||
slot.sell_strategy_id, slot.forced_close_at,
|
||||
slot.updated_at or datetime.now(tz=timezone.utc), slot.slot_id,
|
||||
),
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
conn.rollback()
|
||||
raise SlotNotFoundError(f"slot not found: {slot.slot_id}")
|
||||
conn.commit()
|
||||
|
||||
def append_transition_log(self, record: SlotTransitionRecord) -> None:
|
||||
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO slot_transition_log "
|
||||
"(slot_id, from_state, to_state, ts, reason, actor, extra) "
|
||||
"VALUES (%s, %s, %s, %s, %s, %s, %s)",
|
||||
(
|
||||
record.slot_id, record.from_state.value, record.to_state.value,
|
||||
record.ts, record.reason, record.actor, json.dumps(record.extra),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def increment_symbol_entry(self, trading_date: date, symbol: str) -> int:
|
||||
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO daily_symbol_entries (trading_date, symbol, entry_count, last_entry_at) "
|
||||
"VALUES (%s, %s, 1, %s) "
|
||||
"ON CONFLICT (trading_date, symbol) DO UPDATE "
|
||||
"SET entry_count = daily_symbol_entries.entry_count + 1, "
|
||||
" last_entry_at = EXCLUDED.last_entry_at RETURNING entry_count",
|
||||
(trading_date, symbol, datetime.now(tz=timezone.utc)),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
def get_symbol_entry_count(self, trading_date: date, symbol: str) -> int:
|
||||
with self._psycopg.connect(self._dsn) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT entry_count FROM daily_symbol_entries "
|
||||
"WHERE trading_date = %s AND symbol = %s",
|
||||
(trading_date, symbol),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
def count_slots_by_status(self, trading_date: date) -> dict[SlotState, int]:
|
||||
with self._psycopg.connect(self._dsn) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT status, COUNT(*) FROM daily_slot WHERE trading_date = %s GROUP BY status",
|
||||
(trading_date,),
|
||||
)
|
||||
return {SlotState(r[0]): int(r[1]) for r in cur.fetchall()}
|
||||
|
||||
@staticmethod
|
||||
def _row_to_slot(row: tuple) -> Slot:
|
||||
f = lambda v: float(v) if v is not None else None # noqa: E731
|
||||
return Slot(
|
||||
slot_id=row[0] if isinstance(row[0], UUID) else UUID(str(row[0])),
|
||||
trading_date=row[1],
|
||||
status=SlotState(row[2]),
|
||||
symbol=row[3],
|
||||
qty=f(row[4]),
|
||||
avg_price=f(row[5]),
|
||||
entry_price=f(row[6]),
|
||||
allocated_capital=f(row[7]),
|
||||
order_id=row[8],
|
||||
strategy_id=row[9],
|
||||
broker_order_id=row[10],
|
||||
portfolio_id=row[11],
|
||||
sell_strategy_id=row[12],
|
||||
forced_close_at=row[13],
|
||||
created_at=row[14],
|
||||
updated_at=row[15],
|
||||
)
|
||||
|
||||
|
||||
class PostgreSQLIntentStore:
|
||||
"""intent_log-backed IntentStore. intent_id UNIQUE is the dedup gate."""
|
||||
|
||||
def __init__(self, dsn: str, *, psycopg_module: Any = None) -> None:
|
||||
self._psycopg = _require_psycopg(psycopg_module)
|
||||
self._dsn = dsn
|
||||
|
||||
def try_insert(self, intent_id: str, intent_type: str, payload: dict) -> bool:
|
||||
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO intent_log (intent_id, intent_type, payload, status) "
|
||||
"VALUES (%s, %s, %s, 'processing') ON CONFLICT (intent_id) DO NOTHING",
|
||||
(intent_id, intent_type, json.dumps(payload)),
|
||||
)
|
||||
inserted = cur.rowcount == 1
|
||||
conn.commit()
|
||||
return inserted
|
||||
|
||||
def get(self, intent_id: str) -> Optional[IntentRecord]:
|
||||
with self._psycopg.connect(self._dsn) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT intent_id, intent_type, status, slot_id, reason "
|
||||
"FROM intent_log WHERE intent_id = %s",
|
||||
(intent_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return _PgIntentRecord(
|
||||
intent_id=row[0], intent_type=row[1], status=row[2],
|
||||
slot_id=(row[3] if isinstance(row[3], UUID) or row[3] is None else UUID(str(row[3]))),
|
||||
reason=row[4],
|
||||
)
|
||||
|
||||
def finalize(
|
||||
self, intent_id: str, status: str, slot_id: Optional[UUID], reason: Optional[str]
|
||||
) -> None:
|
||||
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE intent_log SET status=%s, slot_id=%s, reason=%s, updated_at=%s "
|
||||
"WHERE intent_id=%s",
|
||||
(status, slot_id, reason, datetime.now(tz=timezone.utc), intent_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
class _PgIntentRecord:
|
||||
def __init__(self, intent_id, intent_type, status, slot_id, reason):
|
||||
self.intent_id = intent_id
|
||||
self.intent_type = intent_type
|
||||
self.status = status
|
||||
self.slot_id = slot_id
|
||||
self.reason = reason
|
||||
@@ -0,0 +1,74 @@
|
||||
-- BAT-002 / REQ-006: trex-core-owned slot-core schema.
|
||||
--
|
||||
-- Authored fresh for trex-core, keyed to the Slot column contract in
|
||||
-- dcy-001 Appendix A (orbit-v2 DDL used only as reference). Scope: slice 1.
|
||||
-- Excludes daily_slot_archive / retention prune (out of scope, dcy-002).
|
||||
|
||||
-- daily_slot — current lifecycle state (single-writer source of truth).
|
||||
-- Columns mirror dcy-001 Appendix A (16 columns) and trex_core EXPECTED_SLOT_FIELDS.
|
||||
CREATE TABLE IF NOT EXISTS daily_slot (
|
||||
slot_id UUID PRIMARY KEY,
|
||||
trading_date DATE NOT NULL,
|
||||
status TEXT NOT NULL CHECK (
|
||||
status IN ('idle','pending_buy','holding','pending_sell','closed','error')
|
||||
),
|
||||
symbol TEXT,
|
||||
qty NUMERIC(18, 4),
|
||||
avg_price NUMERIC(18, 4),
|
||||
entry_price NUMERIC(18, 4),
|
||||
allocated_capital NUMERIC(18, 2),
|
||||
order_id TEXT,
|
||||
strategy_id TEXT,
|
||||
broker_order_id TEXT,
|
||||
portfolio_id TEXT,
|
||||
sell_strategy_id TEXT,
|
||||
forced_close_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT (now() AT TIME ZONE 'UTC'),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT (now() AT TIME ZONE 'UTC')
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_daily_slot_trading_date_status
|
||||
ON daily_slot (trading_date, status);
|
||||
|
||||
-- daily_symbol_entries — per-day re-entry counter (decision input).
|
||||
CREATE TABLE IF NOT EXISTS daily_symbol_entries (
|
||||
trading_date DATE NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
entry_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_entry_at TIMESTAMPTZ NOT NULL DEFAULT (now() AT TIME ZONE 'UTC'),
|
||||
PRIMARY KEY (trading_date, symbol)
|
||||
);
|
||||
|
||||
-- slot_transition_log — append-only audit of every transition.
|
||||
CREATE TABLE IF NOT EXISTS slot_transition_log (
|
||||
log_id BIGSERIAL PRIMARY KEY,
|
||||
slot_id UUID NOT NULL,
|
||||
from_state TEXT NOT NULL,
|
||||
to_state TEXT NOT NULL,
|
||||
ts TIMESTAMPTZ NOT NULL DEFAULT (now() AT TIME ZONE 'UTC'),
|
||||
reason TEXT NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
extra JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_slot_transition_log_slot_id_ts
|
||||
ON slot_transition_log (slot_id, ts);
|
||||
|
||||
-- intent_log — durable idempotency store + audit for accepted intents
|
||||
-- (dcy-002 OQ-1). intent_id UNIQUE is the dedup gate that prevents double
|
||||
-- slot transitions on at-least-once retransmission. Precursor to a full
|
||||
-- outbox (status column reserved); async consumer loop deferred to the
|
||||
-- broker-execution Discovery.
|
||||
CREATE TABLE IF NOT EXISTS intent_log (
|
||||
intent_id TEXT PRIMARY KEY,
|
||||
intent_type TEXT NOT NULL CHECK (intent_type IN ('buy','sell')),
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
status TEXT NOT NULL DEFAULT 'processing'
|
||||
CHECK (status IN ('processing','admitted','rejected')),
|
||||
slot_id UUID,
|
||||
reason TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT (now() AT TIME ZONE 'UTC'),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT (now() AT TIME ZONE 'UTC')
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_intent_log_status ON intent_log (status);
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Intent application service (REQ-007).
|
||||
|
||||
Turns a buy/sell intent submitted over HTTP into a single-writer slot
|
||||
transition, with intent_id idempotency backed by a durable intent_log.
|
||||
|
||||
Idempotency model (dcy-002 OQ-1): `intent_store.try_insert` is the dedup
|
||||
gate. On a duplicate intent_id it returns the prior decision without
|
||||
re-driving any transition (NFR-3). Slot transitions go exclusively through
|
||||
SlotService (advisory-lock serialized) so the single-writer invariant
|
||||
holds (NFR-2). Concurrency on the last idle slot is resolved by retrying
|
||||
on SlotStateMismatchError.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from trex_core.application.ports import IntentStore
|
||||
from trex_core.domain.slot import (
|
||||
SlotRepository,
|
||||
SlotService,
|
||||
SlotState,
|
||||
SlotStateMismatchError,
|
||||
)
|
||||
|
||||
_MAX_IDLE_RETRY = 8
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IntentDecision:
|
||||
"""Result of submitting an intent."""
|
||||
|
||||
intent_id: str
|
||||
status: str # 'admitted' | 'rejected'
|
||||
slot_id: Optional[UUID] = None
|
||||
reason: Optional[str] = None
|
||||
|
||||
@property
|
||||
def admitted(self) -> bool:
|
||||
return self.status == "admitted"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BuyIntent:
|
||||
intent_id: str
|
||||
trading_date: date
|
||||
symbol: str
|
||||
qty: Optional[float] = None
|
||||
allocated_capital: Optional[float] = None
|
||||
entry_price: Optional[float] = None
|
||||
strategy_id: Optional[str] = None
|
||||
sell_strategy_id: Optional[str] = None
|
||||
portfolio_id: Optional[str] = None
|
||||
forced_close_at: Optional[datetime] = None
|
||||
extra: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SellIntent:
|
||||
intent_id: str
|
||||
slot_id: UUID
|
||||
reason: str = "discretionary_sell"
|
||||
sell_strategy_id: Optional[str] = None
|
||||
extra: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class IntentService:
|
||||
"""Admission + slot transition for buy/sell intents (single writer)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
slot_service: SlotService,
|
||||
slot_repo: SlotRepository,
|
||||
intent_store: IntentStore,
|
||||
) -> None:
|
||||
self._svc = slot_service
|
||||
self._repo = slot_repo
|
||||
self._intents = intent_store
|
||||
|
||||
# ------------------------------------------------------------
|
||||
def lookup(self, intent_id: str) -> Optional[IntentDecision]:
|
||||
"""Return the recorded decision for an intent_id, or None (re-query)."""
|
||||
rec = self._intents.get(intent_id)
|
||||
if rec is None:
|
||||
return None
|
||||
return IntentDecision(rec.intent_id, rec.status, rec.slot_id, rec.reason)
|
||||
|
||||
def submit_buy(self, intent: BuyIntent) -> IntentDecision:
|
||||
payload = {
|
||||
"trading_date": intent.trading_date.isoformat(),
|
||||
"symbol": intent.symbol,
|
||||
"qty": intent.qty,
|
||||
"allocated_capital": intent.allocated_capital,
|
||||
"entry_price": intent.entry_price,
|
||||
"strategy_id": intent.strategy_id,
|
||||
"sell_strategy_id": intent.sell_strategy_id,
|
||||
"portfolio_id": intent.portfolio_id,
|
||||
}
|
||||
prior = self._dedup(intent.intent_id, "buy", payload)
|
||||
if prior is not None:
|
||||
return prior
|
||||
|
||||
decision = self._admit_buy(intent)
|
||||
self._intents.finalize(
|
||||
intent.intent_id, decision.status, decision.slot_id, decision.reason
|
||||
)
|
||||
return decision
|
||||
|
||||
def submit_sell(self, intent: SellIntent) -> IntentDecision:
|
||||
payload = {
|
||||
"slot_id": str(intent.slot_id),
|
||||
"reason": intent.reason,
|
||||
"sell_strategy_id": intent.sell_strategy_id,
|
||||
}
|
||||
prior = self._dedup(intent.intent_id, "sell", payload)
|
||||
if prior is not None:
|
||||
return prior
|
||||
|
||||
decision = self._admit_sell(intent)
|
||||
self._intents.finalize(
|
||||
intent.intent_id, decision.status, decision.slot_id, decision.reason
|
||||
)
|
||||
return decision
|
||||
|
||||
# ------------------------------------------------------------
|
||||
def _dedup(self, intent_id: str, intent_type: str, payload: dict) -> Optional[IntentDecision]:
|
||||
"""Return prior decision if intent_id already seen; else None (and reserve it)."""
|
||||
inserted = self._intents.try_insert(intent_id, intent_type, payload)
|
||||
if inserted:
|
||||
return None
|
||||
rec = self._intents.get(intent_id)
|
||||
if rec is None: # extremely unlikely race; treat as not-yet-decided
|
||||
return IntentDecision(intent_id, "rejected", None, "duplicate_in_flight")
|
||||
return IntentDecision(intent_id, rec.status, rec.slot_id, rec.reason)
|
||||
|
||||
def _admit_buy(self, intent: BuyIntent) -> IntentDecision:
|
||||
# Retry on the last-idle-slot race: another intent may claim the slot
|
||||
# between our find and transit (SlotStateMismatchError).
|
||||
for _ in range(_MAX_IDLE_RETRY):
|
||||
slot = self._repo.find_idle_slot(intent.trading_date)
|
||||
if slot is None:
|
||||
return IntentDecision(intent.intent_id, "rejected", None, "no_idle_slot")
|
||||
try:
|
||||
updated = self._svc.transit_slot(
|
||||
slot.slot_id,
|
||||
SlotState.IDLE,
|
||||
SlotState.PENDING_BUY,
|
||||
reason="buy_intent",
|
||||
actor="intent_api",
|
||||
symbol=intent.symbol,
|
||||
qty=intent.qty,
|
||||
allocated_capital=intent.allocated_capital,
|
||||
entry_price=intent.entry_price,
|
||||
strategy_id=intent.strategy_id,
|
||||
sell_strategy_id=intent.sell_strategy_id,
|
||||
portfolio_id=intent.portfolio_id,
|
||||
forced_close_at=intent.forced_close_at,
|
||||
extra={"intent_id": intent.intent_id, "symbol": intent.symbol},
|
||||
)
|
||||
except SlotStateMismatchError:
|
||||
continue # slot was claimed concurrently; try another idle slot
|
||||
return IntentDecision(intent.intent_id, "admitted", updated.slot_id, None)
|
||||
return IntentDecision(intent.intent_id, "rejected", None, "idle_slot_contended")
|
||||
|
||||
def _admit_sell(self, intent: SellIntent) -> IntentDecision:
|
||||
slot = self._repo.fetch_slot(intent.slot_id)
|
||||
if slot is None:
|
||||
return IntentDecision(intent.intent_id, "rejected", None, "slot_not_found")
|
||||
if slot.status is not SlotState.HOLDING:
|
||||
return IntentDecision(
|
||||
intent.intent_id, "rejected", None, f"slot_not_holding:{slot.status.value}"
|
||||
)
|
||||
try:
|
||||
updated = self._svc.transit_slot(
|
||||
slot.slot_id,
|
||||
SlotState.HOLDING,
|
||||
SlotState.PENDING_SELL,
|
||||
reason=intent.reason,
|
||||
actor="intent_api",
|
||||
sell_strategy_id=intent.sell_strategy_id,
|
||||
extra={"intent_id": intent.intent_id},
|
||||
)
|
||||
except SlotStateMismatchError:
|
||||
return IntentDecision(intent.intent_id, "rejected", None, "slot_state_changed")
|
||||
return IntentDecision(intent.intent_id, "admitted", updated.slot_id, None)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Application ports (REQ-007). The intent dedup/audit store contract.
|
||||
|
||||
Kept in the application layer (not domain) so the pure slot domain stays
|
||||
unaware of the intent concept. Concrete implementations live in adapters.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, Protocol
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
class IntentStore(Protocol):
|
||||
"""Durable idempotency + audit store for accepted intents (dcy-002 OQ-1).
|
||||
|
||||
`try_insert` is the dedup gate: it returns False if `intent_id` already
|
||||
exists, which prevents a duplicate intent from re-driving a slot
|
||||
transition (NFR-3). `finalize` records the decision after processing.
|
||||
"""
|
||||
|
||||
def try_insert(self, intent_id: str, intent_type: str, payload: dict) -> bool:
|
||||
"""Insert a new intent row in 'processing' state.
|
||||
|
||||
Returns True if newly inserted, False if `intent_id` already exists.
|
||||
"""
|
||||
...
|
||||
|
||||
def get(self, intent_id: str) -> Optional["IntentRecord"]:
|
||||
"""Return the stored intent record, or None."""
|
||||
...
|
||||
|
||||
def finalize(
|
||||
self,
|
||||
intent_id: str,
|
||||
status: str,
|
||||
slot_id: Optional[UUID],
|
||||
reason: Optional[str],
|
||||
) -> None:
|
||||
"""Update an intent row with its final decision (admitted/rejected)."""
|
||||
...
|
||||
|
||||
|
||||
class IntentRecord:
|
||||
"""Snapshot of an intent_log row (duck-typed; see adapters)."""
|
||||
|
||||
intent_id: str
|
||||
intent_type: str
|
||||
status: str
|
||||
slot_id: Optional[UUID]
|
||||
reason: Optional[str]
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Daemon configuration resolution (REQ-005)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DaemonConfig:
|
||||
dsn: str
|
||||
total_slots: int
|
||||
host: str
|
||||
port: int
|
||||
|
||||
@staticmethod
|
||||
def from_env() -> "DaemonConfig":
|
||||
dsn = os.environ.get("TREX_POSTGRESQL_DSN")
|
||||
if not dsn:
|
||||
raise RuntimeError(
|
||||
"TREX_POSTGRESQL_DSN is required to start the slot-core daemon."
|
||||
)
|
||||
return DaemonConfig(
|
||||
dsn=dsn,
|
||||
total_slots=int(os.environ.get("TREX_TOTAL_SLOTS", "10")),
|
||||
host=os.environ.get("TREX_API_HOST", "127.0.0.1"),
|
||||
port=int(os.environ.get("TREX_API_PORT", "8080")),
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Slot-core daemon entrypoint (REQ-005).
|
||||
|
||||
Builds the FastAPI app wired to PostgreSQL adapters, applies migrations and
|
||||
preallocates the trading day's slots on startup (lifecycle, not a public
|
||||
endpoint), then serves via uvicorn. Console script: `trex-core-daemon`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from trex_core.adapters.api.app import create_app
|
||||
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 (
|
||||
PostgreSQLIntentStore,
|
||||
PostgreSQLSlotRepository,
|
||||
)
|
||||
from trex_core.application.intents import IntentService
|
||||
from trex_core.domain.slot import SlotService
|
||||
from trex_core.runtime.config import DaemonConfig
|
||||
|
||||
|
||||
def build_app(config: DaemonConfig) -> FastAPI:
|
||||
"""Construct the daemon app with PostgreSQL adapters + startup lifecycle."""
|
||||
slot_repo = PostgreSQLSlotRepository(config.dsn)
|
||||
intent_store = PostgreSQLIntentStore(config.dsn)
|
||||
slot_service = SlotService(slot_repo)
|
||||
intent_service = IntentService(slot_service, slot_repo, intent_store)
|
||||
|
||||
base = create_app(
|
||||
slot_service=slot_service,
|
||||
slot_repo=slot_repo,
|
||||
intent_service=intent_service,
|
||||
default_total_slots=config.total_slots,
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
# Startup: migrate schema, preallocate today's slots (idempotent).
|
||||
apply_migrations(config.dsn)
|
||||
today = datetime.now(tz=timezone.utc).date()
|
||||
slot_service.preallocate_daily_slots(today, config.total_slots)
|
||||
yield
|
||||
# Shutdown: connection-per-call adapters hold no pooled state to close.
|
||||
|
||||
base.router.lifespan_context = lifespan
|
||||
return base
|
||||
|
||||
|
||||
def main() -> None: # pragma: no cover - process entrypoint
|
||||
import uvicorn
|
||||
|
||||
config = DaemonConfig.from_env()
|
||||
app = build_app(config)
|
||||
uvicorn.run(app, host=config.host, port=config.port)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
Reference in New Issue
Block a user