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:
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user