diff --git a/docs/batches/bat-055_20260704_slots-strategy-field-filter/design.md b/docs/batches/bat-055_20260704_slots-strategy-field-filter/design.md new file mode 100644 index 0000000..30ea67e --- /dev/null +++ b/docs/batches/bat-055_20260704_slots-strategy-field-filter/design.md @@ -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 필드 단언 테스트로 충족. diff --git a/docs/batches/bat-055_20260704_slots-strategy-field-filter/implementation.md b/docs/batches/bat-055_20260704_slots-strategy-field-filter/implementation.md new file mode 100644 index 0000000..b5414fe --- /dev/null +++ b/docs/batches/bat-055_20260704_slots-strategy-field-filter/implementation.md @@ -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 참조). diff --git a/docs/batches/bat-055_20260704_slots-strategy-field-filter/index.md b/docs/batches/bat-055_20260704_slots-strategy-field-filter/index.md new file mode 100644 index 0000000..0ff0b65 --- /dev/null +++ b/docs/batches/bat-055_20260704_slots-strategy-field-filter/index.md @@ -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) diff --git a/docs/batches/bat-055_20260704_slots-strategy-field-filter/planning.md b/docs/batches/bat-055_20260704_slots-strategy-field-filter/planning.md new file mode 100644 index 0000000..e750dc8 --- /dev/null +++ b/docs/batches/bat-055_20260704_slots-strategy-field-filter/planning.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) diff --git a/docs/batches/bat-055_20260704_slots-strategy-field-filter/verification.md b/docs/batches/bat-055_20260704_slots-strategy-field-filter/verification.md new file mode 100644 index 0000000..9ec42f5 --- /dev/null +++ b/docs/batches/bat-055_20260704_slots-strategy-field-filter/verification.md @@ -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 필드 무시 계약). diff --git a/docs/interface-contract.md b/docs/interface-contract.md index fae75b0..f3d64c6 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -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 파싱 확장. diff --git a/docs/srs/Interface/req-116_slots-req-strategy-field-and-filter.md b/docs/srs/Interface/req-116_slots-req-strategy-field-and-filter.md new file mode 100644 index 0000000..d191c9d --- /dev/null +++ b/docs/srs/Interface/req-116_slots-req-strategy-field-and-filter.md @@ -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). diff --git a/docs/srs/index.md b/docs/srs/index.md index bb2ccb0..bf2104d 100644 --- a/docs/srs/index.md +++ b/docs/srs/index.md @@ -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 인계. diff --git a/tests/adapters/portfolio/test_hub_read.py b/tests/adapters/portfolio/test_hub_read.py index 9cecaac..569ed2b 100644 --- a/tests/adapters/portfolio/test_hub_read.py +++ b/tests/adapters/portfolio/test_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 == {} diff --git a/tests/application/test_slots_handler.py b/tests/application/test_slots_handler.py index cba8589..bb5764c 100644 --- a/tests/application/test_slots_handler.py +++ b/tests/application/test_slots_handler.py @@ -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 diff --git a/tests/application/test_slots_handler_strategy.py b/tests/application/test_slots_handler_strategy.py new file mode 100644 index 0000000..a278b3b --- /dev/null +++ b/tests/application/test_slots_handler_strategy.py @@ -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"] diff --git a/tests/application/test_slots_handler_strategy_state.py b/tests/application/test_slots_handler_strategy_state.py index 302b06e..e717b14 100644 --- a/tests/application/test_slots_handler_strategy_state.py +++ b/tests/application/test_slots_handler_strategy_state.py @@ -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(): diff --git a/trex_core/adapters/portfolio/hub_read.py b/trex_core/adapters/portfolio/hub_read.py index 2d0b96a..8dacde0 100644 --- a/trex_core/adapters/portfolio/hub_read.py +++ b/trex_core/adapters/portfolio/hub_read.py @@ -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 diff --git a/trex_core/application/messaging_handlers.py b/trex_core/application/messaging_handlers.py index 516902f..3a50b75 100644 --- a/trex_core/application/messaging_handlers.py +++ b/trex_core/application/messaging_handlers.py @@ -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, diff --git a/trex_core/application/portfolio.py b/trex_core/application/portfolio.py index c407167..32f1873 100644 --- a/trex_core/application/portfolio.py +++ b/trex_core/application/portfolio.py @@ -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: