Merge pull request #20 from fourblocks2020/feat/req-055-portfolios-strategy-market-meta
bat-023: /v1/portfolios 전략·시장 메타 노출 (REQ-055)
This commit is contained in:
@@ -81,6 +81,11 @@ def _portfolio_to_item(portfolio: Portfolio) -> PortfolioItem:
|
||||
managed_by_daemon_id=portfolio.managed_by_daemon_id,
|
||||
created_at=portfolio.created_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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Protocol, runtime_checkable
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
|
||||
class MarketCode(str, Enum):
|
||||
@@ -116,6 +116,9 @@ class Portfolio:
|
||||
price: float = 0.0
|
||||
profit_buffer: float = 0.0
|
||||
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
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
@@ -3,11 +3,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
import psycopg
|
||||
from psycopg import sql
|
||||
|
||||
@@ -64,9 +68,13 @@ _SELECT_PORTFOLIO = """
|
||||
p.managed_by_daemon_id,
|
||||
p.asset_id,
|
||||
COALESCE(p.tag_ids, '{}'::uuid[]),
|
||||
a.display_name
|
||||
a.display_name,
|
||||
ss.name,
|
||||
ss.config,
|
||||
a.markets
|
||||
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
|
||||
"""
|
||||
|
||||
_SELECT_TAG = """
|
||||
@@ -495,6 +503,30 @@ class PostgresPortfolioRepository:
|
||||
else:
|
||||
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(
|
||||
portfolio_id=row[0],
|
||||
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,
|
||||
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,
|
||||
strategy_name=strategy_name,
|
||||
strategy_params=strategy_params,
|
||||
markets=markets,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -181,6 +181,11 @@ class PortfolioItem(BaseModel):
|
||||
managed_by_daemon_id: str | None = None
|
||||
created_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):
|
||||
|
||||
@@ -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과 잠금 — 이름 변경 시 소비자 코드 변경 발생.
|
||||
@@ -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-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-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
@@ -9,21 +9,21 @@
|
||||
2. 초안 작성은 `draft-req`, 승인 전환은 `confirm-req`, 변경 대응은 `change-req`, 구현 완료 전환은 `confirm-req-implemented` skill을 기준으로 수행한다.
|
||||
3. 작성 또는 수정 후 누락과 모호성을 점검한다.
|
||||
4. requirement 추가 또는 변경 후 `Next Requirement ID`, `Requirement Register`, `Recent Change Log Summary`를 함께 갱신한다.
|
||||
5. `Proposed` requirement를 승인하려면 승인 준비 상태와 blocker를 점검한다.
|
||||
5. `Approved` requirement를 승인하려면 승인 준비 상태와 blocker를 점검한다.
|
||||
6. `Approved` requirement는 구현 이슈나 작업 항목으로 연결한다.
|
||||
7. 구현과 검증이 끝난 requirement는 `confirm-req-implemented` skill로 완료 전환 가능 여부를 점검한다.
|
||||
8. requirement 변경 시 `change-req` skill을 기준으로 해당 REQ 파일의 `Change Log`를 먼저 갱신한 뒤 본문을 수정한다.
|
||||
9. 구현 전에는 관련 REQ 파일과 최근 `Change Log`를 기준으로 변경 범위를 판단한다.
|
||||
|
||||
## 상태 정의
|
||||
- Proposed: 초안 상태이며 구현 기준으로 확정되지 않음
|
||||
- Approved: 초안 상태이며 구현 기준으로 확정되지 않음
|
||||
- Approved: 구현 기준으로 승인됨
|
||||
- Implemented: 구현과 검증이 완료됨
|
||||
- Deprecated: 더 이상 유지하지 않음
|
||||
|
||||
## Status Transition Evidence
|
||||
|
||||
### Proposed -> Approved
|
||||
### Approved -> Approved
|
||||
- `Status`, `Type`, `Priority`, `Owner`, `Intent`, `Requirement`, `Acceptance Criteria`, `Impacted Area`, `Change Log`가 모두 존재해야 한다.
|
||||
- `Type`은 아래 분류 도표에서 정확하게 하나로 지정되어야 한다.
|
||||
- `Acceptance Criteria`는 구현 판단에 사용할 수 있을 정도로 구체적이어야 한다.
|
||||
@@ -41,7 +41,7 @@
|
||||
- 기존 REQ 본문(`Requirement`, `Acceptance Criteria`, `Impacted Area`)을 수정하기 전에는 새 `Change Log` 항목을 만든다.
|
||||
- `Change Log`에는 최소한 `Acceptance Criteria Delta`, `Impacted Area Delta`, `Delivery Trace Delta`, `Revalidation Impact`, `Validation Plan`이 포함되어야 한다.
|
||||
- 변경이 기존 구현 또는 verification evidence를 무효화하면 `Implemented` requirement는 `Approved`로 되돌린다.
|
||||
- 변경이 승인 전제 자체를 다시 검토하게 만들면 `Approved` 또는 `Implemented` requirement를 `Proposed`로 되돌릴 수 있다.
|
||||
- 변경이 승인 전제 자체를 다시 검토하게 만들면 `Approved` 또는 `Implemented` requirement를 `Approved`로 되돌릴 수 있다.
|
||||
- 상태 되돌림이 발생하면 `Requirement Register`와 `Recent Change Log Summary`도 함께 갱신한다.
|
||||
|
||||
## Requirement 타입 분류 도표
|
||||
@@ -74,7 +74,7 @@
|
||||
|
||||
## Next Requirement ID
|
||||
|
||||
- Current: `REQ-055`
|
||||
- Current: `REQ-056`
|
||||
- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다.
|
||||
|
||||
## 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-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-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
|
||||
|
||||
- 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 `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-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·과도기 정책).
|
||||
|
||||
@@ -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
|
||||
|
||||
import logging
|
||||
from contextlib import ExitStack
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
@@ -42,6 +46,10 @@ def _make_portfolio(
|
||||
status: str = "enabled",
|
||||
daemon_id: str | None = 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:
|
||||
return Portfolio(
|
||||
portfolio_id=portfolio_id,
|
||||
@@ -58,6 +66,10 @@ def _make_portfolio(
|
||||
profit_buffer=0.0,
|
||||
tag_ids=[],
|
||||
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]
|
||||
assert item["asset_id"] == _ASSET_UUID
|
||||
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"]
|
||||
|
||||
Reference in New Issue
Block a user