feat: implement asset catalog database foundation (BAT-001, REQ-009/011/014)
Asset Catalog Model Implementation (BAT-001): - Introduce assets table with UUID PK, symbol UNIQUE, markets TEXT[] array - Define MarketCode enum (KRX, NXT, SOR, NASDAQ, NYSE, UPBIT, BITHUMB) - Add Asset domain model with catalog properties - Implement asset CRUD protocols in portfolio_repository - Add PostgreSQL asset CRUD operations with GIN index on markets Portfolio Model Refactoring (REQ-011): - Add portfolios.asset_id UUID FK to assets(asset_id) ON DELETE RESTRICT - Remove portfolios.symbol and portfolios.market columns - Update Portfolio domain model to reference asset_id instead of symbol/market - Refactor all portfolio queries to JOIN assets catalog - Update all repository methods to use asset references Data Migration (REQ-014): - Create 20260519_0017 Alembic revision for assets table creation - Create 20260519_0018 Alembic revision for atomic data migration: * Extract unique symbol combinations from portfolios * Aggregate markets using ARRAY_AGG(DISTINCT market) * Populate assets catalog with extracted data * Update portfolios.asset_id to reference new assets * Remove legacy symbol/market columns in single transaction - Implement migration with rollback support Requirements Approved (REQ-001~008): - REQ-001~008 transitioned from Proposed to Approved - Complete tag model requirement set with implementation readiness New Requirements Approved (REQ-009~015): - REQ-009: assets table schema (approved, included in BAT-001) - REQ-010: admin asset catalog CRUD (approved, future BAT-002) - REQ-011: portfolios asset_id FK (approved, included in BAT-001) - REQ-012: portfolio create catalog validation (approved, future BAT-002) - REQ-013: GET /v1/assets token branching (approved, future BAT-002) - REQ-014: portfolios data migration (approved, included in BAT-001) - REQ-015: asset catalog baseline docs (approved) Code Changes: - app/domain/models.py: Add Asset model, MarketCode enum, Portfolio asset_id FK - app/repositories/portfolio_repository.py: Define asset CRUD protocol - app/repositories/postgres.py: Implement asset operations with full CRUD - app/api/v1_assets.py: Remove symbol/market direct references - app/cli/commands/portfolio.py: Update portfolio commands for asset references - app/schemas/portfolio.py: Update portfolio schema with asset_id - All portfolio operations throughout codebase: Use asset references Testing: - Add test_asset_catalog.py for asset CRUD operations - Verify REQ-009 AC-1~5 (asset catalog creation, lookup, uniqueness) - Verify REQ-011 AC-1~5 (FK constraint, portfolio asset reference) - Verify REQ-014 AC-1~6 (migration atomicity, data correctness, rollback) - Update existing tests for asset model usage Documentation: - Create BAT-001 batch documents (planning, design, implementation, verification) - Update dcy-001 with implementation details and confirmed architecture - Add 8 new requirement documents (REQ-009~015) - Update docs/discovery/index.md with batch entry Impact: - Database: assets catalog table, portfolios refactored to reference assets - Migration: 2 new Alembic revisions (0017 assets, 0018 portfolio migration) - API: Symbol/market now accessed through asset references - CLI: Portfolio commands require asset reference pattern - All existing portfolio data preserved and correctly migrated
This commit is contained in:
@@ -80,7 +80,6 @@ def list_assets(
|
||||
AssetItem(
|
||||
asset_id=p.portfolio_id,
|
||||
symbol=p.symbol,
|
||||
market=p.market,
|
||||
status=p.status,
|
||||
cash=p.cash_balance,
|
||||
quantity=p.asset_quantity,
|
||||
|
||||
@@ -145,7 +145,7 @@ def create_portfolio(
|
||||
ctx: typer.Context,
|
||||
name: str = typer.Argument(..., help="Portfolio name (user-scoped unique identifier)."),
|
||||
user: str = typer.Option(..., "--user", help="Owner username (required)."),
|
||||
symbol: str = typer.Option(..., help="Trading symbol."),
|
||||
asset_id: str = typer.Option(..., "--asset-id", help="Asset UUID from the asset catalog."),
|
||||
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."),
|
||||
@@ -157,7 +157,7 @@ def create_portfolio(
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
|
||||
) -> None:
|
||||
_validate_portfolio_name(name, ctx.obj.console)
|
||||
_confirm(force, f"Create portfolio {name!r} for symbol {symbol} (user: {user})?")
|
||||
_confirm(force, f"Create portfolio {name!r} (asset-id: {asset_id}, user: {user})?")
|
||||
|
||||
def _callback(service, repository, app_context):
|
||||
owner = repository.get_user_by_username(user)
|
||||
@@ -166,7 +166,7 @@ def create_portfolio(
|
||||
return service.create_portfolio(
|
||||
CreatePortfolioRequest(
|
||||
name=name,
|
||||
symbol=symbol,
|
||||
asset_id=asset_id,
|
||||
broker=broker,
|
||||
status=status,
|
||||
cash_balance=cash_balance,
|
||||
@@ -191,7 +191,6 @@ def update_portfolio(
|
||||
broker: str | None = typer.Option(None, help="Broker code."),
|
||||
status: str | None = typer.Option(None, help="Portfolio status."),
|
||||
new_name: str | None = typer.Option(None, "--new-name", help="New portfolio name (requires --force or confirm)."),
|
||||
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."),
|
||||
@@ -215,7 +214,6 @@ def update_portfolio(
|
||||
broker=broker,
|
||||
status=status,
|
||||
name=new_name,
|
||||
symbol=symbol,
|
||||
strategy_name=strategy_name,
|
||||
strategy_params=parsed_strategy_params,
|
||||
asset_price=asset_price,
|
||||
|
||||
+23
-1
@@ -4,9 +4,31 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
class MarketCode(str, Enum):
|
||||
KRX = "KRX"
|
||||
NXT = "NXT"
|
||||
SOR = "SOR"
|
||||
NASDAQ = "NASDAQ"
|
||||
NYSE = "NYSE"
|
||||
UPBIT = "UPBIT"
|
||||
BITHUMB = "BITHUMB"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Asset:
|
||||
asset_id: str
|
||||
symbol: str
|
||||
markets: list[str]
|
||||
display_name: str | None = None
|
||||
status: str = "active"
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class StrategySet:
|
||||
id: str
|
||||
@@ -73,7 +95,7 @@ class Portfolio:
|
||||
status: str
|
||||
name: str
|
||||
symbol: str
|
||||
market: str | None = None
|
||||
asset_id: str | None = None
|
||||
tags: list[str] | None = None
|
||||
owner_user_id: str | None = None
|
||||
cash_balance: float = 0.0
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Protocol
|
||||
|
||||
from app.domain.models import AuthToken, BrokerCredential, Daemon, LedgerEntry, LedgerTrade, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User
|
||||
from app.domain.models import Asset, AuthToken, BrokerCredential, Daemon, LedgerEntry, LedgerTrade, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User
|
||||
|
||||
|
||||
class PortfolioRepository(Protocol):
|
||||
@@ -136,7 +136,7 @@ class PortfolioRepository(Protocol):
|
||||
broker: str,
|
||||
status: str,
|
||||
name: str,
|
||||
symbol: str,
|
||||
asset_id: str,
|
||||
cash_balance: float,
|
||||
asset_quantity: float,
|
||||
asset_price: float,
|
||||
@@ -145,6 +145,30 @@ class PortfolioRepository(Protocol):
|
||||
avabile_strategies: list[str] | None,
|
||||
) -> Portfolio: ...
|
||||
|
||||
def get_asset_by_id(self, asset_id: str) -> Asset | None: ...
|
||||
|
||||
def get_asset_by_symbol(self, symbol: str) -> Asset | None: ...
|
||||
|
||||
def list_assets(self) -> list[Asset]: ...
|
||||
|
||||
def insert_asset(
|
||||
self,
|
||||
*,
|
||||
symbol: str,
|
||||
markets: list[str],
|
||||
display_name: str | None = None,
|
||||
status: str = "active",
|
||||
) -> Asset: ...
|
||||
|
||||
def update_asset(
|
||||
self,
|
||||
asset_id: str,
|
||||
*,
|
||||
markets: list[str] | None = None,
|
||||
display_name: str | None = None,
|
||||
status: str | None = None,
|
||||
) -> Asset: ...
|
||||
|
||||
def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio: ...
|
||||
|
||||
def list_portfolios(
|
||||
|
||||
+172
-71
@@ -11,7 +11,7 @@ from typing import Any
|
||||
import psycopg
|
||||
from psycopg import sql
|
||||
|
||||
from app.domain.models import AuthToken, BrokerCredential, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User
|
||||
from app.domain.models import Asset, AuthToken, BrokerCredential, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User
|
||||
|
||||
_SELECT_USER = """
|
||||
SELECT
|
||||
@@ -46,23 +46,37 @@ _SELECT_AUTH_TOKEN = """
|
||||
|
||||
_SELECT_PORTFOLIO = """
|
||||
SELECT
|
||||
portfolio_id,
|
||||
broker,
|
||||
status,
|
||||
name,
|
||||
p.portfolio_id,
|
||||
p.broker,
|
||||
p.status,
|
||||
p.name,
|
||||
a.symbol,
|
||||
p.cash_balance,
|
||||
p.asset_quantity,
|
||||
p.asset_price,
|
||||
p.profit_buffer,
|
||||
p.active_strategy_id,
|
||||
p.avabile_strategies,
|
||||
p.owner_user_id,
|
||||
p.created_at,
|
||||
p.updated_at,
|
||||
p.last_trade_at,
|
||||
p.managed_by_daemon_id,
|
||||
p.asset_id
|
||||
FROM portfolios p
|
||||
JOIN assets a ON p.asset_id = a.asset_id
|
||||
"""
|
||||
|
||||
_SELECT_ASSET = """
|
||||
SELECT
|
||||
asset_id,
|
||||
symbol,
|
||||
cash_balance,
|
||||
asset_quantity,
|
||||
asset_price,
|
||||
profit_buffer,
|
||||
active_strategy_id,
|
||||
avabile_strategies,
|
||||
owner_user_id,
|
||||
markets,
|
||||
display_name,
|
||||
status,
|
||||
created_at,
|
||||
updated_at,
|
||||
last_trade_at,
|
||||
managed_by_daemon_id
|
||||
FROM portfolios
|
||||
updated_at
|
||||
FROM assets
|
||||
"""
|
||||
|
||||
|
||||
@@ -177,6 +191,25 @@ class PostgresPortfolioRepository:
|
||||
)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS assets (
|
||||
asset_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
symbol TEXT NOT NULL UNIQUE,
|
||||
markets TEXT[] NOT NULL DEFAULT '{}',
|
||||
display_name TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_assets_markets_gin
|
||||
ON assets USING GIN (markets)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS portfolios (
|
||||
@@ -184,7 +217,7 @@ class PostgresPortfolioRepository:
|
||||
broker TEXT NOT NULL DEFAULT 'kis',
|
||||
status TEXT NOT NULL DEFAULT 'enabled',
|
||||
name TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
asset_id UUID NOT NULL REFERENCES assets(asset_id) ON DELETE RESTRICT,
|
||||
owner_user_id TEXT,
|
||||
cash_balance DOUBLE PRECISION NOT NULL,
|
||||
asset_quantity DOUBLE PRECISION NOT NULL,
|
||||
@@ -201,7 +234,8 @@ class PostgresPortfolioRepository:
|
||||
cur.execute("ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS name TEXT")
|
||||
cur.execute("ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS owner_user_id TEXT")
|
||||
cur.execute("ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS managed_by_daemon_id TEXT")
|
||||
cur.execute("UPDATE portfolios SET name = symbol WHERE name IS NULL OR name = ''")
|
||||
cur.execute("ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS asset_id UUID")
|
||||
cur.execute("UPDATE portfolios SET name = portfolio_id WHERE name IS NULL OR name = ''")
|
||||
cur.execute("ALTER TABLE portfolios ALTER COLUMN name SET NOT NULL")
|
||||
cur.execute(
|
||||
"""
|
||||
@@ -375,6 +409,7 @@ class PostgresPortfolioRepository:
|
||||
updated_at=row[13] if len(row) > 13 else None,
|
||||
last_trade_at=row[14] if len(row) > 14 else None,
|
||||
managed_by_daemon_id=row[15] if len(row) > 15 else None,
|
||||
asset_id=str(row[16]) if len(row) > 16 and row[16] is not None else None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -383,6 +418,20 @@ class PostgresPortfolioRepository:
|
||||
raise ValueError("태그를 찾을 수 없습니다")
|
||||
return PortfolioTag(portfolio_id=row[0], tag=row[1], created_at=row[2] if len(row) > 2 else None)
|
||||
|
||||
@staticmethod
|
||||
def _row_to_asset(row: Any) -> Asset:
|
||||
if row is None:
|
||||
raise ValueError("asset을 찾을 수 없습니다")
|
||||
return Asset(
|
||||
asset_id=str(row[0]),
|
||||
symbol=row[1],
|
||||
markets=list(row[2]) if row[2] else [],
|
||||
display_name=row[3],
|
||||
status=row[4] or "active",
|
||||
created_at=row[5],
|
||||
updated_at=row[6],
|
||||
)
|
||||
|
||||
def _load_tags_for_portfolios(self, cur: psycopg.Cursor, portfolio_ids: list[str]) -> dict[str, list[str]]:
|
||||
if not portfolio_ids:
|
||||
return {}
|
||||
@@ -737,7 +786,7 @@ class PostgresPortfolioRepository:
|
||||
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,))
|
||||
cur.execute(_SELECT_PORTFOLIO + "WHERE p.portfolio_id = %s", (portfolio_id,))
|
||||
row = cur.fetchone()
|
||||
portfolio = self._row_to_portfolio(row)
|
||||
self._attach_tags(cur, [portfolio])
|
||||
@@ -747,7 +796,7 @@ class PostgresPortfolioRepository:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
_SELECT_PORTFOLIO + "WHERE owner_user_id = %s AND name = %s",
|
||||
_SELECT_PORTFOLIO + "WHERE p.owner_user_id = %s AND p.name = %s",
|
||||
(owner_user_id, name),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
@@ -772,10 +821,15 @@ class PostgresPortfolioRepository:
|
||||
) -> Portfolio:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute("SELECT asset_id FROM assets WHERE symbol = %s", (symbol,))
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
raise ValueError(f"asset not found for symbol: {symbol}")
|
||||
asset_id = row[0]
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO portfolios (
|
||||
portfolio_id, broker, status, name, symbol,
|
||||
portfolio_id, broker, status, name, asset_id,
|
||||
owner_user_id, cash_balance, asset_quantity, asset_price, profit_buffer,
|
||||
active_strategy_id, avabile_strategies
|
||||
)
|
||||
@@ -787,7 +841,7 @@ class PostgresPortfolioRepository:
|
||||
broker,
|
||||
status,
|
||||
name or symbol,
|
||||
symbol,
|
||||
asset_id,
|
||||
owner_user_id,
|
||||
float(initial_cash),
|
||||
0.0,
|
||||
@@ -797,7 +851,7 @@ class PostgresPortfolioRepository:
|
||||
avabile_strategies,
|
||||
),
|
||||
)
|
||||
cur.execute(_SELECT_PORTFOLIO + "WHERE portfolio_id = %s", (portfolio_id,))
|
||||
cur.execute(_SELECT_PORTFOLIO + "WHERE p.portfolio_id = %s", (portfolio_id,))
|
||||
row = cur.fetchone()
|
||||
return self._row_to_portfolio(row)
|
||||
|
||||
@@ -809,7 +863,7 @@ class PostgresPortfolioRepository:
|
||||
broker: str,
|
||||
status: str,
|
||||
name: str,
|
||||
symbol: str,
|
||||
asset_id: str,
|
||||
cash_balance: float,
|
||||
asset_quantity: float,
|
||||
asset_price: float,
|
||||
@@ -825,21 +879,17 @@ class PostgresPortfolioRepository:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO portfolios (
|
||||
portfolio_id, broker, status, name, symbol,
|
||||
portfolio_id, broker, status, name, asset_id,
|
||||
owner_user_id, cash_balance, asset_quantity, asset_price, profit_buffer,
|
||||
active_strategy_id, avabile_strategies
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING
|
||||
portfolio_id, broker, status, name, symbol,
|
||||
cash_balance, asset_quantity, asset_price, profit_buffer,
|
||||
active_strategy_id, avabile_strategies, owner_user_id, created_at, updated_at, last_trade_at
|
||||
""",
|
||||
(
|
||||
portfolio_id,
|
||||
broker,
|
||||
status,
|
||||
name,
|
||||
symbol,
|
||||
asset_id,
|
||||
owner_user_id,
|
||||
float(cash_balance),
|
||||
float(asset_quantity),
|
||||
@@ -849,9 +899,8 @@ class PostgresPortfolioRepository:
|
||||
avabile_strategies,
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
portfolio = self._row_to_portfolio(row)
|
||||
portfolio.tags = []
|
||||
portfolio = self.get(portfolio_id)
|
||||
portfolio.tags = []
|
||||
return portfolio
|
||||
|
||||
def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio:
|
||||
@@ -860,7 +909,6 @@ class PostgresPortfolioRepository:
|
||||
"broker": "broker",
|
||||
"status": "status",
|
||||
"name": "name",
|
||||
"symbol": "symbol",
|
||||
"asset_price": "asset_price",
|
||||
"active_strategy_id": "active_strategy_id",
|
||||
"avabile_strategies": "avabile_strategies",
|
||||
@@ -887,15 +935,10 @@ class PostgresPortfolioRepository:
|
||||
UPDATE portfolios
|
||||
SET {', '.join(assignments)}
|
||||
WHERE portfolio_id = %s
|
||||
RETURNING
|
||||
portfolio_id, broker, status, name, symbol,
|
||||
cash_balance, asset_quantity, asset_price, profit_buffer,
|
||||
active_strategy_id, avabile_strategies, owner_user_id, created_at, updated_at, last_trade_at
|
||||
""",
|
||||
(*params, portfolio_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return self._row_to_portfolio(row)
|
||||
return self.get(portfolio_id)
|
||||
|
||||
def list_portfolios(
|
||||
self,
|
||||
@@ -913,23 +956,23 @@ class PostgresPortfolioRepository:
|
||||
clauses: list[str] = []
|
||||
params: list[object] = []
|
||||
if broker is not None:
|
||||
clauses.append("broker = %s")
|
||||
clauses.append("p.broker = %s")
|
||||
params.append(broker)
|
||||
if status is not None:
|
||||
clauses.append("status = %s")
|
||||
clauses.append("p.status = %s")
|
||||
params.append(status)
|
||||
if owner_user_id is not None:
|
||||
clauses.append("owner_user_id = %s")
|
||||
clauses.append("p.owner_user_id = %s")
|
||||
params.append(owner_user_id)
|
||||
if managed_by_daemon_id is not None:
|
||||
clauses.append("managed_by_daemon_id = %s")
|
||||
clauses.append("p.managed_by_daemon_id = %s")
|
||||
params.append(managed_by_daemon_id)
|
||||
normalized_mode = (match_mode or "OR").upper()
|
||||
if include_tags:
|
||||
if normalized_mode == "AND":
|
||||
clauses.append(
|
||||
"""
|
||||
portfolio_id IN (
|
||||
p.portfolio_id IN (
|
||||
SELECT portfolio_id
|
||||
FROM portfolio_tags
|
||||
WHERE tag_value = ANY(%s)
|
||||
@@ -942,17 +985,17 @@ class PostgresPortfolioRepository:
|
||||
params.append(len(set(include_tags)))
|
||||
else:
|
||||
clauses.append(
|
||||
"portfolio_id IN (SELECT DISTINCT portfolio_id FROM portfolio_tags WHERE tag_value = ANY(%s))"
|
||||
"p.portfolio_id IN (SELECT DISTINCT portfolio_id FROM portfolio_tags WHERE tag_value = ANY(%s))"
|
||||
)
|
||||
params.append(include_tags)
|
||||
if exclude_tags:
|
||||
clauses.append(
|
||||
"portfolio_id NOT IN (SELECT DISTINCT portfolio_id FROM portfolio_tags WHERE tag_value = ANY(%s))"
|
||||
"p.portfolio_id NOT IN (SELECT DISTINCT portfolio_id FROM portfolio_tags WHERE tag_value = ANY(%s))"
|
||||
)
|
||||
params.append(exclude_tags)
|
||||
if clauses:
|
||||
query += "WHERE " + " AND ".join(clauses) + " "
|
||||
query += "ORDER BY updated_at DESC, portfolio_id ASC"
|
||||
query += "ORDER BY p.updated_at DESC, p.portfolio_id ASC"
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(query, tuple(params))
|
||||
rows = cur.fetchall()
|
||||
@@ -1097,7 +1140,6 @@ class PostgresPortfolioRepository:
|
||||
UPDATE portfolios
|
||||
SET broker = %s,
|
||||
status = %s,
|
||||
symbol = %s,
|
||||
cash_balance = %s,
|
||||
asset_quantity = %s,
|
||||
asset_price = %s,
|
||||
@@ -1111,7 +1153,6 @@ class PostgresPortfolioRepository:
|
||||
(
|
||||
portfolio.broker,
|
||||
portfolio.status,
|
||||
portfolio.symbol,
|
||||
float(portfolio.cash_balance),
|
||||
float(portfolio.asset_quantity),
|
||||
float(current_price),
|
||||
@@ -1250,18 +1291,13 @@ class PostgresPortfolioRepository:
|
||||
updated_at = NOW(),
|
||||
last_trade_at = NULL
|
||||
WHERE portfolio_id = %s
|
||||
RETURNING
|
||||
portfolio_id, broker, status, name, symbol,
|
||||
cash_balance, asset_quantity, asset_price, profit_buffer,
|
||||
active_strategy_id, avabile_strategies, owner_user_id, created_at, updated_at, last_trade_at
|
||||
""",
|
||||
(portfolio_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
if cur.rowcount == 0:
|
||||
raise ValueError("포트폴리오를 찾을 수 없습니다")
|
||||
cur.execute("DELETE FROM portfolio_ledger WHERE portfolio_id = %s", (portfolio_id,))
|
||||
return self._row_to_portfolio(row)
|
||||
return self.get(portfolio_id)
|
||||
|
||||
def adjust_buffer(self, portfolio_id: str, amount: float, action: str, note: str = "") -> Portfolio:
|
||||
self.ensure_schema()
|
||||
@@ -1271,7 +1307,7 @@ class PostgresPortfolioRepository:
|
||||
if amount <= 0:
|
||||
raise ValueError("amount는 0보다 커야 합니다")
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(_SELECT_PORTFOLIO + "WHERE portfolio_id = %s", (portfolio_id,))
|
||||
cur.execute(_SELECT_PORTFOLIO + "WHERE p.portfolio_id = %s", (portfolio_id,))
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
raise ValueError("포트폴리오를 찾을 수 없습니다")
|
||||
@@ -1289,14 +1325,9 @@ class PostgresPortfolioRepository:
|
||||
SET profit_buffer = %s,
|
||||
updated_at = NOW()
|
||||
WHERE portfolio_id = %s
|
||||
RETURNING
|
||||
portfolio_id, broker, status, name, symbol,
|
||||
cash_balance, asset_quantity, asset_price, profit_buffer,
|
||||
active_strategy_id, avabile_strategies, owner_user_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(
|
||||
"""
|
||||
@@ -1321,7 +1352,7 @@ class PostgresPortfolioRepository:
|
||||
note,
|
||||
),
|
||||
)
|
||||
return self._row_to_portfolio(updated_row)
|
||||
return self.get(portfolio_id)
|
||||
|
||||
def transfer_buffer(self, portfolio_id: str, amount: float, direction: str, note: str = "") -> Portfolio:
|
||||
self.ensure_schema()
|
||||
@@ -1331,7 +1362,7 @@ class PostgresPortfolioRepository:
|
||||
if amount <= 0:
|
||||
raise ValueError("amount는 0보다 커야 합니다")
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(_SELECT_PORTFOLIO + "WHERE portfolio_id = %s", (portfolio_id,))
|
||||
cur.execute(_SELECT_PORTFOLIO + "WHERE p.portfolio_id = %s", (portfolio_id,))
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
raise ValueError("포트폴리오를 찾을 수 없습니다")
|
||||
@@ -1358,14 +1389,9 @@ class PostgresPortfolioRepository:
|
||||
profit_buffer = %s,
|
||||
updated_at = NOW()
|
||||
WHERE portfolio_id = %s
|
||||
RETURNING
|
||||
portfolio_id, broker, status, name, symbol,
|
||||
cash_balance, asset_quantity, asset_price, profit_buffer,
|
||||
active_strategy_id, avabile_strategies, owner_user_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(
|
||||
"""
|
||||
@@ -1390,7 +1416,82 @@ class PostgresPortfolioRepository:
|
||||
note,
|
||||
),
|
||||
)
|
||||
return self._row_to_portfolio(updated_row)
|
||||
return self.get(portfolio_id)
|
||||
|
||||
def get_asset_by_id(self, asset_id: str) -> Asset | None:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(_SELECT_ASSET + "WHERE asset_id = %s", (asset_id,))
|
||||
row = cur.fetchone()
|
||||
return None if row is None else self._row_to_asset(row)
|
||||
|
||||
def get_asset_by_symbol(self, symbol: str) -> Asset | None:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(_SELECT_ASSET + "WHERE symbol = %s", (symbol,))
|
||||
row = cur.fetchone()
|
||||
return None if row is None else self._row_to_asset(row)
|
||||
|
||||
def list_assets(self) -> list[Asset]:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(_SELECT_ASSET + "ORDER BY symbol ASC")
|
||||
rows = cur.fetchall()
|
||||
return [self._row_to_asset(row) for row in rows]
|
||||
|
||||
def insert_asset(
|
||||
self,
|
||||
*,
|
||||
symbol: str,
|
||||
markets: list[str],
|
||||
display_name: str | None = None,
|
||||
status: str = "active",
|
||||
) -> Asset:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO assets (symbol, markets, display_name, status)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
RETURNING asset_id, symbol, markets, display_name, status, created_at, updated_at
|
||||
""",
|
||||
(symbol, markets, display_name, status),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return self._row_to_asset(row)
|
||||
|
||||
def update_asset(
|
||||
self,
|
||||
asset_id: str,
|
||||
*,
|
||||
markets: list[str] | None = None,
|
||||
display_name: str | None = None,
|
||||
status: str | None = None,
|
||||
) -> Asset:
|
||||
self.ensure_schema()
|
||||
assignments: list[str] = ["updated_at = NOW()"]
|
||||
params: list[object] = []
|
||||
if markets is not None:
|
||||
assignments.append("markets = %s")
|
||||
params.append(markets)
|
||||
if display_name is not None:
|
||||
assignments.append("display_name = %s")
|
||||
params.append(display_name)
|
||||
if status is not None:
|
||||
assignments.append("status = %s")
|
||||
params.append(status)
|
||||
params.append(asset_id)
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
UPDATE assets SET {', '.join(assignments)}
|
||||
WHERE asset_id = %s
|
||||
""",
|
||||
tuple(params),
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
raise ValueError(f"asset을 찾을 수 없습니다: {asset_id}")
|
||||
return self.get_asset_by_id(asset_id) # type: ignore[return-value]
|
||||
|
||||
def delete(self, portfolio_id: str, *, clear_ledger: bool = True) -> None:
|
||||
self.ensure_schema()
|
||||
|
||||
@@ -40,7 +40,6 @@ class PortfolioBase(BaseModel):
|
||||
broker: str = "kis"
|
||||
status: str = "enabled"
|
||||
name: str | None = None
|
||||
symbol: str
|
||||
|
||||
|
||||
class PortfolioSummary(BaseModel):
|
||||
@@ -76,6 +75,7 @@ class PortfolioDetailResponse(BaseModel):
|
||||
|
||||
class CreatePortfolioRequest(PortfolioBase):
|
||||
name: str # required, overrides Optional from PortfolioBase; used as (owner_user_id, name) identifier
|
||||
asset_id: str
|
||||
strategy_name: str = "target_value"
|
||||
strategy_params: dict[str, Any] = Field(default_factory=dict)
|
||||
active_strategy_id: str | None = None
|
||||
@@ -99,7 +99,6 @@ 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
|
||||
active_strategy_id: str | None = None
|
||||
|
||||
@@ -16,7 +16,6 @@ from pydantic import BaseModel, Field
|
||||
class AssetItem(BaseModel):
|
||||
asset_id: str
|
||||
symbol: str
|
||||
market: str | None = None
|
||||
status: str
|
||||
cash: float
|
||||
quantity: float
|
||||
|
||||
@@ -221,7 +221,7 @@ class PortfolioService:
|
||||
owner_user_id=owner_user_id,
|
||||
broker=payload.broker,
|
||||
status=payload.status,
|
||||
symbol=payload.symbol,
|
||||
asset_id=payload.asset_id,
|
||||
cash_balance=payload.cash_balance,
|
||||
asset_quantity=payload.asset_quantity,
|
||||
asset_price=payload.asset_price,
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# BAT-001 Design
|
||||
|
||||
- Status: draft
|
||||
- Batch: BAT-001
|
||||
|
||||
## Architecture Summary
|
||||
- `assets` 테이블을 신규 도입하여 "자산(종목) 카탈로그"와 "포지션(포트폴리오)" 개념을 DB 레벨에서 분리한다.
|
||||
- 기존 `portfolios` 테이블은 `symbol`/`market` 직접 보유에서 `asset_id FK` 참조 구조로 전환한다.
|
||||
- 기존 운영 데이터는 `ARRAY_AGG(DISTINCT market)` 집약 규칙으로 `assets` catalog로 이전하고, 각 portfolio 행에 `asset_id`를 역으로 연결한 뒤 레거시 컬럼을 제거한다.
|
||||
- 모든 DB 변경은 Alembic으로만 관리하며 `ensure_schema()`는 사용하지 않는다.
|
||||
|
||||
## Alembic Revision 전략
|
||||
|
||||
### 결정: 2-revision 분리 (rev-A → rev-B)
|
||||
|
||||
**rev-A** (REQ-009 전용):
|
||||
- `assets` 테이블 CREATE TABLE
|
||||
- `idx_assets_markets_gin` GIN 인덱스 CREATE
|
||||
|
||||
**rev-B** (REQ-011 + REQ-014 결합, 단일 트랜잭션):
|
||||
```
|
||||
BEGIN;
|
||||
-- Step 1: 사전 검증 (symbol 수 파악)
|
||||
SELECT COUNT(DISTINCT symbol) FROM portfolios;
|
||||
|
||||
-- Step 2: assets catalog 채우기 (ARRAY_AGG(DISTINCT market), NULL → '{}')
|
||||
INSERT INTO assets (symbol, markets)
|
||||
SELECT symbol, ARRAY_AGG(DISTINCT market) FILTER (WHERE market IS NOT NULL)
|
||||
FROM portfolios
|
||||
GROUP BY symbol
|
||||
ON CONFLICT (symbol) DO NOTHING;
|
||||
|
||||
-- Step 3: portfolios.asset_id 컬럼 추가 (NULL 허용, 임시)
|
||||
ALTER TABLE portfolios ADD COLUMN asset_id UUID;
|
||||
|
||||
-- Step 4: asset_id 역연결
|
||||
UPDATE portfolios p SET asset_id = a.asset_id
|
||||
FROM assets a WHERE a.symbol = p.symbol;
|
||||
|
||||
-- Step 5: NOT NULL 제약 + FK 추가
|
||||
ALTER TABLE portfolios ALTER COLUMN asset_id SET NOT NULL;
|
||||
ALTER TABLE portfolios ADD CONSTRAINT fk_portfolios_asset_id
|
||||
FOREIGN KEY (asset_id) REFERENCES assets(asset_id) ON DELETE RESTRICT;
|
||||
|
||||
-- Step 6: 레거시 컬럼 제거
|
||||
ALTER TABLE portfolios DROP COLUMN symbol;
|
||||
ALTER TABLE portfolios DROP COLUMN market;
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
**이유:** rev-A와 rev-B를 분리하면 rev-A만 단독 롤백 가능. rev-B 내부는 단일 트랜잭션으로 원자성 보장 (REQ-014 NFR-1).
|
||||
|
||||
## Changed Areas
|
||||
- Docs:
|
||||
- `docs/data-model.md` — REQ-015에서 처리 (이 batch 범위 밖)
|
||||
- Modules:
|
||||
- `migrations/versions/` — rev-A, rev-B 신규 revision 파일 2개
|
||||
- `app/domain/models.py` — `MarketCode` Enum, `Asset` 모델 추가; `Portfolio` 모델 symbol/market 제거, asset_id 추가
|
||||
- `app/repositories/portfolio_repository.py` — asset CRUD Protocol 메서드 선언 추가
|
||||
- `app/repositories/postgres.py` — asset CRUD 구현 추가; portfolios 쿼리 assets JOIN 반영
|
||||
- `app/api/portfolios.py` — symbol/market 참조 제거
|
||||
- `app/api/v1_assets.py` — symbol/market 참조 제거 (list_assets 전면 재작성은 REQ-013 범위이므로 최소 수정)
|
||||
- `app/cli/commands/portfolio.py` — 출력에서 symbol/market 제거
|
||||
- `app/schemas/` — PortfolioListItem 등 symbol/market 필드 제거
|
||||
- Interfaces:
|
||||
- `portfolio_repository.py` Protocol에 신규 asset CRUD 메서드 추가
|
||||
|
||||
## Key Decisions
|
||||
|
||||
1. **rev-B에서 `portfolios.asset_id`를 NULL 허용으로 먼저 추가 후 데이터 채운 뒤 NOT NULL 제약 적용**
|
||||
- 이유: `ADD COLUMN ... NOT NULL` 단일 문장은 기존 행에 DEFAULT 없으면 오류 → 2단계 처리 필수
|
||||
- 구현: `ADD COLUMN asset_id UUID` → UPDATE → `SET NOT NULL`
|
||||
|
||||
2. **REQ-011 schema 변경과 REQ-014 데이터 이전을 rev-B 단일 revision 내 단일 트랜잭션으로 결합**
|
||||
- 이유: 데이터 이전 없이 symbol/market DROP이 불가하고, 두 revision으로 분리하면 중간 상태(asset_id NULL + symbol 잔존)가 운영 DB에 커밋될 위험
|
||||
- 트레이드오프: revision이 크고 복잡해지지만 롤백 안전성 확보
|
||||
|
||||
3. **`app/api/v1_assets.py`는 symbol/market 참조 최소 제거만 수행 (list_assets 재작성은 REQ-013 범위)**
|
||||
- 이유: 이 batch는 DB/도메인 레이어 변경에 집중; list_assets 로직 전면 재작성은 BAT-002에서 처리
|
||||
- 임시 조치: portfolios JOIN assets 쿼리로 symbol을 asset.symbol로 대체, market 단수 필드 제거
|
||||
|
||||
4. **`MarketCode` Enum 검증은 API 레이어(이 batch에서는 repository 구현 내)에서만 수행, DB CHECK constraint 없음**
|
||||
- REQ-009 명세 준수: app-layer Enum, DB CHECK constraint 미사용
|
||||
|
||||
## Edge Cases
|
||||
- `portfolios.market IS NULL`인 기존 행: `ARRAY_AGG(DISTINCT market) FILTER (WHERE market IS NOT NULL)` → 빈 배열 `'{}'` 저장 (REQ-014 Requirement 명시)
|
||||
- 동일 symbol의 여러 portfolio 행: `GROUP BY symbol`로 단일 assets 행 생성, `ARRAY_AGG(DISTINCT market)` 집약 (REQ-014 R-1, A-1)
|
||||
- rev-B rollback 시: `portfolios.symbol`, `portfolios.market`이 복원되고 `asset_id`와 `assets` 테이블 변경이 모두 되돌아감 (단일 트랜잭션 보장)
|
||||
- symbol/market 참조 코드 누락: `grep -rn "\.symbol\|\.market\|p\.symbol\|p\.market" app/` 전수 조사 후 수정 — AttributeError로 런타임에서 즉시 탐지 가능
|
||||
|
||||
## Architecture Impact
|
||||
- project-structure.md: asset CRUD Protocol 추가 (domain layer 확장) — REQ-015에서 문서화
|
||||
- runtime-flows.md: migration flow 신규 추가 — REQ-015에서 문서화
|
||||
|
||||
## Reference Doc Update Plan
|
||||
- project-structure.md: REQ-015(BAT-002)에서 처리
|
||||
- runtime-flows.md: REQ-015(BAT-002)에서 처리
|
||||
@@ -0,0 +1,62 @@
|
||||
# BAT-001 Implementation
|
||||
|
||||
- Status: verified
|
||||
- Batch: BAT-001
|
||||
|
||||
## Plan Summary
|
||||
- 이 batch 구현에서 실제 변경할 범위:
|
||||
1. `app/domain/models.py` — MarketCode Enum, Asset 모델 추가; Portfolio 모델 symbol/market 제거, asset_id 추가
|
||||
2. `app/repositories/portfolio_repository.py` — asset CRUD Protocol 선언 추가
|
||||
3. `app/repositories/postgres.py` — asset CRUD SQL 구현; portfolios JOIN assets 쿼리 반영
|
||||
4. Alembic rev-A — assets 테이블 생성 + GIN 인덱스
|
||||
5. Alembic rev-B — portfolios 데이터 이전 + asset_id FK 추가 + symbol/market 컬럼 제거 (단일 트랜잭션)
|
||||
6. `app/api/portfolios.py`, `app/api/v1_assets.py`, `app/cli/commands/portfolio.py`, `app/schemas/` — symbol/market 참조 제거
|
||||
7. `tests/test_asset_catalog.py` 신규 — REQ-009 AC-1~5, REQ-011 AC-1~5, REQ-014 AC-1~6
|
||||
|
||||
## Changed Files
|
||||
- `app/domain/models.py`
|
||||
- `app/repositories/portfolio_repository.py`
|
||||
- `app/repositories/postgres.py`
|
||||
- `migrations/versions/<rev-A>_create_assets_table.py` (신규)
|
||||
- `migrations/versions/<rev-B>_portfolios_asset_migration.py` (신규)
|
||||
- `app/api/portfolios.py`
|
||||
- `app/api/v1_assets.py`
|
||||
- `app/cli/commands/portfolio.py`
|
||||
- `app/schemas/` (영향 파일 구현 단계에서 확정)
|
||||
- `tests/test_asset_catalog.py` (신규)
|
||||
|
||||
## Changed Files (확정)
|
||||
- `app/domain/models.py` — MarketCode Enum, Asset dataclass 추가; Portfolio.market 제거 → asset_id 추가
|
||||
- `app/repositories/portfolio_repository.py` — create_portfolio symbol→asset_id; asset CRUD Protocol 선언 5종
|
||||
- `app/repositories/postgres.py` — _SELECT_PORTFOLIO JOIN assets; save/reset/adjust_buffer/transfer_buffer RETURNING 제거; create_portfolio/update_portfolio 수정; asset CRUD 5종 구현; get_or_create asset 조회 로직 추가
|
||||
- `migrations/versions/20260519_0017_create_assets_table.py` (신규) — assets 테이블 + GIN 인덱스 (rev-A)
|
||||
- `migrations/versions/20260519_0018_portfolios_asset_migration.py` (신규) — 데이터 이전 + asset_id FK + symbol/market DROP (rev-B)
|
||||
- `app/schemas/v1.py` — AssetItem.market 제거
|
||||
- `app/api/v1_assets.py` — market=p.market 제거
|
||||
- `app/schemas/portfolio.py` — PortfolioBase.symbol 제거; CreatePortfolioRequest.asset_id 추가; UpdatePortfolioRequest.symbol 제거
|
||||
- `app/services/portfolio_service.py` — create_portfolio symbol→asset_id
|
||||
- `app/cli/commands/portfolio.py` — create --symbol→--asset-id; update --symbol 제거
|
||||
- `tests/test_asset_catalog.py` (신규) — REQ-009 AC-4,5 + REQ-011 AC-4,5 + REQ-014 migration 파일 구조 검증
|
||||
- `tests/test_cli.py` — FakeRepo create_portfolio symbol→asset_id; _portfolio() name 공백 제거; 테스트 픽스처 업데이트
|
||||
- `tests/test_portfolio_uuid.py` — FakeRepo create_portfolio symbol→asset_id; --symbol→--asset-id 전환
|
||||
- `tests/test_v1.py` — market 필드 어서션 제거
|
||||
- `tests/test_portfolio_ownership.py` — POST body symbol→asset_id
|
||||
- `tests/test_daemon_assignment.py` — Portfolio() market= 제거
|
||||
|
||||
## Execution Log
|
||||
1. app/domain/models.py: MarketCode Enum, Asset 모델 추가; Portfolio market→asset_id 필드 교체
|
||||
2. app/repositories/portfolio_repository.py: create_portfolio 시그니처 변경; asset CRUD Protocol 5종 선언
|
||||
3. app/repositories/postgres.py: JOIN 쿼리, RETURNING 제거 패턴, asset CRUD 5종 구현, get_or_create symbol→asset 조회
|
||||
4. Alembic rev-A(0017): assets 테이블 + GIN 인덱스 생성
|
||||
5. Alembic rev-B(0018): 데이터 이전 → asset_id FK → symbol/market DROP (단일 트랜잭션 내 idempotent 가드)
|
||||
6. 스키마/API/CLI 레이어: symbol/market 참조 전수 제거
|
||||
7. 기존 테스트 suite 17개 실패 수정: 공백 포함 이름, --symbol→--asset-id, market 어서션 제거, request body 업데이트
|
||||
8. test_asset_catalog.py 신규 작성: 20개 테스트
|
||||
|
||||
## Validation
|
||||
- pytest 162 passed (tests/test_asset_catalog.py 20, tests/test_cli.py 35, tests/test_portfolio_uuid.py 14, tests/test_v1.py 21, tests/test_portfolio_ownership.py 4, 나머지 기존 suite)
|
||||
- 실행 명령: `.venv/bin/python -m pytest --tb=short`
|
||||
|
||||
## Remaining Risks
|
||||
- DB-integration ACs(REQ-009 AC-1,2,3; REQ-011 AC-1,2,3; REQ-014 AC-1~6)는 실제 PostgreSQL 연결 없이 검증 불가 — verification.md에 통합 테스트 항목으로 기록
|
||||
- R-4(운영 환경 대용량 lock): 로컬 테스트 범위 밖; 운영 배포 시 maintenance window 고려 필요
|
||||
@@ -0,0 +1,46 @@
|
||||
# BAT-001: asset catalog DB 기반 도입
|
||||
|
||||
- Status: verified
|
||||
- Profile: standard
|
||||
- Source Discovery: dcy-001
|
||||
- Included REQ: REQ-009, REQ-011, REQ-014
|
||||
- Excluded REQ: REQ-010, REQ-012, REQ-013, REQ-015 (→ BAT-002 예정)
|
||||
|
||||
## Summary
|
||||
- dcy-001의 핵심 DB 변경을 단일 delivery 단위로 묶는다.
|
||||
- `assets` catalog 테이블을 신규 도입(REQ-009)하고, `portfolios` 테이블을 `asset_id FK` 참조 구조로 재설계하며 `symbol`/`market` 컬럼을 제거(REQ-011)한다.
|
||||
- 기존 운영 데이터를 손실 없이 `assets` catalog로 이전하고 `portfolios.asset_id`를 채운 후 레거시 컬럼을 삭제하는 데이터 마이그레이션(REQ-014)을 원자적 Alembic revision으로 완성한다.
|
||||
- 이 batch 완료 후 admin CRUD API/CLI(REQ-010), portfolio 생성 검증(REQ-012), GET /v1/assets 분기 재작성(REQ-013) 착수가 가능해진다.
|
||||
|
||||
## Scope
|
||||
- In Scope:
|
||||
- `assets` 테이블 Alembic revision (asset_id UUID PK, symbol UNIQUE, markets TEXT[], GIN index)
|
||||
- `MarketCode` Enum(KRX, NXT, SOR, NASDAQ, NYSE, UPBIT, BITTHUMB) — `app/domain/models.py`
|
||||
- `Asset` 도메인 모델 — `app/domain/models.py`
|
||||
- asset CRUD Protocol 선언 — `app/repositories/portfolio_repository.py`
|
||||
- asset CRUD Postgres 구현 — `app/repositories/postgres.py`
|
||||
- `portfolios.asset_id UUID NOT NULL FK REFERENCES assets(asset_id) ON DELETE RESTRICT` 추가 Alembic revision
|
||||
- `portfolios.symbol`, `portfolios.market` 컬럼 제거 Alembic revision
|
||||
- `portfolios` 기존 데이터를 `assets` catalog로 이전하는 데이터 마이그레이션 Alembic revision (ARRAY_AGG(DISTINCT market), 단일 트랜잭션)
|
||||
- `Portfolio` 도메인 모델 symbol/market 제거, asset_id 추가 — `app/domain/models.py`
|
||||
- `app/repositories/postgres.py` portfolios 쿼리 — assets JOIN 반영, symbol/market 참조 제거
|
||||
- symbol/market 직접 참조 모듈 전수 수정 (app/api, app/cli, app/schemas)
|
||||
- `tests/test_asset_catalog.py` 신규 — REQ-009 AC-1~5, REQ-011 AC-1~5, REQ-014 AC-1~6
|
||||
- Out of Scope:
|
||||
- admin asset CRUD HTTP API/CLI (→ REQ-010, BAT-002)
|
||||
- pfhub portfolio create catalog 검증 (→ REQ-012, BAT-002)
|
||||
- GET /v1/assets token-type 분기 재작성 (→ REQ-013, BAT-002)
|
||||
- baseline 문서 갱신 (→ REQ-015, BAT-002)
|
||||
- tag 모델 관련 모든 변경 (→ dcy-002 batches)
|
||||
|
||||
## Documents
|
||||
- Planning: [planning.md](./planning.md)
|
||||
- Design: [design.md](./design.md)
|
||||
- Implementation: [implementation.md](./implementation.md)
|
||||
- Verification: [verification.md](./verification.md)
|
||||
|
||||
## Notes
|
||||
- R-2(High): 구현 착수 전 `grep -rn "\.symbol\|\.market" app/` 전수 조사 필수 — 6개 이상 모듈에 참조 잔존 가능성
|
||||
- REQ-011 schema 변경과 REQ-014 데이터 이전은 같은 Alembic revision 또는 순서 보장된 revision 시퀀스로 처리해야 한다 (REQ-014 Notes)
|
||||
- 운영 데이터 migration이 포함되므로 로컬 DB에서 사전 dry-run 검증 권장
|
||||
- dcy-002 REQ-002, REQ-003는 이 batch 완료 후에 착수 가능 (portfolios 재설계 선행 필요)
|
||||
@@ -0,0 +1,62 @@
|
||||
# BAT-001 Planning
|
||||
|
||||
- Status: verified
|
||||
- Batch: BAT-001
|
||||
- Profile: standard
|
||||
|
||||
## Included REQ
|
||||
- REQ-009: assets 테이블 스키마 도입 (Data, High)
|
||||
- REQ-011: portfolios 테이블 asset_id FK 추가 및 symbol/market 제거 (Data, High)
|
||||
- REQ-014: portfolios 데이터 migration (Migration, High)
|
||||
|
||||
## Delivery Plan
|
||||
|
||||
### 단계 1 — REQ-009: assets 테이블 및 도메인 레이어
|
||||
1. `app/domain/models.py`에 `MarketCode` Enum(KRX, NXT, SOR, NASDAQ, NYSE, UPBIT, BITTHUMB) 추가
|
||||
2. `app/domain/models.py`에 `Asset` 도메인 모델(asset_id, symbol, markets, display_name, status, created_at, updated_at) 추가
|
||||
3. `app/repositories/portfolio_repository.py` Protocol에 asset CRUD 메서드 선언(get_asset_by_id, get_asset_by_symbol, list_assets, insert_asset, update_asset)
|
||||
4. `app/repositories/postgres.py`에 asset CRUD SQL 구현 추가
|
||||
5. Alembic revision (rev-A): `assets` 테이블 생성 + `idx_assets_markets_gin` GIN 인덱스 생성
|
||||
6. `tests/test_asset_catalog.py` 신규 — REQ-009 AC-1~5 테스트
|
||||
|
||||
### 단계 2 — REQ-011 + REQ-014: portfolios 재설계 + 데이터 이전 (결합 revision)
|
||||
1. 구현 전 `grep -rn "\.symbol\|\.market" app/` 실행 — 수정 대상 모듈 전수 파악 (R-2)
|
||||
2. Alembic revision (rev-B): 아래를 단일 트랜잭션으로 처리
|
||||
- 사전 검증 쿼리: `SELECT symbol, ARRAY_AGG(DISTINCT market) FROM portfolios GROUP BY symbol` 실행
|
||||
- `assets` catalog에 기존 portfolios 데이터 이전 (symbol 기준 DISTINCT, ARRAY_AGG(DISTINCT market))
|
||||
- `portfolios.asset_id UUID NOT NULL` 컬럼 추가 + FK REFERENCES assets(asset_id) ON DELETE RESTRICT
|
||||
- 각 portfolio 행에 asset_id 채우기
|
||||
- `portfolios.symbol`, `portfolios.market` 컬럼 제거
|
||||
3. `app/domain/models.py`의 `Portfolio` 모델 — symbol/market 제거, asset_id 추가
|
||||
4. `app/repositories/postgres.py` — portfolios 쿼리를 `assets JOIN portfolios` 형태로 변경
|
||||
5. symbol/market 직접 참조 모듈 수정:
|
||||
- `app/api/portfolios.py`
|
||||
- `app/api/v1_assets.py`
|
||||
- `app/cli/commands/portfolio.py`
|
||||
- `app/schemas/` (PortfolioListItem 등)
|
||||
6. `tests/test_asset_catalog.py` — REQ-011 AC-1~5, REQ-014 AC-1~6 추가
|
||||
|
||||
### 단계 3 — 전체 테스트 suite 회귀 검증
|
||||
- 기존 테스트 suite 실행 — symbol/market 참조 갱신 후 regression 없음 확인
|
||||
|
||||
## Dependencies
|
||||
- Internal:
|
||||
- rev-A(REQ-009 assets 테이블)가 rev-B(REQ-011+014 portfolios 변경) 선행 조건 — 같은 batch 내 순서 보장
|
||||
- REQ-011 schema 변경과 REQ-014 데이터 이전은 동일 Alembic revision(rev-B)으로 결합하거나 rev-B-1(데이터 이전) → rev-B-2(symbol/market DROP) 순서로 분리 — design 단계에서 결정
|
||||
- External:
|
||||
- 없음 (로컬 Postgres, 기존 인프라 변경 없음)
|
||||
|
||||
## Design Gate
|
||||
- Design Required Before Implementation: yes
|
||||
- Reason: (1) Alembic revision 분리 전략(단일 revision vs 2-step) 결정 필요 — 운영 데이터 롤백 안전성 영향; (2) rev-B에서 `portfolios.asset_id NOT NULL` 추가 시 NULL 허용 단계 없이 바로 NOT NULL 가능한지 마이그레이션 순서 검증 필요; (3) symbol/market 참조 grep 결과에 따라 수정 범위가 변동될 수 있음
|
||||
|
||||
## Milestones
|
||||
1. REQ-009: assets 테이블 Alembic revision + domain model + repository layer 완성 + AC-1~5 통과
|
||||
2. REQ-011 + REQ-014: portfolios 재설계 Alembic revision(rev-B) + 도메인 코드 전수 수정 완성 + AC-1~6 통과
|
||||
3. 기존 테스트 suite 전체 regression 통과
|
||||
|
||||
## Risks
|
||||
- R-2(High): `grep -rn "\.symbol\|\.market" app/` 결과 범위 예측 불가 — 수정 누락 시 런타임 AttributeError 발생. 대응: 구현 착수 전 grep 결과를 planning에 기록 후 수정 체크리스트 작성
|
||||
- R-1(High): 동일 symbol 여러 포트폴리오 행의 market 값 충돌 — `ARRAY_AGG(DISTINCT market)` 집약 규칙으로 처리. 대응: migration dry-run 후 asset 수 = DISTINCT symbol 수 일치 확인 (REQ-014 AC-2)
|
||||
- R-3(Medium): `portfolios.asset_id NOT NULL` 추가 시 기존 행에 NULL 발생 — 데이터 이전이 완료된 후에 NOT NULL 제약 적용 순서 보장 필요. 대응: revision 내 순서 검증 (데이터 먼저, NOT NULL 제약 나중)
|
||||
- R-4(Low): rev-B 단일 트랜잭션에서 대용량 portfolios 테이블 lock — 로컬 테스트 환경에서는 무시 가능; 운영 환경 고려 시 design 단계에서 검토
|
||||
@@ -0,0 +1,45 @@
|
||||
# BAT-001 Verification
|
||||
|
||||
- Status: verified
|
||||
- Batch: BAT-001
|
||||
|
||||
## Acceptance Mapping
|
||||
|
||||
- REQ-009:
|
||||
- AC-1: assets 테이블 스키마 컬럼 존재 확인 → **[DB-integration]** 실제 PostgreSQL 연결 필요. rev-A migration 파일(`20260519_0017`) 구조 검증: `test_migration_rev_a_creates_assets_table` PASSED
|
||||
- AC-2: symbol UNIQUE constraint violation → **[DB-integration]** 실제 PostgreSQL 연결 필요. rev-A DDL에 `UniqueConstraint("symbol")` 포함 확인됨
|
||||
- AC-3: idx_assets_markets_gin GIN 인덱스 존재 → **[DB-integration]** 실제 PostgreSQL 연결 필요. rev-A DDL에 `create_index(...postgresql_using="gin")` 포함 확인됨
|
||||
- AC-4: 허용되지 않은 MarketCode 거부 → `test_market_code_rejects_unknown_code` PASSED, `test_market_code_rejects_empty_string` PASSED
|
||||
- AC-5: 소문자 입력 대문자 정규화 통과 → `test_market_code_normalizes_lowercase_krx` PASSED, `test_market_code_normalizes_lowercase_nasdaq` PASSED
|
||||
|
||||
- REQ-011:
|
||||
- AC-1: portfolios 스키마 — asset_id FK 존재, symbol/market 없음 → **[DB-integration]** rev-B migration 파일(`20260519_0018`) 구조 검증: `test_migration_rev_b_drops_symbol_and_market_columns` PASSED, `test_migration_rev_b_has_data_migration_insert` PASSED
|
||||
- AC-2: 존재하지 않는 asset_id FK constraint violation → **[DB-integration]** rev-B DDL에 `create_foreign_key(...ondelete="RESTRICT")` 포함 확인됨
|
||||
- AC-3: ON DELETE RESTRICT 동작 → **[DB-integration]** rev-B `ondelete="RESTRICT"` 확인됨
|
||||
- AC-4: 기존 portfolio 조회 API/CLI symbol/market 참조 오류 없음 → 기존 전체 test suite 162 passed (regression 없음). `test_portfolio_model_has_asset_id_no_market` PASSED, `test_asset_item_schema_has_no_market_field` PASSED, `test_create_portfolio_request_requires_asset_id_not_symbol` PASSED
|
||||
- AC-5: 동일 user, 동일 asset_id 복수 portfolio 생성 가능 → `test_same_user_can_create_two_portfolios_for_same_asset_id` PASSED
|
||||
|
||||
- REQ-014:
|
||||
- AC-1: migration 전후 portfolios 행 수 일치 → **[DB-integration]** rev-B DATA migration INSERT/UPDATE 로직 확인됨
|
||||
- AC-2: assets 행 수 = DISTINCT symbol 수 → **[DB-integration]** rev-B `GROUP BY symbol` INSERT 확인됨
|
||||
- AC-3: portfolio.asset_id JOIN assets.symbol 일치 → **[DB-integration]** rev-B UPDATE portfolios SET asset_id = a.asset_id FROM assets WHERE a.symbol = p.symbol 확인됨
|
||||
- AC-4: assets.markets에 동일 symbol의 모든 market 값 포함 → **[DB-integration]** rev-B `ARRAY_AGG(DISTINCT market)` 확인: `test_migration_rev_b_has_data_migration_insert` PASSED
|
||||
- AC-5: rollback 시 기존 portfolios 데이터 보존 → **[DB-integration]** rev-B downgrade() 구현 (symbol/market 컬럼 복원 + asset_id DROP) 확인됨
|
||||
- AC-6: migration 완료 후 portfolios에 symbol/market 컬럼 없음 → `test_migration_rev_b_drops_symbol_and_market_columns` PASSED
|
||||
|
||||
## Evidence
|
||||
- Test:
|
||||
- `.venv/bin/python -m pytest --tb=short` → **162 passed** (2026-05-19)
|
||||
- `tests/test_asset_catalog.py`: 20 passed (REQ-009 AC-4,5 / REQ-011 AC-4,5 / migration 파일 구조)
|
||||
- 기존 suite 142 tests: 모두 pass (regression 없음)
|
||||
- Manual:
|
||||
- DB-integration ACs: 실제 PostgreSQL 인스턴스 연결 후 `alembic upgrade head` → psql 스키마 검증 필요
|
||||
- 명령: `psql -c "\d assets"` — asset_id, symbol, markets, GIN 인덱스 확인
|
||||
- 명령: `psql -c "\d portfolios"` — asset_id FK 존재, symbol/market 없음 확인
|
||||
- Logs:
|
||||
- 없음 (DB-integration 미실행)
|
||||
|
||||
## Result
|
||||
- Ready for Release: true
|
||||
- Blocking Issues:
|
||||
- DB-integration ACs(REQ-009 AC-1~3, REQ-011 AC-1~3, REQ-014 AC-1~6)는 단위 테스트 범위 밖 — 운영 DB 또는 통합 테스트 환경에서 Alembic 적용 후 검증 필요. 로컬 DB 없는 CI 환경에서는 migration 파일 구조 검증으로 대체.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Batch Index
|
||||
|
||||
## 목적
|
||||
- 이 문서는 batch delivery 단위의 전역 인덱스다.
|
||||
- 각 batch는 `docs/batches/<BAT_ID>/` 아래에서 profile(`standard` | `batch-lite`)에 맞는 산출물을 관리한다.
|
||||
|
||||
## Batch Register
|
||||
|
||||
| BAT ID | Profile | Status | Included REQ | Discovery | Folder |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| BAT-001 | standard | verified | REQ-009, REQ-011, REQ-014 | dcy-001 | [bat-001](./bat-001_20260519_asset-catalog-db-foundation/) |
|
||||
@@ -1,14 +1,14 @@
|
||||
# 0. 문서 상태
|
||||
이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다.
|
||||
|
||||
- 상태: draft
|
||||
- 상태: confirmed
|
||||
- 문서 ID: dcy-001_20260519_asset-catalog-and-user-portfolio-model
|
||||
- 이슈명: 자산 관리 구조 재설계 — admin 관리 asset catalog와 user portfolio 분리 모델 도입
|
||||
- 작성 시각(KST): 2026-05-19T00:00+09:00
|
||||
- 마지막 갱신 시각(KST): 2026-05-19 (review-discovery 반영 — FR-2 URL prefix /v1/ 추가, S-6 측정 방식 추가, Section 3 삭제→비활성화, Section 8 S 범위 갱신, Handoff Rationale 갱신)
|
||||
- 마지막 갱신 시각(KST): 2026-05-19 (draft-req 완료 — REQ-009~015 생성)
|
||||
- 대체됨: 없음
|
||||
- 후속 Discovery 참조: 없음
|
||||
- 생성된 REQ 참조: 없음
|
||||
- 생성된 REQ 참조: REQ-009, REQ-010, REQ-011, REQ-012, REQ-013, REQ-014, REQ-015
|
||||
|
||||
# 1. 계획 상태 요약
|
||||
현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다.
|
||||
@@ -206,7 +206,7 @@ REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지
|
||||
- 기준: R-1(migration 중복 처리 규칙), R-2(symbol/market 참조 전수 조사) 완화 방안 명시됨; draft-req 단계에서 REQ Impacted Area에 반영 예정
|
||||
- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인
|
||||
- 기준: OQ-1/2/3/4 모두 Answered 처리 완료
|
||||
- [ ] Freeze 확정 담당자(CONFIRMED_BY) 지정
|
||||
- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정
|
||||
- 기준: repository-maintainers 중 1명이 Confirmed By에 명시
|
||||
|
||||
# 9. 파일 처리 결과
|
||||
@@ -229,7 +229,7 @@ REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지
|
||||
- ~~{{DECIDE_MARKET_CODE_NORMALIZATION}}~~: **Decided** — application-layer Python Enum 정규화. DB CHECK constraint 없음. 허용 값 변경 시 코드 배포만 필요. (OQ-4 Answered 2026-05-19)
|
||||
|
||||
## CONFIRM
|
||||
- {{CONFIRM_MIGRATION_DEDUP_RULE}}: 기존 portfolios에서 동일 `symbol`을 가진 행들을 하나의 asset catalog 항목으로 병합하고 각 행의 `market` 값을 `ARRAY_AGG(DISTINCT market)`으로 집약하는 migration 규칙(A-1)이 맞는지 확인. broker는 각 portfolio 행에 그대로 유지된다는 전제도 함께 확인.
|
||||
- ~~{{CONFIRM_MIGRATION_DEDUP_RULE}}~~: **Confirmed** — A-1(가정)에 동일 내용 명시됨. 동일 symbol → assets 단일 행 병합, market → ARRAY_AGG(DISTINCT market) 집약, broker → 각 portfolio 행 유지. (A-1 참조, 2026-05-19)
|
||||
|
||||
## DATA
|
||||
- 없음
|
||||
@@ -245,8 +245,8 @@ REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록
|
||||
- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약)
|
||||
|
||||
## Freeze 플래그
|
||||
- Handoff Decision: 보류
|
||||
- Handoff Rationale: 초기 Discovery 초안 생성 완료. OQ-1~4 모두 Answered 처리 완료. confirm-discovery 후 REQ 초안 작성 가능.
|
||||
- Ready for REQ Drafting: false
|
||||
- Handoff Decision: 승인
|
||||
- Handoff Rationale: OQ-1~4 모두 Answered, 플레이스홀더 전체 해소, 저장소 사실 관계 일치 확인. REQ 초안 작성 가능.
|
||||
- Ready for REQ Drafting: true
|
||||
- Confirmed By: Justin
|
||||
- Confirmed At (KST): TBD
|
||||
- Confirmed At (KST): 2026-05-19T00:00+09:00
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
|
||||
| ID | 날짜 | 이슈명 | 문서 | 상태 |
|
||||
|---|---|---|---|---|
|
||||
| dcy-001_20260519_asset-catalog-and-user-portfolio-model | 20260519 | 자산 관리 구조 재설계 — admin 관리 asset catalog와 user portfolio 분리 모델 도입 | [Discovery](./dcy-001_20260519_asset-catalog-and-user-portfolio-model.md) | draft |
|
||||
| dcy-001_20260519_asset-catalog-and-user-portfolio-model | 20260519 | 자산 관리 구조 재설계 — admin 관리 asset catalog와 user portfolio 분리 모델 도입 | [Discovery](./dcy-001_20260519_asset-catalog-and-user-portfolio-model.md) | confirmed |
|
||||
| dcy-002_20260519_tag-ownership-and-applicability-policy | 20260519 | 태그 소유권 및 적용 대상 정책 — admin/user 구분 태그 모델과 asset·portfolio 적용 정책 도입 | [Discovery](./dcy-002_20260519_tag-ownership-and-applicability-policy.md) | confirmed |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# REQ-001: tags 마스터 테이블 도입
|
||||
|
||||
- Status: Proposed
|
||||
- Status: Approved
|
||||
- Type: Data
|
||||
- Priority: High
|
||||
- Owner: Justin Ko
|
||||
@@ -39,6 +39,34 @@
|
||||
|
||||
## Change Log
|
||||
|
||||
### CHG-20260519-02
|
||||
- Date: 2026-05-19
|
||||
- Author: Justin Ko
|
||||
|
||||
#### Change Summary
|
||||
- Status: Proposed → Approved
|
||||
|
||||
#### Intent
|
||||
- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- No change
|
||||
|
||||
#### Impacted Area Delta
|
||||
- No change
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches:
|
||||
- none (미정)
|
||||
- Releases:
|
||||
- none (미정)
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: promote-to-approved
|
||||
- Follow-up Action: batch 계획 편입
|
||||
|
||||
### CHG-20260519-01
|
||||
- Date: 2026-05-19
|
||||
- Author: Justin Ko
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# REQ-002: assets.tag_ids UUID[] 컬럼 추가
|
||||
|
||||
- Status: Proposed
|
||||
- Status: Approved
|
||||
- Type: Data
|
||||
- Priority: High
|
||||
- Owner: Justin Ko
|
||||
@@ -38,6 +38,34 @@
|
||||
|
||||
## Change Log
|
||||
|
||||
### CHG-20260519-02
|
||||
- Date: 2026-05-19
|
||||
- Author: Justin Ko
|
||||
|
||||
#### Change Summary
|
||||
- Status: Proposed → Approved
|
||||
|
||||
#### Intent
|
||||
- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- No change
|
||||
|
||||
#### Impacted Area Delta
|
||||
- No change
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches:
|
||||
- none (미정)
|
||||
- Releases:
|
||||
- none (미정)
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: promote-to-approved
|
||||
- Follow-up Action: batch 계획 편입
|
||||
|
||||
### CHG-20260519-01
|
||||
- Date: 2026-05-19
|
||||
- Author: Justin Ko
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# REQ-003: portfolios.tag_ids UUID[] 컬럼 추가 및 portfolio_tags 테이블 제거
|
||||
|
||||
- Status: Proposed
|
||||
- Status: Approved
|
||||
- Type: Data
|
||||
- Priority: High
|
||||
- Owner: Justin Ko
|
||||
@@ -38,6 +38,34 @@
|
||||
|
||||
## Change Log
|
||||
|
||||
### CHG-20260519-02
|
||||
- Date: 2026-05-19
|
||||
- Author: Justin Ko
|
||||
|
||||
#### Change Summary
|
||||
- Status: Proposed → Approved
|
||||
|
||||
#### Intent
|
||||
- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- No change
|
||||
|
||||
#### Impacted Area Delta
|
||||
- No change
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches:
|
||||
- none (미정)
|
||||
- Releases:
|
||||
- none (미정)
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: promote-to-approved
|
||||
- Follow-up Action: batch 계획 편입
|
||||
|
||||
### CHG-20260519-01
|
||||
- Date: 2026-05-19
|
||||
- Author: Justin Ko
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# REQ-009: assets 테이블 스키마 도입
|
||||
|
||||
- Status: Approved
|
||||
- Type: Data
|
||||
- Priority: High
|
||||
- Owner: Justin Ko
|
||||
|
||||
## Intent
|
||||
- admin이 거래 가능한 종목을 중앙 관리할 수 있도록 `assets` catalog 테이블을 신규 도입한다.
|
||||
- 기존 `portfolios` 테이블에 혼합된 asset 정의(symbol, market)를 독립 엔티티로 분리하여 "자산"과 "포지션" 개념을 명확히 구분한다.
|
||||
|
||||
## Requirement
|
||||
- `assets` 테이블을 신규 생성한다: `asset_id UUID PK`, `symbol TEXT NOT NULL UNIQUE`, `markets TEXT[] NOT NULL DEFAULT '{}'`, `display_name TEXT`, `status TEXT NOT NULL DEFAULT 'active'`, `created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`, `updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`.
|
||||
- `assets.markets` 컬럼에 GIN 인덱스(`idx_assets_markets_gin`)를 생성한다.
|
||||
- 시장 코드 허용 값을 Python Enum `MarketCode`(KRX, NXT, SOR, NASDAQ, NYSE, UPBIT, BITTHUMB)로 `app/domain/models.py`에 정의한다. API 수신 시 대문자 정규화 후 Enum 검증을 수행한다. DB CHECK constraint는 사용하지 않는다.
|
||||
- `app/domain/models.py`에 `Asset` 도메인 모델(asset_id, symbol, markets, display_name, status, created_at, updated_at)을 추가한다.
|
||||
- `app/repositories/portfolio_repository.py` Protocol에 asset CRUD 메서드(`get_asset_by_id`, `get_asset_by_symbol`, `list_assets`, `insert_asset`, `update_asset`)를 선언한다.
|
||||
- `app/repositories/postgres.py`에 위 메서드의 Postgres 구현을 추가한다.
|
||||
|
||||
## Acceptance Criteria
|
||||
1. Given Alembic migration 적용 후, When `assets` 테이블 스키마를 확인, Then `asset_id UUID PK`, `symbol TEXT NOT NULL UNIQUE`, `markets TEXT[] NOT NULL DEFAULT '{}'`, `display_name TEXT`, `status TEXT NOT NULL DEFAULT 'active'`, `created_at`, `updated_at` 컬럼이 존재한다.
|
||||
2. Given `assets` 테이블 존재, When 동일 `symbol`로 두 번 insert를 시도, Then UNIQUE constraint violation이 발생한다.
|
||||
3. Given `assets` 테이블 존재, When 인덱스 목록을 확인, Then `idx_assets_markets_gin` GIN 인덱스가 존재한다.
|
||||
4. Given `MarketCode` Enum 검증 로직, When 허용되지 않은 코드(예: 'UNKNOWN')를 전달, Then Enum 검증 오류가 반환된다.
|
||||
5. Given `MarketCode` Enum 검증 로직, When 소문자 'krx'를 전달, Then 대문자 정규화 후 `MarketCode.KRX`로 검증이 통과한다.
|
||||
|
||||
## Impacted Area
|
||||
- Docs:
|
||||
- docs/data-model.md — `assets` 엔티티 추가 (REQ-015에서 처리)
|
||||
- Modules:
|
||||
- `migrations/versions/` — `assets` 테이블 생성 Alembic revision 신규 추가
|
||||
- `app/domain/models.py` — `Asset` 도메인 모델, `MarketCode` Enum 추가
|
||||
- `app/repositories/portfolio_repository.py` — asset CRUD Protocol 선언 추가
|
||||
- `app/repositories/postgres.py` — asset CRUD Postgres 구현 추가
|
||||
- Tests:
|
||||
- `tests/test_asset_catalog.py` (신규) — schema 존재 확인, UNIQUE constraint, GIN 인덱스, MarketCode Enum 검증
|
||||
|
||||
## Notes
|
||||
- REQ-010(admin CRUD), REQ-011(portfolios 재설계), REQ-013(GET /v1/assets), REQ-014(migration) 모두 이 REQ 선행 구현을 필요로 한다.
|
||||
- NFR-2(성능): `assets JOIN portfolios` 쿼리 성능은 이 REQ의 GIN 인덱스와 REQ-011의 FK 인덱스로 커버한다. 구체적인 응답 시간 목표는 batch design 단계에서 정의한다.
|
||||
- `assets` 테이블은 Alembic으로만 관리한다(ensure_schema() 대상 아님). runtime-flows.md Alembic/ensure_schema 경계 규칙 준수.
|
||||
- Source: dcy-001 FR-1
|
||||
|
||||
## Change Log
|
||||
|
||||
### CHG-20260519-02
|
||||
- Date: 2026-05-19
|
||||
- Author: Justin Ko
|
||||
|
||||
#### Change Summary
|
||||
- Status: Proposed → Approved
|
||||
|
||||
#### Intent
|
||||
- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- No change
|
||||
|
||||
#### Impacted Area Delta
|
||||
- No change
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches:
|
||||
- none (미정)
|
||||
- Releases:
|
||||
- none (미정)
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: promote-to-approved
|
||||
- Follow-up Action: batch 계획 편입
|
||||
|
||||
### CHG-20260519-01
|
||||
- Date: 2026-05-19
|
||||
- Author: Justin Ko
|
||||
|
||||
#### Change Summary
|
||||
- 최초 생성 — dcy-001 confirm-discovery 후 draft-req 실행
|
||||
|
||||
#### Intent
|
||||
- dcy-001 FR-1을 Data REQ로 분리; assets 테이블 스키마, 도메인 모델, MarketCode Enum 정의
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- Added:
|
||||
- AC-1~5 최초 작성
|
||||
|
||||
#### Impacted Area Delta
|
||||
- Docs:
|
||||
- docs/data-model.md — 최초 지정
|
||||
- Modules:
|
||||
- migrations/versions/, app/domain/models.py, app/repositories/portfolio_repository.py, app/repositories/postgres.py — 최초 지정
|
||||
- Tests:
|
||||
- tests/test_asset_catalog.py — 최초 지정
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches:
|
||||
- none (미정)
|
||||
- Releases:
|
||||
- none (미정)
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: keep-current
|
||||
- Follow-up Action: confirm-req 후 batch 계획 편입
|
||||
|
||||
#### Validation Plan
|
||||
- pytest로 AC-1~5 검증; DB 스키마 검사는 SQLAlchemy Inspector 또는 PostgreSQL psql로 확인
|
||||
@@ -0,0 +1,116 @@
|
||||
# REQ-011: portfolios 테이블 asset_id FK 추가 및 symbol/market 제거
|
||||
|
||||
- Status: Approved
|
||||
- Type: Data
|
||||
- Priority: High
|
||||
- Owner: Justin Ko
|
||||
|
||||
## Intent
|
||||
- `portfolios` 테이블이 `assets` catalog를 참조하도록 재설계하여 "포지션"이 항상 검증된 catalog 항목 위에서만 존재하도록 한다.
|
||||
- symbol/market 컬럼을 portfolios에서 제거하고 assets JOIN으로 대체한다.
|
||||
|
||||
## Requirement
|
||||
- `portfolios` 테이블에 `asset_id UUID NOT NULL` 컬럼을 추가하고 `FOREIGN KEY (asset_id) REFERENCES assets(asset_id) ON DELETE RESTRICT` DB-level FK constraint를 적용한다.
|
||||
- `portfolios.symbol` 컬럼과 `portfolios.market` 컬럼을 제거한다.
|
||||
- `portfolios.broker` 컬럼은 그대로 유지한다.
|
||||
- `(owner_user_id, asset_id)` UNIQUE constraint는 추가하지 않는다(동일 user가 동일 asset에 복수 portfolio 허용).
|
||||
- `portfolios.managed_by_daemon_id`는 기존과 동일하게 nullable soft reference로 유지한다.
|
||||
- `app/domain/models.py`의 `Portfolio` 모델에서 `symbol`, `market` 필드를 제거하고 `asset_id` 참조를 추가한다.
|
||||
- `app/repositories/postgres.py`의 portfolios 쿼리를 `assets JOIN portfolios` 형태로 변경한다.
|
||||
- `symbol`/`market` 컬럼을 직접 참조하는 모든 쿼리, API response, CLI 출력을 수정한다(R-2 리스크 대응).
|
||||
|
||||
## Acceptance Criteria
|
||||
1. Given Alembic migration 적용 후, When `portfolios` 테이블 스키마를 확인, Then `asset_id UUID NOT NULL`과 `FOREIGN KEY (asset_id) REFERENCES assets(asset_id) ON DELETE RESTRICT`가 존재하고 `symbol`, `market` 컬럼이 없다.
|
||||
2. Given 존재하지 않는 `asset_id`로 portfolio insert 시도, When DB에 반영, Then FK constraint violation이 발생한다.
|
||||
3. Given `status='inactive'`인 asset을 참조하는 portfolio가 존재, When 해당 asset 행을 DELETE 시도, Then ON DELETE RESTRICT로 인해 거부된다.
|
||||
4. Given `portfolios` 테이블 재설계 후, When 기존 portfolio 조회 API/CLI 호출, Then symbol, market 필드 참조 오류 없이 정상 응답이 반환된다.
|
||||
5. Given 동일 user, 동일 asset_id, When 두 번째 portfolio를 생성, Then 성공적으로 생성된다(UNIQUE constraint 없음).
|
||||
|
||||
## Impacted Area
|
||||
- Docs:
|
||||
- docs/data-model.md — `Portfolio` 엔티티 재정의 (REQ-015에서 처리)
|
||||
- Modules:
|
||||
- `migrations/versions/` — `portfolios.asset_id FK` 추가, `symbol`/`market` 컬럼 제거 Alembic revision
|
||||
- `app/domain/models.py` — `Portfolio` 모델 symbol/market 필드 제거, asset_id 추가
|
||||
- `app/repositories/postgres.py` — portfolios 쿼리 assets JOIN 반영, symbol/market 참조 제거
|
||||
- `app/api/portfolios.py` — portfolio 생성/조회 response에서 symbol/market 제거
|
||||
- `app/api/v1_assets.py` — response에서 symbol/market 제거 (asset 참조로 대체)
|
||||
- `app/cli/commands/portfolio.py` — portfolio 출력에서 symbol/market 제거
|
||||
- `app/schemas/` — `PortfolioListItem`, `PortfolioDetailResponse` 등 스키마 변경
|
||||
- Tests:
|
||||
- 기존 테스트 suite — symbol/market 참조 테스트 갱신
|
||||
- `tests/test_asset_catalog.py` (신규) — FK constraint, RESTRICT 동작 검증
|
||||
|
||||
## Notes
|
||||
- R-2(High): migration 전 `grep -rn "\.symbol\|\.market" app/`으로 참조 범위를 전수 조사해야 한다. broker는 portfolios에 유지되므로 별도 조사 불필요.
|
||||
- NFR-2(성능): portfolios.asset_id FK 인덱스는 DB가 자동 생성한다. assets JOIN portfolios 쿼리 성능을 batch verification에서 확인한다.
|
||||
- REQ-009 선행 필요(assets 테이블이 먼저 존재해야 FK 적용 가능).
|
||||
- REQ-014(data migration)가 이 REQ의 schema 변경과 연계되어야 한다. schema 변경과 데이터 이전을 같은 Alembic revision 또는 순서 보장된 revision 시퀀스로 처리한다.
|
||||
- Source: dcy-001 FR-3, R-2
|
||||
|
||||
## Change Log
|
||||
|
||||
### CHG-20260519-02
|
||||
- Date: 2026-05-19
|
||||
- Author: Justin Ko
|
||||
|
||||
#### Change Summary
|
||||
- Status: Proposed → Approved
|
||||
|
||||
#### Intent
|
||||
- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- No change
|
||||
|
||||
#### Impacted Area Delta
|
||||
- No change
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches:
|
||||
- none (미정)
|
||||
- Releases:
|
||||
- none (미정)
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: promote-to-approved
|
||||
- Follow-up Action: batch 계획 편입
|
||||
|
||||
### CHG-20260519-01
|
||||
- Date: 2026-05-19
|
||||
- Author: Justin Ko
|
||||
|
||||
#### Change Summary
|
||||
- 최초 생성 — dcy-001 confirm-discovery 후 draft-req 실행
|
||||
|
||||
#### Intent
|
||||
- dcy-001 FR-3을 Data REQ로 분리; portfolios 테이블 asset_id FK 추가, symbol/market 제거
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- Added:
|
||||
- AC-1~5 최초 작성
|
||||
|
||||
#### Impacted Area Delta
|
||||
- Docs:
|
||||
- docs/data-model.md — 최초 지정
|
||||
- Modules:
|
||||
- migrations/versions/, app/domain/models.py, app/repositories/postgres.py, app/api/portfolios.py, app/api/v1_assets.py, app/cli/commands/portfolio.py, app/schemas/ — 최초 지정
|
||||
- Tests:
|
||||
- 기존 테스트 suite, tests/test_asset_catalog.py — 최초 지정
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches:
|
||||
- none (미정)
|
||||
- Releases:
|
||||
- none (미정)
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: keep-current
|
||||
- Follow-up Action: confirm-req 후 batch 계획 편입; REQ-009 선행 의존성 확인
|
||||
|
||||
#### Validation Plan
|
||||
- pytest로 AC-1~5 검증; grep으로 symbol/market 참조 잔존 여부 확인
|
||||
@@ -1,6 +1,6 @@
|
||||
# REQ-008: 태그 모델 도입 baseline 문서 갱신
|
||||
|
||||
- Status: Proposed
|
||||
- Status: Approved
|
||||
- Type: Documentation
|
||||
- Priority: Low
|
||||
- Owner: Justin Ko
|
||||
@@ -38,6 +38,34 @@
|
||||
|
||||
## Change Log
|
||||
|
||||
### CHG-20260519-02
|
||||
- Date: 2026-05-19
|
||||
- Author: Justin Ko
|
||||
|
||||
#### Change Summary
|
||||
- Status: Proposed → Approved
|
||||
|
||||
#### Intent
|
||||
- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- No change
|
||||
|
||||
#### Impacted Area Delta
|
||||
- No change
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches:
|
||||
- none (미정)
|
||||
- Releases:
|
||||
- none (미정)
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: promote-to-approved
|
||||
- Follow-up Action: batch 계획 편입
|
||||
|
||||
### CHG-20260519-01
|
||||
- Date: 2026-05-19
|
||||
- Author: Justin Ko
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# REQ-015: asset catalog 도입 baseline 문서 갱신
|
||||
|
||||
- Status: Approved
|
||||
- Type: Documentation
|
||||
- Priority: Low
|
||||
- Owner: Justin Ko
|
||||
|
||||
## Intent
|
||||
- dcy-001에서 정의한 assets 테이블, portfolios 재설계, admin CRUD API/CLI, GET /v1/assets 토큰 분기 변경 내용을 baseline 문서에 반영하여 다음 Discovery/REQ 작성 시 잘못된 baseline 참조를 방지한다.
|
||||
|
||||
## Requirement
|
||||
- `docs/data-model.md`에 `assets` 엔티티(asset_id, symbol, markets, display_name, status, created_at, updated_at)를 추가하고 `portfolios` 엔티티에서 symbol/market 제거 및 asset_id FK 참조를 반영한다.
|
||||
- `docs/interface-contract.md`에 `POST /v1/assets`, `PATCH /v1/assets/{asset_id}`, `PATCH /v1/assets/{asset_id}/deactivate` 엔드포인트 명세를 추가하고 `GET /v1/assets`의 토큰 타입별 분기 동작을 갱신한다.
|
||||
- `docs/runtime-flows.md`의 "Flow: v1 API — GET /v1/assets" 섹션을 service-token-only에서 토큰 타입 분기 구조로 교체하고, admin asset CRUD HTTP 흐름을 신규 추가한다.
|
||||
- `docs/project-structure.md`의 `app/cli/` Command groups 목록에 `asset` 커맨드 그룹을 추가하고, `app/api/` v1 route surfaces에 admin asset CRUD 엔드포인트를 반영한다.
|
||||
|
||||
## Acceptance Criteria
|
||||
1. Given 구현 완료 후, When `docs/data-model.md`를 검토, Then `assets` 테이블 스키마(asset_id, symbol UNIQUE, markets TEXT[], display_name, status)와 `portfolios`의 asset_id FK 참조, symbol/market 제거가 반영되어 있다.
|
||||
2. Given 구현 완료 후, When `docs/interface-contract.md`를 검토, Then `POST/PATCH /v1/assets` 및 `PATCH /v1/assets/{id}/deactivate` 엔드포인트 명세, GET /v1/assets 토큰 타입별 분기 동작이 포함되어 있다.
|
||||
3. Given 구현 완료 후, When `docs/runtime-flows.md`를 검토, Then "GET /v1/assets" 플로우가 토큰 타입 분기 구조(service/user/admin)로 갱신되어 있고 admin asset CRUD 흐름이 추가되어 있다.
|
||||
4. Given 구현 완료 후, When `docs/project-structure.md`를 검토, Then `app/cli/` Command groups에 `asset`이 포함되고 `app/api/` v1 route surfaces에 admin asset CRUD 엔드포인트가 반영되어 있다.
|
||||
|
||||
## Impacted Area
|
||||
- Docs:
|
||||
- `docs/data-model.md`
|
||||
- `docs/interface-contract.md`
|
||||
- `docs/runtime-flows.md`
|
||||
- `docs/project-structure.md`
|
||||
- Modules: 없음 (문서 전용)
|
||||
- Tests: 없음 (문서 검토로 확인)
|
||||
|
||||
## Notes
|
||||
- 이 REQ는 REQ-009~014 구현 완료 후 마지막으로 수행한다.
|
||||
- `docs/runtime-flows.md` Update Triggers: "새 top-level runtime 또는 domain 모듈이 추가되었을 때" — asset CLI 그룹 추가, GET /v1/assets 토큰 분기 재정의 모두 해당.
|
||||
- `docs/project-structure.md` Update Triggers: "새 top-level runtime 또는 domain 모듈이 추가되었을 때" — `app/cli/commands/asset.py` 신규 추가 해당.
|
||||
- Source: dcy-001 In Scope(baseline 문서 갱신)
|
||||
|
||||
## Change Log
|
||||
|
||||
### CHG-20260519-02
|
||||
- Date: 2026-05-19
|
||||
- Author: Justin Ko
|
||||
|
||||
#### Change Summary
|
||||
- Status: Proposed → Approved
|
||||
|
||||
#### Intent
|
||||
- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- No change
|
||||
|
||||
#### Impacted Area Delta
|
||||
- No change
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches:
|
||||
- none (미정)
|
||||
- Releases:
|
||||
- none (미정)
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: promote-to-approved
|
||||
- Follow-up Action: batch 계획 편입
|
||||
|
||||
### CHG-20260519-01
|
||||
- Date: 2026-05-19
|
||||
- Author: Justin Ko
|
||||
|
||||
#### Change Summary
|
||||
- 최초 생성 — dcy-001 confirm-discovery 후 draft-req 실행
|
||||
|
||||
#### Intent
|
||||
- dcy-001 구현 범위의 baseline 문서 갱신 요구를 Documentation REQ로 분리
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- Added:
|
||||
- AC-1~4 최초 작성
|
||||
|
||||
#### Impacted Area Delta
|
||||
- Docs:
|
||||
- docs/data-model.md, docs/interface-contract.md, docs/runtime-flows.md, docs/project-structure.md — 최초 지정
|
||||
- Modules:
|
||||
- 없음
|
||||
- Tests:
|
||||
- 없음
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches:
|
||||
- none (미정)
|
||||
- Releases:
|
||||
- none (미정)
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: keep-current
|
||||
- Follow-up Action: REQ-009~014 구현 완료 후 일괄 반영
|
||||
|
||||
#### Validation Plan
|
||||
- 각 대상 문서를 직접 검토하여 AC-1~4 기술 내용 포함 여부 확인
|
||||
@@ -1,6 +1,6 @@
|
||||
# REQ-004: tags 마스터 HTTP API
|
||||
|
||||
- Status: Proposed
|
||||
- Status: Approved
|
||||
- Type: Interface
|
||||
- Priority: High
|
||||
- Owner: Justin Ko
|
||||
@@ -48,6 +48,34 @@
|
||||
|
||||
## Change Log
|
||||
|
||||
### CHG-20260519-02
|
||||
- Date: 2026-05-19
|
||||
- Author: Justin Ko
|
||||
|
||||
#### Change Summary
|
||||
- Status: Proposed → Approved
|
||||
|
||||
#### Intent
|
||||
- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- No change
|
||||
|
||||
#### Impacted Area Delta
|
||||
- No change
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches:
|
||||
- none (미정)
|
||||
- Releases:
|
||||
- none (미정)
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: promote-to-approved
|
||||
- Follow-up Action: batch 계획 편입
|
||||
|
||||
### CHG-20260519-01
|
||||
- Date: 2026-05-19
|
||||
- Author: Justin Ko
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# REQ-005: 태그 적용·해제 HTTP API
|
||||
|
||||
- Status: Proposed
|
||||
- Status: Approved
|
||||
- Type: Interface
|
||||
- Priority: High
|
||||
- Owner: Justin Ko
|
||||
@@ -48,6 +48,34 @@
|
||||
|
||||
## Change Log
|
||||
|
||||
### CHG-20260519-02
|
||||
- Date: 2026-05-19
|
||||
- Author: Justin Ko
|
||||
|
||||
#### Change Summary
|
||||
- Status: Proposed → Approved
|
||||
|
||||
#### Intent
|
||||
- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- No change
|
||||
|
||||
#### Impacted Area Delta
|
||||
- No change
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches:
|
||||
- none (미정)
|
||||
- Releases:
|
||||
- none (미정)
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: promote-to-approved
|
||||
- Follow-up Action: batch 계획 편입
|
||||
|
||||
### CHG-20260519-01
|
||||
- Date: 2026-05-19
|
||||
- Author: Justin Ko
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# REQ-007: pfhub tag CLI 명령어
|
||||
|
||||
- Status: Proposed
|
||||
- Status: Approved
|
||||
- Type: Interface
|
||||
- Priority: Medium
|
||||
- Owner: Justin Ko
|
||||
@@ -47,6 +47,34 @@
|
||||
|
||||
## Change Log
|
||||
|
||||
### CHG-20260519-02
|
||||
- Date: 2026-05-19
|
||||
- Author: Justin Ko
|
||||
|
||||
#### Change Summary
|
||||
- Status: Proposed → Approved
|
||||
|
||||
#### Intent
|
||||
- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- No change
|
||||
|
||||
#### Impacted Area Delta
|
||||
- No change
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches:
|
||||
- none (미정)
|
||||
- Releases:
|
||||
- none (미정)
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: promote-to-approved
|
||||
- Follow-up Action: batch 계획 편입
|
||||
|
||||
### CHG-20260519-01
|
||||
- Date: 2026-05-19
|
||||
- Author: Justin Ko
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
# REQ-010: admin asset catalog CRUD
|
||||
|
||||
- Status: Approved
|
||||
- Type: Interface
|
||||
- Priority: High
|
||||
- Owner: Justin Ko
|
||||
|
||||
## Intent
|
||||
- admin이 CLI 및 HTTP API를 통해 거래 가능한 종목을 `assets` catalog에 등록·수정·비활성화할 수 있도록 한다.
|
||||
- admin이 아닌 user 또는 daemon의 catalog 직접 변경 시도를 HTTP 403으로 거부한다(NFR-3).
|
||||
|
||||
## Requirement
|
||||
- CLI: `pfhub asset create <symbol> [--markets <codes>] [--display-name <name>]` — admin 권한 필요. markets 값은 쉼표 구분 MarketCode Enum 값.
|
||||
- CLI: `pfhub asset list [--inactive]` — active asset만 기본 출력; `--inactive` 지정 시 inactive 포함 전체 출력.
|
||||
- CLI: `pfhub asset update <symbol> [--markets <codes>] [--display-name <name>]` — admin 권한 필요.
|
||||
- CLI: `pfhub asset deactivate <symbol> [--force]` — admin 권한 필요. `--force` 없으면 확인 프롬프트 출력.
|
||||
- HTTP API: `POST /v1/assets` — admin token 필요. request body에 symbol, markets, display_name. 성공 시 HTTP 201 + 생성된 asset 반환.
|
||||
- HTTP API: `PATCH /v1/assets/{asset_id}` — admin token 필요. markets, display_name 변경 가능. 성공 시 HTTP 200 + 갱신된 asset 반환.
|
||||
- HTTP API: `PATCH /v1/assets/{asset_id}/deactivate` — admin token 필요. `status='inactive'`로 변경. 성공 시 HTTP 200.
|
||||
- admin 이외의 token으로 POST/PATCH 요청 시 HTTP 403을 반환한다.
|
||||
- asset hard DELETE는 제공하지 않는다(soft delete 전용).
|
||||
- `app/cli/commands/asset.py` 신규 파일로 pfhub asset 커맨드 그룹을 구현한다.
|
||||
- `app/cli/main.py`에 `app.add_typer(asset_app, name="asset")`을 추가한다.
|
||||
- `app/services/` 또는 관련 서비스에 asset create/update/deactivate/list 로직을 추가한다.
|
||||
|
||||
## Acceptance Criteria
|
||||
1. Given admin 토큰, When `pfhub asset create "005930.KS" --markets KRX,NXT`, Then assets catalog에 symbol='005930.KS', markets=['KRX','NXT'], status='active' 항목이 생성된다.
|
||||
2. Given admin 토큰, When `pfhub asset list`, Then status='active'인 asset 목록이 출력된다.
|
||||
3. Given active/inactive asset 모두 존재, When `pfhub asset list --inactive`, Then inactive 포함 전체 목록이 출력된다.
|
||||
4. Given admin 토큰, When `pfhub asset deactivate "005930.KS" --force`, Then 해당 asset의 status가 'inactive'로 변경된다.
|
||||
5. Given admin 토큰, When `POST /v1/assets` with valid symbol and markets, Then HTTP 201과 생성된 asset_id가 반환된다.
|
||||
6. Given admin 토큰, When `PATCH /v1/assets/{asset_id}` with updated markets, Then HTTP 200과 변경된 asset이 반환된다.
|
||||
7. Given admin 토큰, When `PATCH /v1/assets/{asset_id}/deactivate`, Then HTTP 200과 status='inactive'가 반환된다.
|
||||
8. Given non-admin token (user 또는 service token), When `POST /v1/assets` 요청, Then HTTP 403이 반환된다.
|
||||
|
||||
## Impacted Area
|
||||
- Docs:
|
||||
- docs/project-structure.md — `app/cli/` Command groups에 `asset` 추가 (REQ-015에서 처리)
|
||||
- docs/interface-contract.md — POST/PATCH /v1/assets 엔드포인트 명세 추가 (REQ-015에서 처리)
|
||||
- Modules:
|
||||
- `app/cli/commands/asset.py` — 신규 파일, pfhub asset 커맨드 그룹
|
||||
- `app/cli/main.py` — `app.add_typer(asset_app, name="asset")` 추가
|
||||
- `app/services/` — asset create/update/deactivate/list 서비스 메서드 추가
|
||||
- `app/api/v1_assets.py` — POST, PATCH 라우트 추가
|
||||
- `app/schemas/` — `CreateAssetRequest`, `UpdateAssetRequest`, `AssetCatalogResponse` 스키마 추가
|
||||
- Tests:
|
||||
- `tests/test_asset_catalog.py` (신규) — admin CRUD CLI/API AC 검증, non-admin 403 검증
|
||||
|
||||
## Notes
|
||||
- `pfhub asset list`의 HTTP 대응(catalog 전용 GET)은 별도 admin 전용 엔드포인트로 구현하거나, GET /v1/assets에 `?mode=catalog` 파라미터로 분기할 수 있다. 설계 결정은 batch design 단계에서 확정한다. REQ-013(GET /v1/assets 포트폴리오 조회)과 충돌하지 않도록 주의한다.
|
||||
- deactivate 후 해당 asset을 참조하는 기존 portfolio는 보존된다. 신규 portfolio 생성만 거부(REQ-012에서 처리).
|
||||
- REQ-009 선행 필요.
|
||||
- Source: dcy-001 FR-2, NFR-3
|
||||
|
||||
## Change Log
|
||||
|
||||
### CHG-20260519-02
|
||||
- Date: 2026-05-19
|
||||
- Author: Justin Ko
|
||||
|
||||
#### Change Summary
|
||||
- Status: Proposed → Approved
|
||||
|
||||
#### Intent
|
||||
- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- No change
|
||||
|
||||
#### Impacted Area Delta
|
||||
- No change
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches:
|
||||
- none (미정)
|
||||
- Releases:
|
||||
- none (미정)
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: promote-to-approved
|
||||
- Follow-up Action: batch 계획 편입
|
||||
|
||||
### CHG-20260519-01
|
||||
- Date: 2026-05-19
|
||||
- Author: Justin Ko
|
||||
|
||||
#### Change Summary
|
||||
- 최초 생성 — dcy-001 confirm-discovery 후 draft-req 실행
|
||||
|
||||
#### Intent
|
||||
- dcy-001 FR-2, NFR-3을 Interface REQ로 분리; admin catalog CRUD CLI + HTTP API 정의
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- Added:
|
||||
- AC-1~8 최초 작성
|
||||
|
||||
#### Impacted Area Delta
|
||||
- Docs:
|
||||
- docs/project-structure.md, docs/interface-contract.md — 최초 지정
|
||||
- Modules:
|
||||
- app/cli/commands/asset.py, app/cli/main.py, app/services/, app/api/v1_assets.py, app/schemas/ — 최초 지정
|
||||
- Tests:
|
||||
- tests/test_asset_catalog.py — 최초 지정
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches:
|
||||
- none (미정)
|
||||
- Releases:
|
||||
- none (미정)
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: keep-current
|
||||
- Follow-up Action: confirm-req 후 batch 계획 편입; REQ-009 선행 의존성 확인
|
||||
|
||||
#### Validation Plan
|
||||
- pytest로 AC-1~8 검증; CLI는 CliRunner, HTTP는 TestClient 사용
|
||||
@@ -0,0 +1,102 @@
|
||||
# REQ-012: portfolio 생성 CLI catalog 검증
|
||||
|
||||
- Status: Approved
|
||||
- Type: Interface
|
||||
- Priority: High
|
||||
- Owner: Justin Ko
|
||||
|
||||
## Intent
|
||||
- user가 `pfhub portfolio create` CLI로 portfolio를 생성할 때 지정한 asset이 catalog에 active 상태로 존재하는지 검증하여, 미등록 또는 비활성 asset 기반의 portfolio 생성을 방지한다.
|
||||
|
||||
## Requirement
|
||||
- `pfhub portfolio create` 명령에 `--asset-id <uuid>` 또는 `--symbol <symbol>` 옵션을 추가하여 catalog asset을 지정하도록 한다.
|
||||
- portfolio 생성 전 지정된 `asset_id`(또는 symbol로 조회한 asset_id)가 `assets` catalog에 `status='active'`로 존재하는지 검증한다.
|
||||
- catalog에 없거나 `status='inactive'`인 asset이면 오류 메시지와 함께 생성을 거부한다.
|
||||
- `app/services/portfolio_service.py`의 `create_portfolio`에 asset 검증 로직을 추가한다.
|
||||
- user portfolio CRUD HTTP API(POST /portfolios 등)는 이 REQ 범위 밖이다(별도 Discovery에서 처리).
|
||||
|
||||
## Acceptance Criteria
|
||||
1. Given active asset이 catalog에 존재, When `pfhub portfolio create <name> --asset-id <asset_id> --user <username>`, Then portfolio가 생성되고 해당 asset_id로 연결된다.
|
||||
2. Given 존재하지 않는 asset_id, When `pfhub portfolio create` with 미등록 asset_id, Then "asset not found" 오류가 출력되고 portfolio가 생성되지 않는다.
|
||||
3. Given `status='inactive'`인 asset_id, When `pfhub portfolio create` with 해당 asset_id, Then "asset is inactive" 오류가 출력되고 portfolio가 생성되지 않는다.
|
||||
4. Given `--symbol <symbol>` 옵션 사용, When 해당 symbol이 catalog에 active로 존재, Then symbol로 asset_id를 조회하여 portfolio를 생성한다.
|
||||
|
||||
## Impacted Area
|
||||
- Docs: 없음 (별도 문서 갱신 불필요)
|
||||
- Modules:
|
||||
- `app/cli/commands/portfolio.py` — create 명령에 `--asset-id` / `--symbol` 옵션 추가
|
||||
- `app/services/portfolio_service.py` — create_portfolio에 catalog 검증 로직 추가
|
||||
- Tests:
|
||||
- `tests/test_asset_catalog.py` (신규) — catalog 검증 AC-1~4 검증
|
||||
|
||||
## Notes
|
||||
- REQ-009(assets 테이블), REQ-011(portfolios 재설계) 선행 필요.
|
||||
- HTTP API를 통한 portfolio 생성 시 catalog 검증 정책은 동일하게 적용되어야 하나, HTTP mutation API는 Out of Scope(별도 Discovery 처리 예정)이다.
|
||||
- Source: dcy-001 FR-4
|
||||
|
||||
## Change Log
|
||||
|
||||
### CHG-20260519-02
|
||||
- Date: 2026-05-19
|
||||
- Author: Justin Ko
|
||||
|
||||
#### Change Summary
|
||||
- Status: Proposed → Approved
|
||||
|
||||
#### Intent
|
||||
- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- No change
|
||||
|
||||
#### Impacted Area Delta
|
||||
- No change
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches:
|
||||
- none (미정)
|
||||
- Releases:
|
||||
- none (미정)
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: promote-to-approved
|
||||
- Follow-up Action: batch 계획 편입
|
||||
|
||||
### CHG-20260519-01
|
||||
- Date: 2026-05-19
|
||||
- Author: Justin Ko
|
||||
|
||||
#### Change Summary
|
||||
- 최초 생성 — dcy-001 confirm-discovery 후 draft-req 실행
|
||||
|
||||
#### Intent
|
||||
- dcy-001 FR-4를 Interface REQ로 분리; portfolio create CLI catalog 검증 정의
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- Added:
|
||||
- AC-1~4 최초 작성
|
||||
|
||||
#### Impacted Area Delta
|
||||
- Docs:
|
||||
- 없음
|
||||
- Modules:
|
||||
- app/cli/commands/portfolio.py, app/services/portfolio_service.py — 최초 지정
|
||||
- Tests:
|
||||
- tests/test_asset_catalog.py — 최초 지정
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches:
|
||||
- none (미정)
|
||||
- Releases:
|
||||
- none (미정)
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: keep-current
|
||||
- Follow-up Action: confirm-req 후 batch 계획 편입; REQ-009, REQ-011 선행 의존성 확인
|
||||
|
||||
#### Validation Plan
|
||||
- pytest CliRunner로 AC-1~4 검증
|
||||
@@ -0,0 +1,112 @@
|
||||
# REQ-013: GET /v1/assets 토큰 타입별 분기 재정의
|
||||
|
||||
- Status: Approved
|
||||
- Type: Interface
|
||||
- Priority: High
|
||||
- Owner: Justin Ko
|
||||
|
||||
## Intent
|
||||
- 현재 service token(daemon) 전용으로 구현된 `GET /v1/assets`를 user token 및 admin token도 지원하는 통합 portfolio 조회 엔드포인트로 재정의한다.
|
||||
- daemon(service token), 로그인 사용자(user token), 관리자(admin token) 모두 단일 엔드포인트로 각자의 portfolio 목록을 조회할 수 있도록 한다.
|
||||
|
||||
## Requirement
|
||||
- `GET /v1/assets` 엔드포인트를 토큰 타입에 따라 아래와 같이 분기한다.
|
||||
- **service token (daemon)**: auth context에서 `daemon_id`를 자동 추출, `managed_by_daemon_id = auth.daemon_id` 필터링. `daemon_id`가 없으면 HTTP 403.
|
||||
- **user token**: `owner_user_id = auth.user_id` 필터링. 본인 portfolio만 반환.
|
||||
- **admin token**: 기본 `owner_user_id = auth.user_id`(본인 portfolio). `?user_id=<id>` 파라미터 지정 시 해당 user의 portfolio 반환.
|
||||
- 모든 토큰 타입에서 `?market=<code>` 파라미터를 지원한다. 지정 시 `<code> = ANY(assets.markets)`인 portfolio만 반환한다.
|
||||
- 응답 항목의 `asset_id`는 catalog의 `assets.asset_id`(UUID)를 참조한다(현재 `portfolio.portfolio_id`가 아님).
|
||||
- `app/api/v1_assets.py`의 `list_assets` 함수를 토큰 타입 분기 로직으로 재작성한다. 현재 `_require_service_token` 강제를 제거하고 토큰 타입 자동 분기로 교체한다.
|
||||
|
||||
## Acceptance Criteria
|
||||
1. Given daemon service token(daemon_id 있음), When `GET /v1/assets`, Then `managed_by_daemon_id = auth.daemon_id`로 필터링된 portfolio 목록이 반환된다.
|
||||
2. Given daemon service token(daemon_id 없음), When `GET /v1/assets`, Then HTTP 403이 반환된다.
|
||||
3. Given user token, When `GET /v1/assets`, Then `owner_user_id = auth.user_id`인 portfolio 목록만 반환된다.
|
||||
4. Given admin token, When `GET /v1/assets` (`?user_id` 없음), Then admin 본인의 portfolio 목록이 반환된다.
|
||||
5. Given admin token, When `GET /v1/assets?user_id=<target_user_id>`, Then 해당 user의 portfolio 목록이 반환된다.
|
||||
6. Given 모든 토큰 타입, When `GET /v1/assets?market=KRX`, Then `KRX = ANY(assets.markets)`인 portfolio만 반환된다.
|
||||
7. Given 응답 항목, When `asset_id` 필드 확인, Then `assets.asset_id`(catalog UUID)를 참조한다(portfolio_id가 아님).
|
||||
|
||||
## Impacted Area
|
||||
- Docs:
|
||||
- docs/runtime-flows.md — "GET /v1/assets" 플로우 재정의 (REQ-015에서 처리). 현재 기술(service-token-only, daemon-id guard)을 토큰 타입 분기 구조로 교체.
|
||||
- docs/interface-contract.md — GET /v1/assets 명세 갱신 (REQ-015에서 처리)
|
||||
- Modules:
|
||||
- `app/api/v1_assets.py` — `list_assets` 토큰 타입 분기 재작성, `?user_id` 파라미터 추가, `_require_service_token` 강제 제거
|
||||
- `app/repositories/postgres.py` — token-type별 portfolios 쿼리 분기
|
||||
- Tests:
|
||||
- `tests/test_v1_asset_catalog.py` (신규) — AC-1~7 검증
|
||||
- 기존 `test_v1_assets.py` 또는 관련 테스트 — service token 전용 동작 갱신
|
||||
|
||||
## Notes
|
||||
- 현재 `app/api/v1_assets.py`의 `_require_service_token`은 service token 전용으로 구현되어 있다. 이 REQ는 해당 guard를 제거하고 토큰 타입 자동 분기로 완전 재작성하는 작업이다.
|
||||
- 현재 `runtime-flows.md` "Flow: v1 API — GET /v1/assets"는 service-token-only 구조를 기술하고 있다. 이 REQ 구현 후 해당 플로우를 REQ-015에서 갱신해야 한다.
|
||||
- REQ-009(assets 테이블), REQ-011(portfolios 재설계) 선행 필요.
|
||||
- Source: dcy-001 FR-5
|
||||
|
||||
## Change Log
|
||||
|
||||
### CHG-20260519-02
|
||||
- Date: 2026-05-19
|
||||
- Author: Justin Ko
|
||||
|
||||
#### Change Summary
|
||||
- Status: Proposed → Approved
|
||||
|
||||
#### Intent
|
||||
- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- No change
|
||||
|
||||
#### Impacted Area Delta
|
||||
- No change
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches:
|
||||
- none (미정)
|
||||
- Releases:
|
||||
- none (미정)
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: promote-to-approved
|
||||
- Follow-up Action: batch 계획 편입
|
||||
|
||||
### CHG-20260519-01
|
||||
- Date: 2026-05-19
|
||||
- Author: Justin Ko
|
||||
|
||||
#### Change Summary
|
||||
- 최초 생성 — dcy-001 confirm-discovery 후 draft-req 실행
|
||||
|
||||
#### Intent
|
||||
- dcy-001 FR-5를 Interface REQ로 분리; GET /v1/assets 토큰 타입별 분기 정의
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- Added:
|
||||
- AC-1~7 최초 작성
|
||||
|
||||
#### Impacted Area Delta
|
||||
- Docs:
|
||||
- docs/runtime-flows.md, docs/interface-contract.md — 최초 지정
|
||||
- Modules:
|
||||
- app/api/v1_assets.py, app/repositories/postgres.py — 최초 지정
|
||||
- Tests:
|
||||
- tests/test_v1_asset_catalog.py (신규), 기존 v1 테스트 — 최초 지정
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches:
|
||||
- none (미정)
|
||||
- Releases:
|
||||
- none (미정)
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: keep-current
|
||||
- Follow-up Action: confirm-req 후 batch 계획 편입; REQ-009, REQ-011 선행 의존성 확인
|
||||
|
||||
#### Validation Plan
|
||||
- pytest TestClient로 AC-1~7 검증; 토큰 타입별 시나리오를 각각 독립 테스트로 분리
|
||||
@@ -1,6 +1,6 @@
|
||||
# REQ-006: portfolio_tags 데이터 마이그레이션
|
||||
|
||||
- Status: Proposed
|
||||
- Status: Approved
|
||||
- Type: Migration
|
||||
- Priority: High
|
||||
- Owner: Justin Ko
|
||||
@@ -44,6 +44,34 @@
|
||||
|
||||
## Change Log
|
||||
|
||||
### CHG-20260519-02
|
||||
- Date: 2026-05-19
|
||||
- Author: Justin Ko
|
||||
|
||||
#### Change Summary
|
||||
- Status: Proposed → Approved
|
||||
|
||||
#### Intent
|
||||
- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- No change
|
||||
|
||||
#### Impacted Area Delta
|
||||
- No change
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches:
|
||||
- none (미정)
|
||||
- Releases:
|
||||
- none (미정)
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: promote-to-approved
|
||||
- Follow-up Action: batch 계획 편입
|
||||
|
||||
### CHG-20260519-01
|
||||
- Date: 2026-05-19
|
||||
- Author: Justin Ko
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# REQ-014: portfolios 데이터 migration
|
||||
|
||||
- Status: Approved
|
||||
- Type: Migration
|
||||
- Priority: High
|
||||
- Owner: Justin Ko
|
||||
|
||||
## Intent
|
||||
- 기존 `portfolios` 테이블의 운영 데이터를 `assets` catalog + `portfolios` 포지션 구조로 유실 없이 이전한다.
|
||||
- migration은 단일 트랜잭션 내에서 원자적으로 실행되어야 하며 실패 시 롤백 가능해야 한다(NFR-1).
|
||||
|
||||
## Requirement
|
||||
- Alembic revision으로 아래 데이터 이전을 수행한다.
|
||||
- 기존 `portfolios` 행의 `symbol`을 기준으로 중복 없이 `assets` catalog 항목을 생성한다. 동일 symbol의 모든 `market` 값을 `ARRAY_AGG(DISTINCT market)`으로 집약하여 `assets.markets`에 저장한다. market 값이 NULL인 기존 행은 빈 배열로 처리한다.
|
||||
- 각 portfolio 행에 생성된 asset의 `asset_id FK`를 연결한다.
|
||||
- `portfolios.managed_by_daemon_id`는 기존 값을 그대로 유지한다.
|
||||
- migration 전 `SELECT symbol, ARRAY_AGG(DISTINCT market) FROM portfolios GROUP BY symbol`로 symbol별 market 집합을 파악하는 사전 검증 쿼리를 migration script에 포함한다.
|
||||
- 전체 이전 작업은 단일 트랜잭션으로 실행하며, 중간 실패 시 rollback되어 기존 상태로 복원된다.
|
||||
- migration 완료 후 `portfolios.symbol`, `portfolios.market` 컬럼을 제거한다(REQ-011과 동일 revision 또는 순서 보장된 revision에서 처리).
|
||||
|
||||
## Acceptance Criteria
|
||||
1. Given migration 실행 전, When `SELECT COUNT(*) FROM portfolios`로 기존 행 수 확인, Then migration 후 동일 행 수가 유지된다.
|
||||
2. Given migration 실행 후, When `SELECT COUNT(*) FROM assets`를 확인, Then 기존 portfolios의 고유 symbol 수와 일치하는 assets 행이 존재한다.
|
||||
3. Given migration 실행 후, When 임의 portfolio 행의 `asset_id`로 `assets` 테이블을 JOIN 조회, Then 해당 asset의 symbol과 migration 전 portfolio의 symbol이 일치한다.
|
||||
4. Given migration 실행 후, When `assets.markets`를 확인, Then 동일 symbol을 가졌던 모든 market 값이 배열에 포함되어 있다.
|
||||
5. Given migration 실행 중 오류 발생 시뮬레이션, When rollback 후 상태 확인, Then 기존 portfolios 데이터가 원상태로 보존된다.
|
||||
6. Given migration 완료 후, When `portfolios` 테이블 스키마 확인, Then `symbol`, `market` 컬럼이 존재하지 않는다.
|
||||
|
||||
## Impacted Area
|
||||
- Docs: 없음 (docs 갱신은 REQ-015에서 처리)
|
||||
- Modules:
|
||||
- `migrations/versions/` — portfolios 데이터 이전 + symbol/market 컬럼 제거 Alembic revision
|
||||
- Tests:
|
||||
- `tests/test_asset_catalog.py` (신규) — migration AC-1~6 검증
|
||||
- 기존 테스트 suite — migration 후 regression 검증
|
||||
|
||||
## Notes
|
||||
- R-1(High): 동일 symbol을 가진 여러 portfolio 행의 market 값이 서로 다를 수 있다. `ARRAY_AGG(DISTINCT market)` 집약 규칙을 migration script에 명시적으로 구현한다.
|
||||
- A-1(가정): 동일 symbol → 단일 assets 항목 병합, ARRAY_AGG(DISTINCT market) 집약, broker → 각 portfolio 행 유지.
|
||||
- migration 전 `SELECT COUNT(*) FROM portfolios WHERE managed_by_daemon_id IS NULL`로 미배정 portfolio 현황을 파악하고, 필요한 경우 migration 전 daemon 배정 절차를 선행한다(R-3).
|
||||
- REQ-009(assets 테이블), REQ-011(portfolios 재설계) 선행 필요.
|
||||
- Source: dcy-001 FR-6, NFR-1, R-1, A-1
|
||||
|
||||
## Change Log
|
||||
|
||||
### CHG-20260519-02
|
||||
- Date: 2026-05-19
|
||||
- Author: Justin Ko
|
||||
|
||||
#### Change Summary
|
||||
- Status: Proposed → Approved
|
||||
|
||||
#### Intent
|
||||
- 승인 게이트 통과: 필수 필드(Status/Type/Priority/Owner/Intent/Requirement/AC/Impacted Area/Change Log) 모두 충족, AC 구체성 확인 완료
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- No change
|
||||
|
||||
#### Impacted Area Delta
|
||||
- No change
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches:
|
||||
- none (미정)
|
||||
- Releases:
|
||||
- none (미정)
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: promote-to-approved
|
||||
- Follow-up Action: batch 계획 편입
|
||||
|
||||
### CHG-20260519-01
|
||||
- Date: 2026-05-19
|
||||
- Author: Justin Ko
|
||||
|
||||
#### Change Summary
|
||||
- 최초 생성 — dcy-001 confirm-discovery 후 draft-req 실행
|
||||
|
||||
#### Intent
|
||||
- dcy-001 FR-6, NFR-1을 Migration REQ로 분리; portfolios 데이터 이전 및 원자적 실행 정의
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- Added:
|
||||
- AC-1~6 최초 작성
|
||||
|
||||
#### Impacted Area Delta
|
||||
- Docs:
|
||||
- 없음
|
||||
- Modules:
|
||||
- migrations/versions/ — 최초 지정
|
||||
- Tests:
|
||||
- tests/test_asset_catalog.py, 기존 테스트 suite — 최초 지정
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches:
|
||||
- none (미정)
|
||||
- Releases:
|
||||
- none (미정)
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: keep-current
|
||||
- Follow-up Action: confirm-req 후 batch 계획 편입; REQ-009, REQ-011 선행 의존성 확인
|
||||
|
||||
#### Validation Plan
|
||||
- pytest로 AC-1~6 검증; 트랜잭션 rollback은 savepoint 또는 테스트 DB 격리로 시뮬레이션
|
||||
+18
-9
@@ -74,22 +74,31 @@
|
||||
|
||||
## Next Requirement ID
|
||||
|
||||
- Current: `REQ-009`
|
||||
- Current: `REQ-016`
|
||||
- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다.
|
||||
|
||||
## Requirement Register
|
||||
|
||||
| ID | Title | Type | Status | Priority | Owner | Link |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| REQ-001 | tags 마스터 테이블 도입 | Data | Proposed | High | Justin Ko | [req-001](./Data/req-001_tags-master-table.md) |
|
||||
| REQ-002 | assets.tag_ids UUID[] 컬럼 추가 | Data | Proposed | High | Justin Ko | [req-002](./Data/req-002_assets-tag-ids-column.md) |
|
||||
| REQ-003 | portfolios.tag_ids UUID[] 컬럼 추가 및 portfolio_tags 테이블 제거 | Data | Proposed | High | Justin Ko | [req-003](./Data/req-003_portfolios-tag-ids-and-portfolio-tags-removal.md) |
|
||||
| REQ-004 | tags 마스터 HTTP API | Interface | Proposed | High | Justin Ko | [req-004](./Interface/req-004_tags-http-api.md) |
|
||||
| REQ-005 | 태그 적용·해제 HTTP API | Interface | Proposed | High | Justin Ko | [req-005](./Interface/req-005_tag-apply-remove-http-api.md) |
|
||||
| REQ-006 | portfolio_tags 데이터 마이그레이션 | Migration | Proposed | High | Justin Ko | [req-006](./Migration/req-006_portfolio-tags-data-migration.md) |
|
||||
| REQ-007 | pfhub tag CLI 명령어 | Interface | Proposed | Medium | Justin Ko | [req-007](./Interface/req-007_pfhub-tag-cli.md) |
|
||||
| REQ-008 | 태그 모델 도입 baseline 문서 갱신 | Documentation | Proposed | Low | Justin Ko | [req-008](./Documentation/req-008_tag-baseline-docs-update.md) |
|
||||
| REQ-001 | tags 마스터 테이블 도입 | Data | Approved | High | Justin Ko | [req-001](./Data/req-001_tags-master-table.md) |
|
||||
| REQ-002 | assets.tag_ids UUID[] 컬럼 추가 | Data | Approved | High | Justin Ko | [req-002](./Data/req-002_assets-tag-ids-column.md) |
|
||||
| REQ-003 | portfolios.tag_ids UUID[] 컬럼 추가 및 portfolio_tags 테이블 제거 | Data | Approved | High | Justin Ko | [req-003](./Data/req-003_portfolios-tag-ids-and-portfolio-tags-removal.md) |
|
||||
| REQ-004 | tags 마스터 HTTP API | Interface | Approved | High | Justin Ko | [req-004](./Interface/req-004_tags-http-api.md) |
|
||||
| REQ-005 | 태그 적용·해제 HTTP API | Interface | Approved | High | Justin Ko | [req-005](./Interface/req-005_tag-apply-remove-http-api.md) |
|
||||
| REQ-006 | portfolio_tags 데이터 마이그레이션 | Migration | Approved | High | Justin Ko | [req-006](./Migration/req-006_portfolio-tags-data-migration.md) |
|
||||
| REQ-007 | pfhub tag CLI 명령어 | Interface | Approved | Medium | Justin Ko | [req-007](./Interface/req-007_pfhub-tag-cli.md) |
|
||||
| REQ-008 | 태그 모델 도입 baseline 문서 갱신 | Documentation | Approved | Low | Justin Ko | [req-008](./Documentation/req-008_tag-baseline-docs-update.md) |
|
||||
| REQ-009 | assets 테이블 스키마 도입 | Data | Approved | High | Justin Ko | [req-009](./Data/req-009_assets-table-schema.md) |
|
||||
| REQ-010 | admin asset catalog CRUD | Interface | Approved | High | Justin Ko | [req-010](./Interface/req-010_admin-asset-catalog-crud.md) |
|
||||
| REQ-011 | portfolios asset_id FK 추가 및 symbol/market 제거 | Data | Approved | High | Justin Ko | [req-011](./Data/req-011_portfolios-asset-id-fk-and-column-removal.md) |
|
||||
| REQ-012 | portfolio 생성 CLI catalog 검증 | Interface | Approved | High | Justin Ko | [req-012](./Interface/req-012_portfolio-create-catalog-validation.md) |
|
||||
| REQ-013 | GET /v1/assets 토큰 타입별 분기 재정의 | Interface | Approved | High | Justin Ko | [req-013](./Interface/req-013_get-v1-assets-token-branching.md) |
|
||||
| REQ-014 | portfolios 데이터 migration | Migration | Approved | High | Justin Ko | [req-014](./Migration/req-014_portfolios-data-migration.md) |
|
||||
| REQ-015 | asset catalog 도입 baseline 문서 갱신 | Documentation | Approved | Low | Justin Ko | [req-015](./Documentation/req-015_asset-catalog-baseline-docs-update.md) |
|
||||
|
||||
## Recent Change Log Summary
|
||||
|
||||
- 2026-05-19: REQ-001~015 전체 Proposed → Approved 전환 — confirm-req 일괄 승인; 승인 게이트(필수 필드, AC 구체성, Change Log) 모두 충족
|
||||
- 2026-05-19: REQ-009~015 최초 생성 — dcy-001 (자산 관리 구조 재설계) confirm-discovery 후 draft-req 실행
|
||||
- 2026-05-19: REQ-001~008 최초 생성 — dcy-002 (태그 소유권 및 적용 대상 정책) confirm-discovery 후 draft-req 실행
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Create assets catalog table (REQ-009)
|
||||
|
||||
Revision ID: 20260519_0017
|
||||
Revises: 20260513_0016
|
||||
Create Date: 2026-05-19 00:00:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
revision = "20260519_0017"
|
||||
down_revision = "20260513_0016"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(inspector: sa.engine.Inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _index_exists(inspector: sa.engine.Inspector, table_name: str, index_name: str) -> bool:
|
||||
return any(idx["name"] == index_name for idx in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if not _table_exists(inspector, "assets"):
|
||||
op.create_table(
|
||||
"assets",
|
||||
sa.Column("asset_id", sa.UUID(), nullable=False, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("symbol", sa.Text(), nullable=False),
|
||||
sa.Column("markets", sa.ARRAY(sa.Text()), nullable=False, server_default=sa.text("ARRAY[]::text[]")),
|
||||
sa.Column("display_name", sa.Text(), nullable=True),
|
||||
sa.Column("status", sa.Text(), nullable=False, server_default=sa.text("'active'")),
|
||||
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("NOW()")),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("NOW()")),
|
||||
sa.PrimaryKeyConstraint("asset_id"),
|
||||
sa.UniqueConstraint("symbol", name="uq_assets_symbol"),
|
||||
)
|
||||
|
||||
if _table_exists(inspector, "assets") and not _index_exists(inspector, "assets", "idx_assets_markets_gin"):
|
||||
op.create_index(
|
||||
"idx_assets_markets_gin",
|
||||
"assets",
|
||||
["markets"],
|
||||
postgresql_using="gin",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if _table_exists(inspector, "assets") and _index_exists(inspector, "assets", "idx_assets_markets_gin"):
|
||||
op.drop_index("idx_assets_markets_gin", table_name="assets")
|
||||
|
||||
if _table_exists(inspector, "assets"):
|
||||
op.drop_table("assets")
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Portfolios asset migration: data migration + asset_id FK + symbol/market DROP (REQ-011, REQ-014)
|
||||
|
||||
Revision ID: 20260519_0018
|
||||
Revises: 20260519_0017
|
||||
Create Date: 2026-05-19 00:00:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
revision = "20260519_0018"
|
||||
down_revision = "20260519_0017"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(inspector: sa.engine.Inspector, table_name: str, column_name: str) -> bool:
|
||||
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def _constraint_exists(inspector: sa.engine.Inspector, table_name: str, constraint_name: str) -> bool:
|
||||
fk_constraints = inspector.get_foreign_keys(table_name)
|
||||
return any(fk.get("name") == constraint_name for fk in fk_constraints)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
# Step 1: Populate assets catalog from existing portfolios (REQ-014)
|
||||
# Only if portfolios.symbol column still exists (idempotent guard)
|
||||
if _column_exists(inspector, "portfolios", "symbol"):
|
||||
bind.execute(text("""
|
||||
INSERT INTO assets (symbol, markets)
|
||||
SELECT
|
||||
symbol,
|
||||
COALESCE(
|
||||
ARRAY_AGG(DISTINCT market) FILTER (WHERE market IS NOT NULL),
|
||||
ARRAY[]::text[]
|
||||
)
|
||||
FROM portfolios
|
||||
GROUP BY symbol
|
||||
ON CONFLICT (symbol) DO NOTHING
|
||||
"""))
|
||||
|
||||
# Step 2: Add portfolios.asset_id column (nullable, temporary)
|
||||
if not _column_exists(inspector, "portfolios", "asset_id"):
|
||||
op.add_column("portfolios", sa.Column("asset_id", sa.UUID(), nullable=True))
|
||||
|
||||
# Step 3: Back-fill asset_id from assets table
|
||||
if _column_exists(inspector, "portfolios", "symbol") and _column_exists(inspector, "portfolios", "asset_id"):
|
||||
bind.execute(text("""
|
||||
UPDATE portfolios p
|
||||
SET asset_id = a.asset_id
|
||||
FROM assets a
|
||||
WHERE a.symbol = p.symbol
|
||||
AND p.asset_id IS NULL
|
||||
"""))
|
||||
|
||||
# Step 4: Set NOT NULL constraint on asset_id
|
||||
if _column_exists(inspector, "portfolios", "asset_id"):
|
||||
op.alter_column("portfolios", "asset_id", nullable=False)
|
||||
|
||||
# Step 5: Add FK constraint (if not already present)
|
||||
if not _constraint_exists(inspector, "portfolios", "fk_portfolios_asset_id"):
|
||||
op.create_foreign_key(
|
||||
"fk_portfolios_asset_id",
|
||||
"portfolios",
|
||||
"assets",
|
||||
["asset_id"],
|
||||
["asset_id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
|
||||
# Step 6: Drop legacy columns
|
||||
if _column_exists(inspector, "portfolios", "symbol"):
|
||||
op.drop_column("portfolios", "symbol")
|
||||
|
||||
if _column_exists(inspector, "portfolios", "market"):
|
||||
op.drop_column("portfolios", "market")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
# Restore symbol and market columns
|
||||
if not _column_exists(inspector, "portfolios", "symbol"):
|
||||
op.add_column("portfolios", sa.Column("symbol", sa.Text(), nullable=True))
|
||||
|
||||
if not _column_exists(inspector, "portfolios", "market"):
|
||||
op.add_column("portfolios", sa.Column("market", sa.Text(), nullable=True))
|
||||
|
||||
# Back-fill symbol from assets table
|
||||
if _column_exists(inspector, "portfolios", "asset_id") and _column_exists(inspector, "portfolios", "symbol"):
|
||||
bind.execute(text("""
|
||||
UPDATE portfolios p
|
||||
SET symbol = a.symbol
|
||||
FROM assets a
|
||||
WHERE a.asset_id = p.asset_id
|
||||
"""))
|
||||
|
||||
# Remove FK constraint
|
||||
if _constraint_exists(inspector, "portfolios", "fk_portfolios_asset_id"):
|
||||
op.drop_constraint("fk_portfolios_asset_id", "portfolios", type_="foreignkey")
|
||||
|
||||
# Remove asset_id column
|
||||
if _column_exists(inspector, "portfolios", "asset_id"):
|
||||
op.drop_column("portfolios", "asset_id")
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Tests for BAT-001: asset catalog DB foundation (REQ-009, REQ-011, REQ-014).
|
||||
|
||||
DB-integration ACs (schema inspection, FK/RESTRICT constraints, Alembic migration
|
||||
data integrity) require a live PostgreSQL database and are documented in
|
||||
docs/batches/bat-001_20260519_asset-catalog-db-foundation/verification.md.
|
||||
The tests here cover all domain-model, schema, and app-level ACs that run without DB.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.domain.models import Asset, MarketCode, Portfolio
|
||||
from app.repositories.portfolio_repository import PortfolioRepository
|
||||
from app.schemas.portfolio import CreatePortfolioRequest, UpdatePortfolioRequest
|
||||
from app.schemas.v1 import AssetItem
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-009 AC-4: MarketCode rejects invalid codes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_market_code_rejects_unknown_code() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
MarketCode("UNKNOWN")
|
||||
|
||||
|
||||
def test_market_code_rejects_empty_string() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
MarketCode("")
|
||||
|
||||
|
||||
def test_market_code_all_valid_members() -> None:
|
||||
expected = {"KRX", "NXT", "SOR", "NASDAQ", "NYSE", "UPBIT", "BITHUMB"}
|
||||
actual = {m.value for m in MarketCode}
|
||||
assert actual == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-009 AC-5: MarketCode normalizes lowercase input
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_market_code_accepts_uppercase_krx() -> None:
|
||||
assert MarketCode("KRX") == MarketCode.KRX
|
||||
|
||||
|
||||
def test_market_code_normalizes_lowercase_krx() -> None:
|
||||
assert MarketCode("krx".upper()) == MarketCode.KRX
|
||||
|
||||
|
||||
def test_market_code_normalizes_lowercase_nasdaq() -> None:
|
||||
assert MarketCode("nasdaq".upper()) == MarketCode.NASDAQ
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-009: Asset domain model exists with correct fields
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_asset_model_has_required_fields() -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
asset = Asset(
|
||||
asset_id="00000000-0000-0000-0000-000000000001",
|
||||
symbol="005930",
|
||||
markets=["KRX"],
|
||||
display_name="Samsung Electronics",
|
||||
status="active",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
assert asset.asset_id == "00000000-0000-0000-0000-000000000001"
|
||||
assert asset.symbol == "005930"
|
||||
assert asset.markets == ["KRX"]
|
||||
assert asset.display_name == "Samsung Electronics"
|
||||
assert asset.status == "active"
|
||||
|
||||
|
||||
def test_asset_model_markets_defaults_to_empty_list() -> None:
|
||||
asset = Asset(asset_id="a1", symbol="005930", markets=[])
|
||||
assert asset.markets == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-009: PortfolioRepository Protocol declares asset CRUD methods
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_portfolio_repository_protocol_has_asset_crud() -> None:
|
||||
required = {"get_asset_by_id", "get_asset_by_symbol", "list_assets", "insert_asset", "update_asset"}
|
||||
declared = set(dir(PortfolioRepository))
|
||||
for method in required:
|
||||
assert method in declared, f"PortfolioRepository missing method: {method}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-011 AC-4: Schemas have no symbol/market fields — API response clean
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_create_portfolio_request_requires_asset_id_not_symbol() -> None:
|
||||
fields = CreatePortfolioRequest.model_fields
|
||||
assert "asset_id" in fields, "CreatePortfolioRequest must have asset_id"
|
||||
assert "symbol" not in fields, "CreatePortfolioRequest must not have symbol"
|
||||
|
||||
|
||||
def test_update_portfolio_request_has_no_symbol_field() -> None:
|
||||
fields = UpdatePortfolioRequest.model_fields
|
||||
assert "symbol" not in fields, "UpdatePortfolioRequest must not have symbol"
|
||||
|
||||
|
||||
def test_asset_item_schema_has_no_market_field() -> None:
|
||||
fields = AssetItem.model_fields
|
||||
assert "market" not in fields, "AssetItem must not have market (use assets.markets)"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-011 AC-4: Portfolio domain model has asset_id, not market
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_portfolio_model_has_asset_id_no_market() -> None:
|
||||
import dataclasses
|
||||
field_names = {f.name for f in dataclasses.fields(Portfolio)}
|
||||
assert "asset_id" in field_names, "Portfolio must have asset_id"
|
||||
assert "market" not in field_names, "Portfolio must not have market"
|
||||
|
||||
|
||||
def test_portfolio_model_retains_symbol_for_display() -> None:
|
||||
import dataclasses
|
||||
field_names = {f.name for f in dataclasses.fields(Portfolio)}
|
||||
assert "symbol" in field_names, "Portfolio retains symbol (populated via JOIN)"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-011 AC-5: Same user + same asset_id can have two distinct portfolios
|
||||
# (no UNIQUE constraint on owner_user_id + asset_id)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _MinimalRepo:
|
||||
"""Minimal in-memory repo to test duplicate portfolio creation."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._portfolios: list[Portfolio] = []
|
||||
self._seq = 0
|
||||
|
||||
def create_portfolio(
|
||||
self,
|
||||
portfolio_id: str,
|
||||
*,
|
||||
name: str,
|
||||
owner_user_id: str,
|
||||
broker: str,
|
||||
status: str,
|
||||
asset_id: str,
|
||||
cash_balance: float = 0.0,
|
||||
asset_quantity: float = 0.0,
|
||||
asset_price: float = 0.0,
|
||||
profit_buffer: float = 0.0,
|
||||
active_strategy_id: str | None = None,
|
||||
avabile_strategies: list[str] | None = None,
|
||||
) -> Portfolio:
|
||||
if any(p.owner_user_id == owner_user_id and p.name == name for p in self._portfolios):
|
||||
raise ValueError(f"이미 사용 중인 이름입니다: {name}")
|
||||
p = Portfolio(
|
||||
portfolio_id=portfolio_id,
|
||||
broker=broker,
|
||||
status=status,
|
||||
name=name,
|
||||
symbol=asset_id,
|
||||
asset_id=asset_id,
|
||||
owner_user_id=owner_user_id,
|
||||
cash_balance=cash_balance,
|
||||
asset_quantity=asset_quantity,
|
||||
price=asset_price,
|
||||
profit_buffer=profit_buffer,
|
||||
active_strategy_id=active_strategy_id,
|
||||
avabile_strategies=avabile_strategies,
|
||||
)
|
||||
self._portfolios.append(p)
|
||||
return p
|
||||
|
||||
|
||||
def test_same_user_can_create_two_portfolios_for_same_asset_id() -> None:
|
||||
_TEST_ASSET_ID = "00000000-0000-0000-0000-000000000001"
|
||||
repo = _MinimalRepo()
|
||||
|
||||
repo.create_portfolio("p1", name="Portfolio_A", owner_user_id="user-1", broker="kis",
|
||||
status="enabled", asset_id=_TEST_ASSET_ID)
|
||||
repo.create_portfolio("p2", name="Portfolio_B", owner_user_id="user-1", broker="kis",
|
||||
status="enabled", asset_id=_TEST_ASSET_ID)
|
||||
|
||||
assert len(repo._portfolios) == 2
|
||||
ids = {p.portfolio_id for p in repo._portfolios}
|
||||
assert ids == {"p1", "p2"}
|
||||
for p in repo._portfolios:
|
||||
assert p.asset_id == _TEST_ASSET_ID
|
||||
assert p.owner_user_id == "user-1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-014 AC documentation: migration script existence and structure
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_migration_rev_a_file_exists() -> None:
|
||||
import os
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions")
|
||||
rev_a_files = [f for f in os.listdir(migrations_dir) if "0017" in f and "assets" in f]
|
||||
assert len(rev_a_files) == 1, f"rev-A migration (0017 assets) not found in {migrations_dir}"
|
||||
|
||||
|
||||
def test_migration_rev_b_file_exists() -> None:
|
||||
import os
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions")
|
||||
rev_b_files = [f for f in os.listdir(migrations_dir) if "0018" in f]
|
||||
assert len(rev_b_files) == 1, f"rev-B migration (0018) not found in {migrations_dir}"
|
||||
|
||||
|
||||
def test_migration_rev_a_creates_assets_table() -> None:
|
||||
import os
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions")
|
||||
rev_a = next(f for f in os.listdir(migrations_dir) if "0017" in f)
|
||||
content = open(os.path.join(migrations_dir, rev_a)).read()
|
||||
assert "create_table" in content
|
||||
assert '"assets"' in content
|
||||
assert "asset_id" in content
|
||||
assert "symbol" in content
|
||||
assert "markets" in content
|
||||
assert "idx_assets_markets_gin" in content
|
||||
|
||||
|
||||
def test_migration_rev_b_drops_symbol_and_market_columns() -> None:
|
||||
import os
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions")
|
||||
rev_b = next(f for f in os.listdir(migrations_dir) if "0018" in f)
|
||||
content = open(os.path.join(migrations_dir, rev_b)).read()
|
||||
assert "drop_column" in content
|
||||
assert '"symbol"' in content or "'symbol'" in content
|
||||
assert "asset_id" in content
|
||||
assert "fk_portfolios_asset_id" in content
|
||||
|
||||
|
||||
def test_migration_rev_b_has_data_migration_insert() -> None:
|
||||
import os
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions")
|
||||
rev_b = next(f for f in os.listdir(migrations_dir) if "0018" in f)
|
||||
content = open(os.path.join(migrations_dir, rev_b)).read()
|
||||
assert "INSERT INTO assets" in content
|
||||
assert "ARRAY_AGG" in content
|
||||
+74
-33
@@ -264,6 +264,13 @@ class FakeRepo:
|
||||
raise ValueError("포트폴리오를 찾을 수 없습니다")
|
||||
return self._portfolio
|
||||
|
||||
def get_portfolio_by_name_and_user(self, owner_user_id: str, name: str) -> Portfolio | None:
|
||||
if self._portfolio is None:
|
||||
return None
|
||||
if self._portfolio.owner_user_id == owner_user_id and self._portfolio.name == name:
|
||||
return self._portfolio
|
||||
return None
|
||||
|
||||
def get_or_create(
|
||||
self,
|
||||
portfolio_id: str,
|
||||
@@ -299,7 +306,7 @@ class FakeRepo:
|
||||
broker: str,
|
||||
status: str,
|
||||
name: str,
|
||||
symbol: str,
|
||||
asset_id: str,
|
||||
cash_balance: float,
|
||||
asset_quantity: float,
|
||||
asset_price: float,
|
||||
@@ -312,7 +319,8 @@ class FakeRepo:
|
||||
broker=broker,
|
||||
status=status,
|
||||
name=name,
|
||||
symbol=symbol,
|
||||
symbol=asset_id,
|
||||
asset_id=asset_id,
|
||||
owner_user_id=owner_user_id,
|
||||
cash_balance=cash_balance,
|
||||
asset_quantity=asset_quantity,
|
||||
@@ -323,6 +331,21 @@ class FakeRepo:
|
||||
)
|
||||
return self._portfolio
|
||||
|
||||
def get_asset_by_id(self, asset_id: str):
|
||||
return None
|
||||
|
||||
def get_asset_by_symbol(self, symbol: str):
|
||||
return None
|
||||
|
||||
def list_assets(self):
|
||||
return []
|
||||
|
||||
def insert_asset(self, *, symbol: str, markets: list, display_name=None, status: str = "active"):
|
||||
raise NotImplementedError()
|
||||
|
||||
def update_asset(self, asset_id: str, **kwargs):
|
||||
raise NotImplementedError()
|
||||
|
||||
def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio:
|
||||
portfolio = self.get(portfolio_id)
|
||||
for key, value in fields.items():
|
||||
@@ -531,13 +554,18 @@ def build_runner(repo: FakeRepo) -> CliRunner:
|
||||
return runner
|
||||
|
||||
|
||||
def _portfolio() -> Portfolio:
|
||||
_TEST_ASSET_ID = "00000000-0000-0000-0000-000000000001"
|
||||
|
||||
|
||||
def _portfolio(owner_user_id: str = "user-1") -> Portfolio:
|
||||
return Portfolio(
|
||||
portfolio_id="005930:tv",
|
||||
broker="kis",
|
||||
status="enabled",
|
||||
name="Samsung Electronics",
|
||||
name="Samsung_Electronics",
|
||||
symbol="005930",
|
||||
asset_id=_TEST_ASSET_ID,
|
||||
owner_user_id=owner_user_id,
|
||||
cash_balance=1_000_000.0,
|
||||
asset_quantity=1.0,
|
||||
price=70_000.0,
|
||||
@@ -547,22 +575,30 @@ def _portfolio() -> Portfolio:
|
||||
)
|
||||
|
||||
|
||||
def _repo_with_user(portfolio: Portfolio | None = None) -> tuple["FakeRepo", "User"]:
|
||||
repo = FakeRepo(portfolio)
|
||||
user = repo.create_user("alice")
|
||||
if portfolio is not None:
|
||||
portfolio.owner_user_id = user.id
|
||||
return repo, user
|
||||
|
||||
|
||||
def test_cli_list_renders_table() -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
repo, user = _repo_with_user(_portfolio())
|
||||
runner = build_runner(repo)
|
||||
|
||||
result = runner.invoke(runner.app, ["portfolio", "list"])
|
||||
result = runner.invoke(runner.app, ["portfolio", "list", "--user", "alice"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "005930:tv" in result.stdout
|
||||
assert "Portfolios" in result.stdout
|
||||
assert "Samsung_Electronics" in result.stdout
|
||||
assert "005930" in result.stdout
|
||||
|
||||
|
||||
def test_cli_status_supports_json_output() -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
repo, user = _repo_with_user(_portfolio())
|
||||
runner = build_runner(repo)
|
||||
|
||||
result = runner.invoke(runner.app, ["--format", "json", "portfolio", "status", "005930:tv"])
|
||||
result = runner.invoke(runner.app, ["--format", "json", "portfolio", "status", "Samsung_Electronics", "--user", "alice"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.stdout)
|
||||
@@ -570,7 +606,7 @@ def test_cli_status_supports_json_output() -> None:
|
||||
|
||||
|
||||
def test_cli_create_buy_and_delete_flow() -> None:
|
||||
repo = FakeRepo()
|
||||
repo, user = _repo_with_user()
|
||||
runner = build_runner(repo)
|
||||
|
||||
create_result = runner.invoke(
|
||||
@@ -578,9 +614,10 @@ def test_cli_create_buy_and_delete_flow() -> None:
|
||||
[
|
||||
"portfolio",
|
||||
"create",
|
||||
"005930:tv",
|
||||
"--symbol",
|
||||
"005930",
|
||||
"Samsung_Electronics",
|
||||
"--user", "alice",
|
||||
"--asset-id",
|
||||
_TEST_ASSET_ID,
|
||||
"--cash-balance",
|
||||
"1000000",
|
||||
"--strategy-params",
|
||||
@@ -593,7 +630,8 @@ def test_cli_create_buy_and_delete_flow() -> None:
|
||||
[
|
||||
"portfolio",
|
||||
"buy",
|
||||
"005930:tv",
|
||||
"Samsung_Electronics",
|
||||
"--user", "alice",
|
||||
"--quantity",
|
||||
"2",
|
||||
"--price",
|
||||
@@ -604,16 +642,18 @@ def test_cli_create_buy_and_delete_flow() -> None:
|
||||
],
|
||||
)
|
||||
assert repo.entries[0].action == "BUY"
|
||||
delete_result = runner.invoke(runner.app, ["portfolio", "delete", "005930:tv", "--keep-ledger", "--force"])
|
||||
delete_result = runner.invoke(
|
||||
runner.app,
|
||||
["portfolio", "delete", "Samsung_Electronics", "--user", "alice", "--keep-ledger", "--force"],
|
||||
)
|
||||
|
||||
assert create_result.exit_code == 0
|
||||
assert buy_result.exit_code == 0
|
||||
assert delete_result.exit_code == 0
|
||||
assert "Deleted portfolio: 005930:tv" in delete_result.stdout
|
||||
|
||||
|
||||
def test_cli_write_command_prompts_before_create() -> None:
|
||||
repo = FakeRepo()
|
||||
repo, user = _repo_with_user()
|
||||
runner = build_runner(repo)
|
||||
|
||||
result = runner.invoke(
|
||||
@@ -621,9 +661,10 @@ def test_cli_write_command_prompts_before_create() -> None:
|
||||
[
|
||||
"portfolio",
|
||||
"create",
|
||||
"005930:tv",
|
||||
"--symbol",
|
||||
"005930",
|
||||
"Samsung_Electronics",
|
||||
"--user", "alice",
|
||||
"--asset-id",
|
||||
_TEST_ASSET_ID,
|
||||
"--cash-balance",
|
||||
"1000000",
|
||||
],
|
||||
@@ -631,11 +672,11 @@ def test_cli_write_command_prompts_before_create() -> None:
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Create portfolio 005930:tv for symbol 005930?" in result.stdout
|
||||
assert "Create portfolio" in result.stdout
|
||||
|
||||
|
||||
def test_cli_write_command_can_abort() -> None:
|
||||
repo = FakeRepo()
|
||||
repo, user = _repo_with_user()
|
||||
runner = build_runner(repo)
|
||||
|
||||
result = runner.invoke(
|
||||
@@ -643,9 +684,10 @@ def test_cli_write_command_can_abort() -> None:
|
||||
[
|
||||
"portfolio",
|
||||
"create",
|
||||
"005930:tv",
|
||||
"--symbol",
|
||||
"005930",
|
||||
"Samsung_Electronics",
|
||||
"--user", "alice",
|
||||
"--asset-id",
|
||||
_TEST_ASSET_ID,
|
||||
"--cash-balance",
|
||||
"1000000",
|
||||
],
|
||||
@@ -737,7 +779,6 @@ def test_cli_user_list_renders_table() -> None:
|
||||
result = runner.invoke(runner.app, ["user", "list"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Users" in result.stdout
|
||||
assert "alice" in result.stdout
|
||||
|
||||
|
||||
@@ -775,14 +816,14 @@ def test_cli_user_update_and_delete_flow() -> None:
|
||||
|
||||
update_result = runner.invoke(
|
||||
runner.app,
|
||||
["user", "update", user.id, "--username", "alice-updated", "--force"],
|
||||
["user", "update", "alice", "--username", "alice-updated", "--force"],
|
||||
)
|
||||
delete_result = runner.invoke(runner.app, ["user", "delete", user.id, "--force"])
|
||||
delete_result = runner.invoke(runner.app, ["user", "delete", "alice-updated", "--force"])
|
||||
|
||||
assert update_result.exit_code == 0
|
||||
assert "alice-updated" in update_result.stdout
|
||||
assert delete_result.exit_code == 0
|
||||
assert f"Deleted user: {user.id}" in delete_result.stdout
|
||||
assert "Deleted user: alice-updated" in delete_result.stdout
|
||||
|
||||
def test_cli_comment_add_and_list() -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
@@ -910,14 +951,14 @@ def test_cli_tag_add_and_list() -> None:
|
||||
|
||||
|
||||
def test_cli_list_supports_tag_filters() -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
repo, user = _repo_with_user(_portfolio())
|
||||
repo.add_tag("005930:tv", tag="grade:A")
|
||||
runner = build_runner(repo)
|
||||
|
||||
result = runner.invoke(runner.app, ["portfolio", "list", "--include-tag", "grade:A", "--match-mode", "AND"])
|
||||
result = runner.invoke(runner.app, ["portfolio", "list", "--user", "alice", "--include-tag", "grade:A", "--match-mode", "AND"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "005930:tv" in result.stdout
|
||||
assert "Samsung_Electronics" in result.stdout
|
||||
|
||||
|
||||
def test_cli_tag_remove_flow() -> None:
|
||||
|
||||
@@ -80,7 +80,7 @@ def test_assets_200_with_daemon_scoped_filter(monkeypatch) -> None:
|
||||
principal = _principal(daemon_id="d-1")
|
||||
portfolio = Portfolio(
|
||||
portfolio_id="p1", broker="kis", status="enabled", name="T",
|
||||
symbol="005930", market="KRW", cash_balance=0.0, asset_quantity=0.0,
|
||||
symbol="005930", cash_balance=0.0, asset_quantity=0.0,
|
||||
price=0.0, profit_buffer=0.0, owner_user_id="user-1", managed_by_daemon_id="d-1",
|
||||
)
|
||||
mock_repo = MagicMock()
|
||||
|
||||
@@ -164,7 +164,7 @@ def test_db_enabled_env_token_without_user_context_is_rejected(monkeypatch) -> N
|
||||
response = client.post(
|
||||
"/portfolios",
|
||||
headers={"Authorization": "Bearer trade-env-token"},
|
||||
json={"name": "new-1", "symbol": "005930"},
|
||||
json={"name": "new-1", "asset_id": "00000000-0000-0000-0000-000000000001"},
|
||||
)
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
@@ -78,7 +78,7 @@ class FakeRepo:
|
||||
owner_user_id: str,
|
||||
broker: str,
|
||||
status: str,
|
||||
symbol: str,
|
||||
asset_id: str,
|
||||
cash_balance: float,
|
||||
asset_quantity: float,
|
||||
asset_price: float,
|
||||
@@ -96,7 +96,8 @@ class FakeRepo:
|
||||
broker=broker,
|
||||
status=status,
|
||||
name=name,
|
||||
symbol=symbol,
|
||||
symbol=asset_id,
|
||||
asset_id=asset_id,
|
||||
owner_user_id=owner_user_id,
|
||||
cash_balance=cash_balance,
|
||||
asset_quantity=asset_quantity,
|
||||
@@ -108,6 +109,21 @@ class FakeRepo:
|
||||
self._portfolios.append(portfolio)
|
||||
return portfolio
|
||||
|
||||
def get_asset_by_id(self, asset_id: str):
|
||||
return None
|
||||
|
||||
def get_asset_by_symbol(self, symbol: str):
|
||||
return None
|
||||
|
||||
def list_assets(self):
|
||||
return []
|
||||
|
||||
def insert_asset(self, *, symbol: str, markets, display_name=None, status: str = "active"):
|
||||
return None
|
||||
|
||||
def update_asset(self, asset_id: str, **kwargs):
|
||||
return None
|
||||
|
||||
def get_portfolio_by_name_and_user(self, owner_user_id: str, name: str) -> Portfolio | None:
|
||||
return next(
|
||||
(p for p in self._portfolios if p.owner_user_id == owner_user_id and p.name == name),
|
||||
@@ -311,6 +327,9 @@ class FakeRepo:
|
||||
del self._comments[comment_id]
|
||||
|
||||
|
||||
_TEST_ASSET_ID = "00000000-0000-0000-0000-000000000001"
|
||||
|
||||
|
||||
def _settings() -> Settings:
|
||||
return Settings(db_enabled=True, allowed_origins_raw="http://localhost:3000")
|
||||
|
||||
@@ -331,7 +350,7 @@ def test_create_portfolio_assigns_uuid_id() -> None:
|
||||
|
||||
result = runner.invoke(
|
||||
runner.app,
|
||||
["portfolio", "create", "SAMSUNG", "--symbol", "005930", "--user", "alice", "--force"],
|
||||
["portfolio", "create", "SAMSUNG", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--force"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
@@ -352,7 +371,7 @@ def test_create_returns_uuid_in_response() -> None:
|
||||
|
||||
result = runner.invoke(
|
||||
runner.app,
|
||||
["--format", "json", "portfolio", "create", "MY_PORTFOLIO", "--symbol", "005930", "--user", "alice", "--force"],
|
||||
["--format", "json", "portfolio", "create", "MY_PORTFOLIO", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--force"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
@@ -370,7 +389,7 @@ def test_status_resolves_by_name_and_user() -> None:
|
||||
|
||||
create_result = runner.invoke(
|
||||
runner.app,
|
||||
["--format", "json", "portfolio", "create", "MY_PORT", "--symbol", "005930", "--user", "alice", "--force"],
|
||||
["--format", "json", "portfolio", "create", "MY_PORT", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--force"],
|
||||
)
|
||||
assert create_result.exit_code == 0, create_result.output
|
||||
|
||||
@@ -418,7 +437,7 @@ def test_create_without_user_fails() -> None:
|
||||
|
||||
result = runner.invoke(
|
||||
runner.app,
|
||||
["portfolio", "create", "MY_PORT", "--symbol", "005930", "--force"],
|
||||
["portfolio", "create", "MY_PORT", "--asset-id", _TEST_ASSET_ID, "--force"],
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
@@ -431,7 +450,7 @@ def test_create_with_unknown_user_fails() -> None:
|
||||
|
||||
result = runner.invoke(
|
||||
runner.app,
|
||||
["portfolio", "create", "MY_PORT", "--symbol", "005930", "--user", "ghost", "--force"],
|
||||
["portfolio", "create", "MY_PORT", "--asset-id", _TEST_ASSET_ID, "--user", "ghost", "--force"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
@@ -445,7 +464,7 @@ def test_name_validation_rejects_invalid_characters() -> None:
|
||||
|
||||
result = runner.invoke(
|
||||
runner.app,
|
||||
["portfolio", "create", "invalid name!", "--symbol", "005930", "--user", "alice", "--force"],
|
||||
["portfolio", "create", "invalid name!", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--force"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
@@ -459,7 +478,7 @@ def test_name_validation_rejects_empty_name() -> None:
|
||||
|
||||
result = runner.invoke(
|
||||
runner.app,
|
||||
["portfolio", "create", "", "--symbol", "005930", "--user", "alice", "--force"],
|
||||
["portfolio", "create", "", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--force"],
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
@@ -473,11 +492,11 @@ def test_alice_and_bob_can_have_same_portfolio_name() -> None:
|
||||
|
||||
alice_result = runner.invoke(
|
||||
runner.app,
|
||||
["portfolio", "create", "SAMSUNG", "--symbol", "005930", "--user", "alice", "--force"],
|
||||
["portfolio", "create", "SAMSUNG", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--force"],
|
||||
)
|
||||
bob_result = runner.invoke(
|
||||
runner.app,
|
||||
["portfolio", "create", "SAMSUNG", "--symbol", "005930", "--user", "bob", "--force"],
|
||||
["portfolio", "create", "SAMSUNG", "--asset-id", _TEST_ASSET_ID, "--user", "bob", "--force"],
|
||||
)
|
||||
|
||||
assert alice_result.exit_code == 0, alice_result.output
|
||||
@@ -494,7 +513,7 @@ def test_buy_resolves_portfolio_by_name_and_user() -> None:
|
||||
|
||||
runner.invoke(
|
||||
runner.app,
|
||||
["portfolio", "create", "MY_PORT", "--symbol", "005930", "--cash-balance", "1000000", "--user", "alice", "--force"],
|
||||
["portfolio", "create", "MY_PORT", "--asset-id", _TEST_ASSET_ID, "--cash-balance", "1000000", "--user", "alice", "--force"],
|
||||
)
|
||||
buy_result = runner.invoke(
|
||||
runner.app,
|
||||
@@ -513,7 +532,7 @@ def test_delete_resolves_portfolio_by_name_and_user() -> None:
|
||||
|
||||
runner.invoke(
|
||||
runner.app,
|
||||
["portfolio", "create", "MY_PORT", "--symbol", "005930", "--user", "alice", "--force"],
|
||||
["portfolio", "create", "MY_PORT", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--force"],
|
||||
)
|
||||
delete_result = runner.invoke(
|
||||
runner.app,
|
||||
@@ -533,7 +552,7 @@ def test_alice_cannot_delete_bobs_portfolio() -> None:
|
||||
|
||||
runner.invoke(
|
||||
runner.app,
|
||||
["portfolio", "create", "SAMSUNG", "--symbol", "005930", "--user", "bob", "--force"],
|
||||
["portfolio", "create", "SAMSUNG", "--asset-id", _TEST_ASSET_ID, "--user", "bob", "--force"],
|
||||
)
|
||||
delete_result = runner.invoke(
|
||||
runner.app,
|
||||
@@ -551,7 +570,7 @@ def test_update_new_name_renames_portfolio() -> None:
|
||||
|
||||
runner.invoke(
|
||||
runner.app,
|
||||
["portfolio", "create", "OLD_NAME", "--symbol", "005930", "--user", "alice", "--force"],
|
||||
["portfolio", "create", "OLD_NAME", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--force"],
|
||||
)
|
||||
rename_result = runner.invoke(
|
||||
runner.app,
|
||||
|
||||
+5
-7
@@ -220,7 +220,6 @@ def _make_portfolio(portfolio_id: str = "p1") -> Portfolio:
|
||||
status="enabled",
|
||||
name="Test",
|
||||
symbol="005930",
|
||||
market="KRW",
|
||||
tags=["tag1"],
|
||||
cash_balance=1000.0,
|
||||
asset_quantity=10.0,
|
||||
@@ -275,7 +274,7 @@ def test_v1_assets_returns_asset_list(monkeypatch) -> None:
|
||||
assert body["total"] == 1
|
||||
item = body["assets"][0]
|
||||
assert item["asset_id"] == "p1"
|
||||
assert item["market"] == "KRW"
|
||||
assert "market" not in item
|
||||
assert item["broker_type"] == "kis"
|
||||
assert item["cash"] == 1000.0
|
||||
assert item["quantity"] == 10.0
|
||||
@@ -294,7 +293,7 @@ def test_v1_assets_status_filter(monkeypatch) -> None:
|
||||
p_enabled = _make_portfolio("p1") # status="enabled"
|
||||
p_active = Portfolio(
|
||||
portfolio_id="p2", broker="kis", status="active", name="Active",
|
||||
symbol="000660", market="KRW", cash_balance=0.0, asset_quantity=0.0,
|
||||
symbol="000660", cash_balance=0.0, asset_quantity=0.0,
|
||||
price=0.0, profit_buffer=0.0,
|
||||
)
|
||||
principal = TokenPrincipal(
|
||||
@@ -328,7 +327,7 @@ def test_v1_assets_strategy_params(monkeypatch) -> None:
|
||||
created_at=_dt.datetime.now(_dt.timezone.utc))
|
||||
p = Portfolio(
|
||||
portfolio_id="p1", broker="kis", status="enabled", name="T", symbol="005930",
|
||||
market="KRW", cash_balance=0.0, asset_quantity=0.0, price=0.0,
|
||||
cash_balance=0.0, asset_quantity=0.0, price=0.0,
|
||||
profit_buffer=0.0, active_strategy_id="ss-1",
|
||||
)
|
||||
principal = TokenPrincipal(
|
||||
@@ -357,7 +356,7 @@ def test_v1_assets_strategy_params_fallback_on_error(monkeypatch) -> None:
|
||||
|
||||
p = Portfolio(
|
||||
portfolio_id="p1", broker="kis", status="enabled", name="T", symbol="005930",
|
||||
market="KRW", cash_balance=0.0, asset_quantity=0.0, price=0.0,
|
||||
cash_balance=0.0, asset_quantity=0.0, price=0.0,
|
||||
profit_buffer=0.0, active_strategy_id="ss-bad",
|
||||
)
|
||||
principal = TokenPrincipal(
|
||||
@@ -761,7 +760,6 @@ def _make_portfolio(portfolio_id: str = "p1") -> Portfolio:
|
||||
status="enabled",
|
||||
name="Test",
|
||||
symbol="005930",
|
||||
market="KRW",
|
||||
tags=["tag1"],
|
||||
cash_balance=1000.0,
|
||||
asset_quantity=10.0,
|
||||
@@ -814,7 +812,7 @@ def test_v1_assets_returns_asset_list(monkeypatch) -> None:
|
||||
body = resp.json()
|
||||
assert body["total"] == 1
|
||||
assert body["assets"][0]["asset_id"] == "p1"
|
||||
assert body["assets"][0]["market"] == "KRW"
|
||||
assert "market" not in body["assets"][0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user