Pre-launch hardening: KOL module, Telegram, scanners, WS resilience

Big-picture changes since b941223:

KOL pipeline (new) — Substack/podcast/blog RSS → AI ticker extraction →
on-chain wallet diff → talks-vs-trades divergence detection. Daily polls,
19 feeds, divergence emits Post + Telegram fan-out.

Telegram push (new) — walletless free tier + wallet-linked Pro upgrade,
in-bot preference commands (/trump /btc /funding /kol /conf /quiet),
signed-envelope API for dashboard. Disconnect-wallet keeps free
subscription.

BTC funding-rate reversal scanner (new) — hourly cron, 30d cumulative
funding threshold + mean-revert + 7d price confirm, emits via
/api/signals/ingest. BTC bottom-reversal scanner promoted to System 2.

WS broadcast rewrite — per-client send timeout + parallel fan-out
(asyncio.gather). Fixes "Binance WS no close frame" reconnect storms +
APScheduler 11-min job misses, both caused by one slow client stalling
the kline loop.

Error visibility — three silent-error sites (trumpstruth/truth_social
fetchers, funding_reversal scanner) now include exception type name so
httpx ConnectError-style empty-message errors stop logging blank lines.
Telegram bot loop now classifies ReadTimeout vs network vs unknown +
logger.exception for the unknown bucket.

Security hygiene — trumpsignal.db untracked from git (held subscriber
wallets + encrypted HL keys + 22 bot trades); .gitignore now blocks
*.db/.next/backups. CORS only allows FRONTEND_URL in production.

New ops scripts —
  - scripts/preflight.py: env/DB/Telegram/AI auth verification gate
  - scripts/backup_db.sh: cron-friendly daily DB backup (SQLite + Postgres)
  - scripts/seed_kol_wallets.py: idempotent KOL on-chain wallet seeder

