From a4e263c8e7a2995d1b99dcd3488a73b0dd7a9235 Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Thu, 4 Jun 2026 13:46:08 +0900 Subject: [PATCH] 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) --- .../design.md | 39 +++ .../implementation.md | 38 +++ .../index.md | 32 +++ .../planning.md | 47 ++++ .../verification.md | 53 ++++ docs/batches/index.md | 1 + docs/data-model.md | 22 +- docs/interface-contract.md | 31 ++- docs/project-structure.md | 40 +-- docs/runtime-flows.md | 28 +- .../req-006_postgresql-slot-persistence.md | 43 ++- .../req-009_daemon-api-baseline-alignment.md | 43 ++- .../req-005_slot-core-daemon-runtime.md | 43 ++- .../req-007_intent-submission-api.md | 43 ++- .../req-008_slot-readonly-query-api.md | 43 ++- docs/srs/index.md | 14 +- pyproject.toml | 10 +- tests/adapters/__init__.py | 0 tests/adapters/api/__init__.py | 0 tests/adapters/api/test_intent_api.py | 112 ++++++++ tests/adapters/persistence/__init__.py | 0 .../persistence/test_migration_and_drift.py | 55 ++++ .../persistence/test_postgresql_repository.py | 61 ++++ tests/application/__init__.py | 0 tests/application/test_intent_concurrency.py | 74 +++++ tests/conftest.py | 34 +++ trex_core/adapters/__init__.py | 0 trex_core/adapters/api/__init__.py | 0 trex_core/adapters/api/app.py | 114 ++++++++ trex_core/adapters/api/schemas.py | 61 ++++ trex_core/adapters/persistence/__init__.py | 0 .../persistence/in_memory_intent_store.py | 45 +++ trex_core/adapters/persistence/migrate.py | 31 +++ trex_core/adapters/persistence/postgresql.py | 260 ++++++++++++++++++ .../sql/001_create_slot_core_tables.sql | 74 +++++ trex_core/application/__init__.py | 0 trex_core/application/intents.py | 187 +++++++++++++ trex_core/application/ports.py | 49 ++++ trex_core/runtime/__init__.py | 0 trex_core/runtime/config.py | 27 ++ trex_core/runtime/daemon.py | 62 +++++ 41 files changed, 1778 insertions(+), 38 deletions(-) create mode 100644 docs/batches/bat-002_20260603_slot-core-daemon-http-api/design.md create mode 100644 docs/batches/bat-002_20260603_slot-core-daemon-http-api/implementation.md create mode 100644 docs/batches/bat-002_20260603_slot-core-daemon-http-api/index.md create mode 100644 docs/batches/bat-002_20260603_slot-core-daemon-http-api/planning.md create mode 100644 docs/batches/bat-002_20260603_slot-core-daemon-http-api/verification.md create mode 100644 tests/adapters/__init__.py create mode 100644 tests/adapters/api/__init__.py create mode 100644 tests/adapters/api/test_intent_api.py create mode 100644 tests/adapters/persistence/__init__.py create mode 100644 tests/adapters/persistence/test_migration_and_drift.py create mode 100644 tests/adapters/persistence/test_postgresql_repository.py create mode 100644 tests/application/__init__.py create mode 100644 tests/application/test_intent_concurrency.py create mode 100644 tests/conftest.py create mode 100644 trex_core/adapters/__init__.py create mode 100644 trex_core/adapters/api/__init__.py create mode 100644 trex_core/adapters/api/app.py create mode 100644 trex_core/adapters/api/schemas.py create mode 100644 trex_core/adapters/persistence/__init__.py create mode 100644 trex_core/adapters/persistence/in_memory_intent_store.py create mode 100644 trex_core/adapters/persistence/migrate.py create mode 100644 trex_core/adapters/persistence/postgresql.py create mode 100644 trex_core/adapters/persistence/sql/001_create_slot_core_tables.sql create mode 100644 trex_core/application/__init__.py create mode 100644 trex_core/application/intents.py create mode 100644 trex_core/application/ports.py create mode 100644 trex_core/runtime/__init__.py create mode 100644 trex_core/runtime/config.py create mode 100644 trex_core/runtime/daemon.py diff --git a/docs/batches/bat-002_20260603_slot-core-daemon-http-api/design.md b/docs/batches/bat-002_20260603_slot-core-daemon-http-api/design.md new file mode 100644 index 0000000..686d7c3 --- /dev/null +++ b/docs/batches/bat-002_20260603_slot-core-daemon-http-api/design.md @@ -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). diff --git a/docs/batches/bat-002_20260603_slot-core-daemon-http-api/implementation.md b/docs/batches/bat-002_20260603_slot-core-daemon-http-api/implementation.md new file mode 100644 index 0000000..c2d58e5 --- /dev/null +++ b/docs/batches/bat-002_20260603_slot-core-daemon-http-api/implementation.md @@ -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 는 고부하 시 풀링 필요(후속 최적화). diff --git a/docs/batches/bat-002_20260603_slot-core-daemon-http-api/index.md b/docs/batches/bat-002_20260603_slot-core-daemon-http-api/index.md new file mode 100644 index 0000000..4b726ef --- /dev/null +++ b/docs/batches/bat-002_20260603_slot-core-daemon-http-api/index.md @@ -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 에서 분리 판단). diff --git a/docs/batches/bat-002_20260603_slot-core-daemon-http-api/planning.md b/docs/batches/bat-002_20260603_slot-core-daemon-http-api/planning.md new file mode 100644 index 0000000..cd43884 --- /dev/null +++ b/docs/batches/bat-002_20260603_slot-core-daemon-http-api/planning.md @@ -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). diff --git a/docs/batches/bat-002_20260603_slot-core-daemon-http-api/verification.md b/docs/batches/bat-002_20260603_slot-core-daemon-http-api/verification.md new file mode 100644 index 0000000..b1903df --- /dev/null +++ b/docs/batches/bat-002_20260603_slot-core-daemon-http-api/verification.md @@ -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 로 차단), 연결 풀링 미도입 — 후속 인계. diff --git a/docs/batches/index.md b/docs/batches/index.md index 0aa6c01..85d1aca 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -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/) | diff --git a/docs/data-model.md b/docs/data-model.md index 5bb9936..231e26e 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -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). diff --git a/docs/interface-contract.md b/docs/interface-contract.md index c6cf41e..4cad592 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -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:`, `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). diff --git a/docs/project-structure.md b/docs/project-structure.md index dd227d8..a7645e3 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -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). diff --git a/docs/runtime-flows.md b/docs/runtime-flows.md index 2d7487e..fadc99b 100644 --- a/docs/runtime-flows.md +++ b/docs/runtime-flows.md @@ -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. diff --git a/docs/srs/Data/req-006_postgresql-slot-persistence.md b/docs/srs/Data/req-006_postgresql-slot-persistence.md index d021495..3c7f42d 100644 --- a/docs/srs/Data/req-006_postgresql-slot-persistence.md +++ b/docs/srs/Data/req-006_postgresql-slot-persistence.md @@ -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 재참조로 충분. diff --git a/docs/srs/Documentation/req-009_daemon-api-baseline-alignment.md b/docs/srs/Documentation/req-009_daemon-api-baseline-alignment.md index cee1be3..2b2e91a 100644 --- a/docs/srs/Documentation/req-009_daemon-api-baseline-alignment.md +++ b/docs/srs/Documentation/req-009_daemon-api-baseline-alignment.md @@ -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 재참조로 충분. diff --git a/docs/srs/Installation/req-005_slot-core-daemon-runtime.md b/docs/srs/Installation/req-005_slot-core-daemon-runtime.md index 991ec9e..762cebe 100644 --- a/docs/srs/Installation/req-005_slot-core-daemon-runtime.md +++ b/docs/srs/Installation/req-005_slot-core-daemon-runtime.md @@ -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 재참조로 충분. diff --git a/docs/srs/Interface/req-007_intent-submission-api.md b/docs/srs/Interface/req-007_intent-submission-api.md index 067e193..3ac5651 100644 --- a/docs/srs/Interface/req-007_intent-submission-api.md +++ b/docs/srs/Interface/req-007_intent-submission-api.md @@ -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 재참조로 충분. diff --git a/docs/srs/Interface/req-008_slot-readonly-query-api.md b/docs/srs/Interface/req-008_slot-readonly-query-api.md index 128e359..44d5826 100644 --- a/docs/srs/Interface/req-008_slot-readonly-query-api.md +++ b/docs/srs/Interface/req-008_slot-readonly-query-api.md @@ -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 재참조로 충분. diff --git a/docs/srs/index.md b/docs/srs/index.md index 9692715..0ddde62 100644 --- a/docs/srs/index.md +++ b/docs/srs/index.md @@ -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) \ No newline at end of file +- 2026-06-03 :: REQ-001~004 Proposed → Approved — 승인 게이트 통과. (confirm-req) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 46468fa..8411ccb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/adapters/__init__.py b/tests/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/adapters/api/__init__.py b/tests/adapters/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/adapters/api/test_intent_api.py b/tests/adapters/api/test_intent_api.py new file mode 100644 index 0000000..d7874a5 --- /dev/null +++ b/tests/adapters/api/test_intent_api.py @@ -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 diff --git a/tests/adapters/persistence/__init__.py b/tests/adapters/persistence/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/adapters/persistence/test_migration_and_drift.py b/tests/adapters/persistence/test_migration_and_drift.py new file mode 100644 index 0000000..c6eca4f --- /dev/null +++ b/tests/adapters/persistence/test_migration_and_drift.py @@ -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) diff --git a/tests/adapters/persistence/test_postgresql_repository.py b/tests/adapters/persistence/test_postgresql_repository.py new file mode 100644 index 0000000..76a07fb --- /dev/null +++ b/tests/adapters/persistence/test_postgresql_repository.py @@ -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 diff --git a/tests/application/__init__.py b/tests/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/application/test_intent_concurrency.py b/tests/application/test_intent_concurrency.py new file mode 100644 index 0000000..026998a --- /dev/null +++ b/tests/application/test_intent_concurrency.py @@ -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) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..964c8f6 --- /dev/null +++ b/tests/conftest.py @@ -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 diff --git a/trex_core/adapters/__init__.py b/trex_core/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/trex_core/adapters/api/__init__.py b/trex_core/adapters/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/trex_core/adapters/api/app.py b/trex_core/adapters/api/app.py new file mode 100644 index 0000000..33b7bbd --- /dev/null +++ b/trex_core/adapters/api/app.py @@ -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() diff --git a/trex_core/adapters/api/schemas.py b/trex_core/adapters/api/schemas.py new file mode 100644 index 0000000..042a157 --- /dev/null +++ b/trex_core/adapters/api/schemas.py @@ -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" diff --git a/trex_core/adapters/persistence/__init__.py b/trex_core/adapters/persistence/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/trex_core/adapters/persistence/in_memory_intent_store.py b/trex_core/adapters/persistence/in_memory_intent_store.py new file mode 100644 index 0000000..7eb9340 --- /dev/null +++ b/trex_core/adapters/persistence/in_memory_intent_store.py @@ -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 diff --git a/trex_core/adapters/persistence/migrate.py b/trex_core/adapters/persistence/migrate.py new file mode 100644 index 0000000..c92b791 --- /dev/null +++ b/trex_core/adapters/persistence/migrate.py @@ -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 diff --git a/trex_core/adapters/persistence/postgresql.py b/trex_core/adapters/persistence/postgresql.py new file mode 100644 index 0000000..67ea0b0 --- /dev/null +++ b/trex_core/adapters/persistence/postgresql.py @@ -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 diff --git a/trex_core/adapters/persistence/sql/001_create_slot_core_tables.sql b/trex_core/adapters/persistence/sql/001_create_slot_core_tables.sql new file mode 100644 index 0000000..d26e1b3 --- /dev/null +++ b/trex_core/adapters/persistence/sql/001_create_slot_core_tables.sql @@ -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); diff --git a/trex_core/application/__init__.py b/trex_core/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/trex_core/application/intents.py b/trex_core/application/intents.py new file mode 100644 index 0000000..b04b56e --- /dev/null +++ b/trex_core/application/intents.py @@ -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) diff --git a/trex_core/application/ports.py b/trex_core/application/ports.py new file mode 100644 index 0000000..872bc04 --- /dev/null +++ b/trex_core/application/ports.py @@ -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] diff --git a/trex_core/runtime/__init__.py b/trex_core/runtime/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/trex_core/runtime/config.py b/trex_core/runtime/config.py new file mode 100644 index 0000000..bd999ed --- /dev/null +++ b/trex_core/runtime/config.py @@ -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")), + ) diff --git a/trex_core/runtime/daemon.py b/trex_core/runtime/daemon.py new file mode 100644 index 0000000..f851499 --- /dev/null +++ b/trex_core/runtime/daemon.py @@ -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()