diff --git a/app/api/v1_assets.py b/app/api/v1_assets.py index 768dcdf..d0ceb1b 100644 --- a/app/api/v1_assets.py +++ b/app/api/v1_assets.py @@ -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, diff --git a/app/cli/commands/portfolio.py b/app/cli/commands/portfolio.py index cc620fc..d44cb1e 100644 --- a/app/cli/commands/portfolio.py +++ b/app/cli/commands/portfolio.py @@ -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, diff --git a/app/domain/models.py b/app/domain/models.py index 94b37e0..0902065 100644 --- a/app/domain/models.py +++ b/app/domain/models.py @@ -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 diff --git a/app/repositories/portfolio_repository.py b/app/repositories/portfolio_repository.py index e4d8e63..69cbe95 100644 --- a/app/repositories/portfolio_repository.py +++ b/app/repositories/portfolio_repository.py @@ -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( diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index 1429b1a..b1c600a 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -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() diff --git a/app/schemas/portfolio.py b/app/schemas/portfolio.py index 503681d..9fdf46f 100644 --- a/app/schemas/portfolio.py +++ b/app/schemas/portfolio.py @@ -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 diff --git a/app/schemas/v1.py b/app/schemas/v1.py index ebf3aa0..ac829ab 100644 --- a/app/schemas/v1.py +++ b/app/schemas/v1.py @@ -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 diff --git a/app/services/portfolio_service.py b/app/services/portfolio_service.py index 2148fa2..62461b1 100644 --- a/app/services/portfolio_service.py +++ b/app/services/portfolio_service.py @@ -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, diff --git a/docs/batches/bat-001_20260519_asset-catalog-db-foundation/design.md b/docs/batches/bat-001_20260519_asset-catalog-db-foundation/design.md new file mode 100644 index 0000000..e039cf2 --- /dev/null +++ b/docs/batches/bat-001_20260519_asset-catalog-db-foundation/design.md @@ -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)에서 처리 diff --git a/docs/batches/bat-001_20260519_asset-catalog-db-foundation/implementation.md b/docs/batches/bat-001_20260519_asset-catalog-db-foundation/implementation.md new file mode 100644 index 0000000..55d7da9 --- /dev/null +++ b/docs/batches/bat-001_20260519_asset-catalog-db-foundation/implementation.md @@ -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/_create_assets_table.py` (신규) +- `migrations/versions/_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 고려 필요 diff --git a/docs/batches/bat-001_20260519_asset-catalog-db-foundation/index.md b/docs/batches/bat-001_20260519_asset-catalog-db-foundation/index.md new file mode 100644 index 0000000..6ae1293 --- /dev/null +++ b/docs/batches/bat-001_20260519_asset-catalog-db-foundation/index.md @@ -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 재설계 선행 필요) diff --git a/docs/batches/bat-001_20260519_asset-catalog-db-foundation/planning.md b/docs/batches/bat-001_20260519_asset-catalog-db-foundation/planning.md new file mode 100644 index 0000000..2f73cbd --- /dev/null +++ b/docs/batches/bat-001_20260519_asset-catalog-db-foundation/planning.md @@ -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 단계에서 검토 diff --git a/docs/batches/bat-001_20260519_asset-catalog-db-foundation/verification.md b/docs/batches/bat-001_20260519_asset-catalog-db-foundation/verification.md new file mode 100644 index 0000000..e3b933b --- /dev/null +++ b/docs/batches/bat-001_20260519_asset-catalog-db-foundation/verification.md @@ -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 파일 구조 검증으로 대체. diff --git a/docs/batches/index.md b/docs/batches/index.md new file mode 100644 index 0000000..b0b75d4 --- /dev/null +++ b/docs/batches/index.md @@ -0,0 +1,11 @@ +# Batch Index + +## 목적 +- 이 문서는 batch delivery 단위의 전역 인덱스다. +- 각 batch는 `docs/batches//` 아래에서 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/) | diff --git a/docs/discovery/dcy-001_20260519_asset-catalog-and-user-portfolio-model.md b/docs/discovery/dcy-001_20260519_asset-catalog-and-user-portfolio-model.md index 3858bdf..1a940c7 100644 --- a/docs/discovery/dcy-001_20260519_asset-catalog-and-user-portfolio-model.md +++ b/docs/discovery/dcy-001_20260519_asset-catalog-and-user-portfolio-model.md @@ -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 diff --git a/docs/discovery/index.md b/docs/discovery/index.md index f5a36ef..5762aa2 100644 --- a/docs/discovery/index.md +++ b/docs/discovery/index.md @@ -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 | diff --git a/docs/srs/Data/req-001_tags-master-table.md b/docs/srs/Data/req-001_tags-master-table.md index 2523000..6a381e5 100644 --- a/docs/srs/Data/req-001_tags-master-table.md +++ b/docs/srs/Data/req-001_tags-master-table.md @@ -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 diff --git a/docs/srs/Data/req-002_assets-tag-ids-column.md b/docs/srs/Data/req-002_assets-tag-ids-column.md index de3e850..0a4c72c 100644 --- a/docs/srs/Data/req-002_assets-tag-ids-column.md +++ b/docs/srs/Data/req-002_assets-tag-ids-column.md @@ -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 diff --git a/docs/srs/Data/req-003_portfolios-tag-ids-and-portfolio-tags-removal.md b/docs/srs/Data/req-003_portfolios-tag-ids-and-portfolio-tags-removal.md index 518b352..aa7388f 100644 --- a/docs/srs/Data/req-003_portfolios-tag-ids-and-portfolio-tags-removal.md +++ b/docs/srs/Data/req-003_portfolios-tag-ids-and-portfolio-tags-removal.md @@ -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 diff --git a/docs/srs/Data/req-009_assets-table-schema.md b/docs/srs/Data/req-009_assets-table-schema.md new file mode 100644 index 0000000..8ea8388 --- /dev/null +++ b/docs/srs/Data/req-009_assets-table-schema.md @@ -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로 확인 diff --git a/docs/srs/Data/req-011_portfolios-asset-id-fk-and-column-removal.md b/docs/srs/Data/req-011_portfolios-asset-id-fk-and-column-removal.md new file mode 100644 index 0000000..8c937a3 --- /dev/null +++ b/docs/srs/Data/req-011_portfolios-asset-id-fk-and-column-removal.md @@ -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 참조 잔존 여부 확인 diff --git a/docs/srs/Documentation/req-008_tag-baseline-docs-update.md b/docs/srs/Documentation/req-008_tag-baseline-docs-update.md index 083b8ed..9c8cb10 100644 --- a/docs/srs/Documentation/req-008_tag-baseline-docs-update.md +++ b/docs/srs/Documentation/req-008_tag-baseline-docs-update.md @@ -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 diff --git a/docs/srs/Documentation/req-015_asset-catalog-baseline-docs-update.md b/docs/srs/Documentation/req-015_asset-catalog-baseline-docs-update.md new file mode 100644 index 0000000..e362f48 --- /dev/null +++ b/docs/srs/Documentation/req-015_asset-catalog-baseline-docs-update.md @@ -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 기술 내용 포함 여부 확인 diff --git a/docs/srs/Interface/req-004_tags-http-api.md b/docs/srs/Interface/req-004_tags-http-api.md index fdd0961..78be021 100644 --- a/docs/srs/Interface/req-004_tags-http-api.md +++ b/docs/srs/Interface/req-004_tags-http-api.md @@ -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 diff --git a/docs/srs/Interface/req-005_tag-apply-remove-http-api.md b/docs/srs/Interface/req-005_tag-apply-remove-http-api.md index 7c05485..e151bb5 100644 --- a/docs/srs/Interface/req-005_tag-apply-remove-http-api.md +++ b/docs/srs/Interface/req-005_tag-apply-remove-http-api.md @@ -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 diff --git a/docs/srs/Interface/req-007_pfhub-tag-cli.md b/docs/srs/Interface/req-007_pfhub-tag-cli.md index 731e631..a0e6305 100644 --- a/docs/srs/Interface/req-007_pfhub-tag-cli.md +++ b/docs/srs/Interface/req-007_pfhub-tag-cli.md @@ -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 diff --git a/docs/srs/Interface/req-010_admin-asset-catalog-crud.md b/docs/srs/Interface/req-010_admin-asset-catalog-crud.md new file mode 100644 index 0000000..7999c6a --- /dev/null +++ b/docs/srs/Interface/req-010_admin-asset-catalog-crud.md @@ -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 [--markets ] [--display-name ]` — admin 권한 필요. markets 값은 쉼표 구분 MarketCode Enum 값. +- CLI: `pfhub asset list [--inactive]` — active asset만 기본 출력; `--inactive` 지정 시 inactive 포함 전체 출력. +- CLI: `pfhub asset update [--markets ] [--display-name ]` — admin 권한 필요. +- CLI: `pfhub asset deactivate [--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 사용 diff --git a/docs/srs/Interface/req-012_portfolio-create-catalog-validation.md b/docs/srs/Interface/req-012_portfolio-create-catalog-validation.md new file mode 100644 index 0000000..77f003c --- /dev/null +++ b/docs/srs/Interface/req-012_portfolio-create-catalog-validation.md @@ -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 ` 또는 `--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 --asset-id --user `, 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 ` 옵션 사용, 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 검증 diff --git a/docs/srs/Interface/req-013_get-v1-assets-token-branching.md b/docs/srs/Interface/req-013_get-v1-assets-token-branching.md new file mode 100644 index 0000000..f89ddad --- /dev/null +++ b/docs/srs/Interface/req-013_get-v1-assets-token-branching.md @@ -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=` 파라미터 지정 시 해당 user의 portfolio 반환. +- 모든 토큰 타입에서 `?market=` 파라미터를 지원한다. 지정 시 ` = 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=`, 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 검증; 토큰 타입별 시나리오를 각각 독립 테스트로 분리 diff --git a/docs/srs/Migration/req-006_portfolio-tags-data-migration.md b/docs/srs/Migration/req-006_portfolio-tags-data-migration.md index 671e3ab..35ac406 100644 --- a/docs/srs/Migration/req-006_portfolio-tags-data-migration.md +++ b/docs/srs/Migration/req-006_portfolio-tags-data-migration.md @@ -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 diff --git a/docs/srs/Migration/req-014_portfolios-data-migration.md b/docs/srs/Migration/req-014_portfolios-data-migration.md new file mode 100644 index 0000000..d4973c4 --- /dev/null +++ b/docs/srs/Migration/req-014_portfolios-data-migration.md @@ -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 격리로 시뮬레이션 diff --git a/docs/srs/index.md b/docs/srs/index.md index 78f246a..e431ee6 100644 --- a/docs/srs/index.md +++ b/docs/srs/index.md @@ -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 실행 diff --git a/migrations/versions/20260519_0017_create_assets_table.py b/migrations/versions/20260519_0017_create_assets_table.py new file mode 100644 index 0000000..f32137b --- /dev/null +++ b/migrations/versions/20260519_0017_create_assets_table.py @@ -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") diff --git a/migrations/versions/20260519_0018_portfolios_asset_migration.py b/migrations/versions/20260519_0018_portfolios_asset_migration.py new file mode 100644 index 0000000..605194f --- /dev/null +++ b/migrations/versions/20260519_0018_portfolios_asset_migration.py @@ -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") diff --git a/tests/test_asset_catalog.py b/tests/test_asset_catalog.py new file mode 100644 index 0000000..491cdee --- /dev/null +++ b/tests/test_asset_catalog.py @@ -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 diff --git a/tests/test_cli.py b/tests/test_cli.py index 768b43c..b9f7c40 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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: diff --git a/tests/test_daemon_assignment.py b/tests/test_daemon_assignment.py index f1a8672..ed7c377 100644 --- a/tests/test_daemon_assignment.py +++ b/tests/test_daemon_assignment.py @@ -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() diff --git a/tests/test_portfolio_ownership.py b/tests/test_portfolio_ownership.py index 333a4c0..1c3f650 100644 --- a/tests/test_portfolio_ownership.py +++ b/tests/test_portfolio_ownership.py @@ -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() diff --git a/tests/test_portfolio_uuid.py b/tests/test_portfolio_uuid.py index f6e479e..743ce8c 100644 --- a/tests/test_portfolio_uuid.py +++ b/tests/test_portfolio_uuid.py @@ -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, diff --git a/tests/test_v1.py b/tests/test_v1.py index e8670dd..fa7d150 100644 --- a/tests/test_v1.py +++ b/tests/test_v1.py @@ -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] # ---------------------------------------------------------------------------