15 new Alembic migrations (007-021) covering convex strategy fields,
phase-1 safety, two-system frozen exits, invalidation prices, dynamic
SYS2 leverage, staged de-risk + pyramiding, peak gain tracking, risk
mode, auto-trade + grow flags, KOL module, KOL on-chain, KOL divergence,
Telegram bindings + walletless.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
k
2026-05-25 00:52:56 +08:00
parent b941223c88
commit 5fb1d52026
81 changed files with 13251 additions and 158 deletions
@@ -0,0 +1,40 @@
"""Convex-strategy fields: trailing stop, longer hold, manual window.
These columns let the bot run trend-capture (asymmetric payoff) instead of
mean-reversion scalping:
- trailing_stop_pct : Trail distance once profit > activate threshold.
None = use fixed take_profit_pct (legacy behaviour).
- trailing_activate_at_pct: Profit % at which trailing kicks in.
- max_hold_hours : Replaces the hardcoded 1-hour cap. Default 168 (7 days)
so runners can run.
- manual_window_until : One-shot "enable for N hours" override. When set and
in the future, bot trades regardless of active_from /
active_until schedule. NULL = use schedule.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "007"
down_revision: Union[str, None] = "006"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table("subscriptions") as batch_op:
batch_op.add_column(sa.Column("trailing_stop_pct", sa.Float(), nullable=True))
batch_op.add_column(sa.Column("trailing_activate_at_pct", sa.Float(), nullable=True))
batch_op.add_column(sa.Column("max_hold_hours", sa.Integer(), nullable=False, server_default="168"))
batch_op.add_column(sa.Column("manual_window_until", sa.DateTime(), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("subscriptions") as batch_op:
batch_op.drop_column("manual_window_until")
batch_op.drop_column("max_hold_hours")
batch_op.drop_column("trailing_activate_at_pct")
batch_op.drop_column("trailing_stop_pct")
+25
View File
@@ -0,0 +1,25 @@
"""Phase 1 safety fields: paper mode + circuit breaker."""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "008"
down_revision: Union[str, None] = "007"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table("subscriptions") as batch_op:
# Paper mode: trades are simulated, no Hyperliquid call.
batch_op.add_column(sa.Column("paper_mode", sa.Boolean(), nullable=False, server_default="0"))
# Circuit breaker state.
batch_op.add_column(sa.Column("circuit_breaker_tripped_at", sa.DateTime(), nullable=True))
batch_op.add_column(sa.Column("circuit_breaker_reason", sa.String(32), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("subscriptions") as batch_op:
batch_op.drop_column("circuit_breaker_reason")
batch_op.drop_column("circuit_breaker_tripped_at")
batch_op.drop_column("paper_mode")
@@ -0,0 +1,46 @@
"""Two-system separation + frozen per-trade exit profile.
subscriptions:
- sys2_budget_pct : daily-budget slice for System 2 (default 0.7)
- sys2_cb_tripped_at/reason : independent System-2 circuit breaker
bot_trades:
- eff_* : the exit profile FROZEN at open. Recovery rehydrates from these
instead of the (mutable) Subscription, so a 90-day reversal
isn't silently rewritten to the user's Trump stop on restart.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "009"
down_revision: Union[str, None] = "008"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table("subscriptions") as b:
b.add_column(sa.Column("sys2_budget_pct", sa.Float(), nullable=False, server_default="0.7"))
b.add_column(sa.Column("sys2_cb_tripped_at", sa.DateTime(), nullable=True))
b.add_column(sa.Column("sys2_cb_reason", sa.String(32), nullable=True))
with op.batch_alter_table("bot_trades") as b:
b.add_column(sa.Column("eff_take_profit_pct", sa.Float(), nullable=True))
b.add_column(sa.Column("eff_stop_loss_pct", sa.Float(), nullable=True))
b.add_column(sa.Column("eff_trailing_stop_pct", sa.Float(), nullable=True))
b.add_column(sa.Column("eff_trailing_activate_pct", sa.Float(), nullable=True))
b.add_column(sa.Column("eff_max_hold_hours", sa.Integer(), nullable=True))
b.add_column(sa.Column("eff_invalidation", sa.String(24), nullable=True))
b.add_column(sa.Column("eff_invalidation_price", sa.Float(), nullable=True))
b.add_column(sa.Column("eff_min_hold_until_ts", sa.Float(), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("bot_trades") as b:
for c in ("eff_min_hold_until_ts", "eff_invalidation_price", "eff_invalidation", "eff_max_hold_hours",
"eff_trailing_activate_pct", "eff_trailing_stop_pct",
"eff_stop_loss_pct", "eff_take_profit_pct"):
b.drop_column(c)
with op.batch_alter_table("subscriptions") as b:
for c in ("sys2_cb_reason", "sys2_cb_tripped_at", "sys2_budget_pct"):
b.drop_column(c)
@@ -0,0 +1,52 @@
"""Add invalidation_price fields for BTC bottom-reversal trades.
Revision ID: 010
Revises: 009
Create Date: 2026-05-16 00:00:00.000000
NOTE: `bot_trades.eff_invalidation_price` is ALSO created by migration 009
(two_system_and_frozen_exits). The original 010 added it unconditionally,
which made `alembic upgrade head` on a FRESH database fail at 010 with
"duplicate column name: eff_invalidation_price" (009 already added it).
This revision is now IDEMPOTENT: each column is added only if it is not
already present, so it applies cleanly on both fresh DBs (009 already made
eff_invalidation_price → skip; posts.invalidation_price is new → add) and
DBs that pre-date 009's column. The genuinely-new column introduced by 010
is `posts.invalidation_price`; `eff_invalidation_price` is owned by 009 and
left to 009's downgrade.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision: str = "010"
down_revision: Union[str, None] = "009"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _columns(table: str) -> set:
return {c["name"] for c in inspect(op.get_bind()).get_columns(table)}
def upgrade() -> None:
if "invalidation_price" not in _columns("posts"):
with op.batch_alter_table("posts") as b:
b.add_column(sa.Column("invalidation_price", sa.Float(), nullable=True))
# eff_invalidation_price may already exist (created by 009). Only add it
# if some older DB reached 010 without it.
if "eff_invalidation_price" not in _columns("bot_trades"):
with op.batch_alter_table("bot_trades") as b:
b.add_column(sa.Column("eff_invalidation_price", sa.Float(), nullable=True))
def downgrade() -> None:
# Only undo 010's genuine addition. eff_invalidation_price is owned by
# 009 — dropping it here would double-drop in `downgrade base`.
if "invalidation_price" in _columns("posts"):
with op.batch_alter_table("posts") as b:
b.drop_column("invalidation_price")
@@ -0,0 +1,29 @@
"""Add System-2 dynamic leverage.
subscriptions.sys2_leverage: per-user leverage for System-2 (bottom reversal),
independent of the Trump `leverage` field. NULL → platform default.
Revision ID: 011
Revises: 010
Create Date: 2026-05-18 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "011"
down_revision: Union[str, None] = "010"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table("subscriptions") as b:
b.add_column(sa.Column("sys2_leverage", sa.Integer(), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("subscriptions") as b:
b.drop_column("sys2_leverage")
@@ -0,0 +1,39 @@
"""Add System-2 staged de-risk (分段式减仓) bookkeeping to bot_trades.
realized_partial_pnl_usd : cumulative PnL from partial reduces (default 0)
remaining_fraction : fraction of original notional still open (default 1)
derisk_steps_done : number of partial de-risk steps executed (default 0)
Defaults make every existing row behave exactly as before (no partials).
Revision ID: 012
Revises: 011
Create Date: 2026-05-18 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "012"
down_revision: Union[str, None] = "011"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table("bot_trades") as b:
b.add_column(sa.Column("realized_partial_pnl_usd", sa.Float(),
nullable=False, server_default="0"))
b.add_column(sa.Column("remaining_fraction", sa.Float(),
nullable=False, server_default="1"))
b.add_column(sa.Column("derisk_steps_done", sa.Integer(),
nullable=False, server_default="0"))
def downgrade() -> None:
with op.batch_alter_table("bot_trades") as b:
b.drop_column("derisk_steps_done")
b.drop_column("remaining_fraction")
b.drop_column("realized_partial_pnl_usd")
+35
View File
@@ -0,0 +1,35 @@
"""Add System-2 pyramiding (做对了往上加仓) bookkeeping to bot_trades.
base_size_usd : original notional at open, immutable (NULL → legacy,
falls back to size_usd)
addon_steps_done : number of pyramid add-ons executed (default 0)
Defaults make every existing row behave exactly as before (no add-ons).
Revision ID: 013
Revises: 012
Create Date: 2026-05-18 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "013"
down_revision: Union[str, None] = "012"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table("bot_trades") as b:
b.add_column(sa.Column("base_size_usd", sa.Float(), nullable=True))
b.add_column(sa.Column("addon_steps_done", sa.Integer(),
nullable=False, server_default="0"))
def downgrade() -> None:
with op.batch_alter_table("bot_trades") as b:
b.drop_column("addon_steps_done")
b.drop_column("base_size_usd")
@@ -0,0 +1,32 @@
"""Persist tp_sl_monitor peak gain so a restart doesn't reset the regime.
bot_trades.peak_gain_pct : monotonic peak unrealised gain % (default 0)
Without this, a pyramided / in-profit System-2 trade rehydrates with peak=0
on restart → wrongly drops into the underwater de-risk regime.
Revision ID: 014
Revises: 013
Create Date: 2026-05-18 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "014"
down_revision: Union[str, None] = "013"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table("bot_trades") as b:
b.add_column(sa.Column("peak_gain_pct", sa.Float(),
nullable=False, server_default="0"))
def downgrade() -> None:
with op.batch_alter_table("bot_trades") as b:
b.drop_column("peak_gain_pct")
+38
View File
@@ -0,0 +1,38 @@
"""Add System-2 risk mode (standard / aggressive).
subscriptions.sys2_mode : user-selected mode ("standard" default)
bot_trades.sys2_mode : frozen mode for the trade (NULL → standard)
Aggressive is a separately-funded high-risk/high-explosiveness sleeve. Both
modes keep the safety invariants (never exchange-liquidated; post-pyramid
breakeven floor). Defaults make every existing row behave as before.
Revision ID: 015
Revises: 014
Create Date: 2026-05-18 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "015"
down_revision: Union[str, None] = "014"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table("subscriptions") as b:
b.add_column(sa.Column("sys2_mode", sa.String(16),
nullable=False, server_default="standard"))
with op.batch_alter_table("bot_trades") as b:
b.add_column(sa.Column("sys2_mode", sa.String(16), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("bot_trades") as b:
b.drop_column("sys2_mode")
with op.batch_alter_table("subscriptions") as b:
b.drop_column("sys2_mode")
@@ -0,0 +1,48 @@
"""Simplified operator model: master Auto-Trade + per-trade Grow.
subscriptions.auto_trade : ONE persistent gate. OFF (default) = scan +
ingest signals (feed only), no trades. ON = auto-open on signal.
bot_trades.grow_mode : per-trade pyramiding switch (default off).
Defaults are safe (false) so existing rows do NOT auto-trade / auto-pyramid
until the user explicitly opts in — no behavior change on upgrade.
Revision ID: 016
Revises: 015
Create Date: 2026-05-18 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision: str = "016"
down_revision: Union[str, None] = "015"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _columns(table: str) -> set:
return {c["name"] for c in inspect(op.get_bind()).get_columns(table)}
def upgrade() -> None:
if "auto_trade" not in _columns("subscriptions"):
with op.batch_alter_table("subscriptions") as b:
b.add_column(sa.Column("auto_trade", sa.Boolean(),
nullable=False, server_default=sa.false()))
if "grow_mode" not in _columns("bot_trades"):
with op.batch_alter_table("bot_trades") as b:
b.add_column(sa.Column("grow_mode", sa.Boolean(),
nullable=False, server_default=sa.false()))
def downgrade() -> None:
if "grow_mode" in _columns("bot_trades"):
with op.batch_alter_table("bot_trades") as b:
b.drop_column("grow_mode")
if "auto_trade" in _columns("subscriptions"):
with op.batch_alter_table("subscriptions") as b:
b.drop_column("auto_trade")
+55
View File
@@ -0,0 +1,55 @@
"""KOL module — Substack/Twitter post ingestion + AI analysis.
Standalone module, no FK into Trump posts/trades. First slice (B-tier):
- kol_posts: raw ingested posts from Substack/Twitter, keyed by URL
- (kol_holdings + kol_wallets reserved for A-tier in later migration)
Each post carries its own AI analysis inline (1:1, no separate table):
summary (one-sentence), tickers_json (list of {ticker, action,
conviction, quote}), model, analysis_version.
Revision ID: 017
Revises: 016
Create Date: 2026-05-23 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "017"
down_revision: Union[str, None] = "016"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"kol_posts",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("kol_handle", sa.String(64), nullable=False, index=True),
sa.Column("source", sa.String(16), nullable=False), # substack | twitter
sa.Column("external_id", sa.String(512), nullable=False), # canonical URL / tweet id
sa.Column("title", sa.String(512), nullable=True),
sa.Column("url", sa.String(512), nullable=False),
sa.Column("published_at", sa.DateTime(), nullable=False, index=True),
sa.Column("raw_text", sa.Text(), nullable=False),
sa.Column("content_hash", sa.String(64), nullable=False),
# ── Inline analysis (1:1, populated by extractor) ─────────────
sa.Column("summary", sa.Text(), nullable=True),
sa.Column("tickers_json", sa.Text(), nullable=True), # JSON list
sa.Column("analyzed_at", sa.DateTime(), nullable=True),
sa.Column("analysis_model", sa.String(64), nullable=True),
sa.Column("analysis_version", sa.String(16), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False,
server_default=sa.func.current_timestamp()),
sa.UniqueConstraint("source", "external_id", name="uq_kol_post_src_extid"),
)
def downgrade() -> None:
op.drop_table("kol_posts")
+78
View File
@@ -0,0 +1,78 @@
"""KOL A-tier: on-chain wallet tracking + daily holdings snapshots.
Three new tables (standalone, no FK into Trump tables):
kol_wallets — curated KOL wallet addresses (one row per chain-addr pair)
kol_holdings_snapshots — daily portfolio snapshot per wallet (holdings_json)
kol_holding_changes — detected significant moves (new / closed / ±25%)
Source column distinguishes where the snapshot came from:
"arkham" — Arkham Intelligence API portfolio endpoint
"hl" — Hyperliquid public clearinghouseState (perps only)
"manual" — hand-entered for testing
Revision ID: 018
Revises: 017
Create Date: 2026-05-23
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "018"
down_revision: Union[str, None] = "017"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"kol_wallets",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("handle", sa.String(64), nullable=False, index=True),
sa.Column("chain", sa.String(16), nullable=False), # ethereum|solana|hl
sa.Column("address", sa.String(128), nullable=False),
sa.Column("label", sa.String(128), nullable=True), # e.g. "Arthur Hayes hot"
sa.Column("source_url", sa.String(256), nullable=True), # Arkham entity URL for reference
sa.Column("active", sa.Boolean(), nullable=False, server_default="1"),
sa.Column("added_at", sa.DateTime(), nullable=False,
server_default=sa.func.current_timestamp()),
sa.UniqueConstraint("chain", "address", name="uq_kol_wallet_chain_addr"),
)
op.create_table(
"kol_holdings_snapshots",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("wallet_id", sa.Integer(), sa.ForeignKey("kol_wallets.id"), nullable=False, index=True),
sa.Column("snapshot_date",sa.String(10), nullable=False), # YYYY-MM-DD UTC
sa.Column("holdings_json",sa.Text(), nullable=False), # [{ticker,amount,usd_value,chain}]
sa.Column("total_usd", sa.Float(), nullable=True),
sa.Column("source", sa.String(16), nullable=False), # arkham|hl|manual
sa.Column("created_at", sa.DateTime(), nullable=False,
server_default=sa.func.current_timestamp()),
sa.UniqueConstraint("wallet_id", "snapshot_date", name="uq_kol_snapshot_wallet_date"),
)
op.create_table(
"kol_holding_changes",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("wallet_id", sa.Integer(), sa.ForeignKey("kol_wallets.id"), nullable=False, index=True),
sa.Column("detected_at", sa.DateTime(), nullable=False, index=True),
sa.Column("ticker", sa.String(32), nullable=False),
# new_position | closed | increased | decreased
sa.Column("change_type", sa.String(16), nullable=False),
sa.Column("usd_before", sa.Float(), nullable=True),
sa.Column("usd_after", sa.Float(), nullable=True),
sa.Column("pct_change", sa.Float(), nullable=True), # signed %
sa.Column("created_at", sa.DateTime(), nullable=False,
server_default=sa.func.current_timestamp()),
)
def downgrade() -> None:
op.drop_table("kol_holding_changes")
op.drop_table("kol_holdings_snapshots")
op.drop_table("kol_wallets")
+48
View File
@@ -0,0 +1,48 @@
"""kol_divergence table — talks-vs-trades cross-signal
Revision ID: 019
Revises: 018
Create Date: 2026-05-23
"""
from alembic import op
import sqlalchemy as sa
revision = "019"
down_revision = "018"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"kol_divergence",
sa.Column("id", sa.Integer, primary_key=True),
# Which KOL + asset
sa.Column("handle", sa.String(64), nullable=False, index=True),
sa.Column("ticker", sa.String(32), nullable=False),
# Content side (B-tier)
sa.Column("post_id", sa.Integer, sa.ForeignKey("kol_posts.id"), nullable=False),
sa.Column("post_action", sa.String(16), nullable=False), # buy|sell|bullish|bearish
sa.Column("post_conviction", sa.Float, nullable=True),
sa.Column("post_at", sa.DateTime, nullable=False), # post published_at
# On-chain side (A-tier)
sa.Column("change_id", sa.Integer, sa.ForeignKey("kol_holding_changes.id"), nullable=False),
sa.Column("onchain_action", sa.String(16), nullable=False), # new_position|increased|decreased|closed
sa.Column("usd_before", sa.Float, nullable=True),
sa.Column("usd_after", sa.Float, nullable=True),
sa.Column("onchain_at", sa.DateTime, nullable=False),
# Classification
sa.Column("signal_type", sa.String(16), nullable=False), # divergence | alignment
sa.Column("direction", sa.String(8), nullable=True), # long | short (net conclusion)
sa.Column("days_apart", sa.Float, nullable=True), # abs days between post and onchain event
sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.func.now()),
# Unique: one row per (post, change) pair so re-runs are idempotent
sa.UniqueConstraint("post_id", "change_id", name="uq_kol_divergence_pair"),
)
op.create_index("ix_kol_divergence_handle_ticker", "kol_divergence", ["handle", "ticker"])
op.create_index("ix_kol_divergence_signal_type", "kol_divergence", ["signal_type"])
op.create_index("ix_kol_divergence_created_at", "kol_divergence", ["created_at"])
def downgrade() -> None:
op.drop_table("kol_divergence")
+46
View File
@@ -0,0 +1,46 @@
"""telegram_bindings — wallet ↔ Telegram chat_id mapping with per-source prefs.
Revision ID: 020
Revises: 019
Create Date: 2026-05-24
"""
from alembic import op
import sqlalchemy as sa
revision = "020"
down_revision = "019"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"telegram_bindings",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("wallet_address", sa.String(64), nullable=False, unique=True, index=True),
# Telegram chat_id is an int64 in practice; store as BigInteger to be safe.
sa.Column("chat_id", sa.BigInteger, nullable=False, unique=True, index=True),
sa.Column("tg_username", sa.String(64), nullable=True), # display only
sa.Column("bound_at", sa.DateTime, nullable=False, server_default=sa.func.now()),
# Master switch
sa.Column("alerts_enabled", sa.Boolean, nullable=False, server_default=sa.true()),
# Per-source toggles. KOL divergence is noisier so default-off.
sa.Column("alert_trump", sa.Boolean, nullable=False, server_default=sa.true()),
sa.Column("alert_btc_bottom", sa.Boolean, nullable=False, server_default=sa.true()),
sa.Column("alert_funding", sa.Boolean, nullable=False, server_default=sa.true()),
sa.Column("alert_kol_divergence", sa.Boolean, nullable=False, server_default=sa.false()),
# Quality floor — don't ping for low-confidence Trump posts.
sa.Column("min_confidence", sa.Integer, nullable=False, server_default="70"),
# Quiet hours (UTC). 0..23. Both null = always on. If start<end, mute
# [start, end); if start>end, mute window wraps midnight.
sa.Column("mute_from_hour", sa.Integer, nullable=True),
sa.Column("mute_until_hour", sa.Integer, nullable=True),
# Audit
sa.Column("last_alert_at", sa.DateTime, nullable=True),
sa.Column("total_alerts_sent", sa.Integer, nullable=False, server_default="0"),
sa.Column("total_alerts_failed", sa.Integer, nullable=False, server_default="0"),
)
def downgrade() -> None:
op.drop_table("telegram_bindings")
@@ -0,0 +1,59 @@
"""Allow walletless Telegram bindings.
Before: every binding had to be tied to a subscribed wallet.
After: anyone who DMs the bot can subscribe to public alerts; the
wallet_address column becomes nullable. Uniqueness is on chat_id
alone — the same Telegram chat can never have two bindings.
A wallet can still have at most one binding (enforced by a partial unique
index on wallet_address WHERE wallet_address IS NOT NULL).
Revision ID: 021
Revises: 020
Create Date: 2026-05-24
"""
from alembic import op
import sqlalchemy as sa
revision = "021"
down_revision = "020"
branch_labels = None
depends_on = None
def upgrade() -> None:
# SQLite + batch mode → recreates the table preserving rows.
with op.batch_alter_table("telegram_bindings") as batch:
batch.alter_column("wallet_address",
existing_type=sa.String(64),
nullable=True)
# Drop the old unique constraint on wallet_address (was created via
# `unique=True`), then add a partial unique index that allows multiple
# NULL wallets but still prevents two bindings for the same wallet.
# SQLite quirk: the auto-generated unique index name is
# `ix_telegram_bindings_wallet_address` (because we also indexed it).
# We replace it with the partial-unique form.
with op.batch_alter_table("telegram_bindings") as batch:
batch.drop_index("ix_telegram_bindings_wallet_address")
op.create_index(
"ix_telegram_bindings_wallet_address",
"telegram_bindings",
["wallet_address"],
unique=True,
sqlite_where=sa.text("wallet_address IS NOT NULL"),
postgresql_where=sa.text("wallet_address IS NOT NULL"),
)
def downgrade() -> None:
# Note: downgrade FAILS if any walletless rows exist — caller must
# delete them first. This is intentional (data preservation > graceful
# downgrade for an opt-in safety net).
op.drop_index("ix_telegram_bindings_wallet_address", table_name="telegram_bindings")
with op.batch_alter_table("telegram_bindings") as batch:
batch.alter_column("wallet_address",
existing_type=sa.String(64),
nullable=False)
batch.create_index("ix_telegram_bindings_wallet_address",
["wallet_address"], unique=True)