feat: add strategy set CRUD operations (API + CLI)

This commit is contained in:
2026-04-03 08:37:04 +00:00
parent 908068d9aa
commit 1b0991c9cf
12 changed files with 510 additions and 5 deletions
+87
View File
@@ -0,0 +1,87 @@
"""Strategy Set API routes."""
from fastapi import APIRouter, Depends, Response, status
from app.core.auth import require_read, require_trade_write
from app.schemas.portfolio import CreateStrategySetRequest, StrategySetResponse, UpdateStrategySetRequest
from app.services.strategy_set_service import StrategySetService, get_strategy_set_repository
def get_strategy_set_service(repository=Depends(get_strategy_set_repository)) -> StrategySetService:
return StrategySetService(repository)
router = APIRouter(prefix="/strategy-sets", tags=["strategy-sets"])
@router.get("", response_model=list[StrategySetResponse], dependencies=[Depends(require_read)])
def list_strategy_sets(
service: StrategySetService = Depends(get_strategy_set_service),
) -> list[StrategySetResponse]:
"""List all strategy sets."""
strategy_sets = service.list()
return [
StrategySetResponse(
id=ss.id,
name=ss.name,
config=ss.config,
nick=ss.nick,
created_at=ss.created_at,
)
for ss in strategy_sets
]
@router.post("", response_model=StrategySetResponse, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_trade_write)])
def create_strategy_set(
payload: CreateStrategySetRequest,
service: StrategySetService = Depends(get_strategy_set_service),
) -> StrategySetResponse:
"""Create a new strategy set (or return existing if (name, config) pair already exists)."""
strategy_set = service.create(payload)
return StrategySetResponse(
id=strategy_set.id,
name=strategy_set.name,
config=strategy_set.config,
nick=strategy_set.nick,
created_at=strategy_set.created_at,
)
@router.patch("/{strategy_set_id}", response_model=StrategySetResponse, dependencies=[Depends(require_trade_write)])
def update_strategy_set(
strategy_set_id: str,
payload: UpdateStrategySetRequest,
service: StrategySetService = Depends(get_strategy_set_service),
) -> StrategySetResponse:
"""Update a strategy set's name and/or config.
Note: Since strategy sets are identified by (name, config) uniqueness,
updating may result in a new strategy set ID if name or config changes.
"""
strategy_set = service.update(strategy_set_id, payload)
return StrategySetResponse(
id=strategy_set.id,
name=strategy_set.name,
config=strategy_set.config,
nick=strategy_set.nick,
created_at=strategy_set.created_at,
)
@router.delete("/{strategy_set_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_trade_write)])
def delete_strategy_set(
strategy_set_id: str,
service: StrategySetService = Depends(get_strategy_set_service),
) -> Response:
"""Delete a strategy set.
Returns 204 No Content on success.
Returns 409 Conflict if the strategy set is in use by any portfolio.
"""
try:
service.delete(strategy_set_id)
except ValueError as e:
# If strategy set is in use, return 409 Conflict
raise ValueError(str(e)) from e
return Response(status_code=status.HTTP_204_NO_CONTENT)
+121
View File
@@ -0,0 +1,121 @@
"""Strategy Set CLI commands."""
from __future__ import annotations
import typer
from app.cli.core.utils import execute_command, render_output
from app.services.strategy_set_service import StrategySetService
strategy_app = typer.Typer(help="Strategy set management commands.")
def _with_service(ctx: typer.Context, callback):
app_context = ctx.obj
repository = app_context.repository_factory(app_context.settings)
service = StrategySetService(repository)
try:
result = execute_command(app_context.console, callback=lambda: callback(service, app_context))
return result
finally:
if hasattr(repository, "close"):
repository.close()
def _print_strategy_set_table(strategy_sets):
"""Print strategy sets as a simple table."""
if not strategy_sets:
print("No strategy sets found.")
return
# Print header
print(f"{'ID':<40} {'Name':<20} {'Nick':<20} {'Created At':<26}")
print("-" * 106)
# Print rows
for ss in strategy_sets:
created_at = ss.created_at.isoformat() if ss.created_at else "N/A"
print(f"{ss.id:<40} {ss.name:<20} {ss.nick:<20} {created_at:<26}")
@strategy_app.command("list")
def list_strategy_sets(ctx: typer.Context) -> None:
"""List all available strategy sets."""
def cmd(service: StrategySetService, app_context):
strategy_sets = service.list()
_print_strategy_set_table(strategy_sets)
_with_service(ctx, cmd)
@strategy_app.command("create")
def create_strategy_set(
ctx: typer.Context,
name: str = typer.Option(..., help="Strategy name (e.g., 'target_value')."),
config: str = typer.Option(..., help="Strategy config as JSON string."),
) -> None:
"""Create a new strategy set (or retrieve existing if (name, config) pair already exists)."""
def cmd(service: StrategySetService, app_context):
from app.schemas.portfolio import CreateStrategySetRequest
payload = CreateStrategySetRequest(name=name, config=config)
strategy_set = service.create(payload)
print(f"Strategy set created/retrieved:")
print(f" ID: {strategy_set.id}")
print(f" Name: {strategy_set.name}")
print(f" Nick: {strategy_set.nick}")
print(f" Created At: {strategy_set.created_at.isoformat() if strategy_set.created_at else 'N/A'}")
_with_service(ctx, cmd)
@strategy_app.command("update")
def update_strategy_set(
ctx: typer.Context,
strategy_set_id: str = typer.Argument(..., help="Strategy set ID to update."),
name: str | None = typer.Option(None, help="New strategy name (optional)."),
config: str | None = typer.Option(None, help="New strategy config as JSON string (optional)."),
) -> None:
"""Update an existing strategy set's name and/or config."""
def cmd(service: StrategySetService, app_context):
from app.schemas.portfolio import UpdateStrategySetRequest
payload = UpdateStrategySetRequest(name=name, config=config)
strategy_set = service.update(strategy_set_id, payload)
print(f"Strategy set updated:")
print(f" ID: {strategy_set.id}")
print(f" Name: {strategy_set.name}")
print(f" Nick: {strategy_set.nick}")
print(f" Created At: {strategy_set.created_at.isoformat() if strategy_set.created_at else 'N/A'}")
_with_service(ctx, cmd)
@strategy_app.command("delete")
def delete_strategy_set(
ctx: typer.Context,
strategy_set_id: str = typer.Argument(..., help="Strategy set ID to delete."),
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation."),
) -> None:
"""Delete a strategy set.
Returns an error if the strategy set is in use by any portfolio.
"""
def cmd(service: StrategySetService, app_context):
if not force:
confirmed = typer.confirm(f"Delete strategy set {strategy_set_id}?")
if not confirmed:
print("Cancelled.")
return
try:
service.delete(strategy_set_id)
print(f"Strategy set {strategy_set_id} deleted.")
except ValueError as e:
print(f"Error: {e}")
raise typer.Exit(code=1)
_with_service(ctx, cmd)
+2
View File
@@ -10,6 +10,7 @@ from rich.console import Console
from app.cli.commands.health import register_health_commands
from app.cli.commands.portfolio import portfolio_app
from app.cli.commands.strategy import strategy_app
from app.core.config import Settings, get_settings
from app.repositories.factory import get_portfolio_repository_from_settings
from app.repositories.portfolio_repository import PortfolioRepository
@@ -45,6 +46,7 @@ def create_app(
)
app.add_typer(portfolio_app, name="portfolio")
app.add_typer(strategy_app, name="strategy")
register_health_commands(app)
return app
+2
View File
@@ -5,6 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app.api.health import router as health_router
from app.api.portfolios import router as portfolio_router
from app.api.strategy_sets import router as strategy_sets_router
from app.core.config import get_settings
from app.core.errors import register_exception_handlers
@@ -28,6 +29,7 @@ def create_app() -> FastAPI:
app.include_router(health_router)
app.include_router(portfolio_router)
app.include_router(strategy_sets_router)
register_exception_handlers(app)
return app
+4
View File
@@ -47,6 +47,10 @@ class PortfolioRepository(Protocol):
def get_strategy_set(self, id: str) -> StrategySet | None: ...
def list_strategy_sets(self) -> list[StrategySet]: ...
def delete_strategy_set(self, id: str) -> None: ...
def save(
self,
portfolio: Portfolio,
+28
View File
@@ -385,6 +385,34 @@ class PostgresPortfolioRepository:
return None
return StrategySet(id=row[0], name=row[1], config=row[2], nick=row[3], created_at=row[4])
def list_strategy_sets(self) -> 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"
)
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]
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
cur.execute(
"""
SELECT COUNT(*) FROM portfolios
WHERE active_strategy_id = %s
OR avabile_strategies @> ARRAY[%s]
""",
(id, id),
)
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,))
def save(
self,
portfolio: Portfolio,
+11
View File
@@ -123,3 +123,14 @@ class ResetPortfolioRequest(BaseModel):
class ErrorResponse(BaseModel):
detail: str
model_config = ConfigDict(extra="ignore")
class CreateStrategySetRequest(BaseModel):
name: str
config: str
class UpdateStrategySetRequest(BaseModel):
name: str | None = None
config: str | None = None
+74
View File
@@ -0,0 +1,74 @@
"""Strategy set service orchestration."""
from __future__ import annotations
from fastapi import Depends
from app.core.config import Settings, get_settings
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
def get_strategy_set_repository(settings: Settings = Depends(get_settings)) -> PortfolioRepository:
return get_portfolio_repository_from_settings(settings)
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 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 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)
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
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)