feat(req-055): expose strategy & market meta fields in PortfolioItem response (additive)

Adds 4 additive fields to GET /v1/portfolios (and single-portfolio) response:
- active_strategy_id (str|null): portfolios.active_strategy_id raw value
- strategy_name (str|null): strategy_sets.name via LEFT JOIN
- strategy_params (dict): strategy_sets.config JSON parsed; {} on missing/invalid
- markets (list[str]): assets.markets; [] on missing/empty

_SELECT_PORTFOLIO extended with LEFT JOIN strategy_sets; _row_to_portfolio
parses new columns (row[19-21]) with JSON error isolation (WARNING log, no 500).
_portfolio_to_item maps all 4 new fields. Existing 16 fields unchanged.
N+1 prevented: single SQL JOIN for both list and single-item paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
This commit is contained in:
2026-07-04 11:05:51 +09:00
co-authored by Claude Fable 5
parent b2261c4428
commit 8a07742049
4 changed files with 50 additions and 2 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):