Add ProofLine criteria documentation and Strategy Catalog v1 implementation

- Add acceptance criteria documentation (AC-0013 through AC-0023)
- Add Line-0003 discovery documentation
- Implement Strategy Catalog v1 with new database migration
- Update portfolio API endpoints and schemas
- Refactor strategy and portfolio services
- Add strategy management CLI commands
- Update domain models with strategy catalog support
- Add comprehensive tests for v1 strategies
- Update design documentation (data model, interfaces, runtime flows)
This commit is contained in:
2026-08-15 11:18:31 +09:00
parent 5fc389dea8
commit 6e02d92282
48 changed files with 2114 additions and 393 deletions
+140 -33
View File
@@ -10,13 +10,14 @@ from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
logger = logging.getLogger(__name__)
import psycopg
from psycopg import sql
from app.domain.errors import StrategyInactiveError, StrategyNotFoundError
from app.domain.models import Asset, AuthToken, BrokerCredential, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, Tag, TokenPrincipal, User
logger = logging.getLogger(__name__)
_SELECT_USER = """
SELECT
id,
@@ -71,7 +72,8 @@ _SELECT_PORTFOLIO = """
a.display_name,
ss.name,
ss.config,
a.markets
a.markets,
ss.revision
FROM portfolios p
JOIN assets a ON p.asset_id = a.asset_id
LEFT JOIN strategy_sets ss ON p.active_strategy_id = ss.id
@@ -144,6 +146,19 @@ class PostgresPortfolioRepository:
cur.execute("SELECT 1")
cur.fetchone()
@staticmethod
def _lock_active_strategy(cur: psycopg.Cursor, strategy_id: str) -> None:
"""Serialize new selections with strategy PATCH/DELETE and enforce active status."""
cur.execute(
"SELECT status FROM strategy_sets WHERE id = %s FOR SHARE",
(strategy_id,),
)
row = cur.fetchone()
if row is None:
raise StrategyNotFoundError(f"strategy not found: {strategy_id}")
if row[0] != "active":
raise StrategyInactiveError(f"strategy is inactive: {strategy_id}")
def ensure_schema(self) -> None:
conn = self._connect()
with conn.cursor() as cur:
@@ -208,11 +223,17 @@ class PostgresPortfolioRepository:
name TEXT NOT NULL,
config TEXT NOT NULL DEFAULT '',
nick TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'active',
revision INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (name, config)
)
"""
)
cur.execute("ALTER TABLE strategy_sets ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active'")
cur.execute("ALTER TABLE strategy_sets ADD COLUMN IF NOT EXISTS revision INTEGER NOT NULL DEFAULT 1")
cur.execute("ALTER TABLE strategy_sets ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()")
cur.execute(
"""
CREATE TABLE IF NOT EXISTS assets (
@@ -530,6 +551,7 @@ class PostgresPortfolioRepository:
# REQ-055: markets (row[21] = a.markets from assets JOIN)
_markets_raw = row[21] if len(row) > 21 else None
markets: list[str] = list(_markets_raw) if _markets_raw else []
strategy_revision = int(row[22]) if len(row) > 22 and row[22] is not None else None
return Portfolio(
portfolio_id=row[0],
@@ -554,6 +576,7 @@ class PostgresPortfolioRepository:
display_name=row[18] if len(row) > 18 else None,
strategy_name=strategy_name,
strategy_params=strategy_params,
strategy_revision=strategy_revision,
markets=markets,
)
@@ -578,6 +601,21 @@ class PostgresPortfolioRepository:
tag_ids=[str(t) for t in row[7]] if len(row) > 7 and row[7] is not None else [],
)
@staticmethod
def _row_to_strategy_set(row: Any) -> StrategySet:
if row is None:
raise ValueError("전략을 찾을 수 없습니다")
return StrategySet(
id=str(row[0]),
name=str(row[1]),
config=str(row[2]),
nick=str(row[3]),
status=str(row[4]),
revision=int(row[5]),
created_at=row[6],
updated_at=row[7],
)
@staticmethod
def _row_to_tag(row: Any) -> Tag:
if row is None:
@@ -1037,7 +1075,10 @@ class PostgresPortfolioRepository:
initial_cash: float = 0.0,
) -> Portfolio:
self.ensure_schema()
with self._connect().cursor() as cur:
conn = self._connect()
with conn.transaction(), conn.cursor() as cur:
if active_strategy_id is not None:
self._lock_active_strategy(cur, active_strategy_id)
cur.execute("SELECT asset_id FROM assets WHERE symbol = %s", (symbol,))
row = cur.fetchone()
if row is None:
@@ -1089,7 +1130,10 @@ class PostgresPortfolioRepository:
avabile_strategies: list[str] | None,
) -> Portfolio:
self.ensure_schema()
with self._connect().cursor() as cur:
conn = self._connect()
with conn.transaction(), conn.cursor() as cur:
if active_strategy_id is not None:
self._lock_active_strategy(cur, active_strategy_id)
cur.execute("SELECT 1 FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
if cur.fetchone() is not None:
raise ValueError(f"이미 존재하는 포트폴리오입니다: {portfolio_id}")
@@ -1146,7 +1190,11 @@ class PostgresPortfolioRepository:
return self.get(portfolio_id)
assignments.append("updated_at = NOW()")
with self._connect().cursor() as cur:
conn = self._connect()
with conn.transaction(), conn.cursor() as cur:
strategy_id = fields.get("active_strategy_id")
if isinstance(strategy_id, str):
self._lock_active_strategy(cur, strategy_id)
cur.execute(
f"""
UPDATE portfolios
@@ -1276,54 +1324,120 @@ class PostgresPortfolioRepository:
raise ValueError("태그를 찾을 수 없습니다")
def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet:
return self.create_strategy_set(name, config)
def create_strategy_set(self, name: str, config: str, *, status: str = "active") -> StrategySet:
self.ensure_schema()
nick = f"{name}:{hashlib.sha1(config.encode('utf-8')).hexdigest()[:12]}"
with self._connect().cursor() as cur:
new_id = str(uuid.uuid4())
cur.execute(
"""
INSERT INTO strategy_sets (id, name, config, nick)
VALUES (%s, %s, %s, %s)
INSERT INTO strategy_sets (id, name, config, nick, status)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (name, config) DO NOTHING
""",
(new_id, name, config, nick),
(new_id, name, config, nick, status),
)
cur.execute(
"""
SELECT id, name, config, nick, created_at
SELECT id, name, config, nick, status, revision, created_at, updated_at
FROM strategy_sets
WHERE name = %s AND config = %s
""",
(name, config),
)
row = cur.fetchone()
return StrategySet(id=row[0], name=row[1], config=row[2], nick=row[3], created_at=row[4])
return self._row_to_strategy_set(row)
def get_strategy_set(self, id: str) -> StrategySet | None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"SELECT id, name, config, nick, created_at FROM strategy_sets WHERE id = %s",
"SELECT id, name, config, nick, status, revision, created_at, updated_at FROM strategy_sets WHERE id = %s",
(id,),
)
row = cur.fetchone()
if row is None:
return None
return StrategySet(id=row[0], name=row[1], config=row[2], nick=row[3], created_at=row[4])
return self._row_to_strategy_set(row)
def list_strategy_sets(self) -> list[StrategySet]:
def list_strategy_sets(self, *, include_inactive: bool = True) -> list[StrategySet]:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"SELECT id, name, config, nick, created_at FROM strategy_sets ORDER BY created_at DESC"
)
where = "" if include_inactive else "WHERE status = 'active'"
cur.execute(f"""
SELECT id, name, config, nick, status, revision, created_at, updated_at
FROM strategy_sets
{where}
ORDER BY created_at DESC, id ASC
""")
rows = cur.fetchall()
return [StrategySet(id=row[0], name=row[1], config=row[2], nick=row[3], created_at=row[4]) for row in rows]
return [self._row_to_strategy_set(row) for row in rows]
def update_strategy_set(
self,
id: str,
*,
name: str | None = None,
config: str | None = None,
status: str | None = None,
) -> StrategySet:
self.ensure_schema()
conn = self._connect()
try:
with conn.transaction(), conn.cursor() as cur:
cur.execute(
"""
SELECT id, name, config, nick, status, revision, created_at, updated_at
FROM strategy_sets
WHERE id = %s
FOR UPDATE
""",
(id,),
)
row = cur.fetchone()
if row is None:
raise ValueError("전략을 찾을 수 없습니다")
current = self._row_to_strategy_set(row)
final_name = name if name is not None else current.name
final_config = config if config is not None else current.config
final_status = status if status is not None else current.status
if (
final_name == current.name
and final_config == current.config
and final_status == current.status
):
return current
nick = f"{final_name}:{hashlib.sha1(final_config.encode('utf-8')).hexdigest()[:12]}"
cur.execute(
"""
UPDATE strategy_sets
SET name = %s,
config = %s,
nick = %s,
status = %s,
revision = revision + 1,
updated_at = NOW()
WHERE id = %s
RETURNING id, name, config, nick, status, revision, created_at, updated_at
""",
(final_name, final_config, nick, final_status, id),
)
updated = cur.fetchone()
except psycopg.errors.UniqueViolation as exc:
raise ValueError("동일한 name/config 전략이 이미 존재합니다") from exc
if updated is None:
raise ValueError("전략을 찾을 수 없습니다")
return self._row_to_strategy_set(updated)
def delete_strategy_set(self, id: str) -> None:
self.ensure_schema()
with self._connect().cursor() as cur:
# 1. Check if this strategy set is referenced by any portfolio
conn = self._connect()
with conn.transaction(), conn.cursor() as cur:
cur.execute("SELECT id FROM strategy_sets WHERE id = %s FOR UPDATE", (id,))
if cur.fetchone() is None:
raise ValueError("전략을 찾을 수 없습니다")
cur.execute(
"""
SELECT COUNT(*) FROM portfolios
@@ -1335,9 +1449,9 @@ class PostgresPortfolioRepository:
count = cur.fetchone()[0]
if count > 0:
raise ValueError(f"Strategy set is in use by {count} portfolio(s) and cannot be deleted")
# 2. If not in use, proceed with deletion
cur.execute("DELETE FROM strategy_sets WHERE id = %s", (id,))
cur.execute("DELETE FROM strategy_sets WHERE id = %s RETURNING id", (id,))
if cur.fetchone() is None:
raise ValueError("전략을 찾을 수 없습니다")
def save(
self,
@@ -1355,31 +1469,24 @@ class PostgresPortfolioRepository:
) -> None:
self.ensure_schema()
trade_time = datetime.now(timezone.utc)
with self._connect().cursor() as cur:
conn = self._connect()
with conn.transaction(), conn.cursor() as cur:
cur.execute(
"""
UPDATE portfolios
SET broker = %s,
status = %s,
cash_balance = %s,
SET cash_balance = %s,
asset_quantity = %s,
asset_price = %s,
profit_buffer = %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
""",
(
portfolio.broker,
portfolio.status,
float(portfolio.cash_balance),
float(portfolio.asset_quantity),
float(current_price),
float(portfolio.profit_buffer),
portfolio.active_strategy_id,
portfolio.avabile_strategies,
trade_time,
action,
trade_time,