From 91fee4808d4fccfc2fafc593356d7d39d5dd330d Mon Sep 17 00:00:00 2001 From: jordan Date: Wed, 29 Jul 2026 19:56:55 +0900 Subject: [PATCH] feat: add shared account capital allocation policy --- .../design.md | 48 ++++ .../implementation.md | 40 +++ .../index.md | 29 ++ .../planning.md | 47 +++ .../verification.md | 62 ++++ docs/batches/index.md | 1 + ..._20260610_position-sizing-target-intent.md | 4 +- ...hared-account-capital-allocation-policy.md | 172 +++++++++++ docs/discovery/index.md | 1 + docs/project-structure.md | 11 +- ...hared-account-capital-allocation-policy.md | 160 +++++++++++ docs/srs/index.md | 8 +- tests/domain/test_capital_allocation.py | 197 +++++++++++++ trex_core/domain/capital_allocation.py | 269 ++++++++++++++++++ 14 files changed, 1041 insertions(+), 8 deletions(-) create mode 100644 docs/batches/bat-062_20260729_shared-account-capital-allocation/design.md create mode 100644 docs/batches/bat-062_20260729_shared-account-capital-allocation/implementation.md create mode 100644 docs/batches/bat-062_20260729_shared-account-capital-allocation/index.md create mode 100644 docs/batches/bat-062_20260729_shared-account-capital-allocation/planning.md create mode 100644 docs/batches/bat-062_20260729_shared-account-capital-allocation/verification.md create mode 100644 docs/discovery/dcy-042_20260729_shared-account-capital-allocation-policy.md create mode 100644 docs/srs/Interface/req-127_shared-account-capital-allocation-policy.md create mode 100644 tests/domain/test_capital_allocation.py create mode 100644 trex_core/domain/capital_allocation.py diff --git a/docs/batches/bat-062_20260729_shared-account-capital-allocation/design.md b/docs/batches/bat-062_20260729_shared-account-capital-allocation/design.md new file mode 100644 index 0000000..ced44e0 --- /dev/null +++ b/docs/batches/bat-062_20260729_shared-account-capital-allocation/design.md @@ -0,0 +1,48 @@ +# BAT-062 Design + +- Status: confirmed +- Batch: BAT-062 + +## Architecture Summary + +- `trex_core.domain.capital_allocation`은 외부 상태를 읽지 않는 순수 정책이다. +- 호출 계층이 계좌 가용 현금과 후보별 보호 한 주 가격을 준비하고, 정책은 후보별 배정액과 제외 사유를 반환한다. +- 전략 조립 계층은 이 배정액으로 최종 정수 주문수량을 계산하며, 기존 Core 주문 실행과 Portfolio Hub 원장은 그대로 유지한다. + +## Changed Areas + +- Docs: `docs/project-structure.md`, Discovery·REQ·BAT 문서 +- Modules: `trex_core/domain/capital_allocation.py`, 로컬 KIS 어댑터 연결부 +- Interfaces: `CapitalCandidate`, `CapitalAllocation`, `CapitalAllocationPlan`, `protected_share_cost`, `plan_liquid_capital`, `allocate_liquid_slots` + +## Key Decisions + +1. 공통 계산은 Core가 소유하지만 브로커 현금·호가 조회와 실제 주문수량 산출은 소유하지 않는다. +2. 한 주 유동 여유는 가장 싼 보호 한 주 가격 단위로 제한한다. +3. 최악 활성 슬롯 조합을 먼저 만족시킨 뒤 최대 공통 배정액을 이분 탐색한다. +4. 제외와 현금 없음은 0원만 반환하지 않고 안정적인 사유를 함께 반환한다. +5. 기존 어댑터 호출부는 `portfolio_id → 배정액` 호환 표면으로 단계적으로 전환한다. + +## Edge Cases + +- 현금 0: 전 후보 `no_available_cash`. +- 빈 후보·음수 현금·0 이하 슬롯·중복 식별자·0 이하 보호가격: 명시적 예외. +- 적격 후보가 슬롯보다 적음: 실제 적격 수만 활성 조합에 포함. +- 동가 최고 후보 제외: 입력 순서의 뒤 후보를 먼저 제외해 기존 공식과 결정성을 보존. +- 후보가 모두 제외됨: 사유를 보존한 0원 계획. + +## Architecture Impact + +- project-structure + +## Reference Doc Update Plan + +- project-structure.md: 새 순수 도메인 모듈, 책임, 의존 방향, 테스트 경로를 추가한다. +- runtime-flows.md: 런타임·주문 흐름 변경이 없어 갱신하지 않는다. + +## 비설계 대상 + +- `BrokerPort` 현금 조회 확장 +- `BuyIntent` 또는 메시지 계약 변경 +- Portfolio Hub API·원장 변경 +- 실전 배포·주문 활성화 diff --git a/docs/batches/bat-062_20260729_shared-account-capital-allocation/implementation.md b/docs/batches/bat-062_20260729_shared-account-capital-allocation/implementation.md new file mode 100644 index 0000000..f2f3665 --- /dev/null +++ b/docs/batches/bat-062_20260729_shared-account-capital-allocation/implementation.md @@ -0,0 +1,40 @@ +# BAT-062 Implementation + +- Status: completed +- Batch: BAT-062 + +## Plan Summary + +- 로컬 KIS 어댑터의 검증된 배분 공식을 Core 순수 도메인으로 옮기고, 어댑터는 외부 입력과 결과 매핑만 담당하도록 정리한다. + +## Changed Files + +- `trex_core/domain/capital_allocation.py` +- `tests/domain/test_capital_allocation.py` +- `docs/project-structure.md` +- `docs/discovery/dcy-042_20260729_shared-account-capital-allocation-policy.md` +- `docs/srs/Interface/req-127_shared-account-capital-allocation-policy.md` +- `docs/batches/bat-062_20260729_shared-account-capital-allocation/*` +- 별도 docker-local 작업트리의 `live_tools/account_capital_allocator.py` + +## Execution Log + +1. 보호 한 주 가격, 후보·배정·계좌 계획 불변 데이터 모델을 추가했다. +2. 균등 기준, 한 주 유동 여유, 최악 활성 슬롯 제거, 최대 공통 배정액 계산을 구현했다. +3. 입력 검증과 계획 내부 현금 불변식 검사를 추가했다. +4. 메가·탑50 대표값, 오류 입력, 10,000건 무작위 불변식 테스트를 추가했다. +5. 로컬 KIS 어댑터의 중복 계산을 Core 정책 호출로 대체했다. + +## Validation + +- Core 신규 테스트: 16 passed. +- Core 전체 회귀: 849 passed, 47 skipped. +- 기존 어댑터 공식 50,000건 완전 일치: PASS. +- Core 연결 로컬 어댑터 전체 회귀: 161 passed, 4 skipped. +- 탑50 월간 가상거래 요약·거래원장·배분결정 원장 완전 일치: PASS. +- 최종 재실행 결과는 `verification.md`에 고정한다. + +## Remaining Risks + +- 이 브랜치는 실전 이미지에 반영되지 않았다. Core 병합 후 어댑터 이미지에서 모듈 가용성과 공용 가상 인증을 다시 확인해야 한다. +- 주문수량·주문 게이트·계좌 설정 변경은 이 배치에 포함되지 않는다. diff --git a/docs/batches/bat-062_20260729_shared-account-capital-allocation/index.md b/docs/batches/bat-062_20260729_shared-account-capital-allocation/index.md new file mode 100644 index 0000000..6730894 --- /dev/null +++ b/docs/batches/bat-062_20260729_shared-account-capital-allocation/index.md @@ -0,0 +1,29 @@ +# BAT-062: 공통 계좌 자본배분 정책 + +- Status: release-candidate +- Profile: standard +- Source Discovery: dcy-042_20260729_shared-account-capital-allocation-policy +- Source REQ: REQ-127 +- Included REQ: REQ-127 +- Excluded REQ: 없음 + +## Scope + +### In Scope + +- Core 순수 도메인 자본배분 정책과 입력 검증 +- 메가·탑50 대표 경계값 및 무작위 불변식 테스트 +- 로컬 KIS 어댑터를 Core 호출 연결부로 축소 +- 기존 공식 완전 일치, 전체 회귀, 탑50 월간 가상 원장 비교 +- 프로젝트 구조 문서 동기화 + +### Out of Scope + +- 브로커·Portfolio Hub API 또는 데이터 변경 +- 주문 의도, 슬롯 상태머신, 실전 런타임·주문 게이트 변경 +- 계좌 입금·전략 선택·주문 실행 + +## Notes + +- 병렬 Core 브랜치의 BAT-059~061과 충돌하지 않도록 BAT-062를 사용한다. +- 실전 적용은 Core 병합·이미지 검증과 별도 운영 승인 뒤에만 수행한다. diff --git a/docs/batches/bat-062_20260729_shared-account-capital-allocation/planning.md b/docs/batches/bat-062_20260729_shared-account-capital-allocation/planning.md new file mode 100644 index 0000000..0ec0286 --- /dev/null +++ b/docs/batches/bat-062_20260729_shared-account-capital-allocation/planning.md @@ -0,0 +1,47 @@ +# BAT-062 Planning + +- Status: confirmed +- Batch: BAT-062 +- Profile: standard + +## Included REQ + +- REQ-127 (Approved): 공통 계좌 자본배분 순수 정책 + +## Delivery Plan + +1. 기존 로컬 어댑터 공식과 Core 책임 경계를 고정한다. +2. Core에 데이터 모델·보호가격·배분 계획·호환 결과 함수를 구현한다. +3. 로컬 KIS 어댑터는 현금·호가 입력과 Core 결과 매핑만 남긴다. +4. 단위·속성·완전 일치·전체 회귀·월간 가상거래를 순서대로 검증한다. +5. 문서와 검증 증거를 동기화한 뒤 Draft PR로 제출한다. + +## Dependencies + +- Internal: + - Python 표준 `dataclasses`, `decimal` + - 기존 `dcy-021`/`REQ-061` 주문수량 계약 +- External: + - 검증 시 로컬 KIS 어댑터와 탑50 가상거래 도구 + - 실전 브로커·Portfolio Hub 연결은 필요하지 않음 + +## Design Gate + +- Design Required Before Implementation: yes +- Reason: 새 공개 도메인 모듈과 프로젝트 구조 기준선이 추가되며 Core·어댑터·전략·원장 책임 경계를 구현 전에 고정해야 한다. + +## Milestones + +1. 순수 정책과 Core 단위·속성 테스트 +2. 어댑터 얇은 연결부와 기존 공식 완전 일치 +3. 전체 회귀와 메가·탑50 가상 검증 +4. 문서·품질 게이트와 Draft PR + +## Risks + +- 기존 공식과 반올림 또는 동률 제외 순서가 달라질 수 있다. + - 대응: 고정 시드 50,000건 결과 완전 일치. +- Core가 외부 현금·호가 조회 책임까지 흡수할 수 있다. + - 대응: 새 모듈의 외부 의존을 금지하고 어댑터 연결부를 별도 검토. +- 실제 현금보다 큰 동시 슬롯 배정이 생길 수 있다. + - 대응: 최악 활성 슬롯 합 불변식을 구현 내부와 10,000건 속성 테스트에서 이중 검증. diff --git a/docs/batches/bat-062_20260729_shared-account-capital-allocation/verification.md b/docs/batches/bat-062_20260729_shared-account-capital-allocation/verification.md new file mode 100644 index 0000000..9728f74 --- /dev/null +++ b/docs/batches/bat-062_20260729_shared-account-capital-allocation/verification.md @@ -0,0 +1,62 @@ +# BAT-062 Verification + +- Status: confirmed +- Batch: BAT-062 +- Verified By: delivery-runner + +## Acceptance Mapping + +| AC | 결과 | 근거 | +| --- | --- | --- | +| REQ-127 AC-1 | PASS | `test_mega_two_million_excludes_hynix_and_uses_samsung_lane` | +| REQ-127 AC-2 | PASS | `test_mega_three_million_flexes_one_share_into_hynix_lane` | +| REQ-127 AC-3 | PASS | `test_top50_three_slots_exclude_unaffordable_high_price_candidate` | +| REQ-127 AC-4 | PASS | `test_zero_cash_fails_closed_with_explicit_reason` | +| REQ-127 AC-5 | PASS | 잘못된 보호가격·현금·슬롯·후보·중복·유동 여유 입력 단위 테스트 | +| REQ-127 AC-6 | PASS | 고정 시드 10,000건 최악 활성 슬롯 현금 불변식 | +| REQ-127 AC-7 | PASS | 이전 어댑터 커밋 `46f5936`과 고정 시드 50,000건 결과 완전 일치 | +| REQ-127 AC-8 | PASS | Core 849 passed/47 skipped, Core 연결 어댑터 161 passed/4 skipped | +| REQ-127 AC-9 | PASS | 탑50 월간 요약 동일, `trade_ledger.csv`·`allocation_decisions.csv` 바이트 동일 | +| REQ-127 AC-10 | PASS | Core·docker-local 격리 작업트리만 변경, 실전 런타임·주문 게이트·계좌·Hub 변경 없음 | + +## Evidence + +- Test: + - Core 정책: 16 passed. + - Core 전체: 849 passed, 47 skipped. + - Core 대상 Ruff: PASS. + - 로컬 어댑터 연결부: 11 passed. + - 로컬 어댑터 전체: 161 passed, 4 skipped. + - 기존 공식 완전 일치: 50,000 cases PASS, seed `20260729`. + - 탑50 월간 가상계좌: PASS, 111 signals, 71 completed trades, + max active 3, minimum cash 40,500원, open orders 0, positions 0, + duplicate orders/fills 0, restart equivalent true. + - 공용 가상 인증: logic→regression→virtual contract→virtual end-to-end + →fault injection→restart recovery→ledger reconcile 1~7단계 PASS. +- Manual: + - Core는 순수 계산, 어댑터는 현금·호가, 전략은 후보·슬롯·최종 수량, + Portfolio Hub는 원장 정본으로 책임 경계를 대조했다. +- Logs: + - 탑50 월간 최종 재실행: `/tmp/top50-core-policy-final.AhUjsm` + - 공용 인증: + `/Volumes/J_HDD/virtual_exchange/artifacts/validation/trex-top50-swing-v1/top50-core-allocation-20260729-1953-kst/run.json` + +## Architecture Verification + +- Architecture Impact: `project-structure`. +- `docs/project-structure.md`에 새 모듈·책임·의존 규칙과 테스트 경로를 동기화했다. +- `runtime-flows.md`는 런타임 흐름 변경이 없어 갱신 대상이 아니다. + +## Result + +- Ready for Release: true +- Blocking Issues: + - 없음. + +## Safety Classification + +- `research_only=true` +- `official_comparable=false` +- `real_order_allowed=false` +- `order_adapter=virtual_locked_replay` +- 이 확인은 실전 배포·실주문·주문 게이트 활성화를 승인하지 않는다. diff --git a/docs/batches/index.md b/docs/batches/index.md index bc1ea35..a7be732 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -65,3 +65,4 @@ | BAT-057 | standard | release-candidate | REQ-119, REQ-120, REQ-121, REQ-122 | dcy-037 | [bat-057](./bat-057_20260714_operator-broker-truth-hotfix/) | | BAT-058 | standard | release-candidate | REQ-123 | dcy-038 | [bat-058](./bat-058_20260716_cost-basis-safe-sync-seed/) | | BAT-059 | standard | release-candidate | REQ-124 | dcy-039 | [bat-059](./bat-059_20260727_intent-venue-fanin/) | +| BAT-062 | standard | release-candidate | REQ-127 | dcy-042 | [bat-062](./bat-062_20260729_shared-account-capital-allocation/) | diff --git a/docs/discovery/dcy-021_20260610_position-sizing-target-intent.md b/docs/discovery/dcy-021_20260610_position-sizing-target-intent.md index 302e4c3..6f50a9a 100644 --- a/docs/discovery/dcy-021_20260610_position-sizing-target-intent.md +++ b/docs/discovery/dcy-021_20260610_position-sizing-target-intent.md @@ -5,9 +5,9 @@ - 문서 ID: dcy-021_20260610_position-sizing-target-intent - 이슈명: 포지션 사이징 — 활성 자산 슬롯 보유 수량 변경(HOLDING→HOLDING)·부분 매도·선언형 target intent (issue #1, dcy-020 OQ-4 후속) - 작성 시각(KST): 2026-06-10 17:06 KST -- 마지막 갱신 시각(KST): 2026-06-10 17:46 KST (draft-req: REQ-066·067 생성 + change-req REQ-057/061/062 예정) +- 마지막 갱신 시각(KST): 2026-07-29 19:43:47 KST (후속 자본배분 정책 Discovery 참조 연결) - 대체됨: 없음 -- 후속 Discovery 참조: 없음 +- 후속 Discovery 참조: `docs/discovery/dcy-042_20260729_shared-account-capital-allocation-policy.md` - 생성된 REQ 참조: REQ-066, REQ-067 (+ change-req REQ-057/061/062) # 1. 계획 상태 요약 diff --git a/docs/discovery/dcy-042_20260729_shared-account-capital-allocation-policy.md b/docs/discovery/dcy-042_20260729_shared-account-capital-allocation-policy.md new file mode 100644 index 0000000..ac18791 --- /dev/null +++ b/docs/discovery/dcy-042_20260729_shared-account-capital-allocation-policy.md @@ -0,0 +1,172 @@ +# 0. 문서 상태 + +- 상태: confirmed +- 문서 ID: dcy-042_20260729_shared-account-capital-allocation-policy +- 이슈명: 메가 스윙과 탑50 스윙 공통 계좌 자본배분 정책 +- 작성 시각(KST): 2026-07-29 19:43:47 KST +- 마지막 갱신 시각(KST): 2026-07-29 19:47:39 KST (REQ-127 생성·승인) +- 대체됨: 없음 +- 선행 Discovery: dcy-021_20260610_position-sizing-target-intent +- 후속 Discovery 참조: 없음 +- 생성된 REQ 참조: `docs/srs/Interface/req-127_shared-account-capital-allocation-policy.md` + +# 1. 계획 상태 요약 + +- 연계 문서 현황: + - `dcy-021`은 전략이 최종 정수 주문수량과 가격을 계산하고 Core는 실행하는 계약을 확정했다. + - 로컬 KIS 어댑터에는 메가 스윙과 탑50 스윙이 공통으로 쓸 수 있는 계좌 현금 배분 계산이 존재한다. +- 해석: + - 종목 가격 때문에 균등 슬롯 금액으로 한 주도 살 수 없는 후보는 제외하고, 살 수 있는 후보끼리 실제 현금을 넘지 않게 유동 배분해야 한다. + - 이 결정적 계산은 Core 도메인이 소유하고, KIS 어댑터는 현금·호가 조회와 설정 반영만 담당하는 편이 재사용과 검증에 적합하다. + +# 2. 요구사항 판정 결과 + +- 판정: 새 Discovery 생성 +- 근거: + - `dcy-021`은 주문수량 계약과 실행 책임을 다루며, 계좌 후보군 전체의 현금 배분 정책은 명시적으로 범위 밖이었다. + - 이번 변경은 메가 스윙과 탑50 스윙의 공통 정책 소유권을 Core로 옮기는 독립 후속 반복이다. + +# 3. 문제점의 요약 + +## 현재 상태 + +- Core에는 주문·체결·슬롯 상태는 있으나, 후보 가격과 슬롯 수를 받아 계좌 현금을 안전하게 나누는 순수 도메인 정책이 없다. +- 동일한 배분 공식이 로컬 KIS 어댑터에만 있어 다른 전략이 재사용하려면 어댑터 구현에 의존하거나 공식을 복제해야 한다. +- Portfolio Hub는 현금·보유·손익 원장의 정본이며, 기존 `BuyIntent`는 전략이 계산한 정수 수량과 가격을 그대로 전달한다. + +## 기대 상태 + +- Core가 네트워크·브로커·전략에 의존하지 않는 결정적 자본배분 정책을 제공한다. +- 전략은 후보군과 슬롯 수를 정하고, 어댑터는 계좌 현금과 보호 호가를 입력한 뒤 Core 결과를 기존 주문수량 계산에 사용한다. +- Portfolio Hub 원장과 기존 주문 의도 계약은 변경하지 않는다. + +## 이해관계자 및 제약 + +- 운영자: 메가 스윙과 탑50 스윙을 같은 계좌 배분 원칙으로 운영한다. +- Core 개발자: 공통 순수 정책과 불변식의 유지보수 주체다. +- 전략·KIS 어댑터 개발자: 외부 입출력과 전략별 후보·슬롯 연결을 유지한다. +- 실전 런타임·주문 게이트·계좌 상태는 본 반복에서 변경하지 않는다. +- 토큰·키·계좌번호·원본 브로커 응답은 코드·문서·검증 산출물에 남기지 않는다. + +## 주요 사용자 시나리오 + +1. 메가 스윙 계좌의 절반으로 고가 종목 한 주를 살 수 없으면 해당 종목을 제외하고 저가 종목에 가용 현금을 배정한다. +2. 입금으로 고가 종목 한 주를 포함할 여력이 생기면 한 주 가격만큼 유동적으로 양보해 두 종목을 함께 운영한다. +3. 탑50 스윙 3슬롯에서 5개 후보 중 한 주도 살 수 없는 고가 후보를 제외하고, 가능한 후보의 최악 3종 조합도 현금을 넘지 않게 배정한다. + +# 4. 이번에 정의할 변경 + +1. Core에 보호된 한 주 가격과 슬롯 수를 입력받는 순수 자본배분 정책을 추가한다. +2. 적격·제외 사유와 종목별 최대 배정액을 결정적으로 반환하고, 어떤 활성 슬롯 조합도 실제 현금을 넘지 않는 불변식을 둔다. +3. KIS 어댑터의 중복 공식을 제거하고 현금·호가 입력과 Core 결과 매핑만 남긴다. + +## 영향 범위 + +- 코드 영향: + - `trex_core/domain/capital_allocation.py` + - `tests/domain/test_capital_allocation.py` + - 로컬 KIS 어댑터의 호환 연결부 +- 문서 영향: + - `docs/project-structure.md` + - 신규 요구사항·배치·검증 문서 +- 운영 영향: + - 이번 브랜치에서는 실전 배포와 주문 활성화가 없다. + +# 5. 요구사항 목록 + +## 기능 요구사항 + +- FR-1: 보호된 한 주 가격은 현재가와 실행 여유율을 반영해 원 단위 올림으로 계산한다. +- FR-2: 기본 슬롯 균등액보다 비싼 후보도 가장 싼 후보 한 주 가격 범위의 유동 여유 안이면 적격으로 유지한다. +- FR-3: 적격 후보 중 최악의 활성 슬롯 조합이 현금을 넘으면 가장 비싼 후보부터 결정적으로 제외한다. +- FR-4: 남은 후보에는 한 주 이상 살 수 있으면서 최악의 활성 슬롯 조합 합계가 가용 현금을 넘지 않는 최대 공통 배정액을 준다. +- FR-5: 결과는 후보별 배정액과 `eligible`, `no_available_cash`, `protected_share_above_flex_limit`, `worst_case_cash_overcommit` 사유를 제공한다. + +## 비기능 요구사항 + +- NFR-1: 정책은 네트워크, 환경변수, 저장소, 브로커 및 전략 코드에 의존하지 않는 순수 함수여야 한다. +- NFR-2: 입력 순서가 같은 경우 결과가 항상 같고, 중복 식별자·음수 현금·잘못된 가격은 명시적으로 거부해야 한다. +- NFR-3: 기존 로컬 공식과 대량 무작위 입력에서 완전 일치하고, 메가·탑50 가상 검증 결과가 바뀌지 않아야 한다. + +## 범위 경계 + +- In Scope: + - 공통 배분 계산과 입력 검증, 제외 사유, 불변식 검증 + - 로컬 어댑터의 얇은 연결부와 메가·탑50 가상 회귀 +- Out of Scope: + - Core가 브로커 현금을 직접 조회하거나 전략 후보를 선택하는 기능 + - `BuyIntent` 수량·가격 계약, Portfolio Hub 원장, 슬롯 상태머신 변경 + - 자동 주문수량 재조정 스케줄러, 실전 배포, 주문 게이트 변경 + +# 6. 리스크/가정 목록 + +## 리스크 + +- R-1 (High): Core가 현금·호가 조회까지 소유하면 브로커와 Portfolio Hub 경계가 섞인다. + - 완화: Core에는 순수 계산만 두고 외부 조회는 어댑터에 남긴다. +- R-2 (High): 기존 공식과 작은 반올림 차이가 실제 주문 가능 종목을 바꿀 수 있다. + - 완화: 기존 공식과 50,000개 무작위 입력 완전 일치 및 대표 경계값 회귀를 통과시킨다. +- R-3 (Medium): 적격 후보별 배정액의 합만 비교하면 실제로 동시에 열릴 수 있는 최악 조합을 놓칠 수 있다. + - 완화: 슬롯 수만큼의 가장 큰 양수 배정액 합이 현금 이하인지 정책 내부와 속성 테스트에서 검증한다. + +## 가정 + +- A-1: 슬롯 수와 후보군은 전략·계정 설정이 제공한다. +- A-2: 가용 현금과 현재가는 KIS 어댑터 또는 동등한 외부 경계가 최신 값을 제공한다. +- A-3: 최종 정수 주문수량과 가격을 담은 주문 의도는 기존 `dcy-021` 계약대로 전략 조립 계층이 만든다. + +## 오픈 질문 + +- 없음. 사용자가 공통 배분을 Core에 두고 가상 검증 후 Core 브랜치로 올리는 방향을 승인했다. + +# 7. 초기 성공 기준 + +- S-1: 메가 스윙 200만원·300만원 대표 사례와 탑50 3슬롯 고가종목 제외 사례가 기대 배분을 반환한다. +- S-2: 10,000개 무작위 입력에서 최악 활성 슬롯 배정 합이 가용 현금을 초과하지 않는다. +- S-3: 기존 어댑터 공식과 50,000개 무작위 입력의 결과가 완전히 일치한다. +- S-4: Core 전체 회귀, 어댑터 전체 회귀, 탑50 월간 가상 원장 비교가 모두 통과한다. + +## 측정 방식 및 데이터 출처 + +- S-1/S-2: `tests/domain/test_capital_allocation.py` +- S-3: 기존 어댑터와 Core 정책의 고정 시드 비교 스크립트 출력 +- S-4: Core·docker-local 테스트 결과와 탑50 월간 가상거래 요약·거래원장·배분결정 파일 + +# 8. REQ로 넘기기 전 확인 체크 + +- [x] 범위와 책임 경계를 사용자 승인 및 기존 Core 계약으로 확정했다. +- [x] 성공 기준의 측정 방식과 데이터 출처를 지정했다. +- [x] High 리스크는 순수 도메인 경계와 완전 일치 회귀로 해소한다. +- [x] `Open` 상태의 질문이 없다. +- [x] Freeze 확정 담당자는 본 방향을 승인한 사용자다. + +# 9. 파일 처리 결과 + +- 처리 결과: 생성 +- 생성 경로: `docs/discovery/dcy-042_20260729_shared-account-capital-allocation-policy.md` +- 참조 문서: + - `docs/discovery/dcy-021_20260610_position-sizing-target-intent.md` + - `docs/srs/Interface/req-061_asset-keyed-intent-contract.md` + - `docs/project-structure.md` + +# 10. 사용자 결정 필요 항목 요약 + +- 없음. 공통 정책의 Core 소유, 어댑터의 외부 입출력 소유, 가상 검증 후 Core Draft PR 순서를 사용자가 승인했다. + +# 11. Discovery Freeze + +## 섹션 참조 + +- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록) +- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록) +- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준) +- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크) +- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약) + +## Freeze 플래그 + +- Handoff Decision: 승인 +- Handoff Rationale: 기존 주문수량 계약과 새 순수 정책의 책임 경계를 코드·문서로 재확인했고, 사용자 승인 범위와 충돌하는 오픈 질문이 없다. +- Ready for REQ Drafting: true +- Confirmed By: 형님 +- Confirmed At (KST): 2026-07-29 19:45:57 KST diff --git a/docs/discovery/index.md b/docs/discovery/index.md index 56975d4..8781d27 100644 --- a/docs/discovery/index.md +++ b/docs/discovery/index.md @@ -45,3 +45,4 @@ | dcy-038_20260716_cost-basis-response-contract-and-safe-sync-seeding | 20260716 | P1 — portfolio-hub cost-basis response contract 정합 및 안전한 sync 평균가 seed | [Discovery](./dcy-038_20260716_cost-basis-response-contract-and-safe-sync-seeding.md) | confirmed | | dcy-039_20260727_intent-venue-end-to-end-propagation | 20260727 | 전략 intent venue를 슬롯·주문·취소까지 전달 | [Discovery](./dcy-039_20260727_intent-venue-end-to-end-propagation.md) | confirmed | | dcy-041_20260729_broker-uncertainty-and-adapter-retirement | 20260729 | Core 브로커 불확실 상태 구분과 로컬 KIS 어댑터 축소 (#44, #45) | [Discovery](./dcy-041_20260729_broker-uncertainty-and-adapter-retirement.md) | confirmed | +| dcy-042_20260729_shared-account-capital-allocation-policy | 20260729 | 메가 스윙과 탑50 스윙 공통 계좌 자본배분 정책 | [Discovery](./dcy-042_20260729_shared-account-capital-allocation-policy.md) | confirmed | diff --git a/docs/project-structure.md b/docs/project-structure.md index 5f0c3d7..6f408a0 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -4,7 +4,7 @@ - Baseline Seed: .stage-pilot/bootstrap/baseline.yaml - Status: draft - Owner: project maintainers -- Last Updated (KST): 2026-06-19 (BAT-047 — dcy-031 이벤트 발행 연결 신뢰성) +- Last Updated (KST): 2026-07-29 (BAT-062 — 공통 계좌 자본배분 순수 정책) - Source Discovery / Batch: bootstrap-baseline (pre-Discovery, 3-phase lifecycle 밖의 bootstrap 산출물) ## Project Summary @@ -66,6 +66,7 @@ │ ├── domain/slot/ # slot 순수 도메인 (BAT-001, 무의존) │ │ ├── __init__.py · state.py · models.py(+EXPECTED_SLOT_FIELDS) · errors.py │ │ ├── repository.py(Protocol, instance 스코프) · service.py · in_memory_repository.py +│ ├── domain/capital_allocation.py # 전략 공통 계좌 자본배분 순수 정책 (BAT-062 / REQ-127) │ ├── domain/pricing.py # KRX 호가단위(tick) 산출 순수 함수 (BAT-044 / REQ-083, dcy-030) │ ├── application/ # 유스케이스 (BAT-002, BAT-004, BAT-006, BAT-007, BAT-008) │ │ ├── ports.py # IntentStore Protocol @@ -98,6 +99,7 @@ │ └── cli.py # trex-core CLI: pause/resume/paused + universe/slots/enter/exit/cancel + report(REQ-088) (BAT-008/020/046 / REQ-032) └── tests/ ├── domain/slot/ # test_state / test_service / test_contract + ├── domain/test_capital_allocation.py # 메가·탑50 대표값 + 무작위 현금 불변식 (BAT-062) ├── domain/ # test_domain_import_sweep (NFR-1 가드, BAT-006/BAT-007) ├── application/ # test_intent_concurrency · test_execution · test_execution_ledger · test_ledger_outbox · test_messaging_intent_consumer · test_messaging_snapshot_consumer · test_messaging_event_publisher ├── runtime/ # test_config_dotenv · test_daemon_no_http_surface (BAT-007 HTTP 표면 0 회귀 가드) @@ -112,6 +114,10 @@ - `trex_core/domain/slot/` - 자산 중심 영구 슬롯 순수 도메인. 5 상태 머신(`state.py`, inactive 기준 + 인스턴스 스코프 advisory lock id), 데이터 모델 + 컬럼 계약(`models.py`, `EXPECTED_SLOT_FIELDS` 포함), 예외(`errors.py`), 저장소 계약(`repository.py` Protocol — instance 스코프 list/count/reconcile), 도메인 서비스(`service.py`), in-memory 구현(`in_memory_repository.py`). - 외부 의존 0 — stdlib 와 패키지 내부 참조만 사용(정적 import 스윕으로 확인됨). +- `trex_core/domain/capital_allocation.py` (BAT-062 / dcy-042) + - 계좌 가용 현금, 슬롯 수, 후보별 보호 한 주 가격을 받아 후보별 최대 배정액과 제외 사유를 결정한다. + - 브로커·Portfolio Hub·전략·환경·저장소를 읽지 않는다. 호출 계층이 외부 입력과 후보·슬롯을 제공하고 최종 주문수량을 산출한다. + - 최악 활성 슬롯 배정 합이 실제 현금을 넘지 않는 불변식을 모듈 내부와 속성 테스트에서 검증한다. - `trex_core/application/strategy_state.py` (BAT-042 / dcy-028) - slot strategy-state 저장소 경계: `StrategyStateStore` Protocol + `InMemoryStrategyStateStore` + 공용 `_cas`(occupancy_seq+version) + reject reason 상수 + byte size cap. opaque UTF-8 `data`(core 무해석). PG 구현은 `adapters/persistence/postgresql.py`의 `PgStrategyStateStore`(`slot_strategy_state` 테이블, 마이그레이션 014/015). 슬롯 본체에는 `Slot.occupancy_seq` 컬럼 1개만 추가(full 3계층 분할은 후속). - `tests/domain/slot/` @@ -123,7 +129,7 @@ ## Dependency Rules -- 순수 도메인(`trex_core/domain/slot/`)은 저장소 구현, 브로커, 네트워크, CLI 등 인프라에 직접 의존하지 않는다. +- 순수 도메인(`trex_core/domain/slot/`, `trex_core/domain/capital_allocation.py`)은 저장소 구현, 브로커, 네트워크, CLI 등 인프라에 직접 의존하지 않는다. - 저장소 접근은 오직 `SlotRepository` Protocol 을 통해서만 이뤄지고 구현체는 주입된다. - 인프라(향후 PostgreSQL 구현, CLI, config)는 도메인을 의존할 수 있으나 그 역은 금지한다. @@ -161,3 +167,4 @@ - 2026-06-07 :: BAT-013 — 다중 인스턴스 RabbitMQ 토폴로지(`topology.py` 동적 큐명 빌더 `intent/snapshot/control_queue(instance_key)` default 리터럴 하위호환 + `trex.direct`(topic)/`trex.broadcast`(fanout) exchange + `direct_routing_key`, `rabbitmq.declare_topology(instance_key)` per-instance 큐/바인딩+control dual, `config.DaemonInstanceConfig.from_toml`+`DaemonConfig.from_config`+`instance_key` 필드, daemon `--config` 필수 진입점 + instance_key 큐 구독, `.gitignore` 인스턴스 TOML + `config/instance.example.toml`) (REQ-048~051). - 2026-06-10 :: BAT-030 (dcy-020 마감) — 자산 중심 전환: `runtime/reset.py` + `trex-core-reset` 콘솔 스크립트(데이터 보존 cutover 폐기 — wipe+재초기화로 대체, REQ-064 CHG-20260610-03), `application/preallocation_scheduler.py` 삭제(일일 preallocation 폐기), 마이그레이션 010(inactive CHECK)/011(trading_date·entry_count drop), repo 계약 instance 스코프 개편, `ops_checks.py` transition_log 기반 재작성, config 에서 `total_slots`/`daily_preallocation_time` 제거 (REQ-056~065). - 2026-06-10 :: BAT-031 (dcy-021 포지션 사이징) — state.py(holding→pending_buy 전이), service.py(pending_sell qty sentinel), intents.py(증액 admission·SellIntent.qty), execution.py(부분 매도 dispatch·매수 체결 가중평균·브로커 정본 resync·증액 롤백), messaging_handlers.py(sell qty). 신규 코드 모듈 없음(기존 확장). (REQ-066/067/057/061/062) +- 2026-07-29 :: BAT-062 (dcy-042 공통 계좌 자본배분) — `domain/capital_allocation.py` 순수 정책과 대표값·무작위 불변식 테스트 추가. 외부 현금·호가 조회와 최종 주문수량 산출은 호출 계층에 유지. (REQ-127) diff --git a/docs/srs/Interface/req-127_shared-account-capital-allocation-policy.md b/docs/srs/Interface/req-127_shared-account-capital-allocation-policy.md new file mode 100644 index 0000000..47060e5 --- /dev/null +++ b/docs/srs/Interface/req-127_shared-account-capital-allocation-policy.md @@ -0,0 +1,160 @@ +# req-127: 공통 계좌 자본배분 순수 정책 + +- Status: Approved +- Type: Interface +- Priority: High +- Owner: trex-core-dev + +## Intent + +- 메가 스윙과 탑50 스윙이 같은 계좌 현금 배분 규칙을 사용하도록, 외부 입출력과 분리된 결정적 정책을 Core 도메인에 제공한다. +- 고가 종목을 무조건 균등액으로 자르지 않고 제한된 한 주 가격 여유를 허용하되, 실제로 동시에 열릴 수 있는 최악 슬롯 조합도 가용 현금을 넘지 않게 한다. +- KIS 어댑터는 현금·호가 조회와 결과 매핑만 맡고, Portfolio Hub 원장과 기존 주문수량 계약은 변경하지 않는다. + +## Requirement + +1. Core는 `available_cash`, 양의 `slot_count`, 고유한 `portfolio_id`·`symbol`과 양의 `protected_share_cost`를 가진 후보 목록, 0 이상의 `flex_share_count`를 입력받는 순수 자본배분 정책을 제공해야 한다. +2. 보호된 한 주 가격은 양의 `price`와 0 이상의 `slippage_pct`를 받아 `price × (1 + slippage_pct/100)`을 원 단위 올림해 계산해야 한다. +3. 초기 후보 적격 상한은 `available_cash // slot_count + cheapest_protected_share_cost × flex_share_count`여야 하며, 이를 넘는 후보는 `protected_share_above_flex_limit`으로 제외해야 한다. +4. 1차 적격 후보 중 가장 비싼 `min(slot_count, eligible_count)`개 한 주 가격 합이 가용 현금을 넘으면, 후보 가격과 입력 순서를 안정적인 동률 기준으로 가장 비싼 후보부터 제외하고 `worst_case_cash_overcommit`을 반환해야 한다. +5. 남은 후보에는 각 후보의 보호 한 주 가격 이상이면서, 가장 큰 `min(slot_count, eligible_count)`개 배정액 합이 가용 현금 이하인 최대 공통 배정액을 적용해야 한다. +6. 현금이 0이면 모든 후보의 배정액을 0으로 하고 `no_available_cash`를 반환해야 한다. 음수 현금, 0 이하 슬롯 수, 빈 후보, 음수 유동 여유, 중복 식별자, 0 이하 한 주 가격은 명시적으로 거부해야 한다. +7. 결과는 입력 후보 순서를 보존한 후보별 `portfolio_id`, `symbol`, 배정액, 사유와 계좌 수준 `available_cash`, `slot_count`를 제공해야 하며, 단순 `portfolio_id → 배정액` 호환 표면도 제공해야 한다. +8. 정책 모듈은 네트워크, 파일, 환경변수, 데이터베이스, 브로커 및 전략 모듈에 의존하지 않아야 한다. 현금·호가 조회, 후보·슬롯 선택, 설정 저장, 실제 주문수량 산출과 주문 제출은 호출 계층 책임으로 유지해야 한다. +9. 기존 `BuyIntent`의 정수 수량·가격 계약, Core 주문 실행, 슬롯 상태머신, Portfolio Hub 원장 계약을 변경하지 않아야 한다. +10. `docs/project-structure.md`와 배치 검증 문서는 공통 정책의 책임, 호출 계층 책임, 비운영 범위, 회귀 증거를 구현과 일치하게 기록해야 한다. + +## Acceptance Criteria + +1. Given 메가 스윙 후보가 삼성전자 보호가격 220,000원과 SK하이닉스 보호가격 1,400,000원이고 현금 2,000,000원·2슬롯인 경우, When 정책을 실행하면, Then 삼성전자 2,000,000원·SK하이닉스 0원을 반환하고 SK하이닉스 제외 사유가 `protected_share_above_flex_limit`이다. +2. Given 같은 후보에서 현금 3,000,000원이고 SK하이닉스 보호가격이 1,510,000원인 경우, When 한 주 유동 여유를 적용하면, Then 삼성전자 1,490,000원·SK하이닉스 1,510,000원을 반환한다. +3. Given 현금 2,000,000원·3슬롯과 보호가격 100,000/200,000/500,000/700,000/1,050,000원인 탑50 후보 5개, When 정책을 실행하면, Then 마지막 후보를 제외하고 650,000/650,000/650,000/700,000/0원을 반환한다. +4. Given 현금 0원, When 정책을 실행하면, Then 모든 배정액은 0이고 사유는 `no_available_cash`이다. +5. Given 잘못된 가격·현금·슬롯·후보·중복 식별자 입력, When 정책을 실행하면, Then 조용한 보정 없이 `ValueError`로 거부한다. +6. Given 고정 시드 무작위 입력 10,000개, When 모든 결과의 가장 큰 양수 배정액을 슬롯 수만큼 합하면, Then 모든 경우 가용 현금 이하이고 양수 배정액은 해당 후보의 한 주 가격 이상이다. +7. Given 기존 로컬 어댑터 공식과 고정 시드 무작위 입력 50,000개, When 양쪽 결과를 비교하면, Then `portfolio_id → 배정액` 결과가 모든 경우 완전히 일치한다. +8. Given Core 전체 테스트와 로컬 어댑터 전체 테스트, When Core 정책 연결 상태로 실행하면, Then 기존 회귀와 신규 정책 테스트가 모두 통과한다. +9. Given 탑50 3슬롯 한 달 가상거래 입력, When 기존 공식과 Core 정책 연결 결과를 비교하면, Then 요약 지표와 거래원장·배분결정 원장이 동일하다. +10. Given 이 브랜치가 검토 중인 경우, When 운영 상태를 확인하면, Then 실전 런타임·주문 게이트·계좌 설정·Portfolio Hub 데이터에는 변경이 없다. + +## Impacted Area + +- Docs: + - `docs/discovery/dcy-042_20260729_shared-account-capital-allocation-policy.md` + - `docs/project-structure.md` + - 신규 배치의 계획·설계·구현·검증 문서 +- Modules: + - `trex_core/domain/capital_allocation.py` + - 로컬 KIS 어댑터의 자본배분 호환 연결부 +- Tests: + - `tests/domain/test_capital_allocation.py` + - 로컬 어댑터 회귀 + - 메가 대표 경계값과 탑50 월간 가상거래 비교 + +## Notes + +- Source Discovery: `docs/discovery/dcy-042_20260729_shared-account-capital-allocation-policy.md`. +- 기존 `dcy-021`과 `REQ-061`의 주문수량 책임은 유지한다. Core는 이 반복에서 계좌 배분의 순수 계산만 소유하며, 실제 주문 의도의 수량을 자동 수정하지 않는다. +- Portfolio Hub는 현금·보유·손익 원장의 정본으로 유지한다. 이 요구사항은 Hub API·스키마·데이터를 변경하지 않는다. +- `REQ-124`~`REQ-126`과 `dcy-039`~`dcy-041`, `BAT-059`~`BAT-061`은 병렬 Core 브랜치 작업에 예약되어 있어 충돌 방지를 위해 다음 미사용 번호를 선택했다. +- 구현·가상 검증·Draft PR은 허용되지만 실전 배포와 주문 활성화는 별도 승인 대상이다. + +## Change Log + +### CHG-20260729-01 + +- Date: 2026-07-29 +- Author: delivery-runner + +#### Change Summary + +- 확정된 `dcy-042`의 공통 계좌 자본배분을 단일 Core 도메인 인터페이스 요구사항으로 작성했다. + +#### Intent + +- 공통 계산의 중복을 제거하면서 브로커·원장·전략 책임 경계를 유지한다. + +#### Acceptance Criteria Delta + +- Added: + - 메가 대표 2건, 탑50 대표 1건, 입력 거부, 10,000건 불변식, 50,000건 기존 공식 완전 일치, 전체 회귀, 월간 원장 비교, 비운영 확인. +- Updated: + - 없음. +- Removed: + - 없음. + +#### Impacted Area Delta + +- Docs: + - Discovery, 프로젝트 구조, 신규 배치 문서. +- Modules: + - Core 순수 도메인 정책과 로컬 어댑터 연결부. +- Tests: + - Core 단위·속성 회귀, 어댑터 전체 회귀, 탑50 월간 가상 비교. + +#### Delivery Trace Delta + +- Batches: + - none. +- Releases: + - none. + +#### Revalidation Impact + +- Reverification Needed: yes +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: 요구사항 승인 후 단일 배치에서 구현·문서·교차 검증을 완료한다. + +#### Validation Plan + +- Core 단위·전체 회귀, 기존 공식 50,000건 완전 일치, 로컬 어댑터 전체 회귀, 메가 경계값, 탑50 월간 가상 원장 비교와 공용 7단계 가상 인증을 수행한다. + +### CHG-20260729-02 + +- Date: 2026-07-29 +- Author: trex-dev-lead + +#### Change Summary + +- 사용자 승인 범위, 필수 문서 필드, 측정 가능한 수락 기준과 비운영 경계를 재검토해 `REQ-127`을 Approved로 전환했다. + +#### Intent + +- 단일 배치의 구현·검증 기준으로 사용할 수 있도록 요구사항을 동결한다. + +#### Acceptance Criteria Delta + +- Added: + - 없음. +- Updated: + - 없음. +- Removed: + - 없음. + +#### Impacted Area Delta + +- Docs: + - SRS 상태와 인덱스 상태 동기화. +- Modules: + - 없음. +- Tests: + - 없음. + +#### Delivery Trace Delta + +- Batches: + - 다음 단계에서 BAT-062 생성. +- Releases: + - none. + +#### Revalidation Impact + +- Reverification Needed: yes +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: BAT-062에서 구현·검증 증거를 수락 기준별로 연결한다. + +#### Validation Plan + +- REQ-127 수락 기준 1~10을 배치 검증 문서와 자동화된 테스트·가상거래 증거로 대조한다. diff --git a/docs/srs/index.md b/docs/srs/index.md index 3e55a42..3a50292 100644 --- a/docs/srs/index.md +++ b/docs/srs/index.md @@ -74,7 +74,7 @@ ## Next Requirement ID -- Current: `REQ-124` +- Current: `REQ-128` - Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다. ## Requirement Register @@ -205,18 +205,18 @@ | REQ-122 | operator broker truth hotfix baseline 문서 정합 | Documentation | Implemented | High | trex-core-dev | [req-122](./Documentation/req-122_operator-broker-truth-hotfix-baseline-alignment.md) | | REQ-123 | cost-basis canonical parse 및 WAC unavailable 안전 sync seed | Integration | Approved | High | trex-core-dev | [req-123](./Integration/req-123_cost-basis-canonical-parse-and-safe-sync-seeding.md) | | REQ-124 | intent venue persistence and execution | Integration | Implemented | High | trex-core-dev | [req-124](./Integration/req-124_intent-venue-persistence-and-execution.md) | +| REQ-127 | 공통 계좌 자본배분 순수 정책 | Interface | Approved | High | trex-core-dev | [req-127](./Interface/req-127_shared-account-capital-allocation-policy.md) | ## Recent Change Log Summary +- 2026-07-29 :: REQ-127 **승인(Approved, confirm-req)**: 사용자 승인 범위, 순수 Core 정책·어댑터 입출력·기존 주문/원장 불변 경계, 10개 측정 가능한 수락 기준과 비운영 조건을 확인해 단일 배치 인계를 승인. +- 2026-07-29 :: REQ-127 **신규 생성(Proposed, draft-req, dcy-042)**: 메가·탑50 공통 계좌 자본배분 순수 정책, 고가 후보 유동 제외, 최악 활성 슬롯 현금 불변식, Core·어댑터 책임 경계와 완전 일치·가상 원장 검증을 요구사항으로 고정. 실전 배포·주문 게이트·원장 변경 제외. - 2026-07-27 :: REQ-124 Implemented: strategy intent venue를 pending slot과 broker submit/cancel까지 additive 전달. 전체 회귀 837 passed, 47 skipped. 배포·실주문 제외. - - 2026-07-16 :: REQ-123 **confirm-req 승인(Approved, lead confirm)**: dcy-038 OQ-1~3 완료 기준으로 제약(scope 제외 항목: API/schema 변경/credential/config 변경/live sync/배포), OQ-2/OQ-3 결정 반영, fail-closed WAC unavail no-write 정책, canonical parsing 우선순위/legacy fallback 규칙 및 dry-run diagnostics가 일치함을 확인해 `REQ-123`을 Approved 전환. - 2026-07-16 :: REQ-123 **신규 생성(Proposed, draft-req, dcy-038)**: portfolio-hub canonical `cost_basis_per_unit` consumer parse, legacy alias precedence, WAC unavailable fail-closed sync/no-write, dry-run diagnostics, response-contract/regression tests, observability 및 rollback/release gate. portfolio-hub API/schema/auth·token/config·DB migration/direct edit·live sync·배포 제외. - 2026-07-15 :: REQ-119~122 **`Approved` → `Implemented`** (`confirm-req-implemented`, dcy-037/BAT-057): PR #42 merged to `main` (94e1489). Independent QC PASS-WITH-NOTES (acceptance blocker 0); 806 passed/47 skipped/1 deselected. `sync --live`, deployment, and production remediation remain separate release-approval work. - 2026-07-14 :: REQ-119~122 **승인(Approved, confirm-req, dcy-037)**: lead 검토로 각 REQ의 owner·priority·scope·검증 가능 AC와 Notes의 non-blocking 설계 제약을 확인했다. #39/#40/#41은 다음 `suggest-batch-reqs`에서 단일 emergency batch 후보이며, `sync --live`는 여전히 별도 release approval gate다. -- 2026-07-14 :: REQ-119~122 **신규 생성(Proposed, draft-req, dcy-037)**: #39 fetched credential daemon/operator broker truth parity, #40 token/auth fail-loud empty-balance 분리, #41 managed non-holding divergence classification, runtime/interface/operations baseline 정합. #39/#40/#41 단일 emergency batch 및 `sync --live` 별도 release approval gate를 유지한다. - 2026-07-06 :: REQ-117/118 **신규 생성(Implemented, draft-req, trex-tvr-v1 dcy-002 FR-6)**: KIS 브로커 venue 파라미터화 — REQ-117 OrderRequest.venue additive 필드(기본 KRX, 포트 DTO 계층), REQ-118 KIS 어댑터 EXCG_ID_DVSN_CD 전송 + cancel venue 의미론 + poll_fill NXT 체결 보장 + get_current_price market 파라미터화. 실측 근거: 2026-07-06 실전 NXT 주문/취소 rt_cd=0. 고위험(라이브 주문 경로) — worktree+PR+QC(opus) 필수. confirm-req 대기. -- 2026-07-04 :: REQ-116 **신규 생성(Implemented, draft-req, trex-tvr-v1 dcy-001/dcy-002 v3 FR-8)**: `trex.slots.req` additive 확장 — 행 `strategy_name`·`strategy_params`·`markets` 필드 노출(hub 정본, safe-default) + 요청 `strategy` 필터(exact-match, status 필터 AND 조합, available_slots 불변). REQ-074 AC-5 additive 재도입(dcy-025 OQ-4 후속). 라이브 wire 계약 고위험 — worktree+PR+QC 정식 플로, 배포 선행조건=portfolio-hub REQ① 먼저. - 2026-06-28 :: REQ-112/114 **`Approved` → `Implemented`**, REQ-113/115 **`Approved` 유지(confirm-req-implemented, dcy-036/BAT-053)**: BAT-053 PR #34 머지(01655ab). QC PASS-WITH-NOTES(opus, blocker 0). 745 passed/0 회귀. REQ-112(fetch 포트+어댑터+stub AC-1~8 완료), REQ-114(fail-closed 게이트 AC-1~9, BuyIntent 차단/SellIntent 통과 비대칭·CRITICAL 알람·opt-in off 비활성 완료). REQ-113/115 스캐폴드 완료 — KISBroker 런타임 재조립(REQ-113 AC-2, REQ-115 AC-1) M3 후속 batch 연기. - 2026-06-28 :: REQ-112~115 **`Proposed` → `Approved`(confirm-req 팀장 승인)**: dcy-036 FR/NFR 추적·AC testable·blocker 0. 진입 fail-closed/청산·DISPATCHED 통과 비대칭(REQ-114 AC-4/5), 그날 고정 캐시·ledger 무손실(REQ-115), TradingContext 원자 read(REQ-112), opt-in 회귀차단(REQ-113 AC-1). OQ-4(trex-notifier) Deferred. 선행 의존 portfolio-hub dcy-010(머지완료). 다음=suggest-batch-reqs. - 2026-06-26 :: REQ-109~111 **Approved → Implemented** (confirm-req-implemented, dcy-035): BAT-052 머지(PR #33, 81d06d9) 완료. QC PASS. 717 passed/47 skipped/0 fail. REQ-109(portfolio-hub /pnl/realized 포트+어댑터+stub AC-1~6), REQ-110(WAC 운영리포트 통합 AC-1~7, CHG-20260626-02 buy_fee편입 반영), REQ-111(degrade AC-1~5) 전 AC 검증 완료. M3 배포 대기. diff --git a/tests/domain/test_capital_allocation.py b/tests/domain/test_capital_allocation.py new file mode 100644 index 0000000..9f632e6 --- /dev/null +++ b/tests/domain/test_capital_allocation.py @@ -0,0 +1,197 @@ +"""Shared account-capital allocation policy regression tests.""" +from __future__ import annotations + +import random +from decimal import Decimal + +import pytest + +from trex_core.domain.capital_allocation import ( + CapitalCandidate, + allocate_liquid_slots, + plan_liquid_capital, + protected_share_cost, +) + + +def candidate( + portfolio_id: str, + symbol: str, + cost: int, +) -> CapitalCandidate: + return CapitalCandidate(portfolio_id, symbol, cost) + + +def test_mega_two_million_excludes_hynix_and_uses_samsung_lane() -> None: + plan = plan_liquid_capital( + 2_000_000, + slot_count=2, + candidates=( + candidate("samsung", "005930", 220_000), + candidate("hynix", "000660", 1_400_000), + ), + ) + + assert plan.caps == {"samsung": 2_000_000, "hynix": 0} + assert plan.allocations[1].reason == "protected_share_above_flex_limit" + + +def test_mega_three_million_flexes_one_share_into_hynix_lane() -> None: + assert allocate_liquid_slots( + 3_000_000, + slot_count=2, + candidates=( + candidate("samsung", "005930", 220_000), + candidate("hynix", "000660", 1_510_000), + ), + ) == {"samsung": 1_490_000, "hynix": 1_510_000} + + +def test_top50_three_slots_exclude_unaffordable_high_price_candidate() -> None: + plan = plan_liquid_capital( + 2_000_000, + slot_count=3, + candidates=( + candidate("rank1", "000001", 100_000), + candidate("rank2", "000002", 200_000), + candidate("rank3", "000003", 500_000), + candidate("rank4", "000004", 700_000), + candidate("rank5", "000005", 1_050_000), + ), + ) + + assert plan.caps == { + "rank1": 650_000, + "rank2": 650_000, + "rank3": 650_000, + "rank4": 700_000, + "rank5": 0, + } + assert plan.allocations[-1].reason == "protected_share_above_flex_limit" + + +def test_zero_cash_fails_closed_with_explicit_reason() -> None: + plan = plan_liquid_capital( + 0, + slot_count=3, + candidates=(candidate("a", "000001", 100_000),), + ) + + assert plan.caps == {"a": 0} + assert plan.allocations[0].reason == "no_available_cash" + + +def test_protected_share_cost_includes_execution_margin() -> None: + assert protected_share_cost( + Decimal("1433000"), + Decimal("0.5"), + ) == 1_440_165 + + +@pytest.mark.parametrize( + ("price", "slippage"), + [ + (Decimal("0"), Decimal("0.5")), + (Decimal("-1"), Decimal("0.5")), + (Decimal("NaN"), Decimal("0.5")), + (Decimal("1"), Decimal("-0.1")), + (Decimal("1"), Decimal("Infinity")), + ], +) +def test_protected_share_cost_rejects_invalid_inputs( + price: Decimal, + slippage: Decimal, +) -> None: + with pytest.raises(ValueError, match="finite"): + protected_share_cost(price, slippage) + + +@pytest.mark.parametrize( + "kwargs,error", + [ + ( + { + "available_cash": -1, + "slot_count": 2, + "candidates": (candidate("a", "000001", 1),), + }, + "cash", + ), + ( + { + "available_cash": 1, + "slot_count": 0, + "candidates": (candidate("a", "000001", 1),), + }, + "slot", + ), + ( + { + "available_cash": 1, + "slot_count": 1, + "candidates": (), + }, + "candidates", + ), + ], +) +def test_invalid_top_level_inputs_are_rejected(kwargs, error) -> None: + with pytest.raises(ValueError, match=error): + plan_liquid_capital(**kwargs) + + +def test_duplicate_identity_is_rejected() -> None: + with pytest.raises(ValueError, match="must be unique"): + plan_liquid_capital( + 1_000_000, + slot_count=2, + candidates=( + candidate("same", "000001", 100_000), + candidate("same", "000002", 100_000), + ), + ) + + +def test_invalid_candidate_cost_and_flex_are_rejected() -> None: + with pytest.raises(ValueError, match="protected share costs"): + plan_liquid_capital( + 1_000_000, + slot_count=1, + candidates=(candidate("a", "000001", 0),), + ) + with pytest.raises(ValueError, match="flex share"): + plan_liquid_capital( + 1_000_000, + slot_count=1, + candidates=(candidate("a", "000001", 1),), + flex_share_count=-1, + ) + + +def test_randomized_worst_active_combination_never_exceeds_cash() -> None: + rng = random.Random(20260729) + for case in range(10_000): + slot_count = rng.randint(1, 5) + candidate_count = rng.randint(1, 8) + cash = rng.randint(0, 10_000_000) + candidates = tuple( + candidate( + f"portfolio-{index}", + f"{index:06d}", + rng.randint(1, 3_000_000), + ) + for index in range(1, candidate_count + 1) + ) + plan = plan_liquid_capital( + cash, + slot_count=slot_count, + candidates=candidates, + flex_share_count=rng.randint(0, 2), + ) + positive = sorted( + (cap for cap in plan.caps.values() if cap > 0), + reverse=True, + ) + assert sum(positive[:slot_count]) <= cash, case + for item, source in zip(plan.allocations, candidates): + assert item.cap == 0 or item.cap >= source.protected_share_cost diff --git a/trex_core/domain/capital_allocation.py b/trex_core/domain/capital_allocation.py new file mode 100644 index 0000000..fd90486 --- /dev/null +++ b/trex_core/domain/capital_allocation.py @@ -0,0 +1,269 @@ +"""Pure account-capital allocation policy shared by TREX strategies. + +The domain owns only deterministic allocation math. Broker adapters own cash +and quote retrieval, while Portfolio Hub remains the portfolio-ledger owner. +No network, persistence, environment, or strategy dependency is allowed here. +""" +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal, ROUND_CEILING + + +@dataclass(frozen=True) +class CapitalCandidate: + """One strategy candidate priced at a protected one-share cost.""" + + portfolio_id: str + symbol: str + protected_share_cost: int + + +@dataclass(frozen=True) +class CapitalAllocation: + """One candidate's deterministic allocation decision.""" + + portfolio_id: str + symbol: str + cap: int + reason: str + + @property + def eligible(self) -> bool: + return self.cap > 0 + + +@dataclass(frozen=True) +class CapitalAllocationPlan: + """Account-level result that can be consumed by any TREX strategy.""" + + available_cash: int + slot_count: int + allocations: tuple[CapitalAllocation, ...] + + @property + def caps(self) -> dict[str, int]: + return { + allocation.portfolio_id: allocation.cap + for allocation in self.allocations + } + + +def protected_share_cost(price: Decimal, slippage_pct: Decimal) -> int: + """Return the ceiling KRW cost of one share including execution margin.""" + if ( + not price.is_finite() + or not slippage_pct.is_finite() + or price <= 0 + or slippage_pct < 0 + ): + raise ValueError( + "price must be finite and positive and slippage must be finite " + "and non-negative" + ) + multiplier = Decimal("1") + slippage_pct / Decimal("100") + return int( + (price * multiplier).to_integral_value(rounding=ROUND_CEILING) + ) + + +def plan_liquid_capital( + available_cash: int, + *, + slot_count: int, + candidates: tuple[CapitalCandidate, ...], + flex_share_count: int = 1, +) -> CapitalAllocationPlan: + """Plan candidate caps whose worst active-slot combination fits cash. + + The starting point is an equal cash budget per slot. A limited amount, + measured in units of the cheapest protected share, may move between slots + so that a slightly more expensive candidate remains eligible. A candidate + that needs more than that margin is excluded. + + After the first screen, the highest protected-share cost is removed until + every possible active-slot combination can reserve at least one share + without exceeding real account cash. Remaining candidates receive the + largest common cap that preserves that invariant. + """ + _validate_inputs( + available_cash, + slot_count=slot_count, + candidates=candidates, + flex_share_count=flex_share_count, + ) + if available_cash == 0: + return CapitalAllocationPlan( + available_cash=available_cash, + slot_count=slot_count, + allocations=tuple( + CapitalAllocation( + candidate.portfolio_id, + candidate.symbol, + 0, + "no_available_cash", + ) + for candidate in candidates + ), + ) + + equal_budget = available_cash // slot_count + flex_unit = min( + candidate.protected_share_cost for candidate in candidates + ) + maximum_one_share_cost = ( + equal_budget + flex_unit * flex_share_count + ) + eligible_indexes = [ + index + for index, candidate in enumerate(candidates) + if candidate.protected_share_cost <= maximum_one_share_cost + ] + reasons = { + index: "protected_share_above_flex_limit" + for index, candidate in enumerate(candidates) + if candidate.protected_share_cost > maximum_one_share_cost + } + + while eligible_indexes: + active_count = min(slot_count, len(eligible_indexes)) + largest_costs = sorted( + ( + candidates[index].protected_share_cost + for index in eligible_indexes + ), + reverse=True, + )[:active_count] + if sum(largest_costs) <= available_cash: + break + highest_index = max( + eligible_indexes, + key=lambda index: ( + candidates[index].protected_share_cost, + index, + ), + ) + eligible_indexes.remove(highest_index) + reasons[highest_index] = "worst_case_cash_overcommit" + + if not eligible_indexes: + return CapitalAllocationPlan( + available_cash=available_cash, + slot_count=slot_count, + allocations=tuple( + CapitalAllocation( + candidate.portfolio_id, + candidate.symbol, + 0, + reasons[index], + ) + for index, candidate in enumerate(candidates) + ), + ) + + active_count = min(slot_count, len(eligible_indexes)) + + def worst_active_total(common_budget: int) -> int: + caps = sorted( + ( + max( + candidates[index].protected_share_cost, + common_budget, + ) + for index in eligible_indexes + ), + reverse=True, + ) + return sum(caps[:active_count]) + + low, high = 0, available_cash + while low < high: + middle = (low + high + 1) // 2 + if worst_active_total(middle) <= available_cash: + low = middle + else: + high = middle - 1 + + eligible_set = set(eligible_indexes) + allocations = tuple( + CapitalAllocation( + candidate.portfolio_id, + candidate.symbol, + ( + max(candidate.protected_share_cost, low) + if index in eligible_set + else 0 + ), + "eligible" if index in eligible_set else reasons[index], + ) + for index, candidate in enumerate(candidates) + ) + plan = CapitalAllocationPlan( + available_cash=available_cash, + slot_count=slot_count, + allocations=allocations, + ) + _assert_plan_invariants(plan) + return plan + + +def allocate_liquid_slots( + available_cash: int, + *, + slot_count: int, + candidates: tuple[CapitalCandidate, ...], + flex_share_count: int = 1, +) -> dict[str, int]: + """Compatibility surface returning only ``portfolio_id -> cap``.""" + return plan_liquid_capital( + available_cash, + slot_count=slot_count, + candidates=candidates, + flex_share_count=flex_share_count, + ).caps + + +def _validate_inputs( + available_cash: int, + *, + slot_count: int, + candidates: tuple[CapitalCandidate, ...], + flex_share_count: int, +) -> None: + if available_cash < 0 or slot_count <= 0 or not candidates: + raise ValueError( + "cash, positive slot count, and candidates are required" + ) + if flex_share_count < 0: + raise ValueError("flex share count must be non-negative") + portfolio_ids = [ + candidate.portfolio_id for candidate in candidates + ] + symbols = [candidate.symbol for candidate in candidates] + if ( + len(portfolio_ids) != len(set(portfolio_ids)) + or len(symbols) != len(set(symbols)) + ): + raise ValueError( + "candidate portfolio ids and symbols must be unique" + ) + if any( + candidate.protected_share_cost <= 0 + for candidate in candidates + ): + raise ValueError( + "candidate protected share costs must be positive" + ) + + +def _assert_plan_invariants(plan: CapitalAllocationPlan) -> None: + positive = sorted( + ( + allocation.cap + for allocation in plan.allocations + if allocation.cap > 0 + ), + reverse=True, + ) + if sum(positive[: plan.slot_count]) > plan.available_cash: + raise AssertionError("capital plan exceeds available cash")