From 8a077420496ac21082aa79c589df6714aca829f1 Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Sat, 4 Jul 2026 11:03:38 +0900 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5 --- app/api/v1_portfolios.py | 5 +++++ app/domain/models.py | 5 ++++- app/repositories/postgres.py | 37 +++++++++++++++++++++++++++++++++++- app/schemas/v1.py | 5 +++++ 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/app/api/v1_portfolios.py b/app/api/v1_portfolios.py index dc8d886..05d8589 100644 --- a/app/api/v1_portfolios.py +++ b/app/api/v1_portfolios.py @@ -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, ) diff --git a/app/domain/models.py b/app/domain/models.py index d4d6372..cd4038d 100644 --- a/app/domain/models.py +++ b/app/domain/models.py @@ -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 diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index d3fd160..7464343 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -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 diff --git a/app/schemas/v1.py b/app/schemas/v1.py index 600c080..5bc7933 100644 --- a/app/schemas/v1.py +++ b/app/schemas/v1.py @@ -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):