feat: implement portfolio UUID migration and v1 API finalization (BAT-006, BAT-001 re-verified)

Core Changes:
- Migrate portfolio_id from string to UUID in schema and repository layer
- Update portfolio CLI commands to use name+user combination for identification
- Update HTTP API to accept portfolio_id as UUID path parameter
- Implement portfolio ownership validation in repository queries

Requirements Completed (REQ-016 to REQ-019):
- REQ-016: portfolios table UUID schema migration with data migration script
- REQ-017: CLI portfolio commands with name+user identification
- REQ-018: HTTP API portfolio path parameters and v1 asset_id UUID returns
- REQ-019: Portfolio UUID baseline documentation updates

BAT-001 Re-verification:
- REQ-004~010 marked as Implemented after verification evidence validation
- All v1 API endpoints verified with comprehensive test coverage

Documentation Updates:
- Update data-model.md with portfolio UUID structure
- Update interface-contract.md with UUID path parameters
- Update runtime-flows.md with ownership-based resolution flows
- Update project-structure.md with database layer changes
- Add migration script for portfolio UUID transition

Testing:
- Add test_portfolio_uuid.py for migration validation
- Update test_cli.py and test_portfolio_ownership.py for ownership checks
This commit is contained in:
2026-05-11 09:27:57 +00:00
parent 9ed307555a
commit 829629fa8f
46 changed files with 3260 additions and 220 deletions
+84 -47
View File
@@ -198,6 +198,21 @@ class PostgresPortfolioRepository:
cur.execute("ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS owner_user_id TEXT")
cur.execute("UPDATE portfolios SET name = symbol WHERE name IS NULL OR name = ''")
cur.execute("ALTER TABLE portfolios ALTER COLUMN name SET NOT NULL")
cur.execute(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_name = 'uq_portfolios_owner_name'
AND table_name = 'portfolios'
) THEN
ALTER TABLE portfolios
ADD CONSTRAINT uq_portfolios_owner_name UNIQUE (owner_user_id, name);
END IF;
END $$;
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS portfolio_ledger (
@@ -717,12 +732,11 @@ class PostgresPortfolioRepository:
def create_portfolio(
self,
portfolio_id: str,
name: str,
*,
owner_user_id: str,
broker: str,
status: str,
name: str,
symbol: str,
cash_balance: float,
asset_quantity: float,
@@ -731,42 +745,58 @@ class PostgresPortfolioRepository:
active_strategy_id: str | None,
avabile_strategies: list[str] | None,
) -> Portfolio:
self.ensure_schema()
portfolio_id = str(uuid.uuid4())
try:
with self._connect().cursor() as cur:
cur.execute(
"""
INSERT INTO portfolios (
portfolio_id, broker, status, name, symbol,
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,
owner_user_id,
float(cash_balance),
float(asset_quantity),
float(asset_price),
float(profit_buffer),
active_strategy_id,
avabile_strategies,
),
)
row = cur.fetchone()
portfolio = self._row_to_portfolio(row)
portfolio.tags = []
except Exception as exc:
msg = str(exc)
if "uq_portfolios_owner_name" in msg or "unique constraint" in msg.lower():
raise ValueError(f"이미 사용 중인 이름입니다: {name}") from exc
raise
return portfolio
def get_portfolio_by_name_and_user(self, owner_user_id: str, name: str) -> Portfolio | None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute("SELECT 1 FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
if cur.fetchone() is not None:
raise ValueError(f"이미 존재하는 포트폴리오입니다: {portfolio_id}")
cur.execute(
"""
INSERT INTO portfolios (
portfolio_id, broker, status, name, symbol,
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,
owner_user_id,
float(cash_balance),
float(asset_quantity),
float(asset_price),
float(profit_buffer),
active_strategy_id,
avabile_strategies,
),
_SELECT_PORTFOLIO + "WHERE owner_user_id = %s AND name = %s",
(owner_user_id, name),
)
row = cur.fetchone()
portfolio = self._row_to_portfolio(row)
portfolio.tags = []
return portfolio
if row is None:
return None
return self._row_to_portfolio(row)
def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio:
self.ensure_schema()
@@ -794,20 +824,27 @@ class PostgresPortfolioRepository:
return self.get(portfolio_id)
assignments.append("updated_at = NOW()")
with self._connect().cursor() as cur:
cur.execute(
f"""
UPDATE portfolios
SET {', '.join(assignments)}
WHERE portfolio_id = %s
RETURNING
portfolio_id, broker, status, 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()
try:
with self._connect().cursor() as cur:
cur.execute(
f"""
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()
except Exception as exc:
msg = str(exc)
if "uq_portfolios_owner_name" in msg or "unique constraint" in msg.lower():
new_name = fields.get("name", portfolio_id)
raise ValueError(f"이미 사용 중인 이름입니다: {new_name}") from exc
raise
return self._row_to_portfolio(row)
def list_portfolios(