From 908068d9aaa05c53da4370373d2b247379de11ca Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Fri, 3 Apr 2026 08:16:07 +0000 Subject: [PATCH] feat: refactor portfolio strategy references --- app/cli/core/utils.py | 3 +- app/domain/models.py | 9 +- app/domain/services.py | 26 ++-- app/repositories/portfolio_repository.py | 8 +- app/repositories/postgres.py | 119 +++++++++--------- app/schemas/portfolio.py | 13 +- app/services/portfolio_service.py | 77 +++++++++--- docs/api-examples.md | 12 +- docs/db-schema-spec.md | 18 ++- docs/portfoliohub-spec.md | 20 +++ .../20260403_0003_portfolio_strategy_refs.py | 86 +++++++++++++ tests/test_auth.py | 4 + tests/test_cli.py | 23 ++-- tests/test_domain_services.py | 15 ++- 14 files changed, 294 insertions(+), 139 deletions(-) create mode 100644 migrations/versions/20260403_0003_portfolio_strategy_refs.py diff --git a/app/cli/core/utils.py b/app/cli/core/utils.py index 9628290..1e2d95e 100644 --- a/app/cli/core/utils.py +++ b/app/cli/core/utils.py @@ -74,7 +74,8 @@ def print_portfolio_detail(console: Console, detail: PortfolioDetailResponse) -> f"broker: {portfolio.broker}", f"status: {portfolio.status}", f"symbol: {portfolio.symbol}", - f"strategy: {portfolio.strategy_name}", + f"active_strategy_id: {portfolio.active_strategy_id or 'N/A'}", + f"avabile_strategies: {', '.join(portfolio.avabile_strategies) if portfolio.avabile_strategies else 'N/A'}", f"cash_balance: {format_currency(portfolio.cash_balance)}", f"asset_quantity: {portfolio.asset_quantity:g}", f"price: {format_currency(portfolio.price)}", diff --git a/app/domain/models.py b/app/domain/models.py index f7bc436..5cd8dca 100644 --- a/app/domain/models.py +++ b/app/domain/models.py @@ -2,9 +2,9 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import datetime -from typing import Any, Protocol, runtime_checkable +from typing import Protocol, runtime_checkable @dataclass @@ -22,13 +22,12 @@ class Portfolio: broker: str status: str symbol: str - strategy_name: str - strategy_params: dict[str, Any] = field(default_factory=dict) cash_balance: float = 0.0 asset_quantity: float = 0.0 price: float = 0.0 profit_buffer: float = 0.0 - current_strategy_set_id: str | None = None + active_strategy_id: str | None = None + avabile_strategies: list[str] | None = None created_at: datetime | None = None updated_at: datetime | None = None last_trade_at: datetime | None = None diff --git a/app/domain/services.py b/app/domain/services.py index 2eecfca..9791855 100644 --- a/app/domain/services.py +++ b/app/domain/services.py @@ -24,8 +24,8 @@ class PortfolioDomainRepository(Protocol): *, broker: str = "kis", status: str = "enabled", - strategy_name: str = "", - strategy_params: dict | None = None, + active_strategy_id: str | None = None, + avabile_strategies: list[str] | None = None, initial_cash: float = 0.0, ) -> Portfolio: ... def create_portfolio( @@ -35,12 +35,12 @@ class PortfolioDomainRepository(Protocol): broker: str, status: str, symbol: str, - strategy_name: str, - strategy_params: dict, cash_balance: float, asset_quantity: float, asset_price: float, profit_buffer: float, + active_strategy_id: str | None, + avabile_strategies: list[str] | None, ) -> Portfolio: ... def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio: ... def list_portfolios(self, *, broker: str | None = None, status: str | None = None) -> list[Portfolio]: ... @@ -100,13 +100,12 @@ def apply_buy( broker=portfolio.broker, status=portfolio.status, symbol=portfolio.symbol, - strategy_name=portfolio.strategy_name, - strategy_params=portfolio.strategy_params, cash_balance=float(portfolio.cash_balance) - total_cost, asset_quantity=float(portfolio.asset_quantity) + quantity, price=price, profit_buffer=float(portfolio.profit_buffer), - current_strategy_set_id=portfolio.current_strategy_set_id, + active_strategy_id=portfolio.active_strategy_id, + avabile_strategies=portfolio.avabile_strategies, created_at=portfolio.created_at, updated_at=portfolio.updated_at, last_trade_at=portfolio.last_trade_at, @@ -121,7 +120,7 @@ def apply_buy( fee_amount=fee, tax_amount=tax, total_value_after=total_value_after, - strategy_set_id=portfolio.current_strategy_set_id or "manual", + strategy_set_id=portfolio.active_strategy_id or "manual", note=note, ) return repo.get(portfolio_id) @@ -161,13 +160,12 @@ def apply_sell( broker=portfolio.broker, status=portfolio.status, symbol=portfolio.symbol, - strategy_name=portfolio.strategy_name, - strategy_params=portfolio.strategy_params, cash_balance=float(portfolio.cash_balance) + net_proceeds, asset_quantity=float(portfolio.asset_quantity) - quantity, price=price, profit_buffer=float(portfolio.profit_buffer), - current_strategy_set_id=portfolio.current_strategy_set_id, + active_strategy_id=portfolio.active_strategy_id, + avabile_strategies=portfolio.avabile_strategies, created_at=portfolio.created_at, updated_at=portfolio.updated_at, last_trade_at=portfolio.last_trade_at, @@ -182,7 +180,7 @@ def apply_sell( fee_amount=fee, tax_amount=tax, total_value_after=total_value_after, - strategy_set_id=portfolio.current_strategy_set_id or "manual", + strategy_set_id=portfolio.active_strategy_id or "manual", note=note, ) return repo.get(portfolio_id) @@ -265,8 +263,8 @@ def get_portfolio_status( portfolio = repo.get(portfolio_id) recent_entries = repo.list_recent_trades(portfolio_id, limit=recent_trades) strategy_set = None - if portfolio.current_strategy_set_id: - strategy_set = repo.get_strategy_set(portfolio.current_strategy_set_id) + if portfolio.active_strategy_id: + strategy_set = repo.get_strategy_set(portfolio.active_strategy_id) live_quantity = None if live_source is not None: diff --git a/app/repositories/portfolio_repository.py b/app/repositories/portfolio_repository.py index c596a66..678fbaa 100644 --- a/app/repositories/portfolio_repository.py +++ b/app/repositories/portfolio_repository.py @@ -19,8 +19,8 @@ class PortfolioRepository(Protocol): *, broker: str = "kis", status: str = "enabled", - strategy_name: str = "", - strategy_params: dict | None = None, + active_strategy_id: str | None = None, + avabile_strategies: list[str] | None = None, initial_cash: float = 0.0, ) -> Portfolio: ... @@ -31,12 +31,12 @@ class PortfolioRepository(Protocol): broker: str, status: str, symbol: str, - strategy_name: str, - strategy_params: dict, cash_balance: float, asset_quantity: float, asset_price: float, profit_buffer: float, + active_strategy_id: str | None, + avabile_strategies: list[str] | None, ) -> Portfolio: ... def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio: ... diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index 3274143..058ff4b 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -20,13 +20,12 @@ _SELECT_PORTFOLIO = """ broker, status, symbol, - strategy_name, - strategy_params, cash_balance, asset_quantity, asset_price, profit_buffer, - current_strategy_set_id, + active_strategy_id, + avabile_strategies, created_at, updated_at, last_trade_at @@ -98,13 +97,12 @@ class PostgresPortfolioRepository: broker TEXT NOT NULL DEFAULT 'kis', status TEXT NOT NULL DEFAULT 'enabled', symbol TEXT NOT NULL, - strategy_name TEXT NOT NULL DEFAULT '', - strategy_params JSONB NOT NULL DEFAULT '{}'::jsonb, cash_balance DOUBLE PRECISION NOT NULL, asset_quantity DOUBLE PRECISION NOT NULL, asset_price DOUBLE PRECISION NOT NULL DEFAULT 0, profit_buffer DOUBLE PRECISION NOT NULL DEFAULT 0, - current_strategy_set_id TEXT DEFAULT NULL, + active_strategy_id TEXT DEFAULT NULL, + avabile_strategies TEXT[] DEFAULT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), last_trade_at TIMESTAMPTZ @@ -155,32 +153,28 @@ class PostgresPortfolioRepository: if row is None: raise ValueError("포트폴리오를 찾을 수 없습니다") - raw_params = row[5] if len(row) > 5 else {} - if isinstance(raw_params, str): - try: - strategy_params = json.loads(raw_params) if raw_params else {} - except json.JSONDecodeError: - strategy_params = {} - elif isinstance(raw_params, dict): - strategy_params = raw_params + raw_available = row[9] if len(row) > 9 else None + if raw_available is None: + avabile_strategies = None + elif isinstance(raw_available, list): + avabile_strategies = [str(item) for item in raw_available] else: - strategy_params = {} + avabile_strategies = [str(raw_available)] return Portfolio( portfolio_id=row[0], broker=row[1] or "kis", status=row[2] or "enabled", symbol=row[3], - strategy_name=row[4] or "", - strategy_params=strategy_params, - cash_balance=float(row[6]), - asset_quantity=float(row[7]), - price=float(row[8]), - profit_buffer=float(row[9]), - current_strategy_set_id=row[10] if len(row) > 10 else None, - created_at=row[11] if len(row) > 11 else None, - updated_at=row[12] if len(row) > 12 else None, - last_trade_at=row[13] if len(row) > 13 else None, + cash_balance=float(row[4]), + asset_quantity=float(row[5]), + price=float(row[6]), + profit_buffer=float(row[7]), + active_strategy_id=row[8] if len(row) > 8 else None, + avabile_strategies=avabile_strategies, + created_at=row[10] if len(row) > 10 else None, + updated_at=row[11] if len(row) > 11 else None, + last_trade_at=row[12] if len(row) > 12 else None, ) @staticmethod @@ -215,8 +209,8 @@ class PostgresPortfolioRepository: *, broker: str = "kis", status: str = "enabled", - strategy_name: str = "", - strategy_params: dict[str, Any] | None = None, + active_strategy_id: str | None = None, + avabile_strategies: list[str] | None = None, initial_cash: float = 0.0, ) -> Portfolio: self.ensure_schema() @@ -224,10 +218,11 @@ class PostgresPortfolioRepository: cur.execute( """ INSERT INTO portfolios ( - portfolio_id, broker, status, symbol, strategy_name, strategy_params, - cash_balance, asset_quantity, asset_price, profit_buffer + portfolio_id, broker, status, symbol, + cash_balance, asset_quantity, asset_price, profit_buffer, + active_strategy_id, avabile_strategies ) - VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s, %s, %s, %s) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) ON CONFLICT (portfolio_id) DO NOTHING """, ( @@ -235,12 +230,12 @@ class PostgresPortfolioRepository: broker, status, symbol, - strategy_name, - json.dumps(strategy_params or {}), float(initial_cash), 0.0, 0.0, 0.0, + active_strategy_id, + avabile_strategies, ), ) cur.execute(_SELECT_PORTFOLIO + "WHERE portfolio_id = %s", (portfolio_id,)) @@ -254,12 +249,12 @@ class PostgresPortfolioRepository: broker: str, status: str, symbol: str, - strategy_name: str, - strategy_params: dict, cash_balance: float, asset_quantity: float, asset_price: float, profit_buffer: float, + active_strategy_id: str | None, + avabile_strategies: list[str] | None, ) -> Portfolio: self.ensure_schema() with self._connect().cursor() as cur: @@ -269,25 +264,26 @@ class PostgresPortfolioRepository: cur.execute( """ INSERT INTO portfolios ( - portfolio_id, broker, status, symbol, strategy_name, strategy_params, - cash_balance, asset_quantity, asset_price, profit_buffer - ) VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s, %s, %s, %s) - RETURNING - portfolio_id, broker, status, symbol, strategy_name, strategy_params, + portfolio_id, broker, status, symbol, cash_balance, asset_quantity, asset_price, profit_buffer, - current_strategy_set_id, created_at, updated_at, last_trade_at + active_strategy_id, avabile_strategies + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING + portfolio_id, broker, status, symbol, + cash_balance, asset_quantity, asset_price, profit_buffer, + active_strategy_id, avabile_strategies, created_at, updated_at, last_trade_at """, ( portfolio_id, broker, status, symbol, - strategy_name, - json.dumps(strategy_params), float(cash_balance), float(asset_quantity), float(asset_price), float(profit_buffer), + active_strategy_id, + avabile_strategies, ), ) row = cur.fetchone() @@ -299,19 +295,18 @@ class PostgresPortfolioRepository: "broker": "broker", "status": "status", "symbol": "symbol", - "strategy_name": "strategy_name", - "strategy_params": "strategy_params", "asset_price": "asset_price", - "current_strategy_set_id": "current_strategy_set_id", + "active_strategy_id": "active_strategy_id", + "avabile_strategies": "avabile_strategies", } assignments: list[str] = [] params: list[object] = [] for key, value in fields.items(): if value is None or key not in allowed: continue - if key == "strategy_params": - assignments.append("strategy_params = %s::jsonb") - params.append(json.dumps(value)) + if key == "avabile_strategies": + assignments.append("avabile_strategies = %s") + params.append(value) else: assignments.append(f"{allowed[key]} = %s") params.append(value) @@ -326,9 +321,9 @@ class PostgresPortfolioRepository: SET {', '.join(assignments)} WHERE portfolio_id = %s RETURNING - portfolio_id, broker, status, symbol, strategy_name, strategy_params, + portfolio_id, broker, status, symbol, cash_balance, asset_quantity, asset_price, profit_buffer, - current_strategy_set_id, created_at, updated_at, last_trade_at + active_strategy_id, avabile_strategies, created_at, updated_at, last_trade_at """, (*params, portfolio_id), ) @@ -413,13 +408,12 @@ class PostgresPortfolioRepository: SET broker = %s, status = %s, symbol = %s, - strategy_name = %s, - strategy_params = %s::jsonb, cash_balance = %s, asset_quantity = %s, asset_price = %s, profit_buffer = %s, - current_strategy_set_id = %s, + active_strategy_id = %s, + avabile_strategies = %s, updated_at = %s, last_trade_at = CASE WHEN %s IN ('BUY', 'SELL') THEN %s ELSE last_trade_at END WHERE portfolio_id = %s @@ -428,13 +422,12 @@ class PostgresPortfolioRepository: portfolio.broker, portfolio.status, portfolio.symbol, - portfolio.strategy_name, - json.dumps(portfolio.strategy_params), float(portfolio.cash_balance), float(portfolio.asset_quantity), float(current_price), float(portfolio.profit_buffer), - portfolio.current_strategy_set_id, + portfolio.active_strategy_id, + portfolio.avabile_strategies, trade_time, action, trade_time, @@ -496,14 +489,14 @@ class PostgresPortfolioRepository: asset_quantity = 0, asset_price = 0, profit_buffer = 0, - current_strategy_set_id = NULL, + active_strategy_id = NULL, updated_at = NOW(), last_trade_at = NULL WHERE portfolio_id = %s RETURNING - portfolio_id, broker, status, symbol, strategy_name, strategy_params, + portfolio_id, broker, status, symbol, cash_balance, asset_quantity, asset_price, profit_buffer, - current_strategy_set_id, created_at, updated_at, last_trade_at + active_strategy_id, avabile_strategies, created_at, updated_at, last_trade_at """, (portfolio_id,), ) @@ -540,9 +533,9 @@ class PostgresPortfolioRepository: updated_at = NOW() WHERE portfolio_id = %s RETURNING - portfolio_id, broker, status, symbol, strategy_name, strategy_params, + portfolio_id, broker, status, symbol, cash_balance, asset_quantity, asset_price, profit_buffer, - current_strategy_set_id, created_at, updated_at, last_trade_at + active_strategy_id, avabile_strategies, created_at, updated_at, last_trade_at """, (updated_buffer, portfolio_id), ) @@ -609,9 +602,9 @@ class PostgresPortfolioRepository: updated_at = NOW() WHERE portfolio_id = %s RETURNING - portfolio_id, broker, status, symbol, strategy_name, strategy_params, + portfolio_id, broker, status, symbol, cash_balance, asset_quantity, asset_price, profit_buffer, - current_strategy_set_id, created_at, updated_at, last_trade_at + active_strategy_id, avabile_strategies, created_at, updated_at, last_trade_at """, (cash, buf, portfolio_id), ) diff --git a/app/schemas/portfolio.py b/app/schemas/portfolio.py index 785f9ef..3a8fc35 100644 --- a/app/schemas/portfolio.py +++ b/app/schemas/portfolio.py @@ -36,8 +36,6 @@ class PortfolioBase(BaseModel): broker: str = "kis" status: str = "enabled" symbol: str - strategy_name: str = "target_value" - strategy_params: dict[str, Any] = Field(default_factory=dict) class PortfolioSummary(BaseModel): @@ -45,13 +43,12 @@ class PortfolioSummary(BaseModel): broker: str status: str symbol: str - strategy_name: str - strategy_params: dict[str, Any] = Field(default_factory=dict) cash_balance: float = 0.0 asset_quantity: float = 0.0 price: float = 0.0 profit_buffer: float = 0.0 - current_strategy_set_id: str | None = None + active_strategy_id: str | None = None + avabile_strategies: list[str] | None = None created_at: datetime | None = None updated_at: datetime | None = None last_trade_at: datetime | None = None @@ -70,6 +67,10 @@ class PortfolioDetailResponse(BaseModel): class CreatePortfolioRequest(PortfolioBase): portfolio_id: str + strategy_name: str = "target_value" + strategy_params: dict[str, Any] = Field(default_factory=dict) + active_strategy_id: str | None = None + avabile_strategies: list[str] | None = None cash_balance: float = Field(default=0.0, ge=0.0) asset_quantity: float = Field(default=0.0, ge=0.0) asset_price: float = Field(default=0.0, ge=0.0) @@ -82,6 +83,8 @@ class UpdatePortfolioRequest(BaseModel): symbol: str | None = None strategy_name: str | None = None strategy_params: dict[str, Any] | None = None + active_strategy_id: str | None = None + avabile_strategies: list[str] | None = None asset_price: float | None = Field(default=None, ge=0.0) diff --git a/app/services/portfolio_service.py b/app/services/portfolio_service.py index e6f32e9..eea7657 100644 --- a/app/services/portfolio_service.py +++ b/app/services/portfolio_service.py @@ -45,19 +45,33 @@ def _to_portfolio_summary(portfolio: Portfolio): broker=portfolio.broker, status=portfolio.status, symbol=portfolio.symbol, - strategy_name=portfolio.strategy_name, - strategy_params=portfolio.strategy_params, cash_balance=portfolio.cash_balance, asset_quantity=portfolio.asset_quantity, price=portfolio.price, profit_buffer=portfolio.profit_buffer, - current_strategy_set_id=portfolio.current_strategy_set_id, + active_strategy_id=portfolio.active_strategy_id, + avabile_strategies=portfolio.avabile_strategies, created_at=portfolio.created_at, updated_at=portfolio.updated_at, last_trade_at=portfolio.last_trade_at, ) +def _parse_strategy_config(config: str) -> dict[str, object]: + try: + parsed = json.loads(config) if config else {} + except json.JSONDecodeError: + return {} + return parsed if isinstance(parsed, dict) else {} + + +def _merge_strategy_ids(active_strategy_id: str | None, strategy_ids: list[str] | None) -> list[str] | None: + normalized = list(strategy_ids or []) + if active_strategy_id and active_strategy_id not in normalized: + normalized.append(active_strategy_id) + return normalized or None + + def _to_strategy_set_response(strategy_set: StrategySet | None): from app.schemas.portfolio import StrategySetResponse @@ -124,39 +138,68 @@ class PortfolioService: return _to_detail_response(status_result) def create_portfolio(self, payload: CreatePortfolioRequest) -> PortfolioDetailResponse: - strategy_set = self._repository.get_or_create_strategy_set( - payload.strategy_name, - json.dumps(payload.strategy_params, sort_keys=True, ensure_ascii=True), - ) + active_strategy_id = payload.active_strategy_id + if active_strategy_id is None: + strategy_set = self._repository.get_or_create_strategy_set( + payload.strategy_name, + json.dumps(payload.strategy_params, sort_keys=True, ensure_ascii=True), + ) + active_strategy_id = strategy_set.id + avabile_strategies = _merge_strategy_ids(active_strategy_id, payload.avabile_strategies) portfolio = self._repository.create_portfolio( payload.portfolio_id, broker=payload.broker, status=payload.status, symbol=payload.symbol, - strategy_name=payload.strategy_name, - strategy_params=payload.strategy_params, cash_balance=payload.cash_balance, asset_quantity=payload.asset_quantity, asset_price=payload.asset_price, profit_buffer=payload.profit_buffer, - ) - portfolio = self._repository.update_portfolio( - payload.portfolio_id, - current_strategy_set_id=strategy_set.id, + active_strategy_id=active_strategy_id, + avabile_strategies=avabile_strategies, ) return self.get_portfolio_status(portfolio_id=portfolio.portfolio_id, recent_trades=5) def update_portfolio(self, portfolio_id: str, payload: UpdatePortfolioRequest) -> PortfolioDetailResponse: fields = payload.model_dump(exclude_none=True) + current = self._repository.get(portfolio_id) + if "strategy_name" in fields or "strategy_params" in fields: - current = self._repository.get(portfolio_id) - strategy_name = fields.get("strategy_name", current.strategy_name) - strategy_params = fields.get("strategy_params", current.strategy_params) + current_strategy_set = None + if current.active_strategy_id: + current_strategy_set = self._repository.get_strategy_set(current.active_strategy_id) + + strategy_name = fields.get("strategy_name") + if strategy_name is None and current_strategy_set is not None: + strategy_name = current_strategy_set.name + if strategy_name is None: + raise ValueError("strategy_name is required when no active strategy is set") + + strategy_params = fields.get("strategy_params") + if strategy_params is None and current_strategy_set is not None: + strategy_params = _parse_strategy_config(current_strategy_set.config) + if strategy_params is None: + strategy_params = {} + strategy_set = self._repository.get_or_create_strategy_set( strategy_name, json.dumps(strategy_params, sort_keys=True, ensure_ascii=True), ) - fields["current_strategy_set_id"] = strategy_set.id + fields["active_strategy_id"] = strategy_set.id + fields["avabile_strategies"] = _merge_strategy_ids( + strategy_set.id, + fields.get("avabile_strategies", current.avabile_strategies), + ) + + elif "active_strategy_id" in fields: + fields["avabile_strategies"] = _merge_strategy_ids( + fields.get("active_strategy_id"), + fields.get("avabile_strategies", current.avabile_strategies), + ) + + fields.pop("strategy_name", None) + fields.pop("strategy_params", None) + self._repository.update_portfolio(portfolio_id, **fields) return self.get_portfolio_status(portfolio_id=portfolio_id, recent_trades=5) diff --git a/docs/api-examples.md b/docs/api-examples.md index 4167b23..da9af0c 100644 --- a/docs/api-examples.md +++ b/docs/api-examples.md @@ -51,13 +51,12 @@ Response 200: "broker": "upbit", "status": "enabled", "symbol": "KRW-BTC", - "strategy_name": "target_value", - "strategy_params": {}, "cash_balance": 1000000.0, "asset_quantity": 0.1, "price": 100000000.0, "profit_buffer": 0.0, - "current_strategy_set_id": "stv-001", + "active_strategy_id": "stv-001", + "avabile_strategies": ["stv-001"], "created_at": "2026-04-03T00:00:00Z", "updated_at": "2026-04-03T00:00:00Z", "last_trade_at": "2026-04-03T00:00:00Z", @@ -84,13 +83,12 @@ Response 200: "broker": "upbit", "status": "enabled", "symbol": "KRW-BTC", - "strategy_name": "target_value", - "strategy_params": {}, "cash_balance": 1000000.0, "asset_quantity": 0.1, "price": 100000000.0, "profit_buffer": 0.0, - "current_strategy_set_id": "stv-001", + "active_strategy_id": "stv-001", + "avabile_strategies": ["stv-001"], "created_at": "2026-04-03T00:00:00Z", "updated_at": "2026-04-03T00:00:00Z", "last_trade_at": "2026-04-03T00:00:00Z" @@ -138,6 +136,8 @@ curl -X POST "http://localhost:8000/portfolios" \ "symbol": "KRW-BTC", "strategy_name": "target_value", "strategy_params": {}, + "active_strategy_id": null, + "avabile_strategies": null, "cash_balance": 1000000, "asset_quantity": 0, "asset_price": 0, diff --git a/docs/db-schema-spec.md b/docs/db-schema-spec.md index c74c347..771081b 100644 --- a/docs/db-schema-spec.md +++ b/docs/db-schema-spec.md @@ -5,10 +5,11 @@ This document defines the database schema in an AI-agent-friendly format. - Source of truth: Alembic revisions in `migrations/versions` -- Current baseline revision: `20260403_0002` +- Current baseline revision: `20260403_0003` - Baseline files: - `migrations/versions/20260403_0001_initial_portfolio_schema.py` - `migrations/versions/20260403_0002_strategy_set_columns.py` + - `migrations/versions/20260403_0003_portfolio_strategy_refs.py` ## 2. Scope @@ -57,6 +58,10 @@ Purpose: - Stores current portfolio state and strategy linkage. +Current baseline note: + +- The columns below describe the currently migrated schema through revision `20260403_0003`. + Columns: | Column | Type | Null | Default | Constraints / Notes | @@ -65,13 +70,12 @@ Columns: | broker | Text | NO | "kis" | Broker code | | status | Text | NO | "enabled" | Portfolio status | | symbol | Text | NO | - | Trading symbol | -| strategy_name | Text | NO | "" | Strategy label | -| strategy_params | JSONB | NO | '{}'::jsonb | Strategy parameters | | cash_balance | Float | NO | - | Current cash balance | | asset_quantity | Float | NO | - | Current asset quantity | | asset_price | Float | NO | 0 | Current price snapshot | | profit_buffer | Float | NO | 0 | Profit buffer amount | -| current_strategy_set_id | Text | YES | - | Logical reference to `strategy_sets.id` (no FK) | +| active_strategy_id | Text | YES | - | Logical reference to `strategy_sets.id` | +| avabile_strategies | Text[] | YES | - | Nullable array of `strategy_sets.id` values | | created_at | DateTime(timezone=True) | NO | NOW() | Creation timestamp | | updated_at | DateTime(timezone=True) | NO | NOW() | Last update timestamp | | last_trade_at | DateTime(timezone=True) | YES | - | Last trade time | @@ -81,6 +85,11 @@ Constraints: - Primary key: `portfolio_id` - Foreign key constraints: none in baseline +Constraint notes: + +- `active_strategy_id` is a nullable logical reference to `strategy_sets.id` +- `avabile_strategies` stores zero or more selectable strategy set IDs when present + ### 4.3 portfolio_ledger Purpose: @@ -160,3 +169,4 @@ When schema changes: |---|---|---| | 20260403_0001 | migrations/versions/20260403_0001_initial_portfolio_schema.py | Initial schema for strategy sets, portfolios, ledger, indexes, optional hypertable | | 20260403_0002 | migrations/versions/20260403_0002_strategy_set_columns.py | Rename `strategy_name`->`name`, `strategy_config`->`config`, add unique `nick` | +| 20260403_0003 | migrations/versions/20260403_0003_portfolio_strategy_refs.py | Remove embedded portfolio strategy fields, add `active_strategy_id` and `avabile_strategies` | diff --git a/docs/portfoliohub-spec.md b/docs/portfoliohub-spec.md index 2000ff2..1a567fd 100644 --- a/docs/portfoliohub-spec.md +++ b/docs/portfoliohub-spec.md @@ -224,6 +224,8 @@ Base routes: - `symbol: str` - `strategy_name: str = "target_value"` - `strategy_params: object = {}` +- `active_strategy_id: str | null` +- `avabile_strategies: string[] | null` - `cash_balance: float >= 0` - `asset_quantity: float >= 0` - `asset_price: float >= 0` @@ -236,6 +238,8 @@ Base routes: - `symbol: str | null` - `strategy_name: str | null` - `strategy_params: object | null` +- `active_strategy_id: str | null` +- `avabile_strategies: string[] | null` - `asset_price: float >= 0 | null` `BuyRequest` / `SellRequest` @@ -268,6 +272,22 @@ Base routes: - Includes portfolio summary fields and computed `total_value` +`PortfolioSummary` + +- `portfolio_id: str` +- `broker: str` +- `status: str` +- `symbol: str` +- `cash_balance: float` +- `asset_quantity: float` +- `price: float` +- `profit_buffer: float` +- `active_strategy_id: str | null` +- `avabile_strategies: string[] | null` +- `created_at: datetime | null` +- `updated_at: datetime | null` +- `last_trade_at: datetime | null` + `PortfolioDetailResponse` - `portfolio: PortfolioSummary` diff --git a/migrations/versions/20260403_0003_portfolio_strategy_refs.py b/migrations/versions/20260403_0003_portfolio_strategy_refs.py new file mode 100644 index 0000000..7857159 --- /dev/null +++ b/migrations/versions/20260403_0003_portfolio_strategy_refs.py @@ -0,0 +1,86 @@ +"""replace embedded portfolio strategy fields with strategy references + +Revision ID: 20260403_0003 +Revises: 20260403_0002 +Create Date: 2026-04-03 01:00:00 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +revision = "20260403_0003" +down_revision = "20260403_0002" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("portfolios", sa.Column("active_strategy_id", sa.Text(), nullable=True)) + op.add_column("portfolios", sa.Column("avabile_strategies", postgresql.ARRAY(sa.Text()), nullable=True)) + + op.execute( + sa.text( + """ + UPDATE portfolios + SET + active_strategy_id = current_strategy_set_id, + avabile_strategies = CASE + WHEN current_strategy_set_id IS NULL THEN NULL + ELSE ARRAY[current_strategy_set_id]::text[] + END + """ + ) + ) + + op.drop_column("portfolios", "strategy_name") + op.drop_column("portfolios", "strategy_params") + op.drop_column("portfolios", "current_strategy_set_id") + + +def downgrade() -> None: + op.add_column("portfolios", sa.Column("strategy_name", sa.Text(), nullable=False, server_default="")) + op.add_column( + "portfolios", + sa.Column( + "strategy_params", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + server_default=sa.text("'{}'::jsonb"), + ), + ) + op.add_column("portfolios", sa.Column("current_strategy_set_id", sa.Text(), nullable=True)) + + op.execute( + sa.text( + """ + UPDATE portfolios p + SET + current_strategy_set_id = p.active_strategy_id, + strategy_name = COALESCE(s.name, ''), + strategy_params = CASE + WHEN s.config IS NULL OR s.config = '' THEN '{}'::jsonb + ELSE s.config::jsonb + END + FROM strategy_sets s + WHERE p.active_strategy_id IS NOT NULL + AND s.id = p.active_strategy_id + """ + ) + ) + + op.execute( + sa.text( + """ + UPDATE portfolios + SET strategy_name = COALESCE(strategy_name, ''), + strategy_params = COALESCE(strategy_params, '{}'::jsonb) + """ + ) + ) + + op.drop_column("portfolios", "avabile_strategies") + op.drop_column("portfolios", "active_strategy_id") diff --git a/tests/test_auth.py b/tests/test_auth.py index 9b9a41b..f20fd79 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -13,6 +13,7 @@ def _client_with_tokens() -> TestClient: def test_read_route_requires_token(monkeypatch) -> None: + monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "false") monkeypatch.setenv("PORTFOLIOHUB_READ_TOKENS", "read-token") get_settings.cache_clear() client = _client_with_tokens() @@ -24,6 +25,7 @@ def test_read_route_requires_token(monkeypatch) -> None: def test_read_route_accepts_read_token(monkeypatch) -> None: + monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "false") monkeypatch.setenv("PORTFOLIOHUB_READ_TOKENS", "read-token") get_settings.cache_clear() client = _client_with_tokens() @@ -35,6 +37,7 @@ def test_read_route_accepts_read_token(monkeypatch) -> None: def test_trade_route_rejects_read_token(monkeypatch) -> None: + monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "false") monkeypatch.setenv("PORTFOLIOHUB_READ_TOKENS", "read-token") monkeypatch.setenv("PORTFOLIOHUB_TRADE_WRITE_TOKENS", "trade-token") get_settings.cache_clear() @@ -51,6 +54,7 @@ def test_trade_route_rejects_read_token(monkeypatch) -> None: def test_admin_route_accepts_admin_token(monkeypatch) -> None: + monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "false") monkeypatch.setenv("PORTFOLIOHUB_ADMIN_TOKENS", "admin-token") get_settings.cache_clear() client = _client_with_tokens() diff --git a/tests/test_cli.py b/tests/test_cli.py index d9e73f0..32fe548 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -39,8 +39,8 @@ class FakeRepo: *, broker: str = "kis", status: str = "enabled", - strategy_name: str = "", - strategy_params: dict | None = None, + active_strategy_id: str | None = None, + avabile_strategies: list[str] | None = None, initial_cash: float = 0.0, ) -> Portfolio: if self._portfolio is None: @@ -49,9 +49,9 @@ class FakeRepo: broker=broker, status=status, symbol=symbol, - strategy_name=strategy_name, - strategy_params=strategy_params or {}, cash_balance=initial_cash, + active_strategy_id=active_strategy_id, + avabile_strategies=avabile_strategies, ) return self._portfolio @@ -62,24 +62,24 @@ class FakeRepo: broker: str, status: str, symbol: str, - strategy_name: str, - strategy_params: dict, cash_balance: float, asset_quantity: float, asset_price: float, profit_buffer: float, + active_strategy_id: str | None, + avabile_strategies: list[str] | None, ) -> Portfolio: self._portfolio = Portfolio( portfolio_id=portfolio_id, broker=broker, status=status, symbol=symbol, - strategy_name=strategy_name, - strategy_params=strategy_params, cash_balance=cash_balance, asset_quantity=asset_quantity, price=asset_price, profit_buffer=profit_buffer, + active_strategy_id=active_strategy_id, + avabile_strategies=avabile_strategies, ) return self._portfolio @@ -149,7 +149,7 @@ class FakeRepo: portfolio.asset_quantity = 0.0 portfolio.price = 0.0 portfolio.profit_buffer = 0.0 - portfolio.current_strategy_set_id = None + portfolio.active_strategy_id = None self.entries = [] return portfolio @@ -194,13 +194,12 @@ def _portfolio() -> Portfolio: broker="kis", status="enabled", symbol="005930", - strategy_name="target_value", - strategy_params={"target_asset_ratio": 0.7}, cash_balance=1_000_000.0, asset_quantity=1.0, price=70_000.0, profit_buffer=50_000.0, - current_strategy_set_id="set-1", + active_strategy_id="set-1", + avabile_strategies=["set-1"], ) diff --git a/tests/test_domain_services.py b/tests/test_domain_services.py index 598b911..be6623f 100644 --- a/tests/test_domain_services.py +++ b/tests/test_domain_services.py @@ -27,8 +27,8 @@ class FakeRepo: *, broker: str = "kis", status: str = "enabled", - strategy_name: str = "", - strategy_params: dict | None = None, + active_strategy_id: str | None = None, + avabile_strategies: list[str] | None = None, initial_cash: float = 0.0, ) -> Portfolio: self._portfolio = Portfolio( @@ -36,9 +36,9 @@ class FakeRepo: broker=broker, status=status, symbol=symbol, - strategy_name=strategy_name, - strategy_params=strategy_params or {}, cash_balance=initial_cash, + active_strategy_id=active_strategy_id, + avabile_strategies=avabile_strategies, ) return self._portfolio @@ -107,7 +107,7 @@ class FakeRepo: portfolio.asset_quantity = 0.0 portfolio.price = 0.0 portfolio.profit_buffer = 0.0 - portfolio.current_strategy_set_id = None + portfolio.active_strategy_id = None self.entries = [] return portfolio @@ -146,13 +146,12 @@ def _portfolio() -> Portfolio: broker="kis", status="enabled", symbol="005930", - strategy_name="target_value", - strategy_params={"target_asset_ratio": 0.7}, cash_balance=1_000_000.0, asset_quantity=0.0, price=70_000.0, profit_buffer=0.0, - current_strategy_set_id="set-1", + active_strategy_id="set-1", + avabile_strategies=["set-1"], )