From b2261c44281a5f72b5f7929894d1a98a90649fe7 Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Sat, 4 Jul 2026 11:03:26 +0900 Subject: [PATCH] docs(bat-023): add batch planning/design/verification documents for REQ-055 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BAT-023 batch documents — GET /v1/portfolios strategy+market meta additive fields. Includes planning, design, verification, and batch index entry. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5 --- .../design.md | 154 ++++++++++++++++++ .../index.md | 50 ++++++ .../planning.md | 87 ++++++++++ .../verification.md | 70 ++++++++ docs/batches/index.md | 1 + 5 files changed, 362 insertions(+) create mode 100644 docs/batches/bat-023_20260704_portfolios-strategy-market-meta/design.md create mode 100644 docs/batches/bat-023_20260704_portfolios-strategy-market-meta/index.md create mode 100644 docs/batches/bat-023_20260704_portfolios-strategy-market-meta/planning.md create mode 100644 docs/batches/bat-023_20260704_portfolios-strategy-market-meta/verification.md diff --git a/docs/batches/bat-023_20260704_portfolios-strategy-market-meta/design.md b/docs/batches/bat-023_20260704_portfolios-strategy-market-meta/design.md new file mode 100644 index 0000000..0de7355 --- /dev/null +++ b/docs/batches/bat-023_20260704_portfolios-strategy-market-meta/design.md @@ -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으로 해결. diff --git a/docs/batches/bat-023_20260704_portfolios-strategy-market-meta/index.md b/docs/batches/bat-023_20260704_portfolios-strategy-market-meta/index.md new file mode 100644 index 0000000..9c7d50d --- /dev/null +++ b/docs/batches/bat-023_20260704_portfolios-strategy-market-meta/index.md @@ -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 범위 밖(서버 측 파싱으로 처리). diff --git a/docs/batches/bat-023_20260704_portfolios-strategy-market-meta/planning.md b/docs/batches/bat-023_20260704_portfolios-strategy-market-meta/planning.md new file mode 100644 index 0000000..82906b4 --- /dev/null +++ b/docs/batches/bat-023_20260704_portfolios-strategy-market-meta/planning.md @@ -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 보장). diff --git a/docs/batches/bat-023_20260704_portfolios-strategy-market-meta/verification.md b/docs/batches/bat-023_20260704_portfolios-strategy-market-meta/verification.md new file mode 100644 index 0000000..60b07b8 --- /dev/null +++ b/docs/batches/bat-023_20260704_portfolios-strategy-market-meta/verification.md @@ -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과 잠금 — 이름 변경 시 소비자 코드 변경 발생. diff --git a/docs/batches/index.md b/docs/batches/index.md index 174ee19..80a22ab 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -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/) |