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:
+140
-33
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user