POST /v1/portfolios now forwards a non-null managed_by_daemon_id into create persistence, and both create and PATCH apply the same daemon validation contract (UUID format -> 422, missing/foreign -> 404, inactive -> 409, admin target-owner basis). Daemon validation locks the daemon row FOR SHARE inside the portfolio write transaction so validation and write form one atomic decision boundary. Adds domain errors, repository protocol/signature updates, API/PostgreSQL tests (POST->GET persistence, no partial state on failure, assignment vs deactivation race), and contract/data-model documentation.
2931 lines
118 KiB
Python
2931 lines
118 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 datetime, timezone
|
|
from typing import Any
|
|
|
|
import psycopg
|
|
from psycopg import sql
|
|
|
|
from app.domain.errors import DaemonInactiveError, DaemonNotFoundError, StrategyInactiveError, StrategyNotFoundError
|
|
from app.domain.models import Asset, AuthToken, BrokerCredential, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, Tag, TokenPrincipal, User
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_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
|
|
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(
|
|
"""
|
|
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_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)
|
|
"""
|
|
)
|
|
|
|
@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
|
|
|
|
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_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:
|
|
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]),
|
|
fee_amount=float(row[5]),
|
|
tax_amount=float(row[6]),
|
|
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()
|
|
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, *, status: str = "active") -> StrategySet:
|
|
self.ensure_schema()
|
|
nick = f"{name}:{hashlib.sha1(config.encode('utf-8')).hexdigest()[:12]}"
|
|
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, 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()
|
|
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,
|
|
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_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_config == current.config
|
|
and final_status == current.status
|
|
):
|
|
return current
|
|
nick = f"{final_name}:{hashlib.sha1(final_config.encode('utf-8')).hexdigest()[:12]}"
|
|
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, nick, final_status, id),
|
|
)
|
|
updated = cur.fetchone()
|
|
except psycopg.errors.UniqueViolation as exc:
|
|
raise ValueError("동일한 name/config 전략이 이미 존재합니다") 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(timezone.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 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
|
|
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"]
|
|
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
|
|
FROM portfolio_ledger
|
|
WHERE portfolio_id = %s
|
|
AND side IN ('buy', 'sell')
|
|
ORDER BY executed_at ASC, ledger_entry_id ASC
|
|
""",
|
|
(portfolio_id,),
|
|
)
|
|
rows = cur.fetchall()
|
|
return [self._row_to_ledger_entry(row) for row in rows]
|
|
|
|
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("원장 항목을 찾을 수 없습니다")
|
|
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(row[6]),
|
|
fee_amount=float(row[7]),
|
|
tax_amount=float(row[8]),
|
|
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)
|
|
|
|
def record_manual_ledger(
|
|
self,
|
|
portfolio_id: str,
|
|
*,
|
|
action: str,
|
|
amount: float | None = None,
|
|
quantity: float | None = None,
|
|
price: float | None = None,
|
|
fee: float = 0.0,
|
|
tax: float = 0.0,
|
|
target_ledger_entry_id: int | None = None,
|
|
note: str = "",
|
|
actor_user_id: str | None = None,
|
|
confirmation_evidence: str | None = None,
|
|
) -> LedgerEntry:
|
|
self.ensure_schema()
|
|
normalized = str(action).upper()
|
|
if normalized not in {"INVEST", "BUFFER_OUT", "BUFFER_IN", "BUY", "SELL", "WITHDRAW", "PROFIT", "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 = float(portfolio.cash_balance)
|
|
qty = float(portfolio.asset_quantity)
|
|
buffer = float(portfolio.profit_buffer)
|
|
asset_price = float(portfolio.price)
|
|
ledger_price = float(price or 0.0)
|
|
ledger_quantity = float(quantity or 0.0)
|
|
fee_f = float(fee or 0.0)
|
|
tax_f = float(tax or 0.0)
|
|
amount_f = float(amount or 0.0)
|
|
reverse_target: LedgerEntry | None = None
|
|
|
|
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":
|
|
if buffer < amount_f:
|
|
raise ValueError("INSUFFICIENT_BUFFER")
|
|
buffer -= amount_f
|
|
ledger_price = amount_f
|
|
elif normalized == "PROFIT":
|
|
if buffer < amount_f:
|
|
raise ValueError("INSUFFICIENT_BUFFER")
|
|
buffer -= amount_f
|
|
ledger_price = amount_f
|
|
elif normalized == "BUY":
|
|
required = ledger_quantity * ledger_price + fee_f + tax_f
|
|
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
|
|
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 = self._row_to_ledger_entry(cur.fetchone())
|
|
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 = (reverse_target.action or "").upper()
|
|
amount_f = float(reverse_target.price)
|
|
ledger_price = float(reverse_target.price)
|
|
ledger_quantity = float(reverse_target.quantity)
|
|
fee_f = float(reverse_target.fee_amount)
|
|
tax_f = float(reverse_target.tax_amount)
|
|
if target_action == "BUY":
|
|
cash += ledger_quantity * ledger_price + fee_f + tax_f
|
|
qty -= ledger_quantity
|
|
elif target_action == "SELL":
|
|
if cash < ledger_quantity * ledger_price - fee_f - tax_f:
|
|
raise ValueError("INSUFFICIENT_CASH")
|
|
cash -= ledger_quantity * ledger_price - fee_f - tax_f
|
|
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 == "INVEST":
|
|
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")
|
|
|
|
total_value = self._manual_total_value(cash, qty, asset_price, buffer)
|
|
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
|
|
""",
|
|
(cash, qty, asset_price, buffer, 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, NOW(), %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(),
|
|
float(ledger_price),
|
|
float(ledger_quantity),
|
|
float(fee_f),
|
|
float(tax_f),
|
|
float(cash),
|
|
float(qty),
|
|
float(total_value),
|
|
float(buffer),
|
|
"manual",
|
|
note,
|
|
"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 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.source = 'manual'
|
|
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.source = 'manual'
|
|
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, 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).
|
|
|
|
Execution order inside the transaction:
|
|
1. DELETE FROM broker_credentials WHERE daemon_id = %s (credential first)
|
|
2. UPDATE daemons SET owner_user_id = %s ... (owner second)
|
|
This order is also safe if the transaction machinery is somehow bypassed: if
|
|
only step 1 completes the owner is unchanged and credentials are gone (no leak);
|
|
if only step 2 completes the old credentials are still present but the caller
|
|
still owns them (no cross-owner leak).
|
|
"""
|
|
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",
|
|
(daemon_id,),
|
|
)
|
|
if cur.fetchone() is None:
|
|
raise ValueError("데몬을 찾을 수 없습니다")
|
|
# 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:
|
|
with 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
|