Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
The alembic migration unconditionally called create_hypertable() which fails if TimescaleDB isn't installed on the PostgreSQL instance. Wrap in a DO block that checks for the extension first.
290 lines
12 KiB
Python
290 lines
12 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 if the extension is available.
|
|
op.execute("""
|
|
DO $$
|
|
BEGIN
|
|
IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') THEN
|
|
PERFORM create_hypertable('market_data', 'timestamp', if_not_exists => TRUE);
|
|
PERFORM create_hypertable('portfolio_snapshots', 'timestamp', if_not_exists => TRUE);
|
|
PERFORM create_hypertable('strategy_metrics', 'timestamp', if_not_exists => TRUE);
|
|
END IF;
|
|
END $$;
|
|
""")
|
|
|
|
|
|
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)
|