feat(release): add verified image publication pipeline

This commit is contained in:
2026-08-15 14:52:01 +09:00
parent f7b33784ab
commit fd080496b3
47 changed files with 907 additions and 22 deletions
View File
+36
View File
@@ -0,0 +1,36 @@
[alembic]
script_location = migrations
sqlalchemy.url = postgresql+psycopg://postgres:postgres@localhost:5432/postgres
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.core.config import get_settings
config = context.config
if config.config_file_name is not None:
# Alembic may run in-process (bootstrap/tests); preserve application loggers.
fileConfig(config.config_file_name, disable_existing_loggers=False)
target_metadata = None
def _configure_database_url() -> str:
settings = config.attributes.get("portfoliohub_settings") or get_settings()
database_url = settings.active_database_url
config.set_main_option("sqlalchemy.url", database_url.replace("%", "%%"))
return database_url
def run_migrations_offline() -> None:
url = _configure_database_url()
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
_configure_database_url()
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+20
View File
@@ -0,0 +1,20 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,90 @@
"""initial portfolio schema
Revision ID: 20260403_0001
Revises:
Create Date: 2026-04-03 00:00:00
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "20260403_0001"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"strategy_sets",
sa.Column("id", sa.Text(), primary_key=True),
sa.Column("strategy_name", sa.Text(), nullable=False),
sa.Column("strategy_config", sa.Text(), nullable=False, server_default=""),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("NOW()")),
sa.UniqueConstraint("strategy_name", "strategy_config", name="uq_strategy_sets_name_config"),
)
op.create_table(
"portfolios",
sa.Column("portfolio_id", sa.Text(), primary_key=True),
sa.Column("broker", sa.Text(), nullable=False, server_default="kis"),
sa.Column("status", sa.Text(), nullable=False, server_default="enabled"),
sa.Column("symbol", sa.Text(), nullable=False),
sa.Column("strategy_name", sa.Text(), nullable=False, server_default=""),
sa.Column("strategy_params", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'{}'::jsonb")),
sa.Column("cash_balance", sa.Float(), nullable=False),
sa.Column("asset_quantity", sa.Float(), nullable=False),
sa.Column("asset_price", sa.Float(), nullable=False, server_default="0"),
sa.Column("profit_buffer", sa.Float(), nullable=False, server_default="0"),
sa.Column("current_strategy_set_id", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("NOW()")),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("NOW()")),
sa.Column("last_trade_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_table(
"portfolio_ledger",
sa.Column("portfolio_id", sa.Text(), nullable=False),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("action", sa.Text(), nullable=False),
sa.Column("price", sa.Float(), nullable=False),
sa.Column("quantity", sa.Float(), nullable=False),
sa.Column("fee_amount", sa.Float(), nullable=False, server_default="0"),
sa.Column("tax_amount", sa.Float(), nullable=False, server_default="0"),
sa.Column("cash_balance_after", sa.Float(), nullable=False),
sa.Column("asset_quantity_after", sa.Float(), nullable=False),
sa.Column("total_value_after", sa.Float(), nullable=False),
sa.Column("profit_buffer_after", sa.Float(), nullable=False, server_default="0"),
sa.Column("strategy_set_id", sa.Text(), nullable=False, server_default="legacy"),
sa.Column("note", sa.Text(), nullable=False, server_default=""),
)
op.create_index(
"idx_portfolio_ledger_portfolio_time",
"portfolio_ledger",
["portfolio_id", sa.text("recorded_at DESC")],
unique=False,
)
op.create_index(
"idx_portfolios_broker_status_updated",
"portfolios",
["broker", "status", sa.text("updated_at DESC")],
unique=False,
)
bind = op.get_bind()
has_timescaledb = bind.execute(sa.text("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb')")).scalar()
if has_timescaledb:
bind.execute(sa.text("SELECT create_hypertable('portfolio_ledger', 'recorded_at', if_not_exists => TRUE)"))
def downgrade() -> None:
op.drop_index("idx_portfolios_broker_status_updated", table_name="portfolios")
op.drop_index("idx_portfolio_ledger_portfolio_time", table_name="portfolio_ledger")
op.drop_table("portfolio_ledger")
op.drop_table("portfolios")
op.drop_table("strategy_sets")
@@ -0,0 +1,100 @@
"""rename strategy_set columns and add nick
Revision ID: 20260403_0002
Revises: 20260403_0001
Create Date: 2026-04-03 00:30:00
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260403_0002"
down_revision = "20260403_0001"
branch_labels = None
depends_on = None
def _drop_unique_constraint_for_columns(table_name: str, columns: tuple[str, ...]) -> None:
bind = op.get_bind()
rows = bind.execute(
sa.text(
"""
SELECT c.conname,
array_agg(a.attname ORDER BY u.ord) AS column_names
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
JOIN unnest(c.conkey) WITH ORDINALITY AS u(attnum, ord) ON TRUE
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = u.attnum
WHERE c.contype = 'u'
AND n.nspname = current_schema()
AND t.relname = :table_name
GROUP BY c.conname
"""
),
{"table_name": table_name},
)
expected = list(columns)
for row in rows:
if list(row.column_names) == expected:
op.drop_constraint(row.conname, table_name, type_="unique")
return
def upgrade() -> None:
op.add_column("strategy_sets", sa.Column("name", sa.Text(), nullable=True))
op.add_column("strategy_sets", sa.Column("config", sa.Text(), nullable=True, server_default=""))
op.add_column("strategy_sets", sa.Column("nick", sa.Text(), nullable=True))
op.execute(
sa.text(
"""
UPDATE strategy_sets
SET
name = strategy_name,
config = strategy_config,
nick = strategy_name || ':' || substr(md5(strategy_config), 1, 12)
"""
)
)
op.alter_column("strategy_sets", "name", nullable=False)
op.alter_column("strategy_sets", "config", nullable=False)
op.alter_column("strategy_sets", "nick", nullable=False)
_drop_unique_constraint_for_columns("strategy_sets", ("strategy_name", "strategy_config"))
op.create_unique_constraint("uq_strategy_sets_name_config", "strategy_sets", ["name", "config"])
op.create_unique_constraint("uq_strategy_sets_nick", "strategy_sets", ["nick"])
op.drop_column("strategy_sets", "strategy_name")
op.drop_column("strategy_sets", "strategy_config")
def downgrade() -> None:
op.add_column("strategy_sets", sa.Column("strategy_name", sa.Text(), nullable=True))
op.add_column("strategy_sets", sa.Column("strategy_config", sa.Text(), nullable=True, server_default=""))
op.execute(
sa.text(
"""
UPDATE strategy_sets
SET
strategy_name = name,
strategy_config = config
"""
)
)
op.alter_column("strategy_sets", "strategy_name", nullable=False)
op.alter_column("strategy_sets", "strategy_config", nullable=False)
_drop_unique_constraint_for_columns("strategy_sets", ("nick",))
_drop_unique_constraint_for_columns("strategy_sets", ("name", "config"))
op.create_unique_constraint("uq_strategy_sets_name_config", "strategy_sets", ["strategy_name", "strategy_config"])
op.drop_column("strategy_sets", "nick")
op.drop_column("strategy_sets", "name")
op.drop_column("strategy_sets", "config")
@@ -0,0 +1,86 @@
"""replace embedded portfolio strategy fields with strategy references
Revision ID: 20260403_0003
Revises: 20260403_0002
Create Date: 2026-04-03 01:00:00
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "20260403_0003"
down_revision = "20260403_0002"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("portfolios", sa.Column("active_strategy_id", sa.Text(), nullable=True))
op.add_column("portfolios", sa.Column("avabile_strategies", postgresql.ARRAY(sa.Text()), nullable=True))
op.execute(
sa.text(
"""
UPDATE portfolios
SET
active_strategy_id = current_strategy_set_id,
avabile_strategies = CASE
WHEN current_strategy_set_id IS NULL THEN NULL
ELSE ARRAY[current_strategy_set_id]::text[]
END
"""
)
)
op.drop_column("portfolios", "strategy_name")
op.drop_column("portfolios", "strategy_params")
op.drop_column("portfolios", "current_strategy_set_id")
def downgrade() -> None:
op.add_column("portfolios", sa.Column("strategy_name", sa.Text(), nullable=False, server_default=""))
op.add_column(
"portfolios",
sa.Column(
"strategy_params",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default=sa.text("'{}'::jsonb"),
),
)
op.add_column("portfolios", sa.Column("current_strategy_set_id", sa.Text(), nullable=True))
op.execute(
sa.text(
"""
UPDATE portfolios p
SET
current_strategy_set_id = p.active_strategy_id,
strategy_name = COALESCE(s.name, ''),
strategy_params = CASE
WHEN s.config IS NULL OR s.config = '' THEN '{}'::jsonb
ELSE s.config::jsonb
END
FROM strategy_sets s
WHERE p.active_strategy_id IS NOT NULL
AND s.id = p.active_strategy_id
"""
)
)
op.execute(
sa.text(
"""
UPDATE portfolios
SET strategy_name = COALESCE(strategy_name, ''),
strategy_params = COALESCE(strategy_params, '{}'::jsonb)
"""
)
)
op.drop_column("portfolios", "avabile_strategies")
op.drop_column("portfolios", "active_strategy_id")
@@ -0,0 +1,37 @@
"""add users table
Revision ID: 20260403_0004
Revises: 20260403_0003
Create Date: 2026-04-03 02:00:00
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260403_0004"
down_revision = "20260403_0003"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.Text(), nullable=False),
sa.Column("username", sa.Text(), nullable=False),
sa.Column("email", sa.Text(), nullable=True),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("NOW()")),
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("NOW()")),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("username", name="uq_users_username"),
sa.UniqueConstraint("email", name="uq_users_email"),
)
op.create_index("ix_users_created_at", "users", ["created_at"])
def downgrade() -> None:
op.drop_index("ix_users_created_at", table_name="users")
op.drop_table("users")
@@ -0,0 +1,90 @@
"""add user auth fields and auth tokens
Revision ID: 20260403_0005
Revises: 20260403_0004
Create Date: 2026-04-03 03:00:00
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
revision = "20260403_0005"
down_revision = "20260403_0004"
branch_labels = None
depends_on = None
def _table_exists(inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _column_exists(inspector, table_name: str, column_name: str) -> bool:
return any(column["name"] == column_name for column in inspector.get_columns(table_name))
def _index_exists(inspector, table_name: str, index_name: str) -> bool:
return any(index["name"] == index_name for index in inspector.get_indexes(table_name))
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if not _column_exists(inspector, "users", "permission"):
op.add_column("users", sa.Column("permission", sa.Text(), nullable=False, server_default="read"))
if not _column_exists(inspector, "users", "is_active"):
op.add_column("users", sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("TRUE")))
if not _column_exists(inspector, "users", "password_hash"):
op.add_column("users", sa.Column("password_hash", sa.Text(), nullable=True))
if not _column_exists(inspector, "users", "last_login_at"):
op.add_column("users", sa.Column("last_login_at", sa.TIMESTAMP(timezone=True), nullable=True))
if not _table_exists(inspector, "auth_tokens"):
op.create_table(
"auth_tokens",
sa.Column("id", sa.Text(), nullable=False),
sa.Column("user_id", sa.Text(), nullable=False),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("token_prefix", sa.Text(), nullable=False),
sa.Column("token_hash", sa.Text(), nullable=False),
sa.Column("permission", sa.Text(), nullable=False),
sa.Column("token_type", sa.Text(), nullable=False),
sa.Column("expires_at", sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column("last_used_at", sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column("revoked_at", sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("NOW()")),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("token_hash", name="uq_auth_tokens_token_hash"),
)
inspector = inspect(bind)
if not _index_exists(inspector, "auth_tokens", "idx_auth_tokens_user_created"):
op.create_index("idx_auth_tokens_user_created", "auth_tokens", ["user_id", "created_at"])
if not _index_exists(inspector, "auth_tokens", "idx_auth_tokens_hash_active"):
op.create_index("idx_auth_tokens_hash_active", "auth_tokens", ["token_hash"])
def downgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if _table_exists(inspector, "auth_tokens"):
if _index_exists(inspector, "auth_tokens", "idx_auth_tokens_hash_active"):
op.drop_index("idx_auth_tokens_hash_active", table_name="auth_tokens")
if _index_exists(inspector, "auth_tokens", "idx_auth_tokens_user_created"):
op.drop_index("idx_auth_tokens_user_created", table_name="auth_tokens")
op.drop_table("auth_tokens")
inspector = inspect(bind)
if _column_exists(inspector, "users", "last_login_at"):
op.drop_column("users", "last_login_at")
if _column_exists(inspector, "users", "password_hash"):
op.drop_column("users", "password_hash")
if _column_exists(inspector, "users", "is_active"):
op.drop_column("users", "is_active")
if _column_exists(inspector, "users", "permission"):
op.drop_column("users", "permission")
@@ -0,0 +1,79 @@
"""add bot principals and bot token linkage
Revision ID: 20260403_0006
Revises: 20260403_0005
Create Date: 2026-04-03 13:30:00
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260403_0006"
down_revision = "20260403_0005"
branch_labels = None
depends_on = None
def _table_exists(inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _column_exists(inspector, table_name: str, column_name: str) -> bool:
return any(column["name"] == column_name for column in inspector.get_columns(table_name))
def _index_exists(inspector, table_name: str, index_name: str) -> bool:
return any(index["name"] == index_name for index in inspector.get_indexes(table_name))
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if not _table_exists(inspector, "bot_principals"):
op.create_table(
"bot_principals",
sa.Column("id", sa.Text(), nullable=False),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("permission", sa.Text(), nullable=False, server_default="read"),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("TRUE")),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("NOW()")),
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("NOW()")),
sa.Column("deactivated_at", sa.TIMESTAMP(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name", name="uq_bot_principals_name"),
)
inspector = inspect(bind)
if _table_exists(inspector, "auth_tokens") and not _column_exists(inspector, "auth_tokens", "bot_principal_id"):
op.add_column("auth_tokens", sa.Column("bot_principal_id", sa.Text(), nullable=True))
inspector = inspect(bind)
if _table_exists(inspector, "auth_tokens") and not _index_exists(inspector, "auth_tokens", "idx_auth_tokens_bot_created"):
op.create_index("idx_auth_tokens_bot_created", "auth_tokens", ["bot_principal_id", "created_at"])
if _table_exists(inspector, "bot_principals") and not _index_exists(inspector, "bot_principals", "idx_bot_principals_active_created"):
op.create_index("idx_bot_principals_active_created", "bot_principals", ["is_active", "created_at"])
def downgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if _table_exists(inspector, "auth_tokens") and _index_exists(inspector, "auth_tokens", "idx_auth_tokens_bot_created"):
op.drop_index("idx_auth_tokens_bot_created", table_name="auth_tokens")
if _table_exists(inspector, "auth_tokens") and _column_exists(inspector, "auth_tokens", "bot_principal_id"):
op.drop_column("auth_tokens", "bot_principal_id")
inspector = inspect(bind)
if _table_exists(inspector, "bot_principals") and _index_exists(inspector, "bot_principals", "idx_bot_principals_active_created"):
op.drop_index("idx_bot_principals_active_created", table_name="bot_principals")
if _table_exists(inspector, "bot_principals"):
op.drop_table("bot_principals")
@@ -0,0 +1,62 @@
"""remove bot owner link and enforce bot permission
Revision ID: 20260403_0007
Revises: 20260403_0006
Create Date: 2026-04-03 14:20:00
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260403_0007"
down_revision = "20260403_0006"
branch_labels = None
depends_on = None
def _table_exists(inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _column_exists(inspector, table_name: str, column_name: str) -> bool:
return any(column["name"] == column_name for column in inspector.get_columns(table_name))
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if not _table_exists(inspector, "bot_principals"):
return
if not _column_exists(inspector, "bot_principals", "permission"):
op.add_column("bot_principals", sa.Column("permission", sa.Text(), nullable=True))
op.execute("UPDATE bot_principals SET permission = 'read' WHERE permission IS NULL")
op.alter_column("bot_principals", "permission", existing_type=sa.Text(), nullable=False)
inspector = inspect(bind)
if _column_exists(inspector, "bot_principals", "owner_user_id"):
op.drop_column("bot_principals", "owner_user_id")
def downgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if not _table_exists(inspector, "bot_principals"):
return
if not _column_exists(inspector, "bot_principals", "owner_user_id"):
op.add_column("bot_principals", sa.Column("owner_user_id", sa.Text(), nullable=True))
op.execute("UPDATE bot_principals SET owner_user_id = 'legacy-owner' WHERE owner_user_id IS NULL")
op.alter_column("bot_principals", "owner_user_id", existing_type=sa.Text(), nullable=False)
inspector = inspect(bind)
if _column_exists(inspector, "bot_principals", "permission"):
op.drop_column("bot_principals", "permission")
@@ -0,0 +1,51 @@
"""add portfolio name column
Revision ID: 20260403_0008
Revises: 20260403_0007
Create Date: 2026-04-03 14:45:00
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260403_0008"
down_revision = "20260403_0007"
branch_labels = None
depends_on = None
def _table_exists(inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _column_exists(inspector, table_name: str, column_name: str) -> bool:
return any(column["name"] == column_name for column in inspector.get_columns(table_name))
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if not _table_exists(inspector, "portfolios"):
return
if not _column_exists(inspector, "portfolios", "name"):
op.add_column("portfolios", sa.Column("name", sa.Text(), nullable=True))
op.execute("UPDATE portfolios SET name = symbol WHERE name IS NULL OR name = ''")
op.alter_column("portfolios", "name", existing_type=sa.Text(), nullable=False)
def downgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if not _table_exists(inspector, "portfolios"):
return
if _column_exists(inspector, "portfolios", "name"):
op.drop_column("portfolios", "name")
@@ -0,0 +1,45 @@
"""portfolio_comments table
Revision ID: 20260410_0009
Revises: 20260403_0008
Create Date: 2026-04-10 00:00:00
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "20260410_0009"
down_revision = "20260403_0008"
branch_labels = None
depends_on = None
def upgrade() -> None:
# `ensure_schema()` can create this table ahead of Alembic on already-running
# environments, so this migration must be safe to re-apply.
op.execute(
"""
CREATE TABLE IF NOT EXISTS portfolio_comments (
id TEXT NOT NULL,
portfolio_id TEXT NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
PRIMARY KEY (id)
)
"""
)
op.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portfolio_comments_portfolio_created
ON portfolio_comments (portfolio_id, created_at DESC)
"""
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS idx_portfolio_comments_portfolio_created")
op.execute("DROP TABLE IF EXISTS portfolio_comments")
@@ -0,0 +1,97 @@
"""add portfolio owner user id
Revision ID: 20260413_0010
Revises: 20260410_0009
Create Date: 2026-04-13 01:00:00
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260413_0010"
down_revision = "20260410_0009"
branch_labels = None
depends_on = None
def _column_exists(inspector, table_name: str, column_name: str) -> bool:
return any(column["name"] == column_name for column in inspector.get_columns(table_name))
def _index_exists(inspector, table_name: str, index_name: str) -> bool:
return any(index["name"] == index_name for index in inspector.get_indexes(table_name))
def _fk_exists(inspector, table_name: str, fk_name: str) -> bool:
return any(fk.get("name") == fk_name for fk in inspector.get_foreign_keys(table_name))
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if not _column_exists(inspector, "portfolios", "owner_user_id"):
op.add_column("portfolios", sa.Column("owner_user_id", sa.Text(), nullable=True))
bind.execute(
sa.text(
"""
WITH fallback_owner AS (
SELECT id
FROM users
ORDER BY created_at ASC, id ASC
LIMIT 1
)
UPDATE portfolios p
SET owner_user_id = f.id
FROM fallback_owner f
WHERE p.owner_user_id IS NULL
"""
)
)
unresolved = bind.execute(sa.text("SELECT COUNT(*) FROM portfolios WHERE owner_user_id IS NULL")).scalar()
if unresolved and int(unresolved) > 0:
raise RuntimeError("owner_user_id backfill failed: users 테이블에 fallback 소유주가 필요합니다")
op.alter_column("portfolios", "owner_user_id", existing_type=sa.Text(), nullable=False)
inspector = inspect(bind)
if not _index_exists(inspector, "portfolios", "idx_portfolios_owner_updated"):
op.create_index(
"idx_portfolios_owner_updated",
"portfolios",
["owner_user_id", sa.text("updated_at DESC")],
unique=False,
)
inspector = inspect(bind)
if not _fk_exists(inspector, "portfolios", "fk_portfolios_owner_user"):
op.create_foreign_key(
"fk_portfolios_owner_user",
"portfolios",
"users",
["owner_user_id"],
["id"],
ondelete="RESTRICT",
)
def downgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if _fk_exists(inspector, "portfolios", "fk_portfolios_owner_user"):
op.drop_constraint("fk_portfolios_owner_user", "portfolios", type_="foreignkey")
inspector = inspect(bind)
if _index_exists(inspector, "portfolios", "idx_portfolios_owner_updated"):
op.drop_index("idx_portfolios_owner_updated", table_name="portfolios")
inspector = inspect(bind)
if _column_exists(inspector, "portfolios", "owner_user_id"):
op.drop_column("portfolios", "owner_user_id")
@@ -0,0 +1,97 @@
"""restore bot principal owner user id
Revision ID: 20260414_0011
Revises: 20260413_0010
Create Date: 2026-04-14 10:30:00
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260414_0011"
down_revision = "20260413_0010"
branch_labels = None
depends_on = None
def _column_exists(inspector, table_name: str, column_name: str) -> bool:
return any(column["name"] == column_name for column in inspector.get_columns(table_name))
def _index_exists(inspector, table_name: str, index_name: str) -> bool:
return any(index["name"] == index_name for index in inspector.get_indexes(table_name))
def _fk_exists(inspector, table_name: str, fk_name: str) -> bool:
return any(fk.get("name") == fk_name for fk in inspector.get_foreign_keys(table_name))
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if not _column_exists(inspector, "bot_principals", "owner_user_id"):
op.add_column("bot_principals", sa.Column("owner_user_id", sa.Text(), nullable=True))
bind.execute(
sa.text(
"""
WITH fallback_owner AS (
SELECT id
FROM users
ORDER BY created_at ASC, id ASC
LIMIT 1
)
UPDATE bot_principals b
SET owner_user_id = f.id
FROM fallback_owner f
WHERE b.owner_user_id IS NULL
"""
)
)
unresolved = bind.execute(sa.text("SELECT COUNT(*) FROM bot_principals WHERE owner_user_id IS NULL")).scalar()
if unresolved and int(unresolved) > 0:
raise RuntimeError("bot_principals.owner_user_id backfill failed: users 테이블에 fallback 소유주가 필요합니다")
op.alter_column("bot_principals", "owner_user_id", existing_type=sa.Text(), nullable=False)
inspector = inspect(bind)
if not _index_exists(inspector, "bot_principals", "idx_bot_principals_owner_active_created"):
op.create_index(
"idx_bot_principals_owner_active_created",
"bot_principals",
["owner_user_id", "is_active", sa.text("created_at DESC")],
unique=False,
)
inspector = inspect(bind)
if not _fk_exists(inspector, "bot_principals", "fk_bot_principals_owner_user"):
op.create_foreign_key(
"fk_bot_principals_owner_user",
"bot_principals",
"users",
["owner_user_id"],
["id"],
ondelete="RESTRICT",
)
def downgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if _fk_exists(inspector, "bot_principals", "fk_bot_principals_owner_user"):
op.drop_constraint("fk_bot_principals_owner_user", "bot_principals", type_="foreignkey")
inspector = inspect(bind)
if _index_exists(inspector, "bot_principals", "idx_bot_principals_owner_active_created"):
op.drop_index("idx_bot_principals_owner_active_created", table_name="bot_principals")
inspector = inspect(bind)
if _column_exists(inspector, "bot_principals", "owner_user_id"):
op.drop_column("bot_principals", "owner_user_id")
@@ -0,0 +1,79 @@
"""add portfolio tags table
Revision ID: 20260414_0012
Revises: 20260414_0011
Create Date: 2026-04-14 16:10:00
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260414_0012"
down_revision = "20260414_0011"
branch_labels = None
depends_on = None
def _table_exists(inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _index_exists(inspector, table_name: str, index_name: str) -> bool:
return any(index["name"] == index_name for index in inspector.get_indexes(table_name))
def _fk_exists(inspector, table_name: str, fk_name: str) -> bool:
return any(fk.get("name") == fk_name for fk in inspector.get_foreign_keys(table_name))
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if not _table_exists(inspector, "portfolio_tags"):
op.create_table(
"portfolio_tags",
sa.Column("portfolio_id", sa.Text(), nullable=False),
sa.Column("tag_value", sa.Text(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("NOW()")),
sa.PrimaryKeyConstraint("portfolio_id", "tag_value", name="pk_portfolio_tags"),
)
inspector = inspect(bind)
if not _fk_exists(inspector, "portfolio_tags", "fk_portfolio_tags_portfolio"):
op.create_foreign_key(
"fk_portfolio_tags_portfolio",
"portfolio_tags",
"portfolios",
["portfolio_id"],
["portfolio_id"],
ondelete="CASCADE",
)
inspector = inspect(bind)
if not _index_exists(inspector, "portfolio_tags", "idx_portfolio_tags_tag_portfolio"):
op.create_index(
"idx_portfolio_tags_tag_portfolio",
"portfolio_tags",
["tag_value", "portfolio_id"],
unique=False,
)
def downgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if _table_exists(inspector, "portfolio_tags"):
if _fk_exists(inspector, "portfolio_tags", "fk_portfolio_tags_portfolio"):
op.drop_constraint("fk_portfolio_tags_portfolio", "portfolio_tags", type_="foreignkey")
inspector = inspect(bind)
if _index_exists(inspector, "portfolio_tags", "idx_portfolio_tags_tag_portfolio"):
op.drop_index("idx_portfolio_tags_tag_portfolio", table_name="portfolio_tags")
op.drop_table("portfolio_tags")
@@ -0,0 +1,158 @@
"""remove bot_principals and auth_audit_logs, add service token support
Revision ID: 20260423_0013
Revises: 20260414_0012
Create Date: 2026-04-23 00:00:00
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260423_0013"
down_revision = "20260414_0012"
branch_labels = None
depends_on = None
def _table_exists(inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _column_exists(inspector, table_name: str, column_name: str) -> bool:
if not _table_exists(inspector, table_name):
return False
return any(c["name"] == column_name for c in inspector.get_columns(table_name))
def _index_exists(inspector, table_name: str, index_name: str) -> bool:
if not _table_exists(inspector, table_name):
return False
return any(idx["name"] == index_name for idx in inspector.get_indexes(table_name))
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
# 사전 검증: bot_principals.owner_user_id NULL 행이 있으면 중단
if _table_exists(inspector, "bot_principals"):
null_count = bind.execute(
sa.text("SELECT COUNT(*) FROM bot_principals WHERE owner_user_id IS NULL")
).scalar()
if null_count and int(null_count) > 0:
raise RuntimeError(
f"마이그레이션 중단: bot_principals.owner_user_id NULL 행 {null_count}개 발견. "
"owner_user_id를 먼저 보정한 후 다시 실행하세요."
)
# auth_tokens.user_id 보정: bot_principal.id → bot_principal.owner_user_id
if _column_exists(inspector, "auth_tokens", "bot_principal_id"):
bind.execute(
sa.text(
"""
UPDATE auth_tokens t
SET user_id = b.owner_user_id
FROM bot_principals b
WHERE t.bot_principal_id = b.id
AND t.bot_principal_id IS NOT NULL
"""
)
)
# token_type 'bot' → 'service' rename
bind.execute(
sa.text("UPDATE auth_tokens SET token_type = 'service' WHERE token_type = 'bot'")
)
# service token name unique index 생성
inspector = inspect(bind)
if not _index_exists(inspector, "auth_tokens", "idx_auth_tokens_service_name_unique"):
op.execute(
"""
CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_tokens_service_name_unique
ON auth_tokens (name)
WHERE token_type = 'service'
"""
)
# auth_tokens.bot_principal_id 컬럼 DROP
inspector = inspect(bind)
if _column_exists(inspector, "auth_tokens", "bot_principal_id"):
# 기존 인덱스 제거 후 컬럼 DROP
if _index_exists(inspector, "auth_tokens", "idx_auth_tokens_bot_created"):
op.drop_index("idx_auth_tokens_bot_created", table_name="auth_tokens")
op.drop_column("auth_tokens", "bot_principal_id")
# auth_audit_logs 테이블 DROP
inspector = inspect(bind)
if _table_exists(inspector, "auth_audit_logs"):
op.drop_table("auth_audit_logs")
# bot_principals 테이블 DROP (관련 인덱스·FK 포함)
inspector = inspect(bind)
if _table_exists(inspector, "bot_principals"):
for idx_name in [
"idx_bot_principals_active_created",
"idx_bot_principals_owner_active_created",
]:
if _index_exists(inspector, "bot_principals", idx_name):
op.drop_index(idx_name, table_name="bot_principals")
op.drop_table("bot_principals")
def downgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
# bot_principals 테이블 복원
if not _table_exists(inspector, "bot_principals"):
op.create_table(
"bot_principals",
sa.Column("id", sa.Text(), nullable=False),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("owner_user_id", sa.Text(), nullable=True),
sa.Column("permission", sa.Text(), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=True, server_default=sa.text("true")),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column("deactivated_at", sa.TIMESTAMP(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
# auth_audit_logs 테이블 복원
if not _table_exists(inspector, "auth_audit_logs"):
op.create_table(
"auth_audit_logs",
sa.Column("id", sa.Text(), nullable=False),
sa.Column("event_type", sa.Text(), nullable=False),
sa.Column("action", sa.Text(), nullable=False),
sa.Column("actor_id", sa.Text(), nullable=True),
sa.Column("bot_id", sa.Text(), nullable=False),
sa.Column("token_id_masked", sa.Text(), nullable=True),
sa.Column("token_ref_masked", sa.Text(), nullable=True),
sa.Column("result", sa.Text(), nullable=True),
sa.Column("detail", sa.Text(), nullable=True),
sa.Column("payload", sa.JSON(), nullable=True),
sa.Column("occurred_at", sa.TIMESTAMP(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
# auth_tokens.bot_principal_id 컬럼 복원
inspector = inspect(bind)
if not _column_exists(inspector, "auth_tokens", "bot_principal_id"):
op.add_column("auth_tokens", sa.Column("bot_principal_id", sa.Text(), nullable=True))
# token_type 'service' → 'bot' 역방향 (구조 복원 목적)
bind.execute(
sa.text("UPDATE auth_tokens SET token_type = 'bot' WHERE token_type = 'service'")
)
# service token unique index 제거
inspector = inspect(bind)
if _index_exists(inspector, "auth_tokens", "idx_auth_tokens_service_name_unique"):
op.drop_index("idx_auth_tokens_service_name_unique", table_name="auth_tokens")
@@ -0,0 +1,122 @@
"""Add v1 API schema: portfolios.market, auth_tokens.runtime_id, broker_credentials, ledger_trades
Revision ID: 20260502_0014
Revises: 20260423_0013
Create Date: 2026-05-02 00:00:00
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260502_0014"
down_revision = "20260423_0013"
branch_labels = None
depends_on = None
def _table_exists(inspector: sa.engine.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _column_exists(inspector: sa.engine.Inspector, table_name: str, column_name: str) -> bool:
if not _table_exists(inspector, table_name):
return False
return any(c["name"] == column_name for c in inspector.get_columns(table_name))
def _index_exists(inspector: sa.engine.Inspector, table_name: str, index_name: str) -> bool:
if not _table_exists(inspector, table_name):
return False
return any(idx["name"] == index_name for idx in inspector.get_indexes(table_name))
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
# 1. portfolios.market TEXT NULLABLE
if not _column_exists(inspector, "portfolios", "market"):
op.add_column("portfolios", sa.Column("market", sa.Text(), nullable=True))
# 2. auth_tokens.runtime_id TEXT UNIQUE NULLABLE
if not _column_exists(inspector, "auth_tokens", "runtime_id"):
op.add_column("auth_tokens", sa.Column("runtime_id", sa.Text(), nullable=True))
op.create_index(
"idx_auth_tokens_runtime_id_unique",
"auth_tokens",
["runtime_id"],
unique=True,
postgresql_where=sa.text("runtime_id IS NOT NULL"),
)
# 3. broker_credentials 테이블 신규
if not _table_exists(inspector, "broker_credentials"):
op.create_table(
"broker_credentials",
sa.Column("user_id", sa.Text(), nullable=False),
sa.Column("broker", sa.Text(), nullable=False),
sa.Column("account_ref", sa.Text(), nullable=False),
sa.Column("encrypted_payload", sa.Text(), nullable=False),
sa.Column("credential_version", sa.Integer(), nullable=False, server_default="1"),
sa.Column(
"last_rotated_at",
sa.TIMESTAMP(timezone=True),
nullable=False,
server_default=sa.text("NOW()"),
),
sa.PrimaryKeyConstraint("user_id", "broker"),
)
# 4. ledger_trades 테이블 신규
if not _table_exists(inspector, "ledger_trades"):
op.create_table(
"ledger_trades",
sa.Column(
"ledger_entry_id",
sa.BigInteger(),
sa.Identity(always=False),
nullable=False,
),
sa.Column("asset_id", sa.Text(), nullable=False),
sa.Column("order_id", sa.Text(), nullable=False),
sa.Column("execution_id", sa.Text(), nullable=False),
sa.Column("side", sa.Text(), nullable=False),
sa.Column("quantity", sa.Numeric(20, 8), nullable=False),
sa.Column("price", sa.Numeric(20, 8), nullable=False),
sa.Column("fee_amount", sa.Numeric(20, 8), nullable=False, server_default="0"),
sa.Column("tax_amount", sa.Numeric(20, 8), nullable=False, server_default="0"),
sa.Column("executed_at", sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column(
"recorded_at",
sa.TIMESTAMP(timezone=True),
nullable=False,
server_default=sa.text("NOW()"),
),
sa.PrimaryKeyConstraint("ledger_entry_id"),
sa.UniqueConstraint("asset_id", "order_id", "execution_id", name="uq_ledger_trades_idempotency"),
)
op.create_index(
"idx_ledger_trades_asset_executed",
"ledger_trades",
["asset_id", "executed_at"],
)
def downgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if _table_exists(inspector, "ledger_trades"):
op.drop_table("ledger_trades")
if _table_exists(inspector, "broker_credentials"):
op.drop_table("broker_credentials")
if _column_exists(inspector, "auth_tokens", "runtime_id"):
if _index_exists(inspector, "auth_tokens", "idx_auth_tokens_runtime_id_unique"):
op.drop_index("idx_auth_tokens_runtime_id_unique", table_name="auth_tokens")
op.drop_column("auth_tokens", "runtime_id")
if _column_exists(inspector, "portfolios", "market"):
op.drop_column("portfolios", "market")
@@ -0,0 +1,114 @@
"""portfolios.portfolio_id UUID 전환 및 (owner_user_id, name) UNIQUE 제약 추가
Revision ID: 20260511_0015
Revises: 20260502_0014
Create Date: 2026-05-11 00:00:00
"""
from __future__ import annotations
import uuid as _uuid
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260511_0015"
down_revision = "20260502_0014"
branch_labels = None
depends_on = None
def _column_exists(inspector: sa.engine.Inspector, table_name: str, column_name: str) -> bool:
if table_name not in inspector.get_table_names():
return False
return any(c["name"] == column_name for c in inspector.get_columns(table_name))
def _constraint_exists(inspector: sa.engine.Inspector, table_name: str, constraint_name: str) -> bool:
if table_name not in inspector.get_table_names():
return False
return any(c["name"] == constraint_name for c in inspector.get_unique_constraints(table_name))
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
# Step 1: Add name column IF NOT EXISTS (already present via ensure_schema in most envs)
if not _column_exists(inspector, "portfolios", "name"):
op.add_column("portfolios", sa.Column("name", sa.Text(), nullable=True))
bind.execute(sa.text("UPDATE portfolios SET name = portfolio_id WHERE name IS NULL OR name = ''"))
op.alter_column("portfolios", "name", nullable=False)
# Step 2: Guard — skip UUID replacement if already migrated (UNIQUE constraint present)
already_migrated = _constraint_exists(inspector, "portfolios", "uq_portfolios_owner_name")
if not already_migrated:
# Collect all existing portfolio_ids
result = bind.execute(sa.text("SELECT portfolio_id FROM portfolios"))
old_pids = [row[0] for row in result.fetchall()]
for old_pid in old_pids:
new_uuid = str(_uuid.uuid4())
# Copy old portfolio_id to name (overwrites display name with identifier)
bind.execute(
sa.text("UPDATE portfolios SET name = :old_pid WHERE portfolio_id = :old_pid"),
{"old_pid": old_pid},
)
# Update FK reference tables before changing portfolios PK
bind.execute(
sa.text("UPDATE portfolio_ledger SET portfolio_id = :new_uuid WHERE portfolio_id = :old_pid"),
{"new_uuid": new_uuid, "old_pid": old_pid},
)
bind.execute(
sa.text("UPDATE portfolio_comments SET portfolio_id = :new_uuid WHERE portfolio_id = :old_pid"),
{"new_uuid": new_uuid, "old_pid": old_pid},
)
bind.execute(
sa.text("UPDATE portfolio_tags SET portfolio_id = :new_uuid WHERE portfolio_id = :old_pid"),
{"new_uuid": new_uuid, "old_pid": old_pid},
)
bind.execute(
sa.text("UPDATE ledger_trades SET asset_id = :new_uuid WHERE asset_id = :old_pid"),
{"new_uuid": new_uuid, "old_pid": old_pid},
)
# Update portfolios PK last
bind.execute(
sa.text("UPDATE portfolios SET portfolio_id = :new_uuid WHERE portfolio_id = :old_pid"),
{"new_uuid": new_uuid, "old_pid": old_pid},
)
# Ensure no name nulls remain before adding UNIQUE constraint
bind.execute(
sa.text("UPDATE portfolios SET name = portfolio_id WHERE name IS NULL OR name = ''")
)
# Step 3: Add (owner_user_id, name) UNIQUE constraint
op.create_unique_constraint("uq_portfolios_owner_name", "portfolios", ["owner_user_id", "name"])
# Step 4: Verify no orphan records in FK reference tables
for table, col in [
("portfolio_ledger", "portfolio_id"),
("portfolio_comments", "portfolio_id"),
("portfolio_tags", "portfolio_id"),
]:
result = bind.execute(
sa.text(
f"SELECT COUNT(*) FROM {table}" # noqa: S608
f" WHERE {col} NOT IN (SELECT portfolio_id FROM portfolios)"
)
)
count = result.scalar()
if count and count > 0:
raise RuntimeError(
f"Migration 20260511_0015: {count} orphan record(s) found in {table}.{col}"
)
def downgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if _constraint_exists(inspector, "portfolios", "uq_portfolios_owner_name"):
op.drop_constraint("uq_portfolios_owner_name", "portfolios", type_="unique")
@@ -0,0 +1,69 @@
"""Daemon model: daemons table, auth_tokens.daemon_id, portfolios.managed_by_daemon_id
Revision ID: 20260513_0016
Revises: 20260511_0015
Create Date: 2026-05-13 00:00:00
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260513_0016"
down_revision = "20260511_0015"
branch_labels = None
depends_on = None
def _table_exists(inspector: sa.engine.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _column_exists(inspector: sa.engine.Inspector, table_name: str, column_name: str) -> bool:
if not _table_exists(inspector, table_name):
return False
return any(c["name"] == column_name for c in inspector.get_columns(table_name))
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
# Step 1: Create daemons table
if not _table_exists(inspector, "daemons"):
op.create_table(
"daemons",
sa.Column("id", sa.Text(), nullable=False),
sa.Column("owner_user_id", sa.Text(), nullable=False),
sa.Column("daemon_slug", sa.Text(), nullable=False),
sa.Column("display_name", sa.Text(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("NOW()")),
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("NOW()")),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("owner_user_id", "daemon_slug", name="uq_daemons_owner_slug"),
)
# Step 2: Add auth_tokens.daemon_id (nullable FK to daemons.id, no DB-level FK constraint — soft reference)
if not _column_exists(inspector, "auth_tokens", "daemon_id"):
op.add_column("auth_tokens", sa.Column("daemon_id", sa.Text(), nullable=True))
# Step 3: Add portfolios.managed_by_daemon_id (nullable soft reference to daemons.id)
if not _column_exists(inspector, "portfolios", "managed_by_daemon_id"):
op.add_column("portfolios", sa.Column("managed_by_daemon_id", sa.Text(), nullable=True))
def downgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if _column_exists(inspector, "portfolios", "managed_by_daemon_id"):
op.drop_column("portfolios", "managed_by_daemon_id")
if _column_exists(inspector, "auth_tokens", "daemon_id"):
op.drop_column("auth_tokens", "daemon_id")
if _table_exists(inspector, "daemons"):
op.drop_table("daemons")
@@ -0,0 +1,63 @@
"""Create assets catalog table (REQ-009)
Revision ID: 20260519_0017
Revises: 20260513_0016
Create Date: 2026-05-19 00:00:00
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260519_0017"
down_revision = "20260513_0016"
branch_labels = None
depends_on = None
def _table_exists(inspector: sa.engine.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _index_exists(inspector: sa.engine.Inspector, table_name: str, index_name: str) -> bool:
return any(idx["name"] == index_name for idx in inspector.get_indexes(table_name))
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if not _table_exists(inspector, "assets"):
op.create_table(
"assets",
sa.Column("asset_id", sa.UUID(), nullable=False, server_default=sa.text("gen_random_uuid()")),
sa.Column("symbol", sa.Text(), nullable=False),
sa.Column("markets", sa.ARRAY(sa.Text()), nullable=False, server_default=sa.text("ARRAY[]::text[]")),
sa.Column("display_name", sa.Text(), nullable=True),
sa.Column("status", sa.Text(), nullable=False, server_default=sa.text("'active'")),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("NOW()")),
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("NOW()")),
sa.PrimaryKeyConstraint("asset_id"),
sa.UniqueConstraint("symbol", name="uq_assets_symbol"),
)
if _table_exists(inspector, "assets") and not _index_exists(inspector, "assets", "idx_assets_markets_gin"):
op.create_index(
"idx_assets_markets_gin",
"assets",
["markets"],
postgresql_using="gin",
)
def downgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if _table_exists(inspector, "assets") and _index_exists(inspector, "assets", "idx_assets_markets_gin"):
op.drop_index("idx_assets_markets_gin", table_name="assets")
if _table_exists(inspector, "assets"):
op.drop_table("assets")
@@ -0,0 +1,123 @@
"""Portfolios asset migration: data migration + asset_id FK + symbol/market DROP (REQ-011, REQ-014)
Revision ID: 20260519_0018
Revises: 20260519_0017
Create Date: 2026-05-19 00:00:00
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect, text
revision = "20260519_0018"
down_revision = "20260519_0017"
branch_labels = None
depends_on = None
def _column_exists(inspector: sa.engine.Inspector, table_name: str, column_name: str) -> bool:
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
def _constraint_exists(inspector: sa.engine.Inspector, table_name: str, constraint_name: str) -> bool:
fk_constraints = inspector.get_foreign_keys(table_name)
return any(fk.get("name") == constraint_name for fk in fk_constraints)
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
# Step 1: Populate assets catalog from existing portfolios (REQ-014)
# Only if portfolios.symbol column still exists (idempotent guard).
# portfolios.market 컬럼은 이전 BAT-008 migration(20260502_0014) 또는
# ensure_schema() 경로에 따라 존재하지 않을 수 있다. REQ-014 본문 "market 값이
# NULL인 기존 행은 빈 배열로 처리"에 따라 컬럼 부재 시 markets=[]로 fallback.
if _column_exists(inspector, "portfolios", "symbol"):
if _column_exists(inspector, "portfolios", "market"):
bind.execute(text("""
INSERT INTO assets (symbol, markets)
SELECT
symbol,
COALESCE(
ARRAY_AGG(DISTINCT market) FILTER (WHERE market IS NOT NULL),
ARRAY[]::text[]
)
FROM portfolios
GROUP BY symbol
ON CONFLICT (symbol) DO NOTHING
"""))
else:
bind.execute(text("""
INSERT INTO assets (symbol, markets)
SELECT DISTINCT symbol, ARRAY[]::text[]
FROM portfolios
ON CONFLICT (symbol) DO NOTHING
"""))
# Step 2: Add portfolios.asset_id column (nullable, temporary)
if not _column_exists(inspector, "portfolios", "asset_id"):
op.add_column("portfolios", sa.Column("asset_id", sa.UUID(), nullable=True))
# Step 3: Back-fill asset_id from assets table
if _column_exists(inspector, "portfolios", "symbol") and _column_exists(inspector, "portfolios", "asset_id"):
bind.execute(text("""
UPDATE portfolios p
SET asset_id = a.asset_id
FROM assets a
WHERE a.symbol = p.symbol
AND p.asset_id IS NULL
"""))
# Step 4: Set NOT NULL constraint on asset_id
if _column_exists(inspector, "portfolios", "asset_id"):
op.alter_column("portfolios", "asset_id", nullable=False)
# Step 5: Add FK constraint (if not already present)
if not _constraint_exists(inspector, "portfolios", "fk_portfolios_asset_id"):
op.create_foreign_key(
"fk_portfolios_asset_id",
"portfolios",
"assets",
["asset_id"],
["asset_id"],
ondelete="RESTRICT",
)
# Step 6: Drop legacy columns
if _column_exists(inspector, "portfolios", "symbol"):
op.drop_column("portfolios", "symbol")
if _column_exists(inspector, "portfolios", "market"):
op.drop_column("portfolios", "market")
def downgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
# Restore symbol and market columns
if not _column_exists(inspector, "portfolios", "symbol"):
op.add_column("portfolios", sa.Column("symbol", sa.Text(), nullable=True))
if not _column_exists(inspector, "portfolios", "market"):
op.add_column("portfolios", sa.Column("market", sa.Text(), nullable=True))
# Back-fill symbol from assets table
if _column_exists(inspector, "portfolios", "asset_id") and _column_exists(inspector, "portfolios", "symbol"):
bind.execute(text("""
UPDATE portfolios p
SET symbol = a.symbol
FROM assets a
WHERE a.asset_id = p.asset_id
"""))
# Remove FK constraint
if _constraint_exists(inspector, "portfolios", "fk_portfolios_asset_id"):
op.drop_constraint("fk_portfolios_asset_id", "portfolios", type_="foreignkey")
# Remove asset_id column
if _column_exists(inspector, "portfolios", "asset_id"):
op.drop_column("portfolios", "asset_id")
@@ -0,0 +1,52 @@
"""Create tags master table (REQ-001)
Revision ID: 20260519_0019
Revises: 20260519_0018
Create Date: 2026-05-19 00:00:00
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260519_0019"
down_revision = "20260519_0018"
branch_labels = None
depends_on = None
def _table_exists(inspector: sa.engine.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _index_exists(inspector: sa.engine.Inspector, table_name: str, index_name: str) -> bool:
return any(idx["name"] == index_name for idx in inspector.get_indexes(table_name))
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if not _table_exists(inspector, "tags"):
op.create_table(
"tags",
sa.Column("tag_id", sa.UUID(), nullable=False, server_default=sa.text("gen_random_uuid()")),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("created_by_user_id", sa.Text(), nullable=False),
sa.Column("is_admin_tag", sa.Boolean(), nullable=False, server_default=sa.text("FALSE")),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("TRUE")),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("NOW()")),
sa.PrimaryKeyConstraint("tag_id"),
sa.UniqueConstraint("name", name="uq_tags_name"),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], name="fk_tags_created_by_user_id"),
)
def downgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if _table_exists(inspector, "tags"):
op.drop_table("tags")
@@ -0,0 +1,63 @@
"""Add portfolios.tag_ids UUID[] column and GIN index (REQ-002, REQ-003)
Revision ID: 20260519_0020
Revises: 20260519_0019
Create Date: 2026-05-19 00:00:00
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260519_0020"
down_revision = "20260519_0019"
branch_labels = None
depends_on = None
def _column_exists(inspector: sa.engine.Inspector, table_name: str, column_name: str) -> bool:
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
def _index_exists(inspector: sa.engine.Inspector, table_name: str, index_name: str) -> bool:
return any(idx["name"] == index_name for idx in inspector.get_indexes(table_name))
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if not _column_exists(inspector, "portfolios", "tag_ids"):
op.add_column(
"portfolios",
sa.Column(
"tag_ids",
sa.ARRAY(sa.UUID()),
nullable=False,
server_default=sa.text("'{}'::uuid[]"),
),
)
inspector = inspect(bind)
if _column_exists(inspector, "portfolios", "tag_ids") and not _index_exists(
inspector, "portfolios", "idx_portfolios_tag_ids_gin"
):
op.create_index(
"idx_portfolios_tag_ids_gin",
"portfolios",
["tag_ids"],
postgresql_using="gin",
)
def downgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if _index_exists(inspector, "portfolios", "idx_portfolios_tag_ids_gin"):
op.drop_index("idx_portfolios_tag_ids_gin", table_name="portfolios")
if _column_exists(inspector, "portfolios", "tag_ids"):
op.drop_column("portfolios", "tag_ids")
@@ -0,0 +1,132 @@
"""Migrate portfolio_tags data to tags master, fill tag_ids, drop portfolio_tags (REQ-006)
Revision ID: 20260519_0021
Revises: 20260519_0020
Create Date: 2026-05-19 00:00:00
IMPORTANT: Set MIGRATION_ADMIN_ID to a valid user ID that exists in the users table
before running this migration in a live environment.
"""
from __future__ import annotations
import os
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect, text
revision = "20260519_0021"
down_revision = "20260519_0020"
branch_labels = None
depends_on = None
# Operator must confirm this user ID exists in the users table before running.
# Override via `MIGRATION_ADMIN_ID` env var (recommended for live environments
# with existing portfolio_tags rows). Default placeholder triggers admin check
# failure on purpose if real data is present.
MIGRATION_ADMIN_ID = os.environ.get("MIGRATION_ADMIN_ID", "migration-admin")
def _table_exists(inspector: sa.engine.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def run_migration(connection, admin_user_id: str) -> None:
"""Core migration logic — extracted for testability.
admin_user_id can be either a UUID (id) or username.
"""
# Try to find user by id (UUID) first, then by username
result = connection.execute(
text("SELECT id FROM users WHERE id = :uid OR username = :uid LIMIT 1"),
{"uid": admin_user_id},
)
user_id = result.scalar()
if not user_id:
raise ValueError(
f"Migration admin user not found in users table: {admin_user_id}. "
"Set MIGRATION_ADMIN_ID to a valid user ID (UUID) or username before running."
)
# Migrate distinct (lowercased) tag_values from portfolio_tags → tags master
connection.execute(
text("""
INSERT INTO tags (name, created_by_user_id, is_admin_tag)
SELECT LOWER(tag_value), :admin_id, FALSE
FROM portfolio_tags
GROUP BY LOWER(tag_value)
ON CONFLICT (name) DO NOTHING
"""),
{"admin_id": user_id},
)
# Fill portfolios.tag_ids from the migrated tags
connection.execute(text("""
UPDATE portfolios p
SET tag_ids = COALESCE((
SELECT ARRAY_AGG(t.tag_id ORDER BY t.name)
FROM portfolio_tags pt
JOIN tags t ON t.name = LOWER(pt.tag_value)
WHERE pt.portfolio_id = p.portfolio_id
), '{}'::uuid[])
"""))
# Drop portfolio_tags table (data is now in tags + portfolios.tag_ids)
connection.execute(text("DROP TABLE IF EXISTS portfolio_tags"))
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if not _table_exists(inspector, "portfolio_tags"):
# portfolio_tags already gone — migration was already applied or skipped
return
# 빈 portfolio_tags 환경에서는 admin 검증·INSERT가 의미 없으므로 테이블만 drop.
# 데이터가 있으면 REQ-006 본문 정책에 따라 MIGRATION_ADMIN_ID 사전 검증을 강제.
row_count = bind.execute(text("SELECT COUNT(*) FROM portfolio_tags")).scalar() or 0
if row_count == 0:
bind.execute(text("DROP TABLE IF EXISTS portfolio_tags"))
return
run_migration(bind, MIGRATION_ADMIN_ID)
def downgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
# Re-create portfolio_tags table
if not _table_exists(inspector, "portfolio_tags"):
op.create_table(
"portfolio_tags",
sa.Column("portfolio_id", sa.Text(), nullable=False),
sa.Column("tag_value", sa.Text(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("NOW()")),
sa.PrimaryKeyConstraint("portfolio_id", "tag_value", name="pk_portfolio_tags"),
sa.ForeignKeyConstraint(
["portfolio_id"], ["portfolios.portfolio_id"],
name="fk_portfolio_tags_portfolio",
ondelete="CASCADE",
),
)
op.create_index(
"idx_portfolio_tags_tag_portfolio",
"portfolio_tags",
["tag_value", "portfolio_id"],
)
# Restore portfolio_tags from tags + portfolios.tag_ids (best-effort reverse)
bind.execute(text("""
INSERT INTO portfolio_tags (portfolio_id, tag_value)
SELECT p.portfolio_id, t.name
FROM portfolios p
CROSS JOIN LATERAL UNNEST(p.tag_ids) AS tag_id_val
JOIN tags t ON t.tag_id = tag_id_val
ON CONFLICT DO NOTHING
"""))
# Clear tag_ids arrays and drop tags data added by this migration
# (cannot fully reverse is_admin_tag=FALSE origin without original data)
bind.execute(text("UPDATE portfolios SET tag_ids = '{}'::uuid[]"))
@@ -0,0 +1,60 @@
"""service_token rotate unique index 보강: revoked_at IS NULL 조건 추가 (REQ-017)
Revision ID: 20260520_0022
Revises: 20260519_0021
Create Date: 2026-05-20 00:00:00
"""
from __future__ import annotations
from alembic import op
from sqlalchemy import inspect, text
revision = "20260520_0022"
down_revision = "20260519_0021"
branch_labels = None
depends_on = None
def _index_exists(inspector, table_name: str, index_name: str) -> bool:
return any(idx["name"] == index_name for idx in inspector.get_indexes(table_name))
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
# Drop old index if it exists (non-partial index)
if _index_exists(inspector, "auth_tokens", "idx_auth_tokens_service_name_unique"):
op.execute("DROP INDEX IF EXISTS idx_auth_tokens_service_name_unique")
# CREATE INDEX CONCURRENTLY는 트랜잭션 밖에서 실행해야 하므로 autocommit_block 사용.
with op.get_context().autocommit_block():
op.execute(
text(
"""
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS idx_auth_tokens_service_name_unique
ON auth_tokens (name)
WHERE token_type = 'service' AND revoked_at IS NULL
"""
)
)
def downgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if _index_exists(inspector, "auth_tokens", "idx_auth_tokens_service_name_unique"):
op.execute("DROP INDEX IF EXISTS idx_auth_tokens_service_name_unique")
op.execute(
text(
"""
CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_tokens_service_name_unique
ON auth_tokens (name)
WHERE token_type = 'service'
"""
)
)
@@ -0,0 +1,63 @@
"""Add assets.tag_ids UUID[] column and GIN index (REQ-002 CHG-20260521-02)
Revision ID: 20260521_0023
Revises: 20260520_0022
Create Date: 2026-05-21 00:00:00
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260521_0023"
down_revision = "20260520_0022"
branch_labels = None
depends_on = None
def _column_exists(inspector: sa.engine.Inspector, table_name: str, column_name: str) -> bool:
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
def _index_exists(inspector: sa.engine.Inspector, table_name: str, index_name: str) -> bool:
return any(idx["name"] == index_name for idx in inspector.get_indexes(table_name))
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if not _column_exists(inspector, "assets", "tag_ids"):
op.add_column(
"assets",
sa.Column(
"tag_ids",
sa.ARRAY(sa.UUID()),
nullable=False,
server_default=sa.text("'{}'::uuid[]"),
),
)
inspector = inspect(bind)
if _column_exists(inspector, "assets", "tag_ids") and not _index_exists(
inspector, "assets", "idx_assets_tag_ids_gin"
):
op.create_index(
"idx_assets_tag_ids_gin",
"assets",
["tag_ids"],
postgresql_using="gin",
)
def downgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if _index_exists(inspector, "assets", "idx_assets_tag_ids_gin"):
op.drop_index("idx_assets_tag_ids_gin", table_name="assets")
if _column_exists(inspector, "assets", "tag_ids"):
op.drop_column("assets", "tag_ids")
@@ -0,0 +1,53 @@
"""Rename ledger_trades.asset_id to portfolio_id for column-name semantic alignment (REQ-033)
Revision ID: 20260601_0024
Revises: 20260521_0023
Create Date: 2026-06-01 00:00:00
Background:
- REQ-029 implementation stored `portfolio_id` values in the `asset_id` column of `ledger_trades`,
causing schema readers to misinterpret the column as an `assets.asset_id` reference.
- dcy-005 FR-1 + REQ-033 mandates renaming the column to align name and meaning.
- No backfill required: existing values are already `portfolio_id` semantics.
"""
from __future__ import annotations
from alembic import op
from sqlalchemy import inspect, text
revision = "20260601_0024"
down_revision = "20260521_0023"
branch_labels = None
depends_on = None
def _column_exists(inspector, table_name: str, column_name: str) -> bool:
cols = inspector.get_columns(table_name)
return any(c["name"] == column_name for c in cols)
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if not inspector.has_table("ledger_trades"):
return
if _column_exists(inspector, "ledger_trades", "asset_id") and not _column_exists(
inspector, "ledger_trades", "portfolio_id"
):
op.execute(text("ALTER TABLE ledger_trades RENAME COLUMN asset_id TO portfolio_id"))
def downgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if not inspector.has_table("ledger_trades"):
return
if _column_exists(inspector, "ledger_trades", "portfolio_id") and not _column_exists(
inspector, "ledger_trades", "asset_id"
):
op.execute(text("ALTER TABLE ledger_trades RENAME COLUMN portfolio_id TO asset_id"))
@@ -0,0 +1,94 @@
"""consolidate ledger trades into portfolio ledger
Revision ID: 20260605_0025
Revises: 20260601_0024
Create Date: 2026-06-05 00:25:00.000000
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
revision: str = "20260605_0025"
down_revision: str | None = "20260601_0024"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# PostgreSQL identity columns are implicitly NOT NULL. ``nullable=True``
# renders a conflicting trailing ``NULL`` clause on a real PostgreSQL DB.
op.add_column("portfolio_ledger", sa.Column("ledger_entry_id", sa.BigInteger(), sa.Identity(always=False), nullable=False))
op.add_column("portfolio_ledger", sa.Column("order_id", sa.Text(), nullable=True))
op.add_column("portfolio_ledger", sa.Column("execution_id", sa.Text(), nullable=True))
op.add_column("portfolio_ledger", sa.Column("side", sa.Text(), nullable=True))
op.add_column("portfolio_ledger", sa.Column("executed_at", sa.DateTime(timezone=True), nullable=True))
op.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 $$;
"""
)
op.execute("UPDATE portfolio_ledger SET side = 'manual' WHERE side IS NULL")
op.execute("UPDATE portfolio_ledger SET executed_at = recorded_at WHERE executed_at IS NULL")
op.alter_column("portfolio_ledger", "side", nullable=False)
op.alter_column("portfolio_ledger", "executed_at", nullable=False)
for column in (
"price",
"quantity",
"fee_amount",
"tax_amount",
"cash_balance_after",
"asset_quantity_after",
"total_value_after",
"profit_buffer_after",
):
op.alter_column(
"portfolio_ledger",
column,
existing_type=sa.Float(),
type_=sa.Numeric(20, 8),
postgresql_using=f"{column}::numeric(20,8)",
)
op.drop_column("portfolio_ledger", "action")
op.create_index("idx_portfolio_ledger_portfolio_executed", "portfolio_ledger", ["portfolio_id", sa.text("executed_at DESC")])
op.drop_table("ledger_trades")
def downgrade() -> None:
op.create_table(
"ledger_trades",
sa.Column("ledger_trade_id", sa.BigInteger(), primary_key=True),
sa.Column("portfolio_id", sa.Text(), nullable=False),
sa.Column("order_id", sa.Text(), nullable=False),
sa.Column("execution_id", sa.Text(), nullable=False),
sa.Column("side", sa.Text(), nullable=False),
sa.Column("quantity", sa.Float(), nullable=False),
sa.Column("price", sa.Float(), nullable=False),
sa.Column("fee_amount", sa.Float(), nullable=False, server_default="0"),
sa.Column("tax_amount", sa.Float(), nullable=False, server_default="0"),
sa.Column("executed_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("portfolio_id", "order_id", "execution_id", name="uq_ledger_trades_idempotency"),
)
op.add_column("portfolio_ledger", sa.Column("action", sa.Text(), nullable=True))
op.execute("UPDATE portfolio_ledger SET action = upper(side) WHERE action IS NULL")
op.alter_column("portfolio_ledger", "action", nullable=False)
op.drop_index("idx_portfolio_ledger_portfolio_executed", table_name="portfolio_ledger")
op.drop_column("portfolio_ledger", "executed_at")
op.drop_column("portfolio_ledger", "side")
op.drop_column("portfolio_ledger", "execution_id")
op.drop_column("portfolio_ledger", "order_id")
op.drop_column("portfolio_ledger", "ledger_entry_id")
@@ -0,0 +1,69 @@
"""broker_credentials: daemon_id TEXT NULL 컬럼 추가 (REQ-047 Step1)
Revision ID: 20260628_0026
Revises: 20260605_0025
Create Date: 2026-06-28 00:00:00
NOTE: 이 revision은 REQ-047 Step1 — nullable daemon_id 컬럼 추가.
기존 데이터는 daemon_id=NULL 상태로 보존된다(백필은 REQ-048 CLI 별도 수행).
Step2(PK 전환)는 20260628_0027_broker_credential_daemon_id_step2.py에서 수행.
Step2는 비가역(PK 변경)이므로 사장님 결재 후 별도 적용.
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260628_0026"
down_revision = "20260605_0025"
branch_labels = None
depends_on = None
def _table_exists(inspector: sa.engine.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _column_exists(inspector: sa.engine.Inspector, table_name: str, column_name: str) -> bool:
if not _table_exists(inspector, table_name):
return False
return any(c["name"] == column_name for c in inspector.get_columns(table_name))
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
# broker_credentials에 daemon_id TEXT NULL 컬럼 추가
# 기존 PK (user_id, broker)는 그대로 유지
if _table_exists(inspector, "broker_credentials"):
if not _column_exists(inspector, "broker_credentials", "daemon_id"):
op.add_column(
"broker_credentials",
sa.Column("daemon_id", sa.Text(), nullable=True),
)
# daemon_id 인덱스 추가 (조회 성능)
op.create_index(
"idx_broker_credentials_daemon_id",
"broker_credentials",
["daemon_id"],
unique=False,
postgresql_where=sa.text("daemon_id IS NOT NULL"),
)
def downgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if _table_exists(inspector, "broker_credentials"):
# 인덱스 먼저 제거
existing_indexes = [idx["name"] for idx in inspector.get_indexes("broker_credentials")]
if "idx_broker_credentials_daemon_id" in existing_indexes:
op.drop_index("idx_broker_credentials_daemon_id", table_name="broker_credentials")
if _column_exists(inspector, "broker_credentials", "daemon_id"):
op.drop_column("broker_credentials", "daemon_id")
@@ -0,0 +1,144 @@
"""broker_credentials: PK (user_id,broker) → (daemon_id,broker) 전환 (REQ-047 Step2)
Revision ID: 20260628_0027
Revises: 20260628_0026
Create Date: 2026-06-28 00:00:00
CRITICAL: 이 revision은 비가역 PK 변경을 포함한다.
- 실 운영 DB 적용 전 사장님 결재 필수 (dcy-010 R-1, 외부 비가역 작업 게이트)
- REQ-048 backfill-daemon-id CLI로 모든 행의 daemon_id가 채워진 이후에만 적용 가능
(NOT NULL 제약으로 인해 daemon_id=NULL 행 존재 시 upgrade 실패)
- downgrade는 데이터 손실 위험 — 운영 환경에서 rollback 전 별도 데이터 검증 필요
적용 순서:
1. 20260628_0026 (Step1) — daemon_id NULL 컬럼 추가
2. pfhub credential backfill-daemon-id (REQ-048 CLI)
3. 20260628_0027 (Step2, 이 파일) — PK 전환 (사장님 결재 후 적용)
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect, text
revision = "20260628_0027"
down_revision = "20260628_0026"
branch_labels = None
depends_on = None
def _table_exists(inspector: sa.engine.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _column_exists(inspector: sa.engine.Inspector, table_name: str, column_name: str) -> bool:
if not _table_exists(inspector, table_name):
return False
return any(c["name"] == column_name for c in inspector.get_columns(table_name))
def _constraint_exists(bind, table_name: str, constraint_name: str) -> bool:
result = bind.execute(text(
"""
SELECT 1 FROM information_schema.table_constraints
WHERE table_name = :table AND constraint_name = :cname
"""
), {"table": table_name, "cname": constraint_name})
return result.fetchone() is not None
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if not _table_exists(inspector, "broker_credentials"):
return
# 사전 검증: daemon_id IS NULL인 행이 있으면 upgrade 거부
result = bind.execute(text(
"SELECT COUNT(*) FROM broker_credentials WHERE daemon_id IS NULL"
))
null_count = result.scalar()
if null_count and null_count > 0:
raise RuntimeError(
f"broker_credentials에 daemon_id IS NULL인 행이 {null_count}건 있습니다. "
"REQ-048 backfill-daemon-id CLI로 모든 행을 백필한 후 다시 실행하세요."
)
# Step 1: daemon_id NOT NULL 제약 추가
if _column_exists(inspector, "broker_credentials", "daemon_id"):
op.alter_column("broker_credentials", "daemon_id", nullable=False)
# Step 2: 기존 PK (user_id, broker) 제거
# PostgreSQL: PK 이름 찾기
pk_constraints = [
c for c in inspector.get_pk_constraint("broker_credentials").get("constrained_columns", [])
]
# PK 제약 이름을 직접 조회
bind.execute(text(
"""
ALTER TABLE broker_credentials DROP CONSTRAINT IF EXISTS broker_credentials_pkey
"""
))
# Step 3: 새 PK (daemon_id, broker) 추가
op.create_primary_key(
"broker_credentials_pkey",
"broker_credentials",
["daemon_id", "broker"],
)
# NOTE: user_id 컬럼은 감사/이력 목적으로 유지 (삭제 금지, REQ-047 AC-4)
def downgrade() -> None:
"""
WARN: 이 downgrade는 데이터 손실 위험을 수반한다.
(daemon_id, broker) PK를 (user_id, broker)로 복원할 때,
같은 user_id + broker 조합이 여러 daemon에 존재하면 충돌이 발생한다.
운영 환경에서는 downgrade 전 반드시 별도 데이터 검증을 수행하라.
"""
bind = op.get_bind()
inspector = inspect(bind)
if not _table_exists(inspector, "broker_credentials"):
return
# 안전장치: 실 운영 환경에서 downgrade 시도 경고
# (REQ-047 AC-7: downgrade 실패 또는 명시적 경고 존재)
result = bind.execute(text(
"""
SELECT COUNT(*) FROM (
SELECT user_id, broker, COUNT(*) as cnt
FROM broker_credentials
GROUP BY user_id, broker
HAVING COUNT(*) > 1
) t
"""
))
conflict_count = result.scalar()
if conflict_count and conflict_count > 0:
raise RuntimeError(
f"downgrade 불가: {conflict_count}개 (user_id, broker) 조합이 중복됩니다. "
"같은 user의 여러 daemon이 동일 broker를 사용하고 있어 "
"(user_id, broker) PK로 복원 시 데이터 손실이 발생합니다. "
"수동으로 데이터를 정리한 후 downgrade하세요."
)
# PK 전환 역방향: (daemon_id, broker) → (user_id, broker)
bind.execute(text(
"ALTER TABLE broker_credentials DROP CONSTRAINT IF EXISTS broker_credentials_pkey"
))
# daemon_id 컬럼을 nullable로 복원
if _column_exists(inspector, "broker_credentials", "daemon_id"):
op.alter_column("broker_credentials", "daemon_id", nullable=True)
# (user_id, broker) PK 복원
op.create_primary_key(
"broker_credentials_pkey",
"broker_credentials",
["user_id", "broker"],
)
@@ -0,0 +1,96 @@
"""portfolio_ledger: manual ledger metadata columns backfill-safe upgrade
Revision ID: 20260715_0028
Revises: 20260628_0027
Create Date: 2026-07-15 00:00:00
This migration adds the portfolio_ledger additive metadata columns introduced by
REQ-056. It is safe for both fresh DBs and legacy DBs where ensure_schema() already
created columns at runtime.
- source: TEXT NOT NULL DEFAULT 'broker'
- target_ledger_entry_id: BIGINT NULL
- confirmation_evidence: TEXT NULL
- actor_user_id: TEXT NULL
Runtime behavior:
- if source exists, backfill NULL -> 'broker', then enforce NOT NULL/default.
- if source/other columns are absent, add them as idempotent ALTERs.
- existing rows are preserved; only metadata defaults/backfill are adjusted.
- downgrade is forward-only (columns are not removed).
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect, text
revision = "20260715_0028"
down_revision = "20260628_0027"
branch_labels = None
depends_on = None
def _table_exists(inspector: sa.engine.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _column_exists(inspector: sa.engine.Inspector, table_name: str, column_name: str) -> bool:
if not _table_exists(inspector, table_name):
return False
return any(c["name"] == column_name for c in inspector.get_columns(table_name))
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if not _table_exists(inspector, "portfolio_ledger"):
return
if not _column_exists(inspector, "portfolio_ledger", "source"):
op.add_column(
"portfolio_ledger",
sa.Column(
"source",
sa.Text(),
nullable=False,
server_default=sa.text("'broker'"),
),
)
else:
# runtime-precreated 환경: source가 NULL이면 명시적으로 backfill 후 NOT NULL + 기본값 보장
bind.execute(text("UPDATE portfolio_ledger SET source = 'broker' WHERE source IS NULL"))
op.alter_column(
"portfolio_ledger",
"source",
existing_type=sa.Text(),
server_default=sa.text("'broker'"),
nullable=False,
)
if not _column_exists(inspector, "portfolio_ledger", "target_ledger_entry_id"):
op.add_column(
"portfolio_ledger",
sa.Column("target_ledger_entry_id", sa.BigInteger(), nullable=True),
)
if not _column_exists(inspector, "portfolio_ledger", "confirmation_evidence"):
op.add_column(
"portfolio_ledger",
sa.Column("confirmation_evidence", sa.Text(), nullable=True),
)
if not _column_exists(inspector, "portfolio_ledger", "actor_user_id"):
op.add_column(
"portfolio_ledger",
sa.Column("actor_user_id", sa.Text(), nullable=True),
)
# Forward-only policy: 운영 rollback은 컬럼 삭제/재적용 경로가 아니다.
def downgrade() -> None:
"""Forward-only migration: 컬럼 삭제 downgrade는 수행하지 않는다."""
return
@@ -0,0 +1,121 @@
"""strategy_sets: active status, revision, and default strategy
Revision ID: 20260814_0029
Revises: 20260715_0028
Create Date: 2026-08-14 00:00:00
The migration is additive and idempotent for databases where ensure_schema()
may already have created the columns. Existing strategy IDs and portfolio
references are preserved. The canonical target_value/{} strategy is reused
when present, otherwise one stable default row is inserted.
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect, text
revision = "20260814_0029"
down_revision = "20260715_0028"
branch_labels = None
depends_on = None
_DEFAULT_STRATEGY_ID = "00000000-0000-0000-0000-000000000029"
_DEFAULT_NAME = "target_value"
_DEFAULT_CONFIG = "{}"
_DEFAULT_NICK = "target_value:bf21a9e8fbc5"
def _column_exists(inspector: sa.engine.Inspector, table_name: str, column_name: str) -> bool:
return any(column["name"] == column_name for column in inspector.get_columns(table_name))
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if "strategy_sets" not in inspector.get_table_names():
return
if not _column_exists(inspector, "strategy_sets", "status"):
op.add_column(
"strategy_sets",
sa.Column("status", sa.Text(), nullable=False, server_default=sa.text("'active'")),
)
if not _column_exists(inspector, "strategy_sets", "revision"):
op.add_column(
"strategy_sets",
sa.Column("revision", sa.Integer(), nullable=False, server_default=sa.text("1")),
)
if not _column_exists(inspector, "strategy_sets", "updated_at"):
op.add_column(
"strategy_sets",
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("NOW()")),
)
bind.execute(text("UPDATE strategy_sets SET status = 'active' WHERE status IS NULL"))
bind.execute(text("UPDATE strategy_sets SET revision = 1 WHERE revision IS NULL OR revision < 1"))
bind.execute(text("UPDATE strategy_sets SET updated_at = created_at WHERE updated_at IS NULL"))
bind.execute(
text(
"""
INSERT INTO strategy_sets (id, name, config, nick, status, revision)
SELECT :id, :name, :config, :nick, 'active', 1
WHERE NOT EXISTS (
SELECT 1 FROM strategy_sets WHERE name = :name AND config = :config
)
"""
),
{
"id": _DEFAULT_STRATEGY_ID,
"name": _DEFAULT_NAME,
"config": _DEFAULT_CONFIG,
"nick": _DEFAULT_NICK,
},
)
# The same (name, config) row is the canonical default; ensure it is selectable.
bind.execute(
text(
"""
UPDATE strategy_sets
SET status = 'active', updated_at = CASE WHEN status = 'active' THEN updated_at ELSE NOW() END
WHERE name = :name AND config = :config AND status <> 'active'
"""
),
{"name": _DEFAULT_NAME, "config": _DEFAULT_CONFIG},
)
# PostgreSQL has no ADD CONSTRAINT IF NOT EXISTS, so guard by catalog lookup.
bind.execute(
text(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'ck_strategy_sets_status'
AND conrelid = 'strategy_sets'::regclass
) THEN
ALTER TABLE strategy_sets
ADD CONSTRAINT ck_strategy_sets_status
CHECK (status IN ('active', 'inactive'));
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'ck_strategy_sets_revision_positive'
AND conrelid = 'strategy_sets'::regclass
) THEN
ALTER TABLE strategy_sets
ADD CONSTRAINT ck_strategy_sets_revision_positive
CHECK (revision >= 1);
END IF;
END $$;
"""
)
)
def downgrade() -> None:
"""Forward-only data migration; existing references and revisions are retained."""
return
View File