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:
2026-05-19 15:59:20 +00:00
parent 1082f041a1
commit e2226e28b6
40 changed files with 2118 additions and 168 deletions
-1
View File
@@ -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,
+3 -5
View File
@@ -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
View File
@@ -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
+26 -2
View File
@@ -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
View File
@@ -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()
+1 -2
View File
@@ -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
-1
View File
@@ -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
+1 -1
View File
@@ -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 파일 구조 검증으로 대체.
+11
View File
@@ -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
+1 -1
View File
@@ -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 |
+29 -1
View File
@@ -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
+29 -1
View File
@@ -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 기술 내용 포함 여부 확인
+29 -1
View File
@@ -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
+29 -1
View File
@@ -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
View File
@@ -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")
+248
View File
@@ -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
View File
@@ -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:
+1 -1
View File
@@ -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()
+1 -1
View File
@@ -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()
+34 -15
View File
@@ -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
View File
@@ -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]
# ---------------------------------------------------------------------------