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:
+285
@@ -60,6 +60,7 @@ class Post(Base):
|
||||
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")
|
||||
@@ -103,6 +104,54 @@ class BotTrade(Base):
|
||||
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")
|
||||
|
||||
|
||||
@@ -127,3 +176,239 @@ class Subscription(Base):
|
||||
# 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)
|
||||
|
||||
Reference in New Issue
Block a user