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"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)}",
+4 -5
View File
@@ -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
+12 -14
View File
@@ -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:
+4 -4
View File
@@ -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: ...
+56 -63
View File
@@ -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),
)
+8 -5
View File
@@ -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)
+60 -17
View File
@@ -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)
+6 -6
View File
@@ -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,
+14 -4
View File
@@ -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` |
+20
View File
@@ -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`
@@ -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:
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()
+11 -12
View File
@@ -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"],
)
+7 -8
View File
@@ -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"],
)