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:
@@ -36,6 +36,7 @@ from app.schemas.portfolio import (
|
||||
TransferRequest,
|
||||
UpdatePortfolioRequest,
|
||||
)
|
||||
from app.services.strategy_set_service import StrategyInactiveError, StrategyNotFoundError
|
||||
|
||||
|
||||
def _infer_symbol(portfolio_id: str) -> str:
|
||||
@@ -59,6 +60,9 @@ def _to_portfolio_summary(portfolio: Portfolio):
|
||||
price=portfolio.price,
|
||||
profit_buffer=portfolio.profit_buffer,
|
||||
active_strategy_id=portfolio.active_strategy_id,
|
||||
strategy_name=portfolio.strategy_name,
|
||||
strategy_params=portfolio.strategy_params,
|
||||
strategy_revision=portfolio.strategy_revision,
|
||||
avabile_strategies=portfolio.avabile_strategies,
|
||||
managed_by_daemon_id=portfolio.managed_by_daemon_id,
|
||||
created_at=portfolio.created_at,
|
||||
@@ -92,7 +96,10 @@ def _to_strategy_set_response(strategy_set: StrategySet | None):
|
||||
name=strategy_set.name,
|
||||
config=strategy_set.config,
|
||||
nick=strategy_set.nick,
|
||||
status=strategy_set.status,
|
||||
revision=strategy_set.revision,
|
||||
created_at=strategy_set.created_at,
|
||||
updated_at=strategy_set.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@@ -213,14 +220,12 @@ class PortfolioService:
|
||||
if asset.status != "active":
|
||||
raise ValueError(f"asset is inactive: {payload.asset_id}")
|
||||
|
||||
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)
|
||||
strategy_set = self._repository.get_strategy_set(payload.strategy_id)
|
||||
if strategy_set is None:
|
||||
raise StrategyNotFoundError(f"strategy not found: {payload.strategy_id}")
|
||||
if strategy_set.status != "active":
|
||||
raise StrategyInactiveError(f"strategy is inactive: {payload.strategy_id}")
|
||||
active_strategy_id = strategy_set.id
|
||||
portfolio = self._repository.create_portfolio(
|
||||
str(uuid.uuid4()),
|
||||
name=payload.name,
|
||||
@@ -233,7 +238,7 @@ class PortfolioService:
|
||||
asset_price=payload.asset_price,
|
||||
profit_buffer=payload.profit_buffer,
|
||||
active_strategy_id=active_strategy_id,
|
||||
avabile_strategies=avabile_strategies,
|
||||
avabile_strategies=[active_strategy_id],
|
||||
)
|
||||
return self.get_portfolio_status(portfolio_id=portfolio.portfolio_id, recent_trades=5)
|
||||
|
||||
@@ -241,42 +246,19 @@ class PortfolioService:
|
||||
fields = payload.model_dump(exclude_none=True)
|
||||
current = self._repository.get(portfolio_id)
|
||||
|
||||
if "strategy_name" in fields or "strategy_params" in fields:
|
||||
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["active_strategy_id"] = strategy_set.id
|
||||
if "strategy_id" in fields:
|
||||
strategy_id = str(fields.pop("strategy_id"))
|
||||
strategy_set = self._repository.get_strategy_set(strategy_id)
|
||||
if strategy_set is None:
|
||||
raise StrategyNotFoundError(f"strategy not found: {strategy_id}")
|
||||
if strategy_set.status != "active":
|
||||
raise StrategyInactiveError(f"strategy is inactive: {strategy_id}")
|
||||
fields["active_strategy_id"] = strategy_id
|
||||
fields["avabile_strategies"] = _merge_strategy_ids(
|
||||
strategy_set.id,
|
||||
fields.get("avabile_strategies", current.avabile_strategies),
|
||||
strategy_id,
|
||||
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)
|
||||
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
"""Strategy set service orchestration."""
|
||||
"""Strategy catalog service orchestration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import cast
|
||||
|
||||
from fastapi import Depends
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.domain.errors import StrategyInactiveError, StrategyNotFoundError
|
||||
from app.domain.models import StrategySet
|
||||
from app.repositories.factory import get_portfolio_repository_from_settings
|
||||
from app.repositories.portfolio_repository import PortfolioRepository
|
||||
from app.schemas.portfolio import CreateStrategySetRequest, UpdateStrategySetRequest
|
||||
|
||||
|
||||
class StrategyInUseError(ValueError):
|
||||
"""The strategy is referenced by at least one portfolio."""
|
||||
|
||||
|
||||
class StrategyConflictError(ValueError):
|
||||
"""The requested catalog mutation conflicts with another strategy."""
|
||||
|
||||
|
||||
def get_strategy_set_repository(settings: Settings = Depends(get_settings)) -> PortfolioRepository:
|
||||
return get_portfolio_repository_from_settings(settings)
|
||||
|
||||
@@ -19,56 +31,58 @@ class StrategySetService:
|
||||
def __init__(self, repository: PortfolioRepository) -> None:
|
||||
self._repository = repository
|
||||
|
||||
def list(self) -> list[StrategySet]:
|
||||
"""List all strategy sets."""
|
||||
return self._repository.list_strategy_sets()
|
||||
def list(self, *, include_inactive: bool = True) -> list[StrategySet]:
|
||||
return self._repository.list_strategy_sets(include_inactive=include_inactive)
|
||||
|
||||
def get(self, strategy_set_id: str) -> StrategySet | None:
|
||||
"""Get a single strategy set by ID."""
|
||||
return self._repository.get_strategy_set(strategy_set_id)
|
||||
def get(self, strategy_set_id: str, *, active_only: bool = False) -> StrategySet:
|
||||
strategy = self._repository.get_strategy_set(strategy_set_id)
|
||||
if strategy is None:
|
||||
raise StrategyNotFoundError(f"strategy not found: {strategy_set_id}")
|
||||
if active_only and strategy.status != "active":
|
||||
raise StrategyInactiveError(f"strategy is inactive: {strategy_set_id}")
|
||||
return strategy
|
||||
|
||||
def require_active(self, strategy_set_id: str) -> StrategySet:
|
||||
return self.get(strategy_set_id, active_only=True)
|
||||
|
||||
def create(self, payload: CreateStrategySetRequest) -> StrategySet:
|
||||
"""Create or get existing strategy set.
|
||||
|
||||
Uses get_or_create_strategy_set for idempotence.
|
||||
"""
|
||||
return self._repository.get_or_create_strategy_set(payload.name, payload.config)
|
||||
create_strategy = cast(
|
||||
Callable[..., StrategySet] | None,
|
||||
getattr(self._repository, "create_strategy_set", None),
|
||||
)
|
||||
if create_strategy is not None:
|
||||
strategy = create_strategy(payload.name, payload.config, status=payload.status)
|
||||
else:
|
||||
strategy = self._repository.get_or_create_strategy_set(payload.name, payload.config)
|
||||
if payload.status != strategy.status:
|
||||
strategy = self.update(
|
||||
strategy.id,
|
||||
UpdateStrategySetRequest(status=payload.status),
|
||||
)
|
||||
return strategy
|
||||
|
||||
def update(self, strategy_set_id: str, payload: UpdateStrategySetRequest) -> StrategySet:
|
||||
"""Update a strategy set.
|
||||
|
||||
Since strategy sets are identified by (name, config) uniqueness,
|
||||
updating requires creating a new one if name/config changes.
|
||||
"""
|
||||
current = self.get(strategy_set_id)
|
||||
if current is None:
|
||||
raise ValueError(f"Strategy set {strategy_set_id} not found")
|
||||
|
||||
# Use provided values or fall back to current ones
|
||||
new_name = payload.name if payload.name is not None else current.name
|
||||
new_config = payload.config if payload.config is not None else current.config
|
||||
|
||||
# If nothing changed, return current
|
||||
if new_name == current.name and new_config == current.config:
|
||||
return current
|
||||
|
||||
# Create or get the new strategy set
|
||||
new_strategy_set = self._repository.get_or_create_strategy_set(new_name, new_config)
|
||||
|
||||
# If the new strategy set is different from current, delete the old one
|
||||
# (only if it's not in use - delete_strategy_set will raise if in use)
|
||||
if new_strategy_set.id != strategy_set_id:
|
||||
try:
|
||||
self._repository.delete_strategy_set(strategy_set_id)
|
||||
except ValueError:
|
||||
# If old strategy set is in use, keep it and return new one
|
||||
pass
|
||||
|
||||
return new_strategy_set
|
||||
try:
|
||||
return self._repository.update_strategy_set(
|
||||
strategy_set_id,
|
||||
name=payload.name,
|
||||
config=payload.config,
|
||||
status=payload.status,
|
||||
)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if "찾을 수 없습니다" in detail:
|
||||
raise StrategyNotFoundError(f"strategy not found: {strategy_set_id}") from exc
|
||||
raise StrategyConflictError(detail) from exc
|
||||
|
||||
def delete(self, strategy_set_id: str) -> None:
|
||||
"""Delete a strategy set.
|
||||
|
||||
Raises ValueError if the strategy set is in use by any portfolio.
|
||||
"""
|
||||
self._repository.delete_strategy_set(strategy_set_id)
|
||||
self.get(strategy_set_id)
|
||||
try:
|
||||
self._repository.delete_strategy_set(strategy_set_id)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if "in use" in detail:
|
||||
raise StrategyInUseError(detail) from exc
|
||||
if "찾을 수 없습니다" in detail:
|
||||
raise StrategyNotFoundError(f"strategy not found: {strategy_set_id}") from exc
|
||||
raise
|
||||
|
||||
Reference in New Issue
Block a user