I1: Add graceful shutdown (SIGTERM/SIGINT) to all 5 background services I2: Fix Dockerfile healthcheck to use curl on /metrics endpoint I3: Fix StreamConsumer.ensure_group() to only catch BUSYGROUP errors I4: Fix SimulatedBroker to reject orders with insufficient cash/shares I5: Move ORM attribute access inside DB session context in trades routes I6: Add Redis-based rate limiting (10 req/min/IP) on all auth endpoints I8: Prevent backtest background task garbage collection I9: Use Numeric(16,6) instead of Float for financial columns in migration I10: Add index on trades.created_at for time-range queries I11: Bind infrastructure ports to 127.0.0.1 in docker-compose I12: Add migrations init service; all app services depend on it I13: Fix user enumeration in login_begin (return options for non-existent users)
284 lines
11 KiB
Python
284 lines
11 KiB
Python
"""initial schema
|
|
|
|
Revision ID: a1b2c3d4e5f6
|
|
Revises:
|
|
Create Date: 2026-02-22 15:15:15.661206
|
|
|
|
"""
|
|
from typing import Sequence, Union
|
|
|
|
import sqlalchemy as sa
|
|
from alembic import op
|
|
from sqlalchemy.dialects import postgresql
|
|
|
|
# revision identifiers, used by Alembic.
|
|
revision: str = "a1b2c3d4e5f6"
|
|
down_revision: Union[str, Sequence[str], None] = None
|
|
branch_labels: Union[str, Sequence[str], None] = None
|
|
depends_on: Union[str, Sequence[str], None] = None
|
|
|
|
|
|
def upgrade() -> None:
|
|
"""Create all tables for the trading bot."""
|
|
|
|
# --- Core trading tables ---
|
|
|
|
op.create_table(
|
|
"strategies",
|
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
|
sa.Column("name", sa.String(255), unique=True, nullable=False),
|
|
sa.Column("description", sa.Text, nullable=True),
|
|
sa.Column("current_weight", sa.Float, nullable=False, server_default="0.333"),
|
|
sa.Column("active", sa.Boolean, nullable=False, server_default=sa.text("true")),
|
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
)
|
|
|
|
op.create_table(
|
|
"signals",
|
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
|
sa.Column("ticker", sa.String(20), nullable=False, index=True),
|
|
sa.Column(
|
|
"direction",
|
|
sa.Enum("LONG", "SHORT", "NEUTRAL", name="signaldirection"),
|
|
nullable=False,
|
|
),
|
|
sa.Column("strength", sa.Float, nullable=False),
|
|
sa.Column("strategy_sources", postgresql.JSON, nullable=True),
|
|
sa.Column("sentiment_score", sa.Float, nullable=True),
|
|
sa.Column("acted_on", sa.Boolean, nullable=False, server_default=sa.text("false")),
|
|
sa.Column(
|
|
"strategy_id",
|
|
postgresql.UUID(as_uuid=True),
|
|
sa.ForeignKey("strategies.id"),
|
|
nullable=True,
|
|
),
|
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
)
|
|
|
|
op.create_table(
|
|
"trades",
|
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
|
sa.Column("ticker", sa.String(20), nullable=False, index=True),
|
|
sa.Column(
|
|
"side",
|
|
sa.Enum("BUY", "SELL", name="tradeside"),
|
|
nullable=False,
|
|
),
|
|
sa.Column("qty", sa.Numeric(16, 6), nullable=False),
|
|
sa.Column("price", sa.Numeric(16, 6), nullable=False),
|
|
sa.Column("timestamp", sa.String, nullable=True),
|
|
sa.Column(
|
|
"strategy_id",
|
|
postgresql.UUID(as_uuid=True),
|
|
sa.ForeignKey("strategies.id"),
|
|
nullable=True,
|
|
),
|
|
sa.Column(
|
|
"signal_id",
|
|
postgresql.UUID(as_uuid=True),
|
|
sa.ForeignKey("signals.id"),
|
|
nullable=True,
|
|
),
|
|
sa.Column(
|
|
"status",
|
|
sa.Enum("PENDING", "FILLED", "CANCELLED", "REJECTED", name="tradestatus"),
|
|
nullable=False,
|
|
server_default="PENDING",
|
|
),
|
|
sa.Column("pnl", sa.Numeric(16, 6), nullable=True),
|
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), index=True),
|
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
)
|
|
|
|
op.create_table(
|
|
"positions",
|
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
|
sa.Column("ticker", sa.String(20), unique=True, nullable=False),
|
|
sa.Column("qty", sa.Numeric(16, 6), nullable=False),
|
|
sa.Column("avg_entry", sa.Numeric(16, 6), nullable=False),
|
|
sa.Column("unrealized_pnl", sa.Numeric(16, 6), nullable=True),
|
|
sa.Column("stop_loss", sa.Numeric(16, 6), nullable=True),
|
|
sa.Column("take_profit", sa.Numeric(16, 6), nullable=True),
|
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
)
|
|
|
|
op.create_table(
|
|
"strategy_weight_history",
|
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
|
sa.Column(
|
|
"strategy_id",
|
|
postgresql.UUID(as_uuid=True),
|
|
sa.ForeignKey("strategies.id"),
|
|
nullable=False,
|
|
),
|
|
sa.Column("old_weight", sa.Float, nullable=False),
|
|
sa.Column("new_weight", sa.Float, nullable=False),
|
|
sa.Column("reason", sa.String(500), nullable=True),
|
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
)
|
|
|
|
# --- News & sentiment ---
|
|
|
|
op.create_table(
|
|
"articles",
|
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
|
sa.Column("source", sa.String(100), nullable=False),
|
|
sa.Column("url", sa.Text, nullable=False),
|
|
sa.Column("title", sa.Text, nullable=False),
|
|
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
|
|
sa.Column("fetched_at", sa.DateTime(timezone=True), nullable=False),
|
|
sa.Column("content_hash", sa.String(64), unique=True, nullable=False),
|
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
)
|
|
|
|
op.create_table(
|
|
"article_sentiments",
|
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
|
sa.Column(
|
|
"article_id",
|
|
postgresql.UUID(as_uuid=True),
|
|
sa.ForeignKey("articles.id"),
|
|
nullable=False,
|
|
),
|
|
sa.Column("ticker", sa.String(20), nullable=False, index=True),
|
|
sa.Column("score", sa.Float, nullable=False),
|
|
sa.Column("confidence", sa.Float, nullable=False),
|
|
sa.Column("model_used", sa.String(50), nullable=False),
|
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
)
|
|
|
|
# --- Learning ---
|
|
|
|
op.create_table(
|
|
"trade_outcomes",
|
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
|
sa.Column(
|
|
"trade_id",
|
|
postgresql.UUID(as_uuid=True),
|
|
sa.ForeignKey("trades.id"),
|
|
unique=True,
|
|
nullable=False,
|
|
),
|
|
sa.Column("hold_duration", sa.Interval, nullable=True),
|
|
sa.Column("realized_pnl", sa.Float, nullable=False),
|
|
sa.Column("roi_pct", sa.Float, nullable=False),
|
|
sa.Column("was_profitable", sa.Boolean, nullable=False),
|
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
)
|
|
|
|
op.create_table(
|
|
"learning_adjustments",
|
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
|
sa.Column(
|
|
"strategy_id",
|
|
postgresql.UUID(as_uuid=True),
|
|
sa.ForeignKey("strategies.id"),
|
|
nullable=False,
|
|
),
|
|
sa.Column("old_weight", sa.Float, nullable=False),
|
|
sa.Column("new_weight", sa.Float, nullable=False),
|
|
sa.Column("reason", sa.Text, nullable=True),
|
|
sa.Column("reward_signal", sa.Float, nullable=False),
|
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
)
|
|
|
|
# --- Auth ---
|
|
|
|
op.create_table(
|
|
"users",
|
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
|
sa.Column("username", sa.String(100), unique=True, nullable=False),
|
|
sa.Column("display_name", sa.String(255), nullable=True),
|
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
)
|
|
|
|
op.create_table(
|
|
"user_credentials",
|
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
|
sa.Column(
|
|
"user_id",
|
|
postgresql.UUID(as_uuid=True),
|
|
sa.ForeignKey("users.id"),
|
|
nullable=False,
|
|
),
|
|
sa.Column("credential_id", sa.String(512), unique=True, nullable=False),
|
|
sa.Column("public_key", sa.LargeBinary, nullable=False),
|
|
sa.Column("sign_count", sa.Integer, nullable=False, server_default="0"),
|
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
)
|
|
|
|
# --- Timeseries (TimescaleDB hypertables) ---
|
|
|
|
op.create_table(
|
|
"market_data",
|
|
sa.Column("timestamp", sa.DateTime(timezone=True), primary_key=True),
|
|
sa.Column("ticker", sa.String(20), primary_key=True),
|
|
sa.Column("open", sa.Float, nullable=False),
|
|
sa.Column("high", sa.Float, nullable=False),
|
|
sa.Column("low", sa.Float, nullable=False),
|
|
sa.Column("close", sa.Float, nullable=False),
|
|
sa.Column("volume", sa.Float, nullable=False),
|
|
)
|
|
|
|
op.create_table(
|
|
"portfolio_snapshots",
|
|
sa.Column("timestamp", sa.DateTime(timezone=True), primary_key=True),
|
|
sa.Column("total_value", sa.Float, nullable=False),
|
|
sa.Column("cash", sa.Float, nullable=False),
|
|
sa.Column("positions_value", sa.Float, nullable=False),
|
|
sa.Column("daily_pnl", sa.Float, nullable=False),
|
|
)
|
|
|
|
op.create_table(
|
|
"strategy_metrics",
|
|
sa.Column("timestamp", sa.DateTime(timezone=True), primary_key=True),
|
|
sa.Column(
|
|
"strategy_id",
|
|
postgresql.UUID(as_uuid=True),
|
|
sa.ForeignKey("strategies.id"),
|
|
primary_key=True,
|
|
),
|
|
sa.Column("win_rate", sa.Float, nullable=False),
|
|
sa.Column("total_pnl", sa.Float, nullable=False),
|
|
sa.Column("trade_count", sa.Integer, nullable=False),
|
|
sa.Column("sharpe_ratio", sa.Float, nullable=True),
|
|
)
|
|
|
|
# Convert timeseries tables to TimescaleDB hypertables.
|
|
# These calls are idempotent-safe when the extension is loaded.
|
|
op.execute("SELECT create_hypertable('market_data', 'timestamp', if_not_exists => TRUE)")
|
|
op.execute("SELECT create_hypertable('portfolio_snapshots', 'timestamp', if_not_exists => TRUE)")
|
|
op.execute("SELECT create_hypertable('strategy_metrics', 'timestamp', if_not_exists => TRUE)")
|
|
|
|
|
|
def downgrade() -> None:
|
|
"""Drop all tables in reverse dependency order."""
|
|
op.drop_table("strategy_metrics")
|
|
op.drop_table("portfolio_snapshots")
|
|
op.drop_table("market_data")
|
|
op.drop_table("user_credentials")
|
|
op.drop_table("users")
|
|
op.drop_table("learning_adjustments")
|
|
op.drop_table("trade_outcomes")
|
|
op.drop_table("article_sentiments")
|
|
op.drop_table("articles")
|
|
op.drop_table("strategy_weight_history")
|
|
op.drop_table("positions")
|
|
op.drop_table("trades")
|
|
op.drop_table("signals")
|
|
op.drop_table("strategies")
|
|
|
|
# Drop enums
|
|
sa.Enum(name="signaldirection").drop(op.get_bind(), checkfirst=True)
|
|
sa.Enum(name="tradeside").drop(op.get_bind(), checkfirst=True)
|
|
sa.Enum(name="tradestatus").drop(op.get_bind(), checkfirst=True)
|