Add ProofLine criteria documentation and Strategy Catalog v1 implementation

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