Files
portfolio-hub/app/repositories/postgres.py
T
genichin 2b470c735f
Publish verified PortfolioHub image / verify (push) Canceled after 0s
Publish verified PortfolioHub image / publish (push) Canceled after 0s
fix(portfolio): tolerate consolidation WAC residue
2026-09-13 18:36:00 +09:00

3250 lines
138 KiB
Python

"""Postgres repository implementation for portfolios and ledger."""
from __future__ import annotations
import hashlib
import json
import logging
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
from typing import Any
import psycopg
from psycopg import sql
from app.domain.corporate_actions import calculate_stock_consolidation
from app.domain.errors import (
DaemonInactiveError,
DaemonInUseError,
DaemonNotFoundError,
StrategyInactiveError,
StrategyNotFoundError,
)
from app.domain.models import (
Asset,
AuthToken,
BrokerCredential,
CorporateAction,
Daemon,
LedgerEntry,
Portfolio,
PortfolioComment,
PortfolioTag,
StrategySet,
Tag,
TokenPrincipal,
User,
)
logger = logging.getLogger(__name__)
_LEDGER_NUMERIC_SCALE = 8
_LEDGER_MAX_INTEGER_DIGITS = 12
_LEDGER_QUANT = Decimal("0.00000001")
_SELECT_USER = """
SELECT
id,
username,
email,
permission,
is_active,
password_hash,
created_at,
updated_at,
last_login_at
FROM users
"""
_SELECT_AUTH_TOKEN = """
SELECT
id,
user_id,
name,
token_prefix,
permission,
token_type,
expires_at,
last_used_at,
revoked_at,
created_at,
runtime_id,
daemon_id
FROM auth_tokens
"""
_SELECT_PORTFOLIO = """
SELECT
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,
COALESCE(p.tag_ids, '{}'::uuid[]),
a.display_name,
ss.name,
ss.config,
a.markets,
ss.revision,
ss.nick
FROM portfolios p
JOIN assets a ON p.asset_id = a.asset_id
LEFT JOIN strategy_sets ss ON p.active_strategy_id = ss.id
"""
_SELECT_TAG = """
SELECT
tag_id,
name,
created_by_user_id,
is_admin_tag,
is_active,
created_at
FROM tags
"""
_SELECT_ASSET = """
SELECT
asset_id,
symbol,
markets,
display_name,
status,
created_at,
updated_at,
COALESCE(tag_ids, '{}'::uuid[])
FROM assets
"""
@dataclass(frozen=True)
class PortfolioDBConfig:
host: str
port: int
dbname: str
user: str
password: str
sslmode: str
connect_timeout_sec: int = 10
application_name: str = "portfoliohub"
class PostgresPortfolioRepository:
def __init__(self, config: PortfolioDBConfig) -> None:
self._config = config
self._connection: psycopg.Connection | None = None
def _connect(self) -> psycopg.Connection:
if self._connection is None:
self._connection = psycopg.connect(
host=self._config.host,
port=self._config.port,
dbname=self._config.dbname,
user=self._config.user,
password=self._config.password,
sslmode=self._config.sslmode,
connect_timeout=self._config.connect_timeout_sec,
application_name=self._config.application_name,
autocommit=True,
)
return self._connection
def close(self) -> None:
if self._connection is not None:
self._connection.close()
self._connection = None
def ping(self) -> None:
with self._connect().cursor() as cur:
cur.execute("SELECT 1")
cur.fetchone()
@staticmethod
def _lock_active_strategy(cur: psycopg.Cursor, strategy_id: str) -> None:
"""Serialize new selections with strategy PATCH/DELETE and enforce active status."""
cur.execute(
"SELECT status FROM strategy_sets WHERE id = %s FOR SHARE",
(strategy_id,),
)
row = cur.fetchone()
if row is None:
raise StrategyNotFoundError(f"strategy not found: {strategy_id}")
if row[0] != "active":
raise StrategyInactiveError(f"strategy is inactive: {strategy_id}")
@staticmethod
def _lock_active_daemon(cur: psycopg.Cursor, daemon_id: str, owner_user_id: str) -> None:
"""Serialize new portfolio assignments with daemon deactivation/owner transfer.
Locks the daemon row FOR SHARE inside the portfolio write transaction and
enforces existence, same-owner, and active status so validation and write
form a single atomic decision boundary.
"""
cur.execute(
"SELECT owner_user_id, is_active FROM daemons WHERE id = %s FOR SHARE",
(daemon_id,),
)
row = cur.fetchone()
if row is None:
raise DaemonNotFoundError(f"daemon not found: {daemon_id}")
if row[0] != owner_user_id:
raise DaemonNotFoundError(f"daemon not found: {daemon_id}")
if not row[1]:
raise DaemonInactiveError(f"daemon is inactive: {daemon_id}")
def ensure_schema(self) -> None:
conn = self._connect()
with conn.cursor() as cur:
cur.execute("SELECT pg_advisory_lock(hashtext('portfoliohub.ensure_schema.v1'))")
cur.execute(
"""
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
email TEXT UNIQUE,
permission TEXT NOT NULL DEFAULT 'read',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
password_hash TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_login_at TIMESTAMPTZ
)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS auth_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
token_prefix TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
permission TEXT NOT NULL,
token_type TEXT NOT NULL,
runtime_id TEXT,
expires_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""
)
cur.execute("ALTER TABLE auth_tokens ADD COLUMN IF NOT EXISTS runtime_id TEXT")
cur.execute("ALTER TABLE auth_tokens ADD COLUMN IF NOT EXISTS daemon_id TEXT")
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user_created
ON auth_tokens (user_id, created_at DESC)
"""
)
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_auth_tokens_hash_active
ON auth_tokens (token_hash)
"""
)
cur.execute(
"""
CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_tokens_service_name_unique
ON auth_tokens (name)
WHERE token_type = 'service' AND revoked_at IS NULL
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS strategy_sets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
config TEXT NOT NULL DEFAULT '',
nick TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'active',
revision INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (name, config)
)
"""
)
cur.execute("ALTER TABLE strategy_sets ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active'")
cur.execute("ALTER TABLE strategy_sets ADD COLUMN IF NOT EXISTS revision INTEGER NOT NULL DEFAULT 1")
cur.execute("ALTER TABLE strategy_sets ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()")
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 (
portfolio_id TEXT PRIMARY KEY,
broker TEXT NOT NULL DEFAULT 'kis',
status TEXT NOT NULL DEFAULT 'enabled',
name 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,
asset_price DOUBLE PRECISION NOT NULL DEFAULT 0,
profit_buffer DOUBLE PRECISION NOT NULL DEFAULT 0,
active_strategy_id TEXT DEFAULT NULL,
avabile_strategies TEXT[] DEFAULT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_trade_at TIMESTAMPTZ
)
"""
)
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("ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS asset_id UUID")
cur.execute("ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS tag_ids UUID[] NOT NULL DEFAULT '{}'")
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portfolios_tag_ids_gin
ON portfolios USING GIN (tag_ids)
"""
)
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(
"""
CREATE TABLE IF NOT EXISTS portfolio_corporate_actions (
corporate_action_id UUID PRIMARY KEY,
portfolio_id TEXT NOT NULL REFERENCES portfolios(portfolio_id) ON DELETE RESTRICT,
asset_id UUID NOT NULL REFERENCES assets(asset_id) ON DELETE RESTRICT,
action_type TEXT NOT NULL CHECK (action_type = 'stock_consolidation'),
effective_date DATE NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
actor_user_id TEXT,
evidence_ref TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
payload_hash TEXT NOT NULL,
quantity_before NUMERIC(20,8) NOT NULL,
quantity_after NUMERIC(20,8) NOT NULL,
average_price_before NUMERIC(20,8) NOT NULL,
average_price_after NUMERIC(20,8) NOT NULL,
total_cost_basis NUMERIC(20,8) NOT NULL,
cash_balance NUMERIC(20,8) NOT NULL,
profit_buffer NUMERIC(20,8) NOT NULL,
ledger_entry_id BIGINT,
UNIQUE (portfolio_id, idempotency_key)
)
"""
)
cur.execute("CREATE INDEX IF NOT EXISTS idx_corporate_actions_portfolio ON portfolio_corporate_actions (portfolio_id, effective_date)")
cur.execute(
"""
CREATE TABLE IF NOT EXISTS portfolio_ledger (
ledger_entry_id BIGINT GENERATED BY DEFAULT AS IDENTITY,
portfolio_id TEXT NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
order_id TEXT NULL,
execution_id TEXT NULL,
side TEXT NOT NULL,
price NUMERIC(20,8) NOT NULL,
quantity NUMERIC(20,8) NOT NULL,
fee_amount NUMERIC(20,8) NOT NULL DEFAULT 0,
tax_amount NUMERIC(20,8) NOT NULL DEFAULT 0,
cash_balance_after NUMERIC(20,8) NOT NULL,
asset_quantity_after NUMERIC(20,8) NOT NULL,
total_value_after NUMERIC(20,8) NOT NULL,
profit_buffer_after NUMERIC(20,8) NOT NULL DEFAULT 0,
strategy_set_id TEXT NOT NULL DEFAULT 'legacy',
note TEXT NOT NULL DEFAULT '',
executed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""
)
cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS ledger_entry_id BIGINT GENERATED BY DEFAULT AS IDENTITY")
cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS order_id TEXT")
cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS execution_id TEXT")
cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS side TEXT")
cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS executed_at TIMESTAMPTZ")
cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT 'broker'")
cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS target_ledger_entry_id BIGINT")
cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS confirmation_evidence TEXT")
cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS actor_user_id TEXT")
cur.execute(
"""
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'portfolio_ledger'
AND column_name = 'action'
) THEN
UPDATE portfolio_ledger SET side = lower(action) WHERE side IS NULL;
END IF;
END $$;
"""
)
cur.execute("UPDATE portfolio_ledger SET executed_at = recorded_at WHERE executed_at IS NULL")
cur.execute("UPDATE portfolio_ledger SET side = 'manual' WHERE side IS NULL")
cur.execute("ALTER TABLE portfolio_ledger ALTER COLUMN side SET NOT NULL")
cur.execute("ALTER TABLE portfolio_ledger ALTER COLUMN executed_at SET NOT NULL")
for column in (
"price",
"quantity",
"fee_amount",
"tax_amount",
"cash_balance_after",
"asset_quantity_after",
"total_value_after",
"profit_buffer_after",
):
cur.execute(
f"ALTER TABLE portfolio_ledger ALTER COLUMN {column} TYPE NUMERIC(20,8) USING {column}::numeric(20,8)"
)
cur.execute("ALTER TABLE portfolio_ledger DROP COLUMN IF EXISTS action")
cur.execute("DROP TABLE IF EXISTS ledger_trades")
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portfolio_ledger_portfolio_time
ON portfolio_ledger (portfolio_id, recorded_at DESC)
"""
)
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portfolio_ledger_portfolio_executed
ON portfolio_ledger (portfolio_id, executed_at DESC)
"""
)
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portfolios_broker_status_updated
ON portfolios (broker, status, updated_at DESC)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS portfolio_comments (
id TEXT PRIMARY KEY,
portfolio_id TEXT NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""
)
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portfolio_comments_portfolio_created
ON portfolio_comments (portfolio_id, created_at DESC)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS portfolio_tags (
portfolio_id TEXT NOT NULL,
tag_value TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (portfolio_id, tag_value)
)
"""
)
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portfolio_tags_tag_portfolio
ON portfolio_tags (tag_value, portfolio_id)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS tags (
tag_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
created_by_user_id TEXT NOT NULL,
is_admin_tag BOOLEAN NOT NULL DEFAULT FALSE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(name)
)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS daemons (
id TEXT PRIMARY KEY,
owner_user_id TEXT NOT NULL,
daemon_slug TEXT NOT NULL,
display_name TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (owner_user_id, daemon_slug)
)
"""
)
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_daemons_owner
ON daemons (owner_user_id)
"""
)
# NOTE(REQ-047 Step2 / BAT-021): daemon-scoped PK 전환 이후 최종 스키마.
# 이 CREATE TABLE IF NOT EXISTS는 Step2 적용 환경에서 테이블이 이미 존재해 스킵된다.
# Step2 미적용 환경에서는 alembic revision(Step1→Step2)을 먼저 적용해야 한다.
cur.execute(
"""
CREATE TABLE IF NOT EXISTS broker_credentials (
daemon_id TEXT NOT NULL,
broker TEXT NOT NULL,
user_id TEXT NOT NULL,
account_ref TEXT NOT NULL,
encrypted_payload TEXT NOT NULL,
credential_version INTEGER NOT NULL DEFAULT 1,
last_rotated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (daemon_id, broker)
)
"""
)
cur.execute("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb')")
if cur.fetchone()[0]:
cur.execute(
"""
SELECT create_hypertable('portfolio_ledger', 'recorded_at', if_not_exists => TRUE)
"""
)
cur.execute("SELECT pg_advisory_unlock(hashtext('portfoliohub.ensure_schema.v1'))")
@staticmethod
def _row_to_user(row: Any) -> User:
if row is None:
raise ValueError("사용자를 찾을 수 없습니다")
return User(
id=row[0],
username=row[1],
email=row[2],
permission=row[3] if len(row) > 3 else "read",
is_active=bool(row[4]) if len(row) > 4 else True,
password_hash=row[5] if len(row) > 5 else None,
created_at=row[6] if len(row) > 6 else None,
updated_at=row[7] if len(row) > 7 else None,
last_login_at=row[8] if len(row) > 8 else None,
)
@staticmethod
def _row_to_auth_token(row: Any) -> AuthToken:
if row is None:
raise ValueError("토큰을 찾을 수 없습니다")
return AuthToken(
id=row[0],
user_id=row[1],
name=row[2],
token_prefix=row[3],
permission=row[4],
token_type=row[5],
expires_at=row[6] if len(row) > 6 else None,
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,
daemon_id=row[11] if len(row) > 11 else None,
)
@staticmethod
def _row_to_broker_credential(row: Any) -> BrokerCredential:
"""Step2(REQ-047) 이후 컬럼 순서: daemon_id, user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at."""
if row is None:
raise ValueError("브로커 인증 정보를 찾을 수 없습니다")
return BrokerCredential(
daemon_id=row[0],
user_id=row[1],
broker=row[2],
account_ref=row[3],
encrypted_payload=row[4],
credential_version=int(row[5]) if len(row) > 5 else 1,
last_rotated_at=row[6] if len(row) > 6 else None,
)
@staticmethod
def _row_to_portfolio(row: Any) -> Portfolio:
if row is None:
raise ValueError("포트폴리오를 찾을 수 없습니다")
raw_available = row[10] if len(row) > 10 else None
if raw_available is None:
avabile_strategies = None
elif isinstance(raw_available, list):
avabile_strategies = [str(item) for item in raw_available]
else:
avabile_strategies = [str(raw_available)]
# REQ-055: strategy_name (row[19] = ss.name from LEFT JOIN strategy_sets)
strategy_name: str | None = row[19] if len(row) > 19 else None
# REQ-055: strategy_params — JSON 파싱 (파싱 불가 시 {} + WARNING, 500 금지)
_config_raw = row[20] if len(row) > 20 else None
if not _config_raw:
strategy_params: dict[str, Any] = {}
else:
try:
_parsed = json.loads(_config_raw)
strategy_params = _parsed if isinstance(_parsed, dict) else {}
except (json.JSONDecodeError, ValueError):
logger.warning(
"strategy_sets.config 파싱 실패 — portfolio_id=%s, active_strategy_id=%s, config=%r",
row[0],
row[9] if len(row) > 9 else None,
_config_raw[:200] if isinstance(_config_raw, str) else _config_raw,
)
strategy_params = {}
# REQ-055: markets (row[21] = a.markets from assets JOIN)
_markets_raw = row[21] if len(row) > 21 else None
markets: list[str] = list(_markets_raw) if _markets_raw else []
strategy_revision = int(row[22]) if len(row) > 22 and row[22] is not None else None
# REQ-055: strategy_nick (row[23] = ss.nick from LEFT JOIN strategy_sets)
strategy_nick: str | None = row[23] if len(row) > 23 else None
return Portfolio(
portfolio_id=row[0],
broker=row[1] or "kis",
status=row[2] or "enabled",
name=row[3],
symbol=row[4],
tags=None,
cash_balance=float(row[5]),
asset_quantity=float(row[6]),
price=float(row[7]),
profit_buffer=float(row[8]),
active_strategy_id=row[9] if len(row) > 9 else None,
avabile_strategies=avabile_strategies,
owner_user_id=row[11] if len(row) > 11 else None,
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,
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,
tag_ids=[str(t) for t in row[17]] if len(row) > 17 and row[17] is not None else [],
display_name=row[18] if len(row) > 18 else None,
strategy_name=strategy_name,
strategy_nick=strategy_nick,
strategy_params=strategy_params,
strategy_revision=strategy_revision,
markets=markets,
)
@staticmethod
def _row_to_portfolio_tag(row: Any) -> PortfolioTag:
if row is None:
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],
tag_ids=[str(t) for t in row[7]] if len(row) > 7 and row[7] is not None else [],
)
@staticmethod
def _row_to_strategy_set(row: Any) -> StrategySet:
if row is None:
raise ValueError("전략을 찾을 수 없습니다")
return StrategySet(
id=str(row[0]),
name=str(row[1]),
config=str(row[2]),
nick=str(row[3]),
status=str(row[4]),
revision=int(row[5]),
created_at=row[6],
updated_at=row[7],
)
@staticmethod
def _row_to_tag(row: Any) -> Tag:
if row is None:
raise ValueError("태그를 찾을 수 없습니다")
return Tag(
tag_id=str(row[0]),
name=row[1],
created_by_user_id=row[2],
is_admin_tag=bool(row[3]),
is_active=bool(row[4]),
created_at=row[5] if len(row) > 5 else None,
)
def _load_tags_for_portfolios(self, cur: psycopg.Cursor, portfolio_ids: list[str]) -> dict[str, list[str]]:
if not portfolio_ids:
return {}
cur.execute(
"""
SELECT portfolio_id, tag_value
FROM portfolio_tags
WHERE portfolio_id = ANY(%s)
ORDER BY tag_value ASC
""",
(portfolio_ids,),
)
tags_by_portfolio: dict[str, list[str]] = {portfolio_id: [] for portfolio_id in portfolio_ids}
for portfolio_id, tag_value in cur.fetchall():
tags_by_portfolio.setdefault(str(portfolio_id), []).append(str(tag_value))
return tags_by_portfolio
def _attach_tags(self, cur: psycopg.Cursor, portfolios: list[Portfolio]) -> list[Portfolio]:
tags_by_portfolio = self._load_tags_for_portfolios(cur, [portfolio.portfolio_id for portfolio in portfolios])
for portfolio in portfolios:
portfolio.tags = tags_by_portfolio.get(portfolio.portfolio_id, [])
return portfolios
@staticmethod
def _row_to_ledger_entry_legacy(row: Any) -> LedgerEntry:
raw_tax = row[6]
return LedgerEntry(
ledger_entry_id=0,
portfolio_id=row[0],
order_id=None,
execution_id=None,
side=row[2],
quantity=float(row[4]),
price=float(row[3]),
price_decimal=Decimal(str(row[3])) if row[3] is not None else None,
fee_amount=float(row[5]),
tax_amount=float(raw_tax),
tax_amount_decimal=Decimal(str(raw_tax)) if raw_tax is not None else None,
executed_at=row[1],
recorded_at=row[1],
cash_balance_after=float(row[7]),
asset_quantity_after=float(row[8]),
total_value_after=float(row[9]),
profit_buffer_after=float(row[10]),
strategy_set_id=row[11] or "legacy",
note=row[12] or "",
)
def _ensure_user_uniqueness(
self,
cur: psycopg.Cursor,
*,
username: str | None = None,
email: str | None = None,
exclude_user_id: str | None = None,
) -> None:
if username is not None:
if exclude_user_id is None:
cur.execute("SELECT 1 FROM users WHERE username = %s", (username,))
else:
cur.execute("SELECT 1 FROM users WHERE username = %s AND id <> %s", (username, exclude_user_id))
if cur.fetchone() is not None:
raise ValueError(f"이미 존재하는 username입니다: {username}")
if email is not None:
if exclude_user_id is None:
cur.execute("SELECT 1 FROM users WHERE email = %s", (email,))
else:
cur.execute("SELECT 1 FROM users WHERE email = %s AND id <> %s", (email, exclude_user_id))
if cur.fetchone() is not None:
raise ValueError(f"이미 존재하는 email입니다: {email}")
def get_user(self, user_id: str) -> User:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(_SELECT_USER + "WHERE id = %s", (user_id,))
row = cur.fetchone()
return self._row_to_user(row)
def get_user_by_username(self, username: str) -> User | None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(_SELECT_USER + "WHERE username = %s", (username,))
row = cur.fetchone()
if row is None:
return None
return self._row_to_user(row)
def list_users(self) -> list[User]:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(_SELECT_USER + "ORDER BY created_at DESC, username ASC")
rows = cur.fetchall()
return [self._row_to_user(row) for row in rows]
def create_user(
self,
username: str,
*,
email: str | None = None,
password_hash: str | None = None,
permission: str = "read",
is_active: bool = True,
) -> User:
self.ensure_schema()
with self._connect().cursor() as cur:
self._ensure_user_uniqueness(cur, username=username, email=email)
user_id = str(uuid.uuid4())
cur.execute(
"""
INSERT INTO users (id, username, email, permission, is_active, password_hash)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id, username, email, permission, is_active, password_hash, created_at, updated_at, last_login_at
""",
(user_id, username, email, permission, is_active, password_hash),
)
row = cur.fetchone()
return self._row_to_user(row)
def update_user(
self,
user_id: str,
*,
username: str | None = None,
email: str | None = None,
password_hash: str | None = None,
permission: str | None = None,
is_active: bool | None = None,
) -> User:
self.ensure_schema()
assignments: list[str] = []
params: list[object] = []
with self._connect().cursor() as cur:
cur.execute(_SELECT_USER + "WHERE id = %s", (user_id,))
existing_row = cur.fetchone()
if existing_row is None:
raise ValueError("사용자를 찾을 수 없습니다")
self._ensure_user_uniqueness(cur, username=username, email=email, exclude_user_id=user_id)
if username is not None:
assignments.append("username = %s")
params.append(username)
if email is not None:
assignments.append("email = %s")
params.append(email)
if password_hash is not None:
assignments.append("password_hash = %s")
params.append(password_hash)
if permission is not None:
assignments.append("permission = %s")
params.append(permission)
if is_active is not None:
assignments.append("is_active = %s")
params.append(is_active)
if not assignments:
return self._row_to_user(existing_row)
assignments.append("updated_at = NOW()")
cur.execute(
f"""
UPDATE users
SET {', '.join(assignments)}
WHERE id = %s
RETURNING id, username, email, permission, is_active, password_hash, created_at, updated_at, last_login_at
""",
(*params, user_id),
)
row = cur.fetchone()
return self._row_to_user(row)
def delete_user(self, user_id: str) -> None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute("SELECT 1 FROM users WHERE id = %s", (user_id,))
if cur.fetchone() is None:
raise ValueError("사용자를 찾을 수 없습니다")
cur.execute("DELETE FROM auth_tokens WHERE user_id = %s", (user_id,))
cur.execute("DELETE FROM users WHERE id = %s", (user_id,))
def update_user_last_login(self, user_id: str) -> None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"UPDATE users SET last_login_at = NOW(), updated_at = NOW() WHERE id = %s",
(user_id,),
)
def upsert_broker_credential(
self,
user_id: str,
broker: str,
*,
account_ref: str,
encrypted_payload: str,
daemon_id: str | None = None,
) -> BrokerCredential:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute("SELECT 1 FROM users WHERE id = %s", (user_id,))
if cur.fetchone() is None:
raise ValueError("사용자를 찾을 수 없습니다")
# NOTE(REQ-047 Step2 / BAT-021): PK 전환 이후 ON CONFLICT (daemon_id, broker).
# daemon_id는 NOT NULL(Step2 제약). Step2 미적용 환경에서는 이 코드를 배포하지 마라.
# 배포 순서: Step1 alembic upgrade → backfill CLI → Step2 alembic upgrade → 코드 배포.
cur.execute(
"""
INSERT INTO broker_credentials (daemon_id, user_id, broker, account_ref, encrypted_payload)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (daemon_id, broker) DO UPDATE
SET account_ref = EXCLUDED.account_ref,
encrypted_payload = EXCLUDED.encrypted_payload,
user_id = EXCLUDED.user_id,
credential_version = broker_credentials.credential_version + 1,
last_rotated_at = NOW()
RETURNING daemon_id, user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at
""",
(daemon_id, user_id, broker, account_ref, encrypted_payload),
)
row = cur.fetchone()
return self._row_to_broker_credential(row)
def list_broker_credentials(
self,
user_id: str,
daemon_id: str | None = None,
) -> list[BrokerCredential]:
self.ensure_schema()
# NOTE(REQ-047 Step2 / BAT-021): daemon_id 컬럼은 Step2 이후 항상 존재(NOT NULL).
# Step2 미적용 환경에서는 이 코드를 배포하지 마라.
with self._connect().cursor() as cur:
if daemon_id is not None:
cur.execute(
"""
SELECT daemon_id, user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at
FROM broker_credentials
WHERE user_id = %s AND daemon_id = %s
ORDER BY broker ASC
""",
(user_id, daemon_id),
)
else:
cur.execute(
"""
SELECT daemon_id, 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 update_broker_credential_daemon_id(
self,
user_id: str,
broker: str,
daemon_id: str,
) -> bool:
"""백필용: 특정 (user_id, broker) 인증정보에 daemon_id를 설정한다 (REQ-048)."""
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
UPDATE broker_credentials
SET daemon_id = %s
WHERE user_id = %s AND broker = %s AND daemon_id IS NULL
RETURNING user_id
""",
(daemon_id, user_id, broker),
)
row = cur.fetchone()
return row is not None
def delete_broker_credential(
self,
user_id: str,
broker: str,
daemon_id: str | None = None,
) -> bool:
self.ensure_schema()
with self._connect().cursor() as cur:
if daemon_id is not None:
cur.execute(
"DELETE FROM broker_credentials WHERE daemon_id = %s AND broker = %s RETURNING user_id",
(daemon_id, broker),
)
else:
cur.execute(
"DELETE FROM broker_credentials WHERE user_id = %s AND broker = %s RETURNING user_id",
(user_id, broker),
)
row = cur.fetchone()
return row is not None
def create_auth_token(
self,
user_id: str,
*,
name: str,
token_hash: str,
token_prefix: str,
permission: str,
token_type: str,
expires_at: datetime | None = None,
runtime_id: str | None = None,
daemon_id: str | None = None,
) -> AuthToken:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute("SELECT 1 FROM users WHERE id = %s", (user_id,))
if cur.fetchone() is None:
raise ValueError("사용자를 찾을 수 없습니다")
token_id = str(uuid.uuid4())
cur.execute(
"""
INSERT INTO auth_tokens (
id, user_id, name, token_prefix, token_hash, permission, token_type,
expires_at, runtime_id, daemon_id
)
VALUES (%s, %s, %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, daemon_id
""",
(token_id, user_id, name, token_prefix, token_hash, permission, token_type,
expires_at, runtime_id, daemon_id),
)
row = cur.fetchone()
return self._row_to_auth_token(row)
def list_auth_tokens(self, user_id: str) -> list[AuthToken]:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
_SELECT_AUTH_TOKEN + "WHERE user_id = %s ORDER BY created_at DESC, id ASC",
(user_id,),
)
rows = cur.fetchall()
return [self._row_to_auth_token(row) for row in rows]
def list_service_tokens(
self,
user_id: str | None = None,
*,
include_revoked: bool = False,
) -> list[AuthToken]:
self.ensure_schema()
conditions = ["token_type = 'service'"]
params: list = []
if user_id is not None:
conditions.append("user_id = %s")
params.append(user_id)
if not include_revoked:
conditions.append("revoked_at IS NULL")
where = "WHERE " + " AND ".join(conditions) + " "
with self._connect().cursor() as cur:
cur.execute(_SELECT_AUTH_TOKEN + where + "ORDER BY created_at DESC, id ASC", params)
rows = cur.fetchall()
return [self._row_to_auth_token(row) for row in rows]
def revoke_auth_token(self, user_id: str, token_id: str) -> None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"UPDATE auth_tokens SET revoked_at = NOW() WHERE id = %s AND user_id = %s RETURNING id",
(token_id, user_id),
)
row = cur.fetchone()
if row is None:
raise ValueError("토큰을 찾을 수 없습니다")
def get_service_token_by_name(self, name: str) -> AuthToken | None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
_SELECT_AUTH_TOKEN + "WHERE name = %s AND token_type = 'service' AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1",
(name,),
)
row = cur.fetchone()
if row is None:
return None
return self._row_to_auth_token(row)
def get_token_principal(self, token_hash: str) -> TokenPrincipal | None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT t.id, t.user_id, t.permission, t.token_type, t.runtime_id, t.daemon_id
FROM auth_tokens t
WHERE token_hash = %s
AND t.revoked_at IS NULL
AND (t.expires_at IS NULL OR t.expires_at > NOW())
""",
(token_hash,),
)
row = cur.fetchone()
if row is None:
return None
cur.execute("UPDATE auth_tokens SET last_used_at = NOW() WHERE id = %s", (row[0],))
return TokenPrincipal(
token_id=row[0],
user_id=row[1],
permission=row[2],
token_type=row[3],
runtime_id=row[4] if len(row) > 4 else None,
daemon_id=row[5] if len(row) > 5 else None,
)
def get(self, portfolio_id: str) -> Portfolio:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(_SELECT_PORTFOLIO + "WHERE p.portfolio_id = %s", (portfolio_id,))
row = cur.fetchone()
if row is None:
raise ValueError("포트폴리오를 찾을 수 없습니다")
portfolio = self._row_to_portfolio(row)
self._attach_tags(cur, [portfolio])
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_PORTFOLIO + "WHERE p.owner_user_id = %s AND p.name = %s",
(owner_user_id, name),
)
row = cur.fetchone()
if row is None:
return None
portfolio = self._row_to_portfolio(row)
self._attach_tags(cur, [portfolio])
return portfolio
def get_or_create(
self,
portfolio_id: str,
symbol: str,
*,
name: str | None = None,
broker: str = "kis",
status: str = "enabled",
active_strategy_id: str | None = None,
avabile_strategies: list[str] | None = None,
owner_user_id: str | None = None,
initial_cash: float = 0.0,
) -> Portfolio:
self.ensure_schema()
conn = self._connect()
with conn.transaction(), conn.cursor() as cur:
if active_strategy_id is not None:
self._lock_active_strategy(cur, active_strategy_id)
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, 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)
ON CONFLICT (portfolio_id) DO NOTHING
""",
(
portfolio_id,
broker,
status,
name or symbol,
asset_id,
owner_user_id,
float(initial_cash),
0.0,
0.0,
0.0,
active_strategy_id,
avabile_strategies,
),
)
cur.execute(_SELECT_PORTFOLIO + "WHERE p.portfolio_id = %s", (portfolio_id,))
row = cur.fetchone()
return self._row_to_portfolio(row)
def create_portfolio(
self,
portfolio_id: str,
*,
owner_user_id: str,
broker: str,
status: str,
name: str,
asset_id: str,
cash_balance: float,
asset_quantity: float,
asset_price: float,
profit_buffer: float,
active_strategy_id: str | None,
avabile_strategies: list[str] | None,
managed_by_daemon_id: str | None = None,
) -> Portfolio:
self.ensure_schema()
conn = self._connect()
with conn.transaction(), conn.cursor() as cur:
if active_strategy_id is not None:
self._lock_active_strategy(cur, active_strategy_id)
if managed_by_daemon_id is not None:
self._lock_active_daemon(cur, managed_by_daemon_id, owner_user_id)
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, asset_id,
owner_user_id, cash_balance, asset_quantity, asset_price, profit_buffer,
active_strategy_id, avabile_strategies, managed_by_daemon_id
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
portfolio_id,
broker,
status,
name,
asset_id,
owner_user_id,
float(cash_balance),
float(asset_quantity),
float(asset_price),
float(profit_buffer),
active_strategy_id,
avabile_strategies,
managed_by_daemon_id,
),
)
portfolio = self.get(portfolio_id)
portfolio.tags = []
return portfolio
def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio:
self.ensure_schema()
allowed = {
"broker": "broker",
"status": "status",
"name": "name",
"asset_price": "asset_price",
"active_strategy_id": "active_strategy_id",
"avabile_strategies": "avabile_strategies",
"managed_by_daemon_id": "managed_by_daemon_id",
}
assignments: list[str] = []
params: list[object] = []
for key, value in fields.items():
if value is None or key not in allowed:
continue
if key == "avabile_strategies":
assignments.append("avabile_strategies = %s")
params.append(value)
else:
assignments.append(f"{allowed[key]} = %s")
params.append(value)
if not assignments:
return self.get(portfolio_id)
assignments.append("updated_at = NOW()")
conn = self._connect()
with conn.transaction(), conn.cursor() as cur:
strategy_id = fields.get("active_strategy_id")
if isinstance(strategy_id, str):
self._lock_active_strategy(cur, strategy_id)
daemon_id = fields.get("managed_by_daemon_id")
if isinstance(daemon_id, str):
cur.execute(
"SELECT owner_user_id FROM portfolios WHERE portfolio_id = %s",
(portfolio_id,),
)
owner_row = cur.fetchone()
if owner_row is None:
raise ValueError(f"portfolio를 찾을 수 없습니다: {portfolio_id}")
self._lock_active_daemon(cur, daemon_id, owner_row[0])
cur.execute(
f"""
UPDATE portfolios
SET {', '.join(assignments)}
WHERE portfolio_id = %s
""",
(*params, portfolio_id),
)
return self.get(portfolio_id)
def list_portfolios(
self,
*,
broker: str | None = None,
status: str | None = None,
owner_user_id: str | None = None,
include_tags: list[str] | None = None,
exclude_tags: list[str] | None = None,
match_mode: str | None = None,
managed_by_daemon_id: str | None = None,
market: str | None = None,
) -> list[Portfolio]:
self.ensure_schema()
query = _SELECT_PORTFOLIO
clauses: list[str] = []
params: list[object] = []
if broker is not None:
clauses.append("p.broker = %s")
params.append(broker)
if status is not None:
clauses.append("p.status = %s")
params.append(status)
if owner_user_id is not None:
clauses.append("p.owner_user_id = %s")
params.append(owner_user_id)
if managed_by_daemon_id is not None:
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(
"""
p.portfolio_id IN (
SELECT portfolio_id
FROM portfolio_tags
WHERE tag_value = ANY(%s)
GROUP BY portfolio_id
HAVING COUNT(DISTINCT tag_value) = %s
)
"""
)
params.append(include_tags)
params.append(len(set(include_tags)))
else:
clauses.append(
"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(
"p.portfolio_id NOT IN (SELECT DISTINCT portfolio_id FROM portfolio_tags WHERE tag_value = ANY(%s))"
)
params.append(exclude_tags)
if market is not None:
clauses.append("%s = ANY(a.markets)")
params.append(market)
if clauses:
query += "WHERE " + " AND ".join(clauses) + " "
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()
portfolios = [self._row_to_portfolio(row) for row in rows]
self._attach_tags(cur, portfolios)
return portfolios
def add_tag(self, portfolio_id: str, *, tag: str) -> PortfolioTag:
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 None:
raise ValueError("포트폴리오를 찾을 수 없습니다")
try:
cur.execute(
"""
INSERT INTO portfolio_tags (portfolio_id, tag_value)
VALUES (%s, %s)
RETURNING portfolio_id, tag_value, created_at
""",
(portfolio_id, tag),
)
except psycopg.errors.UniqueViolation as exc:
raise ValueError(f"이미 연결된 태그입니다: {tag}") from exc
row = cur.fetchone()
return self._row_to_portfolio_tag(row)
def list_tags(self, portfolio_id: str) -> list[PortfolioTag]:
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 None:
raise ValueError("포트폴리오를 찾을 수 없습니다")
cur.execute(
"""
SELECT portfolio_id, tag_value, created_at
FROM portfolio_tags
WHERE portfolio_id = %s
ORDER BY tag_value ASC
""",
(portfolio_id,),
)
rows = cur.fetchall()
return [self._row_to_portfolio_tag(row) for row in rows]
def remove_tag(self, portfolio_id: str, *, tag: str) -> 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 None:
raise ValueError("포트폴리오를 찾을 수 없습니다")
cur.execute(
"DELETE FROM portfolio_tags WHERE portfolio_id = %s AND tag_value = %s RETURNING portfolio_id",
(portfolio_id, tag),
)
if cur.fetchone() is None:
raise ValueError("태그를 찾을 수 없습니다")
def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet:
return self.create_strategy_set(name, config)
def create_strategy_set(
self,
name: str,
config: str,
*,
nick: str | None = None,
status: str = "active",
) -> StrategySet:
self.ensure_schema()
final_nick = nick or f"{name}:{hashlib.sha1(config.encode('utf-8')).hexdigest()[:12]}"
try:
with self._connect().cursor() as cur:
new_id = str(uuid.uuid4())
cur.execute(
"""
INSERT INTO strategy_sets (id, name, config, nick, status)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (name, config) DO NOTHING
""",
(new_id, name, config, final_nick, status),
)
cur.execute(
"""
SELECT id, name, config, nick, status, revision, created_at, updated_at
FROM strategy_sets
WHERE name = %s AND config = %s
""",
(name, config),
)
row = cur.fetchone()
except psycopg.errors.UniqueViolation as exc:
raise ValueError("동일한 nick 전략이 이미 존재합니다") from exc
return self._row_to_strategy_set(row)
def get_strategy_set(self, id: str) -> StrategySet | None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"SELECT id, name, config, nick, status, revision, created_at, updated_at FROM strategy_sets WHERE id = %s",
(id,),
)
row = cur.fetchone()
if row is None:
return None
return self._row_to_strategy_set(row)
def list_strategy_sets(self, *, include_inactive: bool = True) -> list[StrategySet]:
self.ensure_schema()
with self._connect().cursor() as cur:
where = "" if include_inactive else "WHERE status = 'active'"
cur.execute(f"""
SELECT id, name, config, nick, status, revision, created_at, updated_at
FROM strategy_sets
{where}
ORDER BY created_at DESC, id ASC
""")
rows = cur.fetchall()
return [self._row_to_strategy_set(row) for row in rows]
def update_strategy_set(
self,
id: str,
*,
name: str | None = None,
nick: str | None = None,
config: str | None = None,
status: str | None = None,
) -> StrategySet:
self.ensure_schema()
conn = self._connect()
try:
with conn.transaction(), conn.cursor() as cur:
cur.execute(
"""
SELECT id, name, config, nick, status, revision, created_at, updated_at
FROM strategy_sets
WHERE id = %s
FOR UPDATE
""",
(id,),
)
row = cur.fetchone()
if row is None:
raise ValueError("전략을 찾을 수 없습니다")
current = self._row_to_strategy_set(row)
final_name = name if name is not None else current.name
final_nick = nick if nick is not None else current.nick
final_config = config if config is not None else current.config
final_status = status if status is not None else current.status
if (
final_name == current.name
and final_nick == current.nick
and final_config == current.config
and final_status == current.status
):
return current
cur.execute(
"""
UPDATE strategy_sets
SET name = %s,
config = %s,
nick = %s,
status = %s,
revision = revision + 1,
updated_at = NOW()
WHERE id = %s
RETURNING id, name, config, nick, status, revision, created_at, updated_at
""",
(final_name, final_config, final_nick, final_status, id),
)
updated = cur.fetchone()
except psycopg.errors.UniqueViolation as exc:
raise ValueError("동일한 name/config 또는 nick 전략이 이미 존재합니다") from exc
if updated is None:
raise ValueError("전략을 찾을 수 없습니다")
return self._row_to_strategy_set(updated)
def delete_strategy_set(self, id: str) -> None:
self.ensure_schema()
conn = self._connect()
with conn.transaction(), conn.cursor() as cur:
cur.execute("SELECT id FROM strategy_sets WHERE id = %s FOR UPDATE", (id,))
if cur.fetchone() is None:
raise ValueError("전략을 찾을 수 없습니다")
cur.execute(
"""
SELECT COUNT(*) FROM portfolios
WHERE active_strategy_id = %s
OR avabile_strategies @> ARRAY[%s]
""",
(id, id),
)
count = cur.fetchone()[0]
if count > 0:
raise ValueError(f"Strategy set is in use by {count} portfolio(s) and cannot be deleted")
cur.execute("DELETE FROM strategy_sets WHERE id = %s RETURNING id", (id,))
if cur.fetchone() is None:
raise ValueError("전략을 찾을 수 없습니다")
def save(
self,
portfolio: Portfolio,
*,
current_price: float,
action: str,
trade_price: float,
quantity: float,
fee_amount: float,
tax_amount: float,
total_value_after: float,
strategy_set_id: str = "manual",
note: str = "",
) -> None:
self.ensure_schema()
trade_time = datetime.now(UTC)
conn = self._connect()
with conn.transaction(), conn.cursor() as cur:
cur.execute(
"""
UPDATE portfolios
SET cash_balance = %s,
asset_quantity = %s,
asset_price = %s,
profit_buffer = %s,
updated_at = %s,
last_trade_at = CASE WHEN %s IN ('BUY', 'SELL') THEN %s ELSE last_trade_at END
WHERE portfolio_id = %s
""",
(
float(portfolio.cash_balance),
float(portfolio.asset_quantity),
float(current_price),
float(portfolio.profit_buffer),
trade_time,
action,
trade_time,
portfolio.portfolio_id,
),
)
if action in {"BUY", "SELL"}:
cur.execute(
"""
INSERT INTO portfolio_ledger (
portfolio_id, recorded_at, side, price, quantity, fee_amount,
tax_amount, cash_balance_after, asset_quantity_after,
total_value_after, profit_buffer_after, strategy_set_id, note, executed_at
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
portfolio.portfolio_id,
trade_time,
str(action).lower(),
float(trade_price),
float(quantity),
float(fee_amount),
float(tax_amount),
float(portfolio.cash_balance),
float(portfolio.asset_quantity),
float(total_value_after),
float(portfolio.profit_buffer),
strategy_set_id,
note,
trade_time,
),
)
def record_corporate_action(self, portfolio_id: str, *, action: CorporateAction, idempotency_key: str, payload_hash: str) -> CorporateAction:
"""Persist an immutable action and snapshot atomically."""
self.ensure_schema()
with self._connect() as conn: # noqa: SIM117 - keep transaction and cursor lifetimes explicit
with conn.cursor() as cur:
cur.execute("SELECT asset_quantity, cash_balance, profit_buffer, asset_id, status FROM portfolios WHERE portfolio_id=%s FOR UPDATE", (portfolio_id,))
row = cur.fetchone()
if row is None:
raise ValueError("portfolio not found")
cur.execute("SELECT corporate_action_id, portfolio_id, asset_id, action_type, effective_date, actor_user_id, evidence_ref, idempotency_key, payload_hash, quantity_before, quantity_after, average_price_before, average_price_after, total_cost_basis, cash_balance, profit_buffer, ledger_entry_id, recorded_at FROM portfolio_corporate_actions WHERE portfolio_id=%s AND idempotency_key=%s", (portfolio_id, idempotency_key))
prior = cur.fetchone()
if prior:
if prior[8] != payload_hash:
raise ValueError("IDEMPOTENCY_KEY_REUSE")
(action.corporate_action_id, action.portfolio_id, action.asset_id, action.action_type,
action.effective_date, action.actor_user_id, action.evidence_ref,
action.idempotency_key, action.payload_hash, action.quantity_before,
action.quantity_after, action.average_price_before, action.average_price_after,
action.total_cost_basis, action.cash_balance, action.profit_buffer,
ledger_id, action.recorded_at) = prior
action.corporate_action_id = str(action.corporate_action_id)
action.ledger_entry_id = int(ledger_id)
action.idempotent_replay = True
return action
if row[4] != "enabled":
raise ValueError("CORPORATE_ACTION_PORTFOLIO_DISABLED")
if str(row[3]) != str(action.asset_id):
raise ValueError("CORPORATE_ACTION_ASSET_MISMATCH")
if Decimal(str(row[1])) != action.cash_balance or Decimal(str(row[2])) != action.profit_buffer:
raise ValueError("CORPORATE_ACTION_INVARIANT_MISMATCH")
cur.execute("SELECT 1 FROM portfolio_ledger WHERE portfolio_id=%s AND side IN ('buy','sell') AND executed_at::date >= %s LIMIT 1", (portfolio_id, action.effective_date))
if cur.fetchone() is not None:
raise ValueError("CORPORATE_ACTION_HISTORICAL_CONFLICT")
# Derive WAC from the authoritative locked ledger, never portfolios.price.
cur.execute("SELECT side, quantity, price, fee_amount, tax_amount, executed_at FROM portfolio_ledger WHERE portfolio_id=%s ORDER BY executed_at ASC NULLS FIRST, ledger_entry_id ASC", (portfolio_id,))
history = [{"side": r[0], "quantity": r[1], "price": r[2], "fee_amount": r[3], "tax_amount": r[4], "executed_at": r[5]} for r in cur.fetchall()]
from app.domain.pnl import calculate_current_wac
wac = calculate_current_wac(history)
if Decimal(str(row[0])) != action.quantity_before or Decimal(str(wac.current_quantity)) != action.quantity_before:
raise ValueError("CORPORATE_ACTION_STALE_QUANTITY")
consolidation = calculate_stock_consolidation(
action.quantity_before,
action.quantity_after,
Decimal(str(wac.current_wac or 0)),
)
action.average_price_before = consolidation.average_price_before
action.average_price_after = consolidation.average_price_after
action.total_cost_basis = consolidation.total_cost_basis
cur.execute("SELECT corporate_action_id, portfolio_id, asset_id, action_type, effective_date, actor_user_id, evidence_ref, idempotency_key, payload_hash, quantity_before, quantity_after, average_price_before, average_price_after, total_cost_basis, cash_balance, profit_buffer, ledger_entry_id, recorded_at FROM portfolio_corporate_actions WHERE portfolio_id=%s AND idempotency_key=%s", (portfolio_id, idempotency_key))
prior = cur.fetchone()
if prior:
if prior[8] != payload_hash:
raise ValueError("IDEMPOTENCY_KEY_REUSE")
(action.corporate_action_id, action.portfolio_id, action.asset_id, action.action_type,
action.effective_date, action.actor_user_id, action.evidence_ref,
action.idempotency_key, action.payload_hash, action.quantity_before,
action.quantity_after, action.average_price_before, action.average_price_after,
action.total_cost_basis, action.cash_balance, action.profit_buffer,
ledger_id, action.recorded_at) = prior
action.corporate_action_id = str(action.corporate_action_id)
action.ledger_entry_id = int(ledger_id)
action.idempotent_replay = True
return action
if Decimal(str(row[0])) != action.quantity_before:
raise ValueError("CORPORATE_ACTION_STALE_QUANTITY")
cur.execute("UPDATE portfolios SET asset_quantity=%s, updated_at=NOW() WHERE portfolio_id=%s", (float(action.quantity_after), portfolio_id))
cur.execute("INSERT INTO portfolio_ledger (portfolio_id, recorded_at, side, source, quantity, price, cash_balance_after, asset_quantity_after, profit_buffer_after, executed_at, note) VALUES (%s,NOW(),'stock_consolidation','corporate_action',0,0,%s,%s,%s,NOW(),%s) RETURNING ledger_entry_id, recorded_at", (portfolio_id, float(action.cash_balance), float(action.quantity_after), float(action.profit_buffer), action.evidence_ref))
lid, recorded = cur.fetchone()
action.ledger_entry_id, action.recorded_at = int(lid), recorded
cur.execute("INSERT INTO portfolio_corporate_actions (corporate_action_id,portfolio_id,asset_id,action_type,effective_date,actor_user_id,evidence_ref,idempotency_key,payload_hash,quantity_before,quantity_after,average_price_before,average_price_after,total_cost_basis,cash_balance,profit_buffer,ledger_entry_id) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", (action.corporate_action_id,portfolio_id,action.asset_id,action.action_type,action.effective_date,action.actor_user_id,action.evidence_ref,idempotency_key,payload_hash,action.quantity_before,action.quantity_after,action.average_price_before,action.average_price_after,action.total_cost_basis,action.cash_balance,action.profit_buffer,lid))
return action
def list_recent_trades(self, portfolio_id: str, limit: int = 5) -> list[LedgerEntry]:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT
ledger_entry_id, portfolio_id, order_id, execution_id, side,
quantity::float8, price::float8,
fee_amount::float8, tax_amount::float8,
executed_at, recorded_at,
cash_balance_after::float8, asset_quantity_after::float8,
total_value_after::float8, profit_buffer_after::float8,
strategy_set_id, note
FROM portfolio_ledger
WHERE portfolio_id = %s AND side IN ('buy', 'sell')
ORDER BY recorded_at DESC, ledger_entry_id DESC
LIMIT %s
""",
(portfolio_id, int(limit)),
)
rows = cur.fetchall()
return [self._row_to_ledger_entry(row) for row in rows]
def list_trades_by_date(
self,
portfolio_id: str,
*,
date: datetime | None = None,
from_: datetime | None = None,
to_: datetime | None = None,
) -> list[LedgerEntry]:
"""Return ledger entries for portfolio_id filtered by date range (REQ-039).
Either `date` (single day) or `from_`/`to_` (range, both inclusive) must be
provided. The caller is responsible for converting date strings to datetime
boundaries (start of day / start of next day).
"""
self.ensure_schema()
clauses = ["portfolio_id = %s", "side IN ('buy','sell')"]
params: list[object] = [portfolio_id]
if date is not None:
# Single day: [date 00:00:00, date+1day 00:00:00)
clauses.append("executed_at >= %s")
params.append(date)
import datetime as _dt
next_day = date + _dt.timedelta(days=1)
clauses.append("executed_at < %s")
params.append(next_day)
else:
if from_ is not None:
clauses.append("executed_at >= %s")
params.append(from_)
if to_ is not None:
import datetime as _dt
# to_ is inclusive — extend to end of that day
next_day = to_ + _dt.timedelta(days=1)
clauses.append("executed_at < %s")
params.append(next_day)
where = "WHERE " + " AND ".join(clauses)
with self._connect().cursor() as cur:
cur.execute(
f"""
SELECT
ledger_entry_id, portfolio_id, order_id, execution_id, side,
quantity::float8, price::float8,
fee_amount::float8, tax_amount::float8,
executed_at, recorded_at,
cash_balance_after::float8, asset_quantity_after::float8,
total_value_after::float8, profit_buffer_after::float8,
strategy_set_id, note
FROM portfolio_ledger
{where}
ORDER BY executed_at ASC, ledger_entry_id ASC
""",
tuple(params),
)
rows = cur.fetchall()
return [self._row_to_ledger_entry(row) for row in rows]
def get_all_trades_for_pnl(
self,
portfolio_id: str,
*,
symbol: str | None = None,
) -> list[LedgerEntry]:
"""Return all buy/sell ledger entries for portfolio_id for WAC PnL calculation (REQ-040/041).
Returns ALL buy/sell history (not bounded by from_/to_) so that the WAC
calculation is accurate. The caller (domain layer) applies the date filter
to sell events when collecting realized PnL items.
portfolio_ledger does not store symbol per row — symbol filtering is handled
at the portfolio level (one portfolio = one symbol). The `symbol` parameter
is forwarded to the domain layer via the returned rows; it is not used in SQL.
"""
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT
ledger_entry_id, portfolio_id, order_id, execution_id, side,
quantity::float8, price::float8,
fee_amount::float8, tax_amount::float8,
executed_at, recorded_at,
cash_balance_after::float8, asset_quantity_after::float8,
total_value_after::float8, profit_buffer_after::float8,
strategy_set_id, note, source,
ca.corporate_action_id, ca.action_type, ca.effective_date,
ca.quantity_before, ca.quantity_after, ca.average_price_before,
ca.average_price_after, ca.total_cost_basis, ca.cash_balance,
ca.profit_buffer, ca.evidence_ref, ca.actor_user_id,
ca.idempotency_key, ca.payload_hash, ca.ledger_entry_id, ca.recorded_at
FROM portfolio_ledger AS ledger
LEFT JOIN portfolio_corporate_actions ca ON ca.ledger_entry_id = ledger.ledger_entry_id
WHERE ledger.portfolio_id = %s
ORDER BY ledger.executed_at ASC, ledger.ledger_entry_id ASC
""",
(portfolio_id,),
)
rows = cur.fetchall()
entries = []
for row in rows:
entry = self._row_to_ledger_entry(row)
if len(row) > 18 and row[18] is not None:
entry.corporate_action = {
"corporate_action_id": str(row[18]), "action_type": row[19], "effective_date": row[20],
"quantity_before": Decimal(str(row[21])), "quantity_after": Decimal(str(row[22])),
"average_price_before": Decimal(str(row[23])), "average_price_after": Decimal(str(row[24])),
"total_cost_basis": Decimal(str(row[25])), "cash_balance": Decimal(str(row[26])),
"profit_buffer": Decimal(str(row[27])), "evidence_ref": row[28], "actor_user_id": row[29],
"idempotency_key": row[30], "payload_hash": row[31], "ledger_entry_id": int(row[32]),
"recorded_at": row[33],
}
entries.append(entry)
return entries
def insert_ledger_trade(
self,
*,
portfolio_id: str,
order_id: str,
execution_id: str,
side: str,
quantity: float,
price: float,
executed_at,
fee_amount: float = 0.0,
tax_amount: float = 0.0,
) -> tuple[LedgerEntry, bool]:
self.ensure_schema()
side_normalized = str(side).lower()
if side_normalized not in {"buy", "sell"}:
raise ValueError("side는 buy 또는 sell 이어야 합니다")
conn = self._connect()
# Use a transaction block (not `with conn:`) so the shared connection stays open:
# psycopg3 closes the connection when it is used as a context manager, which would
# leave the repository's reused connection unusable for subsequent calls.
with conn.transaction():
with conn.cursor() as cur:
cur.execute(
"""
SELECT
portfolio_id, cash_balance, asset_quantity, asset_price, profit_buffer,
active_strategy_id
FROM portfolios
WHERE portfolio_id = %s
FOR UPDATE
""",
(portfolio_id,),
)
portfolio_row = cur.fetchone()
if portfolio_row is None:
raise ValueError("포트폴리오를 찾을 수 없습니다")
cash_balance = float(portfolio_row[1])
asset_quantity = float(portfolio_row[2])
price_float = float(price)
qty_float = float(quantity)
fee_float = float(fee_amount)
tax_float = float(tax_amount)
if side_normalized == "buy":
cash_balance_after = cash_balance - (qty_float * price_float + fee_float + tax_float)
asset_quantity_after = asset_quantity + qty_float
else:
cash_balance_after = cash_balance + (qty_float * price_float - fee_float - tax_float)
asset_quantity_after = asset_quantity - qty_float
total_value_after = cash_balance_after + asset_quantity_after * price_float
strategy_set_id = portfolio_row[5] or "manual"
# Application-level idempotency under the portfolios row lock.
# portfolio_ledger is a TimescaleDB hypertable, so a DB-level partial unique
# index on (portfolio_id, order_id, execution_id) is impossible (a unique index
# must include the partitioning column recorded_at). The `SELECT ... FOR UPDATE`
# on the portfolios row above serializes concurrent writes for the same portfolio,
# so this check-then-insert is race-safe for broker writes (order_id present).
if order_id is not None and execution_id is not None:
cur.execute(
"""
SELECT
ledger_entry_id, portfolio_id, order_id, execution_id, side,
quantity::float8, price::float8,
fee_amount::float8, tax_amount::float8,
executed_at, recorded_at,
cash_balance_after::float8, asset_quantity_after::float8,
total_value_after::float8, profit_buffer_after::float8,
strategy_set_id, note
FROM portfolio_ledger
WHERE portfolio_id = %s AND order_id = %s AND execution_id = %s
ORDER BY recorded_at DESC, ledger_entry_id DESC
LIMIT 1
""",
(portfolio_id, order_id, execution_id),
)
existing = cur.fetchone()
if existing is not None:
# Duplicate: do not insert a new row, do not mutate the portfolio.
# OQ-4 (b): return the CURRENT authoritative portfolio state so the
# caller always syncs to the latest balances, not the first-call snapshot.
entry = self._row_to_ledger_entry(existing)
entry.cash_balance_after = cash_balance
entry.asset_quantity_after = asset_quantity
return entry, False
cur.execute(
"""
INSERT INTO portfolio_ledger (
portfolio_id, recorded_at, ledger_entry_id, order_id, execution_id,
side, price, quantity, fee_amount, tax_amount,
cash_balance_after, asset_quantity_after, total_value_after,
profit_buffer_after, strategy_set_id, note, executed_at
) VALUES (
%s, NOW(), DEFAULT, %s, %s,
%s, %s, %s, %s, %s,
%s, %s, %s,
%s, %s, %s, %s
)
RETURNING
ledger_entry_id, portfolio_id, order_id, execution_id, side,
quantity::float8, price::float8,
fee_amount::float8, tax_amount::float8,
executed_at, recorded_at,
cash_balance_after::float8, asset_quantity_after::float8,
total_value_after::float8, profit_buffer_after::float8,
strategy_set_id, note
""",
(
portfolio_id,
order_id,
execution_id,
side_normalized,
price_float,
qty_float,
fee_float,
tax_float,
cash_balance_after,
asset_quantity_after,
total_value_after,
float(portfolio_row[4]),
strategy_set_id,
"",
executed_at,
),
)
row = cur.fetchone()
cur.execute(
"""
UPDATE portfolios
SET cash_balance = %s,
asset_quantity = %s,
asset_price = %s,
updated_at = NOW(),
last_trade_at = %s
WHERE portfolio_id = %s
""",
(cash_balance_after, asset_quantity_after, price_float, executed_at, portfolio_id),
)
return self._row_to_ledger_entry(row), True
@staticmethod
def _row_to_ledger_entry(row: Any) -> LedgerEntry:
if row is None:
raise ValueError("원장 항목을 찾을 수 없습니다")
raw_price = row[6]
raw_tax = row[8]
return LedgerEntry(
ledger_entry_id=int(row[0]),
portfolio_id=row[1],
order_id=row[2],
execution_id=row[3],
side=row[4],
quantity=float(row[5]),
price=float(raw_price),
price_decimal=Decimal(str(raw_price)) if raw_price is not None else None,
fee_amount=float(row[7]),
tax_amount=float(raw_tax),
tax_amount_decimal=Decimal(str(raw_tax)) if raw_tax is not None else None,
executed_at=row[9],
recorded_at=row[10],
cash_balance_after=float(row[11]),
asset_quantity_after=float(row[12]),
total_value_after=float(row[13]),
profit_buffer_after=float(row[14]),
strategy_set_id=row[15] or "legacy",
note=row[16] or "",
source=row[17] if len(row) > 17 and row[17] else "broker",
target_ledger_entry_id=int(row[18]) if len(row) > 18 and row[18] is not None else None,
confirmation_evidence=row[19] if len(row) > 19 else None,
actor_user_id=row[20] if len(row) > 20 else None,
)
def reset(self, portfolio_id: str) -> Portfolio:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
UPDATE portfolios
SET cash_balance = 0,
asset_quantity = 0,
asset_price = 0,
profit_buffer = 0,
active_strategy_id = NULL,
updated_at = NOW(),
last_trade_at = NULL
WHERE portfolio_id = %s
""",
(portfolio_id,),
)
if cur.rowcount == 0:
raise ValueError("포트폴리오를 찾을 수 없습니다")
cur.execute("DELETE FROM portfolio_ledger WHERE portfolio_id = %s", (portfolio_id,))
return self.get(portfolio_id)
def adjust_buffer(self, portfolio_id: str, amount: float, action: str, note: str = "") -> Portfolio:
self.ensure_schema()
normalized = str(action).upper()
if normalized not in {"INVEST", "PROFIT", "WITHDRAW"}:
raise ValueError("action은 INVEST, PROFIT, WITHDRAW 중 하나여야 합니다")
if amount <= 0:
raise ValueError("amount는 0보다 커야 합니다")
with self._connect().cursor() as cur:
cur.execute(_SELECT_PORTFOLIO + "WHERE p.portfolio_id = %s", (portfolio_id,))
row = cur.fetchone()
if row is None:
raise ValueError("포트폴리오를 찾을 수 없습니다")
portfolio = self._row_to_portfolio(row)
updated_buffer = float(portfolio.profit_buffer)
if normalized == "INVEST":
updated_buffer += float(amount)
else:
updated_buffer -= float(amount)
if updated_buffer < 0:
raise ValueError("수익버퍼 잔액이 부족합니다")
cur.execute(
"""
UPDATE portfolios
SET profit_buffer = %s,
updated_at = NOW()
WHERE portfolio_id = %s
""",
(updated_buffer, portfolio_id),
)
total_value = float(portfolio.cash_balance) + float(portfolio.asset_quantity) * float(portfolio.price)
cur.execute(
"""
INSERT INTO portfolio_ledger (
portfolio_id, recorded_at, side, price, quantity, fee_amount,
tax_amount, cash_balance_after, asset_quantity_after,
total_value_after, profit_buffer_after, strategy_set_id, note, executed_at
) VALUES (%s, NOW(), %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
""",
(
portfolio_id,
normalized.lower(),
0.0,
0.0,
0.0,
0.0,
float(portfolio.cash_balance),
float(portfolio.asset_quantity),
float(total_value),
float(updated_buffer),
"manual",
note,
),
)
return self.get(portfolio_id)
def transfer_buffer(self, portfolio_id: str, amount: float, direction: str, note: str = "") -> Portfolio:
self.ensure_schema()
normalized = str(direction).lower()
if normalized not in {"to-buffer", "from-buffer"}:
raise ValueError("direction은 to-buffer 또는 from-buffer 여야 합니다")
if amount <= 0:
raise ValueError("amount는 0보다 커야 합니다")
with self._connect().cursor() as cur:
cur.execute(_SELECT_PORTFOLIO + "WHERE p.portfolio_id = %s", (portfolio_id,))
row = cur.fetchone()
if row is None:
raise ValueError("포트폴리오를 찾을 수 없습니다")
portfolio = self._row_to_portfolio(row)
cash = float(portfolio.cash_balance)
buf = float(portfolio.profit_buffer)
if normalized == "to-buffer":
if cash < float(amount):
raise ValueError("이체 금액이 현금 잔고를 초과합니다")
cash -= float(amount)
buf += float(amount)
ledger_action = "BUFFER_IN"
else:
if buf < float(amount):
raise ValueError("이체 금액이 수익버퍼 잔고를 초과합니다")
buf -= float(amount)
cash += float(amount)
ledger_action = "BUFFER_OUT"
cur.execute(
"""
UPDATE portfolios
SET cash_balance = %s,
profit_buffer = %s,
updated_at = NOW()
WHERE portfolio_id = %s
""",
(cash, buf, portfolio_id),
)
total_value = float(cash) + float(portfolio.asset_quantity) * float(portfolio.price)
cur.execute(
"""
INSERT INTO portfolio_ledger (
portfolio_id, recorded_at, side, price, quantity, fee_amount,
tax_amount, cash_balance_after, asset_quantity_after,
total_value_after, profit_buffer_after, strategy_set_id, note, executed_at
) VALUES (%s, NOW(), %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
""",
(
portfolio_id,
ledger_action.lower(),
0.0,
0.0,
0.0,
0.0,
float(cash),
float(portfolio.asset_quantity),
float(total_value),
float(buf),
"manual",
note,
),
)
return self.get(portfolio_id)
def _manual_total_value(self, cash: float, quantity: float, price: float, buffer: float) -> float:
return float(cash) + float(quantity) * float(price) + float(buffer)
@staticmethod
def _validate_ledger_decimal(value: Decimal, *, field_name: str, allow_zero: bool) -> Decimal:
if value.is_nan() or value.is_infinite():
raise ValueError(f"{field_name} must be a finite decimal")
if allow_zero:
if value < 0:
raise ValueError(f"{field_name} must be greater than or equal to 0")
elif value <= 0:
raise ValueError(f"{field_name} must be greater than 0")
scale = max(-value.as_tuple().exponent, 0)
if scale > _LEDGER_NUMERIC_SCALE:
raise ValueError(f"{field_name} exceeds NUMERIC(20,8) precision")
digits = len(value.copy_abs().as_tuple().digits)
integer_digits = max(digits - scale, 0)
if value == 0:
integer_digits = 0
if integer_digits > _LEDGER_MAX_INTEGER_DIGITS:
raise ValueError(f"{field_name} exceeds NUMERIC(20,8) precision")
return value.quantize(_LEDGER_QUANT, rounding=ROUND_HALF_UP)
@classmethod
def _coerce_ledger_decimal(cls, value: Any, *, field_name: str, allow_zero: bool) -> Decimal:
try:
decimal_value = Decimal(str(value))
except (InvalidOperation, ValueError, TypeError):
raise ValueError(f"{field_name} must be a decimal") from None
return cls._validate_ledger_decimal(decimal_value, field_name=field_name, allow_zero=allow_zero)
@staticmethod
def _coerce_portfolio_snapshot_decimal(value: Any, *, field_name: str) -> Decimal:
try:
decimal_value = Decimal(str(value))
except (InvalidOperation, ValueError, TypeError):
raise ValueError(f"{field_name} must be a decimal") from None
if decimal_value.is_nan() or decimal_value.is_infinite():
raise ValueError(f"{field_name} must be a finite decimal")
return decimal_value
@staticmethod
def _to_snapshot_float_and_ledger_decimal(value: Decimal, *, field_name: str) -> tuple[float, Decimal]:
if value.is_nan() or value.is_infinite():
raise ValueError(f"{field_name} must be a finite decimal")
try:
as_float = float(value)
except (OverflowError, ValueError):
raise ValueError(f"{field_name} cannot be represented in DOUBLE PRECISION snapshot") from None
expected_ledger = value.quantize(_LEDGER_QUANT, rounding=ROUND_HALF_UP)
actual_ledger = Decimal(str(as_float)).quantize(_LEDGER_QUANT, rounding=ROUND_HALF_UP)
if actual_ledger != expected_ledger:
raise ValueError(f"{field_name} cannot be represented in DOUBLE PRECISION snapshot")
return as_float, actual_ledger
def record_manual_ledger(
self,
portfolio_id: str,
*,
action: str,
amount: Decimal | None = None,
quantity: Decimal | None = None,
price: Decimal | None = None,
fee: Decimal = Decimal(0),
tax: Decimal = Decimal(0),
executed_at: datetime | None = None,
target_ledger_entry_id: int | None = None,
note: str = "",
actor_user_id: str | None = None,
confirmation_evidence: str | None = None,
confirm_duplicate: bool = False,
) -> LedgerEntry:
self.ensure_schema()
normalized = str(action).upper()
if normalized not in {"INVEST", "BUFFER_OUT", "BUFFER_IN", "BUY", "SELL", "WITHDRAW", "PROFIT", "DIVIDEND", "REVERSAL"}:
raise ValueError("unsupported manual ledger action")
conn = self._connect()
with conn.transaction():
with conn.cursor() as cur:
cur.execute(_SELECT_PORTFOLIO + "WHERE p.portfolio_id = %s FOR UPDATE OF p", (portfolio_id,))
row = cur.fetchone()
if row is None:
raise ValueError("포트폴리오를 찾을 수 없습니다")
portfolio = self._row_to_portfolio(row)
cash = self._coerce_portfolio_snapshot_decimal(portfolio.cash_balance, field_name="cash_balance")
qty = self._coerce_portfolio_snapshot_decimal(portfolio.asset_quantity, field_name="asset_quantity")
buffer = self._coerce_portfolio_snapshot_decimal(portfolio.profit_buffer, field_name="profit_buffer")
asset_price = self._coerce_portfolio_snapshot_decimal(portfolio.price, field_name="asset_price")
ledger_price = self._coerce_ledger_decimal(price or 0, field_name="price", allow_zero=True)
ledger_quantity = self._coerce_ledger_decimal(quantity or 0, field_name="quantity", allow_zero=True)
fee_f = self._coerce_ledger_decimal(fee or 0, field_name="fee", allow_zero=True)
tax_f = self._coerce_ledger_decimal(tax or 0, field_name="tax", allow_zero=True)
amount_f = self._coerce_ledger_decimal(amount or 0, field_name="amount", allow_zero=True)
ledger_executed_at = executed_at or datetime.now(UTC)
if ledger_executed_at.tzinfo is None:
ledger_executed_at = ledger_executed_at.replace(tzinfo=UTC)
if normalized == "INVEST":
buffer += amount_f
ledger_price = amount_f
elif normalized == "BUFFER_OUT":
if buffer < amount_f:
raise ValueError("INSUFFICIENT_BUFFER")
buffer -= amount_f
cash += amount_f
ledger_price = amount_f
elif normalized == "BUFFER_IN":
if cash < amount_f:
raise ValueError("INSUFFICIENT_CASH")
cash -= amount_f
buffer += amount_f
ledger_price = amount_f
elif normalized == "WITHDRAW" or normalized == "PROFIT":
if buffer < amount_f:
raise ValueError("INSUFFICIENT_BUFFER")
buffer -= amount_f
ledger_price = amount_f
elif normalized == "DIVIDEND":
if not confirm_duplicate:
cur.execute(
"""
SELECT existing.ledger_entry_id
FROM portfolio_ledger AS existing
WHERE existing.portfolio_id = %s
AND existing.source = 'manual'
AND existing.side = 'dividend'
AND existing.price = %s::numeric(20,8)
AND existing.executed_at = %s
AND existing.note = %s
AND NOT EXISTS (
SELECT 1
FROM portfolio_ledger AS reversal
WHERE reversal.portfolio_id = existing.portfolio_id
AND reversal.side = 'reversal'
AND reversal.target_ledger_entry_id = existing.ledger_entry_id
)
ORDER BY existing.ledger_entry_id DESC
LIMIT 1
""",
(portfolio_id, amount_f, ledger_executed_at, note),
)
duplicate_row = cur.fetchone()
if duplicate_row is not None:
raise ValueError(f"POTENTIAL_DUPLICATE_DIVIDEND:{int(duplicate_row[0])}")
buffer += amount_f
ledger_price = amount_f
elif normalized == "BUY":
required = (ledger_quantity * ledger_price + fee_f + tax_f).quantize(_LEDGER_QUANT, rounding=ROUND_HALF_UP)
if cash < required:
raise ValueError("INSUFFICIENT_CASH")
cash -= required
qty += ledger_quantity
asset_price = ledger_price
elif normalized == "SELL":
if qty < ledger_quantity:
raise ValueError("INSUFFICIENT_QUANTITY")
cash += (ledger_quantity * ledger_price - fee_f - tax_f).quantize(_LEDGER_QUANT, rounding=ROUND_HALF_UP)
qty -= ledger_quantity
asset_price = ledger_price
elif normalized == "REVERSAL":
if target_ledger_entry_id is None:
raise ValueError("target_ledger_entry_id is required")
cur.execute(
"""
SELECT ledger_entry_id, portfolio_id, order_id, execution_id, side, quantity, price,
fee_amount, tax_amount, executed_at, recorded_at, cash_balance_after,
asset_quantity_after, total_value_after, profit_buffer_after, strategy_set_id,
note, source, target_ledger_entry_id, confirmation_evidence, actor_user_id
FROM portfolio_ledger
WHERE portfolio_id = %s AND ledger_entry_id = %s
""",
(portfolio_id, target_ledger_entry_id),
)
reverse_target_row = cur.fetchone()
if reverse_target_row is None:
raise ValueError("LEDGER_ENTRY_NOT_FOUND")
cur.execute(
"SELECT 1 FROM portfolio_ledger WHERE portfolio_id = %s AND side = 'reversal' AND target_ledger_entry_id = %s LIMIT 1",
(portfolio_id, target_ledger_entry_id),
)
if cur.fetchone() is not None:
raise ValueError("LEDGER_ENTRY_ALREADY_REVERSED")
target_action = str(reverse_target_row[4] or "").upper()
amount_f = self._coerce_ledger_decimal(reverse_target_row[6], field_name="amount", allow_zero=True)
ledger_price = self._coerce_ledger_decimal(reverse_target_row[6], field_name="price", allow_zero=True)
ledger_quantity = self._coerce_ledger_decimal(reverse_target_row[5], field_name="quantity", allow_zero=True)
fee_f = self._coerce_ledger_decimal(reverse_target_row[7], field_name="fee", allow_zero=True)
tax_f = self._coerce_ledger_decimal(reverse_target_row[8], field_name="tax", allow_zero=True)
if target_action == "BUY":
cash += (ledger_quantity * ledger_price + fee_f + tax_f).quantize(_LEDGER_QUANT, rounding=ROUND_HALF_UP)
qty -= ledger_quantity
elif target_action == "SELL":
required_cash = (ledger_quantity * ledger_price - fee_f - tax_f).quantize(_LEDGER_QUANT, rounding=ROUND_HALF_UP)
if cash < required_cash:
raise ValueError("INSUFFICIENT_CASH")
cash -= required_cash
qty += ledger_quantity
elif target_action == "BUFFER_OUT":
if cash < amount_f:
raise ValueError("INSUFFICIENT_CASH")
cash -= amount_f
buffer += amount_f
elif target_action == "BUFFER_IN":
if buffer < amount_f:
raise ValueError("INSUFFICIENT_BUFFER")
cash += amount_f
buffer -= amount_f
elif target_action in {"INVEST", "DIVIDEND"}:
if buffer < amount_f:
raise ValueError("INSUFFICIENT_BUFFER")
buffer -= amount_f
elif target_action in {"WITHDRAW", "PROFIT"}:
buffer += amount_f
else:
raise ValueError("unsupported reversal target action")
if qty < 0:
raise ValueError("INSUFFICIENT_QUANTITY")
if cash < 0:
raise ValueError("INSUFFICIENT_CASH")
if buffer < 0:
raise ValueError("INSUFFICIENT_BUFFER")
snapshot_cash_float, snapshot_cash_ledger = self._to_snapshot_float_and_ledger_decimal(
cash,
field_name="cash_balance_after",
)
snapshot_qty_float, snapshot_qty_ledger = self._to_snapshot_float_and_ledger_decimal(
qty,
field_name="asset_quantity_after",
)
snapshot_price_float, _ = self._to_snapshot_float_and_ledger_decimal(
asset_price,
field_name="asset_price",
)
snapshot_buffer_float, snapshot_buffer_ledger = self._to_snapshot_float_and_ledger_decimal(
buffer,
field_name="profit_buffer_after",
)
total_value = (
snapshot_cash_ledger + snapshot_qty_ledger * Decimal(str(snapshot_price_float)) + snapshot_buffer_ledger
).quantize(_LEDGER_QUANT, rounding=ROUND_HALF_UP)
cur.execute(
"""
UPDATE portfolios
SET cash_balance = %s,
asset_quantity = %s,
asset_price = %s,
profit_buffer = %s,
updated_at = NOW(),
last_trade_at = NOW()
WHERE portfolio_id = %s
""",
(snapshot_cash_float, snapshot_qty_float, snapshot_price_float, snapshot_buffer_float, portfolio_id),
)
cur.execute(
"""
INSERT INTO portfolio_ledger (
portfolio_id, recorded_at, side, price, quantity, fee_amount,
tax_amount, cash_balance_after, asset_quantity_after,
total_value_after, profit_buffer_after, strategy_set_id, note, executed_at,
source, target_ledger_entry_id, confirmation_evidence, actor_user_id
) VALUES (%s, NOW(), %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING ledger_entry_id, portfolio_id, order_id, execution_id, side, quantity, price,
fee_amount, tax_amount, executed_at, recorded_at, cash_balance_after,
asset_quantity_after, total_value_after, profit_buffer_after, strategy_set_id,
note, source, target_ledger_entry_id, confirmation_evidence, actor_user_id
""",
(
portfolio_id,
normalized.lower(),
ledger_price,
ledger_quantity,
fee_f,
tax_f,
snapshot_cash_ledger,
snapshot_qty_ledger,
total_value,
snapshot_buffer_ledger,
"manual",
note,
ledger_executed_at,
"manual",
target_ledger_entry_id,
confirmation_evidence,
actor_user_id,
),
)
return self._row_to_ledger_entry(cur.fetchone())
def list_manual_ledger_entries(self, portfolio_id: str, limit: int = 50) -> list[LedgerEntry]:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT ledger_entry_id, portfolio_id, order_id, execution_id, side, quantity, price,
fee_amount, tax_amount, executed_at, recorded_at, cash_balance_after,
asset_quantity_after, total_value_after, profit_buffer_after, strategy_set_id,
note, source, target_ledger_entry_id, confirmation_evidence, actor_user_id
FROM portfolio_ledger
WHERE portfolio_id = %s AND source = 'manual'
ORDER BY recorded_at DESC, ledger_entry_id DESC
LIMIT %s
""",
(portfolio_id, int(limit)),
)
return [self._row_to_ledger_entry(row) for row in cur.fetchall()]
def list_ledger_entries(
self,
portfolio_id: str,
*,
source: str | None = None,
before_recorded_at: datetime | None = None,
before_ledger_entry_id: int | None = None,
limit: int = 50,
) -> list[LedgerEntry]:
self.ensure_schema()
clauses = ["portfolio_id = %s"]
params: list[Any] = [portfolio_id]
if source is not None:
clauses.append("source = %s")
params.append(source)
if before_recorded_at is not None and before_ledger_entry_id is not None:
clauses.append("(recorded_at, ledger_entry_id) < (%s, %s)")
params.extend([before_recorded_at, before_ledger_entry_id])
params.append(int(limit))
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT ledger_entry_id, portfolio_id, order_id, execution_id, side, quantity, price,
fee_amount, tax_amount, executed_at, recorded_at, cash_balance_after,
asset_quantity_after, total_value_after, profit_buffer_after, strategy_set_id,
note, source, target_ledger_entry_id, confirmation_evidence, actor_user_id,
ca.corporate_action_id, ca.action_type, ca.effective_date,
ca.quantity_before, ca.quantity_after, ca.average_price_before,
ca.average_price_after, ca.total_cost_basis, ca.cash_balance,
ca.profit_buffer, ca.evidence_ref, ca.actor_user_id
FROM portfolio_ledger
LEFT JOIN portfolio_corporate_actions ca ON ca.ledger_entry_id = portfolio_ledger.ledger_entry_id
WHERE """ + " AND ".join(clauses) + """
ORDER BY recorded_at DESC, ledger_entry_id DESC
LIMIT %s
""",
params,
)
result = []
for row in cur.fetchall():
entry = self._row_to_ledger_entry(row)
if row[21] is not None:
entry.corporate_action = {"corporate_action_id": str(row[21]), "action_type": row[22], "effective_date": row[23], "quantity_before": Decimal(str(row[24])), "quantity_after": Decimal(str(row[25])), "average_price_before": Decimal(str(row[26])), "average_price_after": Decimal(str(row[27])), "total_cost_basis": Decimal(str(row[28])), "cash_balance": Decimal(str(row[29])), "profit_buffer": Decimal(str(row[30])), "evidence_ref": row[31], "actor_user_id": row[32]}
result.append(entry)
return result
def get_return_summaries(self, portfolio_ids: list[str]) -> dict[str, dict[str, float]]:
if not portfolio_ids:
return {}
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT ledger.portfolio_id,
COALESCE(SUM(ledger.price) FILTER (WHERE ledger.side = 'invest'), 0),
COALESCE(SUM(ledger.price) FILTER (WHERE ledger.side = 'withdraw'), 0),
COALESCE(SUM(ledger.price) FILTER (WHERE ledger.side = 'profit'), 0)
FROM portfolio_ledger AS ledger
WHERE ledger.portfolio_id = ANY(%s)
AND ledger.side IN ('invest', 'withdraw', 'profit')
AND NOT EXISTS (
SELECT 1
FROM portfolio_ledger AS reversal
WHERE reversal.portfolio_id = ledger.portfolio_id
AND reversal.side = 'reversal'
AND reversal.target_ledger_entry_id = ledger.ledger_entry_id
)
GROUP BY ledger.portfolio_id
""",
(portfolio_ids,),
)
rows = cur.fetchall()
return {
str(portfolio_id): {
"total_invested": float(invested),
"total_withdrawn": float(withdrawn),
"net_contributed_capital": float(invested) - float(withdrawn),
"cumulative_profit_withdrawn": float(profit),
}
for portfolio_id, invested, withdrawn, profit in rows
}
def get_return_summary(self, portfolio_id: str) -> dict[str, float]:
summaries = self.get_return_summaries([portfolio_id])
return summaries.get(
portfolio_id,
{
"total_invested": 0.0,
"total_withdrawn": 0.0,
"net_contributed_capital": 0.0,
"cumulative_profit_withdrawn": 0.0,
},
)
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,
COALESCE(tag_ids, '{}'::uuid[])
""",
(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()
with self._connect().cursor() as cur:
cur.execute("SELECT portfolio_id FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
if cur.fetchone() is None:
raise ValueError("포트폴리오를 찾을 수 없습니다")
if clear_ledger:
cur.execute("DELETE FROM portfolio_ledger WHERE portfolio_id = %s", (portfolio_id,))
# Comments are always removed with the portfolio (no FK constraint, handled in application layer)
cur.execute("DELETE FROM portfolio_comments WHERE portfolio_id = %s", (portfolio_id,))
cur.execute("DELETE FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
@staticmethod
def _row_to_comment(row: Any) -> PortfolioComment:
if row is None:
raise ValueError("댓글을 찾을 수 없습니다")
return PortfolioComment(
id=row[0],
portfolio_id=row[1],
content=row[2],
created_at=row[3],
updated_at=row[4],
)
def create_comment(self, portfolio_id: str, *, content: str) -> PortfolioComment:
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 None:
raise ValueError("포트폴리오를 찾을 수 없습니다")
comment_id = str(uuid.uuid4())
cur.execute(
"""
INSERT INTO portfolio_comments (id, portfolio_id, content)
VALUES (%s, %s, %s)
RETURNING id, portfolio_id, content, created_at, updated_at
""",
(comment_id, portfolio_id, content),
)
row = cur.fetchone()
return self._row_to_comment(row)
def list_comments(self, portfolio_id: str) -> list[PortfolioComment]:
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 None:
raise ValueError("포트폴리오를 찾을 수 없습니다")
cur.execute(
"""
SELECT id, portfolio_id, content, created_at, updated_at
FROM portfolio_comments
WHERE portfolio_id = %s
ORDER BY created_at DESC
""",
(portfolio_id,),
)
rows = cur.fetchall()
return [self._row_to_comment(row) for row in rows]
def update_comment(self, portfolio_id: str, comment_id: str, *, content: str) -> PortfolioComment:
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 None:
raise ValueError("포트폴리오를 찾을 수 없습니다")
cur.execute(
"""
UPDATE portfolio_comments
SET content = %s, updated_at = NOW()
WHERE id = %s AND portfolio_id = %s
RETURNING id, portfolio_id, content, created_at, updated_at
""",
(content, comment_id, portfolio_id),
)
row = cur.fetchone()
if row is None:
raise ValueError("댓글을 찾을 수 없습니다")
return self._row_to_comment(row)
def delete_comment(self, portfolio_id: str, comment_id: str) -> 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 None:
raise ValueError("포트폴리오를 찾을 수 없습니다")
cur.execute(
"DELETE FROM portfolio_comments WHERE id = %s AND portfolio_id = %s RETURNING id",
(comment_id, portfolio_id),
)
if cur.fetchone() is None:
raise ValueError("댓글을 찾을 수 없습니다")
def soft_delete_portfolio(self, portfolio_id: str) -> Portfolio:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
UPDATE portfolios
SET status = 'disabled', managed_by_daemon_id = NULL, updated_at = NOW()
WHERE portfolio_id = %s
""",
(portfolio_id,),
)
if cur.rowcount == 0:
raise ValueError(f"포트폴리오를 찾을 수 없습니다: {portfolio_id}")
return self.get(portfolio_id)
def list_portfolios_with_strategies(
self,
owner_user_id: str,
*,
include_disabled: bool = True,
managed_by_daemon_id: str | None = None,
market: str | None = None,
) -> list[Portfolio]:
status_filter = None if include_disabled else "enabled"
return self.list_portfolios(
owner_user_id=owner_user_id,
status=status_filter,
managed_by_daemon_id=managed_by_daemon_id,
market=market,
)
def create_tag(
self,
*,
name: str,
created_by_user_id: str,
is_admin_tag: bool = False,
) -> Tag:
self.ensure_schema()
with self._connect().cursor() as cur:
try:
cur.execute(
"""
INSERT INTO tags (name, created_by_user_id, is_admin_tag)
VALUES (%s, %s, %s)
RETURNING tag_id, name, created_by_user_id, is_admin_tag, is_active, created_at
""",
(name, created_by_user_id, is_admin_tag),
)
except psycopg.errors.UniqueViolation as exc:
raise ValueError(f"이미 존재하는 태그 이름입니다: {name}") from exc
row = cur.fetchone()
return self._row_to_tag(row)
def get_tag_by_id(self, tag_id: str) -> Tag | None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(_SELECT_TAG + "WHERE tag_id = %s", (tag_id,))
row = cur.fetchone()
return self._row_to_tag(row) if row is not None else None
def get_tags_by_ids(self, tag_ids: list[str]) -> dict[str, Tag]:
if not tag_ids:
return {}
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(_SELECT_TAG + "WHERE tag_id = ANY(%s)", (tag_ids,))
rows = cur.fetchall()
return {str(row[0]): self._row_to_tag(row) for row in rows}
def list_tags_master(self, *, include_inactive: bool = False) -> list[Tag]:
self.ensure_schema()
query = _SELECT_TAG
if not include_inactive:
query += "WHERE is_active = TRUE "
query += "ORDER BY name ASC"
with self._connect().cursor() as cur:
cur.execute(query)
rows = cur.fetchall()
return [self._row_to_tag(row) for row in rows]
def deactivate_tag(self, tag_id: str) -> Tag:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
UPDATE tags SET is_active = FALSE
WHERE tag_id = %s
RETURNING tag_id, name, created_by_user_id, is_admin_tag, is_active, created_at
""",
(tag_id,),
)
row = cur.fetchone()
if row is None:
raise ValueError(f"태그를 찾을 수 없습니다: {tag_id}")
return self._row_to_tag(row)
def append_portfolio_tag_id(self, portfolio_id: str, tag_id: str) -> None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
UPDATE portfolios
SET tag_ids = array_append(tag_ids, %s::uuid)
WHERE portfolio_id = %s AND NOT (%s::uuid = ANY(tag_ids))
""",
(tag_id, portfolio_id, tag_id),
)
if cur.rowcount == 0:
cur.execute("SELECT portfolio_id FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
if cur.fetchone() is None:
raise ValueError(f"portfolio를 찾을 수 없습니다: {portfolio_id}")
def remove_portfolio_tag_id(self, portfolio_id: str, tag_id: str) -> None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
UPDATE portfolios
SET tag_ids = array_remove(tag_ids, %s::uuid)
WHERE portfolio_id = %s
""",
(tag_id, portfolio_id),
)
if cur.rowcount == 0:
raise ValueError(f"portfolio를 찾을 수 없습니다: {portfolio_id}")
def append_asset_tag_id(self, asset_id: str, tag_id: str) -> None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
UPDATE assets
SET tag_ids = array_append(tag_ids, %s::uuid)
WHERE asset_id = %s AND NOT (%s::uuid = ANY(tag_ids))
""",
(tag_id, asset_id, tag_id),
)
if cur.rowcount == 0:
cur.execute("SELECT asset_id FROM assets WHERE asset_id = %s", (asset_id,))
if cur.fetchone() is None:
raise ValueError(f"asset을 찾을 수 없습니다: {asset_id}")
def remove_asset_tag_id(self, asset_id: str, tag_id: str) -> None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
UPDATE assets
SET tag_ids = array_remove(tag_ids, %s::uuid)
WHERE asset_id = %s
""",
(tag_id, asset_id),
)
if cur.rowcount == 0:
raise ValueError(f"asset을 찾을 수 없습니다: {asset_id}")
def rotate_service_token(self, name: str) -> tuple[str, AuthToken]:
from app.core.security import generate_token, hash_token
from app.core.security import token_prefix as _token_prefix
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
_SELECT_AUTH_TOKEN + "WHERE name = %s AND token_type = 'service' AND revoked_at IS NULL",
(name,),
)
existing_row = cur.fetchone()
if existing_row is None:
raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}")
existing = self._row_to_auth_token(existing_row)
new_raw = generate_token()
new_runtime_id = str(uuid.uuid4())
new_hash = hash_token(new_raw)
new_prefix = _token_prefix(new_raw)
cur.execute("UPDATE auth_tokens SET revoked_at = NOW() WHERE id = %s", (existing.id,))
token_id = str(uuid.uuid4())
cur.execute(
"""
INSERT INTO auth_tokens (
id, user_id, name, token_prefix, token_hash, permission, token_type,
expires_at, runtime_id, daemon_id
)
VALUES (%s, %s, %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, daemon_id
""",
(
token_id,
existing.user_id,
name,
new_prefix,
new_hash,
existing.permission,
"service",
existing.expires_at,
new_runtime_id,
existing.daemon_id,
),
)
row = cur.fetchone()
return new_raw, self._row_to_auth_token(row)
def rebind_service_token(self, name: str, daemon_id: str) -> AuthToken:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
_SELECT_AUTH_TOKEN + "WHERE name = %s AND token_type = 'service' AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1",
(name,),
)
existing_row = cur.fetchone()
if existing_row is None:
raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}")
existing = self._row_to_auth_token(existing_row)
cur.execute(
"""
UPDATE auth_tokens
SET daemon_id = %s
WHERE id = %s
RETURNING
id, user_id, name, token_prefix, permission, token_type,
expires_at, last_used_at, revoked_at, created_at, runtime_id, daemon_id
""",
(daemon_id, existing.id),
)
row = cur.fetchone()
return self._row_to_auth_token(row)
@staticmethod
def _row_to_daemon(row: Any) -> Daemon:
if row is None:
raise ValueError("데몬을 찾을 수 없습니다")
return Daemon(
id=row[0],
owner_user_id=row[1],
daemon_slug=row[2],
display_name=row[3],
is_active=bool(row[4]),
created_at=row[5] if len(row) > 5 else None,
updated_at=row[6] if len(row) > 6 else None,
)
def create_daemon(
self,
owner_user_id: str,
*,
daemon_slug: str,
display_name: str | None = None,
) -> Daemon:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute("SELECT 1 FROM users WHERE id = %s", (owner_user_id,))
if cur.fetchone() is None:
raise ValueError("사용자를 찾을 수 없습니다")
daemon_id = str(uuid.uuid4())
try:
cur.execute(
"""
INSERT INTO daemons (id, owner_user_id, daemon_slug, display_name)
VALUES (%s, %s, %s, %s)
RETURNING id, owner_user_id, daemon_slug, display_name, is_active, created_at, updated_at
""",
(daemon_id, owner_user_id, daemon_slug, display_name),
)
except psycopg.errors.UniqueViolation as exc:
raise ValueError(f"이미 존재하는 daemon slug입니다: {daemon_slug}") from exc
row = cur.fetchone()
return self._row_to_daemon(row)
def get_daemon_by_id(self, daemon_id: str) -> Daemon | None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT id, owner_user_id, daemon_slug, display_name, is_active, created_at, updated_at
FROM daemons WHERE id = %s
""",
(daemon_id,),
)
row = cur.fetchone()
if row is None:
return None
return self._row_to_daemon(row)
def get_daemon_by_slug(self, owner_user_id: str, daemon_slug: str) -> Daemon | None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT id, owner_user_id, daemon_slug, display_name, is_active, created_at, updated_at
FROM daemons WHERE owner_user_id = %s AND daemon_slug = %s
""",
(owner_user_id, daemon_slug),
)
row = cur.fetchone()
if row is None:
return None
return self._row_to_daemon(row)
def list_daemons_for_user(self, owner_user_id: str) -> list[Daemon]:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT id, owner_user_id, daemon_slug, display_name, is_active, created_at, updated_at
FROM daemons WHERE owner_user_id = %s ORDER BY created_at ASC
""",
(owner_user_id,),
)
rows = cur.fetchall()
return [self._row_to_daemon(row) for row in rows]
def list_daemons_all(self) -> list[Daemon]:
"""Return all daemons across all owners (admin use only)."""
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT id, owner_user_id, daemon_slug, display_name, is_active, created_at, updated_at
FROM daemons ORDER BY created_at ASC
""",
)
rows = cur.fetchall()
return [self._row_to_daemon(row) for row in rows]
def update_daemon(
self,
daemon_id: str,
*,
display_name: str | None = None,
is_active: bool | None = None,
) -> Daemon:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT id, owner_user_id, daemon_slug, display_name, is_active, created_at, updated_at
FROM daemons WHERE id = %s
""",
(daemon_id,),
)
existing_row = cur.fetchone()
if existing_row is None:
raise ValueError("데몬을 찾을 수 없습니다")
assignments: list[str] = []
params: list[object] = []
if display_name is not None:
assignments.append("display_name = %s")
params.append(display_name)
if is_active is not None:
assignments.append("is_active = %s")
params.append(is_active)
if not assignments:
return self._row_to_daemon(existing_row)
assignments.append("updated_at = NOW()")
cur.execute(
f"""
UPDATE daemons
SET {', '.join(assignments)}
WHERE id = %s
RETURNING id, owner_user_id, daemon_slug, display_name, is_active, created_at, updated_at
""",
(*params, daemon_id),
)
row = cur.fetchone()
return self._row_to_daemon(row)
def revoke_tokens_by_daemon_id(self, daemon_id: str) -> int:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"UPDATE auth_tokens SET revoked_at = NOW() WHERE daemon_id = %s AND revoked_at IS NULL",
(daemon_id,),
)
return cur.rowcount
def get_broker_credential_by_daemon(
self,
daemon_id: str,
broker: str,
) -> BrokerCredential | None:
"""Return a single BrokerCredential by (daemon_id, broker) PK, or None if absent (REQ-052)."""
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT daemon_id, user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at
FROM broker_credentials
WHERE daemon_id = %s AND broker = %s
""",
(daemon_id, broker),
)
row = cur.fetchone()
if row is None:
return None
return self._row_to_broker_credential(row)
def delete_credentials_by_daemon_id(self, daemon_id: str) -> int:
"""Delete ALL broker credentials for daemon_id (owner re-assignment invalidation — REQ-053 AC-4).
Returns number of rows deleted.
"""
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"DELETE FROM broker_credentials WHERE daemon_id = %s",
(daemon_id,),
)
return cur.rowcount
def update_daemon_owner(
self,
daemon_id: str,
*,
owner_user_id: str,
) -> Daemon:
"""Change daemon owner and invalidate all existing BrokerCredentials (REQ-053 AC-4).
Owner change and credential invalidation are executed atomically in a single
transaction (BAT-022 QC Major fix). If either statement fails the entire
transaction is rolled back, preventing the state where the owner has changed but
the previous owner's credentials remain (credential leak — dcy-011 OQ-3).
The daemon row is locked FOR UPDATE before checking portfolio references.
Portfolio assignment takes the same daemon row FOR SHARE before writing, so
either an earlier assignment makes this transfer fail or an earlier transfer
makes the pending assignment fail its same-owner validation.
"""
self.ensure_schema()
conn = self._connect()
# Use conn.transaction() (not `with conn:`) so the shared connection stays open
# after the block (psycopg3 closes the connection when used as a context manager).
with conn.transaction():
with conn.cursor() as cur:
cur.execute(
"SELECT id FROM daemons WHERE id = %s FOR UPDATE",
(daemon_id,),
)
if cur.fetchone() is None:
raise ValueError("데몬을 찾을 수 없습니다")
cur.execute(
"SELECT 1 FROM portfolios WHERE managed_by_daemon_id = %s LIMIT 1",
(daemon_id,),
)
if cur.fetchone() is not None:
raise DaemonInUseError("배정된 포트폴리오가 있는 daemon의 소유자는 변경할 수 없습니다")
# Step 1: delete credentials first (safe-order)
cur.execute(
"DELETE FROM broker_credentials WHERE daemon_id = %s",
(daemon_id,),
)
# Step 2: update owner
cur.execute(
"""
UPDATE daemons
SET owner_user_id = %s, updated_at = NOW()
WHERE id = %s
RETURNING id, owner_user_id, daemon_slug, display_name, is_active, created_at, updated_at
""",
(owner_user_id, daemon_id),
)
row = cur.fetchone()
return self._row_to_daemon(row)
def list_service_tokens_with_null_daemon(self) -> list[AuthToken]:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
_SELECT_AUTH_TOKEN
+ "WHERE token_type = 'service' AND daemon_id IS NULL AND revoked_at IS NULL "
+ "ORDER BY created_at ASC",
)
rows = cur.fetchall()
return [self._row_to_auth_token(row) for row in rows]
def database_exists(config: PortfolioDBConfig, *, maintenance_db: str = "postgres") -> bool:
with psycopg.connect(
host=config.host,
port=config.port,
dbname=maintenance_db,
user=config.user,
password=config.password,
sslmode=config.sslmode,
connect_timeout=config.connect_timeout_sec,
application_name=config.application_name,
autocommit=True,
) as conn, conn.cursor() as cur:
cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (config.dbname,))
return cur.fetchone() is not None
def create_database_if_missing(config: PortfolioDBConfig, *, maintenance_db: str = "postgres") -> bool:
with psycopg.connect(
host=config.host,
port=config.port,
dbname=maintenance_db,
user=config.user,
password=config.password,
sslmode=config.sslmode,
connect_timeout=config.connect_timeout_sec,
application_name=config.application_name,
autocommit=True,
) as conn:
with conn.cursor() as cur:
cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (config.dbname,))
if cur.fetchone() is not None:
return False
cur.execute(sql.SQL("CREATE DATABASE {}") .format(sql.Identifier(config.dbname)))
return True