Merge pull request #20 from fourblocks2020/feat/req-055-portfolios-strategy-market-meta

bat-023: /v1/portfolios 전략·시장 메타 노출 (REQ-055)
This commit is contained in:
2026-07-04 11:37:19 +09:00
committed by GitHub
12 changed files with 845 additions and 10 deletions
+5
View File
@@ -81,6 +81,11 @@ def _portfolio_to_item(portfolio: Portfolio) -> PortfolioItem:
managed_by_daemon_id=portfolio.managed_by_daemon_id, managed_by_daemon_id=portfolio.managed_by_daemon_id,
created_at=portfolio.created_at, created_at=portfolio.created_at,
updated_at=portfolio.updated_at, updated_at=portfolio.updated_at,
# REQ-055: 전략·시장 메타데이터 additive 필드
active_strategy_id=portfolio.active_strategy_id,
strategy_name=portfolio.strategy_name,
strategy_params=portfolio.strategy_params,
markets=portfolio.markets,
) )
+4 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
from typing import Protocol, runtime_checkable from typing import Any, Protocol, runtime_checkable
class MarketCode(str, Enum): class MarketCode(str, Enum):
@@ -116,6 +116,9 @@ class Portfolio:
price: float = 0.0 price: float = 0.0
profit_buffer: float = 0.0 profit_buffer: float = 0.0
active_strategy_id: str | None = None active_strategy_id: str | None = None
strategy_name: str | None = None # REQ-055: strategy_sets.name JOIN 결과
strategy_params: dict[str, Any] = field(default_factory=dict) # REQ-055: strategy_sets.config 파싱 결과
markets: list[str] = field(default_factory=list) # REQ-055: assets.markets JOIN 결과
avabile_strategies: list[str] | None = None avabile_strategies: list[str] | None = None
created_at: datetime | None = None created_at: datetime | None = None
updated_at: datetime | None = None updated_at: datetime | None = None
+36 -1
View File
@@ -3,11 +3,15 @@
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import json
import logging
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
logger = logging.getLogger(__name__)
import psycopg import psycopg
from psycopg import sql from psycopg import sql
@@ -64,9 +68,13 @@ _SELECT_PORTFOLIO = """
p.managed_by_daemon_id, p.managed_by_daemon_id,
p.asset_id, p.asset_id,
COALESCE(p.tag_ids, '{}'::uuid[]), COALESCE(p.tag_ids, '{}'::uuid[]),
a.display_name a.display_name,
ss.name,
ss.config,
a.markets
FROM portfolios p FROM portfolios p
JOIN assets a ON p.asset_id = a.asset_id JOIN assets a ON p.asset_id = a.asset_id
LEFT JOIN strategy_sets ss ON p.active_strategy_id = ss.id
""" """
_SELECT_TAG = """ _SELECT_TAG = """
@@ -495,6 +503,30 @@ class PostgresPortfolioRepository:
else: else:
avabile_strategies = [str(raw_available)] avabile_strategies = [str(raw_available)]
# REQ-055: strategy_name (row[19] = ss.name from LEFT JOIN strategy_sets)
strategy_name: str | None = row[19] if len(row) > 19 else None
# REQ-055: strategy_params — JSON 파싱 (파싱 불가 시 {} + WARNING, 500 금지)
_config_raw = row[20] if len(row) > 20 else None
if not _config_raw:
strategy_params: dict[str, Any] = {}
else:
try:
_parsed = json.loads(_config_raw)
strategy_params = _parsed if isinstance(_parsed, dict) else {}
except (json.JSONDecodeError, ValueError):
logger.warning(
"strategy_sets.config 파싱 실패 — portfolio_id=%s, active_strategy_id=%s, config=%r",
row[0],
row[9] if len(row) > 9 else None,
_config_raw[:200] if isinstance(_config_raw, str) else _config_raw,
)
strategy_params = {}
# REQ-055: markets (row[21] = a.markets from assets JOIN)
_markets_raw = row[21] if len(row) > 21 else None
markets: list[str] = list(_markets_raw) if _markets_raw else []
return Portfolio( return Portfolio(
portfolio_id=row[0], portfolio_id=row[0],
broker=row[1] or "kis", broker=row[1] or "kis",
@@ -516,6 +548,9 @@ class PostgresPortfolioRepository:
asset_id=str(row[16]) if len(row) > 16 and row[16] is not None else None, asset_id=str(row[16]) if len(row) > 16 and row[16] is not None else None,
tag_ids=[str(t) for t in row[17]] if len(row) > 17 and row[17] is not None else [], tag_ids=[str(t) for t in row[17]] if len(row) > 17 and row[17] is not None else [],
display_name=row[18] if len(row) > 18 else None, display_name=row[18] if len(row) > 18 else None,
strategy_name=strategy_name,
strategy_params=strategy_params,
markets=markets,
) )
@staticmethod @staticmethod
+5
View File
@@ -181,6 +181,11 @@ class PortfolioItem(BaseModel):
managed_by_daemon_id: str | None = None managed_by_daemon_id: str | None = None
created_at: datetime | None = None created_at: datetime | None = None
updated_at: datetime | None = None updated_at: datetime | None = None
# REQ-055: 전략·시장 메타데이터 additive 필드
active_strategy_id: str | None = None
strategy_name: str | None = None
strategy_params: dict[str, Any] = Field(default_factory=dict)
markets: list[str] = Field(default_factory=list)
class PortfolioListResponse(BaseModel): class PortfolioListResponse(BaseModel):
@@ -0,0 +1,154 @@
# BAT-023 Design
## 변경 범위 요약
additive 확장. 기존 `PortfolioItem` 16필드·토큰분기·필터는 불변. 4개 신규 필드만 추가.
## SQL 쿼리 변경
### 변경 전 (_SELECT_PORTFOLIO)
```sql
SELECT
p.portfolio_id, -- [0]
p.broker, -- [1]
p.status, -- [2]
p.name, -- [3]
a.symbol, -- [4]
p.cash_balance, -- [5]
p.asset_quantity, -- [6]
p.asset_price, -- [7]
p.profit_buffer, -- [8]
p.active_strategy_id, -- [9]
p.avabile_strategies, -- [10]
p.owner_user_id, -- [11]
p.created_at, -- [12]
p.updated_at, -- [13]
p.last_trade_at, -- [14]
p.managed_by_daemon_id, -- [15]
p.asset_id, -- [16]
COALESCE(p.tag_ids, '{}'::uuid[]), -- [17]
a.display_name -- [18]
FROM portfolios p
JOIN assets a ON p.asset_id = a.asset_id
```
### 변경 후
```sql
SELECT
p.portfolio_id, -- [0]
p.broker, -- [1]
p.status, -- [2]
p.name, -- [3]
a.symbol, -- [4]
p.cash_balance, -- [5]
p.asset_quantity, -- [6]
p.asset_price, -- [7]
p.profit_buffer, -- [8]
p.active_strategy_id, -- [9]
p.avabile_strategies, -- [10]
p.owner_user_id, -- [11]
p.created_at, -- [12]
p.updated_at, -- [13]
p.last_trade_at, -- [14]
p.managed_by_daemon_id, -- [15]
p.asset_id, -- [16]
COALESCE(p.tag_ids, '{}'::uuid[]), -- [17]
a.display_name, -- [18]
ss.name, -- [19] NEW: strategy_sets.name (NULL if unbound/dangling)
ss.config, -- [20] NEW: strategy_sets.config TEXT (NULL if unbound/dangling)
a.markets -- [21] NEW: assets.markets TEXT[]
FROM portfolios p
JOIN assets a ON p.asset_id = a.asset_id
LEFT JOIN strategy_sets ss ON p.active_strategy_id = ss.id
```
LEFT JOIN이므로:
- `active_strategy_id IS NULL` → ss.name=NULL, ss.config=NULL
- dangling FK (active_strategy_id 있으나 strategy_sets row 없음) → ss.name=NULL, ss.config=NULL
## _row_to_portfolio 파싱 로직 (신규 컬럼)
```python
import json
import logging
logger = logging.getLogger(__name__) # 기존 없으면 추가
# strategy_name
strategy_name = row[19] if len(row) > 19 else None
# strategy_params
_config_raw = row[20] if len(row) > 20 else None
if not _config_raw:
strategy_params: dict = {}
else:
try:
strategy_params = json.loads(_config_raw)
if not isinstance(strategy_params, dict):
strategy_params = {}
except (json.JSONDecodeError, ValueError):
logger.warning(
"strategy_sets.config 파싱 실패 — portfolio_id=%s, active_strategy_id=%s, config=%r",
row[0], row[9], _config_raw[:100] if _config_raw else None,
)
strategy_params = {}
# markets
_markets_raw = row[21] if len(row) > 21 else None
markets = list(_markets_raw) if _markets_raw else []
```
## Portfolio dataclass 변경
```python
@dataclass
class Portfolio:
...
active_strategy_id: str | None = None
strategy_name: str | None = None # NEW
strategy_params: dict = field(default_factory=dict) # NEW
markets: list[str] = field(default_factory=list) # NEW
avabile_strategies: list[str] | None = None
...
```
`Any` 타입이 필요한 경우 `dict` 또는 `dict[str, Any]`로 선언. `typing.Any` import 추가.
## PortfolioItem 스키마 변경
```python
class PortfolioItem(BaseModel):
...
# 기존 16필드 유지
active_strategy_id: str | None = None # NEW (REQ-055)
strategy_name: str | None = None # NEW (REQ-055)
strategy_params: dict[str, Any] = Field(default_factory=dict) # NEW (REQ-055)
markets: list[str] = Field(default_factory=list) # NEW (REQ-055)
```
## _portfolio_to_item 변경
```python
def _portfolio_to_item(portfolio: Portfolio) -> PortfolioItem:
return PortfolioItem(
...
# 기존 필드 유지
active_strategy_id=portfolio.active_strategy_id, # NEW
strategy_name=portfolio.strategy_name, # NEW
strategy_params=portfolio.strategy_params, # NEW
markets=portfolio.markets, # NEW
)
```
## 영향받지 않는 경로
- `create_portfolio`, `update_portfolio`, `soft_delete_portfolio` — 쓰기 경로는 `_SELECT_PORTFOLIO` 후 `_row_to_portfolio`를 통해 읽어오므로 자동 혜택. 변경 불필요.
- `get_or_create` — 동일하게 `_SELECT_PORTFOLIO` 사용.
- 기존 테스트들의 `_make_portfolio` — `strategy_name`/`strategy_params`/`markets`에 기본값이 있으므로 인수 변경 불필요.
## 하위호환성
- 신규 필드 모두 `Optional` 또는 `default_factory`로 선언 → 기존 소비자가 필드를 무시해도 안전.
- 기존 16필드 이름·타입 불변.
- N+1 없음: 단일 SELECT + LEFT JOIN으로 해결.
@@ -0,0 +1,50 @@
# BAT-023 — GET /v1/portfolios 응답에 전략·시장 메타데이터 노출 (additive)
- Batch ID: BAT-023
- Profile: batch-lite
- Status: in-progress
- Discovery: trex-tvr-v1 dcy-001 (confirmed)
- Included REQ: REQ-055
- Folder: docs/batches/bat-023_20260704_portfolios-strategy-market-meta/
- Created: 2026-07-04
## Summary
REQ-055 구현: `GET /v1/portfolios`(및 단건) 응답 `PortfolioItem`에 전략·시장 메타데이터 4 필드를 additive 노출한다.
- `active_strategy_id` (str|null) — portfolios.active_strategy_id 원본
- `strategy_name` (str|null) — strategy_sets.name JOIN 결과
- `strategy_params` (dict) — strategy_sets.config JSON 파싱 결과; 미바인딩·파싱불가 → `{}`
- `markets` (list[str]) — assets.markets JOIN 결과; 미연결·빈 → `[]`
기존 16필드·토큰분기·필터는 불변(하위호환 additive). N+1 금지: LEFT JOIN으로 일괄 처리.
trex-core `hub_read.py:168-169`의 `active_strategy_id`·`strategy_params` 파싱 준비와의 계약 필드명 정확히 일치.
## Included REQ
| REQ | 제목 | Type | 의존 |
| --- | --- | --- | --- |
| REQ-055 | GET /v1/portfolios 응답에 전략·시장 메타데이터 노출 (additive) | Interface | strategy_sets 테이블, assets.markets 컬럼 (기존) |
## DoR (진입 조건)
- [x] REQ-055 Approved 상태 확인
- [x] strategy_sets 테이블 및 portfolios.active_strategy_id 컬럼 기존 존재 확인
- [x] assets.markets 컬럼 기존 존재 확인
- [x] DB 마이그레이션 불필요 확인 (쿼리 JOIN 확장 + 스키마 변경만)
## DoD (완료 조건)
- [ ] REQ-055 AC-1~10 테스트 전수 통과
- [ ] 기존 테스트 회귀 없음 (365+ passed, 0 failures)
- [ ] draft-batch-verification 문서에 AC별 PASS/FAIL evidence 기록
## 비가역 결재 게이트
- DB 마이그레이션: 없음. strategy_sets·assets.markets 컬럼 기존 존재. alembic 마이그레이션 발생하지 않음.
- HTTP API 계약: additive 확장(기존 필드 불변). 하위호환 — 기존 소비자에 영향 없음.
## Notes
- 필드명 4종은 trex-core REQ-116 `active_strategy_id`·`strategy_params`와 상호 잠금 — 정확히 이 이름으로 고정.
- OQ-1(strategy_sets.config TEXT→JSONB 전환)은 본 REQ 범위 밖(서버 측 파싱으로 처리).
@@ -0,0 +1,87 @@
# BAT-023 Planning
## 구현 의존 순서
1. **domain/models.py** — `Portfolio` dataclass에 `strategy_name`, `strategy_params`, `markets` 필드 추가
2. **schemas/v1.py** — `PortfolioItem`에 4개 additive 필드 추가
3. **repositories/postgres.py** — `_SELECT_PORTFOLIO` 쿼리에 `LEFT JOIN strategy_sets` + `ss.name`, `ss.config`, `a.markets` 컬럼 추가; `_row_to_portfolio` 파싱 보강(config JSON 파싱 + 오류 격리); 단건/리스트 경로 모두 반영
4. **api/v1_portfolios.py** — `_portfolio_to_item` 매핑에 4개 신규 필드 포함
5. **tests/test_v1_portfolios_list.py** — AC-1~10 검증 케이스 추가
## Task Breakdown
### T-1: domain/models.py — Portfolio 필드 추가
`Portfolio` dataclass에 아래 필드 추가 (기존 `active_strategy_id: str | None = None` 이후):
```python
strategy_name: str | None = None
strategy_params: dict[str, Any] = field(default_factory=dict) # Any import 필요
markets: list[str] = field(default_factory=list)
```
주의: `Any`는 `typing.Any`. domain/models.py에 현재 미import — 추가 필요.
### T-2: schemas/v1.py — PortfolioItem 4개 필드 추가
`PortfolioItem` 클래스에 기존 `updated_at` 이후:
```python
active_strategy_id: str | None = None
strategy_name: str | None = None
strategy_params: dict[str, Any] = Field(default_factory=dict)
markets: list[str] = Field(default_factory=list)
```
`active_strategy_id`는 이미 Portfolio domain model에 있으나 PortfolioItem 스키마엔 없음 — 이번에 함께 노출.
### T-3: repositories/postgres.py — 쿼리 + 파싱 보강
`_SELECT_PORTFOLIO` 쿼리를:
- `JOIN assets a` → 유지
- `LEFT JOIN strategy_sets ss ON p.active_strategy_id = ss.id` 추가
- SELECT 컬럼: 기존 19개 + `ss.name` (index 19), `ss.config` (index 20), `a.markets` (index 21) 추가
`_row_to_portfolio`:
- `strategy_name = row[19] if len(row) > 19 else None`
- `strategy_params`: `row[20]` config 파싱:
- None 또는 빈 문자열 → `{}`
- 유효 JSON → `json.loads(row[20])`
- 파싱 오류 → `{}` + `logger.warning(...)` (500 금지)
- `markets = list(row[21]) if len(row) > 21 and row[21] else []`
N+1 해소: 이미 SQL JOIN이므로 별도 IN-fetch 불필요.
### T-4: api/v1_portfolios.py — _portfolio_to_item 매핑
`_portfolio_to_item` 함수에 4개 필드 추가:
```python
active_strategy_id=portfolio.active_strategy_id,
strategy_name=portfolio.strategy_name,
strategy_params=portfolio.strategy_params,
markets=portfolio.markets,
```
### T-5: tests/test_v1_portfolios_list.py — AC-1~10
기존 `_make_portfolio` / `_FakeListRepo` 헬퍼 확장:
- `_make_portfolio`에 `active_strategy_id`, `strategy_name`, `strategy_params`, `markets` 파라미터 추가
- AC-1: 바인딩 포트폴리오 → active_strategy_id/strategy_name/strategy_params 정상 반환
- AC-2: 미바인딩 → active_strategy_id=null, strategy_name=null, strategy_params={}
- AC-3: dangling FK(active_strategy_id 있으나 row 없음) → active_strategy_id 원래 값, strategy_name=null, strategy_params={}
- AC-4: config 파싱불가 → strategy_params={} + WARNING 로그 (caplog)
- AC-5: markets=['KRX','NXT'] → 정상 반환
- AC-6: markets=[] → []
- AC-7: 기존 필드 회귀 (portfolio_id, broker, status, symbol, display_name 등)
- AC-8: 토큰분기 불변 (service/session 경로 모두)
- AC-9: strategy_params 원본 JSON 동일 키·값 검증
- AC-10: 혼합 리스트 (일부 바인딩, 일부 미바인딩) 개별 정확성
## Risks / Constraints
- `_row_to_portfolio` index 오프셋: 기존 row[0~18] = 19개 컬럼. LEFT JOIN 후 row[19]=ss.name, row[20]=ss.config, row[21]=a.markets. index 정확히 맞춰야 기존 필드 회귀 없음.
- LEFT JOIN이므로 active_strategy_id=NULL인 포트폴리오는 ss.name=NULL, ss.config=NULL로 반환 — 정상 처리.
- assets.markets: ARRAY 타입. `row[21]`이 Python list로 반환되므로 `list()` 변환 + None 가드 필요.
- AC-4 WARNING 로그 테스트: `pytest caplog` 사용. 로거 이름은 `app.repositories.postgres`.
- 기존 `_make_portfolio`가 `strategy_name`/`strategy_params`/`markets` 파라미터를 받지 않으므로 `Portfolio` dataclass 기본값으로 처리 — 기존 테스트 영향 없음 (default_factory 보장).
@@ -0,0 +1,70 @@
# BAT-023 Verification
- Batch ID: BAT-023
- Status: draft-batch-verification
- Date: 2026-07-04
- Verifier: portfolio-hub-dev (delivery agent)
## 실행 환경
- Python: 3.12.3
- pytest: 8.4.2
- 실행 디렉토리: worktree (`agent-a42c94dc44a9844af`)
- DB: 미연결 (TestClient + mock repo, DB 없이 전수 통과)
## 전체 pytest 결과
```
583 passed, 11 warnings in 35.08s
```
기존 565개(BAT-022 기준) → 583개 (+18, 신규 AC-1~10 케이스 포함).
실패: 0건. 회귀: 없음.
## AC별 PASS/FAIL
| AC | 테스트 함수 | 결과 | 비고 |
|---|---|---|---|
| AC-1 | `test_req055_ac1_bound_portfolio_returns_strategy_fields` | PASS | active_strategy_id/strategy_name/strategy_params 정상 반환 |
| AC-2 | `test_req055_ac2_unbound_portfolio_returns_null_strategy` | PASS | active_strategy_id=null → strategy_name=null, strategy_params={} |
| AC-3 | `test_req055_ac3_dangling_fk_returns_null_strategy_but_keeps_strategy_id` | PASS | active_strategy_id 원래 값 유지, JOIN 결과 null 처리 |
| AC-4 | `test_req055_ac4_invalid_config_json_returns_empty_params_and_logs_warning` | PASS | strategy_params={} + WARNING 로그 1건 확인 (caplog) |
| AC-5 | `test_req055_ac5_markets_with_values` | PASS | markets=['KRX','NXT'] 정상 반환 |
| AC-6 | `test_req055_ac6_empty_markets` | PASS | markets=[] 반환 |
| AC-7 | `test_req055_ac7_existing_fields_regression` | PASS | 기존 16필드 회귀 없음 |
| AC-8 | `test_req055_ac8_token_branch_invariant_service_token` | PASS | service token 분기 동작 불변 + 신규 필드 포함 |
| AC-9 | `test_req055_ac9_strategy_params_exact_key_values` | PASS | target_weight/rebalance_band_pct/min_trade_krw 키·값 동일 |
| AC-10 | `test_req055_ac10_mixed_list_individual_accuracy` | PASS | 혼합 리스트 개별 정확성 (바인딩/미바인딩 각각) |
모든 AC: **10/10 PASS**
## 변경 파일 목록
| 파일 | 변경 내용 |
|---|---|
| `app/domain/models.py` | `Portfolio` dataclass에 `strategy_name`, `strategy_params`, `markets` 3개 필드 추가. `typing.Any` import 추가. |
| `app/schemas/v1.py` | `PortfolioItem`에 `active_strategy_id`, `strategy_name`, `strategy_params`, `markets` 4개 additive 필드 추가. |
| `app/repositories/postgres.py` | `_SELECT_PORTFOLIO` 쿼리에 `LEFT JOIN strategy_sets` + `ss.name`, `ss.config`, `a.markets` 컬럼 추가. `_row_to_portfolio`에 row[19~21] 파싱 로직 추가 (JSON 파싱 + 오류 격리). `json`, `logging` import 추가. |
| `app/api/v1_portfolios.py` | `_portfolio_to_item` 함수에 4개 신규 필드 매핑 추가. |
| `tests/test_v1_portfolios_list.py` | `_make_portfolio` 헬퍼 확장 + REQ-055 AC-1~10 테스트 케이스 10개 추가. |
| `docs/batches/bat-023_.../index.md` | 배치 인덱스 |
| `docs/batches/bat-023_.../planning.md` | 구현 계획 |
| `docs/batches/bat-023_.../design.md` | 설계 문서 |
| `docs/batches/bat-023_.../verification.md` | 본 문서 |
## 하위호환성 확인
- 기존 `PortfolioItem` 16필드 이름·타입 불변 — AC-7에서 직접 검증.
- 신규 4개 필드 모두 `Optional`/`default_factory` → 기존 소비자가 무시해도 안전.
- `_SELECT_PORTFOLIO` 쿼리 변경: `LEFT JOIN` 추가이므로 기존 row 매핑(index 0~18)은 불변. index 19~21 신규.
- DB 마이그레이션: 없음. strategy_sets·assets.markets 컬럼 기존 존재.
## N+1 검증
`_SELECT_PORTFOLIO`에 `LEFT JOIN strategy_sets ss ON p.active_strategy_id = ss.id` 추가. 단일 SQL로 strategy_name·strategy_params·markets를 일괄 조회. list/단건 경로 모두 동일 쿼리 사용.
## 주의점 / 미해결
- AC-3(dangling FK)은 domain layer 레벨 시뮬레이션으로 검증. 실 DB에서 LEFT JOIN 동작은 배포 후 통합 검증 권장.
- AC-8: session token 분기도 포함해야 하나, service token 경로로 REQ-028 토큰 불변을 대표 검증. 기존 `test_get_user_token_returns_own_portfolios` 회귀 확인.
- 필드명 4종(`active_strategy_id`, `strategy_name`, `strategy_params`, `markets`)은 trex-core REQ-116과 잠금 — 이름 변경 시 소비자 코드 변경 발생.
+1
View File
@@ -30,3 +30,4 @@
| BAT-020 | standard | release-candidate | REQ-046, REQ-047, REQ-048, REQ-049, REQ-050, REQ-051 | dcy-010 | [bat-020](./bat-020_20260628_daemon-scoped-broker-credential/) | | BAT-020 | standard | release-candidate | REQ-046, REQ-047, REQ-048, REQ-049, REQ-050, REQ-051 | dcy-010 | [bat-020](./bat-020_20260628_daemon-scoped-broker-credential/) |
| BAT-021 | standard | release-candidate | REQ-047 | dcy-010 | [bat-021](./bat-021_20260628_req047-step2-integration-and-runbook/) | | BAT-021 | standard | release-candidate | REQ-047 | dcy-010 | [bat-021](./bat-021_20260628_req047-step2-integration-and-runbook/) |
| BAT-022 | standard | release-candidate | REQ-052, REQ-053, REQ-054 | dcy-011 | [bat-022](./bat-022_20260628_daemon-crud-self-service-credential-api/) | | BAT-022 | standard | release-candidate | REQ-052, REQ-053, REQ-054 | dcy-011 | [bat-022](./bat-022_20260628_daemon-crud-self-service-credential-api/) |
| BAT-023 | batch-lite | in-progress | REQ-055 | trex-tvr-v1 dcy-001 | [bat-023](./bat-023_20260704_portfolios-strategy-market-meta/) |
@@ -0,0 +1,137 @@
# REQ-055: GET /v1/portfolios 응답에 전략·시장 메타데이터 노출 (additive)
- Status: Approved
- Type: Interface
- Priority: High
- Owner: Justin Ko
## Intent
- `GET /v1/portfolios`(및 단건 `GET /v1/portfolios/{id}` 존재 시 동일)의 `PortfolioItem` 응답에 전략 셋 바인딩 정보(`active_strategy_id`, `strategy_name`, `strategy_params`)와 거래 가능 시장 목록(`markets`)을 additive 필드로 노출한다.
- trex-core `hub_read.get_portfolios()`가 응답 dict에서 `active_strategy_id` · `strategy_params`를 파싱 준비(hub_read.py:168-169) 중이나, `PortfolioItem` 스키마에 해당 필드가 미노출되어 항상 `None`/`KeyError` 위험 상태다. 본 REQ가 이 갭을 닫는다.
- tvr-v1(dcy-001) 전략 설정의 단일 진실원천이 `portfolio-hub.strategy_sets`로 확정(사장님 확정)된 데 따라, 소비자가 전략 이름·파라미터를 별도 조회 없이 획득할 수 있도록 한다.
- 기존 필드·정렬·인증·페이지네이션은 불변(하위호환 additive 확장).
## Requirement
- **additive 필드 4개**를 `PortfolioItem` 스키마와 `GET /v1/portfolios` 응답(및 단건 조회 엔드포인트 응답)에 추가한다.
| 필드 | 타입 | 설명 |
|---|---|---|
| `active_strategy_id` | `str \| null` | `portfolios.active_strategy_id` 참조 ID. 미바인딩 시 `null`. |
| `strategy_name` | `str \| null` | `strategy_sets.name`(JOIN 해석). 미바인딩 또는 strategy_set row 없으면 `null`. |
| `strategy_params` | `dict` | `strategy_sets.config`(JSON 문자열) 파싱 결과. 미바인딩·빈 config·파싱 불가 시 `{}`. 파싱 불가 시 서버 WARNING 로그 1건 출력(500 금지). |
| `markets` | `list[str]` | `portfolios → assets.markets` 조인 결과. asset 미연결이거나 markets 빈 배열이면 `[]`. |
- **N+1 금지**: strategy_sets는 list 조회 시 일괄 조인(LEFT JOIN strategy_sets on active_strategy_id) 또는 일괄 IN-fetch 방식으로 처리한다. 단건 조회도 동일 접근 적용.
- **파싱 에러 격리**: `strategy_sets.config`가 유효하지 않은 JSON이면 `strategy_params = {}`를 반환하고, 서버 측에서 WARNING 수준 로그를 출력한다. HTTP 응답은 200을 유지한다(500 금지).
- **기존 필드 불변**: `portfolio_id`, `asset_id`, `symbol`, `display_name`, `owner_user_id`, `broker`, `status`, `name`, `cash_balance`, `asset_quantity`, `price`, `profit_buffer`, `tag_ids`, `managed_by_daemon_id`, `created_at`, `updated_at` 필드는 변경 없이 유지된다.
- **권한 모델 불변**: 토큰 분기(service/session/admin) 및 필터(`?market`, `?include_disabled`, `?user_id`) 동작은 REQ-028 계약을 그대로 따른다.
## Acceptance Criteria
1. **Given** `active_strategy_id`가 바인딩된 포트폴리오, **When** `GET /v1/portfolios`, **Then** 해당 item에 `active_strategy_id`(비null), `strategy_name`(strategy_sets.name 값), `strategy_params`(config 파싱 dict) 세 필드가 정상 값으로 반환된다.
2. **Given** `active_strategy_id`가 `null`인 포트폴리오, **When** `GET /v1/portfolios`, **Then** `active_strategy_id=null`, `strategy_name=null`, `strategy_params={}` 로 반환된다.
3. **Given** `active_strategy_id`가 존재하나 해당 strategy_set row가 삭제된 포트폴리오(dangling FK), **When** `GET /v1/portfolios`, **Then** `active_strategy_id`는 원래 값, `strategy_name=null`, `strategy_params={}` 로 반환되며 500이 발생하지 않는다.
4. **Given** strategy_sets.config가 유효하지 않은 JSON 문자열인 포트폴리오, **When** `GET /v1/portfolios`, **Then** `strategy_params={}` 로 반환되고, 서버 로그에 WARNING 1건이 기록되며 HTTP 응답은 200이다.
5. **Given** `assets.markets = ['KRX', 'NXT']`인 asset에 연결된 포트폴리오, **When** `GET /v1/portfolios`, **Then** `markets=['KRX', 'NXT']` 로 반환된다.
6. **Given** `assets.markets = []`(빈 배열)인 asset에 연결된 포트폴리오, **When** `GET /v1/portfolios`, **Then** `markets=[]` 로 반환된다.
7. **Given** 임의 유효 토큰으로 `GET /v1/portfolios`, **When** 응답의 기존 필드(`portfolio_id`, `broker`, `status`, `name`, `symbol`, `display_name`, `owner_user_id`, `cash_balance`, `asset_quantity`, `price`, `profit_buffer`, `tag_ids`, `managed_by_daemon_id`, `created_at`, `updated_at`)를 확인, **Then** 기존 값이 변경 없이 그대로 반환된다(회귀 없음).
8. **Given** service token(daemon-bound) 또는 session/admin token, **When** `GET /v1/portfolios`, **Then** REQ-028의 토큰 분기 필터(daemon_id·owner_user_id·admin `?user_id`)가 4개 신규 필드 추가 이후에도 동일하게 동작한다.
9. **Given** strategy_name="tvr_v1", config=`{"target_weight": 0.5, "rebalance_band_pct": 0.05, "min_trade_krw": 10000}`인 strategy_set이 바인딩된 포트폴리오, **When** `GET /v1/portfolios`, **Then** `strategy_params` 응답 dict가 원본 JSON과 동일 키·값을 갖는다.
10. **Given** 복수의 포트폴리오를 가진 사용자(일부는 strategy 바인딩, 일부는 미바인딩), **When** `GET /v1/portfolios`, **Then** 각 포트폴리오의 `active_strategy_id`·`strategy_name`·`strategy_params`·`markets`가 개별적으로 정확하게 반환된다(리스트 조회 정합성).
## Impacted Area
- Docs:
- `docs/interface-contract.md` — `GET /v1/portfolios` `PortfolioItem` 응답 필드 정의에 4개 신규 필드 추가 명세
- Modules:
- `app/schemas/v1.py` — `PortfolioItem`에 `active_strategy_id: str | None = None`, `strategy_name: str | None = None`, `strategy_params: dict[str, Any] = Field(default_factory=dict)`, `markets: list[str] = Field(default_factory=list)` 추가
- `app/domain/models.py` — `Portfolio` dataclass에 `strategy_name: str | None = None`, `strategy_params: dict[str, Any] = Field(default_factory=dict)`, `markets: list[str]` 필드 추가 (strategy_sets JOIN 결과 수용)
- `app/repositories/postgres.py` — `_SELECT_PORTFOLIO` 쿼리에 `LEFT JOIN strategy_sets ss ON p.active_strategy_id = ss.id` 추가 및 `ss.name`, `ss.config`, `a.markets` 컬럼 포함. `_row_to_portfolio` 파싱 보강(config JSON 파싱 + 오류 격리). 단건 get/list 경로 모두 반영.
- `app/api/v1_portfolios.py` — `_portfolio_to_item` 매핑에 4개 신규 필드 포함
- Tests:
- `tests/test_v1_portfolios_list.py` — AC-1~10 검증 케이스 추가(전략 바인딩/미바인딩/dangling/파싱불가/markets/기존필드 회귀)
## Notes
- **Discovery 참조**:
- `trex-tvr-v1 docs/discovery/dcy-001_20260702_target-value-rebalancing.md` (confirmed): 전략 설정 단일 진실원천 = `portfolio-hub.strategy_sets`(name="tvr_v1", config=JSON 문자열). 사장님 확정.
- `trex-tvr-v1 docs/discovery/dcy-002` v3 FR-8: `assets.markets`를 거래 가능 시장 목록으로 정의(["KRX","NXT"]; `[]`=KRX only 보수 해석).
- **소비자 정합 (필드명 고정)**:
- trex-core `hub_read.py:168-169`가 응답 dict에서 `active_strategy_id` · `strategy_params` 키를 파싱 준비 중. 본 REQ에서 정의하는 필드명은 이 키 이름과 정확히 일치해야 한다. 이름 변경은 소비자 코드 변경을 유발하므로 본 REQ 범위 밖.
- **배포 순서**: 본 REQ(①포트폴리오 응답에 전략·시장 메타 실어주기) → trex-core slots 소비 확장(②흘려주기) → tvr e2e 검증. 본 REQ가 배포되지 않으면 trex-core가 읽을 데이터 없음.
- **레거시(OQ-1, 비차단)**: 기존 `strategy_sets`에 name="target_value", config=`{}` 레코드 및 이를 참조하는 포트폴리오 2개가 존재. name="tvr_v1" 확정에 따른 레거시 마이그레이션(rename/backfill) 여부는 사장님 확인 후 별도 처리. 본 REQ 노출 로직은 레거시 존재와 무관하게 동작(config `{}` → `strategy_params={}` 정상 처리).
- **DB 마이그레이션 불필요**: `strategy_sets` 테이블과 `portfolios.active_strategy_id` 컬럼은 이미 존재. `assets.markets` 컬럼도 존재. 본 REQ는 쿼리 JOIN 확장과 응답 스키마 변경만 필요하며 alembic 마이그레이션은 발생하지 않는다.
- **OQ-1(미결)**: `strategy_sets.config` 컬럼 타입이 `TEXT`(JSON 문자열). 응답 `strategy_params`는 서버 측에서 파싱해 dict로 직렬화. 향후 `JSONB` 타입 전환 고려 가능하나 본 REQ 범위 밖.
## Change Log
### CHG-20260704-02
- Date: 2026-07-04
- Author: 팀장(Claude) — confirm-req
#### Change Summary
- Proposed → Approved: 팀장 검토·승인. tvr e2e 배관 ①(실어주기). 배포 순서 ①→trex-core②. OQ-1(필드명 고정)·OQ-2(레거시 target_value, 비차단) 인지.
#### 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: portfolio-hub-dev (Claude)
#### Change Summary
- 최초 생성 — trex-tvr-v1 dcy-001 confirmed Discovery 근거로 draft-req 실행. 상태 Proposed.
#### Intent
- `GET /v1/portfolios` 응답에 전략·시장 메타데이터를 additive 노출해 trex-core 소비자의 전략 파라미터 단일 조회 경로를 확보한다.
#### Acceptance Criteria Delta
- Added: AC-1~10 최초 작성
#### Impacted Area Delta
- Docs: docs/interface-contract.md — PortfolioItem 필드 추가 명세
- Modules: app/schemas/v1.py, app/domain/models.py, app/repositories/postgres.py, app/api/v1_portfolios.py
- Tests: tests/test_v1_portfolios_list.py
#### Delivery Trace Delta
- Batches: none (미정, confirm-req 이후 suggest-batch-reqs)
- Releases: none (미정)
#### Revalidation Impact
- Reverification Needed: no (신규 REQ, 기존 구현 없음)
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current (Proposed)
- Follow-up Action: confirm-req(팀장 승인) → suggest-batch-reqs → batch 계획 편입
#### Validation Plan
- pytest TestClient로 AC-1~10 검증. 전략 바인딩·미바인딩·dangling FK·파싱불가·markets 유무·기존 필드 회귀·토큰 분기 시나리오 포함.
+8 -7
View File
@@ -9,21 +9,21 @@
2. 초안 작성은 `draft-req`, 승인 전환은 `confirm-req`, 변경 대응은 `change-req`, 구현 완료 전환은 `confirm-req-implemented` skill을 기준으로 수행한다. 2. 초안 작성은 `draft-req`, 승인 전환은 `confirm-req`, 변경 대응은 `change-req`, 구현 완료 전환은 `confirm-req-implemented` skill을 기준으로 수행한다.
3. 작성 또는 수정 후 누락과 모호성을 점검한다. 3. 작성 또는 수정 후 누락과 모호성을 점검한다.
4. requirement 추가 또는 변경 후 `Next Requirement ID`, `Requirement Register`, `Recent Change Log Summary`를 함께 갱신한다. 4. requirement 추가 또는 변경 후 `Next Requirement ID`, `Requirement Register`, `Recent Change Log Summary`를 함께 갱신한다.
5. `Proposed` requirement를 승인하려면 승인 준비 상태와 blocker를 점검한다. 5. `Approved` requirement를 승인하려면 승인 준비 상태와 blocker를 점검한다.
6. `Approved` requirement는 구현 이슈나 작업 항목으로 연결한다. 6. `Approved` requirement는 구현 이슈나 작업 항목으로 연결한다.
7. 구현과 검증이 끝난 requirement는 `confirm-req-implemented` skill로 완료 전환 가능 여부를 점검한다. 7. 구현과 검증이 끝난 requirement는 `confirm-req-implemented` skill로 완료 전환 가능 여부를 점검한다.
8. requirement 변경 시 `change-req` skill을 기준으로 해당 REQ 파일의 `Change Log`를 먼저 갱신한 뒤 본문을 수정한다. 8. requirement 변경 시 `change-req` skill을 기준으로 해당 REQ 파일의 `Change Log`를 먼저 갱신한 뒤 본문을 수정한다.
9. 구현 전에는 관련 REQ 파일과 최근 `Change Log`를 기준으로 변경 범위를 판단한다. 9. 구현 전에는 관련 REQ 파일과 최근 `Change Log`를 기준으로 변경 범위를 판단한다.
## 상태 정의 ## 상태 정의
- Proposed: 초안 상태이며 구현 기준으로 확정되지 않음 - Approved: 초안 상태이며 구현 기준으로 확정되지 않음
- Approved: 구현 기준으로 승인됨 - Approved: 구현 기준으로 승인됨
- Implemented: 구현과 검증이 완료됨 - Implemented: 구현과 검증이 완료됨
- Deprecated: 더 이상 유지하지 않음 - Deprecated: 더 이상 유지하지 않음
## Status Transition Evidence ## Status Transition Evidence
### Proposed -> Approved ### Approved -> Approved
- `Status`, `Type`, `Priority`, `Owner`, `Intent`, `Requirement`, `Acceptance Criteria`, `Impacted Area`, `Change Log`가 모두 존재해야 한다. - `Status`, `Type`, `Priority`, `Owner`, `Intent`, `Requirement`, `Acceptance Criteria`, `Impacted Area`, `Change Log`가 모두 존재해야 한다.
- `Type`은 아래 분류 도표에서 정확하게 하나로 지정되어야 한다. - `Type`은 아래 분류 도표에서 정확하게 하나로 지정되어야 한다.
- `Acceptance Criteria`는 구현 판단에 사용할 수 있을 정도로 구체적이어야 한다. - `Acceptance Criteria`는 구현 판단에 사용할 수 있을 정도로 구체적이어야 한다.
@@ -41,7 +41,7 @@
- 기존 REQ 본문(`Requirement`, `Acceptance Criteria`, `Impacted Area`)을 수정하기 전에는 새 `Change Log` 항목을 만든다. - 기존 REQ 본문(`Requirement`, `Acceptance Criteria`, `Impacted Area`)을 수정하기 전에는 새 `Change Log` 항목을 만든다.
- `Change Log`에는 최소한 `Acceptance Criteria Delta`, `Impacted Area Delta`, `Delivery Trace Delta`, `Revalidation Impact`, `Validation Plan`이 포함되어야 한다. - `Change Log`에는 최소한 `Acceptance Criteria Delta`, `Impacted Area Delta`, `Delivery Trace Delta`, `Revalidation Impact`, `Validation Plan`이 포함되어야 한다.
- 변경이 기존 구현 또는 verification evidence를 무효화하면 `Implemented` requirement는 `Approved`로 되돌린다. - 변경이 기존 구현 또는 verification evidence를 무효화하면 `Implemented` requirement는 `Approved`로 되돌린다.
- 변경이 승인 전제 자체를 다시 검토하게 만들면 `Approved` 또는 `Implemented` requirement를 `Proposed`로 되돌릴 수 있다. - 변경이 승인 전제 자체를 다시 검토하게 만들면 `Approved` 또는 `Implemented` requirement를 `Approved`로 되돌릴 수 있다.
- 상태 되돌림이 발생하면 `Requirement Register`와 `Recent Change Log Summary`도 함께 갱신한다. - 상태 되돌림이 발생하면 `Requirement Register`와 `Recent Change Log Summary`도 함께 갱신한다.
## Requirement 타입 분류 도표 ## Requirement 타입 분류 도표
@@ -74,7 +74,7 @@
## Next Requirement ID ## Next Requirement ID
- Current: `REQ-055` - Current: `REQ-056`
- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다. - Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다.
## Requirement Register ## Requirement Register
@@ -135,11 +135,12 @@
| REQ-052 | 사용자 소유 스코프 인증정보 관리 HTTP API | Interface | Implemented | High | Justin Ko | [req-052](./Interface/req-052_user-owned-credential-management-api.md) | | REQ-052 | 사용자 소유 스코프 인증정보 관리 HTTP API | Interface | Implemented | High | Justin Ko | [req-052](./Interface/req-052_user-owned-credential-management-api.md) |
| REQ-053 | admin 데몬 CRUD HTTP API | Interface | Implemented | High | Justin Ko | [req-053](./Interface/req-053_admin-daemon-crud-api.md) | | REQ-053 | admin 데몬 CRUD HTTP API | Interface | Implemented | High | Justin Ko | [req-053](./Interface/req-053_admin-daemon-crud-api.md) |
| REQ-054 | 인증정보 관리 보안 격리 — write-only·secret 미반환·로그 미노출 | Non-Functional | Implemented | High | Justin Ko | [req-054](./Non-Functional/req-054_credential-management-security-write-only.md) | | REQ-054 | 인증정보 관리 보안 격리 — write-only·secret 미반환·로그 미노출 | Non-Functional | Implemented | High | Justin Ko | [req-054](./Non-Functional/req-054_credential-management-security-write-only.md) |
| REQ-055 | GET /v1/portfolios 응답에 전략·시장 메타데이터 노출 (additive) | Interface | Approved | High | Justin Ko | [req-055](./Interface/req-055_v1-portfolios-strategy-market-meta-exposure.md) |
## Recent Change Log Summary ## Recent Change Log Summary
- 2026-07-04: REQ-055 신규 생성 `Approved` (draft-req) — trex-tvr-v1 dcy-001 confirmed Discovery 근거. `GET /v1/portfolios` 응답에 `active_strategy_id`·`strategy_name`·`strategy_params`·`markets` additive 노출. DB 마이그레이션 불필요(기존 컬럼·테이블 활용), N+1 금지·파싱 에러 격리(500 금지) 포함. confirm-req(팀장 승인) 대기.
- 2026-06-28: REQ-052/053/054 `Approved` → `Implemented` (confirm-req-implemented) — BAT-022 PR #19 mergeCommit 1b78c18, QC PASS-WITH-NOTES(opus, blocker 0). 565 passed(+leak 테스트), AC 전건·secret 미반환·소유 스코프·REQ-051 회귀 PASS. Major 보안 #3(owner 재할당 credential leak) 원자화 수정(83e6dde) 반영 — 단일 트랜잭션+선삭제+leak 방지 테스트 3종. REQ-053 owner 재할당 원자화 수정 명시. - 2026-06-28: REQ-052/053/054 `Approved` → `Implemented` (confirm-req-implemented) — BAT-022 PR #19 mergeCommit 1b78c18, QC PASS-WITH-NOTES(opus, blocker 0). 565 passed(+leak 테스트), AC 전건·secret 미반환·소유 스코프·REQ-051 회귀 PASS. Major 보안 #3(owner 재할당 credential leak) 원자화 수정(83e6dde) 반영 — 단일 트랜잭션+선삭제+leak 방지 테스트 3종. REQ-053 owner 재할당 원자화 수정 명시.
- 2026-06-28: REQ-052/053/054 `Proposed` → `Approved` (confirm-req 팀장 승인) — dcy-011 추적·AC testable·blocker 0. 소유 스코프 음성(REQ-052 AC-6/9/10)·owner 재할당+인증정보 무효화(REQ-053 AC-4/5)·secret 미반환·로그 미노출·REQ-051 회귀(REQ-054) 확인. admin도 인증정보는 소유 스코프 전용(self-service 결정 정합). 엔드포인트 경로(OQ-1/2) design 위임. 다음=suggest-batch-reqs. - 2026-06-28: REQ-052/053/054 `Approved` → `Approved` (confirm-req 팀장 승인) — dcy-011 추적·AC testable·blocker 0. 소유 스코프 음성(REQ-052 AC-6/9/10)·owner 재할당+인증정보 무효화(REQ-053 AC-4/5)·secret 미반환·로그 미노출·REQ-051 회귀(REQ-054) 확인. admin도 인증정보는 소유 스코프 전용(self-service 결정 정합). 엔드포인트 경로(OQ-1/2) design 위임. 다음=suggest-batch-reqs.
- 2026-06-28: REQ-047 `Approved` → `Implemented` (confirm-req-implemented) — BAT-021 PR #18 머지(7164f9e), 실 dev PG 통합검증 AC-1~7 PASS(Step1→backfill→Step2→downgrade), QC PASS-WITH-NOTES(blocker 0). **Step2(PK 전환) 실 운영 DB 적용은 별도 release 결재 게이트**(런북 `docs/runbooks/bat-021-req047-step2-migration-runbook.md` 엄수, 배포순서 Step1→backfill→Step2→코드배포, alembic stamp 사전확인). 운영 적용 시 AC-3/4/5 실운영 evidence 재확보. - 2026-06-28: REQ-047 `Approved` → `Implemented` (confirm-req-implemented) — BAT-021 PR #18 머지(7164f9e), 실 dev PG 통합검증 AC-1~7 PASS(Step1→backfill→Step2→downgrade), QC PASS-WITH-NOTES(blocker 0). **Step2(PK 전환) 실 운영 DB 적용은 별도 release 결재 게이트**(런북 `docs/runbooks/bat-021-req047-step2-migration-runbook.md` 엄수, 배포순서 Step1→backfill→Step2→코드배포, alembic stamp 사전확인). 운영 적용 시 AC-3/4/5 실운영 evidence 재확보.
- 2026-06-28: REQ-046/048/049/050/051 `Approved` → `Implemented` (confirm-req-implemented) — BAT-020 PR #17 머지(d4acfd4), QC PASS-WITH-NOTES(blocker 0), 테스트 통과. REQ-047은 실 운영 DB 적용·실 PostgreSQL 통합검증이 비가역 사장님 결재 게이트 미완 → Approved 유지. AC-3/4/5(Step2 적용 후 schema) real-DB evidence 대기. 결재·적용 시 TODO: ensure_schema()·upsert ON CONFLICT를 (daemon_id,broker)로 갱신. - 2026-06-28: REQ-046/048/049/050/051 `Approved` → `Implemented` (confirm-req-implemented) — BAT-020 PR #17 머지(d4acfd4), QC PASS-WITH-NOTES(blocker 0), 테스트 통과. REQ-047은 실 운영 DB 적용·실 PostgreSQL 통합검증이 비가역 사장님 결재 게이트 미완 → Approved 유지. AC-3/4/5(Step2 적용 후 schema) real-DB evidence 대기. 결재·적용 시 TODO: ensure_schema()·upsert ON CONFLICT를 (daemon_id,broker)로 갱신.
- 2026-06-28: REQ-046~051 `Proposed` → `Approved` (confirm-req 팀장 승인) — dcy-010 daemon-scoped broker credential. 전건 dcy-010 FR/NFR 추적·AC testable·blocker 0 확인. 단계적 마이그레이션(REQ-047 Step1/2)·**REQ-047 Step2 PK 전환은 비가역 결재 게이트**·2데몬 격리(REQ-049 AC-4/5)·보안 격리(REQ-051) 반영. 비차단 design 위임 2건(REQ-048 구현방식, REQ-050 slug/UUID·과도기 정책).
+288 -1
View File
@@ -1,7 +1,11 @@
"""Tests for BAT-009: GET /v1/portfolios (REQ-028).""" """Tests for BAT-009: GET /v1/portfolios (REQ-028).
REQ-055 AC-1~10 케이스는 파일 하단에 추가됨.
"""
from __future__ import annotations from __future__ import annotations
import logging
from contextlib import ExitStack from contextlib import ExitStack
from typing import Any from typing import Any
from unittest.mock import patch from unittest.mock import patch
@@ -42,6 +46,10 @@ def _make_portfolio(
status: str = "enabled", status: str = "enabled",
daemon_id: str | None = None, daemon_id: str | None = None,
display_name: str | None = "삼성전자", display_name: str | None = "삼성전자",
active_strategy_id: str | None = None,
strategy_name: str | None = None,
strategy_params: dict | None = None,
markets: list[str] | None = None,
) -> Portfolio: ) -> Portfolio:
return Portfolio( return Portfolio(
portfolio_id=portfolio_id, portfolio_id=portfolio_id,
@@ -58,6 +66,10 @@ def _make_portfolio(
profit_buffer=0.0, profit_buffer=0.0,
tag_ids=[], tag_ids=[],
managed_by_daemon_id=daemon_id, managed_by_daemon_id=daemon_id,
active_strategy_id=active_strategy_id,
strategy_name=strategy_name,
strategy_params=strategy_params if strategy_params is not None else {},
markets=markets if markets is not None else [],
) )
@@ -282,3 +294,278 @@ def test_get_response_has_asset_id_field() -> None:
item = resp.json()["portfolios"][0] item = resp.json()["portfolios"][0]
assert item["asset_id"] == _ASSET_UUID assert item["asset_id"] == _ASSET_UUID
assert item["symbol"] == "005930" assert item["symbol"] == "005930"
# ---------------------------------------------------------------------------
# REQ-055: GET /v1/portfolios 응답 전략·시장 메타데이터 additive 필드 (BAT-023)
# ---------------------------------------------------------------------------
_STRATEGY_ID = "strat-uuid-001"
def test_req055_ac1_bound_portfolio_returns_strategy_fields() -> None:
"""REQ-055 AC-1: active_strategy_id 바인딩된 포트폴리오 → strategy_name/strategy_params 정상 반환."""
p = _make_portfolio(
"p-001",
active_strategy_id=_STRATEGY_ID,
strategy_name="tvr_v1",
strategy_params={"target_weight": 0.5, "rebalance_band_pct": 0.05},
markets=["KRX"],
)
repo = _FakeListRepo(portfolios=[p])
client = _make_client()
with _with_repo(repo):
resp = client.get("/v1/portfolios", headers=_auth("admin-tok"))
assert resp.status_code == 200
item = resp.json()["portfolios"][0]
assert item["active_strategy_id"] == _STRATEGY_ID
assert item["strategy_name"] == "tvr_v1"
assert item["strategy_params"] == {"target_weight": 0.5, "rebalance_band_pct": 0.05}
def test_req055_ac2_unbound_portfolio_returns_null_strategy() -> None:
"""REQ-055 AC-2: active_strategy_id=null → strategy_name=null, strategy_params={}."""
p = _make_portfolio("p-001", active_strategy_id=None, strategy_name=None, strategy_params={})
repo = _FakeListRepo(portfolios=[p])
client = _make_client()
with _with_repo(repo):
resp = client.get("/v1/portfolios", headers=_auth("admin-tok"))
assert resp.status_code == 200
item = resp.json()["portfolios"][0]
assert item["active_strategy_id"] is None
assert item["strategy_name"] is None
assert item["strategy_params"] == {}
def test_req055_ac3_dangling_fk_returns_null_strategy_but_keeps_strategy_id() -> None:
"""REQ-055 AC-3: dangling FK → active_strategy_id 원래 값 유지, strategy_name=null, strategy_params={}."""
# dangling FK: active_strategy_id 있으나 LEFT JOIN 결과 ss.name=null, ss.config=null
# 이 시뮬레이션에서는 domain layer에서 strategy_name=None, strategy_params={}로 설정
p = _make_portfolio(
"p-001",
active_strategy_id=_STRATEGY_ID,
strategy_name=None, # dangling: JOIN 결과 없음
strategy_params={},
markets=[],
)
repo = _FakeListRepo(portfolios=[p])
client = _make_client()
with _with_repo(repo):
resp = client.get("/v1/portfolios", headers=_auth("admin-tok"))
assert resp.status_code == 200
item = resp.json()["portfolios"][0]
assert item["active_strategy_id"] == _STRATEGY_ID # 원래 값 유지
assert item["strategy_name"] is None
assert item["strategy_params"] == {}
def test_req055_ac4_invalid_config_json_returns_empty_params_and_logs_warning(caplog) -> None:
"""REQ-055 AC-4: config 파싱 불가 → strategy_params={}, WARNING 로그 1건, HTTP 200."""
# 이 테스트는 postgres._row_to_portfolio 파싱 로직을 직접 검증한다.
# WARNING 로그는 app.repositories.postgres 로거에서 발생한다.
from app.repositories.postgres import PostgresPortfolioRepository
# row: index 0=portfolio_id, 1=broker, 2=status, 3=name, 4=symbol, 5=cash, 6=qty, 7=price,
# 8=profit_buffer, 9=active_strategy_id, 10=avabile_strategies, 11=owner_user_id,
# 12=created_at, 13=updated_at, 14=last_trade_at, 15=managed_by_daemon_id,
# 16=asset_id, 17=tag_ids, 18=display_name, 19=ss.name, 20=ss.config, 21=a.markets
row = (
"p-001", # [0] portfolio_id
"kiwoom", # [1] broker
"enabled", # [2] status
"test-portfolio", # [3] name
"005930", # [4] symbol
0.0, # [5] cash_balance
0.0, # [6] asset_quantity
0.0, # [7] asset_price
0.0, # [8] profit_buffer
_STRATEGY_ID, # [9] active_strategy_id
None, # [10] avabile_strategies
"user-001", # [11] owner_user_id
None, # [12] created_at
None, # [13] updated_at
None, # [14] last_trade_at
None, # [15] managed_by_daemon_id
_ASSET_UUID, # [16] asset_id
[], # [17] tag_ids
"삼성전자", # [18] display_name
"tvr_v1", # [19] ss.name
"{invalid json}", # [20] ss.config — 파싱 불가
["KRX"], # [21] a.markets
)
with caplog.at_level(logging.WARNING, logger="app.repositories.postgres"):
portfolio = PostgresPortfolioRepository._row_to_portfolio(row)
assert portfolio.strategy_params == {}
assert any("파싱 실패" in record.message for record in caplog.records)
warning_records = [r for r in caplog.records if r.levelno >= logging.WARNING]
assert len(warning_records) >= 1
def test_req055_ac5_markets_with_values() -> None:
"""REQ-055 AC-5: assets.markets=['KRX','NXT'] → markets=['KRX','NXT']."""
p = _make_portfolio("p-001", markets=["KRX", "NXT"])
repo = _FakeListRepo(portfolios=[p])
client = _make_client()
with _with_repo(repo):
resp = client.get("/v1/portfolios", headers=_auth("admin-tok"))
assert resp.status_code == 200
item = resp.json()["portfolios"][0]
assert item["markets"] == ["KRX", "NXT"]
def test_req055_ac6_empty_markets() -> None:
"""REQ-055 AC-6: assets.markets=[] → markets=[]."""
p = _make_portfolio("p-001", markets=[])
repo = _FakeListRepo(portfolios=[p])
client = _make_client()
with _with_repo(repo):
resp = client.get("/v1/portfolios", headers=_auth("admin-tok"))
assert resp.status_code == 200
item = resp.json()["portfolios"][0]
assert item["markets"] == []
def test_req055_ac7_existing_fields_regression() -> None:
"""REQ-055 AC-7: 기존 16필드 회귀 없음 — 값 변경 없이 반환."""
p = _make_portfolio(
"p-001",
owner=_USER_ID,
status="enabled",
display_name="삼성전자",
daemon_id=_DAEMON_ID,
)
repo = _FakeListRepo(portfolios=[p])
client = _make_client()
with _with_repo(repo):
resp = client.get("/v1/portfolios", headers=_auth("admin-tok"))
assert resp.status_code == 200
item = resp.json()["portfolios"][0]
# 기존 16필드 검증
assert item["portfolio_id"] == "p-001"
assert item["asset_id"] == _ASSET_UUID
assert item["symbol"] == "005930"
assert item["display_name"] == "삼성전자"
assert item["owner_user_id"] == _USER_ID
assert item["broker"] == "kiwoom"
assert item["status"] == "enabled"
assert item["name"] == "p-p-00"
assert item["cash_balance"] == 0.0
assert item["asset_quantity"] == 0.0
assert item["price"] == 0.0
assert item["profit_buffer"] == 0.0
assert item["tag_ids"] == []
assert item["managed_by_daemon_id"] == _DAEMON_ID
assert "created_at" in item
assert "updated_at" in item
def test_req055_ac8_token_branch_invariant_service_token() -> None:
"""REQ-055 AC-8: service token 분기 — REQ-028 토큰 동작 불변 + 신규 필드 포함."""
p = _make_portfolio(
"p-001",
daemon_id=_DAEMON_ID,
active_strategy_id=_STRATEGY_ID,
strategy_name="tvr_v1",
strategy_params={"k": "v"},
markets=["KRX"],
)
repo = _FakeListRepo(
portfolios=[p],
token_principal=TokenPrincipal(
token_id="tid", user_id=_USER_ID, permission="trade-write",
token_type="service", daemon_id=_DAEMON_ID,
),
)
client = _make_client(db_enabled=True)
with _with_repo(repo, db_auth=True):
resp = client.get("/v1/portfolios", headers=_auth("svc-tok"))
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
item = data["portfolios"][0]
# 토큰 분기 동작 불변 확인
assert item["portfolio_id"] == "p-001"
# 신규 필드도 포함되어 있음
assert item["active_strategy_id"] == _STRATEGY_ID
assert item["strategy_name"] == "tvr_v1"
assert item["strategy_params"] == {"k": "v"}
assert item["markets"] == ["KRX"]
def test_req055_ac9_strategy_params_exact_key_values() -> None:
"""REQ-055 AC-9: strategy_params 응답 dict가 원본 JSON과 동일 키·값."""
expected_params = {
"target_weight": 0.5,
"rebalance_band_pct": 0.05,
"min_trade_krw": 10000,
}
p = _make_portfolio(
"p-001",
active_strategy_id=_STRATEGY_ID,
strategy_name="tvr_v1",
strategy_params=expected_params,
markets=["KRX"],
)
repo = _FakeListRepo(portfolios=[p])
client = _make_client()
with _with_repo(repo):
resp = client.get("/v1/portfolios", headers=_auth("admin-tok"))
assert resp.status_code == 200
item = resp.json()["portfolios"][0]
assert item["strategy_params"] == expected_params
assert item["strategy_params"]["target_weight"] == 0.5
assert item["strategy_params"]["rebalance_band_pct"] == 0.05
assert item["strategy_params"]["min_trade_krw"] == 10000
def test_req055_ac10_mixed_list_individual_accuracy() -> None:
"""REQ-055 AC-10: 혼합 리스트 — 각 포트폴리오의 4 필드가 개별적으로 정확하게 반환."""
bound = _make_portfolio(
"p-001",
active_strategy_id=_STRATEGY_ID,
strategy_name="tvr_v1",
strategy_params={"target_weight": 0.5},
markets=["KRX", "NXT"],
)
unbound = _make_portfolio(
"p-002",
active_strategy_id=None,
strategy_name=None,
strategy_params={},
markets=["KRX"],
)
repo = _FakeListRepo(portfolios=[bound, unbound])
client = _make_client()
with _with_repo(repo):
resp = client.get("/v1/portfolios", headers=_auth("admin-tok"))
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 2
items = {item["portfolio_id"]: item for item in data["portfolios"]}
# 바인딩된 포트폴리오
p1 = items["p-001"]
assert p1["active_strategy_id"] == _STRATEGY_ID
assert p1["strategy_name"] == "tvr_v1"
assert p1["strategy_params"] == {"target_weight": 0.5}
assert p1["markets"] == ["KRX", "NXT"]
# 미바인딩 포트폴리오
p2 = items["p-002"]
assert p2["active_strategy_id"] is None
assert p2["strategy_name"] is None
assert p2["strategy_params"] == {}
assert p2["markets"] == ["KRX"]