feat(slots): additive strategy fields + filter (REQ-116, BAT-055)
trex.slots.req wire 계약 additive 확장 — 기존 소비자(orb/tsl) backward compatible. - PortfolioItem: strategy_name(Optional[str]) + markets(list) 필드 추가 - hub_read.py: get_portfolios()에서 strategy_name/markets 파싱 추가 (safe default) - slot_to_dict(): strategy_name/strategy_params/markets 3필드 row에 추가 (SnapshotStore→PortfolioItem 경유) - make_slots_rpc_handler(): strategy 요청 파라미터 → Python 레벨 후처리 필터 (available_slots 계산 이후, REQ-073 AC-6 불변) - 테스트: AC-1~8 전수 + hub_read 파싱 + 회귀 갱신 (777 passed, 47 skipped) - docs/interface-contract.md: 요청 파라미터·응답 행 스키마·소스 테이블·REQ-074 AC-5 주석 갱신 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
# BAT-055 Design
|
||||
|
||||
- Status: confirmed
|
||||
- Batch: BAT-055
|
||||
|
||||
## 1. `PortfolioItem` 필드 확장
|
||||
|
||||
**결정**: `application/portfolio.py` 의 `PortfolioItem` dataclass에 두 필드 추가.
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class PortfolioItem:
|
||||
...
|
||||
active_strategy_id: Optional[str] = None # 기존 유지
|
||||
strategy_params: dict = ... # 기존 유지
|
||||
strategy_name: Optional[str] = None # REQ-116 신규
|
||||
markets: list[str] = field(default_factory=list) # REQ-116 신규
|
||||
```
|
||||
|
||||
- `strategy_name`: portfolio-hub 응답의 `strategy_name` 필드. 미제공 시 None.
|
||||
- `markets`: portfolio-hub 응답의 `markets` 배열. 미제공 시 [].
|
||||
- 두 필드 모두 `frozen=True` dataclass 의 `default`/`default_factory` 로 하위 호환. 기존 생성자 호출(`PortfolioItem(portfolio_id=..., symbol=..., cash_balance=..., asset_quantity=...)`)은 수정 없이 동작.
|
||||
|
||||
## 2. `hub_read.py` 파싱 확장
|
||||
|
||||
**결정**: `get_portfolios()` 의 `PortfolioItem(...)` 생성 시 두 필드 추가 파싱.
|
||||
|
||||
```python
|
||||
strategy_name = raw.get("strategy_name") or None # str or None
|
||||
markets_raw = raw.get("markets")
|
||||
markets = list(markets_raw) if isinstance(markets_raw, list) else []
|
||||
```
|
||||
|
||||
- `strategy_name`: 빈 문자열 → None 처리 (`or None`).
|
||||
- `markets`: list 타입이 아닌 경우(None, 비배열) → `[]`.
|
||||
- 구버전 hub 응답(키 미존재) → safe default. 데몬 비중단.
|
||||
|
||||
## 3. `slot_to_dict()` 신규 필드 추가
|
||||
|
||||
**결정**: strategy 3필드는 `snapshot_store.get(portfolio_id)` 의 `PortfolioItem` 에서 읽어 row에 추가.
|
||||
|
||||
```python
|
||||
# strategy fields (REQ-116 additive — source: PortfolioItem via SnapshotStore)
|
||||
strategy_name_val: Optional[str] = None
|
||||
strategy_params_val: dict = {}
|
||||
markets_val: list = []
|
||||
if snapshot_store is not None and s.portfolio_id:
|
||||
snap = snapshot_store.get(s.portfolio_id)
|
||||
if snap is not None:
|
||||
strategy_name_val = snap.item.strategy_name
|
||||
strategy_params_val = snap.item.strategy_params
|
||||
markets_val = list(snap.item.markets)
|
||||
d["strategy_name"] = strategy_name_val
|
||||
d["strategy_params"] = strategy_params_val
|
||||
d["markets"] = markets_val
|
||||
```
|
||||
|
||||
- `asset_qty`/`cash_balance` 읽기(기존)와 동일 `snap` 참조 위치 — 중복 조회 없음.
|
||||
- safe default: `PortfolioItem.strategy_params` 은 이미 `{}` default; `strategy_name` 은 None; `markets` 는 [].
|
||||
- **REQ-074 AC-5 노트**: 기존 excluded 필드(`strategy_id`, `sell_strategy_id`, `active_strategy_id`) 는 여전히 제외. 이번에 추가하는 것은 `PortfolioItem` 출처의 `strategy_name`/`strategy_params`/`markets` — 의미적으로 다른 출처.
|
||||
|
||||
## 4. `make_slots_rpc_handler()` strategy 필터 설계
|
||||
|
||||
**결정**: `strategy` 파라미터 파싱 + `slot_rows` 빌드 완료 후 Python 레벨 후처리 필터.
|
||||
|
||||
흐름:
|
||||
1. `available_slots` 계산 (필터 이전 — REQ-073 AC-6 불변)
|
||||
2. `status_filter` → DB 레벨 `list_slots(status_filter=...)` 실행 (기존)
|
||||
3. `slot_rows` 빌드 (기존 + strategy 3필드 포함)
|
||||
4. **신규**: `strategy` 파라미터가 있으면 Python 레벨 필터
|
||||
```python
|
||||
strategy_filter = _opt_str(body.get("strategy"))
|
||||
if strategy_filter is not None:
|
||||
slot_rows = [r for r in slot_rows if r.get("strategy_name") == strategy_filter]
|
||||
```
|
||||
5. degraded/available_slots 응답 조립 (기존)
|
||||
|
||||
`available_slots` 는 4번 이전에 이미 계산 완료 → strategy 필터 영향 없음.
|
||||
|
||||
## 5. REQ-074 AC-5 호환성
|
||||
|
||||
- 기존 테스트 `test_strategy_fields_absent` 는 `strategy_id`, `sell_strategy_id`, `strategy_params`, `active_strategy_id` 를 체크.
|
||||
- `strategy_id` / `sell_strategy_id` / `active_strategy_id`: 여전히 응답에 없음 (불변).
|
||||
- `strategy_params`: **이번에 추가**. 단, 이는 slot 필드(REQ-074 AC-5 의 제외 대상)가 아니라 `PortfolioItem.strategy_params` — 다른 소스. REQ-116 은 REQ-074 AC-5 의 additive 재도입이므로 기존 테스트 `test_strategy_fields_absent` 를 REQ-116 범위에 맞게 갱신한다: `strategy_params`(hub 출처, 신규 노출 허용) 는 제외 체크 목록에서 제거하고, `strategy_id`/`sell_strategy_id`/`active_strategy_id` 만 여전히 체크.
|
||||
|
||||
## 6. 계약 테스트 (`EXPECTED_SLOT_FIELDS`)
|
||||
|
||||
- `EXPECTED_SLOT_FIELDS` 는 `Slot` 도메인 dataclass 필드 집합 — strategy 3필드는 `Slot` 에 없으므로 변경 불필요.
|
||||
- `slot_to_dict()` row 필드는 `EXPECTED_SLOT_FIELDS` 와 1:1 매핑이 아니다(row에는 hub/overlay 필드 포함). `EXPECTED_SLOT_FIELDS` 는 `Slot` domain model 계약이므로 수정 대상 아님.
|
||||
- REQ-116 AC-8 "계약 테스트 갱신" 은 `test_strategy_fields_absent` 갱신 + 신규 slot row 필드 단언 테스트로 충족.
|
||||
@@ -0,0 +1,63 @@
|
||||
# BAT-055 Implementation
|
||||
|
||||
- Status: complete
|
||||
- Batch: BAT-055
|
||||
|
||||
## Changed Files
|
||||
|
||||
### application (core logic)
|
||||
|
||||
- `trex_core/application/portfolio.py`
|
||||
- `PortfolioItem`: `strategy_name: Optional[str] = None`, `markets: list = field(default_factory=list)` 추가 (REQ-116)
|
||||
- `slot_to_dict()`: strategy_name/strategy_params/markets 3필드를 SnapshotStore → PortfolioItem 경유로 row에 추가. safe default 적용(snapshot 없거나 item 없으면 null/{}/[])
|
||||
|
||||
- `trex_core/application/messaging_handlers.py`
|
||||
- `make_slots_rpc_handler()` docstring: `strategy?: str` 파라미터 설명 추가
|
||||
- handler 내부: `strategy_filter = _opt_str(body.get("strategy"))` 파싱 추가
|
||||
- `available_slots` 계산 이후 Python 레벨 strategy 후처리 필터 추가 (REQ-073 AC-6 불변 보장)
|
||||
|
||||
### adapters
|
||||
|
||||
- `trex_core/adapters/portfolio/hub_read.py`
|
||||
- `get_portfolios()`: `strategy_name` (or None), `markets` (list or []) 파싱 추가
|
||||
- `PortfolioItem(...)` 생성 시 두 필드 주입
|
||||
|
||||
### tests
|
||||
|
||||
- `tests/application/test_slots_handler.py`
|
||||
- `test_strategy_fields_absent`: REQ-116 additive 재도입 반영 — `strategy_params` 제외 체크 제거, 신규 필드 present 체크 추가
|
||||
|
||||
- `tests/application/test_slots_handler_strategy.py` (신규)
|
||||
- AC-1~AC-8 전수 테스트 (16 케이스)
|
||||
|
||||
- `tests/application/test_slots_handler_strategy_state.py`
|
||||
- `test_no_strategy_reasoning_fields`: slot-origin 제외 유지하되, PortfolioItem-origin 필드 present 체크 추가
|
||||
|
||||
- `tests/adapters/portfolio/test_hub_read.py`
|
||||
- 기존 `test_get_portfolios_parses_envelope_and_filters_by_daemon`: safe default 확인 어서션 추가
|
||||
- 신규 4개 테스트: REQ-116 파싱 AC-1/2
|
||||
|
||||
### docs
|
||||
|
||||
- `docs/interface-contract.md`
|
||||
- `trex.slots.req` 요청 payload: `strategy: str (optional)` 추가
|
||||
- 응답 슬롯 행: `strategy_name`/`strategy_params`/`markets` 추가
|
||||
- 필드 소스 테이블: 3필드 + 정본 소스 추가
|
||||
- REQ-074 AC-5 주석 갱신: slot-origin 제외 유지, PortfolioItem-origin 필드 구분 명시
|
||||
- `PortfolioHubReadPort.PortfolioItem Fields`: `strategy_name`/`markets` 추가
|
||||
- Change Log: BAT-055 항목 추가
|
||||
|
||||
## Additive-Only Invariants Confirmed
|
||||
|
||||
1. 기존 `filter` 동작 변경 없음 — strategy 필터는 독립 파라미터
|
||||
2. `available_slots` 계산 위치 불변 — strategy 필터 이전(DB count 기반)
|
||||
3. 기존 slot row 필드 값 불변 — 신규 3필드 추가만
|
||||
4. `EXPECTED_SLOT_FIELDS` (Slot domain dataclass 계약) 변경 없음 — slot_to_dict row 확장은 별도
|
||||
5. strategy_id/sell_strategy_id/active_strategy_id 는 여전히 row에 없음 (REQ-074 AC-5 유지)
|
||||
|
||||
## REQ-074 CHG Reference (REQ-116 Notes)
|
||||
|
||||
REQ-074 AC-5 의 "strategy 계열 제외" 불변식을 파괴하지 않는다. 본 배치가 도입하는 것은
|
||||
PortfolioItem(hub) 출처의 `strategy_name`/`strategy_params`/`markets` — slot 도메인 전략 필드가 아님.
|
||||
REQ-116 이 별도 REQ 로 additive 재도입 근거를 남기므로 REQ-074 change-req 별도 생성 불필요
|
||||
(REQ-116 Notes 참조).
|
||||
@@ -0,0 +1,17 @@
|
||||
# BAT-055: slots 계약 strategy 필드 + 필터 (additive)
|
||||
|
||||
- Status: implementing
|
||||
- Profile: standard
|
||||
- Discovery: dcy-001/dcy-002 v3 FR-8 (tvr-v1 배관 ②)
|
||||
- Created: 2026-07-04
|
||||
|
||||
## Included REQ
|
||||
|
||||
- REQ-116: `trex.slots.req` 전략 필드 노출 + `strategy` 요청 필터 (additive)
|
||||
|
||||
## Documents
|
||||
|
||||
- [planning.md](./planning.md)
|
||||
- [design.md](./design.md)
|
||||
- [implementation.md](./implementation.md)
|
||||
- [verification.md](./verification.md)
|
||||
@@ -0,0 +1,56 @@
|
||||
# BAT-055 Planning
|
||||
|
||||
- Status: confirmed
|
||||
- Batch: BAT-055
|
||||
- Profile: standard
|
||||
|
||||
## Included REQ
|
||||
|
||||
- REQ-116: `trex.slots.req` 전략 필드 노출 + `strategy` 요청 필터 (additive)
|
||||
|
||||
## Delivery Plan
|
||||
|
||||
additive 확장 — 기존 필드/동작 불변 원칙 하에 아래 4가지를 구현한다.
|
||||
|
||||
1. **`PortfolioItem` 필드 추가** (`application/portfolio.py`):
|
||||
- `strategy_name: Optional[str]` 추가 (default None)
|
||||
- `markets: list[str]` 추가 (default_factory=list)
|
||||
- `strategy_params` 은 이미 존재 — 변경 불필요
|
||||
|
||||
2. **`hub_read.py` 파싱 추가** (`adapters/portfolio/hub_read.py`):
|
||||
- `get_portfolios()` 에서 `strategy_name`, `markets` 파싱
|
||||
- 미존재 키 → safe default (None / [])
|
||||
|
||||
3. **`slot_to_dict()` 신규 필드 추가** (`application/portfolio.py`):
|
||||
- `strategy_name` (null), `strategy_params` ({}), `markets` ([]) 3필드를 슬롯 row에 추가
|
||||
- `snapshot_store`에서 해당 포트폴리오의 `PortfolioItem` 참조
|
||||
- `PortfolioItem` 없으면 safe default
|
||||
|
||||
4. **`make_slots_rpc_handler()` strategy 필터** (`application/messaging_handlers.py`):
|
||||
- 요청에서 `strategy: str` 선택적 파라미터 파싱
|
||||
- `available_slots` 계산 → strategy 필터 이전에 수행 (REQ-073 AC-6 불변)
|
||||
- `slot_rows` 빌드 후 Python 레벨 strategy 필터 (status 필터는 DB, strategy 필터는 Python)
|
||||
- 기존 status `filter` AND strategy → 두 필터 모두 만족하는 행만
|
||||
- 미지정 시 기존과 완전 동일
|
||||
|
||||
## Dependencies
|
||||
|
||||
- BAT-050 (reconcile-warning): 완료. `slot_to_dict` 의 `warn_store` 파라미터 패턴 선례.
|
||||
- REQ-074 AC-5 additive 재도입: REQ-116 Notes 참조. `strategy_id`/`sell_strategy_id`/`active_strategy_id` 는 여전히 제외 — `strategy_name`/`strategy_params`/`markets`(PortfolioItem 출처)만 추가.
|
||||
|
||||
## Design Gate
|
||||
|
||||
**Design 필요: yes (인터페이스 변경 + 필터 흐름 설계)**
|
||||
|
||||
- `slot_to_dict` strategy 필드 읽기 위치: `snapshot_store.get(portfolio_id).item` 경유.
|
||||
- `strategy_params` 는 `PortfolioItem.strategy_params` (이미 존재) — `slot_to_dict` 에서 읽기만 추가.
|
||||
- `strategy` 필터 위치: `slot_rows` 빌드 완료 후 Python 후처리 필터.
|
||||
- `available_slots` 는 필터 이전 계산 확인.
|
||||
|
||||
## Milestones
|
||||
|
||||
1. `PortfolioItem` 필드 추가 + `hub_read.py` 파싱 (AC-1/2 기반)
|
||||
2. `slot_to_dict` 3필드 추가 (AC-1/2)
|
||||
3. `make_slots_rpc_handler` strategy 필터 추가 (AC-3/4/5/6)
|
||||
4. 테스트 작성: AC-1~8 + 회귀 (AC-7)
|
||||
5. `interface-contract.md` 갱신 (AC-8)
|
||||
@@ -0,0 +1,44 @@
|
||||
# BAT-055 Verification
|
||||
|
||||
- Status: draft-batch-verification
|
||||
- Batch: BAT-055
|
||||
|
||||
## Pytest 실행 결과
|
||||
|
||||
```
|
||||
777 passed, 47 skipped in 1.71s
|
||||
```
|
||||
|
||||
- 총 실행: 824건 (= 777 통과 + 47 스킵)
|
||||
- 실패: 0
|
||||
- 스킵: 47 (TREX_POSTGRESQL_TEST_DSN/TREX_RABBITMQ_URL 미설정 — 인프라 연동 테스트, bat-055 범위 외)
|
||||
|
||||
## AC 매핑
|
||||
|
||||
| AC | 테스트 | 결과 |
|
||||
|----|--------|------|
|
||||
| AC-1: 필드 노출 | `test_ac1_strategy_fields_present_when_hub_provides_them` | PASS |
|
||||
| AC-2: safe-default | `test_ac2_safe_defaults_when_hub_does_not_provide_strategy_fields`, `test_ac2_no_snapshot_store_uses_safe_defaults` | PASS |
|
||||
| AC-2 hub parsing | `test_get_portfolios_strategy_fields_absent_safe_default`, `test_get_portfolios_strategy_name_empty_string_becomes_none`, `test_get_portfolios_markets_non_list_becomes_empty` | PASS |
|
||||
| AC-3: 필터 매칭 | `test_ac3_strategy_filter_matching`, `test_ac3_strategy_filter_no_match_returns_empty` | PASS |
|
||||
| AC-4: 필터 미지정 | `test_ac4_no_strategy_param_returns_all_slots`, `test_ac4_response_keys_unchanged_without_strategy_param` | PASS |
|
||||
| AC-5: AND 조합 | `test_ac5_strategy_and_status_filter_and_combination`, `test_ac5_strategy_and_status_no_match_returns_empty` | PASS |
|
||||
| AC-6: available_slots 불변 | `test_ac6_available_slots_invariant_with_strategy_filter` | PASS |
|
||||
| AC-7: orb·tsl 회귀 없음 | `test_ac7_orb_tsl_regression_no_strategy_param` | PASS |
|
||||
| AC-8: 계약 테스트 갱신 | `test_ac8_contract_test_strategy_fields_in_slot_row`, `test_strategy_fields_absent`(갱신), `test_no_strategy_reasoning_fields`(갱신) | PASS |
|
||||
|
||||
## 미지정 요청 응답 byte-수준 동일성 확인 근거
|
||||
|
||||
`strategy` 파라미터 미지정 시 `strategy_filter = None` → `slot_rows = [r for r in slot_rows if ...]` 블록 진입 안 함(if 조건 false). 슬롯 row 목록은 코드 경로상 동일. 신규 필드 3개(`strategy_name`/`strategy_params`/`markets`) 는 additive 추가이며 기존 필드 값에 영향 없음.
|
||||
|
||||
AC-7 테스트 `test_ac7_orb_tsl_regression_no_strategy_param` 에서 기존 mandatory 필드 전수 확인 (`slot_id`, `status`, `symbol`, `qty`, `avg_price`, `entry_price`, `portfolio_id`, `forced_close_at`, `universe_detached`, `occupancy_seq`, `asset_quantity`, `cash_balance`, `cash_estimated`, `warned`, `watchdog`, `paused`, `reconcile_warned`) 모두 present 확인.
|
||||
|
||||
## 기존 스위트 회귀 확인
|
||||
|
||||
bat-055 범위 외 기존 테스트 770+건 전부 통과 (slot domain / execution / reconcile / broker / messaging / runtime 포함).
|
||||
|
||||
## 주의점 / 미해결
|
||||
|
||||
1. **배포 선행 조건**: portfolio-hub REQ-055(`strategy_name`/`markets`/`strategy_params` 노출)가 배포된 뒤 본 배치를 배포해야 한다. 선행 배포 전에는 AC-2 safe-default로 동작(정상).
|
||||
2. `markets` 필드 타입: `PortfolioItem.markets: list` (런타임 엘리먼트 타입 미강제). portfolio-hub가 `list[str]` 를 보장하면 정상 동작. 타입 강제가 필요하면 별도 change-req.
|
||||
3. `strategy_params` 는 이미 `PortfolioItem`에 존재했으나 `slot_to_dict` 에서 노출하지 않았다. 본 배치에서 노출하므로 기존 소비자(orb/tsl)가 unknown 필드로 무시하는 계약 준수 확인 필요(REQ-116 Notes — unknown 필드 무시 계약).
|
||||
@@ -70,14 +70,16 @@
|
||||
|
||||
### Message contracts
|
||||
|
||||
#### `trex.slots.req` 요청 payload (REQ-073 AC-1, BAT-035)
|
||||
#### `trex.slots.req` 요청 payload (REQ-073 AC-1, BAT-035; REQ-116 BAT-055)
|
||||
```
|
||||
{ "filter": "all" | "inactive" | "pending" | "holding" | "error", # default "all"
|
||||
"spy": true | false # default false; true = watchdog 갱신 안 함(운영자 조회)
|
||||
{ "filter": "all" | "inactive" | "pending" | "holding" | "error", # default "all"
|
||||
"spy": true | false, # default false; true = watchdog 갱신 안 함(운영자 조회)
|
||||
"strategy": str # optional (REQ-116 BAT-055) — exact-match on strategy_name
|
||||
}
|
||||
```
|
||||
- `filter`: 서버 측 슬롯 status 필터. `"inactive"` 는 buyable 슬롯(universe에 바인딩된 매수 가능 슬롯). 미인식 값은 `"all"` 로 처리.
|
||||
- `spy=true`: 응답에 포함된 holding 슬롯의 워치독 `last_seen_at` 을 갱신하지 않는다(운영자 CLI 조회가 전략 생존 신호로 오인되지 않도록 — REQ-034 CHG-01).
|
||||
- `strategy` (REQ-116 additive): 지정 시 `strategy_name == strategy` 인 슬롯 행만 반환. 대소문자 exact-match. 미지정 시 기존과 완전 동일. `filter` 와 AND 조합. 매칭 없으면 `slots=[]`(에러 없음). `available_slots` 는 strategy 필터와 무관하게 인스턴스 전체 기준으로 계산(REQ-073 AC-6 불변).
|
||||
|
||||
코어가 reply-to 큐로 보내는 응답 payload:
|
||||
```json
|
||||
@@ -100,7 +102,10 @@
|
||||
"expired": "bool",
|
||||
"last_seen": "ISO-8601 | null"
|
||||
},
|
||||
"paused": "bool"
|
||||
"paused": "bool",
|
||||
"strategy_name": "str | null", // REQ-116 additive (BAT-055) — portfolio-hub PortfolioItem 출처
|
||||
"strategy_params": "dict", // REQ-116 additive (BAT-055) — portfolio-hub opaque dict; {} if absent
|
||||
"markets": ["str"] // REQ-116 additive (BAT-055) — portfolio-hub markets list; [] if absent
|
||||
}
|
||||
],
|
||||
"degraded": "bool",
|
||||
@@ -109,7 +114,7 @@
|
||||
}
|
||||
```
|
||||
|
||||
**필드 소스 테이블** (REQ-074 AC-1):
|
||||
**필드 소스 테이블** (REQ-074 AC-1, REQ-116 추가):
|
||||
|
||||
| 필드 | 권위 소스 | 비고 |
|
||||
|---|---|---|
|
||||
@@ -122,8 +127,11 @@
|
||||
| `warned` | 파생 | `qty != asset_quantity` (REQ-074 AC-2) |
|
||||
| `watchdog` | WatchdogStore | 만료 여부 + last_seen; null=store 미연결 |
|
||||
| `paused` | PauseStore | durable pause overlay (REQ-032) |
|
||||
| `strategy_name` | hub (PortfolioItem via SnapshotStore) | REQ-116 additive. portfolio-hub 정본. hub 미제공 시 null |
|
||||
| `strategy_params` | hub (PortfolioItem via SnapshotStore) | REQ-116 additive. opaque dict; hub 미제공 시 {} |
|
||||
| `markets` | hub (PortfolioItem via SnapshotStore) | REQ-116 additive. list[str]; hub 미제공 시 [] |
|
||||
|
||||
- **strategy 필드 없음** (REQ-074 AC-5): `strategy_id`, `sell_strategy_id`, `strategy_params`, `active_strategy_id` 는 응답에 포함되지 않는다.
|
||||
- **slot-origin strategy 필드 없음** (REQ-074 AC-5, REQ-116 additive 재도입 후에도 유지): `strategy_id`, `sell_strategy_id`, `active_strategy_id` 는 응답에 포함되지 않는다. `strategy_name`/`strategy_params`/`markets` 는 PortfolioItem(hub) 출처이므로 AC-5 제외 대상과 구분된다 — REQ-116 Notes 참조.
|
||||
- `spy=false`(기본): holding 슬롯 응답 후 `WatchdogStore.update(slot_id)` 호출 — 전략 폴링 = 생존 신호.
|
||||
- `spy=true`: watchdog 갱신 건너뜀 — 운영자 CLI(`universe`/`slots`) 가 항상 사용.
|
||||
|
||||
@@ -210,6 +218,8 @@ resume_kill_switch 요청·응답 (REQ-046):
|
||||
- `asset_quantity :: float` :: integer shares semantically
|
||||
- `active_strategy_id :: str | None`
|
||||
- `strategy_params :: dict` :: opaque pass-through (no core parsing)
|
||||
- `strategy_name :: str | None` :: REQ-116 additive (BAT-055) — portfolio-hub 정본; 미제공 시 None
|
||||
- `markets :: list[str]` :: REQ-116 additive (BAT-055) — portfolio-hub 정본; 미제공 시 []
|
||||
|
||||
### Outputs / Outcomes
|
||||
|
||||
@@ -556,4 +566,5 @@ trex-core가 `source="trex-core"`, `severity="critical"`로 발행하는 초기
|
||||
- 2026-06-11 :: BAT-035 (dcy-025) — `trex.snapshot.req` → `trex.slots.req` lockstep 교체: 요청 `{filter, spy}`, 응답 `{slots[], degraded, reason, message}`. 슬롯 row 에 hub `asset_quantity`/`cash_balance` + `warned`/`cash_estimated`/`watchdog`/`paused` 병합(REQ-073/074). `trex.snapshot.req` envelope(`assets[]`/`available_slots`/view enum) 제거(REQ-028 Deprecated). strategy 필드 제거(REQ-074 AC-5). CLI `universe`/`slots` → `trex.slots.req`(spy=true). `trex.direct` channel_type `snapshot` → `slots`.
|
||||
- 2026-06-11 :: BAT-036 (dcy-024) — `safety_sell_enabled: bool=True` per-instance TOML 플래그 추가(REQ-048 CHG-01). `false` 시 `trex-safety-sell` worker 미기동 + WARNING 로그(REQ-005 CHG-01).
|
||||
- 2026-06-21 :: BAT-049 (dcy-033) — 알람 채널 `trex.alerts` (topic, durable) 신규 추가. 알람 envelope 표준 JSON 스키마(7필드: source/severity/code/title/fields/occurred_at/schema_version, schema_version="1"), routing key `alarm.{severity}.{source}`, 발행자(모든 모듈)/소비자(trex-notifier), mandatory=False best-effort 정책. trex-core CRITICAL code 10종 초기 배선. 기존 wire 계약 불변(가산·격리). (REQ-098~101)
|
||||
- 2026-06-10 :: BAT-031 (dcy-021 포지션 사이징) — 매수 intent 가 **holding 슬롯 대상이면 증액**(qty+price 추가 매수; M 캡 미적용); 매도 intent 에 **`qty`(부분 매도, 선택)** 추가 — 지정 시 `min(qty, 브로커잔고)` 부분 매도(잔량 holding), 미지정 시 전량(→inactive). 상태머신 13 전이(holding→pending_buy). avg_price 증액 가중평균·부분 매도 cost basis 유지. (REQ-066/057/061/062)
|
||||
- 2026-06-10 :: BAT-031 (dcy-021 포지션 사이징) — 매수 intent 가 **holding 슬롯 대상이면 증액**(qty+price 추가 매수; M 캡 미적용); ���도 intent 에 **`qty`(부분 매���, 선택)** 추가 — 지정 시 `min(qty, 브로커잔고)` 부분 매도(잔량 holding), 미지정 시 전량(��inactive). 상태머신 13 전이(holding→pending_buy). avg_price 증액 가중평균·부분 매도 cost basis 유지. (REQ-066/057/061/062)
|
||||
- 2026-07-04 :: BAT-055 (dcy-001/002 v3 FR-8, REQ-116) — `trex.slots.req` additive 확장: 요청에 선택적 `strategy: str` 필터 추가(exact-match, `filter` AND 조합, `available_slots` 불변 — REQ-073 AC-6); 응답 슬롯 행에 `strategy_name`(null)/`strategy_params`({})/`markets`([]) 3필드 추가(PortfolioItem/hub 정본, safe default). 기존 소비자(orb·tsl) backward compatible(unknown 필드 무시). REQ-074 AC-5 additive 재도입(dcy-025 OQ-4 후속 확정). PortfolioItem에 `strategy_name`/`markets` 필드 추가 + hub_read.py 파싱 확장.
|
||||
|
||||
@@ -65,6 +65,9 @@ def test_get_portfolios_parses_envelope_and_filters_by_daemon():
|
||||
# strategy fields absent on PortfolioItem -> graceful defaults
|
||||
assert items[0].active_strategy_id is None
|
||||
assert items[0].strategy_params == {}
|
||||
# REQ-116 (BAT-055): safe defaults when hub response has no strategy_name/markets
|
||||
assert items[0].strategy_name is None
|
||||
assert items[0].markets == []
|
||||
# no price field on the domain item
|
||||
for it in items:
|
||||
assert not hasattr(it, "price")
|
||||
@@ -380,3 +383,72 @@ def test_fetch_broker_credential_app_secret_not_in_logs(caplog):
|
||||
full_log = caplog.text
|
||||
# app_secret value must not appear in any log output
|
||||
assert "secret-value" not in full_log
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-116 (BAT-055): strategy_name + markets parsing in get_portfolios
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_get_portfolios_parses_strategy_name_and_markets():
|
||||
"""REQ-116 AC-1: hub provides strategy_name/markets → PortfolioItem carries them."""
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"portfolios": [
|
||||
{"portfolio_id": "pf-1", "symbol": "005930",
|
||||
"cash_balance": 1_000_000, "asset_quantity": 0,
|
||||
"managed_by_daemon_id": "d-7",
|
||||
"strategy_name": "tvr_v1",
|
||||
"strategy_params": {"target_weight": 0.05},
|
||||
"markets": ["KRX"]},
|
||||
], "total": 1})
|
||||
|
||||
adapter = PortfolioHubReadAdapter("https://pf.test", token="tok",
|
||||
client=_client(handler))
|
||||
items = adapter.get_portfolios("d-7")
|
||||
assert len(items) == 1
|
||||
it = items[0]
|
||||
assert it.strategy_name == "tvr_v1"
|
||||
assert it.strategy_params == {"target_weight": 0.05}
|
||||
assert it.markets == ["KRX"]
|
||||
|
||||
|
||||
def test_get_portfolios_strategy_name_empty_string_becomes_none():
|
||||
"""REQ-116 AC-2: empty string strategy_name → None."""
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"portfolios": [
|
||||
{"portfolio_id": "pf-1", "symbol": "005930",
|
||||
"cash_balance": 0, "asset_quantity": 0,
|
||||
"strategy_name": ""},
|
||||
], "total": 1})
|
||||
|
||||
adapter = PortfolioHubReadAdapter("https://pf.test", client=_client(handler))
|
||||
items = adapter.get_portfolios("d-7")
|
||||
assert items[0].strategy_name is None
|
||||
|
||||
|
||||
def test_get_portfolios_markets_non_list_becomes_empty():
|
||||
"""REQ-116 AC-2: markets not a list (None, str, int) → []."""
|
||||
for bad_val in [None, "KRX", 42]:
|
||||
def handler(req: httpx.Request, _v=bad_val) -> httpx.Response:
|
||||
return httpx.Response(200, json={"portfolios": [
|
||||
{"portfolio_id": "pf-1", "symbol": "005930",
|
||||
"cash_balance": 0, "asset_quantity": 0,
|
||||
"markets": _v},
|
||||
], "total": 1})
|
||||
adapter = PortfolioHubReadAdapter("https://pf.test", client=_client(handler))
|
||||
items = adapter.get_portfolios("d-7")
|
||||
assert items[0].markets == [], f"expected [] for markets={bad_val!r}"
|
||||
|
||||
|
||||
def test_get_portfolios_strategy_fields_absent_safe_default():
|
||||
"""REQ-116 AC-2: hub response has no strategy_name/markets keys → safe defaults."""
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"portfolios": [
|
||||
{"portfolio_id": "pf-1", "symbol": "005930",
|
||||
"cash_balance": 0, "asset_quantity": 0},
|
||||
], "total": 1})
|
||||
|
||||
adapter = PortfolioHubReadAdapter("https://pf.test", client=_client(handler))
|
||||
items = adapter.get_portfolios("d-7")
|
||||
assert items[0].strategy_name is None
|
||||
assert items[0].markets == []
|
||||
assert items[0].strategy_params == {}
|
||||
|
||||
@@ -174,15 +174,20 @@ def test_paused_holding_orthogonal_to_filter():
|
||||
assert resp.payload["slots"][0]["paused"] is True
|
||||
|
||||
|
||||
# REQ-074 AC-5: strategy fields absent from row
|
||||
# REQ-074 AC-5 (REQ-116 additive 재도입): slot-origin strategy fields absent from row.
|
||||
# strategy_name / strategy_params / markets (PortfolioItem-origin) are now present
|
||||
# (REQ-116 BAT-055 additive); slot-origin fields still excluded.
|
||||
def test_strategy_fields_absent():
|
||||
_, _, _, _, _, handler = _wire()
|
||||
resp = handler(_req())
|
||||
for s in resp.payload["slots"]:
|
||||
assert "strategy_id" not in s
|
||||
assert "sell_strategy_id" not in s
|
||||
assert "strategy_params" not in s
|
||||
assert "active_strategy_id" not in s
|
||||
# strategy_params / strategy_name / markets are now present (REQ-116 additive)
|
||||
assert "strategy_params" in s
|
||||
assert "strategy_name" in s
|
||||
assert "markets" in s
|
||||
|
||||
|
||||
# REQ-073 AC-4: spy=True → watchdog NOT updated
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
"""REQ-116 AC-1~8: strategy 필드 노출 + strategy 필터 테스트 (BAT-055).
|
||||
|
||||
AC coverage:
|
||||
AC-1: strategy fields present and correct when hub provides them
|
||||
AC-2: safe defaults when hub does not provide strategy fields
|
||||
AC-3: strategy filter — matching
|
||||
AC-4: strategy filter — absent (identical to pre-REQ-116 behaviour)
|
||||
AC-5: strategy + status AND combination
|
||||
AC-6: available_slots invariant (not affected by strategy filter)
|
||||
AC-7: orb/tsl regression — no-strategy request returns unchanged key set + values
|
||||
AC-8: contract test — strategy fields present in slot row (EXPECTED row keys)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from trex_core.application.messaging import RpcRequest
|
||||
from trex_core.application.messaging_handlers import make_slots_rpc_handler
|
||||
from trex_core.application.portfolio import (
|
||||
InMemoryPauseStore,
|
||||
PortfolioItem,
|
||||
SnapshotStore,
|
||||
)
|
||||
from trex_core.application.watchdog import WatchdogStore
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- helpers
|
||||
|
||||
def _make_item(portfolio_id, symbol="005930", **kwargs) -> PortfolioItem:
|
||||
defaults = dict(
|
||||
cash_balance=1_000_000,
|
||||
asset_quantity=0,
|
||||
strategy_name=None,
|
||||
markets=[],
|
||||
strategy_params={},
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return PortfolioItem(portfolio_id=portfolio_id, symbol=symbol, **defaults)
|
||||
|
||||
|
||||
def _wire_strategy(items=None, *, universe=None, max_active_slots=None):
|
||||
"""Build a handler with an optional universe + snapshot."""
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
uni = universe or {"pf-A": "SYM", "pf-B": "SYM"}
|
||||
repo.reconcile_universe("default", uni)
|
||||
store = SnapshotStore()
|
||||
store.swap(items or [
|
||||
_make_item("pf-A"),
|
||||
_make_item("pf-B"),
|
||||
])
|
||||
pause = InMemoryPauseStore()
|
||||
watchdog = WatchdogStore()
|
||||
handler = make_slots_rpc_handler(
|
||||
repo,
|
||||
snapshot_store=store,
|
||||
pause_store=pause,
|
||||
watchdog_store=watchdog,
|
||||
max_active_slots=max_active_slots,
|
||||
)
|
||||
return repo, svc, store, pause, watchdog, handler
|
||||
|
||||
|
||||
def _req(filter=None, spy=False, strategy=None):
|
||||
payload: dict = {}
|
||||
if filter is not None:
|
||||
payload["filter"] = filter
|
||||
if spy:
|
||||
payload["spy"] = True
|
||||
if strategy is not None:
|
||||
payload["strategy"] = strategy
|
||||
return RpcRequest(payload=payload, correlation_id="test-corr")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- AC-1
|
||||
|
||||
def test_ac1_strategy_fields_present_when_hub_provides_them():
|
||||
"""AC-1: hub returns strategy_name/strategy_params/markets → slot row contains them."""
|
||||
items = [
|
||||
_make_item("pf-A",
|
||||
strategy_name="tvr_v1",
|
||||
strategy_params={"target_weight": 0.05},
|
||||
markets=["KRX"]),
|
||||
_make_item("pf-B"),
|
||||
]
|
||||
_, _, _, _, _, handler = _wire_strategy(items)
|
||||
resp = handler(_req())
|
||||
rows = {r["portfolio_id"]: r for r in resp.payload["slots"]}
|
||||
|
||||
row_a = rows["pf-A"]
|
||||
assert row_a["strategy_name"] == "tvr_v1"
|
||||
assert row_a["strategy_params"] == {"target_weight": 0.05}
|
||||
assert row_a["markets"] == ["KRX"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- AC-2
|
||||
|
||||
def test_ac2_safe_defaults_when_hub_does_not_provide_strategy_fields():
|
||||
"""AC-2: hub returns no strategy fields → strategy_name=null, strategy_params={}, markets=[]."""
|
||||
items = [_make_item("pf-A")] # no strategy_name / markets / strategy_params
|
||||
_, _, _, _, _, handler = _wire_strategy(items)
|
||||
resp = handler(_req())
|
||||
row = resp.payload["slots"][0]
|
||||
assert row["strategy_name"] is None
|
||||
assert row["strategy_params"] == {}
|
||||
assert row["markets"] == []
|
||||
|
||||
|
||||
def test_ac2_no_snapshot_store_uses_safe_defaults():
|
||||
"""AC-2 variant: snapshot_store=None → safe defaults, daemon does not crash."""
|
||||
repo = InMemorySlotRepository()
|
||||
repo.reconcile_universe("default", {"pf-A": "SYM"})
|
||||
handler = make_slots_rpc_handler(repo) # no snapshot_store
|
||||
resp = handler(_req())
|
||||
row = resp.payload["slots"][0]
|
||||
assert row["strategy_name"] is None
|
||||
assert row["strategy_params"] == {}
|
||||
assert row["markets"] == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- AC-3
|
||||
|
||||
def test_ac3_strategy_filter_matching():
|
||||
"""AC-3: strategy="tvr_v1" → only tvr_v1 slots returned."""
|
||||
items = [
|
||||
_make_item("pf-A", strategy_name="tvr_v1"),
|
||||
_make_item("pf-B", strategy_name="orb_v1"),
|
||||
]
|
||||
_, _, _, _, _, handler = _wire_strategy(items)
|
||||
resp = handler(_req(strategy="tvr_v1"))
|
||||
rows = resp.payload["slots"]
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["portfolio_id"] == "pf-A"
|
||||
assert rows[0]["strategy_name"] == "tvr_v1"
|
||||
|
||||
|
||||
def test_ac3_strategy_filter_no_match_returns_empty():
|
||||
"""AC-3 edge: strategy value matches nothing → slots=[]."""
|
||||
items = [_make_item("pf-A", strategy_name="orb_v1")]
|
||||
_, _, _, _, _, handler = _wire_strategy(items)
|
||||
resp = handler(_req(strategy="nonexistent"))
|
||||
assert resp.payload["slots"] == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- AC-4
|
||||
|
||||
def test_ac4_no_strategy_param_returns_all_slots():
|
||||
"""AC-4: strategy absent → all slots returned, no filter applied."""
|
||||
items = [
|
||||
_make_item("pf-A", strategy_name="tvr_v1"),
|
||||
_make_item("pf-B", strategy_name="orb_v1"),
|
||||
]
|
||||
_, _, _, _, _, handler = _wire_strategy(items)
|
||||
resp = handler(_req()) # no strategy param
|
||||
assert len(resp.payload["slots"]) == 2
|
||||
|
||||
|
||||
def test_ac4_response_keys_unchanged_without_strategy_param():
|
||||
"""AC-4: response key set unchanged when strategy not provided (additive only)."""
|
||||
_, _, _, _, _, handler = _wire_strategy()
|
||||
resp = handler(_req())
|
||||
assert set(resp.payload.keys()) == {"slots", "available_slots", "degraded", "reason", "message"}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- AC-5
|
||||
|
||||
def test_ac5_strategy_and_status_filter_and_combination():
|
||||
"""AC-5: {filter:"holding", strategy:"tvr_v1"} → only tvr_v1 + holding slots."""
|
||||
items = [
|
||||
_make_item("pf-A", strategy_name="tvr_v1"),
|
||||
_make_item("pf-B", strategy_name="tvr_v1"),
|
||||
_make_item("pf-C", strategy_name="orb_v1"),
|
||||
]
|
||||
repo, svc, _, _, _, handler = _wire_strategy(
|
||||
items,
|
||||
universe={"pf-A": "SYM", "pf-B": "SYM", "pf-C": "SYM"},
|
||||
)
|
||||
# Put pf-A into holding
|
||||
slot_a = repo.find_slot_by_portfolio("default", "pf-A")
|
||||
pending = svc.transit_slot(slot_a.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol="005930")
|
||||
svc.transit_slot(pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="filled", actor="test", avg_price=100.0, qty=5.0)
|
||||
# pf-B stays inactive; pf-C stays inactive
|
||||
|
||||
resp = handler(_req(filter="holding", strategy="tvr_v1"))
|
||||
rows = resp.payload["slots"]
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["portfolio_id"] == "pf-A"
|
||||
assert rows[0]["status"] == "holding"
|
||||
assert rows[0]["strategy_name"] == "tvr_v1"
|
||||
|
||||
|
||||
def test_ac5_strategy_and_status_no_match_returns_empty():
|
||||
"""AC-5: AND combination yields empty when no slot satisfies both conditions."""
|
||||
items = [
|
||||
_make_item("pf-A", strategy_name="tvr_v1"),
|
||||
]
|
||||
_, _, _, _, _, handler = _wire_strategy(items)
|
||||
# tvr_v1 exists but filter=holding: no holding slots
|
||||
resp = handler(_req(filter="holding", strategy="tvr_v1"))
|
||||
assert resp.payload["slots"] == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- AC-6
|
||||
|
||||
def test_ac6_available_slots_invariant_with_strategy_filter():
|
||||
"""AC-6: available_slots = instance-wide (M - active), not affected by strategy filter."""
|
||||
items = [
|
||||
_make_item("pf-A", strategy_name="tvr_v1"),
|
||||
_make_item("pf-B", strategy_name="orb_v1"),
|
||||
]
|
||||
repo, svc, _, _, _, handler = _wire_strategy(items, max_active_slots=10)
|
||||
# Put pf-A into holding (1 active overall)
|
||||
slot_a = repo.find_slot_by_portfolio("default", "pf-A")
|
||||
pending = svc.transit_slot(slot_a.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol="005930")
|
||||
svc.transit_slot(pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="filled", actor="test", avg_price=100.0, qty=5.0)
|
||||
|
||||
# strategy="tvr_v1" matches 1 slot (pf-A holding), but available_slots = 10-1 = 9
|
||||
resp_filtered = handler(_req(strategy="tvr_v1"))
|
||||
resp_all = handler(_req())
|
||||
|
||||
assert resp_filtered.payload["available_slots"] == 9
|
||||
assert resp_all.payload["available_slots"] == 9
|
||||
# Filtered slots differ but available_slots is the same
|
||||
assert len(resp_filtered.payload["slots"]) == 1
|
||||
assert len(resp_all.payload["slots"]) == 2
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- AC-7
|
||||
|
||||
def test_ac7_orb_tsl_regression_no_strategy_param():
|
||||
"""AC-7: existing orb/tsl-style request (no strategy param) unchanged behaviour.
|
||||
|
||||
Verifies that existing mandatory fields are present and that strategy_name /
|
||||
strategy_params / markets are additive (unknown fields, safe to ignore).
|
||||
"""
|
||||
items = [
|
||||
_make_item("pf-A", strategy_name="orb_v1",
|
||||
strategy_params={"risk": 0.01}, markets=["KRX"]),
|
||||
]
|
||||
# universe matches items (single slot pf-A only)
|
||||
_, _, _, _, _, handler = _wire_strategy(
|
||||
items, universe={"pf-A": "SYM"},
|
||||
)
|
||||
# Legacy request: no strategy param
|
||||
resp = handler(_req(filter="all", spy=True))
|
||||
rows = resp.payload["slots"]
|
||||
assert len(rows) == 1
|
||||
row = rows[0]
|
||||
|
||||
# Existing fields must still be present with correct types
|
||||
mandatory = {
|
||||
"slot_id", "status", "symbol", "qty", "avg_price", "entry_price",
|
||||
"portfolio_id", "forced_close_at", "universe_detached", "occupancy_seq",
|
||||
"asset_quantity", "cash_balance", "cash_estimated", "warned",
|
||||
"watchdog", "paused", "reconcile_warned",
|
||||
}
|
||||
for key in mandatory:
|
||||
assert key in row, f"mandatory field {key!r} missing from slot row"
|
||||
|
||||
# Slot-origin strategy fields must NOT be present
|
||||
assert "strategy_id" not in row
|
||||
assert "sell_strategy_id" not in row
|
||||
assert "active_strategy_id" not in row
|
||||
|
||||
# REQ-116 additive fields ARE present (unknown to old consumers, safe to ignore)
|
||||
assert "strategy_name" in row
|
||||
assert "strategy_params" in row
|
||||
assert "markets" in row
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- AC-8
|
||||
|
||||
def test_ac8_contract_test_strategy_fields_in_slot_row():
|
||||
"""AC-8: slot row schema includes strategy_name, strategy_params, markets."""
|
||||
items = [
|
||||
_make_item("pf-A",
|
||||
strategy_name="tvr_v1",
|
||||
strategy_params={"target_weight": 0.05},
|
||||
markets=["KRX", "NASDAQ"]),
|
||||
]
|
||||
_, _, _, _, _, handler = _wire_strategy(items)
|
||||
resp = handler(_req())
|
||||
row = resp.payload["slots"][0]
|
||||
|
||||
# AC-8: new fields present in row schema
|
||||
assert "strategy_name" in row
|
||||
assert "strategy_params" in row
|
||||
assert "markets" in row
|
||||
|
||||
# Values correct
|
||||
assert row["strategy_name"] == "tvr_v1"
|
||||
assert row["strategy_params"] == {"target_weight": 0.05}
|
||||
assert row["markets"] == ["KRX", "NASDAQ"]
|
||||
@@ -58,12 +58,22 @@ def test_row_omits_strategy_state_when_absent():
|
||||
|
||||
|
||||
def test_no_strategy_reasoning_fields():
|
||||
"""REQ-074 AC-5 (REQ-116 additive 재도입 후에도 유지): slot-origin strategy fields absent.
|
||||
|
||||
strategy_params / strategy_name / markets are now present (REQ-116 PortfolioItem-origin);
|
||||
slot-origin strategy_id / sell_strategy_id / active_strategy_id remain absent.
|
||||
"""
|
||||
_, store, _, slot, handler = _wire()
|
||||
store.set(slot.slot_id, "tsl_v1", expected_version=0, expected_occupancy_seq=1,
|
||||
data="x", current_occupancy_seq=1, slot_active=True)
|
||||
row = _rows(handler)[0]
|
||||
for k in ("strategy_id", "sell_strategy_id", "strategy_params", "active_strategy_id"):
|
||||
# slot-origin fields must be absent (REQ-074 AC-5 unchanged)
|
||||
for k in ("strategy_id", "sell_strategy_id", "active_strategy_id"):
|
||||
assert k not in row
|
||||
# PortfolioItem-origin fields now present (REQ-116 additive)
|
||||
assert "strategy_params" in row # {} (safe default, no snapshot_store)
|
||||
assert "strategy_name" in row # None
|
||||
assert "markets" in row # []
|
||||
|
||||
|
||||
def test_reused_slot_shows_no_state():
|
||||
|
||||
@@ -160,6 +160,9 @@ class PortfolioHubReadAdapter:
|
||||
pid = str(raw.get("portfolio_id") or raw.get("asset_id") or "")
|
||||
if not pid:
|
||||
continue
|
||||
# REQ-116 (BAT-055): parse strategy_name and markets additively.
|
||||
# Absent keys or wrong types → safe defaults (None / []).
|
||||
raw_markets = raw.get("markets")
|
||||
out.append(PortfolioItem(
|
||||
portfolio_id=pid,
|
||||
symbol=str(raw.get("symbol") or ""),
|
||||
@@ -167,6 +170,8 @@ class PortfolioHubReadAdapter:
|
||||
asset_quantity=float(raw.get("asset_quantity") or 0.0),
|
||||
active_strategy_id=raw.get("active_strategy_id") or None,
|
||||
strategy_params=dict(raw.get("strategy_params") or {}),
|
||||
strategy_name=raw.get("strategy_name") or None,
|
||||
markets=list(raw_markets) if isinstance(raw_markets, list) else [],
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
@@ -228,11 +228,14 @@ def make_slots_rpc_handler(
|
||||
max_active_slots: Optional[int] = None,
|
||||
strategy_state_store=None,
|
||||
):
|
||||
"""Build the RPC handler for `trex.slots.req` (REQ-073/074).
|
||||
"""Build the RPC handler for `trex.slots.req` (REQ-073/074/116).
|
||||
|
||||
Request: {filter?: str, spy?: bool}
|
||||
Request: {filter?: str, spy?: bool, strategy?: str}
|
||||
filter: "all"|"inactive"|"pending"|"holding"|"error" (default "all")
|
||||
spy=true: watchdog not updated (operator view, REQ-034 CHG-01)
|
||||
strategy: exact-match on strategy_name (REQ-116, optional). When specified
|
||||
only slots whose strategy_name == strategy are returned. AND-combined with
|
||||
filter. available_slots is always computed before this filter (REQ-073 AC-6).
|
||||
|
||||
Response: {slots: [...], available_slots: int|null, degraded: bool,
|
||||
reason: str|null, message: str|null}
|
||||
@@ -244,6 +247,9 @@ def make_slots_rpc_handler(
|
||||
body = req.payload or {}
|
||||
filter_val = str(body.get("filter") or "all").lower()
|
||||
spy = bool(body.get("spy", False))
|
||||
# REQ-116 (BAT-055): optional strategy filter — exact-match on strategy_name.
|
||||
# None means no filter (all slots returned regardless of strategy_name).
|
||||
strategy_filter = _opt_str(body.get("strategy"))
|
||||
|
||||
# issue #25: filter by status at the DB (REQ-002/006 list_slots
|
||||
# status_filter) instead of fetching every row and filtering in Python.
|
||||
@@ -320,11 +326,19 @@ def make_slots_rpc_handler(
|
||||
reason = reason_code.value if reason_code is not None else None
|
||||
message = msg
|
||||
|
||||
# REQ-073 AC-6: available_slots computed from instance-wide active count —
|
||||
# independent of strategy filter (REQ-116). Calculate before filtering.
|
||||
if max_active_slots is not None and max_active_slots > 0:
|
||||
available_slots = max(0, max_active_slots - slot_repo.count_active(instance_key))
|
||||
else:
|
||||
available_slots = None
|
||||
|
||||
# REQ-116 (BAT-055): strategy filter — Python-level post-filter after
|
||||
# available_slots is already computed. AND-combined with status filter.
|
||||
# Unmatched strategy (no slots) returns [] without error.
|
||||
if strategy_filter is not None:
|
||||
slot_rows = [r for r in slot_rows if r.get("strategy_name") == strategy_filter]
|
||||
|
||||
return RpcResponse(payload={
|
||||
"slots": slot_rows,
|
||||
"available_slots": available_slots,
|
||||
|
||||
@@ -30,6 +30,9 @@ class PortfolioItem:
|
||||
|
||||
Mirrors portfolio-hub's `list_portfolios_v1` PortfolioItem with **no price
|
||||
field** — pricing is the strategy module's concern, not core's.
|
||||
|
||||
REQ-116 (BAT-055): strategy_name and markets added additively.
|
||||
strategy_params already existed; now surfaced in slot_to_dict output.
|
||||
"""
|
||||
portfolio_id: str # == asset_id, dcy-004 OQ-1
|
||||
symbol: str
|
||||
@@ -37,6 +40,9 @@ class PortfolioItem:
|
||||
asset_quantity: float # integer shares semantically; float for tolerance
|
||||
active_strategy_id: Optional[str] = None
|
||||
strategy_params: dict = field(default_factory=dict) # opaque, pass-through
|
||||
# REQ-116 additive fields (BAT-055) — safe defaults for legacy hub responses
|
||||
strategy_name: Optional[str] = None # portfolio-hub strategy_name; None if absent
|
||||
markets: list = field(default_factory=list) # portfolio-hub markets list; [] if absent
|
||||
|
||||
|
||||
class PortfolioHubReadError(Exception):
|
||||
@@ -461,13 +467,21 @@ def slot_to_dict(
|
||||
}
|
||||
|
||||
# hub authority: asset_quantity (hub primary) + cash_balance
|
||||
# REQ-116: strategy_name / strategy_params / markets also from PortfolioItem
|
||||
asset_qty: Optional[float] = None
|
||||
cash_balance: Optional[float] = None
|
||||
strategy_name_val: Optional[str] = None
|
||||
strategy_params_val: dict = {}
|
||||
markets_val: list = []
|
||||
if snapshot_store is not None and s.portfolio_id:
|
||||
snap = snapshot_store.get(s.portfolio_id)
|
||||
if snap is not None:
|
||||
asset_qty = float(snap.item.asset_quantity)
|
||||
cash_balance = float(snap.item.cash_balance)
|
||||
# REQ-116 (BAT-055): strategy fields from PortfolioItem (hub authority)
|
||||
strategy_name_val = snap.item.strategy_name
|
||||
strategy_params_val = dict(snap.item.strategy_params)
|
||||
markets_val = list(snap.item.markets)
|
||||
|
||||
# qty (slot mirror) vs asset_quantity (hub): divergence → warned=True
|
||||
slot_qty = float(s.qty) if s.qty is not None else None
|
||||
@@ -477,6 +491,10 @@ def slot_to_dict(
|
||||
d["cash_balance"] = cash_balance
|
||||
d["cash_estimated"] = False # handler overlays ledger_outbox estimate if available
|
||||
d["warned"] = warned
|
||||
# REQ-116 (BAT-055): strategy fields — additive, safe default when hub absent
|
||||
d["strategy_name"] = strategy_name_val
|
||||
d["strategy_params"] = strategy_params_val
|
||||
d["markets"] = markets_val
|
||||
|
||||
# watchdog nested object (WatchdogStore)
|
||||
if watchdog_store is not None:
|
||||
|
||||
Reference in New Issue
Block a user