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:
+10
-1
@@ -25,12 +25,17 @@ from app.schemas.portfolio import (
|
||||
UpdatePortfolioRequest,
|
||||
)
|
||||
from app.services.portfolio_service import PortfolioService
|
||||
from app.services.strategy_set_service import StrategyInactiveError, StrategyNotFoundError
|
||||
|
||||
router = APIRouter(prefix="/portfolios", tags=["portfolios"])
|
||||
|
||||
|
||||
def _raise_portfolio_error(exc: ValueError) -> None:
|
||||
def _raise_portfolio_error(exc: ValueError) -> NoReturn:
|
||||
detail = str(exc)
|
||||
if isinstance(exc, StrategyNotFoundError):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail) from exc
|
||||
if isinstance(exc, StrategyInactiveError):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail) from exc
|
||||
if "이미 연결된 태그입니다" in detail:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail) from exc
|
||||
if "찾을 수 없습니다" in detail:
|
||||
@@ -151,6 +156,8 @@ def create_portfolio(
|
||||
if _ownership_required(settings):
|
||||
owner_user_id = _require_owner_context(auth_context)
|
||||
return service.create_portfolio(payload, owner_user_id=owner_user_id)
|
||||
except ValueError as exc:
|
||||
_raise_portfolio_error(exc)
|
||||
finally:
|
||||
_close_repository(repository)
|
||||
|
||||
@@ -169,6 +176,8 @@ def update_portfolio(
|
||||
if _ownership_required(settings):
|
||||
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
|
||||
return service.update_portfolio(portfolio_id, payload)
|
||||
except ValueError as exc:
|
||||
_raise_portfolio_error(exc)
|
||||
finally:
|
||||
_close_repository(repository)
|
||||
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
"""Strategy Set API routes."""
|
||||
|
||||
from fastapi import APIRouter, Depends, Response, status
|
||||
|
||||
from app.core.auth import AuthContext, require_read, require_trade_write
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.repositories.factory import get_portfolio_repository_from_settings
|
||||
from app.schemas.portfolio import CreateStrategySetRequest, StrategySetResponse, UpdateStrategySetRequest
|
||||
from app.services.strategy_set_service import StrategySetService
|
||||
|
||||
|
||||
router = APIRouter(prefix="/strategy-sets", tags=["strategy-sets"])
|
||||
|
||||
|
||||
def _get_service(settings: Settings) -> tuple[StrategySetService, object]:
|
||||
repository = get_portfolio_repository_from_settings(settings)
|
||||
return StrategySetService(repository), repository
|
||||
|
||||
|
||||
def _close_repository(repository: object) -> None:
|
||||
if callable(getattr(type(repository), "close", None)):
|
||||
repository.close()
|
||||
|
||||
|
||||
@router.get("", response_model=list[StrategySetResponse])
|
||||
def list_strategy_sets(
|
||||
_auth: AuthContext = Depends(require_read),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> list[StrategySetResponse]:
|
||||
"""List all strategy sets."""
|
||||
service, repository = _get_service(settings)
|
||||
try:
|
||||
strategy_sets = service.list()
|
||||
finally:
|
||||
_close_repository(repository)
|
||||
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)
|
||||
def create_strategy_set(
|
||||
payload: CreateStrategySetRequest,
|
||||
_auth: AuthContext = Depends(require_trade_write),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> StrategySetResponse:
|
||||
"""Create a new strategy set (or return existing if (name, config) pair already exists)."""
|
||||
service, repository = _get_service(settings)
|
||||
try:
|
||||
strategy_set = service.create(payload)
|
||||
finally:
|
||||
_close_repository(repository)
|
||||
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)
|
||||
def update_strategy_set(
|
||||
strategy_set_id: str,
|
||||
payload: UpdateStrategySetRequest,
|
||||
_auth: AuthContext = Depends(require_trade_write),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> 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.
|
||||
"""
|
||||
service, repository = _get_service(settings)
|
||||
try:
|
||||
strategy_set = service.update(strategy_set_id, payload)
|
||||
finally:
|
||||
_close_repository(repository)
|
||||
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)
|
||||
def delete_strategy_set(
|
||||
strategy_set_id: str,
|
||||
_auth: AuthContext = Depends(require_trade_write),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> 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, repository = _get_service(settings)
|
||||
try:
|
||||
service.delete(strategy_set_id)
|
||||
finally:
|
||||
_close_repository(repository)
|
||||
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)
|
||||
+22
-13
@@ -8,7 +8,6 @@ REQ-043: GET /v1/portfolios/{id}/holdings/cost-basis — current holding WAC cos
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
@@ -44,6 +43,7 @@ from app.schemas.v1 import (
|
||||
TradeHistoryItem,
|
||||
TradeHistoryResponse,
|
||||
)
|
||||
from app.services.strategy_set_service import StrategyInactiveError, StrategyNotFoundError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/v1/portfolios", tags=["v1"])
|
||||
@@ -96,6 +96,7 @@ def _portfolio_to_item(
|
||||
active_strategy_id=portfolio.active_strategy_id,
|
||||
strategy_name=portfolio.strategy_name,
|
||||
strategy_params=portfolio.strategy_params,
|
||||
strategy_revision=portfolio.strategy_revision,
|
||||
markets=portfolio.markets,
|
||||
)
|
||||
|
||||
@@ -160,10 +161,11 @@ def create_portfolio_v1(
|
||||
)
|
||||
|
||||
name = body.name or f"{body.broker}-{body.asset_id[:8]}"
|
||||
strategy_set = repo.get_or_create_strategy_set(
|
||||
body.strategy_name or "target_value",
|
||||
json.dumps(body.strategy_params or {}, sort_keys=True, ensure_ascii=True),
|
||||
)
|
||||
strategy_set = repo.get_strategy_set(body.strategy_id)
|
||||
if strategy_set is None:
|
||||
raise StrategyNotFoundError(f"strategy not found: {body.strategy_id}")
|
||||
if strategy_set.status != "active":
|
||||
raise StrategyInactiveError(f"strategy is inactive: {body.strategy_id}")
|
||||
|
||||
portfolio = repo.create_portfolio(
|
||||
str(uuid.uuid4()),
|
||||
@@ -181,6 +183,10 @@ def create_portfolio_v1(
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except StrategyNotFoundError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
except StrategyInactiveError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
finally:
|
||||
@@ -215,21 +221,24 @@ def update_portfolio_v1(
|
||||
|
||||
fields = body.model_dump(exclude_none=True)
|
||||
if fields:
|
||||
if "strategy_name" in fields or "strategy_params" in fields:
|
||||
strategy_set = repo.get_or_create_strategy_set(
|
||||
fields.pop("strategy_name", None) or "target_value",
|
||||
json.dumps(fields.pop("strategy_params", None) or {}, sort_keys=True, ensure_ascii=True),
|
||||
)
|
||||
if "strategy_id" in fields:
|
||||
strategy_id = str(fields.pop("strategy_id"))
|
||||
strategy_set = repo.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_set.id
|
||||
fields["avabile_strategies"] = [strategy_set.id]
|
||||
else:
|
||||
fields.pop("strategy_name", None)
|
||||
fields.pop("strategy_params", None)
|
||||
repo.update_portfolio(portfolio_id, **fields)
|
||||
|
||||
portfolio = repo.get(portfolio_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except StrategyNotFoundError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
except StrategyInactiveError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
finally:
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Versioned strategy catalog APIs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
|
||||
from app.core.auth import AuthContext, require_admin, require_read
|
||||
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.schemas.portfolio import CreateStrategySetRequest, UpdateStrategySetRequest
|
||||
from app.schemas.v1 import StrategyCreateRequest, StrategyResponse, StrategyUpdateRequest
|
||||
from app.services.strategy_set_service import (
|
||||
StrategyConflictError,
|
||||
StrategyInactiveError,
|
||||
StrategyInUseError,
|
||||
StrategyNotFoundError,
|
||||
StrategySetService,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["v1-strategies"])
|
||||
|
||||
|
||||
def _get_service(settings: Settings) -> tuple[StrategySetService, object]:
|
||||
repository = get_portfolio_repository_from_settings(settings)
|
||||
return StrategySetService(repository), repository
|
||||
|
||||
|
||||
def _close_repository(repository: object) -> None:
|
||||
if callable(getattr(type(repository), "close", None)):
|
||||
repository.close()
|
||||
|
||||
|
||||
def _canonical_config(config: dict[str, object]) -> str:
|
||||
return json.dumps(config, sort_keys=True, ensure_ascii=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def _response(strategy: StrategySet) -> StrategyResponse:
|
||||
try:
|
||||
parsed = json.loads(strategy.config) if strategy.config else {}
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
parsed = {}
|
||||
return StrategyResponse(
|
||||
strategy_id=strategy.id,
|
||||
name=strategy.name,
|
||||
config=parsed if isinstance(parsed, dict) else {},
|
||||
status=strategy.status,
|
||||
revision=strategy.revision,
|
||||
created_at=strategy.created_at,
|
||||
updated_at=strategy.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _raise_strategy_error(exc: ValueError) -> None:
|
||||
if isinstance(exc, StrategyNotFoundError):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
if isinstance(exc, (StrategyInactiveError, StrategyInUseError, StrategyConflictError)):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||
raise exc
|
||||
|
||||
|
||||
@router.get("/v1/strategies", response_model=list[StrategyResponse])
|
||||
def list_active_strategies(
|
||||
_auth: AuthContext = Depends(require_read),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> list[StrategyResponse]:
|
||||
service, repository = _get_service(settings)
|
||||
try:
|
||||
return [_response(item) for item in service.list(include_inactive=False)]
|
||||
finally:
|
||||
_close_repository(repository)
|
||||
|
||||
|
||||
@router.get("/v1/strategies/{strategy_id}", response_model=StrategyResponse)
|
||||
def get_active_strategy(
|
||||
strategy_id: str,
|
||||
_auth: AuthContext = Depends(require_read),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> StrategyResponse:
|
||||
service, repository = _get_service(settings)
|
||||
try:
|
||||
try:
|
||||
return _response(service.get(strategy_id, active_only=True))
|
||||
except ValueError as exc:
|
||||
_raise_strategy_error(exc)
|
||||
finally:
|
||||
_close_repository(repository)
|
||||
|
||||
|
||||
@router.get("/v1/admin/strategies", response_model=list[StrategyResponse])
|
||||
def list_admin_strategies(
|
||||
_auth: AuthContext = Depends(require_admin),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> list[StrategyResponse]:
|
||||
service, repository = _get_service(settings)
|
||||
try:
|
||||
return [_response(item) for item in service.list(include_inactive=True)]
|
||||
finally:
|
||||
_close_repository(repository)
|
||||
|
||||
|
||||
@router.get("/v1/admin/strategies/{strategy_id}", response_model=StrategyResponse)
|
||||
def get_admin_strategy(
|
||||
strategy_id: str,
|
||||
_auth: AuthContext = Depends(require_admin),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> StrategyResponse:
|
||||
service, repository = _get_service(settings)
|
||||
try:
|
||||
try:
|
||||
return _response(service.get(strategy_id))
|
||||
except ValueError as exc:
|
||||
_raise_strategy_error(exc)
|
||||
finally:
|
||||
_close_repository(repository)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/v1/admin/strategies",
|
||||
response_model=StrategyResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_admin_strategy(
|
||||
body: StrategyCreateRequest,
|
||||
_auth: AuthContext = Depends(require_admin),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> StrategyResponse:
|
||||
service, repository = _get_service(settings)
|
||||
try:
|
||||
strategy = service.create(
|
||||
CreateStrategySetRequest(
|
||||
name=body.name,
|
||||
config=_canonical_config(body.config),
|
||||
status=body.status,
|
||||
)
|
||||
)
|
||||
return _response(strategy)
|
||||
finally:
|
||||
_close_repository(repository)
|
||||
|
||||
|
||||
@router.patch("/v1/admin/strategies/{strategy_id}", response_model=StrategyResponse)
|
||||
def update_admin_strategy(
|
||||
strategy_id: str,
|
||||
body: StrategyUpdateRequest,
|
||||
_auth: AuthContext = Depends(require_admin),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> StrategyResponse:
|
||||
service, repository = _get_service(settings)
|
||||
try:
|
||||
try:
|
||||
strategy = service.update(
|
||||
strategy_id,
|
||||
UpdateStrategySetRequest(
|
||||
name=body.name,
|
||||
config=_canonical_config(body.config) if body.config is not None else None,
|
||||
status=body.status,
|
||||
),
|
||||
)
|
||||
return _response(strategy)
|
||||
except ValueError as exc:
|
||||
_raise_strategy_error(exc)
|
||||
finally:
|
||||
_close_repository(repository)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/v1/admin/strategies/{strategy_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
def delete_admin_strategy(
|
||||
strategy_id: str,
|
||||
_auth: AuthContext = Depends(require_admin),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> Response:
|
||||
service, repository = _get_service(settings)
|
||||
try:
|
||||
try:
|
||||
service.delete(strategy_id)
|
||||
except ValueError as exc:
|
||||
_raise_strategy_error(exc)
|
||||
finally:
|
||||
_close_repository(repository)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -150,8 +150,7 @@ def create_portfolio(
|
||||
asset_quantity: float = typer.Option(0.0, min=0.0, help="Starting asset quantity."),
|
||||
asset_price: float = typer.Option(0.0, min=0.0, help="Starting asset price."),
|
||||
profit_buffer: float = typer.Option(0.0, min=0.0, help="Initial profit buffer."),
|
||||
strategy_name: str = typer.Option("target_value", help="Strategy name."),
|
||||
strategy_params: str = typer.Option("{}", help="Strategy params as JSON object."),
|
||||
strategy_id: str = typer.Option(..., "--strategy-id", help="Active strategy catalog ID."),
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
|
||||
) -> None:
|
||||
_validate_portfolio_name(name, ctx.obj.console)
|
||||
@@ -179,8 +178,7 @@ def create_portfolio(
|
||||
asset_quantity=asset_quantity,
|
||||
asset_price=asset_price,
|
||||
profit_buffer=profit_buffer,
|
||||
strategy_name=strategy_name,
|
||||
strategy_params=_parse_strategy_params(strategy_params),
|
||||
strategy_id=strategy_id,
|
||||
),
|
||||
owner_user_id=owner.id,
|
||||
)
|
||||
@@ -198,8 +196,7 @@ def update_portfolio(
|
||||
broker: str | None = typer.Option(None, help="Broker code."),
|
||||
status: str | None = typer.Option(None, help="Portfolio status."),
|
||||
new_name: str | None = typer.Option(None, "--new-name", help="New portfolio name (requires --force or confirm)."),
|
||||
strategy_name: str | None = typer.Option(None, help="Strategy name."),
|
||||
strategy_params: str | None = typer.Option(None, help="Strategy params as JSON object."),
|
||||
strategy_id: str | None = typer.Option(None, "--strategy-id", help="New active strategy catalog ID."),
|
||||
asset_price: float | None = typer.Option(None, min=0.0, help="Current asset price."),
|
||||
daemon_id: str | None = typer.Option(None, "--daemon-id", help="Assign portfolio to this daemon (managed_by_daemon_id)."),
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
|
||||
@@ -211,8 +208,6 @@ def update_portfolio(
|
||||
else:
|
||||
_confirm(force, f"Update portfolio {name!r} (user: {user})?")
|
||||
|
||||
parsed_strategy_params = None if strategy_params is None else _parse_strategy_params(strategy_params)
|
||||
|
||||
def _callback(service, repository, app_context):
|
||||
portfolio_id = _resolve_portfolio_id(repository, name, user)
|
||||
return service.update_portfolio(
|
||||
@@ -221,8 +216,7 @@ def update_portfolio(
|
||||
broker=broker,
|
||||
status=status,
|
||||
name=new_name,
|
||||
strategy_name=strategy_name,
|
||||
strategy_params=parsed_strategy_params,
|
||||
strategy_id=strategy_id,
|
||||
asset_price=asset_price,
|
||||
managed_by_daemon_id=daemon_id,
|
||||
),
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
"""Strategy Set CLI commands."""
|
||||
"""Strategy catalog CLI commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typer
|
||||
from typing import Literal
|
||||
|
||||
from app.cli.core.utils import execute_command, render_output
|
||||
from app.cli.core.utils import execute_command
|
||||
from app.schemas.portfolio import CreateStrategySetRequest, UpdateStrategySetRequest
|
||||
from app.services.strategy_set_service import StrategySetService
|
||||
|
||||
|
||||
strategy_app = typer.Typer(help="Strategy set management commands.")
|
||||
strategy_app = typer.Typer(help="Strategy catalog management commands.")
|
||||
|
||||
|
||||
def _with_service(ctx: typer.Context, callback):
|
||||
@@ -16,37 +18,43 @@ def _with_service(ctx: typer.Context, callback):
|
||||
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
|
||||
return execute_command(app_context.console, callback=lambda: callback(service))
|
||||
finally:
|
||||
if callable(getattr(type(repository), "close", None)):
|
||||
repository.close()
|
||||
|
||||
|
||||
def _print_strategy_set_table(strategy_sets):
|
||||
"""Print strategy sets as a simple table."""
|
||||
def _print_strategy_set_table(strategy_sets) -> None:
|
||||
if not strategy_sets:
|
||||
print("No strategy sets found.")
|
||||
print("No strategies 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}")
|
||||
|
||||
print(f"{'ID':<40} {'Name':<20} {'Status':<10} {'Revision':<8} {'Nick':<28} {'Updated At':<26}")
|
||||
print("-" * 138)
|
||||
for strategy in strategy_sets:
|
||||
updated_at = strategy.updated_at.isoformat() if strategy.updated_at else "N/A"
|
||||
print(
|
||||
f"{strategy.id:<40} {strategy.name:<20} {strategy.status:<10} "
|
||||
f"{strategy.revision:<8} {strategy.nick:<28} {updated_at:<26}"
|
||||
)
|
||||
|
||||
|
||||
def _print_strategy(strategy, *, action: str) -> None:
|
||||
print(f"Strategy {action}:")
|
||||
print(f" ID: {strategy.id}")
|
||||
print(f" Name: {strategy.name}")
|
||||
print(f" Nick: {strategy.nick}")
|
||||
print(f" Status: {strategy.status}")
|
||||
print(f" Revision: {strategy.revision}")
|
||||
print(f" Created At: {strategy.created_at.isoformat() if strategy.created_at else 'N/A'}")
|
||||
print(f" Updated At: {strategy.updated_at.isoformat() if strategy.updated_at else 'N/A'}")
|
||||
|
||||
|
||||
@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)
|
||||
"""List active and inactive catalog strategies."""
|
||||
|
||||
_with_service(ctx, lambda service: _print_strategy_set_table(service.list(include_inactive=True)))
|
||||
|
||||
|
||||
@strategy_app.command("create")
|
||||
@@ -54,68 +62,52 @@ 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."),
|
||||
status: Literal["active", "inactive"] = typer.Option("active", help="Strategy status: active or inactive."),
|
||||
) -> 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'}")
|
||||
|
||||
"""Create a strategy or return the existing matching catalog entry."""
|
||||
|
||||
def cmd(service: StrategySetService) -> None:
|
||||
strategy = service.create(CreateStrategySetRequest(name=name, config=config, status=status))
|
||||
_print_strategy(strategy, action="created/retrieved")
|
||||
|
||||
_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)."),
|
||||
strategy_set_id: str = typer.Argument(..., help="Strategy ID to update."),
|
||||
name: str | None = typer.Option(None, help="New strategy name."),
|
||||
config: str | None = typer.Option(None, help="New strategy config as JSON string."),
|
||||
status: Literal["active", "inactive"] | None = typer.Option(None, help="New strategy status: active or inactive."),
|
||||
) -> 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'}")
|
||||
|
||||
"""Update catalog fields while preserving the strategy ID."""
|
||||
|
||||
def cmd(service: StrategySetService) -> None:
|
||||
if name is None and config is None and status is None:
|
||||
raise ValueError("at least one of --name, --config, or --status is required")
|
||||
strategy = service.update(
|
||||
strategy_set_id,
|
||||
UpdateStrategySetRequest(name=name, config=config, status=status),
|
||||
)
|
||||
_print_strategy(strategy, action="updated")
|
||||
|
||||
_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."),
|
||||
strategy_set_id: str = typer.Argument(..., help="Strategy 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)
|
||||
|
||||
"""Delete an unreferenced strategy."""
|
||||
|
||||
def cmd(service: StrategySetService) -> None:
|
||||
if not force and not typer.confirm(f"Delete strategy {strategy_set_id}?"):
|
||||
print("Cancelled.")
|
||||
return
|
||||
service.delete(strategy_set_id)
|
||||
print(f"Strategy {strategy_set_id} deleted.")
|
||||
|
||||
_with_service(ctx, cmd)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Shared strategy-catalog domain errors used across API, service, and persistence layers."""
|
||||
|
||||
|
||||
class StrategyNotFoundError(ValueError):
|
||||
"""Raised when a referenced strategy ID does not exist."""
|
||||
|
||||
|
||||
class StrategyInactiveError(ValueError):
|
||||
"""Raised when a new portfolio selection targets an inactive strategy."""
|
||||
@@ -46,7 +46,10 @@ class StrategySet:
|
||||
name: str
|
||||
config: str
|
||||
nick: str
|
||||
status: str = "active"
|
||||
revision: int = 1
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -118,6 +121,7 @@ class Portfolio:
|
||||
active_strategy_id: str | None = None
|
||||
strategy_name: str | None = None # REQ-055: strategy_sets.name JOIN 결과
|
||||
strategy_params: dict[str, Any] = field(default_factory=dict) # REQ-055: strategy_sets.config 파싱 결과
|
||||
strategy_revision: int | None = None
|
||||
markets: list[str] = field(default_factory=list) # REQ-055: assets.markets JOIN 결과
|
||||
avabile_strategies: list[str] | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
+4
-2
@@ -9,13 +9,14 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.api.health import router as health_router
|
||||
from app.api.auth_routes import router as auth_router
|
||||
from app.api.portfolios import router as portfolio_router
|
||||
from app.api.strategy_sets import router as strategy_sets_router
|
||||
|
||||
from app.api.users import router as users_router
|
||||
from app.api.v1_admin_daemons import router as v1_admin_daemons_router
|
||||
from app.api.v1_assets import router as v1_assets_router
|
||||
from app.api.v1_daemon_credential import router as v1_daemon_credential_router
|
||||
from app.api.v1_daemons import router as v1_daemons_router
|
||||
from app.api.v1_portfolios import router as v1_portfolios_router
|
||||
from app.api.v1_strategies import router as v1_strategies_router
|
||||
from app.api.v1_tags import router as v1_tags_router
|
||||
from app.api.v1_user_info import router as v1_user_info_router
|
||||
from app.core.config import Settings, get_settings
|
||||
@@ -81,13 +82,14 @@ def create_app(
|
||||
app.include_router(health_router)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(portfolio_router)
|
||||
app.include_router(strategy_sets_router)
|
||||
|
||||
app.include_router(users_router)
|
||||
app.include_router(v1_admin_daemons_router)
|
||||
app.include_router(v1_assets_router)
|
||||
app.include_router(v1_daemon_credential_router)
|
||||
app.include_router(v1_daemons_router)
|
||||
app.include_router(v1_portfolios_router)
|
||||
app.include_router(v1_strategies_router)
|
||||
app.include_router(v1_tags_router)
|
||||
app.include_router(v1_user_info_router)
|
||||
register_exception_handlers(app)
|
||||
|
||||
@@ -242,9 +242,20 @@ class PortfolioRepository(Protocol):
|
||||
|
||||
def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet: ...
|
||||
|
||||
def create_strategy_set(self, name: str, config: str, *, status: str = "active") -> StrategySet: ...
|
||||
|
||||
def get_strategy_set(self, id: str) -> StrategySet | None: ...
|
||||
|
||||
def list_strategy_sets(self) -> list[StrategySet]: ...
|
||||
def list_strategy_sets(self, *, include_inactive: bool = True) -> list[StrategySet]: ...
|
||||
|
||||
def update_strategy_set(
|
||||
self,
|
||||
id: str,
|
||||
*,
|
||||
name: str | None = None,
|
||||
config: str | None = None,
|
||||
status: str | None = None,
|
||||
) -> StrategySet: ...
|
||||
|
||||
def delete_strategy_set(self, id: str) -> None: ...
|
||||
|
||||
|
||||
+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,
|
||||
|
||||
@@ -17,7 +17,10 @@ class StrategySetResponse(BaseModel):
|
||||
name: str
|
||||
config: str
|
||||
nick: str
|
||||
status: Literal["active", "inactive"] = "active"
|
||||
revision: int = 1
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class LedgerEntryResponse(BaseModel):
|
||||
@@ -55,6 +58,9 @@ class PortfolioSummary(BaseModel):
|
||||
price: float = 0.0
|
||||
profit_buffer: float = 0.0
|
||||
active_strategy_id: str | None = None
|
||||
strategy_name: str | None = None
|
||||
strategy_params: dict[str, Any] = Field(default_factory=dict)
|
||||
strategy_revision: int | None = Field(default=None, ge=1)
|
||||
avabile_strategies: list[str] | None = None
|
||||
managed_by_daemon_id: str | None = None
|
||||
created_at: datetime | None = None
|
||||
@@ -74,12 +80,11 @@ class PortfolioDetailResponse(BaseModel):
|
||||
|
||||
|
||||
class CreatePortfolioRequest(PortfolioBase):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str # required, overrides Optional from PortfolioBase; used as (owner_user_id, name) identifier
|
||||
asset_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
|
||||
strategy_id: str = Field(..., min_length=1)
|
||||
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)
|
||||
@@ -96,13 +101,12 @@ class CreatePortfolioRequest(PortfolioBase):
|
||||
|
||||
|
||||
class UpdatePortfolioRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
broker: str | None = None
|
||||
status: str | None = None
|
||||
name: 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
|
||||
strategy_id: str | None = Field(default=None, min_length=1)
|
||||
asset_price: float | None = Field(default=None, ge=0.0)
|
||||
managed_by_daemon_id: str | None = None
|
||||
|
||||
@@ -179,11 +183,17 @@ class ErrorResponse(BaseModel):
|
||||
|
||||
|
||||
class CreateStrategySetRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str
|
||||
config: str
|
||||
status: Literal["active", "inactive"] = "active"
|
||||
|
||||
|
||||
class UpdateStrategySetRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str | None = None
|
||||
config: str | None = None
|
||||
status: Literal["active", "inactive"] | None = None
|
||||
|
||||
|
||||
+46
-4
@@ -233,6 +233,8 @@ class PortfolioReturnSummaryResponse(BaseModel):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class PortfolioCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
asset_id: str = Field(..., min_length=1)
|
||||
broker: str = Field(..., min_length=1)
|
||||
name: str | None = None
|
||||
@@ -240,18 +242,18 @@ class PortfolioCreateRequest(BaseModel):
|
||||
asset_quantity: float = Field(default=0.0, ge=0.0)
|
||||
asset_price: float = Field(default=0.0, ge=0.0)
|
||||
profit_buffer: float = Field(default=0.0, ge=0.0)
|
||||
strategy_name: str | None = None
|
||||
strategy_params: dict[str, Any] = Field(default_factory=dict)
|
||||
strategy_id: str = Field(..., min_length=1)
|
||||
managed_by_daemon_id: str | None = None
|
||||
user_id: str | None = None # admin-only: override owner
|
||||
|
||||
|
||||
class PortfolioUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
broker: str | None = None
|
||||
status: str | None = None
|
||||
name: str | None = None
|
||||
strategy_name: str | None = None
|
||||
strategy_params: dict[str, Any] | None = None
|
||||
strategy_id: str | None = Field(default=None, min_length=1)
|
||||
asset_price: float | None = Field(default=None, ge=0.0)
|
||||
managed_by_daemon_id: str | None = None
|
||||
|
||||
@@ -285,6 +287,7 @@ class PortfolioItem(BaseModel):
|
||||
active_strategy_id: str | None = None
|
||||
strategy_name: str | None = None
|
||||
strategy_params: dict[str, Any] = Field(default_factory=dict)
|
||||
strategy_revision: int | None = Field(default=None, ge=1)
|
||||
markets: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
@@ -293,6 +296,45 @@ class PortfolioListResponse(BaseModel):
|
||||
total: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /v1/strategies, CRUD /v1/admin/strategies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
StrategyStatus = Literal["active", "inactive"]
|
||||
|
||||
|
||||
class StrategyResponse(BaseModel):
|
||||
strategy_id: str
|
||||
name: str
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
status: StrategyStatus
|
||||
revision: int = Field(ge=1)
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class StrategyCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str = Field(..., min_length=1)
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
status: StrategyStatus = "active"
|
||||
|
||||
|
||||
class StrategyUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str | None = Field(default=None, min_length=1)
|
||||
config: dict[str, Any] | None = None
|
||||
status: StrategyStatus | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_change(self) -> "StrategyUpdateRequest":
|
||||
if not self.model_fields_set:
|
||||
raise ValueError("at least one field is required")
|
||||
return self
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /v1/portfolios/{id}/tags, CRUD /v1/portfolios/{id}/comments (REQ-030, REQ-031)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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