feat: add shared account capital allocation policy

This commit is contained in:
jordan
2026-07-29 19:59:55 +09:00
parent 91e8803424
commit 91fee4808d
14 changed files with 1041 additions and 8 deletions
@@ -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·원장 변경
- 실전 배포·주문 활성화
@@ -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 병합 후 어댑터 이미지에서 모듈 가용성과 공용 가상 인증을 다시 확인해야 한다.
- 주문수량·주문 게이트·계좌 설정 변경은 이 배치에 포함되지 않는다.
@@ -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 병합·이미지 검증과 별도 운영 승인 뒤에만 수행한다.
@@ -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건 속성 테스트에서 이중 검증.
@@ -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`
- 이 확인은 실전 배포·실주문·주문 게이트 활성화를 승인하지 않는다.
+1
View File
@@ -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-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-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-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/) |
@@ -5,9 +5,9 @@
- 문서 ID: dcy-021_20260610_position-sizing-target-intent - 문서 ID: dcy-021_20260610_position-sizing-target-intent
- 이슈명: 포지션 사이징 — 활성 자산 슬롯 보유 수량 변경(HOLDING→HOLDING)·부분 매도·선언형 target intent (issue #1, dcy-020 OQ-4 후속) - 이슈명: 포지션 사이징 — 활성 자산 슬롯 보유 수량 변경(HOLDING→HOLDING)·부분 매도·선언형 target intent (issue #1, dcy-020 OQ-4 후속)
- 작성 시각(KST): 2026-06-10 17:06 KST - 작성 시각(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) - 생성된 REQ 참조: REQ-066, REQ-067 (+ change-req REQ-057/061/062)
# 1. 계획 상태 요약 # 1. 계획 상태 요약
@@ -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
+1
View File
@@ -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-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-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-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 |
+9 -2
View File
@@ -4,7 +4,7 @@
- Baseline Seed: .stage-pilot/bootstrap/baseline.yaml - Baseline Seed: .stage-pilot/bootstrap/baseline.yaml
- Status: draft - Status: draft
- Owner: project maintainers - 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 산출물) - Source Discovery / Batch: bootstrap-baseline (pre-Discovery, 3-phase lifecycle 밖의 bootstrap 산출물)
## Project Summary ## Project Summary
@@ -66,6 +66,7 @@
│ ├── domain/slot/ # slot 순수 도메인 (BAT-001, 무의존) │ ├── domain/slot/ # slot 순수 도메인 (BAT-001, 무의존)
│ │ ├── __init__.py · state.py · models.py(+EXPECTED_SLOT_FIELDS) · errors.py │ │ ├── __init__.py · state.py · models.py(+EXPECTED_SLOT_FIELDS) · errors.py
│ │ ├── repository.py(Protocol, instance 스코프) · service.py · in_memory_repository.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) │ ├── domain/pricing.py # KRX 호가단위(tick) 산출 순수 함수 (BAT-044 / REQ-083, dcy-030)
│ ├── application/ # 유스케이스 (BAT-002, BAT-004, BAT-006, BAT-007, BAT-008) │ ├── application/ # 유스케이스 (BAT-002, BAT-004, BAT-006, BAT-007, BAT-008)
│ │ ├── ports.py # IntentStore Protocol │ │ ├── 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) │ └── cli.py # trex-core CLI: pause/resume/paused + universe/slots/enter/exit/cancel + report(REQ-088) (BAT-008/020/046 / REQ-032)
└── tests/ └── tests/
├── domain/slot/ # test_state / test_service / test_contract ├── 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) ├── 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 ├── 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 회귀 가드) ├── runtime/ # test_config_dotenv · test_daemon_no_http_surface (BAT-007 HTTP 표면 0 회귀 가드)
@@ -112,6 +114,10 @@
- `trex_core/domain/slot/` - `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`). - 자산 중심 영구 슬롯 순수 도메인. 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 스윕으로 확인됨). - 외부 의존 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) - `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계층 분할은 후속). - 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/` - `tests/domain/slot/`
@@ -123,7 +129,7 @@
## Dependency Rules ## Dependency Rules
- 순수 도메인(`trex_core/domain/slot/`)은 저장소 구현, 브로커, 네트워크, CLI 등 인프라에 직접 의존하지 않는다. - 순수 도메인(`trex_core/domain/slot/`, `trex_core/domain/capital_allocation.py`)은 저장소 구현, 브로커, 네트워크, CLI 등 인프라에 직접 의존하지 않는다.
- 저장소 접근은 오직 `SlotRepository` Protocol 을 통해서만 이뤄지고 구현체는 주입된다. - 저장소 접근은 오직 `SlotRepository` Protocol 을 통해서만 이뤄지고 구현체는 주입된다.
- 인프라(향후 PostgreSQL 구현, CLI, config)는 도메인을 의존할 수 있으나 그 역은 금지한다. - 인프라(향후 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-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-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-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)
@@ -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을 배치 검증 문서와 자동화된 테스트·가상거래 증거로 대조한다.
+4 -4
View File
@@ -74,7 +74,7 @@
## Next Requirement ID ## Next Requirement ID
- Current: `REQ-124` - Current: `REQ-128`
- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다. - Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다.
## Requirement Register ## 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-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-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-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 ## 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-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 **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-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-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 **승인(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-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/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-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 배포 대기. - 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 배포 대기.
+197
View File
@@ -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
+269
View File
@@ -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")