refactor: simplify data model by removing unused fields and constraints
- Remove runtime_id from AuthToken model - Remove market field from Portfolio model - Remove BrokerCredential and LedgerTrade unused models - Remove unique constraint on (owner_user_id, name) - Simplify postgres repository schema and queries - Clean up auth_service implementation - Update test suite for simplified model
This commit is contained in:
+55
-345
@@ -11,7 +11,7 @@ from typing import Any
|
||||
import psycopg
|
||||
from psycopg import sql
|
||||
|
||||
from app.domain.models import AuthToken, BrokerCredential, LedgerEntry, LedgerTrade, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User
|
||||
from app.domain.models import AuthToken, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User
|
||||
|
||||
_SELECT_USER = """
|
||||
SELECT
|
||||
@@ -38,8 +38,7 @@ _SELECT_AUTH_TOKEN = """
|
||||
expires_at,
|
||||
last_used_at,
|
||||
revoked_at,
|
||||
created_at,
|
||||
runtime_id
|
||||
created_at
|
||||
FROM auth_tokens
|
||||
"""
|
||||
|
||||
@@ -59,8 +58,7 @@ _SELECT_PORTFOLIO = """
|
||||
owner_user_id,
|
||||
created_at,
|
||||
updated_at,
|
||||
last_trade_at,
|
||||
market
|
||||
last_trade_at
|
||||
FROM portfolios
|
||||
"""
|
||||
|
||||
@@ -198,21 +196,6 @@ 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 (
|
||||
@@ -317,7 +300,6 @@ class PostgresPortfolioRepository:
|
||||
last_used_at=row[7] if len(row) > 7 else None,
|
||||
revoked_at=row[8] if len(row) > 8 else None,
|
||||
created_at=row[9] if len(row) > 9 else None,
|
||||
runtime_id=row[10] if len(row) > 10 else None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -350,7 +332,6 @@ class PostgresPortfolioRepository:
|
||||
created_at=row[12] if len(row) > 12 else None,
|
||||
updated_at=row[13] if len(row) > 13 else None,
|
||||
last_trade_at=row[14] if len(row) > 14 else None,
|
||||
market=row[15] if len(row) > 15 else None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -551,7 +532,6 @@ class PostgresPortfolioRepository:
|
||||
permission: str,
|
||||
token_type: str,
|
||||
expires_at: datetime | None = None,
|
||||
runtime_id: str | None = None,
|
||||
) -> AuthToken:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
@@ -562,14 +542,14 @@ class PostgresPortfolioRepository:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO auth_tokens (
|
||||
id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at, runtime_id
|
||||
id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING
|
||||
id, user_id, name, token_prefix, permission, token_type,
|
||||
expires_at, last_used_at, revoked_at, created_at, runtime_id
|
||||
expires_at, last_used_at, revoked_at, created_at
|
||||
""",
|
||||
(token_id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at, runtime_id),
|
||||
(token_id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return self._row_to_auth_token(row)
|
||||
@@ -615,31 +595,6 @@ class PostgresPortfolioRepository:
|
||||
if row is None:
|
||||
raise ValueError("토큰을 찾을 수 없습니다")
|
||||
|
||||
def rotate_service_token(
|
||||
self,
|
||||
token_id: str,
|
||||
token_hash: str,
|
||||
token_prefix: str,
|
||||
runtime_id: str,
|
||||
) -> AuthToken:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE auth_tokens
|
||||
SET token_hash = %s, token_prefix = %s, runtime_id = %s
|
||||
WHERE id = %s AND token_type = 'service' AND revoked_at IS NULL
|
||||
RETURNING
|
||||
id, user_id, name, token_prefix, permission, token_type,
|
||||
expires_at, last_used_at, revoked_at, created_at, runtime_id
|
||||
""",
|
||||
(token_hash, token_prefix, runtime_id, token_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
raise ValueError("토큰을 찾을 수 없습니다")
|
||||
return self._row_to_auth_token(row)
|
||||
|
||||
def get_service_token_by_name(self, name: str) -> AuthToken | None:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
@@ -657,7 +612,7 @@ class PostgresPortfolioRepository:
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT t.id, t.user_id, t.permission, t.token_type, t.runtime_id
|
||||
SELECT t.id, t.user_id, t.permission, t.token_type
|
||||
FROM auth_tokens t
|
||||
WHERE token_hash = %s
|
||||
AND t.revoked_at IS NULL
|
||||
@@ -674,7 +629,6 @@ class PostgresPortfolioRepository:
|
||||
user_id=row[1],
|
||||
permission=row[2],
|
||||
token_type=row[3],
|
||||
runtime_id=row[4] if len(row) > 4 else None,
|
||||
)
|
||||
|
||||
def get(self, portfolio_id: str) -> Portfolio:
|
||||
@@ -732,11 +686,12 @@ class PostgresPortfolioRepository:
|
||||
|
||||
def create_portfolio(
|
||||
self,
|
||||
name: str,
|
||||
portfolio_id: str,
|
||||
*,
|
||||
owner_user_id: str,
|
||||
broker: str,
|
||||
status: str,
|
||||
name: str,
|
||||
symbol: str,
|
||||
cash_balance: float,
|
||||
asset_quantity: float,
|
||||
@@ -745,58 +700,42 @@ 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(
|
||||
_SELECT_PORTFOLIO + "WHERE owner_user_id = %s AND name = %s",
|
||||
(owner_user_id, name),
|
||||
"""
|
||||
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()
|
||||
if row is None:
|
||||
return None
|
||||
return self._row_to_portfolio(row)
|
||||
portfolio = self._row_to_portfolio(row)
|
||||
portfolio.tags = []
|
||||
return portfolio
|
||||
|
||||
def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio:
|
||||
self.ensure_schema()
|
||||
@@ -824,27 +763,20 @@ class PostgresPortfolioRepository:
|
||||
return self.get(portfolio_id)
|
||||
|
||||
assignments.append("updated_at = NOW()")
|
||||
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
|
||||
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()
|
||||
return self._row_to_portfolio(row)
|
||||
|
||||
def list_portfolios(
|
||||
@@ -1364,228 +1296,6 @@ class PostgresPortfolioRepository:
|
||||
if cur.fetchone() is None:
|
||||
raise ValueError("댓글을 찾을 수 없습니다")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Broker credentials
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _row_to_broker_credential(row: Any) -> BrokerCredential:
|
||||
if row is None:
|
||||
raise ValueError("자격증명을 찾을 수 없습니다")
|
||||
return BrokerCredential(
|
||||
user_id=row[0],
|
||||
broker=row[1],
|
||||
account_ref=row[2],
|
||||
encrypted_payload=row[3],
|
||||
credential_version=int(row[4]),
|
||||
last_rotated_at=row[5] if len(row) > 5 else None,
|
||||
)
|
||||
|
||||
def get_broker_credential(self, user_id: str, broker: str) -> BrokerCredential | None:
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at
|
||||
FROM broker_credentials
|
||||
WHERE user_id = %s AND broker = %s
|
||||
""",
|
||||
(user_id, broker),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return self._row_to_broker_credential(row)
|
||||
|
||||
def list_broker_credentials(self, user_id: str) -> list[BrokerCredential]:
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at
|
||||
FROM broker_credentials
|
||||
WHERE user_id = %s
|
||||
ORDER BY broker ASC
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return [self._row_to_broker_credential(row) for row in rows]
|
||||
|
||||
def upsert_broker_credential(
|
||||
self,
|
||||
user_id: str,
|
||||
broker: str,
|
||||
*,
|
||||
account_ref: str,
|
||||
encrypted_payload: str,
|
||||
) -> BrokerCredential:
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO broker_credentials (user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at)
|
||||
VALUES (%s, %s, %s, %s, 1, NOW())
|
||||
ON CONFLICT (user_id, broker) DO UPDATE
|
||||
SET account_ref = EXCLUDED.account_ref,
|
||||
encrypted_payload = EXCLUDED.encrypted_payload,
|
||||
credential_version = broker_credentials.credential_version + 1,
|
||||
last_rotated_at = NOW()
|
||||
RETURNING user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at
|
||||
""",
|
||||
(user_id, broker, account_ref, encrypted_payload),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return self._row_to_broker_credential(row)
|
||||
|
||||
def delete_broker_credential(self, user_id: str, broker: str) -> bool:
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM broker_credentials
|
||||
WHERE user_id = %s AND broker = %s
|
||||
""",
|
||||
(user_id, broker),
|
||||
)
|
||||
return cur.rowcount > 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Ledger trades
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _row_to_ledger_trade(row: Any) -> LedgerTrade:
|
||||
if row is None:
|
||||
raise ValueError("원장 거래를 찾을 수 없습니다")
|
||||
return LedgerTrade(
|
||||
ledger_entry_id=int(row[0]),
|
||||
asset_id=row[1],
|
||||
order_id=row[2],
|
||||
execution_id=row[3],
|
||||
side=row[4],
|
||||
quantity=float(row[5]),
|
||||
price=float(row[6]),
|
||||
fee_amount=float(row[7]),
|
||||
tax_amount=float(row[8]),
|
||||
executed_at=row[9],
|
||||
recorded_at=row[10] if len(row) > 10 else None,
|
||||
)
|
||||
|
||||
def insert_ledger_trade(
|
||||
self,
|
||||
asset_id: str,
|
||||
order_id: str,
|
||||
execution_id: str,
|
||||
side: str,
|
||||
quantity: float,
|
||||
price: float,
|
||||
executed_at: datetime,
|
||||
*,
|
||||
fee_amount: float = 0.0,
|
||||
tax_amount: float = 0.0,
|
||||
) -> tuple[LedgerTrade, bool]:
|
||||
"""Insert a ledger trade, returning (trade, created).
|
||||
|
||||
Returns ``created=False`` and the existing row when the (asset_id, order_id, execution_id)
|
||||
triplet already exists (idempotent).
|
||||
"""
|
||||
from decimal import Decimal
|
||||
|
||||
with self._connect().cursor() as cur:
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO ledger_trades
|
||||
(asset_id, order_id, execution_id, side, quantity, price, fee_amount, tax_amount, executed_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING
|
||||
ledger_entry_id, asset_id, order_id, execution_id, side,
|
||||
quantity, price, fee_amount, tax_amount, executed_at, recorded_at
|
||||
""",
|
||||
(
|
||||
asset_id,
|
||||
order_id,
|
||||
execution_id,
|
||||
side,
|
||||
Decimal(str(quantity)),
|
||||
Decimal(str(price)),
|
||||
Decimal(str(fee_amount)),
|
||||
Decimal(str(tax_amount)),
|
||||
executed_at,
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return self._row_to_ledger_trade(row), True
|
||||
except psycopg.errors.UniqueViolation:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
ledger_entry_id, asset_id, order_id, execution_id, side,
|
||||
quantity, price, fee_amount, tax_amount, executed_at, recorded_at
|
||||
FROM ledger_trades
|
||||
WHERE asset_id = %s AND order_id = %s AND execution_id = %s
|
||||
""",
|
||||
(asset_id, order_id, execution_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return self._row_to_ledger_trade(row), False
|
||||
|
||||
def list_ledger_trades(self, asset_id: str, *, limit: int = 100) -> list[LedgerTrade]:
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
ledger_entry_id, asset_id, order_id, execution_id, side,
|
||||
quantity, price, fee_amount, tax_amount, executed_at, recorded_at
|
||||
FROM ledger_trades
|
||||
WHERE asset_id = %s
|
||||
ORDER BY executed_at DESC, ledger_entry_id DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(asset_id, limit),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return [self._row_to_ledger_trade(row) for row in rows]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Portfolio+strategy_set join query for /v1/assets
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_portfolios_with_strategies(
|
||||
self,
|
||||
owner_user_id: str | None = None,
|
||||
*,
|
||||
broker: str | None = None,
|
||||
include_disabled: bool = False,
|
||||
) -> list[Portfolio]:
|
||||
"""Return portfolios with tags attached. Optionally filter by owner or broker."""
|
||||
conditions: list[str] = []
|
||||
params: list[object] = []
|
||||
if not include_disabled:
|
||||
conditions.append("p.status != 'disabled'")
|
||||
if owner_user_id is not None:
|
||||
conditions.append("p.owner_user_id = %s")
|
||||
params.append(owner_user_id)
|
||||
if broker is not None:
|
||||
conditions.append("p.broker = %s")
|
||||
params.append(broker)
|
||||
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT
|
||||
p.portfolio_id, p.broker, p.status, p.name, p.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.market
|
||||
FROM portfolios p
|
||||
{where}
|
||||
ORDER BY p.updated_at DESC, p.portfolio_id ASC
|
||||
""",
|
||||
params,
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
portfolios = [self._row_to_portfolio(row) for row in rows]
|
||||
self._attach_tags(cur, portfolios)
|
||||
return portfolios
|
||||
|
||||
|
||||
def database_exists(config: PortfolioDBConfig, *, maintenance_db: str = "postgres") -> bool:
|
||||
with psycopg.connect(
|
||||
|
||||
Reference in New Issue
Block a user