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
+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)