feat: refactor portfolio strategy references

This commit is contained in:
2026-04-03 08:16:07 +00:00
parent ebd4559939
commit 908068d9aa
14 changed files with 294 additions and 139 deletions
+2 -1
View File
@@ -74,7 +74,8 @@ def print_portfolio_detail(console: Console, detail: PortfolioDetailResponse) ->
f"broker: {portfolio.broker}", f"broker: {portfolio.broker}",
f"status: {portfolio.status}", f"status: {portfolio.status}",
f"symbol: {portfolio.symbol}", 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"cash_balance: {format_currency(portfolio.cash_balance)}",
f"asset_quantity: {portfolio.asset_quantity:g}", f"asset_quantity: {portfolio.asset_quantity:g}",
f"price: {format_currency(portfolio.price)}", f"price: {format_currency(portfolio.price)}",
+4 -5
View File
@@ -2,9 +2,9 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from typing import Any, Protocol, runtime_checkable from typing import Protocol, runtime_checkable
@dataclass @dataclass
@@ -22,13 +22,12 @@ class Portfolio:
broker: str broker: str
status: str status: str
symbol: str symbol: str
strategy_name: str
strategy_params: dict[str, Any] = field(default_factory=dict)
cash_balance: float = 0.0 cash_balance: float = 0.0
asset_quantity: float = 0.0 asset_quantity: float = 0.0
price: float = 0.0 price: float = 0.0
profit_buffer: 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 created_at: datetime | None = None
updated_at: datetime | None = None updated_at: datetime | None = None
last_trade_at: datetime | None = None last_trade_at: datetime | None = None
+12 -14
View File
@@ -24,8 +24,8 @@ class PortfolioDomainRepository(Protocol):
*, *,
broker: str = "kis", broker: str = "kis",
status: str = "enabled", status: str = "enabled",
strategy_name: str = "", active_strategy_id: str | None = None,
strategy_params: dict | None = None, avabile_strategies: list[str] | None = None,
initial_cash: float = 0.0, initial_cash: float = 0.0,
) -> Portfolio: ... ) -> Portfolio: ...
def create_portfolio( def create_portfolio(
@@ -35,12 +35,12 @@ class PortfolioDomainRepository(Protocol):
broker: str, broker: str,
status: str, status: str,
symbol: str, symbol: str,
strategy_name: str,
strategy_params: dict,
cash_balance: float, cash_balance: float,
asset_quantity: float, asset_quantity: float,
asset_price: float, asset_price: float,
profit_buffer: float, profit_buffer: float,
active_strategy_id: str | None,
avabile_strategies: list[str] | None,
) -> Portfolio: ... ) -> Portfolio: ...
def update_portfolio(self, portfolio_id: str, **fields: object) -> 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]: ... def list_portfolios(self, *, broker: str | None = None, status: str | None = None) -> list[Portfolio]: ...
@@ -100,13 +100,12 @@ def apply_buy(
broker=portfolio.broker, broker=portfolio.broker,
status=portfolio.status, status=portfolio.status,
symbol=portfolio.symbol, symbol=portfolio.symbol,
strategy_name=portfolio.strategy_name,
strategy_params=portfolio.strategy_params,
cash_balance=float(portfolio.cash_balance) - total_cost, cash_balance=float(portfolio.cash_balance) - total_cost,
asset_quantity=float(portfolio.asset_quantity) + quantity, asset_quantity=float(portfolio.asset_quantity) + quantity,
price=price, price=price,
profit_buffer=float(portfolio.profit_buffer), 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, created_at=portfolio.created_at,
updated_at=portfolio.updated_at, updated_at=portfolio.updated_at,
last_trade_at=portfolio.last_trade_at, last_trade_at=portfolio.last_trade_at,
@@ -121,7 +120,7 @@ def apply_buy(
fee_amount=fee, fee_amount=fee,
tax_amount=tax, tax_amount=tax,
total_value_after=total_value_after, 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, note=note,
) )
return repo.get(portfolio_id) return repo.get(portfolio_id)
@@ -161,13 +160,12 @@ def apply_sell(
broker=portfolio.broker, broker=portfolio.broker,
status=portfolio.status, status=portfolio.status,
symbol=portfolio.symbol, symbol=portfolio.symbol,
strategy_name=portfolio.strategy_name,
strategy_params=portfolio.strategy_params,
cash_balance=float(portfolio.cash_balance) + net_proceeds, cash_balance=float(portfolio.cash_balance) + net_proceeds,
asset_quantity=float(portfolio.asset_quantity) - quantity, asset_quantity=float(portfolio.asset_quantity) - quantity,
price=price, price=price,
profit_buffer=float(portfolio.profit_buffer), 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, created_at=portfolio.created_at,
updated_at=portfolio.updated_at, updated_at=portfolio.updated_at,
last_trade_at=portfolio.last_trade_at, last_trade_at=portfolio.last_trade_at,
@@ -182,7 +180,7 @@ def apply_sell(
fee_amount=fee, fee_amount=fee,
tax_amount=tax, tax_amount=tax,
total_value_after=total_value_after, 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, note=note,
) )
return repo.get(portfolio_id) return repo.get(portfolio_id)
@@ -265,8 +263,8 @@ def get_portfolio_status(
portfolio = repo.get(portfolio_id) portfolio = repo.get(portfolio_id)
recent_entries = repo.list_recent_trades(portfolio_id, limit=recent_trades) recent_entries = repo.list_recent_trades(portfolio_id, limit=recent_trades)
strategy_set = None strategy_set = None
if portfolio.current_strategy_set_id: if portfolio.active_strategy_id:
strategy_set = repo.get_strategy_set(portfolio.current_strategy_set_id) strategy_set = repo.get_strategy_set(portfolio.active_strategy_id)
live_quantity = None live_quantity = None
if live_source is not None: if live_source is not None:
+4 -4
View File
@@ -19,8 +19,8 @@ class PortfolioRepository(Protocol):
*, *,
broker: str = "kis", broker: str = "kis",
status: str = "enabled", status: str = "enabled",
strategy_name: str = "", active_strategy_id: str | None = None,
strategy_params: dict | None = None, avabile_strategies: list[str] | None = None,
initial_cash: float = 0.0, initial_cash: float = 0.0,
) -> Portfolio: ... ) -> Portfolio: ...
@@ -31,12 +31,12 @@ class PortfolioRepository(Protocol):
broker: str, broker: str,
status: str, status: str,
symbol: str, symbol: str,
strategy_name: str,
strategy_params: dict,
cash_balance: float, cash_balance: float,
asset_quantity: float, asset_quantity: float,
asset_price: float, asset_price: float,
profit_buffer: float, profit_buffer: float,
active_strategy_id: str | None,
avabile_strategies: list[str] | None,
) -> Portfolio: ... ) -> Portfolio: ...
def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio: ... def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio: ...
+56 -63
View File
@@ -20,13 +20,12 @@ _SELECT_PORTFOLIO = """
broker, broker,
status, status,
symbol, symbol,
strategy_name,
strategy_params,
cash_balance, cash_balance,
asset_quantity, asset_quantity,
asset_price, asset_price,
profit_buffer, profit_buffer,
current_strategy_set_id, active_strategy_id,
avabile_strategies,
created_at, created_at,
updated_at, updated_at,
last_trade_at last_trade_at
@@ -98,13 +97,12 @@ class PostgresPortfolioRepository:
broker TEXT NOT NULL DEFAULT 'kis', broker TEXT NOT NULL DEFAULT 'kis',
status TEXT NOT NULL DEFAULT 'enabled', status TEXT NOT NULL DEFAULT 'enabled',
symbol TEXT NOT NULL, symbol TEXT NOT NULL,
strategy_name TEXT NOT NULL DEFAULT '',
strategy_params JSONB NOT NULL DEFAULT '{}'::jsonb,
cash_balance DOUBLE PRECISION NOT NULL, cash_balance DOUBLE PRECISION NOT NULL,
asset_quantity DOUBLE PRECISION NOT NULL, asset_quantity DOUBLE PRECISION NOT NULL,
asset_price DOUBLE PRECISION NOT NULL DEFAULT 0, asset_price DOUBLE PRECISION NOT NULL DEFAULT 0,
profit_buffer 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(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_trade_at TIMESTAMPTZ last_trade_at TIMESTAMPTZ
@@ -155,32 +153,28 @@ class PostgresPortfolioRepository:
if row is None: if row is None:
raise ValueError("포트폴리오를 찾을 수 없습니다") raise ValueError("포트폴리오를 찾을 수 없습니다")
raw_params = row[5] if len(row) > 5 else {} raw_available = row[9] if len(row) > 9 else None
if isinstance(raw_params, str): if raw_available is None:
try: avabile_strategies = None
strategy_params = json.loads(raw_params) if raw_params else {} elif isinstance(raw_available, list):
except json.JSONDecodeError: avabile_strategies = [str(item) for item in raw_available]
strategy_params = {}
elif isinstance(raw_params, dict):
strategy_params = raw_params
else: else:
strategy_params = {} avabile_strategies = [str(raw_available)]
return Portfolio( return Portfolio(
portfolio_id=row[0], portfolio_id=row[0],
broker=row[1] or "kis", broker=row[1] or "kis",
status=row[2] or "enabled", status=row[2] or "enabled",
symbol=row[3], symbol=row[3],
strategy_name=row[4] or "", cash_balance=float(row[4]),
strategy_params=strategy_params, asset_quantity=float(row[5]),
cash_balance=float(row[6]), price=float(row[6]),
asset_quantity=float(row[7]), profit_buffer=float(row[7]),
price=float(row[8]), active_strategy_id=row[8] if len(row) > 8 else None,
profit_buffer=float(row[9]), avabile_strategies=avabile_strategies,
current_strategy_set_id=row[10] if len(row) > 10 else None, created_at=row[10] if len(row) > 10 else None,
created_at=row[11] if len(row) > 11 else None, updated_at=row[11] if len(row) > 11 else None,
updated_at=row[12] if len(row) > 12 else None, last_trade_at=row[12] if len(row) > 12 else None,
last_trade_at=row[13] if len(row) > 13 else None,
) )
@staticmethod @staticmethod
@@ -215,8 +209,8 @@ class PostgresPortfolioRepository:
*, *,
broker: str = "kis", broker: str = "kis",
status: str = "enabled", status: str = "enabled",
strategy_name: str = "", active_strategy_id: str | None = None,
strategy_params: dict[str, Any] | None = None, avabile_strategies: list[str] | None = None,
initial_cash: float = 0.0, initial_cash: float = 0.0,
) -> Portfolio: ) -> Portfolio:
self.ensure_schema() self.ensure_schema()
@@ -224,10 +218,11 @@ class PostgresPortfolioRepository:
cur.execute( cur.execute(
""" """
INSERT INTO portfolios ( INSERT INTO portfolios (
portfolio_id, broker, status, symbol, strategy_name, strategy_params, portfolio_id, broker, status, symbol,
cash_balance, asset_quantity, asset_price, profit_buffer 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 ON CONFLICT (portfolio_id) DO NOTHING
""", """,
( (
@@ -235,12 +230,12 @@ class PostgresPortfolioRepository:
broker, broker,
status, status,
symbol, symbol,
strategy_name,
json.dumps(strategy_params or {}),
float(initial_cash), float(initial_cash),
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
active_strategy_id,
avabile_strategies,
), ),
) )
cur.execute(_SELECT_PORTFOLIO + "WHERE portfolio_id = %s", (portfolio_id,)) cur.execute(_SELECT_PORTFOLIO + "WHERE portfolio_id = %s", (portfolio_id,))
@@ -254,12 +249,12 @@ class PostgresPortfolioRepository:
broker: str, broker: str,
status: str, status: str,
symbol: str, symbol: str,
strategy_name: str,
strategy_params: dict,
cash_balance: float, cash_balance: float,
asset_quantity: float, asset_quantity: float,
asset_price: float, asset_price: float,
profit_buffer: float, profit_buffer: float,
active_strategy_id: str | None,
avabile_strategies: list[str] | None,
) -> Portfolio: ) -> Portfolio:
self.ensure_schema() self.ensure_schema()
with self._connect().cursor() as cur: with self._connect().cursor() as cur:
@@ -269,25 +264,26 @@ class PostgresPortfolioRepository:
cur.execute( cur.execute(
""" """
INSERT INTO portfolios ( INSERT INTO portfolios (
portfolio_id, broker, status, symbol, strategy_name, strategy_params, portfolio_id, broker, status, symbol,
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,
cash_balance, asset_quantity, asset_price, profit_buffer, 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, portfolio_id,
broker, broker,
status, status,
symbol, symbol,
strategy_name,
json.dumps(strategy_params),
float(cash_balance), float(cash_balance),
float(asset_quantity), float(asset_quantity),
float(asset_price), float(asset_price),
float(profit_buffer), float(profit_buffer),
active_strategy_id,
avabile_strategies,
), ),
) )
row = cur.fetchone() row = cur.fetchone()
@@ -299,19 +295,18 @@ class PostgresPortfolioRepository:
"broker": "broker", "broker": "broker",
"status": "status", "status": "status",
"symbol": "symbol", "symbol": "symbol",
"strategy_name": "strategy_name",
"strategy_params": "strategy_params",
"asset_price": "asset_price", "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] = [] assignments: list[str] = []
params: list[object] = [] params: list[object] = []
for key, value in fields.items(): for key, value in fields.items():
if value is None or key not in allowed: if value is None or key not in allowed:
continue continue
if key == "strategy_params": if key == "avabile_strategies":
assignments.append("strategy_params = %s::jsonb") assignments.append("avabile_strategies = %s")
params.append(json.dumps(value)) params.append(value)
else: else:
assignments.append(f"{allowed[key]} = %s") assignments.append(f"{allowed[key]} = %s")
params.append(value) params.append(value)
@@ -326,9 +321,9 @@ class PostgresPortfolioRepository:
SET {', '.join(assignments)} SET {', '.join(assignments)}
WHERE portfolio_id = %s WHERE portfolio_id = %s
RETURNING RETURNING
portfolio_id, broker, status, symbol, strategy_name, strategy_params, portfolio_id, broker, status, symbol,
cash_balance, asset_quantity, asset_price, profit_buffer, 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), (*params, portfolio_id),
) )
@@ -413,13 +408,12 @@ class PostgresPortfolioRepository:
SET broker = %s, SET broker = %s,
status = %s, status = %s,
symbol = %s, symbol = %s,
strategy_name = %s,
strategy_params = %s::jsonb,
cash_balance = %s, cash_balance = %s,
asset_quantity = %s, asset_quantity = %s,
asset_price = %s, asset_price = %s,
profit_buffer = %s, profit_buffer = %s,
current_strategy_set_id = %s, active_strategy_id = %s,
avabile_strategies = %s,
updated_at = %s, updated_at = %s,
last_trade_at = CASE WHEN %s IN ('BUY', 'SELL') THEN %s ELSE last_trade_at END last_trade_at = CASE WHEN %s IN ('BUY', 'SELL') THEN %s ELSE last_trade_at END
WHERE portfolio_id = %s WHERE portfolio_id = %s
@@ -428,13 +422,12 @@ class PostgresPortfolioRepository:
portfolio.broker, portfolio.broker,
portfolio.status, portfolio.status,
portfolio.symbol, portfolio.symbol,
portfolio.strategy_name,
json.dumps(portfolio.strategy_params),
float(portfolio.cash_balance), float(portfolio.cash_balance),
float(portfolio.asset_quantity), float(portfolio.asset_quantity),
float(current_price), float(current_price),
float(portfolio.profit_buffer), float(portfolio.profit_buffer),
portfolio.current_strategy_set_id, portfolio.active_strategy_id,
portfolio.avabile_strategies,
trade_time, trade_time,
action, action,
trade_time, trade_time,
@@ -496,14 +489,14 @@ class PostgresPortfolioRepository:
asset_quantity = 0, asset_quantity = 0,
asset_price = 0, asset_price = 0,
profit_buffer = 0, profit_buffer = 0,
current_strategy_set_id = NULL, active_strategy_id = NULL,
updated_at = NOW(), updated_at = NOW(),
last_trade_at = NULL last_trade_at = NULL
WHERE portfolio_id = %s WHERE portfolio_id = %s
RETURNING RETURNING
portfolio_id, broker, status, symbol, strategy_name, strategy_params, portfolio_id, broker, status, symbol,
cash_balance, asset_quantity, asset_price, profit_buffer, 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,), (portfolio_id,),
) )
@@ -540,9 +533,9 @@ class PostgresPortfolioRepository:
updated_at = NOW() updated_at = NOW()
WHERE portfolio_id = %s WHERE portfolio_id = %s
RETURNING RETURNING
portfolio_id, broker, status, symbol, strategy_name, strategy_params, portfolio_id, broker, status, symbol,
cash_balance, asset_quantity, asset_price, profit_buffer, 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), (updated_buffer, portfolio_id),
) )
@@ -609,9 +602,9 @@ class PostgresPortfolioRepository:
updated_at = NOW() updated_at = NOW()
WHERE portfolio_id = %s WHERE portfolio_id = %s
RETURNING RETURNING
portfolio_id, broker, status, symbol, strategy_name, strategy_params, portfolio_id, broker, status, symbol,
cash_balance, asset_quantity, asset_price, profit_buffer, 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), (cash, buf, portfolio_id),
) )
+8 -5
View File
@@ -36,8 +36,6 @@ class PortfolioBase(BaseModel):
broker: str = "kis" broker: str = "kis"
status: str = "enabled" status: str = "enabled"
symbol: str symbol: str
strategy_name: str = "target_value"
strategy_params: dict[str, Any] = Field(default_factory=dict)
class PortfolioSummary(BaseModel): class PortfolioSummary(BaseModel):
@@ -45,13 +43,12 @@ class PortfolioSummary(BaseModel):
broker: str broker: str
status: str status: str
symbol: str symbol: str
strategy_name: str
strategy_params: dict[str, Any] = Field(default_factory=dict)
cash_balance: float = 0.0 cash_balance: float = 0.0
asset_quantity: float = 0.0 asset_quantity: float = 0.0
price: float = 0.0 price: float = 0.0
profit_buffer: 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 created_at: datetime | None = None
updated_at: datetime | None = None updated_at: datetime | None = None
last_trade_at: datetime | None = None last_trade_at: datetime | None = None
@@ -70,6 +67,10 @@ class PortfolioDetailResponse(BaseModel):
class CreatePortfolioRequest(PortfolioBase): class CreatePortfolioRequest(PortfolioBase):
portfolio_id: str 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) cash_balance: float = Field(default=0.0, ge=0.0)
asset_quantity: 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) asset_price: float = Field(default=0.0, ge=0.0)
@@ -82,6 +83,8 @@ class UpdatePortfolioRequest(BaseModel):
symbol: str | None = None symbol: str | None = None
strategy_name: str | None = None strategy_name: str | None = None
strategy_params: dict[str, Any] | 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) asset_price: float | None = Field(default=None, ge=0.0)
+60 -17
View File
@@ -45,19 +45,33 @@ def _to_portfolio_summary(portfolio: Portfolio):
broker=portfolio.broker, broker=portfolio.broker,
status=portfolio.status, status=portfolio.status,
symbol=portfolio.symbol, symbol=portfolio.symbol,
strategy_name=portfolio.strategy_name,
strategy_params=portfolio.strategy_params,
cash_balance=portfolio.cash_balance, cash_balance=portfolio.cash_balance,
asset_quantity=portfolio.asset_quantity, asset_quantity=portfolio.asset_quantity,
price=portfolio.price, price=portfolio.price,
profit_buffer=portfolio.profit_buffer, 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, created_at=portfolio.created_at,
updated_at=portfolio.updated_at, updated_at=portfolio.updated_at,
last_trade_at=portfolio.last_trade_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): def _to_strategy_set_response(strategy_set: StrategySet | None):
from app.schemas.portfolio import StrategySetResponse from app.schemas.portfolio import StrategySetResponse
@@ -124,39 +138,68 @@ class PortfolioService:
return _to_detail_response(status_result) return _to_detail_response(status_result)
def create_portfolio(self, payload: CreatePortfolioRequest) -> PortfolioDetailResponse: def create_portfolio(self, payload: CreatePortfolioRequest) -> PortfolioDetailResponse:
strategy_set = self._repository.get_or_create_strategy_set( active_strategy_id = payload.active_strategy_id
payload.strategy_name, if active_strategy_id is None:
json.dumps(payload.strategy_params, sort_keys=True, ensure_ascii=True), 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( portfolio = self._repository.create_portfolio(
payload.portfolio_id, payload.portfolio_id,
broker=payload.broker, broker=payload.broker,
status=payload.status, status=payload.status,
symbol=payload.symbol, symbol=payload.symbol,
strategy_name=payload.strategy_name,
strategy_params=payload.strategy_params,
cash_balance=payload.cash_balance, cash_balance=payload.cash_balance,
asset_quantity=payload.asset_quantity, asset_quantity=payload.asset_quantity,
asset_price=payload.asset_price, asset_price=payload.asset_price,
profit_buffer=payload.profit_buffer, profit_buffer=payload.profit_buffer,
) active_strategy_id=active_strategy_id,
portfolio = self._repository.update_portfolio( avabile_strategies=avabile_strategies,
payload.portfolio_id,
current_strategy_set_id=strategy_set.id,
) )
return self.get_portfolio_status(portfolio_id=portfolio.portfolio_id, recent_trades=5) return self.get_portfolio_status(portfolio_id=portfolio.portfolio_id, recent_trades=5)
def update_portfolio(self, portfolio_id: str, payload: UpdatePortfolioRequest) -> PortfolioDetailResponse: def update_portfolio(self, portfolio_id: str, payload: UpdatePortfolioRequest) -> PortfolioDetailResponse:
fields = payload.model_dump(exclude_none=True) fields = payload.model_dump(exclude_none=True)
current = self._repository.get(portfolio_id)
if "strategy_name" in fields or "strategy_params" in fields: if "strategy_name" in fields or "strategy_params" in fields:
current = self._repository.get(portfolio_id) current_strategy_set = None
strategy_name = fields.get("strategy_name", current.strategy_name) if current.active_strategy_id:
strategy_params = fields.get("strategy_params", current.strategy_params) 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_set = self._repository.get_or_create_strategy_set(
strategy_name, strategy_name,
json.dumps(strategy_params, sort_keys=True, ensure_ascii=True), 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) self._repository.update_portfolio(portfolio_id, **fields)
return self.get_portfolio_status(portfolio_id=portfolio_id, recent_trades=5) return self.get_portfolio_status(portfolio_id=portfolio_id, recent_trades=5)
+6 -6
View File
@@ -51,13 +51,12 @@ Response 200:
"broker": "upbit", "broker": "upbit",
"status": "enabled", "status": "enabled",
"symbol": "KRW-BTC", "symbol": "KRW-BTC",
"strategy_name": "target_value",
"strategy_params": {},
"cash_balance": 1000000.0, "cash_balance": 1000000.0,
"asset_quantity": 0.1, "asset_quantity": 0.1,
"price": 100000000.0, "price": 100000000.0,
"profit_buffer": 0.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", "created_at": "2026-04-03T00:00:00Z",
"updated_at": "2026-04-03T00:00:00Z", "updated_at": "2026-04-03T00:00:00Z",
"last_trade_at": "2026-04-03T00:00:00Z", "last_trade_at": "2026-04-03T00:00:00Z",
@@ -84,13 +83,12 @@ Response 200:
"broker": "upbit", "broker": "upbit",
"status": "enabled", "status": "enabled",
"symbol": "KRW-BTC", "symbol": "KRW-BTC",
"strategy_name": "target_value",
"strategy_params": {},
"cash_balance": 1000000.0, "cash_balance": 1000000.0,
"asset_quantity": 0.1, "asset_quantity": 0.1,
"price": 100000000.0, "price": 100000000.0,
"profit_buffer": 0.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", "created_at": "2026-04-03T00:00:00Z",
"updated_at": "2026-04-03T00:00:00Z", "updated_at": "2026-04-03T00:00:00Z",
"last_trade_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", "symbol": "KRW-BTC",
"strategy_name": "target_value", "strategy_name": "target_value",
"strategy_params": {}, "strategy_params": {},
"active_strategy_id": null,
"avabile_strategies": null,
"cash_balance": 1000000, "cash_balance": 1000000,
"asset_quantity": 0, "asset_quantity": 0,
"asset_price": 0, "asset_price": 0,
+14 -4
View File
@@ -5,10 +5,11 @@
This document defines the database schema in an AI-agent-friendly format. This document defines the database schema in an AI-agent-friendly format.
- Source of truth: Alembic revisions in `migrations/versions` - Source of truth: Alembic revisions in `migrations/versions`
- Current baseline revision: `20260403_0002` - Current baseline revision: `20260403_0003`
- Baseline files: - Baseline files:
- `migrations/versions/20260403_0001_initial_portfolio_schema.py` - `migrations/versions/20260403_0001_initial_portfolio_schema.py`
- `migrations/versions/20260403_0002_strategy_set_columns.py` - `migrations/versions/20260403_0002_strategy_set_columns.py`
- `migrations/versions/20260403_0003_portfolio_strategy_refs.py`
## 2. Scope ## 2. Scope
@@ -57,6 +58,10 @@ Purpose:
- Stores current portfolio state and strategy linkage. - Stores current portfolio state and strategy linkage.
Current baseline note:
- The columns below describe the currently migrated schema through revision `20260403_0003`.
Columns: Columns:
| Column | Type | Null | Default | Constraints / Notes | | Column | Type | Null | Default | Constraints / Notes |
@@ -65,13 +70,12 @@ Columns:
| broker | Text | NO | "kis" | Broker code | | broker | Text | NO | "kis" | Broker code |
| status | Text | NO | "enabled" | Portfolio status | | status | Text | NO | "enabled" | Portfolio status |
| symbol | Text | NO | - | Trading symbol | | 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 | | cash_balance | Float | NO | - | Current cash balance |
| asset_quantity | Float | NO | - | Current asset quantity | | asset_quantity | Float | NO | - | Current asset quantity |
| asset_price | Float | NO | 0 | Current price snapshot | | asset_price | Float | NO | 0 | Current price snapshot |
| profit_buffer | Float | NO | 0 | Profit buffer amount | | 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 | | created_at | DateTime(timezone=True) | NO | NOW() | Creation timestamp |
| updated_at | DateTime(timezone=True) | NO | NOW() | Last update timestamp | | updated_at | DateTime(timezone=True) | NO | NOW() | Last update timestamp |
| last_trade_at | DateTime(timezone=True) | YES | - | Last trade time | | last_trade_at | DateTime(timezone=True) | YES | - | Last trade time |
@@ -81,6 +85,11 @@ Constraints:
- Primary key: `portfolio_id` - Primary key: `portfolio_id`
- Foreign key constraints: none in baseline - 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 ### 4.3 portfolio_ledger
Purpose: 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_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_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` |
+20
View File
@@ -224,6 +224,8 @@ Base routes:
- `symbol: str` - `symbol: str`
- `strategy_name: str = "target_value"` - `strategy_name: str = "target_value"`
- `strategy_params: object = {}` - `strategy_params: object = {}`
- `active_strategy_id: str | null`
- `avabile_strategies: string[] | null`
- `cash_balance: float >= 0` - `cash_balance: float >= 0`
- `asset_quantity: float >= 0` - `asset_quantity: float >= 0`
- `asset_price: float >= 0` - `asset_price: float >= 0`
@@ -236,6 +238,8 @@ Base routes:
- `symbol: str | null` - `symbol: str | null`
- `strategy_name: str | null` - `strategy_name: str | null`
- `strategy_params: object | null` - `strategy_params: object | null`
- `active_strategy_id: str | null`
- `avabile_strategies: string[] | null`
- `asset_price: float >= 0 | null` - `asset_price: float >= 0 | null`
`BuyRequest` / `SellRequest` `BuyRequest` / `SellRequest`
@@ -268,6 +272,22 @@ Base routes:
- Includes portfolio summary fields and computed `total_value` - 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` `PortfolioDetailResponse`
- `portfolio: PortfolioSummary` - `portfolio: PortfolioSummary`
@@ -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")
+4
View File
@@ -13,6 +13,7 @@ def _client_with_tokens() -> TestClient:
def test_read_route_requires_token(monkeypatch) -> None: def test_read_route_requires_token(monkeypatch) -> None:
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "false")
monkeypatch.setenv("PORTFOLIOHUB_READ_TOKENS", "read-token") monkeypatch.setenv("PORTFOLIOHUB_READ_TOKENS", "read-token")
get_settings.cache_clear() get_settings.cache_clear()
client = _client_with_tokens() 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: def test_read_route_accepts_read_token(monkeypatch) -> None:
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "false")
monkeypatch.setenv("PORTFOLIOHUB_READ_TOKENS", "read-token") monkeypatch.setenv("PORTFOLIOHUB_READ_TOKENS", "read-token")
get_settings.cache_clear() get_settings.cache_clear()
client = _client_with_tokens() 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: 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_READ_TOKENS", "read-token")
monkeypatch.setenv("PORTFOLIOHUB_TRADE_WRITE_TOKENS", "trade-token") monkeypatch.setenv("PORTFOLIOHUB_TRADE_WRITE_TOKENS", "trade-token")
get_settings.cache_clear() 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: def test_admin_route_accepts_admin_token(monkeypatch) -> None:
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "false")
monkeypatch.setenv("PORTFOLIOHUB_ADMIN_TOKENS", "admin-token") monkeypatch.setenv("PORTFOLIOHUB_ADMIN_TOKENS", "admin-token")
get_settings.cache_clear() get_settings.cache_clear()
client = _client_with_tokens() client = _client_with_tokens()
+11 -12
View File
@@ -39,8 +39,8 @@ class FakeRepo:
*, *,
broker: str = "kis", broker: str = "kis",
status: str = "enabled", status: str = "enabled",
strategy_name: str = "", active_strategy_id: str | None = None,
strategy_params: dict | None = None, avabile_strategies: list[str] | None = None,
initial_cash: float = 0.0, initial_cash: float = 0.0,
) -> Portfolio: ) -> Portfolio:
if self._portfolio is None: if self._portfolio is None:
@@ -49,9 +49,9 @@ class FakeRepo:
broker=broker, broker=broker,
status=status, status=status,
symbol=symbol, symbol=symbol,
strategy_name=strategy_name,
strategy_params=strategy_params or {},
cash_balance=initial_cash, cash_balance=initial_cash,
active_strategy_id=active_strategy_id,
avabile_strategies=avabile_strategies,
) )
return self._portfolio return self._portfolio
@@ -62,24 +62,24 @@ class FakeRepo:
broker: str, broker: str,
status: str, status: str,
symbol: str, symbol: str,
strategy_name: str,
strategy_params: dict,
cash_balance: float, cash_balance: float,
asset_quantity: float, asset_quantity: float,
asset_price: float, asset_price: float,
profit_buffer: float, profit_buffer: float,
active_strategy_id: str | None,
avabile_strategies: list[str] | None,
) -> Portfolio: ) -> Portfolio:
self._portfolio = Portfolio( self._portfolio = Portfolio(
portfolio_id=portfolio_id, portfolio_id=portfolio_id,
broker=broker, broker=broker,
status=status, status=status,
symbol=symbol, symbol=symbol,
strategy_name=strategy_name,
strategy_params=strategy_params,
cash_balance=cash_balance, cash_balance=cash_balance,
asset_quantity=asset_quantity, asset_quantity=asset_quantity,
price=asset_price, price=asset_price,
profit_buffer=profit_buffer, profit_buffer=profit_buffer,
active_strategy_id=active_strategy_id,
avabile_strategies=avabile_strategies,
) )
return self._portfolio return self._portfolio
@@ -149,7 +149,7 @@ class FakeRepo:
portfolio.asset_quantity = 0.0 portfolio.asset_quantity = 0.0
portfolio.price = 0.0 portfolio.price = 0.0
portfolio.profit_buffer = 0.0 portfolio.profit_buffer = 0.0
portfolio.current_strategy_set_id = None portfolio.active_strategy_id = None
self.entries = [] self.entries = []
return portfolio return portfolio
@@ -194,13 +194,12 @@ def _portfolio() -> Portfolio:
broker="kis", broker="kis",
status="enabled", status="enabled",
symbol="005930", symbol="005930",
strategy_name="target_value",
strategy_params={"target_asset_ratio": 0.7},
cash_balance=1_000_000.0, cash_balance=1_000_000.0,
asset_quantity=1.0, asset_quantity=1.0,
price=70_000.0, price=70_000.0,
profit_buffer=50_000.0, profit_buffer=50_000.0,
current_strategy_set_id="set-1", active_strategy_id="set-1",
avabile_strategies=["set-1"],
) )
+7 -8
View File
@@ -27,8 +27,8 @@ class FakeRepo:
*, *,
broker: str = "kis", broker: str = "kis",
status: str = "enabled", status: str = "enabled",
strategy_name: str = "", active_strategy_id: str | None = None,
strategy_params: dict | None = None, avabile_strategies: list[str] | None = None,
initial_cash: float = 0.0, initial_cash: float = 0.0,
) -> Portfolio: ) -> Portfolio:
self._portfolio = Portfolio( self._portfolio = Portfolio(
@@ -36,9 +36,9 @@ class FakeRepo:
broker=broker, broker=broker,
status=status, status=status,
symbol=symbol, symbol=symbol,
strategy_name=strategy_name,
strategy_params=strategy_params or {},
cash_balance=initial_cash, cash_balance=initial_cash,
active_strategy_id=active_strategy_id,
avabile_strategies=avabile_strategies,
) )
return self._portfolio return self._portfolio
@@ -107,7 +107,7 @@ class FakeRepo:
portfolio.asset_quantity = 0.0 portfolio.asset_quantity = 0.0
portfolio.price = 0.0 portfolio.price = 0.0
portfolio.profit_buffer = 0.0 portfolio.profit_buffer = 0.0
portfolio.current_strategy_set_id = None portfolio.active_strategy_id = None
self.entries = [] self.entries = []
return portfolio return portfolio
@@ -146,13 +146,12 @@ def _portfolio() -> Portfolio:
broker="kis", broker="kis",
status="enabled", status="enabled",
symbol="005930", symbol="005930",
strategy_name="target_value",
strategy_params={"target_asset_ratio": 0.7},
cash_balance=1_000_000.0, cash_balance=1_000_000.0,
asset_quantity=0.0, asset_quantity=0.0,
price=70_000.0, price=70_000.0,
profit_buffer=0.0, profit_buffer=0.0,
current_strategy_set_id="set-1", active_strategy_id="set-1",
avabile_strategies=["set-1"],
) )