From 0654b24d668ac694cf224b16bb53a6b30384c369 Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Fri, 3 Apr 2026 14:51:36 +0000 Subject: [PATCH] feat(portfolio): add asset name field across API, DB, and CLI --- app/cli/commands/portfolio.py | 4 ++ app/domain/models.py | 1 + app/domain/services.py | 4 ++ app/repositories/portfolio_repository.py | 2 + app/repositories/postgres.py | 43 ++++++++++------ app/schemas/portfolio.py | 3 ++ app/services/portfolio_service.py | 2 + docs/api-examples.md | 4 ++ docs/db-schema-spec.md | 7 ++- docs/portfoliohub-spec.md | 5 +- .../20260403_0008_portfolio_name_column.py | 51 +++++++++++++++++++ tests/test_cli.py | 5 ++ tests/test_domain_services.py | 3 ++ 13 files changed, 115 insertions(+), 19 deletions(-) create mode 100644 migrations/versions/20260403_0008_portfolio_name_column.py diff --git a/app/cli/commands/portfolio.py b/app/cli/commands/portfolio.py index b1b9952..d3ca1cc 100644 --- a/app/cli/commands/portfolio.py +++ b/app/cli/commands/portfolio.py @@ -79,6 +79,7 @@ def portfolio_status( def create_portfolio( ctx: typer.Context, portfolio_id: str = typer.Argument(..., help="Portfolio identifier."), + name: str | None = typer.Option(None, help="Asset display name."), symbol: str = typer.Option(..., help="Trading symbol."), broker: str = typer.Option("kis", help="Broker code."), status: str = typer.Option("enabled", help="Portfolio status."), @@ -96,6 +97,7 @@ def create_portfolio( lambda service, app_context: service.create_portfolio( CreatePortfolioRequest( portfolio_id=portfolio_id, + name=name, symbol=symbol, broker=broker, status=status, @@ -117,6 +119,7 @@ def update_portfolio( 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."), + name: str | None = typer.Option(None, help="Asset display name."), 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."), @@ -132,6 +135,7 @@ def update_portfolio( UpdatePortfolioRequest( broker=broker, status=status, + name=name, symbol=symbol, strategy_name=strategy_name, strategy_params=parsed_strategy_params, diff --git a/app/domain/models.py b/app/domain/models.py index e182b68..85d1096 100644 --- a/app/domain/models.py +++ b/app/domain/models.py @@ -70,6 +70,7 @@ class Portfolio: portfolio_id: str broker: str status: str + name: str symbol: str cash_balance: float = 0.0 asset_quantity: float = 0.0 diff --git a/app/domain/services.py b/app/domain/services.py index 9791855..4bb94e5 100644 --- a/app/domain/services.py +++ b/app/domain/services.py @@ -22,6 +22,7 @@ class PortfolioDomainRepository(Protocol): portfolio_id: str, symbol: str, *, + name: str | None = None, broker: str = "kis", status: str = "enabled", active_strategy_id: str | None = None, @@ -34,6 +35,7 @@ class PortfolioDomainRepository(Protocol): *, broker: str, status: str, + name: str, symbol: str, cash_balance: float, asset_quantity: float, @@ -99,6 +101,7 @@ def apply_buy( portfolio_id=portfolio.portfolio_id, broker=portfolio.broker, status=portfolio.status, + name=portfolio.name, symbol=portfolio.symbol, cash_balance=float(portfolio.cash_balance) - total_cost, asset_quantity=float(portfolio.asset_quantity) + quantity, @@ -159,6 +162,7 @@ def apply_sell( portfolio_id=portfolio.portfolio_id, broker=portfolio.broker, status=portfolio.status, + name=portfolio.name, symbol=portfolio.symbol, cash_balance=float(portfolio.cash_balance) + net_proceeds, asset_quantity=float(portfolio.asset_quantity) - quantity, diff --git a/app/repositories/portfolio_repository.py b/app/repositories/portfolio_repository.py index 3cbd985..b441a11 100644 --- a/app/repositories/portfolio_repository.py +++ b/app/repositories/portfolio_repository.py @@ -86,6 +86,7 @@ class PortfolioRepository(Protocol): portfolio_id: str, symbol: str, *, + name: str | None = None, broker: str = "kis", status: str = "enabled", active_strategy_id: str | None = None, @@ -99,6 +100,7 @@ class PortfolioRepository(Protocol): *, broker: str, status: str, + name: str, symbol: str, cash_balance: float, asset_quantity: float, diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index 2f2a1c7..cbb9481 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -62,6 +62,7 @@ _SELECT_PORTFOLIO = """ portfolio_id, broker, status, + name, symbol, cash_balance, asset_quantity, @@ -214,6 +215,7 @@ class PostgresPortfolioRepository: portfolio_id TEXT PRIMARY KEY, broker TEXT NOT NULL DEFAULT 'kis', status TEXT NOT NULL DEFAULT 'enabled', + name TEXT NOT NULL, symbol TEXT NOT NULL, cash_balance DOUBLE PRECISION NOT NULL, asset_quantity DOUBLE PRECISION NOT NULL, @@ -227,6 +229,9 @@ class PostgresPortfolioRepository: ) """ ) + cur.execute("ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS name TEXT") + cur.execute("UPDATE portfolios SET name = symbol WHERE name IS NULL OR name = ''") + cur.execute("ALTER TABLE portfolios ALTER COLUMN name SET NOT NULL") cur.execute( """ CREATE TABLE IF NOT EXISTS portfolio_ledger ( @@ -321,7 +326,7 @@ class PostgresPortfolioRepository: if row is None: raise ValueError("포트폴리오를 찾을 수 없습니다") - raw_available = row[9] if len(row) > 9 else None + raw_available = row[10] if len(row) > 10 else None if raw_available is None: avabile_strategies = None elif isinstance(raw_available, list): @@ -333,16 +338,17 @@ class PostgresPortfolioRepository: portfolio_id=row[0], broker=row[1] or "kis", status=row[2] or "enabled", - symbol=row[3], - cash_balance=float(row[4]), - asset_quantity=float(row[5]), - price=float(row[6]), - profit_buffer=float(row[7]), - active_strategy_id=row[8] if len(row) > 8 else None, + name=row[3], + symbol=row[4], + cash_balance=float(row[5]), + asset_quantity=float(row[6]), + price=float(row[7]), + profit_buffer=float(row[8]), + active_strategy_id=row[9] if len(row) > 9 else None, avabile_strategies=avabile_strategies, - created_at=row[10] if len(row) > 10 else None, - updated_at=row[11] if len(row) > 11 else None, - last_trade_at=row[12] if len(row) > 12 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 @@ -698,6 +704,7 @@ class PostgresPortfolioRepository: portfolio_id: str, symbol: str, *, + name: str | None = None, broker: str = "kis", status: str = "enabled", active_strategy_id: str | None = None, @@ -709,17 +716,18 @@ class PostgresPortfolioRepository: cur.execute( """ INSERT INTO portfolios ( - portfolio_id, broker, status, symbol, + portfolio_id, broker, status, name, symbol, cash_balance, asset_quantity, asset_price, profit_buffer, active_strategy_id, avabile_strategies ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) ON CONFLICT (portfolio_id) DO NOTHING """, ( portfolio_id, broker, status, + name or symbol, symbol, float(initial_cash), 0.0, @@ -739,6 +747,7 @@ class PostgresPortfolioRepository: *, broker: str, status: str, + name: str, symbol: str, cash_balance: float, asset_quantity: float, @@ -755,12 +764,12 @@ class PostgresPortfolioRepository: cur.execute( """ INSERT INTO portfolios ( - portfolio_id, broker, status, symbol, + portfolio_id, broker, status, name, symbol, cash_balance, asset_quantity, asset_price, profit_buffer, active_strategy_id, avabile_strategies - ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING - portfolio_id, broker, status, symbol, + portfolio_id, broker, status, name, symbol, cash_balance, asset_quantity, asset_price, profit_buffer, active_strategy_id, avabile_strategies, created_at, updated_at, last_trade_at """, @@ -768,6 +777,7 @@ class PostgresPortfolioRepository: portfolio_id, broker, status, + name, symbol, float(cash_balance), float(asset_quantity), @@ -785,6 +795,7 @@ class PostgresPortfolioRepository: allowed = { "broker": "broker", "status": "status", + "name": "name", "symbol": "symbol", "asset_price": "asset_price", "active_strategy_id": "active_strategy_id", @@ -812,7 +823,7 @@ class PostgresPortfolioRepository: SET {', '.join(assignments)} WHERE portfolio_id = %s RETURNING - portfolio_id, broker, status, symbol, + portfolio_id, broker, status, name, symbol, cash_balance, asset_quantity, asset_price, profit_buffer, active_strategy_id, avabile_strategies, created_at, updated_at, last_trade_at """, diff --git a/app/schemas/portfolio.py b/app/schemas/portfolio.py index 689d1c0..6832b08 100644 --- a/app/schemas/portfolio.py +++ b/app/schemas/portfolio.py @@ -35,6 +35,7 @@ class LedgerEntryResponse(BaseModel): class PortfolioBase(BaseModel): broker: str = "kis" status: str = "enabled" + name: str | None = None symbol: str @@ -42,6 +43,7 @@ class PortfolioSummary(BaseModel): portfolio_id: str broker: str status: str + name: str symbol: str cash_balance: float = 0.0 asset_quantity: float = 0.0 @@ -80,6 +82,7 @@ class CreatePortfolioRequest(PortfolioBase): class UpdatePortfolioRequest(BaseModel): broker: str | None = None status: str | None = None + name: str | None = None symbol: str | None = None strategy_name: str | None = None strategy_params: dict[str, Any] | None = None diff --git a/app/services/portfolio_service.py b/app/services/portfolio_service.py index eea7657..0feecd8 100644 --- a/app/services/portfolio_service.py +++ b/app/services/portfolio_service.py @@ -44,6 +44,7 @@ def _to_portfolio_summary(portfolio: Portfolio): portfolio_id=portfolio.portfolio_id, broker=portfolio.broker, status=portfolio.status, + name=portfolio.name, symbol=portfolio.symbol, cash_balance=portfolio.cash_balance, asset_quantity=portfolio.asset_quantity, @@ -150,6 +151,7 @@ class PortfolioService: payload.portfolio_id, broker=payload.broker, status=payload.status, + name=payload.name or payload.symbol, symbol=payload.symbol, cash_balance=payload.cash_balance, asset_quantity=payload.asset_quantity, diff --git a/docs/api-examples.md b/docs/api-examples.md index 701d9f9..fbd7c7a 100644 --- a/docs/api-examples.md +++ b/docs/api-examples.md @@ -50,6 +50,7 @@ Response 200: "portfolio_id": "krw-btc", "broker": "upbit", "status": "enabled", + "name": "Bitcoin", "symbol": "KRW-BTC", "cash_balance": 1000000.0, "asset_quantity": 0.1, @@ -82,6 +83,7 @@ Response 200: "portfolio_id": "krw-btc", "broker": "upbit", "status": "enabled", + "name": "Bitcoin", "symbol": "KRW-BTC", "cash_balance": 1000000.0, "asset_quantity": 0.1, @@ -133,6 +135,7 @@ curl -X POST "http://localhost:8000/portfolios" \ "portfolio_id": "krw-btc", "broker": "upbit", "status": "enabled", + "name": "Bitcoin", "symbol": "KRW-BTC", "strategy_name": "target_value", "strategy_params": {}, @@ -159,6 +162,7 @@ curl -X PATCH "http://localhost:8000/portfolios/krw-btc" \ -H "Content-Type: application/json" \ -d '{ "broker": "upbit", + "name": "Bitcoin", "symbol": "KRW-BTC" }' ``` diff --git a/docs/db-schema-spec.md b/docs/db-schema-spec.md index 6dbd40c..4f9c1ab 100644 --- a/docs/db-schema-spec.md +++ b/docs/db-schema-spec.md @@ -5,7 +5,7 @@ This document defines the database schema in an AI-agent-friendly format. - Source of truth: Alembic revisions in `migrations/versions` -- Current baseline revision: `20260403_0007` +- Current baseline revision: `20260403_0008` - Baseline files: - `migrations/versions/20260403_0001_initial_portfolio_schema.py` - `migrations/versions/20260403_0002_strategy_set_columns.py` @@ -14,6 +14,7 @@ This document defines the database schema in an AI-agent-friendly format. - `migrations/versions/20260403_0005_auth_user_fields_and_tokens.py` - `migrations/versions/20260403_0006_bot_principals_and_bot_tokens.py` - `migrations/versions/20260403_0007_bot_principal_permission_only.py` + - `migrations/versions/20260403_0008_portfolio_name_column.py` ## 2. Scope @@ -145,7 +146,7 @@ Purpose: Current baseline note: -- The columns below describe the currently migrated schema through revision `20260403_0005`. +- The columns below describe the currently migrated schema through revision `20260403_0008`. Columns: @@ -154,6 +155,7 @@ Columns: | portfolio_id | Text | NO | - | Primary Key | | broker | Text | NO | "kis" | Broker code | | status | Text | NO | "enabled" | Portfolio status | +| name | Text | NO | - | Asset name | | symbol | Text | NO | - | Trading symbol | | cash_balance | Float | NO | - | Current cash balance | | asset_quantity | Float | NO | - | Current asset quantity | @@ -294,3 +296,4 @@ When schema changes: | 20260403_0005 | migrations/versions/20260403_0005_auth_user_fields_and_tokens.py | Add password/permission/login fields to `users` and create `auth_tokens` table | | 20260403_0006 | migrations/versions/20260403_0006_bot_principals_and_bot_tokens.py | Add `bot_principals` table and `auth_tokens.bot_principal_id` for bot API key management | | 20260403_0007 | migrations/versions/20260403_0007_bot_principal_permission_only.py | Remove `bot_principals.owner_user_id` and enforce bot-local `permission` model | +| 20260403_0008 | migrations/versions/20260403_0008_portfolio_name_column.py | Add non-null `portfolios.name` (asset display name), backfilled from `symbol` | diff --git a/docs/portfoliohub-spec.md b/docs/portfoliohub-spec.md index 636576c..f732bb0 100644 --- a/docs/portfoliohub-spec.md +++ b/docs/portfoliohub-spec.md @@ -387,6 +387,7 @@ Bootstrap rule: - `portfolio_id: str` - `broker: str = "kis"` - `status: str = "enabled"` +- `name: str | null` (when null, defaults to `symbol`) - `symbol: str` - `strategy_name: str = "target_value"` - `strategy_params: object = {}` @@ -401,6 +402,7 @@ Bootstrap rule: - `broker: str | null` - `status: str | null` +- `name: str | null` - `symbol: str | null` - `strategy_name: str | null` - `strategy_params: object | null` @@ -492,6 +494,7 @@ Bootstrap rule: - `portfolio_id: str` - `broker: str` - `status: str` +- `name: str` - `symbol: str` - `cash_balance: float` - `asset_quantity: float` @@ -553,7 +556,7 @@ Portfolio subcommands: - `portfoliohub portfolio list [--broker ...] [--status ...]` - `portfoliohub portfolio status [--recent 5]` -- `portfoliohub portfolio create --symbol ... [options] [--force]` +- `portfoliohub portfolio create --symbol ... [--name ...] [options] [--force]` - `portfoliohub portfolio update [options] [--force]` - `portfoliohub portfolio buy --quantity ... --price ... [options] [--force]` - `portfoliohub portfolio sell --quantity ... --price ... [options] [--force]` diff --git a/migrations/versions/20260403_0008_portfolio_name_column.py b/migrations/versions/20260403_0008_portfolio_name_column.py new file mode 100644 index 0000000..09aa532 --- /dev/null +++ b/migrations/versions/20260403_0008_portfolio_name_column.py @@ -0,0 +1,51 @@ +"""add portfolio name column + +Revision ID: 20260403_0008 +Revises: 20260403_0007 +Create Date: 2026-04-03 14:45:00 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + + +revision = "20260403_0008" +down_revision = "20260403_0007" +branch_labels = None +depends_on = None + + +def _table_exists(inspector, table_name: str) -> bool: + return table_name in inspector.get_table_names() + + +def _column_exists(inspector, table_name: str, column_name: str) -> bool: + return any(column["name"] == column_name for column in inspector.get_columns(table_name)) + + +def upgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + + if not _table_exists(inspector, "portfolios"): + return + + if not _column_exists(inspector, "portfolios", "name"): + op.add_column("portfolios", sa.Column("name", sa.Text(), nullable=True)) + + op.execute("UPDATE portfolios SET name = symbol WHERE name IS NULL OR name = ''") + op.alter_column("portfolios", "name", existing_type=sa.Text(), nullable=False) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + + if not _table_exists(inspector, "portfolios"): + return + + if _column_exists(inspector, "portfolios", "name"): + op.drop_column("portfolios", "name") diff --git a/tests/test_cli.py b/tests/test_cli.py index cbedd15..3a1dcb0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -110,6 +110,7 @@ class FakeRepo: portfolio_id: str, symbol: str, *, + name: str | None = None, broker: str = "kis", status: str = "enabled", active_strategy_id: str | None = None, @@ -121,6 +122,7 @@ class FakeRepo: portfolio_id=portfolio_id, broker=broker, status=status, + name=name or symbol, symbol=symbol, cash_balance=initial_cash, active_strategy_id=active_strategy_id, @@ -134,6 +136,7 @@ class FakeRepo: *, broker: str, status: str, + name: str, symbol: str, cash_balance: float, asset_quantity: float, @@ -146,6 +149,7 @@ class FakeRepo: portfolio_id=portfolio_id, broker=broker, status=status, + name=name, symbol=symbol, cash_balance=cash_balance, asset_quantity=asset_quantity, @@ -275,6 +279,7 @@ def _portfolio() -> Portfolio: portfolio_id="005930:tv", broker="kis", status="enabled", + name="Samsung Electronics", symbol="005930", cash_balance=1_000_000.0, asset_quantity=1.0, diff --git a/tests/test_domain_services.py b/tests/test_domain_services.py index a7652f2..ee9e31f 100644 --- a/tests/test_domain_services.py +++ b/tests/test_domain_services.py @@ -26,6 +26,7 @@ class FakeRepo: portfolio_id: str, symbol: str, *, + name: str | None = None, broker: str = "kis", status: str = "enabled", active_strategy_id: str | None = None, @@ -36,6 +37,7 @@ class FakeRepo: portfolio_id=portfolio_id, broker=broker, status=status, + name=name or symbol, symbol=symbol, cash_balance=initial_cash, active_strategy_id=active_strategy_id, @@ -155,6 +157,7 @@ def _portfolio() -> Portfolio: portfolio_id="p1", broker="kis", status="enabled", + name="Samsung Electronics", symbol="005930", cash_balance=1_000_000.0, asset_quantity=0.0,