Files
trumpsignal-backend/app/models.py
T
k 54884f3e24 KOL feeds: fix dead/blocked sources, drop stale feeds (29→25)
Feed-health pass over KOL_FEEDS:
- raoulpal: stale Substack (last 2024-05) → Real Vision podcast feed
- dampedspring: paywalled (0 entries) → free "Damped Spring 101" Substack
- unchained: Cloudflare 403 → canonical Megaphone podcast feed
- lynalden: Cloudflare 202 → FeedBurner mirror
- glassnode: recovered via httpx http2=True (was 403 on HTTP/1.1)
- browser User-Agent + Accept headers on feed fetch
- removed dead feeds with no active replacement: placeholder,
  dragonfly, niccarter, eugene
- pin h2==4.3.0 (required by http2=True)

All 25 remaining feeds verified fetching real body content; newest
post per feed ≤88d. Bundles in-flight KOL-module work already in the
working tree (kol_x ingest, migration 027, tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:55:16 +08:00

480 lines
30 KiB
Python

from datetime import date, datetime, timezone
from typing import List, Optional
from sqlalchemy import (
BigInteger,
Boolean,
Date,
DateTime,
Float,
ForeignKey,
Index,
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"
__table_args__ = (
# Composite index for the two most common query patterns:
# circuit_breaker: WHERE wallet_address=? AND closed_at>=? AND pnl_usd IS NOT NULL
# trades API: WHERE wallet_address=? ORDER BY closed_at DESC
Index("ix_bot_trades_wallet_closed", "wallet_address", "closed_at"),
)
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)
# User-initiated "stop managing" marker. When set: recovery skips
# re-register on restart, monitor stops driving, HL position keeps
# running under the user's own control. NOT the same as closed_at —
# there's no exit_price / pnl on a released row. Used by /release in
# the adopt-then-release flow for System-2 positions.
released_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)
# ── Module on/off toggles ───────────────────────────────────────────────
# Both default False so newly-subscribed users start with the bot idle
# until they explicitly enable a module and save settings.
trump_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
macro_enabled: 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)
# x_analysis extended output (migration 027)
tier: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
post_type: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
talks_vs_trades_flag: Mapped[Optional[bool]] = mapped_column(Boolean, nullable=True, default=False)
sentiment: 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)
# Daily 3-section digest (Macro / KOL / Trump). Independent of the
# per-signal alerts above — a user can disable all real-time alerts but
# still want the once-a-day overview, or vice versa. See migration 023
# and app/services/telegram_digest.py.
digest_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
digest_hour_utc: Mapped[int] = mapped_column(Integer, nullable=False, default=12)
last_digest_sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
class MacroSnapshot(Base):
"""Daily snapshot of all macro indicators surfaced on the BTC page.
One row per calendar date. Every indicator column is nullable — any single
upstream API failing must not block the rest from being recorded.
Composite score is computed at insert time by app/services/macro/scoring.py
against whichever indicators successfully fetched (the formula degrades
gracefully if a few are missing).
"""
__tablename__ = "macro_snapshots"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
snapshot_date: Mapped[date] = mapped_column(Date, nullable=False, unique=True, index=True)
captured_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
ahr999: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
altcoin_season_index: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
fear_greed: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
fear_greed_label: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
btc_dominance_pct: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
eth_btc_ratio: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
stablecoin_supply_usd: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
etf_flow_net_usd_1d: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
btc_open_interest_usd: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
composite_score: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
regime_label: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
raw_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True)