from datetime import datetime, timezone from typing import List, Optional from sqlalchemy import ( BigInteger, Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, ) from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database import Base def utcnow() -> datetime: """Naive UTC datetime. All datetimes in this codebase are stored as naive-UTC.""" return datetime.now(timezone.utc).replace(tzinfo=None) def iso_utc(dt: Optional[datetime]) -> Optional[str]: """Serialize a naive-UTC datetime to an explicit ISO-8601 UTC string (suffix 'Z'). If dt already carries tzinfo, convert to UTC first. Returns None if dt is None.""" if dt is None: return None if dt.tzinfo is not None: dt = dt.astimezone(timezone.utc).replace(tzinfo=None) # Drop microseconds past millisecond for compactness; always end in Z return dt.isoformat(timespec="milliseconds") + "Z" class Post(Base): __tablename__ = "posts" id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) external_id: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False) text: Mapped[str] = mapped_column(Text, nullable=False) source: Mapped[str] = mapped_column(String(32), nullable=False, default="truth") published_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) sentiment: Mapped[str] = mapped_column(String(16), nullable=False, default="neutral") ai_confidence: Mapped[int] = mapped_column(Integer, nullable=False, default=0) relevant: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) price_impact_asset: Mapped[Optional[str]] = mapped_column(String(8), nullable=True) price_impact_m5: Mapped[Optional[float]] = mapped_column(Float, nullable=True) price_impact_m15: Mapped[Optional[float]] = mapped_column(Float, nullable=True) price_impact_m1h: Mapped[Optional[float]] = mapped_column(Float, nullable=True) price_at_post: Mapped[Optional[float]] = mapped_column(Float, nullable=True) signal: Mapped[Optional[str]] = mapped_column(String(8), nullable=True) ai_reasoning: Mapped[Optional[str]] = mapped_column(Text, nullable=True) prefilter_reason: Mapped[Optional[str]] = mapped_column(String(32), nullable=True) analysis_version: Mapped[Optional[str]] = mapped_column(String(16), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow) trades: Mapped[List["BotTrade"]] = relationship("BotTrade", back_populates="trigger_post") class Candle(Base): __tablename__ = "candles" __table_args__ = (UniqueConstraint("asset", "timeframe", "time", name="uq_candle_asset_tf_time"),) id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) asset: Mapped[str] = mapped_column(String(8), nullable=False, index=True) timeframe: Mapped[str] = mapped_column(String(4), nullable=False) time: Mapped[int] = mapped_column(BigInteger, nullable=False, index=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 BotTrade(Base): __tablename__ = "bot_trades" id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) asset: Mapped[str] = mapped_column(String(8), nullable=False) side: Mapped[str] = mapped_column(String(8), nullable=False) entry_price: Mapped[float] = mapped_column(Float, nullable=False) exit_price: Mapped[Optional[float]] = mapped_column(Float, nullable=True) pnl_usd: Mapped[Optional[float]] = mapped_column(Float, nullable=True) hold_seconds: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) trigger_post_id: Mapped[Optional[int]] = mapped_column( Integer, ForeignKey("posts.id"), nullable=True, index=True ) wallet_address: Mapped[str] = mapped_column(String(64), nullable=False, index=True) opened_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow) closed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) hl_order_id: Mapped[Optional[str]] = mapped_column(String(128), nullable=True) trigger_post: Mapped[Optional["Post"]] = relationship("Post", back_populates="trades") class Subscription(Base): __tablename__ = "subscriptions" id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) wallet_address: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False) hl_api_key: Mapped[Optional[str]] = mapped_column(String(256), nullable=True) active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) subscribed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow) # Per-user trading config (null → use platform defaults) leverage: Mapped[int] = mapped_column(Integer, nullable=False, default=3) position_size_usd: Mapped[float] = mapped_column(Float, nullable=False, default=20.0) take_profit_pct: Mapped[Optional[float]] = mapped_column(Float, nullable=True) # e.g. 2.0 = close at +2% stop_loss_pct: Mapped[Optional[float]] = mapped_column(Float, nullable=True) # e.g. 1.5 = close at -1.5% min_confidence: Mapped[int] = mapped_column(Integer, nullable=False, default=80)