chore: initial project scaffold and gitignore
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""PortfolioHub application package."""
|
||||
@@ -0,0 +1 @@
|
||||
"""API routers for PortfolioHub."""
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Health and readiness endpoints."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def healthcheck() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Portfolio API routes."""
|
||||
|
||||
from fastapi import APIRouter, Depends, Response, status
|
||||
|
||||
from app.core.auth import require_admin, require_read, require_trade_write
|
||||
from app.schemas.portfolio import (
|
||||
BuyRequest,
|
||||
CreatePortfolioRequest,
|
||||
InvestRequest,
|
||||
PortfolioDetailResponse,
|
||||
PortfolioListItem,
|
||||
ResetPortfolioRequest,
|
||||
SellRequest,
|
||||
TransferRequest,
|
||||
UpdatePortfolioRequest,
|
||||
)
|
||||
from app.services.portfolio_service import PortfolioService, get_portfolio_service
|
||||
|
||||
router = APIRouter(prefix="/portfolios", tags=["portfolios"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[PortfolioListItem], dependencies=[Depends(require_read)])
|
||||
def list_portfolios(
|
||||
broker: str | None = None,
|
||||
status_filter: str | None = None,
|
||||
service: PortfolioService = Depends(get_portfolio_service),
|
||||
) -> list[PortfolioListItem]:
|
||||
return service.list_portfolios(broker=broker, status=status_filter)
|
||||
|
||||
|
||||
@router.get("/{portfolio_id}", response_model=PortfolioDetailResponse, dependencies=[Depends(require_read)])
|
||||
def get_portfolio(
|
||||
portfolio_id: str,
|
||||
recent_trades: int = 5,
|
||||
service: PortfolioService = Depends(get_portfolio_service),
|
||||
) -> PortfolioDetailResponse:
|
||||
return service.get_portfolio_status(portfolio_id=portfolio_id, recent_trades=recent_trades)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=PortfolioDetailResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_trade_write)],
|
||||
)
|
||||
def create_portfolio(
|
||||
payload: CreatePortfolioRequest,
|
||||
service: PortfolioService = Depends(get_portfolio_service),
|
||||
) -> PortfolioDetailResponse:
|
||||
return service.create_portfolio(payload)
|
||||
|
||||
|
||||
@router.patch("/{portfolio_id}", response_model=PortfolioDetailResponse, dependencies=[Depends(require_trade_write)])
|
||||
def update_portfolio(
|
||||
portfolio_id: str,
|
||||
payload: UpdatePortfolioRequest,
|
||||
service: PortfolioService = Depends(get_portfolio_service),
|
||||
) -> PortfolioDetailResponse:
|
||||
return service.update_portfolio(portfolio_id, payload)
|
||||
|
||||
|
||||
@router.post("/{portfolio_id}/buy", response_model=PortfolioDetailResponse, dependencies=[Depends(require_trade_write)])
|
||||
def buy_portfolio_asset(
|
||||
portfolio_id: str,
|
||||
payload: BuyRequest,
|
||||
service: PortfolioService = Depends(get_portfolio_service),
|
||||
) -> PortfolioDetailResponse:
|
||||
return service.buy(portfolio_id, payload)
|
||||
|
||||
|
||||
@router.post("/{portfolio_id}/sell", response_model=PortfolioDetailResponse, dependencies=[Depends(require_trade_write)])
|
||||
def sell_portfolio_asset(
|
||||
portfolio_id: str,
|
||||
payload: SellRequest,
|
||||
service: PortfolioService = Depends(get_portfolio_service),
|
||||
) -> PortfolioDetailResponse:
|
||||
return service.sell(portfolio_id, payload)
|
||||
|
||||
|
||||
@router.post("/{portfolio_id}/invest", response_model=PortfolioDetailResponse, dependencies=[Depends(require_trade_write)])
|
||||
def invest_buffer(
|
||||
portfolio_id: str,
|
||||
payload: InvestRequest,
|
||||
service: PortfolioService = Depends(get_portfolio_service),
|
||||
) -> PortfolioDetailResponse:
|
||||
return service.invest(portfolio_id, payload)
|
||||
|
||||
|
||||
@router.post("/{portfolio_id}/transfer", response_model=PortfolioDetailResponse, dependencies=[Depends(require_trade_write)])
|
||||
def transfer_buffer(
|
||||
portfolio_id: str,
|
||||
payload: TransferRequest,
|
||||
service: PortfolioService = Depends(get_portfolio_service),
|
||||
) -> PortfolioDetailResponse:
|
||||
return service.transfer(portfolio_id, payload)
|
||||
|
||||
|
||||
@router.post("/{portfolio_id}/reset", response_model=PortfolioDetailResponse, dependencies=[Depends(require_trade_write)])
|
||||
def reset_portfolio(
|
||||
portfolio_id: str,
|
||||
payload: ResetPortfolioRequest,
|
||||
service: PortfolioService = Depends(get_portfolio_service),
|
||||
) -> PortfolioDetailResponse:
|
||||
return service.reset(portfolio_id, payload)
|
||||
|
||||
|
||||
@router.delete("/{portfolio_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_admin)])
|
||||
def delete_portfolio(
|
||||
portfolio_id: str,
|
||||
keep_ledger: bool = False,
|
||||
service: PortfolioService = Depends(get_portfolio_service),
|
||||
) -> Response:
|
||||
service.delete(portfolio_id, keep_ledger=keep_ledger)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -0,0 +1 @@
|
||||
"""CLI package for PortfolioHub."""
|
||||
@@ -0,0 +1 @@
|
||||
"""CLI command groups."""
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Health-related CLI commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typer
|
||||
|
||||
from app.cli.core.utils import print_json
|
||||
from app.db.migrations import bootstrap_or_upgrade_to_head
|
||||
from app.repositories.factory import build_portfolio_db_config
|
||||
from app.repositories.postgres import PostgresPortfolioRepository, create_database_if_missing, database_exists
|
||||
|
||||
|
||||
def initialize_database(*, settings, maintenance_db: str = "postgres") -> dict[str, object]:
|
||||
config = build_portfolio_db_config(settings)
|
||||
existed_before = database_exists(config, maintenance_db=maintenance_db)
|
||||
created = False
|
||||
if not existed_before:
|
||||
created = create_database_if_missing(config, maintenance_db=maintenance_db)
|
||||
|
||||
bootstrap_or_upgrade_to_head(settings)
|
||||
|
||||
return {
|
||||
"database": config.dbname,
|
||||
"host": config.host,
|
||||
"port": config.port,
|
||||
"created": created,
|
||||
"schema_initialized": True,
|
||||
"migration_applied": "head",
|
||||
}
|
||||
|
||||
|
||||
def register_health_commands(app: typer.Typer) -> None:
|
||||
@app.command("init-db")
|
||||
def init_db(
|
||||
ctx: typer.Context,
|
||||
maintenance_db: str = typer.Option("postgres", help="Database to connect to for CREATE DATABASE."),
|
||||
) -> None:
|
||||
app_context = ctx.obj
|
||||
try:
|
||||
result = initialize_database(settings=app_context.settings, maintenance_db=maintenance_db)
|
||||
except Exception as exc:
|
||||
app_context.console.print(f"init-db failed: {exc}")
|
||||
raise typer.Exit(code=1) from exc
|
||||
else:
|
||||
if app_context.output_format == "json":
|
||||
print_json(app_context.console, result)
|
||||
else:
|
||||
app_context.console.print(f"database: {result['database']}")
|
||||
app_context.console.print(f"host: {result['host']}")
|
||||
app_context.console.print(f"port: {result['port']}")
|
||||
app_context.console.print(f"created: {result['created']}")
|
||||
app_context.console.print("schema_initialized: True")
|
||||
app_context.console.print("migration_applied: head")
|
||||
|
||||
@app.command("migrate-db")
|
||||
def migrate_db(ctx: typer.Context) -> None:
|
||||
app_context = ctx.obj
|
||||
try:
|
||||
bootstrap_or_upgrade_to_head(app_context.settings)
|
||||
result = {
|
||||
"database": app_context.settings.active_db_name,
|
||||
"host": app_context.settings.active_db_host,
|
||||
"port": app_context.settings.active_db_port,
|
||||
"migration_applied": "head",
|
||||
}
|
||||
except Exception as exc:
|
||||
app_context.console.print(f"migrate-db failed: {exc}")
|
||||
raise typer.Exit(code=1) from exc
|
||||
else:
|
||||
if app_context.output_format == "json":
|
||||
print_json(app_context.console, result)
|
||||
else:
|
||||
app_context.console.print(f"database: {result['database']}")
|
||||
app_context.console.print(f"host: {result['host']}")
|
||||
app_context.console.print(f"port: {result['port']}")
|
||||
app_context.console.print("migration_applied: head")
|
||||
|
||||
@app.command("health-check")
|
||||
def health_check(ctx: typer.Context) -> None:
|
||||
app_context = ctx.obj
|
||||
repository = app_context.repository_factory(app_context.settings)
|
||||
try:
|
||||
health = {
|
||||
"app_name": app_context.settings.app_name,
|
||||
"environment": app_context.settings.environment,
|
||||
"db_enabled": app_context.settings.db_enabled,
|
||||
"active_db_host": app_context.settings.active_db_host,
|
||||
"active_db_port": app_context.settings.active_db_port,
|
||||
"repository": repository.__class__.__name__,
|
||||
}
|
||||
if hasattr(repository, "ping"):
|
||||
repository.ping()
|
||||
except Exception as exc:
|
||||
app_context.console.print(f"health-check failed: {exc}")
|
||||
raise typer.Exit(code=1) from exc
|
||||
else:
|
||||
if app_context.output_format == "json":
|
||||
print_json(app_context.console, health)
|
||||
else:
|
||||
for key, value in health.items():
|
||||
app_context.console.print(f"{key}: {value}")
|
||||
finally:
|
||||
if hasattr(repository, "close"):
|
||||
repository.close()
|
||||
@@ -0,0 +1,262 @@
|
||||
"""Portfolio CLI commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import typer
|
||||
|
||||
from app.cli.core.utils import execute_command, render_output
|
||||
from app.schemas.portfolio import (
|
||||
BuyRequest,
|
||||
CreatePortfolioRequest,
|
||||
InvestRequest,
|
||||
ResetPortfolioRequest,
|
||||
SellRequest,
|
||||
TransferRequest,
|
||||
UpdatePortfolioRequest,
|
||||
)
|
||||
from app.services.portfolio_service import PortfolioService
|
||||
|
||||
|
||||
portfolio_app = typer.Typer(help="Portfolio lifecycle and trade commands.")
|
||||
|
||||
|
||||
def _parse_strategy_params(value: str) -> dict[str, Any]:
|
||||
if not value.strip():
|
||||
return {}
|
||||
parsed = json.loads(value)
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError("strategy params must be a JSON object")
|
||||
return parsed
|
||||
|
||||
|
||||
def _with_service(ctx: typer.Context, callback):
|
||||
app_context = ctx.obj
|
||||
repository = app_context.repository_factory(app_context.settings)
|
||||
service = PortfolioService(repository)
|
||||
try:
|
||||
result = execute_command(app_context.console, callback=lambda: callback(service, app_context))
|
||||
return result
|
||||
finally:
|
||||
if hasattr(repository, "close"):
|
||||
repository.close()
|
||||
|
||||
|
||||
def _confirm(force: bool, message: str) -> None:
|
||||
if force:
|
||||
return
|
||||
confirmed = typer.confirm(message)
|
||||
if not confirmed:
|
||||
raise typer.Exit(code=0)
|
||||
|
||||
|
||||
@portfolio_app.command("list")
|
||||
def list_portfolios(
|
||||
ctx: typer.Context,
|
||||
broker: str | None = typer.Option(None, help="Filter by broker."),
|
||||
status: str | None = typer.Option(None, help="Filter by status."),
|
||||
) -> None:
|
||||
detail = _with_service(ctx, lambda service, app_context: service.list_portfolios(broker=broker, status=status))
|
||||
render_output(ctx.obj.console, ctx.obj.output_format, detail, empty_message="No portfolios found.")
|
||||
|
||||
|
||||
@portfolio_app.command("status")
|
||||
def portfolio_status(
|
||||
ctx: typer.Context,
|
||||
portfolio_id: str = typer.Argument(..., help="Portfolio identifier."),
|
||||
recent: int = typer.Option(5, "--recent", min=1, help="Number of recent ledger entries."),
|
||||
) -> None:
|
||||
detail = _with_service(
|
||||
ctx,
|
||||
lambda service, app_context: service.get_portfolio_status(portfolio_id=portfolio_id, recent_trades=recent),
|
||||
)
|
||||
render_output(ctx.obj.console, ctx.obj.output_format, detail)
|
||||
|
||||
|
||||
@portfolio_app.command("create")
|
||||
def create_portfolio(
|
||||
ctx: typer.Context,
|
||||
portfolio_id: str = typer.Argument(..., help="Portfolio identifier."),
|
||||
symbol: str = typer.Option(..., help="Trading symbol."),
|
||||
broker: str = typer.Option("kis", help="Broker code."),
|
||||
status: str = typer.Option("enabled", help="Portfolio status."),
|
||||
cash_balance: float = typer.Option(0.0, min=0.0, help="Starting cash balance."),
|
||||
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."),
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
|
||||
) -> None:
|
||||
_confirm(force, f"Create portfolio {portfolio_id} for symbol {symbol}?")
|
||||
detail = _with_service(
|
||||
ctx,
|
||||
lambda service, app_context: service.create_portfolio(
|
||||
CreatePortfolioRequest(
|
||||
portfolio_id=portfolio_id,
|
||||
symbol=symbol,
|
||||
broker=broker,
|
||||
status=status,
|
||||
cash_balance=cash_balance,
|
||||
asset_quantity=asset_quantity,
|
||||
asset_price=asset_price,
|
||||
profit_buffer=profit_buffer,
|
||||
strategy_name=strategy_name,
|
||||
strategy_params=_parse_strategy_params(strategy_params),
|
||||
)
|
||||
),
|
||||
)
|
||||
render_output(ctx.obj.console, ctx.obj.output_format, detail)
|
||||
|
||||
|
||||
@portfolio_app.command("update")
|
||||
def update_portfolio(
|
||||
ctx: typer.Context,
|
||||
portfolio_id: str = typer.Argument(..., help="Portfolio identifier."),
|
||||
broker: str | None = typer.Option(None, help="Broker code."),
|
||||
status: str | None = typer.Option(None, help="Portfolio status."),
|
||||
symbol: str | None = typer.Option(None, help="Trading symbol."),
|
||||
strategy_name: str | None = typer.Option(None, help="Strategy name."),
|
||||
strategy_params: str | None = typer.Option(None, help="Strategy params as JSON object."),
|
||||
asset_price: float | None = typer.Option(None, min=0.0, help="Current asset price."),
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
|
||||
) -> None:
|
||||
_confirm(force, f"Update portfolio {portfolio_id}?")
|
||||
parsed_strategy_params = None if strategy_params is None else _parse_strategy_params(strategy_params)
|
||||
detail = _with_service(
|
||||
ctx,
|
||||
lambda service, app_context: service.update_portfolio(
|
||||
portfolio_id,
|
||||
UpdatePortfolioRequest(
|
||||
broker=broker,
|
||||
status=status,
|
||||
symbol=symbol,
|
||||
strategy_name=strategy_name,
|
||||
strategy_params=parsed_strategy_params,
|
||||
asset_price=asset_price,
|
||||
),
|
||||
),
|
||||
)
|
||||
render_output(ctx.obj.console, ctx.obj.output_format, detail)
|
||||
|
||||
|
||||
@portfolio_app.command("buy")
|
||||
def buy_portfolio(
|
||||
ctx: typer.Context,
|
||||
portfolio_id: str = typer.Argument(..., help="Portfolio identifier."),
|
||||
quantity: float = typer.Option(..., min=0.0000001, help="Quantity to buy."),
|
||||
price: float = typer.Option(..., min=0.0000001, help="Execution price."),
|
||||
fee: float = typer.Option(0.0, min=0.0, help="Fee amount."),
|
||||
tax: float = typer.Option(0.0, min=0.0, help="Tax amount."),
|
||||
note: str = typer.Option("", help="Ledger note."),
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
|
||||
) -> None:
|
||||
total_cost = quantity * price + fee + tax
|
||||
_confirm(force, f"Buy {quantity:g} units of {portfolio_id} at {price:,.0f} (total {total_cost:,.0f})?")
|
||||
detail = _with_service(
|
||||
ctx,
|
||||
lambda service, app_context: service.buy(
|
||||
portfolio_id,
|
||||
BuyRequest(quantity=quantity, price=price, fee=fee, tax=tax, note=note),
|
||||
),
|
||||
)
|
||||
render_output(ctx.obj.console, ctx.obj.output_format, detail)
|
||||
|
||||
|
||||
@portfolio_app.command("sell")
|
||||
def sell_portfolio(
|
||||
ctx: typer.Context,
|
||||
portfolio_id: str = typer.Argument(..., help="Portfolio identifier."),
|
||||
quantity: float = typer.Option(..., min=0.0000001, help="Quantity to sell."),
|
||||
price: float = typer.Option(..., min=0.0000001, help="Execution price."),
|
||||
fee: float = typer.Option(0.0, min=0.0, help="Fee amount."),
|
||||
tax: float = typer.Option(0.0, min=0.0, help="Tax amount."),
|
||||
note: str = typer.Option("", help="Ledger note."),
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
|
||||
) -> None:
|
||||
net_amount = quantity * price - fee - tax
|
||||
_confirm(force, f"Sell {quantity:g} units of {portfolio_id} at {price:,.0f} (net {net_amount:,.0f})?")
|
||||
detail = _with_service(
|
||||
ctx,
|
||||
lambda service, app_context: service.sell(
|
||||
portfolio_id,
|
||||
SellRequest(quantity=quantity, price=price, fee=fee, tax=tax, note=note),
|
||||
),
|
||||
)
|
||||
render_output(ctx.obj.console, ctx.obj.output_format, detail)
|
||||
|
||||
|
||||
@portfolio_app.command("invest")
|
||||
def invest_portfolio(
|
||||
ctx: typer.Context,
|
||||
portfolio_id: str = typer.Argument(..., help="Portfolio identifier."),
|
||||
movement_type: str = typer.Option(..., "--type", help="deposit, profit, or withdraw."),
|
||||
amount: float = typer.Option(..., min=0.0000001, help="Buffer adjustment amount."),
|
||||
note: str = typer.Option("", help="Ledger note."),
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
|
||||
) -> None:
|
||||
_confirm(force, f"Apply {movement_type} of {amount:,.0f} to portfolio {portfolio_id}?")
|
||||
detail = _with_service(
|
||||
ctx,
|
||||
lambda service, app_context: service.invest(
|
||||
portfolio_id,
|
||||
InvestRequest(movement_type=movement_type, amount=amount, note=note),
|
||||
),
|
||||
)
|
||||
render_output(ctx.obj.console, ctx.obj.output_format, detail)
|
||||
|
||||
|
||||
@portfolio_app.command("transfer")
|
||||
def transfer_portfolio(
|
||||
ctx: typer.Context,
|
||||
portfolio_id: str = typer.Argument(..., help="Portfolio identifier."),
|
||||
direction: str = typer.Option(..., help="to-buffer or from-buffer."),
|
||||
amount: float = typer.Option(..., min=0.0000001, help="Transfer amount."),
|
||||
note: str = typer.Option("", help="Ledger note."),
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
|
||||
) -> None:
|
||||
_confirm(force, f"Transfer {amount:,.0f} {direction} for portfolio {portfolio_id}?")
|
||||
detail = _with_service(
|
||||
ctx,
|
||||
lambda service, app_context: service.transfer(
|
||||
portfolio_id,
|
||||
TransferRequest(direction=direction, amount=amount, note=note),
|
||||
),
|
||||
)
|
||||
render_output(ctx.obj.console, ctx.obj.output_format, detail)
|
||||
|
||||
|
||||
@portfolio_app.command("reset")
|
||||
def reset_portfolio(
|
||||
ctx: typer.Context,
|
||||
portfolio_id: str = typer.Argument(..., help="Portfolio identifier."),
|
||||
infer_symbol: str | None = typer.Option(None, help="Fallback symbol when portfolio does not exist."),
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
|
||||
) -> None:
|
||||
_confirm(force, f"Reset portfolio {portfolio_id}?")
|
||||
detail = _with_service(
|
||||
ctx,
|
||||
lambda service, app_context: service.reset(portfolio_id, ResetPortfolioRequest(infer_symbol=infer_symbol)),
|
||||
)
|
||||
render_output(ctx.obj.console, ctx.obj.output_format, detail)
|
||||
|
||||
|
||||
@portfolio_app.command("delete")
|
||||
def delete_portfolio(
|
||||
ctx: typer.Context,
|
||||
portfolio_id: str = typer.Argument(..., help="Portfolio identifier."),
|
||||
keep_ledger: bool = typer.Option(False, help="Preserve ledger history."),
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
|
||||
) -> None:
|
||||
suffix = " and keep ledger" if keep_ledger else ""
|
||||
_confirm(force, f"Delete portfolio {portfolio_id}{suffix}?")
|
||||
_with_service(
|
||||
ctx,
|
||||
lambda service, app_context: service.delete(portfolio_id, keep_ledger=keep_ledger),
|
||||
)
|
||||
if ctx.obj.output_format == "json":
|
||||
render_output(ctx.obj.console, ctx.obj.output_format, {"deleted": portfolio_id, "keep_ledger": keep_ledger})
|
||||
else:
|
||||
ctx.obj.console.print(f"Deleted portfolio: {portfolio_id}")
|
||||
@@ -0,0 +1 @@
|
||||
"""CLI helpers."""
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Formatting and execution helpers for the CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from typing import Any, Callable, TypeVar
|
||||
|
||||
import typer
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from app.core.errors import FeatureNotReadyError
|
||||
from app.schemas.portfolio import PortfolioDetailResponse, PortfolioListItem
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def format_currency(value: float | None) -> str:
|
||||
if value is None:
|
||||
return "N/A"
|
||||
return f"{value:,.0f}"
|
||||
|
||||
|
||||
def to_plain_data(value: Any) -> Any:
|
||||
if isinstance(value, BaseModel):
|
||||
return value.model_dump(mode="json")
|
||||
if is_dataclass(value):
|
||||
return asdict(value)
|
||||
if isinstance(value, list):
|
||||
return [to_plain_data(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {key: to_plain_data(item) for key, item in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def print_portfolio_table(console: Console, portfolios: list[PortfolioListItem]) -> None:
|
||||
table = Table(title="Portfolios", expand=True)
|
||||
table.add_column("ID", style="cyan", no_wrap=True)
|
||||
table.add_column("Broker")
|
||||
table.add_column("Status", no_wrap=True)
|
||||
table.add_column("Symbol", no_wrap=True)
|
||||
table.add_column("Cash", justify="right")
|
||||
table.add_column("Qty", justify="right")
|
||||
table.add_column("Price", justify="right")
|
||||
table.add_column("Buffer", justify="right")
|
||||
table.add_column("Total", justify="right")
|
||||
for item in portfolios:
|
||||
table.add_row(
|
||||
item.portfolio_id,
|
||||
item.broker,
|
||||
item.status,
|
||||
item.symbol,
|
||||
format_currency(item.cash_balance),
|
||||
f"{item.asset_quantity:g}",
|
||||
format_currency(item.price),
|
||||
format_currency(item.profit_buffer),
|
||||
format_currency(item.total_value),
|
||||
)
|
||||
console.print(table)
|
||||
|
||||
|
||||
def print_portfolio_detail(console: Console, detail: PortfolioDetailResponse) -> None:
|
||||
portfolio = detail.portfolio
|
||||
position_value = float(portfolio.asset_quantity) * float(portfolio.price)
|
||||
bot_total = float(portfolio.cash_balance) + position_value
|
||||
user_total = bot_total + float(portfolio.profit_buffer)
|
||||
panel = Panel.fit(
|
||||
"\n".join(
|
||||
[
|
||||
f"id: {portfolio.portfolio_id}",
|
||||
f"broker: {portfolio.broker}",
|
||||
f"status: {portfolio.status}",
|
||||
f"symbol: {portfolio.symbol}",
|
||||
f"strategy: {portfolio.strategy_name}",
|
||||
f"cash_balance: {format_currency(portfolio.cash_balance)}",
|
||||
f"asset_quantity: {portfolio.asset_quantity:g}",
|
||||
f"price: {format_currency(portfolio.price)}",
|
||||
f"profit_buffer: {format_currency(portfolio.profit_buffer)}",
|
||||
f"position_value: {format_currency(position_value)}",
|
||||
f"bot_total: {format_currency(bot_total)}",
|
||||
f"user_total: {format_currency(user_total)}",
|
||||
f"live_quantity: {detail.live_quantity if detail.live_quantity is not None else 'N/A'}",
|
||||
]
|
||||
),
|
||||
title="Portfolio Status",
|
||||
)
|
||||
console.print(panel)
|
||||
|
||||
if detail.strategy_set is not None:
|
||||
console.print(
|
||||
Panel.fit(
|
||||
"\n".join(
|
||||
[
|
||||
f"id: {detail.strategy_set.id}",
|
||||
f"name: {detail.strategy_set.strategy_name}",
|
||||
f"config: {detail.strategy_set.strategy_config}",
|
||||
]
|
||||
),
|
||||
title="Strategy Set",
|
||||
)
|
||||
)
|
||||
|
||||
ledger_table = Table(title="Recent Ledger Entries", expand=True)
|
||||
ledger_table.add_column("Recorded At", style="cyan", no_wrap=True)
|
||||
ledger_table.add_column("Action")
|
||||
ledger_table.add_column("Qty", justify="right")
|
||||
ledger_table.add_column("Price", justify="right")
|
||||
ledger_table.add_column("Cash After", justify="right")
|
||||
ledger_table.add_column("Qty After", justify="right")
|
||||
ledger_table.add_column("Buffer After", justify="right")
|
||||
for entry in detail.recent_entries:
|
||||
ledger_table.add_row(
|
||||
entry.recorded_at.isoformat(timespec="seconds"),
|
||||
entry.action,
|
||||
f"{entry.quantity:g}",
|
||||
format_currency(entry.price),
|
||||
format_currency(entry.cash_balance_after),
|
||||
f"{entry.asset_quantity_after:g}",
|
||||
format_currency(entry.profit_buffer_after),
|
||||
)
|
||||
console.print(ledger_table)
|
||||
|
||||
|
||||
def print_json(console: Console, payload: Any) -> None:
|
||||
console.print_json(data=to_plain_data(payload))
|
||||
|
||||
|
||||
def render_output(console: Console, output_format: str, payload: Any, *, empty_message: str | None = None) -> None:
|
||||
if output_format == "json":
|
||||
print_json(console, payload)
|
||||
return
|
||||
|
||||
if isinstance(payload, list):
|
||||
if not payload and empty_message:
|
||||
console.print(empty_message)
|
||||
return
|
||||
print_portfolio_table(console, payload)
|
||||
return
|
||||
|
||||
if isinstance(payload, PortfolioDetailResponse):
|
||||
print_portfolio_detail(console, payload)
|
||||
return
|
||||
|
||||
console.print(payload)
|
||||
|
||||
|
||||
def execute_command(console: Console, callback: Callable[[], T]) -> T:
|
||||
try:
|
||||
return callback()
|
||||
except ValidationError as exc:
|
||||
console.print(f"Input validation failed: {exc}")
|
||||
raise typer.Exit(code=1) from exc
|
||||
except FeatureNotReadyError as exc:
|
||||
console.print(str(exc))
|
||||
raise typer.Exit(code=1) from exc
|
||||
except ValueError as exc:
|
||||
console.print(str(exc))
|
||||
raise typer.Exit(code=1) from exc
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Typer entrypoint for PortfolioHub."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
|
||||
from app.cli.commands.health import register_health_commands
|
||||
from app.cli.commands.portfolio import portfolio_app
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.repositories.factory import get_portfolio_repository_from_settings
|
||||
from app.repositories.portfolio_repository import PortfolioRepository
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppContext:
|
||||
settings: Settings
|
||||
output_format: str
|
||||
console: Console
|
||||
repository_factory: Callable[[Settings], PortfolioRepository]
|
||||
|
||||
|
||||
def create_app(
|
||||
*,
|
||||
settings_factory: Callable[[], Settings] = get_settings,
|
||||
repository_factory: Callable[[Settings], PortfolioRepository] = get_portfolio_repository_from_settings,
|
||||
) -> typer.Typer:
|
||||
app = typer.Typer(help="PortfolioHub command line interface.", no_args_is_help=True)
|
||||
|
||||
@app.callback()
|
||||
def main_callback(
|
||||
ctx: typer.Context,
|
||||
output_format: str = typer.Option("table", "--format", help="Output format: table or json."),
|
||||
) -> None:
|
||||
settings = settings_factory()
|
||||
console = Console(no_color=True, width=160)
|
||||
ctx.obj = AppContext(
|
||||
settings=settings,
|
||||
output_format=output_format,
|
||||
console=console,
|
||||
repository_factory=repository_factory,
|
||||
)
|
||||
|
||||
app.add_typer(portfolio_app, name="portfolio")
|
||||
register_health_commands(app)
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
app()
|
||||
@@ -0,0 +1 @@
|
||||
"""Core application modules."""
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Bearer token authentication and authorization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
|
||||
|
||||
class Permission(StrEnum):
|
||||
READ = "read"
|
||||
TRADE_WRITE = "trade-write"
|
||||
ADMIN = "admin"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthContext:
|
||||
token: str
|
||||
permission: Permission
|
||||
|
||||
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def _resolve_permission(token: str, settings: Settings) -> Permission | None:
|
||||
if token in settings.admin_tokens:
|
||||
return Permission.ADMIN
|
||||
if token in settings.trade_write_tokens:
|
||||
return Permission.TRADE_WRITE
|
||||
if token in settings.read_tokens:
|
||||
return Permission.READ
|
||||
return None
|
||||
|
||||
|
||||
def _has_permission(current: Permission, required: Permission) -> bool:
|
||||
if current == Permission.ADMIN:
|
||||
return True
|
||||
if current == required:
|
||||
return True
|
||||
if current == Permission.TRADE_WRITE and required == Permission.READ:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def require_permission(
|
||||
required: Permission,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> AuthContext:
|
||||
if credentials is None or credentials.scheme.lower() != "bearer":
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token")
|
||||
|
||||
permission = _resolve_permission(credentials.credentials, settings)
|
||||
if permission is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
|
||||
if not _has_permission(permission, required):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permission")
|
||||
|
||||
return AuthContext(token=credentials.credentials, permission=permission)
|
||||
|
||||
|
||||
def permission_dependency(required: Permission):
|
||||
def dependency(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> AuthContext:
|
||||
return require_permission(required, credentials=credentials, settings=settings)
|
||||
|
||||
return dependency
|
||||
|
||||
|
||||
require_read = permission_dependency(Permission.READ)
|
||||
require_trade_write = permission_dependency(Permission.TRADE_WRITE)
|
||||
require_admin = permission_dependency(Permission.ADMIN)
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Application configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from pydantic import Field, computed_field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
# Load .env file from project root
|
||||
env_file = Path(__file__).parent.parent.parent / ".env"
|
||||
if env_file.exists():
|
||||
load_dotenv(env_file)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
app_name: str = "PortfolioHub"
|
||||
app_version: str = "0.1.0"
|
||||
environment: str = "development"
|
||||
allowed_origins_raw: str = Field(default="http://localhost:3000", validation_alias="PORTFOLIOHUB_ALLOWED_ORIGINS")
|
||||
read_tokens_raw: str = Field(default="", validation_alias="PORTFOLIOHUB_READ_TOKENS")
|
||||
trade_write_tokens_raw: str = Field(default="", validation_alias="PORTFOLIOHUB_TRADE_WRITE_TOKENS")
|
||||
admin_tokens_raw: str = Field(default="", validation_alias="PORTFOLIOHUB_ADMIN_TOKENS")
|
||||
db_enabled: bool = Field(default=False, validation_alias="PORTFOLIOHUB_DB_ENABLED")
|
||||
|
||||
# External services configuration
|
||||
timescaledb_host: str = Field(default="localhost", validation_alias="TIMESCALEDB_HOST")
|
||||
timescaledb_port: int = Field(default=5432, validation_alias="TIMESCALEDB_PORT")
|
||||
timescaledb_name: str = Field(default="portfoliohub", validation_alias="TIMESCALEDB_NAME")
|
||||
timescaledb_user: str = Field(default="portfoliohub", validation_alias="TIMESCALEDB_USER")
|
||||
timescaledb_password: str = Field(default="", validation_alias="TIMESCALEDB_PASSWORD")
|
||||
timescaledb_sslmode: str = Field(default="prefer", validation_alias="TIMESCALEDB_SSLMODE")
|
||||
timescaledb_connect_timeout_sec: int = Field(default=10, validation_alias="TIMESCALEDB_CONNECT_TIMEOUT_SEC")
|
||||
timescaledb_application_name: str = Field(default="portfoliohub", validation_alias="TIMESCALEDB_APPLICATION_NAME")
|
||||
|
||||
redis_host: str = Field(default="localhost", validation_alias="REDIS_HOST")
|
||||
redis_port: int = Field(default=6379, validation_alias="REDIS_PORT")
|
||||
redis_db: int = Field(default=0, validation_alias="REDIS_DB")
|
||||
redis_password: str = Field(default="", validation_alias="REDIS_PASSWORD")
|
||||
|
||||
loki_host: str = Field(default="localhost", validation_alias="LOKI_HOST")
|
||||
loki_port: int = Field(default=3100, validation_alias="LOKI_PORT")
|
||||
loki_enabled: bool = Field(default=False, validation_alias="LOKI_ENABLED")
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="PORTFOLIOHUB_",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def allowed_origins(self) -> list[str]:
|
||||
return [item.strip() for item in self.allowed_origins_raw.split(",") if item.strip()]
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def read_tokens(self) -> set[str]:
|
||||
return {item.strip() for item in self.read_tokens_raw.split(",") if item.strip()}
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def trade_write_tokens(self) -> set[str]:
|
||||
return {item.strip() for item in self.trade_write_tokens_raw.split(",") if item.strip()}
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def admin_tokens(self) -> set[str]:
|
||||
return {item.strip() for item in self.admin_tokens_raw.split(",") if item.strip()}
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def active_db_host(self) -> str:
|
||||
return self.timescaledb_host
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def active_db_port(self) -> int:
|
||||
return self.timescaledb_port
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def active_db_name(self) -> str:
|
||||
return self.timescaledb_name
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def active_db_user(self) -> str:
|
||||
return self.timescaledb_user
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def active_db_password(self) -> str:
|
||||
return self.timescaledb_password
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def active_db_sslmode(self) -> str:
|
||||
return self.timescaledb_sslmode
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def active_db_connect_timeout_sec(self) -> int:
|
||||
return self.timescaledb_connect_timeout_sec
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def active_db_application_name(self) -> str:
|
||||
return self.timescaledb_application_name
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def active_database_url(self) -> str:
|
||||
user = quote_plus(self.active_db_user)
|
||||
password = quote_plus(self.active_db_password)
|
||||
database = quote_plus(self.active_db_name)
|
||||
return (
|
||||
f"postgresql+psycopg://{user}:{password}@{self.active_db_host}:{self.active_db_port}/{database}"
|
||||
f"?sslmode={quote_plus(self.active_db_sslmode)}"
|
||||
f"&connect_timeout={self.active_db_connect_timeout_sec}"
|
||||
f"&application_name={quote_plus(self.active_db_application_name)}"
|
||||
)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Exception types and FastAPI handlers."""
|
||||
|
||||
from fastapi import FastAPI, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
|
||||
class PortfolioHubError(Exception):
|
||||
"""Base application exception."""
|
||||
|
||||
|
||||
class FeatureNotReadyError(PortfolioHubError):
|
||||
"""Raised when an endpoint contract exists but the implementation is pending."""
|
||||
|
||||
|
||||
def register_exception_handlers(app: FastAPI) -> None:
|
||||
@app.exception_handler(FeatureNotReadyError)
|
||||
async def handle_not_ready(_: Request, exc: FeatureNotReadyError) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
content={"detail": str(exc) or "Feature not implemented yet"},
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Database tooling for migrations and bootstrap."""
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Alembic helpers for PortfolioHub database migrations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
import psycopg
|
||||
|
||||
from app.core.config import Settings
|
||||
|
||||
|
||||
def get_alembic_config(settings: Settings) -> Config:
|
||||
project_root = Path(__file__).resolve().parents[2]
|
||||
config = Config(str(project_root / "alembic.ini"))
|
||||
config.set_main_option("script_location", str(project_root / "migrations"))
|
||||
config.set_main_option("sqlalchemy.url", settings.active_database_url.replace("%", "%%"))
|
||||
return config
|
||||
|
||||
|
||||
def upgrade_to_head(settings: Settings) -> None:
|
||||
command.upgrade(get_alembic_config(settings), "head")
|
||||
|
||||
|
||||
def bootstrap_or_upgrade_to_head(settings: Settings) -> str:
|
||||
if _has_alembic_version_table(settings):
|
||||
upgrade_to_head(settings)
|
||||
return "head"
|
||||
|
||||
if _has_legacy_portfolio_schema(settings):
|
||||
command.stamp(get_alembic_config(settings), "head")
|
||||
return "head"
|
||||
|
||||
upgrade_to_head(settings)
|
||||
return "head"
|
||||
|
||||
|
||||
def current_revision(settings: Settings) -> str | None:
|
||||
output: list[str] = []
|
||||
|
||||
def capture(message: str, *_args, **_kwargs) -> None:
|
||||
if message.strip():
|
||||
output.append(message.strip())
|
||||
|
||||
config = get_alembic_config(settings)
|
||||
command.current(config, verbose=False)
|
||||
return output[-1] if output else None
|
||||
|
||||
|
||||
def _connect(settings: Settings) -> psycopg.Connection:
|
||||
return psycopg.connect(
|
||||
host=settings.active_db_host,
|
||||
port=settings.active_db_port,
|
||||
dbname=settings.active_db_name,
|
||||
user=settings.active_db_user,
|
||||
password=settings.active_db_password,
|
||||
sslmode=settings.active_db_sslmode,
|
||||
connect_timeout=settings.active_db_connect_timeout_sec,
|
||||
application_name=settings.active_db_application_name,
|
||||
autocommit=True,
|
||||
)
|
||||
|
||||
|
||||
def _has_alembic_version_table(settings: Settings) -> bool:
|
||||
with _connect(settings) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT to_regclass('public.alembic_version')")
|
||||
return cur.fetchone()[0] is not None
|
||||
|
||||
|
||||
def _has_legacy_portfolio_schema(settings: Settings) -> bool:
|
||||
required_tables = {"strategy_sets", "portfolios", "portfolio_ledger"}
|
||||
with _connect(settings) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename = ANY(%s)",
|
||||
(list(required_tables),),
|
||||
)
|
||||
found = {row[0] for row in cur.fetchall()}
|
||||
return found == required_tables
|
||||
@@ -0,0 +1 @@
|
||||
"""Domain models and services for PortfolioHub."""
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Portfolio domain models."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
|
||||
@dataclass
|
||||
class StrategySet:
|
||||
id: str
|
||||
strategy_name: str
|
||||
strategy_config: str
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Portfolio:
|
||||
portfolio_id: str
|
||||
broker: str
|
||||
status: str
|
||||
symbol: str
|
||||
strategy_name: str
|
||||
strategy_params: dict[str, Any] = field(default_factory=dict)
|
||||
cash_balance: float = 0.0
|
||||
asset_quantity: float = 0.0
|
||||
price: float = 0.0
|
||||
profit_buffer: float = 0.0
|
||||
current_strategy_set_id: str | None = None
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
last_trade_at: datetime | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class LedgerEntry:
|
||||
portfolio_id: str
|
||||
recorded_at: datetime
|
||||
action: str
|
||||
price: float
|
||||
quantity: float
|
||||
cash_balance_after: float
|
||||
asset_quantity_after: float
|
||||
total_value_after: float
|
||||
fee_amount: float = 0.0
|
||||
tax_amount: float = 0.0
|
||||
profit_buffer_after: float = 0.0
|
||||
strategy_set_id: str = "legacy"
|
||||
note: str = ""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class LiveAssetSource(Protocol):
|
||||
def get_asset_quantity(self, symbol: str) -> float: ...
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Portfolio domain services ported from jsbot-kis."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Protocol
|
||||
|
||||
from app.domain.models import LedgerEntry, LiveAssetSource, Portfolio, StrategySet
|
||||
|
||||
_NOT_FOUND_MSG = "포트폴리오를 찾을 수 없습니다"
|
||||
_INVEST_ACTION_MAP = {
|
||||
"deposit": "INVEST",
|
||||
"profit": "PROFIT",
|
||||
"withdraw": "WITHDRAW",
|
||||
}
|
||||
|
||||
|
||||
class PortfolioDomainRepository(Protocol):
|
||||
def get(self, portfolio_id: str) -> Portfolio: ...
|
||||
def get_or_create(
|
||||
self,
|
||||
portfolio_id: str,
|
||||
symbol: str,
|
||||
*,
|
||||
broker: str = "kis",
|
||||
status: str = "enabled",
|
||||
strategy_name: str = "",
|
||||
strategy_params: dict | None = None,
|
||||
initial_cash: float = 0.0,
|
||||
) -> Portfolio: ...
|
||||
def create_portfolio(
|
||||
self,
|
||||
portfolio_id: str,
|
||||
*,
|
||||
broker: str,
|
||||
status: str,
|
||||
symbol: str,
|
||||
strategy_name: str,
|
||||
strategy_params: dict,
|
||||
cash_balance: float,
|
||||
asset_quantity: float,
|
||||
asset_price: float,
|
||||
profit_buffer: float,
|
||||
) -> Portfolio: ...
|
||||
def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio: ...
|
||||
def list_portfolios(self, *, broker: str | None = None, status: str | None = None) -> list[Portfolio]: ...
|
||||
def get_strategy_set(self, id: str) -> StrategySet | None: ...
|
||||
def get_or_create_strategy_set(self, strategy_name: str, strategy_config: str) -> StrategySet: ...
|
||||
def save(
|
||||
self,
|
||||
portfolio: Portfolio,
|
||||
*,
|
||||
current_price: float,
|
||||
action: str,
|
||||
trade_price: float,
|
||||
quantity: float,
|
||||
fee_amount: float,
|
||||
tax_amount: float,
|
||||
total_value_after: float,
|
||||
strategy_set_id: str = "manual",
|
||||
note: str = "",
|
||||
) -> None: ...
|
||||
def list_recent_trades(self, portfolio_id: str, limit: int = 5) -> list[LedgerEntry]: ...
|
||||
def reset(self, portfolio_id: str) -> Portfolio: ...
|
||||
def adjust_buffer(self, portfolio_id: str, amount: float, action: str, note: str = "") -> Portfolio: ...
|
||||
def transfer_buffer(self, portfolio_id: str, amount: float, direction: str, note: str = "") -> Portfolio: ...
|
||||
def delete(self, portfolio_id: str, clear_ledger: bool = True) -> None: ...
|
||||
|
||||
|
||||
def apply_buy(
|
||||
repo: PortfolioDomainRepository,
|
||||
portfolio_id: str,
|
||||
*,
|
||||
quantity: float,
|
||||
price: float,
|
||||
fee: float = 0.0,
|
||||
tax: float = 0.0,
|
||||
note: str = "",
|
||||
) -> Portfolio:
|
||||
quantity = float(quantity)
|
||||
price = float(price)
|
||||
fee = float(fee)
|
||||
tax = float(tax)
|
||||
|
||||
if quantity <= 0:
|
||||
raise ValueError("quantity는 0보다 커야 합니다")
|
||||
if price <= 0:
|
||||
raise ValueError("price는 0보다 커야 합니다")
|
||||
if fee < 0 or tax < 0:
|
||||
raise ValueError("fee/tax는 음수가 될 수 없습니다")
|
||||
|
||||
portfolio = repo.get(portfolio_id)
|
||||
gross = quantity * price
|
||||
total_cost = gross + fee + tax
|
||||
if float(portfolio.cash_balance) < total_cost:
|
||||
raise ValueError("현금 잔고가 부족합니다")
|
||||
|
||||
updated = Portfolio(
|
||||
portfolio_id=portfolio.portfolio_id,
|
||||
broker=portfolio.broker,
|
||||
status=portfolio.status,
|
||||
symbol=portfolio.symbol,
|
||||
strategy_name=portfolio.strategy_name,
|
||||
strategy_params=portfolio.strategy_params,
|
||||
cash_balance=float(portfolio.cash_balance) - total_cost,
|
||||
asset_quantity=float(portfolio.asset_quantity) + quantity,
|
||||
price=price,
|
||||
profit_buffer=float(portfolio.profit_buffer),
|
||||
current_strategy_set_id=portfolio.current_strategy_set_id,
|
||||
created_at=portfolio.created_at,
|
||||
updated_at=portfolio.updated_at,
|
||||
last_trade_at=portfolio.last_trade_at,
|
||||
)
|
||||
total_value_after = float(updated.cash_balance) + float(updated.asset_quantity) * price
|
||||
repo.save(
|
||||
updated,
|
||||
current_price=price,
|
||||
action="BUY",
|
||||
trade_price=price,
|
||||
quantity=quantity,
|
||||
fee_amount=fee,
|
||||
tax_amount=tax,
|
||||
total_value_after=total_value_after,
|
||||
strategy_set_id=portfolio.current_strategy_set_id or "manual",
|
||||
note=note,
|
||||
)
|
||||
return repo.get(portfolio_id)
|
||||
|
||||
|
||||
def apply_sell(
|
||||
repo: PortfolioDomainRepository,
|
||||
portfolio_id: str,
|
||||
*,
|
||||
quantity: float,
|
||||
price: float,
|
||||
fee: float = 0.0,
|
||||
tax: float = 0.0,
|
||||
note: str = "",
|
||||
) -> Portfolio:
|
||||
quantity = float(quantity)
|
||||
price = float(price)
|
||||
fee = float(fee)
|
||||
tax = float(tax)
|
||||
|
||||
if quantity <= 0:
|
||||
raise ValueError("quantity는 0보다 커야 합니다")
|
||||
if price <= 0:
|
||||
raise ValueError("price는 0보다 커야 합니다")
|
||||
if fee < 0 or tax < 0:
|
||||
raise ValueError("fee/tax는 음수가 될 수 없습니다")
|
||||
|
||||
portfolio = repo.get(portfolio_id)
|
||||
if float(portfolio.asset_quantity) < quantity:
|
||||
raise ValueError("보유 수량이 부족합니다")
|
||||
|
||||
gross = quantity * price
|
||||
net_proceeds = gross - fee - tax
|
||||
|
||||
updated = Portfolio(
|
||||
portfolio_id=portfolio.portfolio_id,
|
||||
broker=portfolio.broker,
|
||||
status=portfolio.status,
|
||||
symbol=portfolio.symbol,
|
||||
strategy_name=portfolio.strategy_name,
|
||||
strategy_params=portfolio.strategy_params,
|
||||
cash_balance=float(portfolio.cash_balance) + net_proceeds,
|
||||
asset_quantity=float(portfolio.asset_quantity) - quantity,
|
||||
price=price,
|
||||
profit_buffer=float(portfolio.profit_buffer),
|
||||
current_strategy_set_id=portfolio.current_strategy_set_id,
|
||||
created_at=portfolio.created_at,
|
||||
updated_at=portfolio.updated_at,
|
||||
last_trade_at=portfolio.last_trade_at,
|
||||
)
|
||||
total_value_after = float(updated.cash_balance) + float(updated.asset_quantity) * price
|
||||
repo.save(
|
||||
updated,
|
||||
current_price=price,
|
||||
action="SELL",
|
||||
trade_price=price,
|
||||
quantity=quantity,
|
||||
fee_amount=fee,
|
||||
tax_amount=tax,
|
||||
total_value_after=total_value_after,
|
||||
strategy_set_id=portfolio.current_strategy_set_id or "manual",
|
||||
note=note,
|
||||
)
|
||||
return repo.get(portfolio_id)
|
||||
|
||||
|
||||
def apply_invest(
|
||||
repo: PortfolioDomainRepository,
|
||||
portfolio_id: str,
|
||||
*,
|
||||
movement_type: str,
|
||||
amount: float,
|
||||
note: str = "",
|
||||
) -> Portfolio:
|
||||
amount = float(amount)
|
||||
if amount <= 0:
|
||||
raise ValueError("--amount는 0보다 커야 합니다")
|
||||
|
||||
key = str(movement_type).lower()
|
||||
if key not in _INVEST_ACTION_MAP:
|
||||
raise ValueError("movement_type은 deposit, profit, withdraw 중 하나여야 합니다")
|
||||
|
||||
return repo.adjust_buffer(portfolio_id, amount, _INVEST_ACTION_MAP[key], note)
|
||||
|
||||
|
||||
def apply_transfer(
|
||||
repo: PortfolioDomainRepository,
|
||||
portfolio_id: str,
|
||||
*,
|
||||
direction: str,
|
||||
amount: float,
|
||||
note: str = "",
|
||||
) -> Portfolio:
|
||||
amount = float(amount)
|
||||
if amount <= 0:
|
||||
raise ValueError("--amount는 0보다 커야 합니다")
|
||||
return repo.transfer_buffer(portfolio_id, amount, direction, note)
|
||||
|
||||
|
||||
def apply_reset(
|
||||
repo: PortfolioDomainRepository,
|
||||
portfolio_id: str,
|
||||
*,
|
||||
infer_symbol: Callable[[str], str],
|
||||
) -> tuple[Portfolio, bool]:
|
||||
try:
|
||||
portfolio = repo.reset(portfolio_id)
|
||||
return portfolio, False
|
||||
except ValueError as exc:
|
||||
if str(exc) != _NOT_FOUND_MSG:
|
||||
raise
|
||||
symbol = infer_symbol(portfolio_id)
|
||||
portfolio = repo.get_or_create(portfolio_id, symbol, initial_cash=0.0)
|
||||
return portfolio, True
|
||||
|
||||
|
||||
def apply_delete(
|
||||
repo: PortfolioDomainRepository,
|
||||
portfolio_id: str,
|
||||
*,
|
||||
clear_ledger: bool = True,
|
||||
) -> None:
|
||||
repo.delete(portfolio_id, clear_ledger=clear_ledger)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PortfolioStatusResult:
|
||||
portfolio: Portfolio
|
||||
recent_entries: list[LedgerEntry]
|
||||
strategy_set: StrategySet | None
|
||||
live_quantity: float | None
|
||||
|
||||
|
||||
def get_portfolio_status(
|
||||
repo: PortfolioDomainRepository,
|
||||
portfolio_id: str,
|
||||
*,
|
||||
recent_trades: int = 5,
|
||||
live_source: LiveAssetSource | None = None,
|
||||
) -> PortfolioStatusResult:
|
||||
portfolio = repo.get(portfolio_id)
|
||||
recent_entries = repo.list_recent_trades(portfolio_id, limit=recent_trades)
|
||||
strategy_set = None
|
||||
if portfolio.current_strategy_set_id:
|
||||
strategy_set = repo.get_strategy_set(portfolio.current_strategy_set_id)
|
||||
|
||||
live_quantity = None
|
||||
if live_source is not None:
|
||||
live_quantity = live_source.get_asset_quantity(portfolio.symbol)
|
||||
|
||||
return PortfolioStatusResult(
|
||||
portfolio=portfolio,
|
||||
recent_entries=recent_entries,
|
||||
strategy_set=strategy_set,
|
||||
live_quantity=live_quantity,
|
||||
)
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
"""FastAPI application entrypoint."""
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.health import router as health_router
|
||||
from app.api.portfolios import router as portfolio_router
|
||||
from app.core.config import get_settings
|
||||
from app.core.errors import register_exception_handlers
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = get_settings()
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
version=settings.app_version,
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.allowed_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Authorization", "Content-Type"],
|
||||
)
|
||||
|
||||
app.include_router(health_router)
|
||||
app.include_router(portfolio_router)
|
||||
register_exception_handlers(app)
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1 @@
|
||||
"""Repository layer for PortfolioHub."""
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Repository factory helpers shared by API and CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.core.errors import FeatureNotReadyError
|
||||
from app.repositories.postgres import PortfolioDBConfig, PostgresPortfolioRepository
|
||||
from app.repositories.portfolio_repository import PortfolioRepository
|
||||
|
||||
|
||||
class UnimplementedPortfolioRepository:
|
||||
def __getattr__(self, _: str) -> object:
|
||||
raise FeatureNotReadyError("Portfolio persistence is not implemented yet")
|
||||
|
||||
|
||||
def build_portfolio_db_config(settings: Settings) -> PortfolioDBConfig:
|
||||
return PortfolioDBConfig(
|
||||
host=settings.active_db_host,
|
||||
port=settings.active_db_port,
|
||||
dbname=settings.active_db_name,
|
||||
user=settings.active_db_user,
|
||||
password=settings.active_db_password,
|
||||
sslmode=settings.active_db_sslmode,
|
||||
connect_timeout_sec=settings.active_db_connect_timeout_sec,
|
||||
application_name=settings.active_db_application_name,
|
||||
)
|
||||
|
||||
|
||||
def get_portfolio_repository_from_settings(settings: Settings) -> PortfolioRepository:
|
||||
if not settings.db_enabled:
|
||||
return UnimplementedPortfolioRepository()
|
||||
return PostgresPortfolioRepository(build_portfolio_db_config(settings))
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Portfolio repository contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from app.domain.models import LedgerEntry, Portfolio, StrategySet
|
||||
|
||||
|
||||
class PortfolioRepository(Protocol):
|
||||
def ping(self) -> None: ...
|
||||
|
||||
def get(self, portfolio_id: str) -> Portfolio: ...
|
||||
|
||||
def get_or_create(
|
||||
self,
|
||||
portfolio_id: str,
|
||||
symbol: str,
|
||||
*,
|
||||
broker: str = "kis",
|
||||
status: str = "enabled",
|
||||
strategy_name: str = "",
|
||||
strategy_params: dict | None = None,
|
||||
initial_cash: float = 0.0,
|
||||
) -> Portfolio: ...
|
||||
|
||||
def create_portfolio(
|
||||
self,
|
||||
portfolio_id: str,
|
||||
*,
|
||||
broker: str,
|
||||
status: str,
|
||||
symbol: str,
|
||||
strategy_name: str,
|
||||
strategy_params: dict,
|
||||
cash_balance: float,
|
||||
asset_quantity: float,
|
||||
asset_price: float,
|
||||
profit_buffer: float,
|
||||
) -> Portfolio: ...
|
||||
|
||||
def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio: ...
|
||||
|
||||
def list_portfolios(self, *, broker: str | None = None, status: str | None = None) -> list[Portfolio]: ...
|
||||
|
||||
def get_or_create_strategy_set(self, strategy_name: str, strategy_config: str) -> StrategySet: ...
|
||||
|
||||
def get_strategy_set(self, id: str) -> StrategySet | None: ...
|
||||
|
||||
def save(
|
||||
self,
|
||||
portfolio: Portfolio,
|
||||
*,
|
||||
current_price: float,
|
||||
action: str,
|
||||
trade_price: float,
|
||||
quantity: float,
|
||||
fee_amount: float,
|
||||
tax_amount: float,
|
||||
total_value_after: float,
|
||||
strategy_set_id: str = "manual",
|
||||
note: str = "",
|
||||
) -> None: ...
|
||||
|
||||
def list_recent_trades(self, portfolio_id: str, limit: int = 5) -> list[LedgerEntry]: ...
|
||||
|
||||
def reset(self, portfolio_id: str) -> Portfolio: ...
|
||||
|
||||
def adjust_buffer(self, portfolio_id: str, amount: float, action: str, note: str = "") -> Portfolio: ...
|
||||
|
||||
def transfer_buffer(self, portfolio_id: str, amount: float, direction: str, note: str = "") -> Portfolio: ...
|
||||
|
||||
def delete(self, portfolio_id: str, *, clear_ledger: bool = True) -> None: ...
|
||||
|
||||
@@ -0,0 +1,687 @@
|
||||
"""Postgres repository implementation for portfolios and ledger."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import psycopg
|
||||
from psycopg import sql
|
||||
|
||||
from app.domain.models import LedgerEntry, Portfolio, StrategySet
|
||||
|
||||
_SELECT_PORTFOLIO = """
|
||||
SELECT
|
||||
portfolio_id,
|
||||
broker,
|
||||
status,
|
||||
symbol,
|
||||
strategy_name,
|
||||
strategy_params,
|
||||
cash_balance,
|
||||
asset_quantity,
|
||||
asset_price,
|
||||
profit_buffer,
|
||||
current_strategy_set_id,
|
||||
created_at,
|
||||
updated_at,
|
||||
last_trade_at
|
||||
FROM portfolios
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PortfolioDBConfig:
|
||||
host: str
|
||||
port: int
|
||||
dbname: str
|
||||
user: str
|
||||
password: str
|
||||
sslmode: str
|
||||
connect_timeout_sec: int = 10
|
||||
application_name: str = "portfoliohub"
|
||||
|
||||
|
||||
class PostgresPortfolioRepository:
|
||||
def __init__(self, config: PortfolioDBConfig) -> None:
|
||||
self._config = config
|
||||
self._connection: psycopg.Connection | None = None
|
||||
|
||||
def _connect(self) -> psycopg.Connection:
|
||||
if self._connection is None:
|
||||
self._connection = psycopg.connect(
|
||||
host=self._config.host,
|
||||
port=self._config.port,
|
||||
dbname=self._config.dbname,
|
||||
user=self._config.user,
|
||||
password=self._config.password,
|
||||
sslmode=self._config.sslmode,
|
||||
connect_timeout=self._config.connect_timeout_sec,
|
||||
application_name=self._config.application_name,
|
||||
autocommit=True,
|
||||
)
|
||||
return self._connection
|
||||
|
||||
def close(self) -> None:
|
||||
if self._connection is not None:
|
||||
self._connection.close()
|
||||
self._connection = None
|
||||
|
||||
def ping(self) -> None:
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute("SELECT 1")
|
||||
cur.fetchone()
|
||||
|
||||
def ensure_schema(self) -> None:
|
||||
conn = self._connect()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS strategy_sets (
|
||||
id TEXT PRIMARY KEY,
|
||||
strategy_name TEXT NOT NULL,
|
||||
strategy_config TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (strategy_name, strategy_config)
|
||||
)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS portfolios (
|
||||
portfolio_id TEXT PRIMARY KEY,
|
||||
broker TEXT NOT NULL DEFAULT 'kis',
|
||||
status TEXT NOT NULL DEFAULT 'enabled',
|
||||
symbol TEXT NOT NULL,
|
||||
strategy_name TEXT NOT NULL DEFAULT '',
|
||||
strategy_params JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
cash_balance DOUBLE PRECISION NOT NULL,
|
||||
asset_quantity DOUBLE PRECISION NOT NULL,
|
||||
asset_price DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
profit_buffer DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
current_strategy_set_id TEXT DEFAULT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_trade_at TIMESTAMPTZ
|
||||
)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS portfolio_ledger (
|
||||
portfolio_id TEXT NOT NULL,
|
||||
recorded_at TIMESTAMPTZ NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
price DOUBLE PRECISION NOT NULL,
|
||||
quantity DOUBLE PRECISION NOT NULL,
|
||||
fee_amount DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
tax_amount DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
cash_balance_after DOUBLE PRECISION NOT NULL,
|
||||
asset_quantity_after DOUBLE PRECISION NOT NULL,
|
||||
total_value_after DOUBLE PRECISION NOT NULL,
|
||||
profit_buffer_after DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
strategy_set_id TEXT NOT NULL DEFAULT 'legacy',
|
||||
note TEXT NOT NULL DEFAULT ''
|
||||
)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_portfolio_ledger_portfolio_time
|
||||
ON portfolio_ledger (portfolio_id, recorded_at DESC)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_portfolios_broker_status_updated
|
||||
ON portfolios (broker, status, updated_at DESC)
|
||||
"""
|
||||
)
|
||||
cur.execute("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb')")
|
||||
if cur.fetchone()[0]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT create_hypertable('portfolio_ledger', 'recorded_at', if_not_exists => TRUE)
|
||||
"""
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _row_to_portfolio(row: Any) -> Portfolio:
|
||||
if row is None:
|
||||
raise ValueError("포트폴리오를 찾을 수 없습니다")
|
||||
|
||||
raw_params = row[5] if len(row) > 5 else {}
|
||||
if isinstance(raw_params, str):
|
||||
try:
|
||||
strategy_params = json.loads(raw_params) if raw_params else {}
|
||||
except json.JSONDecodeError:
|
||||
strategy_params = {}
|
||||
elif isinstance(raw_params, dict):
|
||||
strategy_params = raw_params
|
||||
else:
|
||||
strategy_params = {}
|
||||
|
||||
return Portfolio(
|
||||
portfolio_id=row[0],
|
||||
broker=row[1] or "kis",
|
||||
status=row[2] or "enabled",
|
||||
symbol=row[3],
|
||||
strategy_name=row[4] or "",
|
||||
strategy_params=strategy_params,
|
||||
cash_balance=float(row[6]),
|
||||
asset_quantity=float(row[7]),
|
||||
price=float(row[8]),
|
||||
profit_buffer=float(row[9]),
|
||||
current_strategy_set_id=row[10] if len(row) > 10 else None,
|
||||
created_at=row[11] if len(row) > 11 else None,
|
||||
updated_at=row[12] if len(row) > 12 else None,
|
||||
last_trade_at=row[13] if len(row) > 13 else None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _row_to_ledger_entry(row: Any) -> LedgerEntry:
|
||||
return LedgerEntry(
|
||||
portfolio_id=row[0],
|
||||
recorded_at=row[1],
|
||||
action=row[2],
|
||||
price=float(row[3]),
|
||||
quantity=float(row[4]),
|
||||
fee_amount=float(row[5]),
|
||||
tax_amount=float(row[6]),
|
||||
cash_balance_after=float(row[7]),
|
||||
asset_quantity_after=float(row[8]),
|
||||
total_value_after=float(row[9]),
|
||||
profit_buffer_after=float(row[10]),
|
||||
strategy_set_id=row[11] or "legacy",
|
||||
note=row[12] or "",
|
||||
)
|
||||
|
||||
def get(self, portfolio_id: str) -> Portfolio:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(_SELECT_PORTFOLIO + "WHERE portfolio_id = %s", (portfolio_id,))
|
||||
row = cur.fetchone()
|
||||
return self._row_to_portfolio(row)
|
||||
|
||||
def get_or_create(
|
||||
self,
|
||||
portfolio_id: str,
|
||||
symbol: str,
|
||||
*,
|
||||
broker: str = "kis",
|
||||
status: str = "enabled",
|
||||
strategy_name: str = "",
|
||||
strategy_params: dict[str, Any] | None = None,
|
||||
initial_cash: float = 0.0,
|
||||
) -> Portfolio:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO portfolios (
|
||||
portfolio_id, broker, status, symbol, strategy_name, strategy_params,
|
||||
cash_balance, asset_quantity, asset_price, profit_buffer
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s, %s, %s, %s)
|
||||
ON CONFLICT (portfolio_id) DO NOTHING
|
||||
""",
|
||||
(
|
||||
portfolio_id,
|
||||
broker,
|
||||
status,
|
||||
symbol,
|
||||
strategy_name,
|
||||
json.dumps(strategy_params or {}),
|
||||
float(initial_cash),
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
),
|
||||
)
|
||||
cur.execute(_SELECT_PORTFOLIO + "WHERE portfolio_id = %s", (portfolio_id,))
|
||||
row = cur.fetchone()
|
||||
return self._row_to_portfolio(row)
|
||||
|
||||
def create_portfolio(
|
||||
self,
|
||||
portfolio_id: str,
|
||||
*,
|
||||
broker: str,
|
||||
status: str,
|
||||
symbol: str,
|
||||
strategy_name: str,
|
||||
strategy_params: dict,
|
||||
cash_balance: float,
|
||||
asset_quantity: float,
|
||||
asset_price: float,
|
||||
profit_buffer: float,
|
||||
) -> Portfolio:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute("SELECT 1 FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
|
||||
if cur.fetchone() is not None:
|
||||
raise ValueError(f"이미 존재하는 포트폴리오입니다: {portfolio_id}")
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO portfolios (
|
||||
portfolio_id, broker, status, symbol, strategy_name, strategy_params,
|
||||
cash_balance, asset_quantity, asset_price, profit_buffer
|
||||
) VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s, %s, %s, %s)
|
||||
RETURNING
|
||||
portfolio_id, broker, status, symbol, strategy_name, strategy_params,
|
||||
cash_balance, asset_quantity, asset_price, profit_buffer,
|
||||
current_strategy_set_id, created_at, updated_at, last_trade_at
|
||||
""",
|
||||
(
|
||||
portfolio_id,
|
||||
broker,
|
||||
status,
|
||||
symbol,
|
||||
strategy_name,
|
||||
json.dumps(strategy_params),
|
||||
float(cash_balance),
|
||||
float(asset_quantity),
|
||||
float(asset_price),
|
||||
float(profit_buffer),
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return self._row_to_portfolio(row)
|
||||
|
||||
def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio:
|
||||
self.ensure_schema()
|
||||
allowed = {
|
||||
"broker": "broker",
|
||||
"status": "status",
|
||||
"symbol": "symbol",
|
||||
"strategy_name": "strategy_name",
|
||||
"strategy_params": "strategy_params",
|
||||
"asset_price": "asset_price",
|
||||
"current_strategy_set_id": "current_strategy_set_id",
|
||||
}
|
||||
assignments: list[str] = []
|
||||
params: list[object] = []
|
||||
for key, value in fields.items():
|
||||
if value is None or key not in allowed:
|
||||
continue
|
||||
if key == "strategy_params":
|
||||
assignments.append("strategy_params = %s::jsonb")
|
||||
params.append(json.dumps(value))
|
||||
else:
|
||||
assignments.append(f"{allowed[key]} = %s")
|
||||
params.append(value)
|
||||
if not assignments:
|
||||
return self.get(portfolio_id)
|
||||
|
||||
assignments.append("updated_at = NOW()")
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
UPDATE portfolios
|
||||
SET {', '.join(assignments)}
|
||||
WHERE portfolio_id = %s
|
||||
RETURNING
|
||||
portfolio_id, broker, status, symbol, strategy_name, strategy_params,
|
||||
cash_balance, asset_quantity, asset_price, profit_buffer,
|
||||
current_strategy_set_id, created_at, updated_at, last_trade_at
|
||||
""",
|
||||
(*params, portfolio_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return self._row_to_portfolio(row)
|
||||
|
||||
def list_portfolios(self, *, broker: str | None = None, status: str | None = None) -> list[Portfolio]:
|
||||
self.ensure_schema()
|
||||
query = _SELECT_PORTFOLIO
|
||||
clauses: list[str] = []
|
||||
params: list[object] = []
|
||||
if broker is not None:
|
||||
clauses.append("broker = %s")
|
||||
params.append(broker)
|
||||
if status is not None:
|
||||
clauses.append("status = %s")
|
||||
params.append(status)
|
||||
if clauses:
|
||||
query += "WHERE " + " AND ".join(clauses) + " "
|
||||
query += "ORDER BY updated_at DESC, portfolio_id ASC"
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(query, tuple(params))
|
||||
rows = cur.fetchall()
|
||||
return [self._row_to_portfolio(row) for row in rows]
|
||||
|
||||
def get_or_create_strategy_set(self, strategy_name: str, strategy_config: str) -> StrategySet:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
new_id = str(uuid.uuid4())
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO strategy_sets (id, strategy_name, strategy_config)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (strategy_name, strategy_config) DO NOTHING
|
||||
""",
|
||||
(new_id, strategy_name, strategy_config),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, strategy_name, strategy_config, created_at
|
||||
FROM strategy_sets
|
||||
WHERE strategy_name = %s AND strategy_config = %s
|
||||
""",
|
||||
(strategy_name, strategy_config),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return StrategySet(id=row[0], strategy_name=row[1], strategy_config=row[2], created_at=row[3])
|
||||
|
||||
def get_strategy_set(self, id: str) -> StrategySet | None:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id, strategy_name, strategy_config, created_at FROM strategy_sets WHERE id = %s",
|
||||
(id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return StrategySet(id=row[0], strategy_name=row[1], strategy_config=row[2], created_at=row[3])
|
||||
|
||||
def save(
|
||||
self,
|
||||
portfolio: Portfolio,
|
||||
*,
|
||||
current_price: float,
|
||||
action: str,
|
||||
trade_price: float,
|
||||
quantity: float,
|
||||
fee_amount: float,
|
||||
tax_amount: float,
|
||||
total_value_after: float,
|
||||
strategy_set_id: str = "manual",
|
||||
note: str = "",
|
||||
) -> None:
|
||||
self.ensure_schema()
|
||||
trade_time = datetime.now(timezone.utc)
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE portfolios
|
||||
SET broker = %s,
|
||||
status = %s,
|
||||
symbol = %s,
|
||||
strategy_name = %s,
|
||||
strategy_params = %s::jsonb,
|
||||
cash_balance = %s,
|
||||
asset_quantity = %s,
|
||||
asset_price = %s,
|
||||
profit_buffer = %s,
|
||||
current_strategy_set_id = %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,
|
||||
portfolio.symbol,
|
||||
portfolio.strategy_name,
|
||||
json.dumps(portfolio.strategy_params),
|
||||
float(portfolio.cash_balance),
|
||||
float(portfolio.asset_quantity),
|
||||
float(current_price),
|
||||
float(portfolio.profit_buffer),
|
||||
portfolio.current_strategy_set_id,
|
||||
trade_time,
|
||||
action,
|
||||
trade_time,
|
||||
portfolio.portfolio_id,
|
||||
),
|
||||
)
|
||||
if action in {"BUY", "SELL"}:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO portfolio_ledger (
|
||||
portfolio_id, recorded_at, action, price, quantity, fee_amount,
|
||||
tax_amount, cash_balance_after, asset_quantity_after,
|
||||
total_value_after, profit_buffer_after, strategy_set_id, note
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
portfolio.portfolio_id,
|
||||
trade_time,
|
||||
action,
|
||||
float(trade_price),
|
||||
float(quantity),
|
||||
float(fee_amount),
|
||||
float(tax_amount),
|
||||
float(portfolio.cash_balance),
|
||||
float(portfolio.asset_quantity),
|
||||
float(total_value_after),
|
||||
float(portfolio.profit_buffer),
|
||||
strategy_set_id,
|
||||
note,
|
||||
),
|
||||
)
|
||||
|
||||
def list_recent_trades(self, portfolio_id: str, limit: int = 5) -> list[LedgerEntry]:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
portfolio_id, recorded_at, action, price, quantity, fee_amount,
|
||||
tax_amount, cash_balance_after, asset_quantity_after,
|
||||
total_value_after, profit_buffer_after, strategy_set_id, note
|
||||
FROM portfolio_ledger
|
||||
WHERE portfolio_id = %s
|
||||
ORDER BY recorded_at DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(portfolio_id, int(limit)),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return [self._row_to_ledger_entry(row) for row in rows]
|
||||
|
||||
def reset(self, portfolio_id: str) -> Portfolio:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE portfolios
|
||||
SET cash_balance = 0,
|
||||
asset_quantity = 0,
|
||||
asset_price = 0,
|
||||
profit_buffer = 0,
|
||||
current_strategy_set_id = NULL,
|
||||
updated_at = NOW(),
|
||||
last_trade_at = NULL
|
||||
WHERE portfolio_id = %s
|
||||
RETURNING
|
||||
portfolio_id, broker, status, symbol, strategy_name, strategy_params,
|
||||
cash_balance, asset_quantity, asset_price, profit_buffer,
|
||||
current_strategy_set_id, created_at, updated_at, last_trade_at
|
||||
""",
|
||||
(portfolio_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
raise ValueError("포트폴리오를 찾을 수 없습니다")
|
||||
cur.execute("DELETE FROM portfolio_ledger WHERE portfolio_id = %s", (portfolio_id,))
|
||||
return self._row_to_portfolio(row)
|
||||
|
||||
def adjust_buffer(self, portfolio_id: str, amount: float, action: str, note: str = "") -> Portfolio:
|
||||
self.ensure_schema()
|
||||
normalized = str(action).upper()
|
||||
if normalized not in {"INVEST", "PROFIT", "WITHDRAW"}:
|
||||
raise ValueError("action은 INVEST, PROFIT, WITHDRAW 중 하나여야 합니다")
|
||||
if amount <= 0:
|
||||
raise ValueError("amount는 0보다 커야 합니다")
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(_SELECT_PORTFOLIO + "WHERE portfolio_id = %s", (portfolio_id,))
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
raise ValueError("포트폴리오를 찾을 수 없습니다")
|
||||
portfolio = self._row_to_portfolio(row)
|
||||
updated_buffer = float(portfolio.profit_buffer)
|
||||
if normalized == "INVEST":
|
||||
updated_buffer += float(amount)
|
||||
else:
|
||||
updated_buffer -= float(amount)
|
||||
if updated_buffer < 0:
|
||||
raise ValueError("수익버퍼 잔액이 부족합니다")
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE portfolios
|
||||
SET profit_buffer = %s,
|
||||
updated_at = NOW()
|
||||
WHERE portfolio_id = %s
|
||||
RETURNING
|
||||
portfolio_id, broker, status, symbol, strategy_name, strategy_params,
|
||||
cash_balance, asset_quantity, asset_price, profit_buffer,
|
||||
current_strategy_set_id, created_at, updated_at, last_trade_at
|
||||
""",
|
||||
(updated_buffer, portfolio_id),
|
||||
)
|
||||
updated_row = cur.fetchone()
|
||||
total_value = float(portfolio.cash_balance) + float(portfolio.asset_quantity) * float(portfolio.price)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO portfolio_ledger (
|
||||
portfolio_id, recorded_at, action, price, quantity, fee_amount,
|
||||
tax_amount, cash_balance_after, asset_quantity_after,
|
||||
total_value_after, profit_buffer_after, strategy_set_id, note
|
||||
) VALUES (%s, NOW(), %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
portfolio_id,
|
||||
normalized,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
float(portfolio.cash_balance),
|
||||
float(portfolio.asset_quantity),
|
||||
float(total_value),
|
||||
float(updated_buffer),
|
||||
"manual",
|
||||
note,
|
||||
),
|
||||
)
|
||||
return self._row_to_portfolio(updated_row)
|
||||
|
||||
def transfer_buffer(self, portfolio_id: str, amount: float, direction: str, note: str = "") -> Portfolio:
|
||||
self.ensure_schema()
|
||||
normalized = str(direction).lower()
|
||||
if normalized not in {"to-buffer", "from-buffer"}:
|
||||
raise ValueError("direction은 to-buffer 또는 from-buffer 여야 합니다")
|
||||
if amount <= 0:
|
||||
raise ValueError("amount는 0보다 커야 합니다")
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(_SELECT_PORTFOLIO + "WHERE portfolio_id = %s", (portfolio_id,))
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
raise ValueError("포트폴리오를 찾을 수 없습니다")
|
||||
portfolio = self._row_to_portfolio(row)
|
||||
|
||||
cash = float(portfolio.cash_balance)
|
||||
buf = float(portfolio.profit_buffer)
|
||||
if normalized == "to-buffer":
|
||||
if cash < float(amount):
|
||||
raise ValueError("이체 금액이 현금 잔고를 초과합니다")
|
||||
cash -= float(amount)
|
||||
buf += float(amount)
|
||||
ledger_action = "BUFFER_IN"
|
||||
else:
|
||||
if buf < float(amount):
|
||||
raise ValueError("이체 금액이 수익버퍼 잔고를 초과합니다")
|
||||
buf -= float(amount)
|
||||
cash += float(amount)
|
||||
ledger_action = "BUFFER_OUT"
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE portfolios
|
||||
SET cash_balance = %s,
|
||||
profit_buffer = %s,
|
||||
updated_at = NOW()
|
||||
WHERE portfolio_id = %s
|
||||
RETURNING
|
||||
portfolio_id, broker, status, symbol, strategy_name, strategy_params,
|
||||
cash_balance, asset_quantity, asset_price, profit_buffer,
|
||||
current_strategy_set_id, created_at, updated_at, last_trade_at
|
||||
""",
|
||||
(cash, buf, portfolio_id),
|
||||
)
|
||||
updated_row = cur.fetchone()
|
||||
total_value = float(cash) + float(portfolio.asset_quantity) * float(portfolio.price)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO portfolio_ledger (
|
||||
portfolio_id, recorded_at, action, price, quantity, fee_amount,
|
||||
tax_amount, cash_balance_after, asset_quantity_after,
|
||||
total_value_after, profit_buffer_after, strategy_set_id, note
|
||||
) VALUES (%s, NOW(), %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
portfolio_id,
|
||||
ledger_action,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
float(cash),
|
||||
float(portfolio.asset_quantity),
|
||||
float(total_value),
|
||||
float(buf),
|
||||
"manual",
|
||||
note,
|
||||
),
|
||||
)
|
||||
return self._row_to_portfolio(updated_row)
|
||||
|
||||
def delete(self, portfolio_id: str, *, clear_ledger: bool = True) -> None:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute("SELECT portfolio_id FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
|
||||
if cur.fetchone() is None:
|
||||
raise ValueError("포트폴리오를 찾을 수 없습니다")
|
||||
if clear_ledger:
|
||||
cur.execute("DELETE FROM portfolio_ledger WHERE portfolio_id = %s", (portfolio_id,))
|
||||
cur.execute("DELETE FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
|
||||
|
||||
|
||||
def database_exists(config: PortfolioDBConfig, *, maintenance_db: str = "postgres") -> bool:
|
||||
with psycopg.connect(
|
||||
host=config.host,
|
||||
port=config.port,
|
||||
dbname=maintenance_db,
|
||||
user=config.user,
|
||||
password=config.password,
|
||||
sslmode=config.sslmode,
|
||||
connect_timeout=config.connect_timeout_sec,
|
||||
application_name=config.application_name,
|
||||
autocommit=True,
|
||||
) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (config.dbname,))
|
||||
return cur.fetchone() is not None
|
||||
|
||||
|
||||
def create_database_if_missing(config: PortfolioDBConfig, *, maintenance_db: str = "postgres") -> bool:
|
||||
with psycopg.connect(
|
||||
host=config.host,
|
||||
port=config.port,
|
||||
dbname=maintenance_db,
|
||||
user=config.user,
|
||||
password=config.password,
|
||||
sslmode=config.sslmode,
|
||||
connect_timeout=config.connect_timeout_sec,
|
||||
application_name=config.application_name,
|
||||
autocommit=True,
|
||||
) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (config.dbname,))
|
||||
if cur.fetchone() is not None:
|
||||
return False
|
||||
cur.execute(sql.SQL("CREATE DATABASE {}") .format(sql.Identifier(config.dbname)))
|
||||
return True
|
||||
@@ -0,0 +1 @@
|
||||
"""Pydantic schemas for PortfolioHub."""
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Portfolio request and response schemas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class StrategySetResponse(BaseModel):
|
||||
id: str
|
||||
strategy_name: str
|
||||
strategy_config: str
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class LedgerEntryResponse(BaseModel):
|
||||
portfolio_id: str
|
||||
recorded_at: datetime
|
||||
action: str
|
||||
price: float
|
||||
quantity: float
|
||||
fee_amount: float = 0.0
|
||||
tax_amount: float = 0.0
|
||||
cash_balance_after: float
|
||||
asset_quantity_after: float
|
||||
total_value_after: float
|
||||
profit_buffer_after: float = 0.0
|
||||
strategy_set_id: str = "manual"
|
||||
note: str = ""
|
||||
|
||||
|
||||
class PortfolioBase(BaseModel):
|
||||
broker: str = "kis"
|
||||
status: str = "enabled"
|
||||
symbol: str
|
||||
strategy_name: str = "target_value"
|
||||
strategy_params: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PortfolioSummary(BaseModel):
|
||||
portfolio_id: str
|
||||
broker: str
|
||||
status: str
|
||||
symbol: str
|
||||
strategy_name: str
|
||||
strategy_params: dict[str, Any] = Field(default_factory=dict)
|
||||
cash_balance: float = 0.0
|
||||
asset_quantity: float = 0.0
|
||||
price: float = 0.0
|
||||
profit_buffer: float = 0.0
|
||||
current_strategy_set_id: str | None = None
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
last_trade_at: datetime | None = None
|
||||
|
||||
|
||||
class PortfolioListItem(PortfolioSummary):
|
||||
total_value: float = 0.0
|
||||
|
||||
|
||||
class PortfolioDetailResponse(BaseModel):
|
||||
portfolio: PortfolioSummary
|
||||
recent_entries: list[LedgerEntryResponse] = Field(default_factory=list)
|
||||
strategy_set: StrategySetResponse | None = None
|
||||
live_quantity: float | None = None
|
||||
|
||||
|
||||
class CreatePortfolioRequest(PortfolioBase):
|
||||
portfolio_id: str
|
||||
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)
|
||||
profit_buffer: float = Field(default=0.0, ge=0.0)
|
||||
|
||||
|
||||
class UpdatePortfolioRequest(BaseModel):
|
||||
broker: str | None = None
|
||||
status: str | None = None
|
||||
symbol: str | None = None
|
||||
strategy_name: str | None = None
|
||||
strategy_params: dict[str, Any] | None = None
|
||||
asset_price: float | None = Field(default=None, ge=0.0)
|
||||
|
||||
|
||||
class BuyRequest(BaseModel):
|
||||
quantity: float = Field(gt=0.0)
|
||||
price: float = Field(gt=0.0)
|
||||
fee: float = Field(default=0.0, ge=0.0)
|
||||
tax: float = Field(default=0.0, ge=0.0)
|
||||
note: str = ""
|
||||
|
||||
|
||||
class SellRequest(BaseModel):
|
||||
quantity: float = Field(gt=0.0)
|
||||
price: float = Field(gt=0.0)
|
||||
fee: float = Field(default=0.0, ge=0.0)
|
||||
tax: float = Field(default=0.0, ge=0.0)
|
||||
note: str = ""
|
||||
|
||||
|
||||
class InvestRequest(BaseModel):
|
||||
movement_type: Literal["deposit", "profit", "withdraw"]
|
||||
amount: float = Field(gt=0.0)
|
||||
note: str = ""
|
||||
|
||||
|
||||
class TransferRequest(BaseModel):
|
||||
direction: Literal["to-buffer", "from-buffer"]
|
||||
amount: float = Field(gt=0.0)
|
||||
note: str = ""
|
||||
|
||||
|
||||
class ResetPortfolioRequest(BaseModel):
|
||||
infer_symbol: str | None = None
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
detail: str
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
@@ -0,0 +1 @@
|
||||
"""Service layer for PortfolioHub."""
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Portfolio service orchestration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import Depends
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.domain.models import LedgerEntry, Portfolio, StrategySet
|
||||
from app.domain.services import (
|
||||
apply_buy,
|
||||
apply_delete,
|
||||
apply_invest,
|
||||
apply_reset,
|
||||
apply_sell,
|
||||
apply_transfer,
|
||||
get_portfolio_status,
|
||||
)
|
||||
from app.repositories.factory import get_portfolio_repository_from_settings
|
||||
from app.repositories.portfolio_repository import PortfolioRepository
|
||||
from app.schemas.portfolio import (
|
||||
BuyRequest,
|
||||
CreatePortfolioRequest,
|
||||
InvestRequest,
|
||||
PortfolioDetailResponse,
|
||||
PortfolioListItem,
|
||||
ResetPortfolioRequest,
|
||||
SellRequest,
|
||||
TransferRequest,
|
||||
UpdatePortfolioRequest,
|
||||
)
|
||||
|
||||
|
||||
def _infer_symbol(portfolio_id: str) -> str:
|
||||
parts = [part.strip() for part in portfolio_id.split(":") if part.strip()]
|
||||
return parts[0] if parts else ""
|
||||
|
||||
|
||||
def _to_portfolio_summary(portfolio: Portfolio):
|
||||
from app.schemas.portfolio import PortfolioSummary
|
||||
|
||||
return PortfolioSummary(
|
||||
portfolio_id=portfolio.portfolio_id,
|
||||
broker=portfolio.broker,
|
||||
status=portfolio.status,
|
||||
symbol=portfolio.symbol,
|
||||
strategy_name=portfolio.strategy_name,
|
||||
strategy_params=portfolio.strategy_params,
|
||||
cash_balance=portfolio.cash_balance,
|
||||
asset_quantity=portfolio.asset_quantity,
|
||||
price=portfolio.price,
|
||||
profit_buffer=portfolio.profit_buffer,
|
||||
current_strategy_set_id=portfolio.current_strategy_set_id,
|
||||
created_at=portfolio.created_at,
|
||||
updated_at=portfolio.updated_at,
|
||||
last_trade_at=portfolio.last_trade_at,
|
||||
)
|
||||
|
||||
|
||||
def _to_strategy_set_response(strategy_set: StrategySet | None):
|
||||
from app.schemas.portfolio import StrategySetResponse
|
||||
|
||||
if strategy_set is None:
|
||||
return None
|
||||
return StrategySetResponse(
|
||||
id=strategy_set.id,
|
||||
strategy_name=strategy_set.strategy_name,
|
||||
strategy_config=strategy_set.strategy_config,
|
||||
created_at=strategy_set.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _to_ledger_entry_response(entry: LedgerEntry):
|
||||
from app.schemas.portfolio import LedgerEntryResponse
|
||||
|
||||
return LedgerEntryResponse(
|
||||
portfolio_id=entry.portfolio_id,
|
||||
recorded_at=entry.recorded_at,
|
||||
action=entry.action,
|
||||
price=entry.price,
|
||||
quantity=entry.quantity,
|
||||
fee_amount=entry.fee_amount,
|
||||
tax_amount=entry.tax_amount,
|
||||
cash_balance_after=entry.cash_balance_after,
|
||||
asset_quantity_after=entry.asset_quantity_after,
|
||||
total_value_after=entry.total_value_after,
|
||||
profit_buffer_after=entry.profit_buffer_after,
|
||||
strategy_set_id=entry.strategy_set_id,
|
||||
note=entry.note,
|
||||
)
|
||||
|
||||
|
||||
def _to_detail_response(status_result) -> PortfolioDetailResponse:
|
||||
return PortfolioDetailResponse(
|
||||
portfolio=_to_portfolio_summary(status_result.portfolio),
|
||||
recent_entries=[_to_ledger_entry_response(entry) for entry in status_result.recent_entries],
|
||||
strategy_set=_to_strategy_set_response(status_result.strategy_set),
|
||||
live_quantity=status_result.live_quantity,
|
||||
)
|
||||
|
||||
|
||||
def get_portfolio_repository(settings: Settings = Depends(get_settings)) -> PortfolioRepository:
|
||||
return get_portfolio_repository_from_settings(settings)
|
||||
|
||||
|
||||
class PortfolioService:
|
||||
def __init__(self, repository: PortfolioRepository) -> None:
|
||||
self._repository = repository
|
||||
|
||||
def list_portfolios(self, *, broker: str | None = None, status: str | None = None) -> list[PortfolioListItem]:
|
||||
portfolios = self._repository.list_portfolios(broker=broker, status=status)
|
||||
return [
|
||||
PortfolioListItem(
|
||||
**_to_portfolio_summary(portfolio).model_dump(),
|
||||
total_value=float(portfolio.cash_balance) + float(portfolio.asset_quantity) * float(portfolio.price),
|
||||
)
|
||||
for portfolio in portfolios
|
||||
]
|
||||
|
||||
def get_portfolio_status(self, *, portfolio_id: str, recent_trades: int = 5) -> PortfolioDetailResponse:
|
||||
status_result = get_portfolio_status(self._repository, portfolio_id, recent_trades=recent_trades)
|
||||
return _to_detail_response(status_result)
|
||||
|
||||
def create_portfolio(self, payload: CreatePortfolioRequest) -> PortfolioDetailResponse:
|
||||
strategy_set = self._repository.get_or_create_strategy_set(
|
||||
payload.strategy_name,
|
||||
json.dumps(payload.strategy_params, sort_keys=True, ensure_ascii=True),
|
||||
)
|
||||
portfolio = self._repository.create_portfolio(
|
||||
payload.portfolio_id,
|
||||
broker=payload.broker,
|
||||
status=payload.status,
|
||||
symbol=payload.symbol,
|
||||
strategy_name=payload.strategy_name,
|
||||
strategy_params=payload.strategy_params,
|
||||
cash_balance=payload.cash_balance,
|
||||
asset_quantity=payload.asset_quantity,
|
||||
asset_price=payload.asset_price,
|
||||
profit_buffer=payload.profit_buffer,
|
||||
)
|
||||
portfolio = self._repository.update_portfolio(
|
||||
payload.portfolio_id,
|
||||
current_strategy_set_id=strategy_set.id,
|
||||
)
|
||||
return self.get_portfolio_status(portfolio_id=portfolio.portfolio_id, recent_trades=5)
|
||||
|
||||
def update_portfolio(self, portfolio_id: str, payload: UpdatePortfolioRequest) -> PortfolioDetailResponse:
|
||||
fields = payload.model_dump(exclude_none=True)
|
||||
if "strategy_name" in fields or "strategy_params" in fields:
|
||||
current = self._repository.get(portfolio_id)
|
||||
strategy_name = fields.get("strategy_name", current.strategy_name)
|
||||
strategy_params = fields.get("strategy_params", current.strategy_params)
|
||||
strategy_set = self._repository.get_or_create_strategy_set(
|
||||
strategy_name,
|
||||
json.dumps(strategy_params, sort_keys=True, ensure_ascii=True),
|
||||
)
|
||||
fields["current_strategy_set_id"] = strategy_set.id
|
||||
self._repository.update_portfolio(portfolio_id, **fields)
|
||||
return self.get_portfolio_status(portfolio_id=portfolio_id, recent_trades=5)
|
||||
|
||||
def buy(self, portfolio_id: str, payload: BuyRequest) -> PortfolioDetailResponse:
|
||||
apply_buy(
|
||||
self._repository,
|
||||
portfolio_id,
|
||||
quantity=payload.quantity,
|
||||
price=payload.price,
|
||||
fee=payload.fee,
|
||||
tax=payload.tax,
|
||||
note=payload.note,
|
||||
)
|
||||
return self.get_portfolio_status(portfolio_id=portfolio_id, recent_trades=5)
|
||||
|
||||
def sell(self, portfolio_id: str, payload: SellRequest) -> PortfolioDetailResponse:
|
||||
apply_sell(
|
||||
self._repository,
|
||||
portfolio_id,
|
||||
quantity=payload.quantity,
|
||||
price=payload.price,
|
||||
fee=payload.fee,
|
||||
tax=payload.tax,
|
||||
note=payload.note,
|
||||
)
|
||||
return self.get_portfolio_status(portfolio_id=portfolio_id, recent_trades=5)
|
||||
|
||||
def invest(self, portfolio_id: str, payload: InvestRequest) -> PortfolioDetailResponse:
|
||||
apply_invest(
|
||||
self._repository,
|
||||
portfolio_id,
|
||||
movement_type=payload.movement_type,
|
||||
amount=payload.amount,
|
||||
note=payload.note,
|
||||
)
|
||||
return self.get_portfolio_status(portfolio_id=portfolio_id, recent_trades=5)
|
||||
|
||||
def transfer(self, portfolio_id: str, payload: TransferRequest) -> PortfolioDetailResponse:
|
||||
apply_transfer(
|
||||
self._repository,
|
||||
portfolio_id,
|
||||
direction=payload.direction,
|
||||
amount=payload.amount,
|
||||
note=payload.note,
|
||||
)
|
||||
return self.get_portfolio_status(portfolio_id=portfolio_id, recent_trades=5)
|
||||
|
||||
def reset(self, portfolio_id: str, payload: ResetPortfolioRequest) -> PortfolioDetailResponse:
|
||||
infer_symbol = payload.infer_symbol or _infer_symbol(portfolio_id)
|
||||
apply_reset(self._repository, portfolio_id, infer_symbol=lambda _: infer_symbol)
|
||||
return self.get_portfolio_status(portfolio_id=portfolio_id, recent_trades=5)
|
||||
|
||||
def delete(self, portfolio_id: str, *, keep_ledger: bool = False) -> None:
|
||||
apply_delete(self._repository, portfolio_id, clear_ledger=not keep_ledger)
|
||||
|
||||
|
||||
def get_portfolio_service(
|
||||
repository: PortfolioRepository = Depends(get_portfolio_repository),
|
||||
) -> PortfolioService:
|
||||
return PortfolioService(repository)
|
||||
Reference in New Issue
Block a user