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) # v5: AI-decided trade target. Can be any HL perp (BTC/ETH/SOL/TRUMP/...), # not just BTC/ETH. The bot routes the actual trade here. # `price_impact_asset` stays bound to BTC/ETH for the existing # price_impact_monitor; `target_asset` is what we actually trade. target_asset: Mapped[Optional[str]] = mapped_column(String(16), nullable=True) category: Mapped[Optional[str]] = mapped_column(String(24), nullable=True) expected_move_pct: Mapped[Optional[float]] = mapped_column(Float, nullable=True) invalidation_price: Mapped[Optional[float]] = mapped_column(Float, 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) # Snapshot of user settings AT TIME OF ENTRY. Required so changes to # Subscription.position_size_usd / leverage after-the-fact don't corrupt # historical PnL or daily-budget accounting. size_usd: Mapped[Optional[float]] = mapped_column(Float, nullable=True) leverage: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # ── Effective exit params, FROZEN at open ─────────────────────────────── # A trade's risk profile is a property of the trade, not of the mutable # Subscription/category config. Without these, recovery.py rehydrates # every open position with the USER's Trump settings — silently rewriting # a 90-day reversal's stop to 1.5% / its max-hold to 7d on every restart. # Stamped from the resolved snapshot at open; recovery reads these back. eff_take_profit_pct: Mapped[Optional[float]] = mapped_column(Float, nullable=True) eff_stop_loss_pct: Mapped[Optional[float]] = mapped_column(Float, nullable=True) eff_trailing_stop_pct: Mapped[Optional[float]] = mapped_column(Float, nullable=True) eff_trailing_activate_pct: Mapped[Optional[float]] = mapped_column(Float, nullable=True) eff_max_hold_hours: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) eff_invalidation: Mapped[Optional[str]] = mapped_column(String(24), nullable=True) eff_invalidation_price: Mapped[Optional[float]] = mapped_column(Float, nullable=True) eff_min_hold_until_ts: Mapped[Optional[float]] = mapped_column(Float, nullable=True) # ── System-2 staged de-risk (分段式减仓) ───────────────────────────────── # A System-2 bottom trade is de-risked in stages as it moves against us: # partial reduce-only closes at the early rungs, full close at the last. # The trade stays OPEN (closed_at NULL) until the final rung / upside # ladder / max-hold. Legacy + System-1 rows keep the defaults so all # existing PnL math is unchanged (remaining=1.0, partial_pnl=0, steps=0). realized_partial_pnl_usd: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) remaining_fraction: Mapped[float] = mapped_column(Float, nullable=False, default=1.0) derisk_steps_done: Mapped[int] = mapped_column(Integer, nullable=False, default=0) # ── System-2 pyramiding (做对了往上加仓) ───────────────────────────────── # base_size_usd: the ORIGINAL notional at open (immutable). size_usd grows # as add-ons fill; entry_price becomes the blended average. addon_steps_done # counts executed pyramids. Pyramiding only runs while derisk_steps_done==0 # (clean uptrend), so remaining_fraction stays 1.0 throughout. base_size_usd: Mapped[Optional[float]] = mapped_column(Float, nullable=True) addon_steps_done: Mapped[int] = mapped_column(Integer, nullable=False, default=0) # Frozen System-2 risk mode for this trade ("standard"/"aggressive"). # NULL → legacy/standard. Recovery rebuilds mode-aware ladders from this. sys2_mode: Mapped[Optional[str]] = mapped_column(String(16), nullable=True) # Per-trade "Grow" switch. False (default): hold + protective de-risk / # ratchet only — NO pyramiding. True: also scale INTO this winner on # confirmed trend. User flips it per open position. De-risk + stop-loss # run regardless (safety floor is never user-toggleable). grow_mode: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) # Monotonic peak unrealised gain (%) seen by tp_sl_monitor. Persisted # (throttled) so a restart doesn't reset the regime: without it a # pyramided / in-profit System-2 trade would fall back to peak=0 → # underwater de-risk regime on the next restart. Recovery seeds the # monitor from this. peak_gain_pct: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) 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) daily_budget_usd: Mapped[Optional[float]] = mapped_column(Float, nullable=True) # e.g. 15.0 = max $15 of new positions per UTC day # Optional active window. Both None = always on (when Subscription.active=True). # Both stored as naive-UTC datetimes. active_from: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) active_until: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) # ── Convex-strategy fields ────────────────────────────────────────────── # Trailing-stop pair: when profit reaches `trailing_activate_at_pct`, switch # from fixed TP to a trailing stop at `trailing_stop_pct` below the peak. # Both None → legacy behaviour (fixed take_profit_pct closes the position). trailing_stop_pct: Mapped[Optional[float]] = mapped_column(Float, nullable=True) trailing_activate_at_pct: Mapped[Optional[float]] = mapped_column(Float, nullable=True) # Max hold time (hours). 168 = 7 days, long enough for trend capture. # Replaces the old hardcoded 1h cap. max_hold_hours: Mapped[int] = mapped_column(Integer, nullable=False, default=168) # One-shot "enable for the next N hours" override. When set and in the future, # the bot trades regardless of the active_from/active_until schedule. NULL or # past timestamp → fall back to the schedule. manual_window_until: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) # ── Phase 1: safety ───────────────────────────────────────────────────── # Paper mode: trades are simulated end-to-end (entry price from Binance, # exit from price_store at trigger time) but no Hyperliquid call is made. # Lets you forward-test a new signal source for 30+ days without real money. paper_mode: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) # Circuit breaker: set by services/circuit_breaker.py when daily DD or # consecutive-loss threshold trips. Blocks new trades for 24h and nulls # out any active manual_window. Cleared when user explicitly re-arms via # POST /api/user/{wallet}/manual-window. circuit_breaker_tripped_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) circuit_breaker_reason: Mapped[Optional[str]] = mapped_column(String(32), nullable=True) # ── Two-system separation ─────────────────────────────────────────────── # System 1 (Trump) and System 2 (reversal) must not starve or halt each # other. sys2_budget_pct slices the daily budget; the sys2_cb_* columns # are an independent circuit breaker so a Trump losing streak can't lock # out a once-a-year reversal signal (and vice versa). sys2_budget_pct: Mapped[float] = mapped_column(Float, nullable=False, default=0.7) sys2_cb_tripped_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) sys2_cb_reason: Mapped[Optional[str]] = mapped_column(String(32), nullable=True) # ── System-2 dynamic leverage ─────────────────────────────────────────── # System 2 (bottom reversal) uses its OWN leverage, independent of the # Trump `leverage` field. The user picks it freely; the value at signal # time is frozen onto the trade. The protective stop in tp_sl_monitor is # auto-scaled to this leverage so the position is de-risked INSIDE the # exchange liquidation line — it is never liquidated by the exchange. # NULL → fall back to SYS2_DEFAULT_LEVERAGE (signal_categories). sys2_leverage: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # System-2 risk mode: "standard" (tuned cycle-rider, low leverage) or # "aggressive" (separately-funded high-risk sleeve: high leverage, heavier # earlier pyramiding, wider peak-trail, lighter early de-risk). Both keep # the never-exchange-liquidated + post-pyramid-breakeven invariants. sys2_mode: Mapped[str] = mapped_column(String(16), nullable=False, default="standard") # ── Master Auto-Trade switch (simplified operator model) ──────────────── # The ONE persistent gate the user controls. OFF (default, safe): signals # are still scanned + ingested (shown in the feed) but NO trade is opened. # ON: a qualifying signal auto-opens a trade. Replaces the old confusing # scanner-toggle / timed manual-window / schedule trio. Flipping it ON also # acknowledges + clears a tripped circuit breaker (human-in-the-loop). auto_trade: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) class KolPost(Base): """A long-form post (Substack) or tweet from a tracked KOL, with inline AI analysis. Standalone module — no FK into Trump posts/trades. Dedupe key: (source, external_id). For Substack external_id is the post URL; for Twitter it will be the tweet id. """ __tablename__ = "kol_posts" __table_args__ = (UniqueConstraint("source", "external_id", name="uq_kol_post_src_extid"),) id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) kol_handle: Mapped[str] = mapped_column(String(64), nullable=False, index=True) source: Mapped[str] = mapped_column(String(16), nullable=False) # substack|twitter external_id: Mapped[str] = mapped_column(String(512), nullable=False) title: Mapped[Optional[str]] = mapped_column(String(512), nullable=True) url: Mapped[str] = mapped_column(String(512), nullable=False) published_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True) raw_text: Mapped[str] = mapped_column(Text, nullable=False) content_hash: Mapped[str] = mapped_column(String(64), nullable=False) summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True) tickers_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True) analyzed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) analysis_model: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) analysis_version: Mapped[Optional[str]] = mapped_column(String(16), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow) # ── KOL A-tier: on-chain holdings ──────────────────────────────────────────── class KolWallet(Base): """One tracked wallet address for a KOL. Many-to-one with handle.""" __tablename__ = "kol_wallets" __table_args__ = (UniqueConstraint("chain", "address", name="uq_kol_wallet_chain_addr"),) id: Mapped[int] = mapped_column(Integer, primary_key=True) handle: Mapped[str] = mapped_column(String(64), nullable=False, index=True) chain: Mapped[str] = mapped_column(String(16), nullable=False) # ethereum|solana|hl address: Mapped[str] = mapped_column(String(128), nullable=False) label: Mapped[Optional[str]] = mapped_column(String(128), nullable=True) # e.g. "hot wallet" source_url: Mapped[Optional[str]] = mapped_column(String(256), nullable=True) # Arkham entity URL active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) added_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow) snapshots: Mapped[List["KolHoldingSnapshot"]] = relationship( "KolHoldingSnapshot", back_populates="wallet", order_by="KolHoldingSnapshot.snapshot_date.desc()" ) changes: Mapped[List["KolHoldingChange"]] = relationship( "KolHoldingChange", back_populates="wallet" ) class KolHoldingSnapshot(Base): """Daily portfolio snapshot for one wallet. holdings_json is a list of {ticker, amount, usd_value, chain} dicts. One row per (wallet, date).""" __tablename__ = "kol_holdings_snapshots" __table_args__ = (UniqueConstraint("wallet_id", "snapshot_date", name="uq_kol_snapshot_wallet_date"),) id: Mapped[int] = mapped_column(Integer, primary_key=True) wallet_id: Mapped[int] = mapped_column(Integer, ForeignKey("kol_wallets.id"), nullable=False, index=True) snapshot_date: Mapped[str] = mapped_column(String(10), nullable=False) # YYYY-MM-DD UTC holdings_json: Mapped[str] = mapped_column(Text, nullable=False) # JSON list total_usd: Mapped[Optional[float]] = mapped_column(Float, nullable=True) source: Mapped[str] = mapped_column(String(16), nullable=False) # arkham|hl|manual created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow) wallet: Mapped["KolWallet"] = relationship("KolWallet", back_populates="snapshots") class KolHoldingChange(Base): """A detected significant change between two consecutive daily snapshots.""" __tablename__ = "kol_holding_changes" id: Mapped[int] = mapped_column(Integer, primary_key=True) wallet_id: Mapped[int] = mapped_column(Integer, ForeignKey("kol_wallets.id"), nullable=False, index=True) detected_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True, default=utcnow) ticker: Mapped[str] = mapped_column(String(32), nullable=False) change_type: Mapped[str] = mapped_column(String(16), nullable=False) # new_position|closed|increased|decreased usd_before: Mapped[Optional[float]] = mapped_column(Float, nullable=True) usd_after: Mapped[Optional[float]] = mapped_column(Float, nullable=True) pct_change: Mapped[Optional[float]] = mapped_column(Float, nullable=True) # signed % created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow) wallet: Mapped["KolWallet"] = relationship("KolWallet", back_populates="changes") class KolDivergence(Base): """Cross-signal: a (post, on-chain change) pair for the same KOL+ticker within a ±7-day window. signal_type='divergence' → KOL's public post contradicts their on-chain action (says bullish, actually selling → smart-money caution) signal_type='alignment' → post and chain agree → higher-conviction signal direction='long'/'short' → net conclusion after resolving the pair """ __tablename__ = "kol_divergence" __table_args__ = ( UniqueConstraint("post_id", "change_id", name="uq_kol_divergence_pair"), ) id: Mapped[int] = mapped_column(Integer, primary_key=True) handle: Mapped[str] = mapped_column(String(64), nullable=False, index=True) ticker: Mapped[str] = mapped_column(String(32), nullable=False) # B-tier content side post_id: Mapped[int] = mapped_column(Integer, ForeignKey("kol_posts.id"), nullable=False) post_action: Mapped[str] = mapped_column(String(16), nullable=False) post_conviction: Mapped[Optional[float]]= mapped_column(Float, nullable=True) post_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) # A-tier on-chain side change_id: Mapped[int] = mapped_column(Integer, ForeignKey("kol_holding_changes.id"), nullable=False) onchain_action: Mapped[str] = mapped_column(String(16), nullable=False) usd_before: Mapped[Optional[float]]= mapped_column(Float, nullable=True) usd_after: Mapped[Optional[float]]= mapped_column(Float, nullable=True) onchain_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) # Classification signal_type: Mapped[str] = mapped_column(String(16), nullable=False) # divergence|alignment direction: Mapped[Optional[str]] = mapped_column(String(8), nullable=True) # long|short days_apart: Mapped[Optional[float]]= mapped_column(Float, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow) post: Mapped["KolPost"] = relationship("KolPost") change: Mapped["KolHoldingChange"] = relationship("KolHoldingChange") class TelegramBinding(Base): """Wallet ↔ Telegram chat_id mapping plus per-source alert preferences. Two flavours of binding co-exist in this table: Free tier (walletless): user DM'd the bot `/start` with no code. wallet_address is NULL. They get the same push content as Pro users unless they tune preferences in the bot. Pro tier (wallet-linked): user generated a 6-char code in Settings, replied `/start CODE` in the bot. wallet is attached so the dashboard knows which chat to disconnect / link / show status. Uniqueness: chat_id is unique across all rows (table-level). wallet_address is unique among non-NULL rows via the partial index in migration 021 — NEVER use SQLAlchemy `unique=True` here because some dialects would emit a plain UNIQUE that disallows multiple walletless rows. Preferences (alert_*, min_confidence, mute_*) are now mutated exclusively via the bot's /trump /btc /funding /kol /conf /quiet commands. The /api/telegram/preferences endpoint exists for legacy reasons but no UI surface calls it. """ __tablename__ = "telegram_bindings" id: Mapped[int] = mapped_column(Integer, primary_key=True) # NULL is allowed — see class docstring. Uniqueness for non-NULL values # is enforced by the partial unique index defined in migration 021. wallet_address: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True) chat_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True, index=True) tg_username: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) bound_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow) alerts_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) alert_trump: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) alert_btc_bottom: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) alert_funding: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) alert_kol_divergence: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) min_confidence: Mapped[int] = mapped_column(Integer, nullable=False, default=70) # Quiet hours (UTC). Both null = always on. mute_from_hour: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) mute_until_hour: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) last_alert_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) total_alerts_sent: Mapped[int] = mapped_column(Integer, nullable=False, default=0) total_alerts_failed: Mapped[int] = mapped_column(Integer, nullable=False, default=0)