feat: database models and alembic migrations — all tables per design
- shared/db.py: async engine + session factory - shared/models/base.py: DeclarativeBase + TimestampMixin - shared/models/trading.py: Strategy, Signal, Trade, Position, StrategyWeightHistory - shared/models/news.py: Article, ArticleSentiment - shared/models/learning.py: TradeOutcome, LearningAdjustment - shared/models/auth.py: User, UserCredential - shared/models/timeseries.py: MarketData, PortfolioSnapshot, StrategyMetric - Alembic async env.py with initial migration including TimescaleDB hypertables - 21 model tests covering enums, instantiation, metadata registration
This commit is contained in:
parent
ae5b3f89d1
commit
72cb1b6fe5
23 changed files with 1283 additions and 0 deletions
22
shared/db.py
Normal file
22
shared/db.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"""SQLAlchemy async engine and session factory."""
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from shared.config import BaseConfig
|
||||
|
||||
|
||||
def create_db(config: BaseConfig) -> tuple:
|
||||
"""Create an async engine and session factory from the given config.
|
||||
|
||||
Returns a ``(engine, session_factory)`` tuple.
|
||||
"""
|
||||
engine = create_async_engine(
|
||||
config.database_url,
|
||||
echo=config.log_level == "DEBUG",
|
||||
)
|
||||
session_factory = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
return engine, session_factory
|
||||
44
shared/models/__init__.py
Normal file
44
shared/models/__init__.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"""Shared SQLAlchemy models — import all models here so Alembic can discover them."""
|
||||
|
||||
from shared.models.base import Base, TimestampMixin
|
||||
from shared.models.trading import (
|
||||
Signal,
|
||||
SignalDirection,
|
||||
Strategy,
|
||||
StrategyWeightHistory,
|
||||
Trade,
|
||||
TradeSide,
|
||||
TradeStatus,
|
||||
Position,
|
||||
)
|
||||
from shared.models.news import Article, ArticleSentiment
|
||||
from shared.models.learning import LearningAdjustment, TradeOutcome
|
||||
from shared.models.auth import User, UserCredential
|
||||
from shared.models.timeseries import MarketData, PortfolioSnapshot, StrategyMetric
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"TimestampMixin",
|
||||
# Trading
|
||||
"Strategy",
|
||||
"Signal",
|
||||
"SignalDirection",
|
||||
"Trade",
|
||||
"TradeSide",
|
||||
"TradeStatus",
|
||||
"Position",
|
||||
"StrategyWeightHistory",
|
||||
# News
|
||||
"Article",
|
||||
"ArticleSentiment",
|
||||
# Learning
|
||||
"TradeOutcome",
|
||||
"LearningAdjustment",
|
||||
# Auth
|
||||
"User",
|
||||
"UserCredential",
|
||||
# Timeseries
|
||||
"MarketData",
|
||||
"PortfolioSnapshot",
|
||||
"StrategyMetric",
|
||||
]
|
||||
BIN
shared/models/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
shared/models/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
shared/models/__pycache__/auth.cpython-314.pyc
Normal file
BIN
shared/models/__pycache__/auth.cpython-314.pyc
Normal file
Binary file not shown.
BIN
shared/models/__pycache__/base.cpython-314.pyc
Normal file
BIN
shared/models/__pycache__/base.cpython-314.pyc
Normal file
Binary file not shown.
BIN
shared/models/__pycache__/learning.cpython-314.pyc
Normal file
BIN
shared/models/__pycache__/learning.cpython-314.pyc
Normal file
Binary file not shown.
BIN
shared/models/__pycache__/news.cpython-314.pyc
Normal file
BIN
shared/models/__pycache__/news.cpython-314.pyc
Normal file
Binary file not shown.
BIN
shared/models/__pycache__/timeseries.cpython-314.pyc
Normal file
BIN
shared/models/__pycache__/timeseries.cpython-314.pyc
Normal file
Binary file not shown.
BIN
shared/models/__pycache__/trading.cpython-314.pyc
Normal file
BIN
shared/models/__pycache__/trading.cpython-314.pyc
Normal file
Binary file not shown.
39
shared/models/auth.py
Normal file
39
shared/models/auth.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
"""Authentication models: User, UserCredential."""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import ForeignKey, Integer, LargeBinary, String
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from shared.models.base import Base, TimestampMixin
|
||||
|
||||
|
||||
class User(TimestampMixin, Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
username: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
||||
display_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
# Relationships
|
||||
credentials: Mapped[list["UserCredential"]] = relationship(back_populates="user")
|
||||
|
||||
|
||||
class UserCredential(TimestampMixin, Base):
|
||||
__tablename__ = "user_credentials"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id"), nullable=False
|
||||
)
|
||||
credential_id: Mapped[str] = mapped_column(String(512), unique=True, nullable=False)
|
||||
public_key: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
|
||||
sign_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
# Relationships
|
||||
user: Mapped[User] = relationship(back_populates="credentials")
|
||||
26
shared/models/base.py
Normal file
26
shared/models/base.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"""SQLAlchemy declarative base and common mixins."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, func
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Shared declarative base for all models."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
"""Adds ``created_at`` and ``updated_at`` columns with server defaults."""
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
51
shared/models/learning.py
Normal file
51
shared/models/learning.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"""Learning domain models: TradeOutcome, LearningAdjustment."""
|
||||
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlalchemy import Boolean, Float, ForeignKey, Interval, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from shared.models.base import Base, TimestampMixin
|
||||
|
||||
|
||||
class TradeOutcome(TimestampMixin, Base):
|
||||
__tablename__ = "trade_outcomes"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
trade_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("trades.id"), unique=True, nullable=False
|
||||
)
|
||||
hold_duration: Mapped[timedelta | None] = mapped_column(Interval, nullable=True)
|
||||
realized_pnl: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
roi_pct: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
was_profitable: Mapped[bool] = mapped_column(Boolean, nullable=False)
|
||||
|
||||
# Relationships
|
||||
trade: Mapped["Trade"] = relationship("Trade", back_populates="outcome")
|
||||
|
||||
|
||||
class LearningAdjustment(TimestampMixin, Base):
|
||||
__tablename__ = "learning_adjustments"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
strategy_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("strategies.id"), nullable=False
|
||||
)
|
||||
old_weight: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
new_weight: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
reward_signal: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
|
||||
# Relationships
|
||||
strategy: Mapped["Strategy"] = relationship("Strategy")
|
||||
|
||||
|
||||
# Avoid circular imports — reference by string in relationship()
|
||||
from shared.models.trading import Trade as Trade # noqa: E402, F401
|
||||
from shared.models.trading import Strategy as Strategy # noqa: E402, F401
|
||||
49
shared/models/news.py
Normal file
49
shared/models/news.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"""News and sentiment models: Article, ArticleSentiment."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Float, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from shared.models.base import Base, TimestampMixin
|
||||
|
||||
|
||||
class Article(TimestampMixin, Base):
|
||||
__tablename__ = "articles"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
source: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
url: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
title: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
published_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
fetched_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False
|
||||
)
|
||||
content_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
|
||||
# Relationships
|
||||
sentiments: Mapped[list["ArticleSentiment"]] = relationship(back_populates="article")
|
||||
|
||||
|
||||
class ArticleSentiment(TimestampMixin, Base):
|
||||
__tablename__ = "article_sentiments"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
article_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("articles.id"), nullable=False
|
||||
)
|
||||
ticker: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||
score: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
confidence: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
model_used: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
|
||||
# Relationships
|
||||
article: Mapped[Article] = relationship(back_populates="sentiments")
|
||||
57
shared/models/timeseries.py
Normal file
57
shared/models/timeseries.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"""TimescaleDB hypertable models: MarketData, PortfolioSnapshot, StrategyMetric."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from shared.models.base import Base
|
||||
|
||||
|
||||
class MarketData(Base):
|
||||
"""OHLCV bars — intended as a TimescaleDB hypertable partitioned by timestamp."""
|
||||
|
||||
__tablename__ = "market_data"
|
||||
|
||||
timestamp: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), primary_key=True
|
||||
)
|
||||
ticker: Mapped[str] = mapped_column(String(20), primary_key=True)
|
||||
open: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
high: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
low: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
close: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
volume: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
|
||||
|
||||
class PortfolioSnapshot(Base):
|
||||
"""Periodic portfolio value snapshots — TimescaleDB hypertable."""
|
||||
|
||||
__tablename__ = "portfolio_snapshots"
|
||||
|
||||
timestamp: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), primary_key=True
|
||||
)
|
||||
total_value: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
cash: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
positions_value: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
daily_pnl: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
|
||||
|
||||
class StrategyMetric(Base):
|
||||
"""Per-strategy performance over time — TimescaleDB hypertable."""
|
||||
|
||||
__tablename__ = "strategy_metrics"
|
||||
|
||||
timestamp: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), primary_key=True
|
||||
)
|
||||
strategy_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("strategies.id"), primary_key=True
|
||||
)
|
||||
win_rate: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
total_pnl: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
trade_count: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
sharpe_ratio: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
137
shared/models/trading.py
Normal file
137
shared/models/trading.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
"""Trading domain models: Strategy, Signal, Trade, Position, StrategyWeightHistory."""
|
||||
|
||||
import enum
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Boolean, Float, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSON, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from shared.models.base import Base, TimestampMixin
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enums
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TradeSide(str, enum.Enum):
|
||||
BUY = "BUY"
|
||||
SELL = "SELL"
|
||||
|
||||
|
||||
class TradeStatus(str, enum.Enum):
|
||||
PENDING = "PENDING"
|
||||
FILLED = "FILLED"
|
||||
CANCELLED = "CANCELLED"
|
||||
REJECTED = "REJECTED"
|
||||
|
||||
|
||||
class SignalDirection(str, enum.Enum):
|
||||
LONG = "LONG"
|
||||
SHORT = "SHORT"
|
||||
NEUTRAL = "NEUTRAL"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Strategy(TimestampMixin, Base):
|
||||
__tablename__ = "strategies"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
current_weight: Mapped[float] = mapped_column(Float, nullable=False, default=0.333)
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
# Relationships
|
||||
trades: Mapped[list["Trade"]] = relationship(back_populates="strategy")
|
||||
signals: Mapped[list["Signal"]] = relationship(back_populates="strategy", foreign_keys="Signal.strategy_id", viewonly=True)
|
||||
weight_history: Mapped[list["StrategyWeightHistory"]] = relationship(back_populates="strategy")
|
||||
|
||||
|
||||
class Signal(TimestampMixin, Base):
|
||||
__tablename__ = "signals"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
ticker: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||
direction: Mapped[SignalDirection] = mapped_column(nullable=False)
|
||||
strength: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
strategy_sources: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
sentiment_score: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
acted_on: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
strategy_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("strategies.id"), nullable=True
|
||||
)
|
||||
|
||||
# Relationships
|
||||
strategy: Mapped[Strategy | None] = relationship(back_populates="signals", foreign_keys=[strategy_id])
|
||||
trades: Mapped[list["Trade"]] = relationship(back_populates="signal")
|
||||
|
||||
|
||||
class Trade(TimestampMixin, Base):
|
||||
__tablename__ = "trades"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
ticker: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||
side: Mapped[TradeSide] = mapped_column(nullable=False)
|
||||
qty: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
price: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
timestamp: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
strategy_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("strategies.id"), nullable=True
|
||||
)
|
||||
signal_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("signals.id"), nullable=True
|
||||
)
|
||||
status: Mapped[TradeStatus] = mapped_column(nullable=False, default=TradeStatus.PENDING)
|
||||
pnl: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
# Relationships
|
||||
strategy: Mapped[Strategy | None] = relationship(back_populates="trades")
|
||||
signal: Mapped[Signal | None] = relationship(back_populates="trades")
|
||||
outcome: Mapped["TradeOutcome | None"] = relationship(
|
||||
"TradeOutcome", back_populates="trade", uselist=False
|
||||
)
|
||||
|
||||
|
||||
class Position(TimestampMixin, Base):
|
||||
__tablename__ = "positions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
ticker: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
|
||||
qty: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
avg_entry: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
unrealized_pnl: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
stop_loss: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
take_profit: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
|
||||
class StrategyWeightHistory(TimestampMixin, Base):
|
||||
__tablename__ = "strategy_weight_history"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
strategy_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("strategies.id"), nullable=False
|
||||
)
|
||||
old_weight: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
new_weight: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
# Relationships
|
||||
strategy: Mapped[Strategy] = relationship(back_populates="weight_history")
|
||||
|
||||
|
||||
# Avoid circular import — TradeOutcome is defined in learning.py
|
||||
from shared.models.learning import TradeOutcome # noqa: E402, F401
|
||||
Loading…
Add table
Add a link
Reference in a new issue