Merge pull request #37 from fourblocks2020/feat/req-116-slots-strategy-field-filter

bat-055: slots.req strategy 필터 + row strategy/markets 필드 (REQ-116)
This commit is contained in:
2026-07-04 11:37:18 +09:00
committed by GitHub
15 changed files with 845 additions and 13 deletions
@@ -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 필드 무시 계약).
+17 -6
View File
@@ -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
@@ -557,3 +567,4 @@ trex-core가 `source="trex-core"`, `severity="critical"`로 발행하는 초기
- 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-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 파싱 확장.
@@ -0,0 +1,129 @@
# req-116: `trex.slots.req` 전략 필드 노출 + `strategy` 요청 필터 (additive)
- Status: Approved
- Type: Interface
- Priority: High
- Owner: trex-core-dev
## Intent
- 신규 전략 데몬 trex-tvr-v1(TVR — Target-Value Rebalancing)이 `trex.slots.req` 응답 행에서 `strategy_name`·`strategy_params`·`markets` 를 읽어 자기 슬롯을 식별하고 리밸런싱 목표 비중(`target_weight` 등)을 파라미터로 수신해야 한다. 또한 `strategy="tvr_v1"` 요청 필터로 자기 전략 슬롯만 수신하려 한다.
- REQ-074 AC-5 는 dcy-025 OQ-4 당시 결정을 유보한 것이었고, 본 REQ 는 그 additive 재도입(dcy-025 OQ-4 후속)이다. **기존 소비자(orb·tsl)에게 byte-동일 기본 동작을 보장**하면서 필드를 추가한다.
- 근거: trex-tvr-v1 Discovery(dcy-001/dcy-002 v3 FR-8, confirmed) — portfolio-hub 측 `strategy_name`/`markets`/`strategy_params` 노출 REQ(①)가 선행 배포된 뒤 본 REQ(②)를 배포한다.
## Requirement
### 행 필드 추가 (additive — 기존 필드 불변)
- `slot_to_dict()`(application/portfolio.py)가 `SnapshotStore` 의 `PortfolioItem` 에서 아래 세 필드를 읽어 슬롯 행에 추가한다:
- `strategy_name: str | null` — portfolio-hub `strategy_name`. PortfolioItem 에 없거나 빈 값이면 `null`.
- `strategy_params: dict` — portfolio-hub `strategy_params` opaque dict. PortfolioItem 에 없으면 `{}`.
- `markets: list[str]` — portfolio-hub `markets` 배열. PortfolioItem 에 없으면 `[]`.
- 이 세 필드의 정본 출처는 portfolio-hub(SnapshotStore 경유)이다. hub 응답에 없으면 safe default(null/`{}`/`[]`)를 사용하고 데몬을 중단하지 않는다.
- hub_read.py(`get_portfolios`) 가 portfolio-hub 응답에서 `strategy_name` 과 `markets` 를 파싱해 `PortfolioItem` 에 담는다. 구버전 hub 응답(미존재 키)에서는 각각 `None`/`[]` 로 safe-default 한다.
- `PortfolioItem` 도메인 객체에 `strategy_name: Optional[str]` 과 `markets: list[str]` 필드를 추가한다.
### 요청 `strategy` 필터 추가 (additive — 기존 필터 불변)
- `trex.slots.req` 요청 payload 에 선택적 파라미터 `strategy: str` 을 추가한다.
- 지정 시: `strategy_name == strategy` 인 슬롯 행만 반환한다. 대소문자 exact-match.
- 미지정 시: 기존과 완전 동일(전체 반환).
- `strategy` 필터는 기존 `filter`(status 필터) 와 **AND 조합**된다. 두 필터를 동시에 지정하면 status 조건과 strategy 조건 모두 만족하는 행만 반환한다.
- `available_slots` 힌트는 `strategy` 필터 적용 여부와 **무관하게 인스턴스 전체 활성 슬롯 기준**으로 계산한다(capacity 의미 불변 — REQ-073 AC-6).
### interface-contract.md 갱신
- `trex.slots.req` 요청 payload 에 `strategy: str (optional)` 파라미터를 추가한다.
- 응답 슬롯 행 스키마에 `strategy_name`·`strategy_params`·`markets` 필드와 정본 소스 테이블을 추가한다.
- REQ-074 AC-5 주석("strategy 필드 없음")을 본 REQ 에 의한 additive 재도입으로 갱신한다.
## Acceptance Criteria
1. **행 필드 노출**: Given portfolio-hub 가 `strategy_name="tvr_v1"`, `strategy_params={"target_weight": 0.05}`, `markets=["KRX"]` 를 반환하는 환경에서, When `trex.slots.req` 조회, Then 해당 슬롯 행에 `strategy_name="tvr_v1"`, `strategy_params={"target_weight": 0.05}`, `markets=["KRX"]` 가 포함된다.
2. **hub 미제공 safe-default**: Given portfolio-hub 응답에 `strategy_name`·`markets` 키가 없는 구버전 응답 환경에서, When `trex.slots.req` 조회, Then 행의 `strategy_name=null`, `strategy_params={}`, `markets=[]` 이고 데몬이 중단되지 않는다.
3. **strategy 필터 — 매칭**: Given `strategy_name="tvr_v1"` 인 슬롯과 `strategy_name="orb_v1"` 인 슬롯이 공존하는 인스턴스에서, When `{strategy:"tvr_v1"}` 요청, Then `strategy_name="tvr_v1"` 인 슬롯 행만 반환된다.
4. **strategy 필터 — 미지정**: Given 동일 인스턴스에서, When `strategy` 없이 요청(기존 포맷), Then 모든 슬롯 행이 반환되고 응답 내용이 `strategy` 도입 전과 동일하다(기존 필드 불변 — 신규 필드만 추가).
5. **AND 조합**: Given `strategy_name="tvr_v1"` 인 holding 슬롯과 `strategy_name="tvr_v1"` 인 inactive 슬롯이 있는 환경에서, When `{filter:"holding", strategy:"tvr_v1"}` 요청, Then `strategy_name="tvr_v1"` + status `holding` 인 슬롯만 반환된다.
6. **available_slots 불변**: Given `max_active_slots=10`, 현재 active 3개인 인스턴스에서, When `{strategy:"tvr_v1"}` 요청(tvr_v1 슬롯이 1개뿐이라도), Then `available_slots=7`(인스턴스 전체 기준). strategy 필터가 available_slots 계산에 영향을 미치지 않는다.
7. **orb·tsl 회귀 없음**: Given 기존 orb-v1 또는 tsl-v1 소비자가 `strategy` 파라미터 없이 기존 요청 포맷을 전송하는 경우, When 처리, Then 응답 키 집합 및 기존 필드 값이 변경 전과 동일하다(신규 `strategy_name`·`strategy_params`·`markets` 필드 추가는 backward compatible — unknown 필드 무시 계약).
8. **계약 테스트 갱신**: Given 슬롯 계약 테스트(`EXPECTED_SLOT_FIELDS` 등)가 존재하는 경우, When 본 REQ 구현, Then 계약 테스트가 신규 필드를 포함하도록 갱신되고 통과한다.
## Impacted Area
- Docs:
- `docs/interface-contract.md` — 요청 파라미터(`strategy` 추가) + 행 스키마(`strategy_name`·`strategy_params`·`markets` 추가) + 정본 소스 테이블 + REQ-074 AC-5 주석 갱신
- Modules:
- `trex_core/application/portfolio.py` — `PortfolioItem` 에 `strategy_name: Optional[str]`·`markets: list[str]` 필드 추가; `slot_to_dict()` 에서 SnapshotStore 의 `PortfolioItem` strategy 필드 읽기
- `trex_core/adapters/portfolio/hub_read.py` — `get_portfolios()` 에서 `strategy_name`·`markets` 파싱 추가
- `trex_core/application/messaging_handlers.py` — `make_slots_rpc_handler()` 에서 `strategy` 파라미터 파싱 + strategy 필터 적용(행 후처리 or slot_to_dict 이후 Python 필터)
- Tests:
- `tests/application/test_slots_handler.py` (또는 신규) — strategy 필터 AC-3/4/5, available_slots 불변 AC-6, 회귀 AC-7
- `tests/application/test_slot_to_dict.py` (또는 기존 행 스키마 테스트) — strategy 필드 노출 AC-1/2
- 슬롯 계약 테스트(`EXPECTED_SLOT_FIELDS` 등) — AC-8 갱신
## Notes
- **REQ-074 AC-5 와의 관계**: REQ-074 AC-5 는 dcy-025 OQ-4 당시 "전략↔자산 할당 모델 미확정" 사유로 strategy 필드 전체를 제외했다. 본 REQ 는 그 유보 결정을 additive 로 개정(OQ-4 후속 확정)하며, REQ-074 의 기존 AC(1~6) 는 불변이다. REQ-074 에 change-req 를 별도로 기록하지 않고 본 REQ Notes 에 관계를 명기하는 것으로 충분하다 — 이유: REQ-074 는 Implemented 상태이고 AC-5 의 "strategy 계열 제외" 불변식 자체를 파괴하지 않으며, 본 REQ 가 additive 재도입이라는 별도 REQ 근거를 남기는 것이 추적성 측면에서 더 명확하다. 단, 구현 batch 에서 REQ-074 CHG 항목으로도 참조를 남긴다.
- **라이브 wire 계약 변경 — 고위험**: orb-v1·tsl-v1 이 현재 `trex.slots.req` 를 소비 중이다. 신규 필드는 additive(unknown 필드 무시)이므로 orb·tsl 파손 없음이 예상되나, 계약 테스트(AC-8)·회귀 테스트(AC-7) 필수. 구현은 worktree+PR+QC 정식 플로로 진행한다.
- **배포 선행 조건**: portfolio-hub 측 `strategy_name`/`markets`/`strategy_params` 노출 REQ(①)가 배포된 뒤 본 REQ(②)를 배포한다. 선행 배포 전에는 safe-default(AC-2) 로 동작한다.
- **`strategy` 필터 적용 위치**: `strategy_name` 은 SnapshotStore(in-memory) 에 있으므로 DB 레벨 필터가 아닌 Python 레벨 후처리 필터로 구현한다. status 필터(`list_slots(status_filter=...)`)가 먼저 DB 에서 실행되고, 그 결과에 strategy 필터를 Python 으로 적용한다. available_slots 는 필터 이전에 계산한다(AC-6).
- **`strategy` 값 오타/미존재**: `strategy` 로 지정한 값에 매칭되는 슬롯이 없으면 빈 `slots=[]` 를 반환한다(에러 없음 — REQ-073 AC-2 의 unrecognized filter 처리 선례).
- **`PortfolioItem.markets` 타입**: portfolio-hub 가 `markets: list[str]` 를 반환하지 않는 경우(None 또는 키 부재) `[]` 로 처리. `PortfolioItem.markets` 기본값 `field(default_factory=list)`.
- **Open Question**: portfolio-hub 가 `strategy_name` 필드를 `active_strategy_id` 와 별도로 반환하는지, 아니면 `active_strategy_id` 를 `strategy_name` 의미로 사용하는지 portfolio-hub REQ(①) 확정 후 확인 필요. 구현 전에 portfolio-hub 응답 스키마를 검증한다. (현재 hub_read.py 는 `active_strategy_id` 를 파싱하지만 `strategy_name` 은 별도 필드일 수 있다.)
## Change Log
### CHG-20260704-02
- Date: 2026-07-04
- Author: 팀장(Claude) — confirm-req
#### Change Summary
- Proposed → Approved: 팀장 검토·승인. tvr e2e 배관 ②(흘려주기). Notes의 오픈질문(strategy_name 별도 필드 여부)은 portfolio-hub REQ-055(Approved)가 `active_strategy_id`와 **별도 필드 `strategy_name`으로 확정** — 필드명 4종(active_strategy_id/strategy_name/strategy_params/markets) 상호 잠금. 라이브 wire 계약 변경이므로 구현은 worktree+PR+QC 정식 플로, 배포는 REQ-055 후.
#### Acceptance Criteria Delta
- Added/Updated/Removed: 없음
#### Revalidation Impact
- Reverification Needed: no / Existing Implementation Invalidated: no
- Status Recommendation: Approved
### CHG-20260704-01
- Date: 2026-07-04
- Author: trex-core-dev
#### Change Summary
- trex-tvr-v1 Discovery(dcy-001/dcy-002 v3 FR-8, confirmed)로부터 최초 생성(Proposed). `trex.slots.req` additive 확장 — 행 `strategy_name`·`strategy_params`·`markets` 필드 추가 + 요청 `strategy` 필터 추가. REQ-074 AC-5 additive 재도입(dcy-025 OQ-4 후속).
#### Intent
- trex-tvr-v1 이 `strategy="tvr_v1"` 필터로 자기 슬롯만 수신하고 행의 `strategy_params.target_weight` 로 리밸런싱할 수 있도록 wire 계약을 확장.
#### Acceptance Criteria Delta
- Added:
- AC-1~8 신규
- Updated: none
- Removed: none
#### Impacted Area Delta
- Docs: docs/interface-contract.md (요청·응답 스키마 갱신)
- Modules: portfolio.py / hub_read.py / messaging_handlers.py
- Tests: slots 핸들러·행 스키마·계약 테스트
#### Delivery Trace Delta
- Batches: none (승인 후 draft-batch)
- Releases: none
#### Revalidation Impact
- Reverification Needed: no (신규)
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current (Proposed)
- Follow-up Action: confirm-req → suggest-batch-reqs → draft-batch
#### Validation Plan
- 신규 batch: 행 필드 단언(AC-1/2), strategy 필터 단위(AC-3/4/5), available_slots 불변(AC-6), orb·tsl 회귀(AC-7), 계약 테스트 갱신(AC-8).
+3 -2
View File
@@ -74,7 +74,7 @@
## Next Requirement ID
- Current: `REQ-116`
- Current: `REQ-117`
- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다.
## Requirement Register
@@ -196,11 +196,12 @@
| REQ-113 | KISConfig 소스 우선순위 — portfolio-hub fetch 1차 + env break-glass + opt-in | Configuration | Approved | High | trex-core-dev | [req-113](./Configuration/req-113_kisconfig-fetch-source-priority-and-break-glass.md) |
| REQ-114 | 그날-시작 로드 게이트 — fail-closed 신규 진입 차단 + 알람 | Integration | Implemented | High | trex-core-dev | [req-114](./Integration/req-114_daystart-load-gate-fail-closed-entry.md) |
| REQ-115 | 그날-시작 장중 무중단 보장 — 그날 고정 인증정보 캐시 + portfolio-hub 장애 비중단 | Non-Functional | Approved | High | trex-core-dev | [req-115](./Non-Functional/req-115_daystart-intraday-resilience-credential-cache.md) |
| REQ-116 | `trex.slots.req` 전략 필드 노출 + `strategy` 요청 필터 (additive) | Interface | Approved | High | trex-core-dev | [req-116](./Interface/req-116_slots-req-strategy-field-and-filter.md) |
## Recent Change Log Summary
- 2026-07-04 :: REQ-116 **신규 생성(Approved, 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 배포 대기.
- 2026-06-26 :: REQ-102~108 **Approved → Implemented** (confirm-req-implemented, dcy-034): BAT-050(core-A, PR #31 ced7eda) + BAT-051(core-B, PR #32 07b393d) origin/main 머지 완료. QC(opus) PASS + 팀장 confirm-batch-verification. 최종 683 passed/47 skipped/0 fail. 차단형 reconcile(REQ-102·103), 전략 차단 게이트(REQ-104), sync/verify CLI(REQ-105·106), 차단/해제 로깅+알람(REQ-107), NFR(REQ-108) 전 AC 검증 완료. M3 배포 대기.
- 2026-06-26 :: REQ-109~111 **신규 생성(Proposed, draft-req dcy-035)**: 멀티데이 실현 PnL — WAC 기반 운영 리포트. REQ-109(portfolio-hub /pnl/realized read 포트+어댑터+stub), REQ-110(운영 리포트 WAC 실현손익 통합: 전 인스턴스 공통, 매수일 수수료 별도, completeness 경고), REQ-111(portfolio-hub 미가용 시 실현손익 섹션 에러 degrade, 폴백 없음). NFR-1(wire 불변)/NFR-3(데몬 격리)은 REQ-111 AC/Notes 흡수. NFR-2(일중 WAC==net 회귀)는 REQ-110 AC-2 흡수. OQ-2(기간)/OQ-3(수수료 출처)/OQ-4(N콜) design 인계.
+72
View File
@@ -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 == {}
+7 -2
View File
@@ -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():
+5
View File
@@ -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
+16 -2
View File
@@ -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,
+18
View File
@@ -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: