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
+53 -10
View File
@@ -1,12 +1,14 @@
# ── Database ───────────────────────────────────────────────────────────────── # ── Database ─────────────────────────────────────────────────────────────────
# For Docker Compose — only POSTGRES_PASSWORD needs to be set; # Docker Compose reads SERVICE_* values and injects DATABASE_URL into the API
# DATABASE_URL is built automatically by docker-compose.yml. # container. Bare-metal/systemd reads DATABASE_URL directly.
# For local dev without Docker, set DATABASE_URL directly: SERVICE_USER_POSTGRES=trumpsignal
# DATABASE_URL=sqlite+aiosqlite:///./trumpsignal.db SERVICE_PASSWORD_POSTGRES=change_me_in_production
POSTGRES_PASSWORD=change_me_in_production DATABASE_URL=postgresql+asyncpg://trumpsignal:change_me_in_production@localhost:5432/trumpsignal
# ── CORS / Frontend ────────────────────────────────────────────────────────── # ── CORS / Frontend ──────────────────────────────────────────────────────────
# Your frontend origin — no trailing slash # Your frontend origin — no trailing slash. Used as the only allowed CORS
# origin in production. Multiple origins not currently supported here; if
# you need staging + prod simultaneously, edit app/main.py allowed_origins.
FRONTEND_URL=https://yourdomain.com FRONTEND_URL=https://yourdomain.com
# ── Encryption ─────────────────────────────────────────────────────────────── # ── Encryption ───────────────────────────────────────────────────────────────
@@ -15,14 +17,55 @@ FRONTEND_URL=https://yourdomain.com
# WARNING: rotating this invalidates all stored keys. # WARNING: rotating this invalidates all stored keys.
ENCRYPTION_KEY= ENCRYPTION_KEY=
# ── AI provider ────────────────────────────────────────────────────────────── # ── AI provider (System 1 / Trump analysis only) ────────────────────────────
# OpenAI-compatible endpoint. DeepSeek used in dev.
AI_API_KEY= AI_API_KEY=
AI_BASE_URL=https://api.gptsapi.net/v1 AI_BASE_URL=https://api.deepseek.com/v1
AI_MODEL=claude-sonnet-4-6 AI_MODEL=deepseek-v4-pro # batch / reanalysis (quality)
AI_LIVE_MODEL=deepseek-v4-flash # live post analysis (latency)
# Optional: native Anthropic key takes priority over AI_API_KEY if set.
# Used for KOL analysis (long-form essays) where reasoning > latency.
ANTHROPIC_API_KEY=
# ── Hyperliquid ────────────────────────────────────────────────────────────── # ── Signal ingest (System 2 scanners + external modules) ─────────────────────
# Shared secret for POST /api/signals/ingest. Empty = endpoint disabled
# (fail-closed). Generate: openssl rand -hex 32
INGEST_API_KEY=
# ── On-chain data providers ──────────────────────────────────────────────────
# Glassnode — used by the BTC bottom-reversal scanner (MVRV-Z + STH-SOPR).
# Free tier covers our query volume. Empty = scanner skips with a warning.
# Sign up: https://glassnode.com → Account → API
GLASSNODE_API_KEY=
# Etherscan — used by KOL A-tier on-chain poller to read ERC-20 balances for
# the wallets in `kol_wallets`. Free tier (5 req/s) is plenty for daily polls.
# Empty = KOL on-chain snapshot pipeline skips Ethereum wallets (HL perps
# still polled via Hyperliquid's free public API).
# Sign up: https://etherscan.io/register → My API Keys
ETHERSCAN_API_KEY=
# ── Telegram push alerts ─────────────────────────────────────────────────────
# Bot token from @BotFather (https://t.me/BotFather → /newbot). Free.
# Empty = Telegram alerts disabled (bot loop skipped, notify_signal is a
# no-op, /api/telegram endpoints return 503).
TELEGRAM_BOT_TOKEN=
# Bot username (no @) — used by the Settings UI to render the deep link
# t.me/<username>?start=<code>. Example: trumpalpha_bot
TELEGRAM_BOT_USERNAME=
# ── Hyperliquid (server-side fallback only — most ops use user-provided keys) ─
# These are ONLY for server-controlled signals (e.g. a dev/admin trade) — the
# user-facing bot reads each subscriber's encrypted key from the DB instead.
# Leave empty in production unless you're running a single-account demo.
HL_API_PRIVATE_KEY=
HL_ACCOUNT_ADDRESS=
HL_LEVERAGE=3
HL_MAINNET=true HL_MAINNET=true
# ── Runtime ────────────────────────────────────────────────────────────────── # ── Runtime ──────────────────────────────────────────────────────────────────
# development | production # development | production
# development → enables /api/dev/* endpoints (paper-mode toggle, etc.) AND
# auto-runs create_all() on startup (Alembic bypass — DEV ONLY).
# production → both are off; schema strictly Alembic-managed.
ENVIRONMENT=production ENVIRONMENT=production
+10 -1
View File
@@ -7,9 +7,17 @@ env/
.env .env
*.pem *.pem
# 数据库 (trumpsignal.db 是例外,用于一次性迁移到 Postgres) # 数据库 — 含真实订阅者钱包地址、HL 加密 key、bot trades 等敏感数据。
# 一旦推到 git 历史回收代价 = 重 init 仓库。全部 untrack。
# 一次性迁到 Postgres 用 scripts/migrate_sqlite_to_postgres.py 本地跑。
*.sqlite *.sqlite
*.sqlite3 *.sqlite3
*.db
*.db.bak-*
*.db-journal
backups/
# Next.js build artifacts (本来在 trumpsignal repo 应忽略;偶尔从 backend 误触)
.next/
# Python 缓存 # Python 缓存
__pycache__/ __pycache__/
@@ -21,3 +29,4 @@ __pycache__/
# Alembic 产生的临时迁移记录 # Alembic 产生的临时迁移记录
# 注意:alembic/versions 中的迁移脚本应该提交,但 alembic 目录下的其他临时文件可能需要忽略 # 注意:alembic/versions 中的迁移脚本应该提交,但 alembic 目录下的其他临时文件可能需要忽略
.cache/
@@ -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)
+158
View File
@@ -57,9 +57,167 @@ async def insert_fake_post(
return {"id": post.id, "text": post.text[:80]} return {"id": post.id, "text": post.text[:80]}
@router.post("/dev/fake-signal")
async def inject_fake_signal(
symbol: str = "ETHUSDT",
close: float = 1850.42,
tbr: float = 0.72,
vol_mult: float = 3.1,
bb_pct: float = 8.5,
btc_trend: str = "↑ uptrend",
):
"""Inject a synthetic funding_signal for end-to-end testing."""
from datetime import datetime, timezone
from app.services import funding_signal as fs
now = datetime.now(timezone.utc)
alert = {
"type": "funding_signal",
"symbol": symbol,
"time": now.isoformat(),
"close": close,
"tbr": tbr,
"vol_mult": vol_mult,
"bb_pct": bb_pct,
"bb_upper": round(close * 1.002, 4),
"btc_trend": btc_trend,
"enabled": fs.is_enabled(),
}
fs._recent_signals.append(alert)
if fs.is_enabled():
await manager.broadcast(alert)
return {"status": "broadcast", "alert": alert}
else:
return {"status": "recorded_only (monitor OFF)", "alert": alert}
@router.post("/dev/reanalyze")
async def trigger_reanalyze(
background_tasks: BackgroundTasks,
limit: int = 500,
dry_run: bool = False,
delay_secs: float = 0.5,
legacy_signals: bool = False,
model: str = "",
):
"""
Batch re-run AI analysis on unscored posts.
model: override the AI model (default: ai_live_model=flash for speed).
Runs in the background — poll GET /dev/reanalyze/status for progress.
"""
from app.services.reanalyze import reanalyze_unscored, get_state
from app.config import settings as _s
state = get_state()
if state["running"]:
return {"status": "already_running", "state": state}
effective_model = model or _s.ai_live_model # default to flash
background_tasks.add_task(
reanalyze_unscored, AsyncSessionLocal,
limit=limit, dry_run=dry_run, delay_secs=delay_secs,
legacy_signals=legacy_signals, model=effective_model,
)
return {
"status": "started",
"limit": limit,
"dry_run": dry_run,
"delay_secs": delay_secs,
"legacy_signals": legacy_signals,
"model": effective_model,
"message": f"Re-analyzing up to {limit} posts in background. Poll /api/dev/reanalyze/status.",
}
@router.get("/dev/reanalyze/status")
async def reanalyze_status():
"""Current progress of the background re-analyzer."""
from app.services.reanalyze import get_state
return get_state()
@router.post("/dev/backfill-prices") @router.post("/dev/backfill-prices")
async def trigger_price_backfill(background_tasks: BackgroundTasks, asset: str = "BTC"): async def trigger_price_backfill(background_tasks: BackgroundTasks, asset: str = "BTC"):
"""触发历史帖子价格回溯(后台运行)""" """触发历史帖子价格回溯(后台运行)"""
from app.services.price_backfill import backfill_price_impact from app.services.price_backfill import backfill_price_impact
background_tasks.add_task(backfill_price_impact, AsyncSessionLocal, asset) background_tasks.add_task(backfill_price_impact, AsyncSessionLocal, asset)
return {"status": "started", "asset": asset, "message": "后台回溯中,约需 1-2 分钟"} return {"status": "started", "asset": asset, "message": "后台回溯中,约需 1-2 分钟"}
# ─── Convex-strategy backtest ──────────────────────────────────────────────
@router.post("/dev/backtest/post")
async def backtest_one_post(
post_id: int,
stop_loss_pct: float = 1.5,
trailing_stop_pct: float = 2.5,
trailing_activate_at_pct: float = 5.0,
take_profit_pct: float = 0.0, # 0 = treat as None (no fixed TP)
max_hold_hours: int = 168,
):
"""Replay one post through the new convex-strategy exit rules.
Pulls 1m candles from Binance for the post's hold window and simulates
trailing-stop + SL + max-hold. Useful for sanity-checking parameters.
Pass `take_profit_pct=0` to disable the fixed TP and run pure trailing.
"""
from app.services.backtest import backtest_post, BacktestParams
params = BacktestParams(
stop_loss_pct=stop_loss_pct,
trailing_stop_pct=trailing_stop_pct or None,
trailing_activate_at_pct=trailing_activate_at_pct or None,
take_profit_pct=take_profit_pct or None,
max_hold_hours=max_hold_hours,
)
r = await backtest_post(post_id, params)
if r is None:
return {"status": "skipped", "post_id": post_id}
return {"status": "ok", "result": r.to_dict()}
@router.post("/dev/paper-mode")
async def toggle_paper_mode(
wallet: str,
enabled: bool,
db: AsyncSession = Depends(get_db),
):
"""Flip paper_mode for a subscription. Dev-only — no signature required.
Paper mode: trades are simulated end-to-end (entry/exit from Binance,
DB row with hl_order_id='paper') but no Hyperliquid call is made.
"""
from sqlalchemy import select
from app.models import Subscription
wallet = wallet.lower().strip()
result = await db.execute(select(Subscription).where(Subscription.wallet_address == wallet))
sub = result.scalar_one_or_none()
if sub is None:
return {"status": "not_found", "wallet": wallet}
sub.paper_mode = bool(enabled)
await db.commit()
return {"status": "ok", "wallet": wallet, "paper_mode": sub.paper_mode}
@router.post("/dev/backtest/batch")
async def backtest_batch_route(
limit: int = 50,
min_confidence: int = 80,
stop_loss_pct: float = 1.5,
trailing_stop_pct: float = 2.5,
trailing_activate_at_pct: float = 5.0,
take_profit_pct: float = 0.0,
max_hold_hours: int = 168,
):
"""Batch backtest: every directional post with conf ≥ min_confidence.
WARNING: synchronous — for 50 posts at ~10s each on Binance fetches this
can take several minutes. Run with limit=5 first to sanity-check.
"""
from app.services.backtest import backtest_batch, BacktestParams
params = BacktestParams(
stop_loss_pct=stop_loss_pct,
trailing_stop_pct=trailing_stop_pct or None,
trailing_activate_at_pct=trailing_activate_at_pct or None,
take_profit_pct=take_profit_pct or None,
max_hold_hours=max_hold_hours,
)
return await backtest_batch(limit=limit, min_confidence=min_confidence, params=params)
+22
View File
@@ -0,0 +1,22 @@
"""
API endpoints for the BTC funding-rate reversal signal.
GET /api/funding/snapshot
Live snapshot of current funding state + signal verdict + 7d history.
Powers the BTC page "Funding" tab.
Historical fired signals are returned via the standard /api/posts endpoint
with source=funding_reversal — same plumbing as btc_bottom_reversal.
"""
from fastapi import APIRouter
from app.services.scanners.funding_reversal import get_current_snapshot
router = APIRouter(prefix="/funding", tags=["funding"])
@router.get("/snapshot")
async def snapshot() -> dict:
"""Cheap (≤2 outbound requests). Frontend polls every ~5min."""
return await get_current_snapshot()
+38
View File
@@ -0,0 +1,38 @@
"""
API endpoints for the breakout signal monitor.
GET /api/signal/status — current state (enabled, recent signals)
POST /api/signal/toggle — flip the on/off switch
GET /api/signal/history — last N signals (fired regardless of enabled state)
"""
from fastapi import APIRouter
from app.services.funding_signal import (
set_enabled,
is_enabled,
get_recent_signals,
get_status,
)
router = APIRouter(prefix="/signal", tags=["signal"])
@router.get("/status")
async def status():
return get_status()
@router.post("/toggle")
async def toggle(enabled: bool):
"""
Body: ?enabled=true or ?enabled=false
Example: POST /api/signal/toggle?enabled=true
"""
set_enabled(enabled)
return {"enabled": is_enabled()}
@router.get("/history")
async def history(limit: int = 20):
return get_recent_signals(limit)
+395
View File
@@ -0,0 +1,395 @@
"""KOL module — public read API.
Endpoints:
GET /api/kol/posts list KOL posts (newest first), with summary +
tickers but WITHOUT raw_text to keep payload small
GET /api/kol/posts/{id} full detail incl. raw_text for the modal/page view
"""
import json
import logging
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, Header, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.models import KolDivergence, KolHoldingChange, KolHoldingSnapshot, KolPost, KolWallet, iso_utc
def _verify_admin_key(x_ingest_key: Optional[str]) -> None:
"""Shared-secret auth for write endpoints (wallet add, scan trigger).
Same key as the signal ingest endpoint. Fail-closed if INGEST_API_KEY unset."""
expected = settings.ingest_api_key
if not expected:
raise HTTPException(503, "write endpoint disabled (INGEST_API_KEY not configured)")
if not x_ingest_key:
raise HTTPException(401, "missing X-Ingest-Key header")
if x_ingest_key != expected:
raise HTTPException(401, "invalid X-Ingest-Key")
router = APIRouter()
logger = logging.getLogger(__name__)
def _parse_tickers(raw: Optional[str]) -> List[dict]:
if not raw:
return []
try:
data = json.loads(raw)
return data if isinstance(data, list) else []
except json.JSONDecodeError:
return []
def _summary_dto(post: KolPost) -> dict:
"""List-view shape: no raw_text, no content_hash."""
return {
"id": post.id,
"kol_handle": post.kol_handle,
"source": post.source,
"url": post.url,
"title": post.title,
"published_at": iso_utc(post.published_at),
"summary": post.summary,
"tickers": _parse_tickers(post.tickers_json),
"analyzed_at": iso_utc(post.analyzed_at),
"analysis_model": post.analysis_model,
}
def _detail_dto(post: KolPost) -> dict:
base = _summary_dto(post)
base["raw_text"] = post.raw_text
return base
@router.get("/kol/posts")
async def list_kol_posts(
handle: Optional[str] = Query(default=None, description="filter by kol_handle"),
source: Optional[str] = Query(default=None, description="substack | twitter"),
limit: int = Query(default=50, ge=1, le=200),
page: int = Query(default=1, ge=1),
db: AsyncSession = Depends(get_db),
) -> dict[str, Any]:
stmt = select(KolPost)
if handle:
stmt = stmt.where(KolPost.kol_handle == handle)
if source:
stmt = stmt.where(KolPost.source == source)
stmt = stmt.order_by(KolPost.published_at.desc()).offset((page - 1) * limit).limit(limit)
rows = (await db.execute(stmt)).scalars().all()
return {"items": [_summary_dto(p) for p in rows], "page": page, "limit": limit}
@router.get("/kol/posts/{post_id}")
async def get_kol_post(post_id: int, db: AsyncSession = Depends(get_db)) -> dict:
row = (await db.execute(select(KolPost).where(KolPost.id == post_id))).scalar_one_or_none()
if row is None:
raise HTTPException(status_code=404, detail="KOL post not found")
return _detail_dto(row)
# ── Action ordering for picking the "dominant" call per ticker ─────────
# When the same ticker appears across multiple posts with different actions,
# the strongest signal wins. buy/sell (explicit position) outrank
# bullish/bearish (directional view) outrank mention (passing reference).
_ACTION_STRENGTH = {"buy": 3, "sell": 3, "bullish": 2, "bearish": 2, "mention": 1}
_ACTION_SIDE = {"buy": "long", "bullish": "long",
"sell": "short", "bearish": "short", "mention": "neutral"}
@router.get("/kol/digest")
async def kol_digest(
days: int = Query(default=7, ge=1, le=90,
description="rolling window in days"),
db: AsyncSession = Depends(get_db),
) -> dict[str, Any]:
"""Aggregate ticker calls across recent KOL posts.
For each ticker mentioned in posts within the window, returns the
dominant action, the highest conviction, and the list of (KOL, post,
action, conviction) calls. Sorted by post_count desc, then conviction.
"""
since = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days)
rows = (await db.execute(
select(KolPost)
.where(KolPost.published_at >= since)
.where(KolPost.tickers_json.is_not(None))
.order_by(KolPost.published_at.desc())
)).scalars().all()
# ticker -> list of call dicts
bucket: dict[str, list[dict]] = defaultdict(list)
for post in rows:
try:
tickers = json.loads(post.tickers_json or "[]")
except json.JSONDecodeError:
continue
for t in tickers:
sym = (t.get("ticker") or "").upper()
action = (t.get("action") or "mention").lower()
if not sym or action == "mention":
# Don't surface 'mention' on the digest — too noisy. Users
# can still see it on the post detail.
continue
bucket[sym].append({
"post_id": post.id,
"kol_handle": post.kol_handle,
"post_title": post.title,
"published_at": iso_utc(post.published_at),
"action": action,
"conviction": float(t.get("conviction") or 0.0),
"quote": (t.get("quote") or "")[:240],
})
tickers_out: list[dict] = []
for sym, calls in bucket.items():
# Dominant action: pick the action with the highest sum of conviction,
# broken by strength tier then count. Ensures one strong "buy" beats
# three weak "bullish" mentions.
action_weights: dict[str, float] = defaultdict(float)
for c in calls:
action_weights[c["action"]] += c["conviction"]
dominant = max(
action_weights.items(),
key=lambda kv: (_ACTION_STRENGTH.get(kv[0], 0), kv[1]),
)[0]
max_conv = max(c["conviction"] for c in calls)
unique_kols = sorted({c["kol_handle"] for c in calls})
tickers_out.append({
"ticker": sym,
"dominant_action": dominant,
"side": _ACTION_SIDE.get(dominant, "neutral"),
"max_conviction": round(max_conv, 2),
"post_count": len(calls),
"kol_count": len(unique_kols),
"kols": unique_kols,
"calls": sorted(calls, key=lambda c: c["conviction"], reverse=True),
})
tickers_out.sort(
key=lambda t: (t["kol_count"], t["post_count"], t["max_conviction"]),
reverse=True,
)
return {
"window_days": days,
"since": iso_utc(since),
"post_count": len(rows),
"ticker_count": len(tickers_out),
"tickers": tickers_out,
}
# ── A-tier: on-chain wallets + holdings ──────────────────────────────────────
def _wallet_dto(w: KolWallet) -> dict:
return {
"id": w.id,
"handle": w.handle,
"chain": w.chain,
"address": w.address,
"label": w.label,
"source_url": w.source_url,
"active": w.active,
"added_at": iso_utc(w.added_at),
}
def _snapshot_dto(s: KolHoldingSnapshot) -> dict:
return {
"id": s.id,
"wallet_id": s.wallet_id,
"snapshot_date": s.snapshot_date,
"holdings": json.loads(s.holdings_json or "[]"),
"total_usd": s.total_usd,
"source": s.source,
"created_at": iso_utc(s.created_at),
}
def _change_dto(c: KolHoldingChange, handle: str) -> dict:
return {
"id": c.id,
"wallet_id": c.wallet_id,
"handle": handle,
"detected_at": iso_utc(c.detected_at),
"ticker": c.ticker,
"change_type": c.change_type,
"usd_before": c.usd_before,
"usd_after": c.usd_after,
"pct_change": c.pct_change,
}
@router.get("/kol/wallets")
async def list_kol_wallets(
handle: Optional[str] = Query(default=None),
db: AsyncSession = Depends(get_db),
) -> dict[str, Any]:
stmt = select(KolWallet).where(KolWallet.active == True)
if handle:
stmt = stmt.where(KolWallet.handle == handle)
stmt = stmt.order_by(KolWallet.handle)
rows = (await db.execute(stmt)).scalars().all()
return {"wallets": [_wallet_dto(w) for w in rows]}
@router.post("/kol/wallets")
async def add_kol_wallet(
body: dict,
x_ingest_key: Optional[str] = Header(None, alias="X-Ingest-Key"),
db: AsyncSession = Depends(get_db),
) -> dict:
"""Add a new KOL wallet address. Body: {handle, chain, address, label?, source_url?}.
Auth: requires X-Ingest-Key header matching INGEST_API_KEY."""
_verify_admin_key(x_ingest_key)
required = {"handle", "chain", "address"}
if not required.issubset(body):
raise HTTPException(status_code=422, detail=f"Required fields: {required}")
# Check duplicate
existing = (await db.execute(
select(KolWallet).where(
KolWallet.chain == body["chain"],
KolWallet.address == body["address"].lower(),
)
)).scalar_one_or_none()
if existing:
raise HTTPException(status_code=409, detail="Wallet already tracked")
wallet = KolWallet(
handle=body["handle"],
chain=body["chain"],
address=body["address"].lower(),
label=body.get("label"),
source_url=body.get("source_url"),
)
db.add(wallet)
await db.commit()
await db.refresh(wallet)
return _wallet_dto(wallet)
@router.get("/kol/wallets/{wallet_id}/snapshots")
async def get_wallet_snapshots(
wallet_id: int,
limit: int = Query(default=30, ge=1, le=90),
db: AsyncSession = Depends(get_db),
) -> dict[str, Any]:
wallet = (await db.execute(
select(KolWallet).where(KolWallet.id == wallet_id)
)).scalar_one_or_none()
if not wallet:
raise HTTPException(status_code=404, detail="Wallet not found")
snaps = (await db.execute(
select(KolHoldingSnapshot)
.where(KolHoldingSnapshot.wallet_id == wallet_id)
.order_by(KolHoldingSnapshot.snapshot_date.desc())
.limit(limit)
)).scalars().all()
return {
"wallet": _wallet_dto(wallet),
"snapshots": [_snapshot_dto(s) for s in snaps],
}
@router.get("/kol/changes")
async def list_holding_changes(
handle: Optional[str] = Query(default=None),
days: int = Query(default=7, ge=1, le=90),
db: AsyncSession = Depends(get_db),
) -> dict[str, Any]:
"""Recent on-chain position changes across all tracked KOL wallets."""
since = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days)
stmt = (
select(KolHoldingChange, KolWallet.handle)
.join(KolWallet, KolHoldingChange.wallet_id == KolWallet.id)
.where(KolHoldingChange.detected_at >= since)
)
if handle:
stmt = stmt.where(KolWallet.handle == handle)
stmt = stmt.order_by(KolHoldingChange.detected_at.desc()).limit(200)
rows = (await db.execute(stmt)).all()
changes = [_change_dto(c, h) for c, h in rows]
return {
"window_days": days,
"since": iso_utc(since),
"count": len(changes),
"changes": changes,
}
# ── Talks-vs-trades divergence ────────────────────────────────────────────────
def _divergence_dto(d: KolDivergence) -> dict:
return {
"id": d.id,
"handle": d.handle,
"ticker": d.ticker,
"signal_type": d.signal_type, # divergence | alignment
"direction": d.direction, # long | short
"post_id": d.post_id,
"post_action": d.post_action,
"post_conviction":d.post_conviction,
"post_at": iso_utc(d.post_at),
"onchain_action": d.onchain_action,
"usd_before": d.usd_before,
"usd_after": d.usd_after,
"onchain_at": iso_utc(d.onchain_at),
"days_apart": d.days_apart,
"created_at": iso_utc(d.created_at),
}
@router.get("/kol/divergence")
async def list_divergence(
handle: Optional[str] = Query(default=None),
ticker: Optional[str] = Query(default=None),
signal_type: Optional[str] = Query(default=None, description="divergence | alignment"),
days: int = Query(default=30, ge=1, le=180),
db: AsyncSession = Depends(get_db),
) -> dict[str, Any]:
"""List talks-vs-trades cross-signal pairs.
signal_type=divergence → KOL said X but chain did the opposite (high alpha).
signal_type=alignment → KOL's words matched their on-chain action (reinforced signal).
"""
since = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days)
stmt = select(KolDivergence).where(KolDivergence.created_at >= since)
if handle:
stmt = stmt.where(KolDivergence.handle == handle)
if ticker:
stmt = stmt.where(KolDivergence.ticker == ticker.upper())
if signal_type:
stmt = stmt.where(KolDivergence.signal_type == signal_type)
stmt = stmt.order_by(KolDivergence.created_at.desc()).limit(200)
rows = (await db.execute(stmt)).scalars().all()
return {
"window_days": days,
"since": iso_utc(since),
"count": len(rows),
"items": [_divergence_dto(r) for r in rows],
}
@router.post("/kol/divergence/scan")
async def trigger_divergence_scan(
lookback_days: int = Query(default=30, ge=1, le=90),
x_ingest_key: Optional[str] = Header(None, alias="X-Ingest-Key"),
) -> dict[str, Any]:
"""Manually trigger the talks-vs-trades scan. Returns newly written pairs.
Auth: requires X-Ingest-Key header matching INGEST_API_KEY."""
_verify_admin_key(x_ingest_key)
from app.services.kol_divergence import run_divergence_scan
results = await run_divergence_scan(lookback_days=lookback_days)
return {"new_pairs": len(results), "items": results}
+19 -2
View File
@@ -1,26 +1,43 @@
import logging import logging
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
from app.models import BotTrade from app.models import BotTrade
from app.schemas import BotPerformance from app.schemas import BotPerformance
from app.services.signed_request import verify_signed_request
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
PERIOD_DAYS = 30 PERIOD_DAYS = 30
ACTION_VIEW_PERFORMANCE = "view_performance"
@router.get("/performance", response_model=BotPerformance) @router.get("/performance", response_model=BotPerformance)
async def get_performance(db: AsyncSession = Depends(get_db)): async def get_performance(
wallet: str = Query(..., description="Wallet address (lower-cased internally)"),
ts: int = Query(..., description="Signed timestamp (ms)"),
sig: str = Query(..., description="EIP-191 signature"),
db: AsyncSession = Depends(get_db),
):
wallet = wallet.lower().strip()
verify_signed_request(
action=ACTION_VIEW_PERFORMANCE,
wallet=wallet,
timestamp_ms=ts,
signature=sig,
body=None,
allow_replay=True,
)
since = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=PERIOD_DAYS) since = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=PERIOD_DAYS)
result = await db.execute( result = await db.execute(
select(BotTrade) select(BotTrade)
.where(BotTrade.wallet_address == wallet)
.where(BotTrade.closed_at.is_not(None)) .where(BotTrade.closed_at.is_not(None))
.where(BotTrade.opened_at >= since) .where(BotTrade.opened_at >= since)
.order_by(BotTrade.opened_at.asc()) .order_by(BotTrade.opened_at.asc())
+392
View File
@@ -0,0 +1,392 @@
"""
Open positions + today's P&L — surface what every trader checks first.
The existing endpoints answer "what closed?" (trades) and "what's a signal?"
(posts). Neither answers "what do I currently hold?" — which is the very
first question on every trading dashboard.
This module exposes two reads for a connected wallet:
GET /api/positions/open?wallet=...
List every BotTrade with closed_at IS NULL for the wallet, enriched
with current price (from price_store) and unrealized PnL.
Works for both paper and real trades.
GET /api/positions/today?wallet=...
Realized P&L for trades closed since UTC midnight. Plus open count.
Cheap aggregate — drives the "today" tile on dashboards.
Both are no-auth (same trust model as /user/{wallet}/public). The wallet
address is the access key; anyone who knows it can read its state.
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import BotTrade, Subscription, iso_utc
from app.services.crypto import decrypt_api_key
from app.services.price_store import price_store
from app.services.signed_request import verify_signed_request
router = APIRouter()
logger = logging.getLogger(__name__)
# ─── Schemas ────────────────────────────────────────────────────────────────
class OpenPosition(BaseModel):
trade_id: int
asset: str
side: str # "long" | "short"
entry_price: float
current_price: Optional[float] = None # None if price_store lacks the asset
size_usd: Optional[float] = None # OPEN notional (after de-risk)
leverage: Optional[int] = None
opened_at: str
hold_minutes: int
unrealized_pct: Optional[float] = None # signed in trade direction
unrealized_usd: Optional[float] = None # on the OPEN portion only
is_paper: bool = False
trigger_post_id: Optional[int] = None
# System-2 staged lifecycle (so the UI doesn't misreport a de-risked /
# pyramided trade). realized_usd = PnL already banked by partial de-risk.
realized_usd: Optional[float] = None
derisk_steps: int = 0
addon_steps: int = 0
grow_mode: bool = False
class OpenPositionsResponse(BaseModel):
wallet: str
count: int
positions: list[OpenPosition]
class TodayStatsResponse(BaseModel):
wallet: str
realized_pnl_usd: float
trades_closed: int
wins: int
losses: int
open_count: int
# PnL already banked by staged de-risk on positions that are STILL open
# (cumulative for those trades — not date-scoped, since per-step times
# aren't tracked). Surfaced separately so it's visible without corrupting
# the strict closed-trade realised figure.
open_realized_usd: float = 0.0
# ─── Helpers ────────────────────────────────────────────────────────────────
def _enrich(trade: BotTrade) -> OpenPosition:
"""Add current price + unrealized PnL to a BotTrade row.
Uses price_store (in-memory Binance ticks). For assets we don't stream
(TRUMP, HYPE, niche perps) current_price will be None and the UI shows
"live price unavailable".
"""
now_aware = datetime.now(timezone.utc)
opened_aware = trade.opened_at.replace(tzinfo=timezone.utc)
hold_min = int((now_aware - opened_aware).total_seconds() // 60)
# Only the STILL-OPEN fraction is on the book. After staged de-risk,
# remaining_fraction < 1.0 and the rest was already realised — marking
# the full size_usd to market would overstate both size and PnL.
rem_frac = trade.remaining_fraction if trade.remaining_fraction is not None else 1.0
open_notional = round((trade.size_usd or 0.0) * rem_frac, 2)
realized = round(trade.realized_partial_pnl_usd or 0.0, 2)
current = price_store.latest_price(trade.asset)
unrealized_pct: Optional[float] = None
unrealized_usd: Optional[float] = None
if current is not None and trade.entry_price:
raw_pct = (current - trade.entry_price) / trade.entry_price
signed_pct = (raw_pct if trade.side == "long" else -raw_pct) * 100
unrealized_pct = round(signed_pct, 3)
if open_notional:
unrealized_usd = round(open_notional * signed_pct / 100, 2)
return OpenPosition(
trade_id=trade.id,
asset=trade.asset,
side=trade.side,
entry_price=trade.entry_price,
current_price=current,
size_usd=open_notional,
leverage=trade.leverage,
opened_at=iso_utc(trade.opened_at) or "",
hold_minutes=hold_min,
unrealized_pct=unrealized_pct,
unrealized_usd=unrealized_usd,
is_paper=(trade.hl_order_id == "paper"),
trigger_post_id=trade.trigger_post_id,
realized_usd=(realized if realized else None),
derisk_steps=(trade.derisk_steps_done or 0),
addon_steps=(trade.addon_steps_done or 0),
grow_mode=bool(getattr(trade, "grow_mode", False)),
)
# ─── Endpoints ──────────────────────────────────────────────────────────────
@router.get("/positions/open", response_model=OpenPositionsResponse)
async def get_open_positions(
wallet: str = Query(..., description="Wallet address (lower-cased internally)"),
ts: int = Query(..., description="Signed timestamp (ms)"),
sig: str = Query(..., description="EIP-191 signature"),
db: AsyncSession = Depends(get_db),
):
"""Live open positions for the wallet, with mark-to-market PnL."""
wallet = wallet.lower().strip()
verify_signed_request(
action=ACTION_VIEW_POSITIONS,
wallet=wallet,
timestamp_ms=ts,
signature=sig,
body=None,
allow_replay=True,
)
rows = await db.execute(
select(BotTrade).where(
BotTrade.wallet_address == wallet,
BotTrade.closed_at.is_(None),
).order_by(BotTrade.opened_at.desc())
)
trades = rows.scalars().all()
positions = [_enrich(t) for t in trades]
return OpenPositionsResponse(wallet=wallet, count=len(positions), positions=positions)
@router.get("/positions/today", response_model=TodayStatsResponse)
async def get_today_stats(
wallet: str = Query(...),
ts: int = Query(..., description="Signed timestamp (ms)"),
sig: str = Query(..., description="EIP-191 signature"),
db: AsyncSession = Depends(get_db),
):
"""Today's realized P&L (since UTC midnight) + open count.
Used for the "today" KPI tile. Cheap — one indexed range scan + a
one-shot count for opens.
"""
wallet = wallet.lower().strip()
verify_signed_request(
action=ACTION_VIEW_POSITIONS,
wallet=wallet,
timestamp_ms=ts,
signature=sig,
body=None,
allow_replay=True,
)
midnight = datetime.now(timezone.utc).replace(
hour=0, minute=0, second=0, microsecond=0, tzinfo=None
)
closed_rows = await db.execute(
select(BotTrade).where(
BotTrade.wallet_address == wallet,
BotTrade.closed_at >= midnight,
BotTrade.pnl_usd.is_not(None),
)
)
closed = closed_rows.scalars().all()
open_rows = await db.execute(
select(BotTrade).where(
BotTrade.wallet_address == wallet,
BotTrade.closed_at.is_(None),
)
)
open_trades = open_rows.scalars().all()
open_count = len(open_trades)
open_realized = sum(t.realized_partial_pnl_usd or 0.0 for t in open_trades)
realized = sum(t.pnl_usd or 0 for t in closed)
wins = sum(1 for t in closed if (t.pnl_usd or 0) > 0)
losses = sum(1 for t in closed if (t.pnl_usd or 0) < 0)
return TodayStatsResponse(
wallet=wallet,
realized_pnl_usd=round(realized, 2),
trades_closed=len(closed),
wins=wins,
losses=losses,
open_count=open_count,
open_realized_usd=round(open_realized, 2),
)
# ─── Manual close (P0.1 safety valve) ───────────────────────────────────────
ACTION_CLOSE_TRADE = "close_trade"
ACTION_SET_GROW = "set_trade_grow"
ACTION_VIEW_POSITIONS = "view_positions"
class CloseTradeResponse(BaseModel):
status: str
trade_id: int
exit_price: Optional[float] = None
pnl_usd: Optional[float] = None
reason: str
note: Optional[str] = None
@router.post("/positions/{trade_id}/close", response_model=CloseTradeResponse)
async def manual_close(
trade_id: int,
request: Request,
db: AsyncSession = Depends(get_db),
):
"""User-initiated emergency close. Always available — bypasses CB, schedule,
even circuit breaker lockout. Wallet ownership is verified by signature.
Path: trade_id → look up wallet → verify signature against THAT wallet →
call close_and_finalize. Reason stamped as "manual" so it's distinguishable
in the trade log from TP / SL / trailing / max_hold exits.
The signed body is {"trade_id": <id>} so a leaked sig for trade X can't be
replayed to close trade Y.
"""
raw = await request.json()
wallet = (raw.get("wallet") or "").lower().strip()
timestamp = raw.get("timestamp")
signature = raw.get("signature")
if not wallet or not isinstance(timestamp, int) or not isinstance(signature, str):
raise HTTPException(422, "wallet, timestamp, signature required")
# Load the trade. Must be open AND owned by the signing wallet.
trade = (await db.execute(select(BotTrade).where(BotTrade.id == trade_id))).scalar_one_or_none()
if trade is None:
raise HTTPException(404, f"trade {trade_id} not found")
if trade.wallet_address.lower() != wallet:
raise HTTPException(403, "trade belongs to a different wallet")
if trade.closed_at is not None:
raise HTTPException(409, f"trade {trade_id} is already closed")
verify_signed_request(
action=ACTION_CLOSE_TRADE,
wallet=wallet,
timestamp_ms=timestamp,
signature=signature,
body={"trade_id": trade_id},
)
# Find the API key from the subscription.
sub = (await db.execute(
select(Subscription).where(Subscription.wallet_address == wallet)
)).scalar_one_or_none()
if sub is None:
raise HTTPException(404, "subscription not found")
# Paper trades close synthetically; live trades need the decrypted HL key.
api_key = ""
if trade.hl_order_id != "paper":
if not sub.hl_api_key:
raise HTTPException(400, "wallet has no HL API key — cannot close live position")
try:
api_key = decrypt_api_key(sub.hl_api_key)
except Exception as exc:
raise HTTPException(500, f"key decryption failed: {exc}")
# close_and_finalize handles BOTH paper and live branches internally.
from app.services.bot_engine import close_and_finalize
leverage = trade.leverage if trade.leverage is not None else sub.leverage
await close_and_finalize(
trade_id=trade.id,
api_key=api_key,
leverage=leverage,
asset=trade.asset,
wallet=wallet,
reason="manual",
)
closed = (await db.execute(select(BotTrade).where(BotTrade.id == trade_id))).scalar_one()
return CloseTradeResponse(
status="ok",
trade_id=trade_id,
exit_price=closed.exit_price,
pnl_usd=closed.pnl_usd,
reason="manual",
)
class GrowResponse(BaseModel):
trade_id: int
grow_mode: bool
@router.post("/positions/{trade_id}/grow", response_model=GrowResponse)
async def set_trade_grow(
trade_id: int,
request: Request,
db: AsyncSession = Depends(get_db),
):
"""Flip the per-trade Grow switch (pyramiding). Signed + ownership-checked.
Body: { wallet, timestamp, signature, trade_id, enabled }
grow=on → this winner is scaled INTO on confirmed trend.
grow=off → hold + protective de-risk / ratchet only (de-risk + stop-loss
always run regardless — never user-toggleable).
Takes effect immediately on the live monitor (no restart needed).
"""
raw = await request.json()
wallet = (raw.get("wallet") or "").lower().strip()
timestamp = raw.get("timestamp")
signature = raw.get("signature")
enabled = raw.get("enabled")
body_tid = raw.get("trade_id")
if not wallet or not isinstance(timestamp, int) or not isinstance(signature, str):
raise HTTPException(422, "wallet, timestamp, signature required")
if not isinstance(enabled, bool):
raise HTTPException(422, "enabled (bool) required")
if body_tid != trade_id:
raise HTTPException(400, "trade_id mismatch (path vs signed body)")
trade = (await db.execute(select(BotTrade).where(BotTrade.id == trade_id))).scalar_one_or_none()
if trade is None:
raise HTTPException(404, f"trade {trade_id} not found")
if trade.wallet_address.lower() != wallet:
raise HTTPException(403, "trade belongs to a different wallet")
if trade.closed_at is not None:
raise HTTPException(409, f"trade {trade_id} is already closed")
verify_signed_request(
action=ACTION_SET_GROW,
wallet=wallet,
timestamp_ms=timestamp,
signature=signature,
body={"trade_id": trade_id, "enabled": enabled},
)
trade.grow_mode = enabled
await db.commit()
# Apply to the live monitor immediately so it takes effect this tick.
try:
from app.services.tp_sl_monitor import _watched
wt = _watched.get(trade_id)
if wt is not None:
wt.grow_mode = enabled
except Exception as exc:
logger.warning("grow toggle: live monitor update skipped trade %d: %s",
trade_id, exc)
logger.info("Grow %s for trade %d by %s",
"ON" if enabled else "OFF", trade_id, wallet)
return GrowResponse(trade_id=trade_id, grow_mode=enabled)
+1
View File
@@ -61,6 +61,7 @@ def _post_to_schema(post: Post) -> TrumpPost:
target_asset=post.target_asset, target_asset=post.target_asset,
category=post.category, category=post.category,
expected_move_pct=post.expected_move_pct, expected_move_pct=post.expected_move_pct,
invalidation_price=post.invalidation_price,
) )
+182
View File
@@ -0,0 +1,182 @@
"""
Scanner control endpoints — kill switch + per-scanner toggle + status.
GET /scanners is public (read-only status, polled by the UI).
ALL mutating endpoints (toggle / all-disable / all-enable) require a SIGNED
request from a SUBSCRIBED wallet. Rationale: the scanner switch is a global
control — flipping it OFF stops the signal engine for every user. Before, it
was unauthenticated and any anonymous visitor could kill it. Now the caller
must prove wallet ownership (EIP-191 signature) AND already be a subscriber.
# See what's running (still public)
curl http://localhost:8000/api/scanners
Mutations are no longer curl-able without a wallet signature — that's the
point. Use the dashboard (which signs via the connected wallet).
"""
from __future__ import annotations
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Subscription
from app.services import scanner_state
from app.services.signed_request import verify_signed_request
router = APIRouter()
logger = logging.getLogger(__name__)
ACTION_TOGGLE_SCANNER = "toggle_scanner"
ACTION_SCANNER_KILL = "scanner_kill_all"
ACTION_SCANNER_REVIVE = "scanner_revive_all"
class ScannerSummary(BaseModel):
name: str
enabled: bool
last_run_at: Optional[str] = None
last_status: str
last_message: Optional[str] = None
last_fired_at: Optional[str] = None
total_runs: int
total_fires: int
consecutive_errors: int
class ScannersResponse(BaseModel):
count: int
enabled: int
scanners: list[ScannerSummary]
class ToggleResponse(BaseModel):
status: str
name: Optional[str] = None
enabled: Optional[bool] = None
count: Optional[int] = None
async def _require_subscribed_signer(
request: Request,
db: AsyncSession,
*,
action: str,
body: Optional[dict],
) -> str:
"""Verify the signed envelope and that the signer is a subscriber.
Expects JSON body: { wallet, timestamp, signature, ...body fields }.
`body` is the canonical signed payload (must match what the client
hashed) — pass None for no-body actions (kill/revive).
Returns the verified lower-cased wallet.
"""
raw = await request.json()
wallet = (raw.get("wallet") or "").lower().strip()
timestamp = raw.get("timestamp")
signature = raw.get("signature")
if not wallet:
raise HTTPException(422, "wallet required")
if not isinstance(timestamp, int):
raise HTTPException(422, "timestamp (ms) required")
if not isinstance(signature, str) or not signature:
raise HTTPException(422, "signature required")
verify_signed_request(
action=action,
wallet=wallet,
timestamp_ms=timestamp,
signature=signature,
body=body,
)
sub = (await db.execute(
select(Subscription).where(Subscription.wallet_address == wallet)
)).scalar_one_or_none()
if sub is None:
raise HTTPException(403, "Wallet is not a subscriber — scanner control denied")
return wallet
@router.get("/scanners", response_model=ScannersResponse)
async def list_scanners() -> ScannersResponse:
"""Operational snapshot of every registered scanner. Polled by the UI
every 15-30s. Idempotent + cheap (in-memory dict read). Public."""
all_states = scanner_state.get_all()
return ScannersResponse(
count=len(all_states),
enabled=sum(1 for s in all_states if s.enabled),
scanners=[ScannerSummary(**s.to_dict()) for s in all_states],
)
@router.post("/scanners/{name}/toggle", response_model=ToggleResponse)
async def toggle_scanner(
name: str,
request: Request,
db: AsyncSession = Depends(get_db),
) -> ToggleResponse:
"""Flip ONE scanner. Signed + subscriber-gated.
Body: { wallet, timestamp, signature, name, enabled }
The signed body is { "enabled": <bool>, "name": <str> }.
"""
raw = await request.json()
enabled = raw.get("enabled")
body_name = raw.get("name")
if not isinstance(enabled, bool):
raise HTTPException(422, "enabled (bool) required")
if body_name != name:
raise HTTPException(400, "scanner name mismatch (path vs signed body)")
# NOTE: request.json() is cached by Starlette, so re-reading inside the
# helper returns the same payload.
wallet = await _require_subscribed_signer(
request, db, action=ACTION_TOGGLE_SCANNER,
body={"enabled": enabled, "name": name},
)
s = scanner_state.set_enabled(name, enabled)
if s is None:
raise HTTPException(404, f"unknown scanner {name!r}")
logger.info("Scanner %s set enabled=%s by %s", name, s.enabled, wallet)
return ToggleResponse(status="ok", name=name, enabled=s.enabled)
@router.post("/scanners/all/disable", response_model=ToggleResponse)
async def kill_switch(
request: Request,
db: AsyncSession = Depends(get_db),
) -> ToggleResponse:
"""Emergency stop — disables ALL scanners. Signed + subscriber-gated.
Body: { wallet, timestamp, signature }. Does NOT close open positions —
manage those via /positions/{id}/close or the Trades page."""
wallet = await _require_subscribed_signer(
request, db, action=ACTION_SCANNER_KILL, body=None,
)
n = scanner_state.disable_all()
logger.warning("ALL scanners disabled (%d) by %s", n, wallet)
return ToggleResponse(status="killed", count=n)
@router.post("/scanners/all/enable", response_model=ToggleResponse)
async def revive_all(
request: Request,
db: AsyncSession = Depends(get_db),
) -> ToggleResponse:
"""Re-enable every scanner that was disabled. Signed + subscriber-gated.
Body: { wallet, timestamp, signature }."""
wallet = await _require_subscribed_signer(
request, db, action=ACTION_SCANNER_REVIVE, body=None,
)
n = scanner_state.enable_all()
logger.info("ALL scanners re-enabled (%d) by %s", n, wallet)
return ToggleResponse(status="revived", count=n)
+243
View File
@@ -0,0 +1,243 @@
"""
Generic signal ingestion endpoint.
The BTC bottom scanner POSTs a signal here and it flows through the shared
execution pipeline: sizing → risk caps → Hyperliquid execution → trailing
stop monitor. Unknown sources are accepted into the posts table for audit,
but the execution layer fails closed and will not trade them.
Why one endpoint instead of many?
The trusted scanners and the Trump scraper share the same trade execution
path. The downstream code sees a `Post` row with `signal=buy|short`, then
checks whether that post source is allowed to trade.
Auth: shared secret in X-Ingest-Key header. Fail-closed if INGEST_API_KEY
is empty in env (so a fresh deploy can't accidentally accept random POSTs).
Source field convention:
- "truth" → Trump posts (existing scraper). Reserved — rejected here.
- "btc_bottom_reversal" → Bitcoin Bottom scanner
- "<custom>" → Audit/storage only unless whitelisted in
signal_categories.py.
"""
from __future__ import annotations
import hashlib
import logging
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.models import Post
router = APIRouter()
logger = logging.getLogger(__name__)
# ─── Schema ─────────────────────────────────────────────────────────────────
class SignalIngestRequest(BaseModel):
"""Payload for POST /api/signals/ingest.
Mirrors the Post schema closely so a signal here becomes a Post row
1:1, with no field translation. Downstream code is unaware of the source.
"""
source: str = Field(..., min_length=2, max_length=32,
description="Signal source tag, e.g. 'btc_bottom_reversal'. 'truth' is reserved.")
external_id: str = Field(..., min_length=4, max_length=64,
description="Caller-supplied unique key. Used for idempotent retries.")
text: str = Field(..., min_length=1, max_length=4000,
description="Human-readable description of why the signal fired. Shown in UI.")
signal: str = Field(..., description="'buy' or 'short' — non-actionable signals should not be ingested.")
target_asset: str = Field(..., min_length=2, max_length=16,
description="Hyperliquid perp ticker, e.g. 'SOL', 'BTC'.")
confidence: int = Field(..., ge=0, le=100,
description="0100. Drives position sizing (≥90 boosts multiplier).")
category: str = Field(..., min_length=2, max_length=24,
description="Subtype within source, e.g. 'btc_bottom_reversal_long'.")
expected_move_pct: Optional[float] = Field(None, ge=0, le=100,
description="Optional: caller's expected % move. UI display only.")
invalidation_price: Optional[float] = Field(None, ge=0,
description="Optional thesis invalidation level for System 2.")
published_at: Optional[datetime] = Field(None,
description="Defaults to server now (UTC). Set explicitly only for backfill.")
@field_validator("signal")
@classmethod
def _signal_must_be_directional(cls, v: str) -> str:
if v not in ("buy", "short"):
raise ValueError(f"signal must be 'buy' or 'short' (got {v!r})")
return v
@field_validator("source")
@classmethod
def _no_reserved_sources(cls, v: str) -> str:
if v.lower() == "truth":
raise ValueError("'truth' is reserved for the Trump scraper. Use a different source tag.")
return v.lower()
class SignalIngestResponse(BaseModel):
status: str # "accepted" | "duplicate" | "skipped"
post_id: Optional[int] = None
dedup_against: Optional[int] = None
note: Optional[str] = None
# ─── Auth helper ────────────────────────────────────────────────────────────
def _verify_ingest_key(x_ingest_key: Optional[str]) -> None:
"""Fail-closed auth check.
- INGEST_API_KEY unset → return 503 (endpoint disabled by config)
- Header missing or wrong → 401
"""
expected = settings.ingest_api_key
if not expected:
raise HTTPException(503, "signal ingest endpoint is disabled (INGEST_API_KEY not configured)")
if not x_ingest_key:
raise HTTPException(401, "missing X-Ingest-Key header")
if x_ingest_key != expected:
raise HTTPException(401, "invalid X-Ingest-Key")
# ─── Endpoint ───────────────────────────────────────────────────────────────
@router.post("/signals/ingest", response_model=SignalIngestResponse)
async def ingest_signal(
body: SignalIngestRequest,
x_ingest_key: Optional[str] = Header(None, alias="X-Ingest-Key"),
db: AsyncSession = Depends(get_db),
) -> SignalIngestResponse:
"""Accept a signal from an external trading module.
Pipeline behaviour:
1. Auth (shared secret in header)
2. Dedup by external_id (idempotent — same id returns the existing post_id)
3. Insert as a Post row (source = caller-supplied)
4. If the source is whitelisted for trading, hand off to
bot_engine.process_post. Otherwise store for audit and return
status="skipped".
Note: the entry filter ([A] in the pipeline) is SKIPPED for non-truth
sources. That filter is tuned for Trump's writing patterns; technical
signals are their own catalyst and don't need text-based gating.
"""
_verify_ingest_key(x_ingest_key)
# Hash external_id the same way truth_social.py does — keeps the dedup
# key short and uniform across sources.
ext_id_hashed = hashlib.md5(f"{body.source}:{body.external_id}".encode()).hexdigest()
# Idempotency check
existing = await db.execute(select(Post).where(Post.external_id == ext_id_hashed))
prior = existing.scalar_one_or_none()
if prior:
return SignalIngestResponse(
status="duplicate",
post_id=prior.id,
dedup_against=prior.id,
note=f"external_id {body.external_id!r} already ingested",
)
# Publication time defaults to server now (naive UTC, matching other rows)
pub_naive = (body.published_at or datetime.now(timezone.utc))
if pub_naive.tzinfo is not None:
pub_naive = pub_naive.astimezone(timezone.utc).replace(tzinfo=None)
post = Post(
external_id=ext_id_hashed,
text=body.text,
source=body.source,
published_at=pub_naive,
sentiment="bullish" if body.signal == "buy" else "bearish",
ai_confidence=body.confidence,
relevant=True,
signal=body.signal,
target_asset=body.target_asset.upper(),
category=body.category,
expected_move_pct=body.expected_move_pct,
invalidation_price=body.invalidation_price,
# `analysis_version` doubles as a provenance tag — easy to query in DB
analysis_version=f"ingest:{body.source}",
prefilter_reason="external_signal", # bypasses entry-filter audit
)
db.add(post)
await db.commit()
await db.refresh(post)
logger.info(
"Ingested signal: source=%s id=%s → post_id=%d, %s/%s conf=%d category=%s",
body.source, body.external_id, post.id,
body.signal, body.target_asset, body.confidence, body.category,
)
from app.services.signal_categories import is_supported_trading_source
if not is_supported_trading_source(post.source):
logger.info("Signal %d stored but skipped: unsupported trading source=%s",
post.id, post.source)
return SignalIngestResponse(
status="skipped",
post_id=post.id,
note=f"source {post.source!r} is not enabled for live trading",
)
# Hand off to the same trade pipeline used by Trump posts.
# Runs sizing → risk caps → HL trade → trailing-stop monitor.
try:
from app.services.bot_engine import process_post
await process_post(post, db)
except Exception as exc:
# Don't fail the HTTP response — the post is already in DB, and
# process_post failures shouldn't make the external module retry
# (which would duplicate signals). Log and return success.
logger.error("process_post failed for ingested signal %d: %s", post.id, exc)
# Broadcast to UI so the new signal shows up live, same as Trump posts.
try:
from app.scrapers.truth_social import _post_to_ws_payload
from app.ws.manager import manager
await manager.broadcast(_post_to_ws_payload(post))
except Exception as exc:
logger.warning("WS broadcast failed for signal %d: %s", post.id, exc)
# Fan out to Telegram subscribers (fire-and-forget; never blocks ingest).
try:
from app.services.telegram import notify_signal
notify_signal(post)
except Exception as exc:
logger.warning("Telegram notify failed for signal %d: %s", post.id, exc)
return SignalIngestResponse(status="accepted", post_id=post.id)
@router.get("/signals/sources")
async def list_sources(db: AsyncSession = Depends(get_db)) -> dict:
"""List distinct signal sources we've seen, with counts.
Helpful for spot-checking that ingestion is working and for the UI to
show a source filter without hard-coding the list.
"""
from sqlalchemy import func
rows = await db.execute(
select(Post.source, func.count(Post.id), func.max(Post.published_at))
.group_by(Post.source)
.order_by(func.count(Post.id).desc())
)
return {
"sources": [
{"source": r[0], "count": r[1], "latest": r[2].isoformat() if r[2] else None}
for r in rows.all()
]
}
+29 -6
View File
@@ -1,7 +1,7 @@
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, Request
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -17,15 +17,29 @@ ACTION_SUBSCRIBE = "subscribe"
@router.post("/subscribe", response_model=SubscribeResponse) @router.post("/subscribe", response_model=SubscribeResponse)
async def subscribe(body: SubscribeRequest, db: AsyncSession = Depends(get_db)): async def subscribe(request: Request, db: AsyncSession = Depends(get_db)):
"""Activate a subscription. Optional `paper_mode` flag in the body lets
new users try the system safely (no Hyperliquid call, simulated fills).
Signed message body is the RAW dict so the canonical hash matches what
the frontend signed — same pattern as set_user_settings.
"""
raw = await request.json()
body = SubscribeRequest(**raw)
wallet = body.wallet.lower().strip() wallet = body.wallet.lower().strip()
# The signed body excludes the envelope fields. For backwards compatibility
# with old clients that signed `body=None` (no paper_mode key), accept
# either signature.
paper_mode = bool(raw.get("paper_mode", False))
signed_body = {"paper_mode": paper_mode} if paper_mode else None
verify_signed_request( verify_signed_request(
action=ACTION_SUBSCRIBE, action=ACTION_SUBSCRIBE,
wallet=wallet, wallet=wallet,
timestamp_ms=body.timestamp, timestamp_ms=body.timestamp,
signature=body.signature, signature=body.signature,
body=None, body=signed_body,
) )
result = await db.execute( result = await db.execute(
@@ -35,13 +49,22 @@ async def subscribe(body: SubscribeRequest, db: AsyncSession = Depends(get_db)):
now = datetime.now(timezone.utc).replace(tzinfo=None) now = datetime.now(timezone.utc).replace(tzinfo=None)
if sub is None: if sub is None:
sub = Subscription(wallet_address=wallet, active=True, subscribed_at=now) sub = Subscription(
wallet_address=wallet,
active=True,
subscribed_at=now,
paper_mode=paper_mode,
)
db.add(sub) db.add(sub)
else: else:
sub.active = True sub.active = True
if sub.subscribed_at is None: if sub.subscribed_at is None:
sub.subscribed_at = now sub.subscribed_at = now
# Re-subscribing is allowed to change paper mode (e.g. user wants to
# promote from paper to live). Otherwise leave existing flag alone.
if paper_mode != sub.paper_mode:
sub.paper_mode = paper_mode
await db.commit() await db.commit()
logger.info("Subscription activated for wallet %s", wallet) logger.info("Subscription activated for %s (paper_mode=%s)", wallet, paper_mode)
return SubscribeResponse(status="ok", wallet=wallet) return SubscribeResponse(status="ok", wallet=wallet, paper_mode=paper_mode)
+232
View File
@@ -0,0 +1,232 @@
"""
Telegram binding + preferences API.
All mutating endpoints require a signed envelope from the wallet (same EIP-191
flow as set_hl_api_key). Read endpoints are unsigned but require the wallet
in the path so other users' bindings stay private.
GET /api/telegram/{wallet}/status ← unsigned read
POST /api/telegram/{wallet}/init ← signed; returns binding code + deep link
POST /api/telegram/{wallet}/preferences ← signed; update toggles
POST /api/telegram/{wallet}/unbind ← signed; removes binding
POST /api/telegram/{wallet}/test ← signed; sends a self-test
"""
from __future__ import annotations
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.models import TelegramBinding, Subscription
from app.services.signed_request import verify_signed_request
from app.services.telegram import send_test_message
from app.services.telegram_bot import issue_binding_code, unbind_wallet
router = APIRouter(prefix="/telegram", tags=["telegram"])
logger = logging.getLogger(__name__)
ACTION_TG_INIT = "telegram_init"
ACTION_TG_PREFS = "telegram_prefs"
ACTION_TG_UNBIND = "telegram_unbind"
ACTION_TG_TEST = "telegram_test"
def _require_tg_configured() -> None:
if not settings.telegram_bot_token or not settings.telegram_bot_username:
raise HTTPException(503, "Telegram alerts are not configured on this server")
# ── Schemas ──────────────────────────────────────────────────────────────
class SignedEnvelope(BaseModel):
wallet: str
timestamp: int
signature: str
class PrefsBody(SignedEnvelope):
alerts_enabled: Optional[bool] = None
alert_trump: Optional[bool] = None
alert_btc_bottom: Optional[bool] = None
alert_funding: Optional[bool] = None
alert_kol_divergence: Optional[bool] = None
min_confidence: Optional[int] = Field(None, ge=0, le=100)
mute_from_hour: Optional[int] = Field(None, ge=0, le=23)
mute_until_hour: Optional[int] = Field(None, ge=0, le=23)
class StatusResponse(BaseModel):
configured: bool # whether server has bot token
bot_username: Optional[str] = None
bound: bool
wallet_address: Optional[str] = None
tg_username: Optional[str] = None
chat_id: Optional[int] = None
alerts_enabled: Optional[bool] = None
alert_trump: Optional[bool] = None
alert_btc_bottom: Optional[bool] = None
alert_funding: Optional[bool] = None
alert_kol_divergence: Optional[bool] = None
min_confidence: Optional[int] = None
mute_from_hour: Optional[int] = None
mute_until_hour: Optional[int] = None
total_alerts_sent: Optional[int] = None
class InitResponse(BaseModel):
code: str
deep_link: str # t.me/<bot>?start=<code>
expires_in_seconds: int
# ── Endpoints ────────────────────────────────────────────────────────────
@router.get("/{wallet}/status", response_model=StatusResponse)
async def status(wallet: str, db: AsyncSession = Depends(get_db)) -> StatusResponse:
"""Public-by-wallet read. Returns whether server is configured AND
whether this wallet has bound a Telegram chat."""
wallet = wallet.lower().strip()
configured = bool(settings.telegram_bot_token and settings.telegram_bot_username)
b = (await db.execute(
select(TelegramBinding).where(TelegramBinding.wallet_address == wallet)
)).scalar_one_or_none()
if not b:
return StatusResponse(configured=configured,
bot_username=settings.telegram_bot_username or None,
bound=False)
return StatusResponse(
configured=configured,
bot_username=settings.telegram_bot_username or None,
bound=True,
wallet_address=b.wallet_address,
tg_username=b.tg_username,
chat_id=b.chat_id,
alerts_enabled=b.alerts_enabled,
alert_trump=b.alert_trump,
alert_btc_bottom=b.alert_btc_bottom,
alert_funding=b.alert_funding,
alert_kol_divergence=b.alert_kol_divergence,
min_confidence=b.min_confidence,
mute_from_hour=b.mute_from_hour,
mute_until_hour=b.mute_until_hour,
total_alerts_sent=b.total_alerts_sent,
)
@router.post("/{wallet}/init", response_model=InitResponse)
async def init_binding(
wallet: str, body: SignedEnvelope,
db: AsyncSession = Depends(get_db),
) -> InitResponse:
"""Generate a one-time code and the deep link the frontend renders.
Subscriber-gated — only paying wallets can receive alerts."""
_require_tg_configured()
wallet = wallet.lower().strip()
if wallet != body.wallet.lower().strip():
raise HTTPException(400, "Wallet mismatch")
verify_signed_request(
action=ACTION_TG_INIT, wallet=wallet,
timestamp_ms=body.timestamp, signature=body.signature, body=None,
)
sub = (await db.execute(
select(Subscription).where(Subscription.wallet_address == wallet)
)).scalar_one_or_none()
if not sub or not sub.active:
raise HTTPException(403, "Wallet must be subscribed to enable Telegram alerts")
code = issue_binding_code(wallet)
deep_link = f"https://t.me/{settings.telegram_bot_username}?start={code}"
return InitResponse(code=code, deep_link=deep_link, expires_in_seconds=600)
@router.post("/{wallet}/preferences", response_model=StatusResponse)
async def update_preferences(
wallet: str, body: PrefsBody,
db: AsyncSession = Depends(get_db),
) -> StatusResponse:
"""Toggle alert sources, confidence floor, mute hours. Signed.
Idempotent — only fields present in the body are updated."""
_require_tg_configured()
wallet = wallet.lower().strip()
if wallet != body.wallet.lower().strip():
raise HTTPException(400, "Wallet mismatch")
# Build a canonical body for signing: include only the fields the user
# is actually trying to change (so the signed payload matches what the
# frontend hashed).
signed_body = {k: v for k, v in body.model_dump(exclude={"wallet", "timestamp", "signature"}).items()
if v is not None}
verify_signed_request(
action=ACTION_TG_PREFS, wallet=wallet,
timestamp_ms=body.timestamp, signature=body.signature,
body=signed_body,
)
b = (await db.execute(
select(TelegramBinding).where(TelegramBinding.wallet_address == wallet)
)).scalar_one_or_none()
if not b:
raise HTTPException(404, "No Telegram binding for this wallet — bind via /start first")
values = {}
for f in ["alerts_enabled", "alert_trump", "alert_btc_bottom",
"alert_funding", "alert_kol_divergence", "min_confidence",
"mute_from_hour", "mute_until_hour"]:
v = getattr(body, f)
if v is not None:
values[f] = v
if values:
await db.execute(
update(TelegramBinding).where(TelegramBinding.id == b.id).values(**values)
)
await db.commit()
await db.refresh(b)
return await status(wallet, db)
@router.post("/{wallet}/unbind")
async def unbind(
wallet: str, body: SignedEnvelope,
db: AsyncSession = Depends(get_db),
) -> dict:
_require_tg_configured()
wallet = wallet.lower().strip()
if wallet != body.wallet.lower().strip():
raise HTTPException(400, "Wallet mismatch")
verify_signed_request(
action=ACTION_TG_UNBIND, wallet=wallet,
timestamp_ms=body.timestamp, signature=body.signature, body=None,
)
n = await unbind_wallet(wallet)
return {"removed": n}
@router.post("/{wallet}/test")
async def test(
wallet: str, body: SignedEnvelope,
db: AsyncSession = Depends(get_db),
) -> dict:
"""Send a sample alert to verify the binding works end-to-end."""
_require_tg_configured()
wallet = wallet.lower().strip()
if wallet != body.wallet.lower().strip():
raise HTTPException(400, "Wallet mismatch")
verify_signed_request(
action=ACTION_TG_TEST, wallet=wallet,
timestamp_ms=body.timestamp, signature=body.signature, body=None,
)
ok = await send_test_message(wallet)
if not ok:
raise HTTPException(400, "Test failed — bind via /start first, or check bot token")
return {"sent": True}
+33 -1
View File
@@ -3,17 +3,31 @@ from typing import List
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import joinedload
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
from app.models import BotTrade, iso_utc from app.models import BotTrade, iso_utc
from app.schemas import BotTrade as BotTradeSchema from app.schemas import BotTrade as BotTradeSchema
from app.services.signed_request import verify_signed_request
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ACTION_VIEW_TRADES = "view_trades"
def _trade_to_schema(trade: BotTrade) -> BotTradeSchema: def _trade_to_schema(trade: BotTrade) -> BotTradeSchema:
# Join against trigger_post to surface the source tag. When a trade was
# opened by an ingested signal (VCP scanner, user's module, etc.) the
# source reveals WHICH module produced it — critical for "is module X
# actually making money?" attribution analysis.
trigger_source = None
if trade.trigger_post is not None:
trigger_source = trade.trigger_post.source
# Paper trades are tagged via hl_order_id at open time; that's the only
# stable signal we have to distinguish them in aggregate views.
is_paper = (trade.hl_order_id == "paper")
return BotTradeSchema( return BotTradeSchema(
id=trade.id, id=trade.id,
asset=trade.asset, asset=trade.asset,
@@ -25,18 +39,36 @@ def _trade_to_schema(trade: BotTrade) -> BotTradeSchema:
trigger_post_id=trade.trigger_post_id or 0, trigger_post_id=trade.trigger_post_id or 0,
opened_at=iso_utc(trade.opened_at) or "", opened_at=iso_utc(trade.opened_at) or "",
closed_at=iso_utc(trade.closed_at) or "", closed_at=iso_utc(trade.closed_at) or "",
trigger_source=trigger_source,
is_paper=is_paper,
) )
@router.get("/trades", response_model=List[BotTradeSchema]) @router.get("/trades", response_model=List[BotTradeSchema])
async def get_trades( async def get_trades(
wallet: str = Query(..., description="Wallet address (lower-cased internally)"),
ts: int = Query(..., description="Signed timestamp (ms)"),
sig: str = Query(..., description="EIP-191 signature"),
limit: int = Query(default=20, ge=1, le=100), limit: int = Query(default=20, ge=1, le=100),
page: int = Query(default=1, ge=1), page: int = Query(default=1, ge=1),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
wallet = wallet.lower().strip()
verify_signed_request(
action=ACTION_VIEW_TRADES,
wallet=wallet,
timestamp_ms=ts,
signature=sig,
body=None,
allow_replay=True,
)
offset = (page - 1) * limit offset = (page - 1) * limit
# joinedload pulls trigger_post in the same query — avoids N+1 lookups
# when serialising 100 trades.
result = await db.execute( result = await db.execute(
select(BotTrade) select(BotTrade)
.options(joinedload(BotTrade.trigger_post))
.where(BotTrade.wallet_address == wallet)
.where(BotTrade.closed_at.is_not(None)) .where(BotTrade.closed_at.is_not(None))
.order_by(BotTrade.opened_at.desc()) .order_by(BotTrade.opened_at.desc())
.offset(offset) .offset(offset)
+177 -5
View File
@@ -15,15 +15,30 @@ from app.schemas import (
SetSettingsRequest, SetSettingsRequest,
) )
from app.services.crypto import encrypt_api_key from app.services.crypto import encrypt_api_key
from app.services.hyperliquid import HyperliquidTrader
from app.services.signed_request import verify_signed_request from app.services.signed_request import verify_signed_request
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Action names — must match frontend exactly (used in the signed message) # Action names — must match frontend exactly (used in the signed message)
ACTION_SET_API_KEY = "set_hl_api_key" ACTION_SET_API_KEY = "set_hl_api_key"
ACTION_SET_SETTINGS = "set_settings" ACTION_SET_SETTINGS = "set_settings"
ACTION_VIEW_USER = "view_user" ACTION_VIEW_USER = "view_user"
ACTION_SET_MANUAL_WINDOW = "set_manual_window"
ACTION_SET_AUTO_TRADE = "set_auto_trade"
async def verify_hl_api_key_can_trade(api_key: str, account_address: str) -> None:
"""Fail before storing an unusable Hyperliquid API wallet key."""
try:
trader = HyperliquidTrader(
api_private_key=api_key,
account_address=account_address,
)
await trader.get_balance()
except Exception as exc:
raise HTTPException(422, f"Hyperliquid rejected this API key: {exc}")
def _trade_to_schema(trade: BotTrade) -> BotTradeSchema: def _trade_to_schema(trade: BotTrade) -> BotTradeSchema:
@@ -70,12 +85,14 @@ async def set_hl_api_key(
if sub is None: if sub is None:
raise HTTPException(404, "Wallet not subscribed. Subscribe first.") raise HTTPException(404, "Wallet not subscribed. Subscribe first.")
await verify_hl_api_key_can_trade(api_key=api_key, account_address=wallet)
sub.hl_api_key = encrypt_api_key(api_key) sub.hl_api_key = encrypt_api_key(api_key)
await db.commit() await db.commit()
masked = f"...{api_key[-6:]}" masked = f"...{api_key[-6:]}"
logger.info("HL API key updated for wallet %s (masked: %s)", wallet, masked) logger.info("HL API key updated for wallet %s (masked: %s)", wallet, masked)
return SetApiKeyResponse(status="ok", masked_key=masked) return SetApiKeyResponse(status="ok", masked_key=masked, verified=True)
@router.get("/user/{wallet}/public") @router.get("/user/{wallet}/public")
@@ -86,11 +103,22 @@ async def get_user_public(wallet: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Subscription).where(Subscription.wallet_address == wallet)) result = await db.execute(select(Subscription).where(Subscription.wallet_address == wallet))
sub = result.scalar_one_or_none() sub = result.scalar_one_or_none()
if sub is None: if sub is None:
return {"wallet_address": wallet, "active": False, "hl_api_key_set": False} return {
"wallet_address": wallet, "active": False, "hl_api_key_set": False,
"paper_mode": False, "manual_window_until": None,
"circuit_breaker_tripped_at": None, "circuit_breaker_reason": None,
"auto_trade": False,
}
return { return {
"wallet_address": wallet, "wallet_address": wallet,
"active": sub.active, "active": sub.active,
"hl_api_key_set": bool(sub.hl_api_key), "hl_api_key_set": bool(sub.hl_api_key),
# Operational state shown on /signals page. Not sensitive.
"paper_mode": bool(sub.paper_mode),
"manual_window_until": iso_utc(sub.manual_window_until),
"circuit_breaker_tripped_at": iso_utc(sub.circuit_breaker_tripped_at),
"circuit_breaker_reason": sub.circuit_breaker_reason,
"auto_trade": bool(sub.auto_trade),
} }
@@ -151,9 +179,12 @@ async def get_user(
stop_loss_pct=sub.stop_loss_pct, stop_loss_pct=sub.stop_loss_pct,
min_confidence=sub.min_confidence, min_confidence=sub.min_confidence,
daily_budget_usd=sub.daily_budget_usd, daily_budget_usd=sub.daily_budget_usd,
sys2_leverage=sub.sys2_leverage,
sys2_mode=sub.sys2_mode,
active_from=iso_utc(sub.active_from), active_from=iso_utc(sub.active_from),
active_until=iso_utc(sub.active_until), active_until=iso_utc(sub.active_until),
), ),
manual_window_until=iso_utc(sub.manual_window_until),
) )
@@ -190,6 +221,10 @@ async def set_user_settings(
raise HTTPException(422, "stop_loss_pct is required (0.150)") raise HTTPException(422, "stop_loss_pct is required (0.150)")
if not (0 <= s.min_confidence <= 100): if not (0 <= s.min_confidence <= 100):
raise HTTPException(422, "min_confidence must be 0100") raise HTTPException(422, "min_confidence must be 0100")
if s.sys2_leverage is not None and not (1 <= s.sys2_leverage <= 10):
raise HTTPException(422, "sys2_leverage must be 110")
if s.sys2_mode is not None and s.sys2_mode not in ("standard", "aggressive"):
raise HTTPException(422, "sys2_mode must be 'standard' or 'aggressive'")
if s.daily_budget_usd is None or not (0 < s.daily_budget_usd <= 100000): if s.daily_budget_usd is None or not (0 < s.daily_budget_usd <= 100000):
raise HTTPException(422, "daily_budget_usd is required (>0, ≤100,000)") raise HTTPException(422, "daily_budget_usd is required (>0, ≤100,000)")
@@ -235,8 +270,145 @@ async def set_user_settings(
sub.stop_loss_pct = s.stop_loss_pct sub.stop_loss_pct = s.stop_loss_pct
sub.min_confidence = s.min_confidence sub.min_confidence = s.min_confidence
sub.daily_budget_usd = s.daily_budget_usd sub.daily_budget_usd = s.daily_budget_usd
sub.sys2_leverage = s.sys2_leverage
if s.sys2_mode is not None:
sub.sys2_mode = s.sys2_mode
sub.active_from = af sub.active_from = af
sub.active_until = au sub.active_until = au
await db.commit() await db.commit()
logger.info("Settings updated for %s: %s", wallet, s.model_dump()) logger.info("Settings updated for %s: %s", wallet, s.model_dump())
return s return s
# ─── Manual window (convex-strategy "enable for N hours" override) ───────────
@router.post("/user/{wallet}/manual-window")
async def set_manual_window(
wallet: str,
request: Request,
db: AsyncSession = Depends(get_db),
):
"""Arm the bot for the next N hours, overriding the active_from/until schedule.
Body: { wallet, timestamp, signature, hours }
hours: integer 0168. Pass 0 to clear (immediate disarm).
The bot's main gate (Subscription.active) still applies. This endpoint only
flips the schedule override; an inactive subscription stays paused.
"""
from datetime import datetime as _dt, timezone as _tz, timedelta as _td
wallet = wallet.lower().strip()
raw = await request.json()
# Manual auth payload — keep deliberately minimal so the signed string is
# short and easy to reason about.
body_wallet = (raw.get("wallet") or "").lower().strip()
timestamp = raw.get("timestamp")
signature = raw.get("signature")
hours_raw = raw.get("hours")
if body_wallet != wallet:
raise HTTPException(400, "Wallet mismatch")
if not isinstance(timestamp, int):
raise HTTPException(422, "timestamp (ms) required")
if not isinstance(signature, str) or not signature:
raise HTTPException(422, "signature required")
if not isinstance(hours_raw, int) or hours_raw < 0 or hours_raw > 168:
raise HTTPException(422, "hours must be 0168")
verify_signed_request(
action=ACTION_SET_MANUAL_WINDOW,
wallet=wallet,
timestamp_ms=timestamp,
signature=signature,
body={"hours": hours_raw},
)
result = await db.execute(select(Subscription).where(Subscription.wallet_address == wallet))
sub = result.scalar_one_or_none()
if sub is None:
raise HTTPException(404, "Wallet not subscribed")
if hours_raw == 0:
sub.manual_window_until = None
new_until_iso = None
logger.info("Manual window cleared for %s", wallet)
else:
until = _dt.now(_tz.utc).replace(tzinfo=None) + _td(hours=hours_raw)
sub.manual_window_until = until
new_until_iso = iso_utc(until)
# Explicit re-arm clears any active circuit-breaker trip — human in
# the loop has acknowledged the risk and chosen to resume.
cb_cleared = False
if sub.circuit_breaker_tripped_at is not None:
sub.circuit_breaker_tripped_at = None
sub.circuit_breaker_reason = None
cb_cleared = True
logger.info("Manual window armed for %s: until %s (%dh)%s",
wallet, until, hours_raw, " — CB cleared" if cb_cleared else "")
await db.commit()
return {"manual_window_until": new_until_iso}
# ─── Master Auto-Trade switch (simplified operator model) ───────────────────
@router.post("/user/{wallet}/auto-trade")
async def set_auto_trade(
wallet: str,
request: Request,
db: AsyncSession = Depends(get_db),
):
"""Flip the ONE persistent Auto-Trade gate.
Body: { wallet, timestamp, signature, enabled }
enabled=false → signals are still ingested/shown but NO trade opens.
enabled=true → qualifying signals auto-open trades. Turning it ON also
acknowledges + clears a tripped circuit breaker
(human-in-the-loop), mirroring the old manual-window arm.
"""
wallet = wallet.lower().strip()
raw = await request.json()
body_wallet = (raw.get("wallet") or "").lower().strip()
timestamp = raw.get("timestamp")
signature = raw.get("signature")
enabled = raw.get("enabled")
if body_wallet != wallet:
raise HTTPException(400, "Wallet mismatch")
if not isinstance(timestamp, int):
raise HTTPException(422, "timestamp (ms) required")
if not isinstance(signature, str) or not signature:
raise HTTPException(422, "signature required")
if not isinstance(enabled, bool):
raise HTTPException(422, "enabled (bool) required")
verify_signed_request(
action=ACTION_SET_AUTO_TRADE,
wallet=wallet,
timestamp_ms=timestamp,
signature=signature,
body={"enabled": enabled},
)
result = await db.execute(select(Subscription).where(Subscription.wallet_address == wallet))
sub = result.scalar_one_or_none()
if sub is None:
raise HTTPException(404, "Wallet not subscribed")
sub.auto_trade = enabled
cb_cleared = False
if enabled and sub.circuit_breaker_tripped_at is not None:
# Turning Auto-Trade ON = explicit human ack → clear the breaker.
sub.circuit_breaker_tripped_at = None
sub.circuit_breaker_reason = None
cb_cleared = True
await db.commit()
logger.info("Auto-Trade %s for %s%s",
"ON" if enabled else "OFF", wallet,
" — CB cleared" if cb_cleared else "")
return {"auto_trade": enabled, "circuit_breaker_cleared": cb_cleared}
+31 -4
View File
@@ -11,10 +11,14 @@ class Settings(BaseSettings):
"?streams=btcusdt@kline_1m/ethusdt@kline_1m" "?streams=btcusdt@kline_1m/ethusdt@kline_1m"
) )
binance_rest_url: str = "https://data-api.binance.vision" binance_rest_url: str = "https://data-api.binance.vision"
environment: str = "development" environment: str = "production"
ai_api_key: str = "" ai_api_key: str = ""
ai_base_url: str = "https://api.gptsapi.net/v1" ai_base_url: str = "https://api.deepseek.com/v1"
ai_model: str = "claude-haiku-4-5-20251001" ai_model: str = "deepseek-v4-pro" # batch / reanalysis (quality over speed)
ai_live_model: str = "deepseek-v4-flash" # live post analysis (latency-sensitive, ~2s)
# Native Anthropic API key — if set, takes priority over ai_api_key + ai_base_url.
# Get from https://console.anthropic.com → API Keys
anthropic_api_key: str = ""
# Hyperliquid — API wallet private key (NOT your MetaMask key) # Hyperliquid — API wallet private key (NOT your MetaMask key)
# Created at https://app.hyperliquid.xyz/API and authorized by MetaMask # Created at https://app.hyperliquid.xyz/API and authorized by MetaMask
@@ -30,7 +34,30 @@ class Settings(BaseSettings):
# Generate with: openssl rand -hex 32 # Generate with: openssl rand -hex 32
encryption_key: str = "" encryption_key: str = ""
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"} # Shared secret for /api/signals/ingest — external trading modules pass it
# in the X-Ingest-Key header. Empty (default) = ingest endpoint is REJECTED
# entirely until a key is configured (fail-closed).
ingest_api_key: str = ""
# Glassnode on-chain data. Used only by the BTC bottom-reversal state
# machine (MVRV-Z + STH-SOPR). Empty = scanner logs and skips fail-closed.
glassnode_api_key: str = ""
# Etherscan API key — free at etherscan.io/register → My API Keys.
# Used for KOL A-tier: fetch all ERC-20 token balances for a given ETH address.
# Empty = skip Ethereum wallet snapshots (only HL perp positions polled).
etherscan_api_key: str = ""
# ── Telegram push alerts ─────────────────────────────────────────────────
# Bot token from @BotFather (https://t.me/BotFather → /newbot). Free.
# Empty (default) = Telegram alerts disabled completely (bot loop skipped,
# notify_signal becomes a no-op, API endpoints return 503).
telegram_bot_token: str = ""
# Bot username (no @) — used by the Settings UI to render the deep link
# t.me/<username>?start=<code>. Example: "trumpalpha_bot".
telegram_bot_username: str = ""
model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"}
settings = Settings() settings = Settings()
+130 -10
View File
@@ -21,6 +21,13 @@ from app.api.trades import router as trades_router
from app.api.performance import router as performance_router from app.api.performance import router as performance_router
from app.api.subscribe import router as subscribe_router from app.api.subscribe import router as subscribe_router
from app.api.user import router as user_router from app.api.user import router as user_router
from app.api.funding_signal import router as funding_signal_router
from app.api.funding_reversal import router as funding_reversal_router
from app.api.telegram import router as telegram_router
from app.api.signals import router as signals_router
from app.api.positions import router as positions_router
from app.api.scanners import router as scanners_router
from app.api.kol import router as kol_router
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@@ -30,17 +37,22 @@ logger = logging.getLogger(__name__)
from typing import Optional from typing import Optional
_binance_task: Optional[asyncio.Task] = None _binance_task: Optional[asyncio.Task] = None
_telegram_task: Optional[asyncio.Task] = None
_scheduler: Optional[AsyncIOScheduler] = None _scheduler: Optional[AsyncIOScheduler] = None
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
global _binance_task, _scheduler global _binance_task, _telegram_task, _scheduler
# 1. Create DB tables (dev convenience; production uses Alembic) # 1. Dev convenience only. Production should rely on Alembic so schema
async with engine.begin() as conn: # ownership stays explicit and startup never mutates the DB implicitly.
await conn.run_sync(Base.metadata.create_all) if settings.environment == "development":
logger.info("Database tables ensured.") async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logger.info("Database tables ensured (development mode).")
else:
logger.info("Skipping create_all; expecting schema managed by Alembic.")
# 2. Backfill historical posts on startup (fast, no Claude API call) # 2. Backfill historical posts on startup (fast, no Claude API call)
asyncio.create_task(backfill_history(AsyncSessionLocal, limit=500)) asyncio.create_task(backfill_history(AsyncSessionLocal, limit=500))
@@ -56,6 +68,18 @@ async def lifespan(app: FastAPI):
# 3. Start Truth Social poller via APScheduler # 3. Start Truth Social poller via APScheduler
_scheduler = AsyncIOScheduler() _scheduler = AsyncIOScheduler()
# Signal monitor — polls every 5 minutes
from app.services.funding_signal import poll_funding_signal
_scheduler.add_job(
poll_funding_signal,
"interval",
minutes=5,
id="funding_signal_poll",
max_instances=1,
coalesce=True,
)
logger.info("Breakout signal monitor scheduled every 5 minutes.")
_scheduler.add_job( _scheduler.add_job(
poll_truth_social, poll_truth_social,
"interval", "interval",
@@ -84,12 +108,88 @@ async def lifespan(app: FastAPI):
coalesce=True, coalesce=True,
next_run_time=_dt.now(_tz.utc) + _td(seconds=offset), next_run_time=_dt.now(_tz.utc) + _td(seconds=offset),
) )
# HL <-> DB reconciliation — every 60s, detects state drift
# (manual closes on HL UI, liquidations, orphan positions). See
# app/services/reconciler.py. Critical for live trading safety.
from app.services.reconciler import reconcile_all_once, RECONCILE_INTERVAL_SECONDS
_scheduler.add_job(
reconcile_all_once,
"interval",
seconds=RECONCILE_INTERVAL_SECONDS,
id="hl_reconcile",
max_instances=1,
coalesce=True,
)
logger.info("HL <-> DB reconciler scheduled every %ds.", RECONCILE_INTERVAL_SECONDS)
# ── System-2 bottom-reversal state machine ─────────────────────────────
# Low-frequency, long-only: fires when ≥2 of 3 classic bottom signals
# agree — AHR999 < 0.45, price ≤ 200-week MA ×1.05, Pi Cycle Bottom
# (150d EMA ≤ 471d SMA × 0.745). Funding is a booster inside the scanner,
# not an independent entry source. See app/services/scanners/btc_bottom_reversal.py.
from app.services.scanners.btc_bottom_reversal import scan_once as btc_bottom_scan
_scheduler.add_job(
btc_bottom_scan, "cron", hour=0, minute=45,
id="btc_bottom_reversal_scan", max_instances=1, coalesce=True,
)
logger.info("BTC bottom-reversal scanner scheduled daily at 00:45 UTC.")
# ── BTC funding-rate reversal (hourly) ────────────────────────────────
# Mean-reversion play on extreme perp positioning. Independent of the
# bottom-reversal state machine but uses the same `evaluate_funding_reversal`
# algorithm. Runs every hour because HL funding settles hourly.
from app.services.scanners.funding_reversal import scan_once as funding_scan
_scheduler.add_job(
funding_scan, "cron", minute=7, # :07 every hour, away from other jobs
id="funding_reversal_scan", max_instances=1, coalesce=True,
)
logger.info("Funding reversal scanner scheduled hourly at :07.")
# ── KOL Substack poller (daily) ──────────────────────────────────────
# Hayes & co publish at most a few times a week. Daily poll is plenty;
# RSS dedupe by URL is idempotent if it ever fires twice.
from app.services.kol_substack import run_substack_poll
_scheduler.add_job(
run_substack_poll, "cron", hour=1, minute=15,
id="kol_substack_poll", max_instances=1, coalesce=True,
)
logger.info("KOL Substack poller scheduled daily at 01:15 UTC.")
# ── KOL A-tier: on-chain holdings snapshot (daily) ────────────────────
# Polls HL public API (free) for perp positions; Arkham (key optional)
# for full portfolio. Diffs against yesterday's snapshot → writes
# kol_holding_changes. Runs at 02:00 UTC, after Substack poll finishes.
from app.services.kol_onchain import run_onchain_poll
_scheduler.add_job(
run_onchain_poll, "cron", hour=2, minute=0,
id="kol_onchain_poll", max_instances=1, coalesce=True,
)
logger.info("KOL on-chain holdings poller scheduled daily at 02:00 UTC.")
# ── KOL talks-vs-trades divergence scan (daily) ───────────────────────
# Runs after the on-chain poll finishes. Matches post ticker signals vs
# holding changes for the same KOL+ticker within ±7 days.
from app.services.kol_divergence import run_divergence_scan
_scheduler.add_job(
run_divergence_scan, "cron", hour=2, minute=15,
id="kol_divergence_scan", max_instances=1, coalesce=True,
)
logger.info("KOL divergence scan scheduled daily at 02:15 UTC.")
_scheduler.start() _scheduler.start()
logger.info( logger.info(
"Truth Social pollers scheduled every %ds (CNN + trumpstruth.org).", "Truth Social pollers scheduled every %ds (CNN + trumpstruth.org).",
settings.truth_social_poll_seconds, settings.truth_social_poll_seconds,
) )
# ── Telegram bot long-poll loop (optional) ────────────────────────────
# Only started if TELEGRAM_BOT_TOKEN is set. Handles /start CODE bindings
# and /stop, /status, /test commands. Survives transient network errors
# via internal back-off.
from app.services.telegram_bot import run_bot_loop
_telegram_task = asyncio.create_task(run_bot_loop(), name="telegram_bot")
logger.info("Telegram bot task created (will no-op if token missing).")
yield yield
# Shutdown # Shutdown
@@ -102,6 +202,12 @@ async def lifespan(app: FastAPI):
await _binance_task await _binance_task
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
if _telegram_task and not _telegram_task.done():
_telegram_task.cancel()
try:
await _telegram_task
except asyncio.CancelledError:
pass
await engine.dispose() await engine.dispose()
logger.info("Shutdown complete.") logger.info("Shutdown complete.")
@@ -114,11 +220,18 @@ app = FastAPI(
) )
# CORS # CORS
allowed_origins = [ # In production we only allow the canonical frontend origin (FRONTEND_URL).
settings.frontend_url, # In development we additionally permit the local Next dev server. NEVER
"http://localhost:3001", # permit "*" here — every endpoint either reads/writes user-personalised
"http://localhost:3000", # data or accepts signed envelopes, both of which require credentialled
] # requests (and "*" is rejected by browsers in combination with credentials
# anyway).
allowed_origins = [settings.frontend_url]
if settings.environment == "development":
allowed_origins.extend([
"http://localhost:3000",
"http://localhost:3001",
])
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=allowed_origins, allow_origins=allowed_origins,
@@ -134,6 +247,13 @@ app.include_router(trades_router, prefix="/api")
app.include_router(performance_router, prefix="/api") app.include_router(performance_router, prefix="/api")
app.include_router(subscribe_router, prefix="/api") app.include_router(subscribe_router, prefix="/api")
app.include_router(user_router, prefix="/api") app.include_router(user_router, prefix="/api")
app.include_router(funding_signal_router, prefix="/api")
app.include_router(funding_reversal_router, prefix="/api")
app.include_router(telegram_router, prefix="/api")
app.include_router(signals_router, prefix="/api")
app.include_router(positions_router, prefix="/api")
app.include_router(scanners_router, prefix="/api")
app.include_router(kol_router, prefix="/api")
@app.get("/api/health") @app.get("/api/health")
+285
View File
@@ -60,6 +60,7 @@ class Post(Base):
target_asset: Mapped[Optional[str]] = mapped_column(String(16), nullable=True) target_asset: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
category: Mapped[Optional[str]] = mapped_column(String(24), nullable=True) category: Mapped[Optional[str]] = mapped_column(String(24), nullable=True)
expected_move_pct: Mapped[Optional[float]] = mapped_column(Float, 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) created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
trades: Mapped[List["BotTrade"]] = relationship("BotTrade", back_populates="trigger_post") 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) size_usd: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
leverage: Mapped[Optional[int]] = mapped_column(Integer, 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") trigger_post: Mapped[Optional["Post"]] = relationship("Post", back_populates="trades")
@@ -127,3 +176,239 @@ class Subscription(Base):
# Both stored as naive-UTC datetimes. # Both stored as naive-UTC datetimes.
active_from: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) active_from: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
active_until: 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)
+27 -3
View File
@@ -37,6 +37,7 @@ class TrumpPost(BaseModel):
target_asset: Optional[str] = None target_asset: Optional[str] = None
category: Optional[str] = None category: Optional[str] = None
expected_move_pct: Optional[float] = None expected_move_pct: Optional[float] = None
invalidation_price: Optional[float] = None
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@@ -61,6 +62,13 @@ class BotTrade(BaseModel):
trigger_post_id: int trigger_post_id: int
opened_at: str opened_at: str
closed_at: str closed_at: str
# Source tag of the originating signal (e.g. 'truth', 'breakout', 'my_strategy').
# Joined from posts.source on read; not stored on BotTrade itself.
# 'unknown' when the trigger post has been deleted or trigger_post_id is null.
trigger_source: Optional[str] = None
# True iff this was a paper trade (hl_order_id == 'paper'). Lets the UI
# tag the row so paper-mode P&L isn't mixed with real money in summaries.
is_paper: bool = False
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@@ -87,12 +95,17 @@ class SignedEnvelope(BaseModel):
class SubscribeRequest(SignedEnvelope): class SubscribeRequest(SignedEnvelope):
pass # Optional paper-mode flag. When true the bot writes simulated trades to
# the DB but never hits Hyperliquid — safe path for new users to try the
# system without risking funds. Defaults to false (live) for backwards
# compatibility with the existing signed-message format (body=None).
paper_mode: Optional[bool] = False
class SubscribeResponse(BaseModel): class SubscribeResponse(BaseModel):
status: str status: str
wallet: str wallet: str
paper_mode: bool = False
class SetApiKeyRequest(SignedEnvelope): class SetApiKeyRequest(SignedEnvelope):
@@ -102,6 +115,7 @@ class SetApiKeyRequest(SignedEnvelope):
class SetApiKeyResponse(BaseModel): class SetApiKeyResponse(BaseModel):
status: str status: str
masked_key: str # last 6 chars only, e.g. "...a1b2c3" masked_key: str # last 6 chars only, e.g. "...a1b2c3"
verified: bool = False
class UserSettings(BaseModel): class UserSettings(BaseModel):
@@ -111,6 +125,13 @@ class UserSettings(BaseModel):
stop_loss_pct: Optional[float] = None stop_loss_pct: Optional[float] = None
min_confidence: int min_confidence: int
daily_budget_usd: Optional[float] = None daily_budget_usd: Optional[float] = None
# System-2 (bottom reversal) leverage — independent of `leverage` (Trump).
# None → platform default (SYS2_DEFAULT_LEVERAGE). The protective stop is
# auto-scaled to this so the position is never exchange-liquidated.
sys2_leverage: Optional[int] = None
# System-2 risk mode: "standard" (default) or "aggressive" (separately
# funded high-risk/high-explosiveness sleeve). None → unchanged/standard.
sys2_mode: Optional[str] = None
# ISO-8601 UTC strings; both None = always on (Subscription.active still gates it). # ISO-8601 UTC strings; both None = always on (Subscription.active still gates it).
active_from: Optional[str] = None active_from: Optional[str] = None
active_until: Optional[str] = None active_until: Optional[str] = None
@@ -128,3 +149,6 @@ class UserResponse(BaseModel):
hl_api_key_masked: Optional[str] = None hl_api_key_masked: Optional[str] = None
trades: list[BotTrade] trades: list[BotTrade]
settings: UserSettings settings: UserSettings
# Convex-strategy: ISO-UTC timestamp until which the bot is manually armed.
# When null or in the past, the regular active_from/active_until schedule applies.
manual_window_until: Optional[str] = None
+12 -1
View File
@@ -53,7 +53,10 @@ async def _fetch_feed() -> Optional[str]:
resp.raise_for_status() resp.raise_for_status()
return resp.text return resp.text
except Exception as exc: except Exception as exc:
logger.warning("Failed to fetch trumpstruth.org feed: %s", exc) # Include type name — httpx often raises bare ConnectError/RemoteProtocolError
# with empty .args, which formats as just "Failed to fetch ..." with no body.
logger.warning("Failed to fetch trumpstruth.org feed: %s (%s)",
type(exc).__name__, exc)
return None return None
@@ -141,6 +144,14 @@ async def poll_trumpstruth(db_session_factory) -> None:
await manager.broadcast(_post_to_ws_payload(post)) await manager.broadcast(_post_to_ws_payload(post))
logger.info("[trumpstruth] beat CNN — new post id=%d: %s", logger.info("[trumpstruth] beat CNN — new post id=%d: %s",
post.id, post.text[:60]) post.id, post.text[:60])
# Telegram fan-out — matches truth_social.py. Without this,
# whichever poller wins the race determines whether users
# get pushed — flaky 50% delivery.
try:
from app.services.telegram import notify_signal
notify_signal(post)
except Exception as exc:
logger.warning("Telegram notify failed for post %d: %s", post.id, exc)
try: try:
from app.services.bot_engine import process_post from app.services.bot_engine import process_post
await process_post(post, db) await process_post(post, db)
+40 -1
View File
@@ -63,7 +63,11 @@ async def _fetch_archive() -> Optional[list]:
resp.raise_for_status() resp.raise_for_status()
return resp.json() return resp.json()
except Exception as exc: except Exception as exc:
logger.error("Failed to fetch CNN archive: %s", exc) # Include type name — httpx often raises bare ConnectError/TimeoutException
# with empty .args, which used to log as just "Failed to fetch CNN archive:"
# with no body, making outages impossible to diagnose.
logger.error("Failed to fetch CNN archive: %s (%s)",
type(exc).__name__, exc)
return None return None
@@ -80,6 +84,31 @@ async def _process_entry(entry: dict, db: AsyncSession) -> Optional[Post]:
published_at = _parse_dt(entry.get("created_at", "")) published_at = _parse_dt(entry.get("created_at", ""))
# ── Deterministic entry pre-filter (saves AI spend + blocks 80% of noise) ──
# The 13-trade backtest showed AI confidence ≥ 85 still lets through pure
# rhetoric and second-derivative news. Hard-coded action-marker + future-
# tense + dedup check rejects those BEFORE the AI call. Failing posts are
# still saved to DB (so we have a record) but stamped as non-actionable.
from app.database import AsyncSessionLocal
from app.services.entry_filter import passes_entry_filter
filter_ok, filter_reason = await passes_entry_filter(text, AsyncSessionLocal)
if not filter_ok:
logger.info("Entry filter rejected post (id=%s): %s%s",
entry.get("id"), filter_reason, text[:80])
# Insert a stub row so we don't keep re-fetching the same post from
# the upstream archive. Signal=hold, no AI call, no analysis.
stub = Post(
external_id=external_id, text=text, source="truth",
published_at=published_at,
sentiment="neutral", ai_confidence=0,
relevant=False, signal="hold",
prefilter_reason=filter_reason[:32],
analysis_version="entry_filter_rejected",
)
db.add(stub)
await db.flush()
return None # don't broadcast, don't trade
analysis = await analyze_post(text) analysis = await analyze_post(text)
asset = analysis["asset"] asset = analysis["asset"]
@@ -154,6 +183,9 @@ def _post_to_ws_payload(post: Post) -> dict:
"ai_confidence": post.ai_confidence, "ai_confidence": post.ai_confidence,
"ai_reasoning": post.ai_reasoning, "ai_reasoning": post.ai_reasoning,
"relevant": post.relevant, "relevant": post.relevant,
"target_asset": post.target_asset,
"category": post.category,
"expected_move_pct": post.expected_move_pct,
"price_impact": price_impact, "price_impact": price_impact,
}, },
} }
@@ -187,6 +219,13 @@ async def poll_truth_social(db_session_factory) -> None:
for post in new_posts: for post in new_posts:
await manager.broadcast(_post_to_ws_payload(post)) await manager.broadcast(_post_to_ws_payload(post))
logger.info("Saved new post id=%d: %s", post.id, post.text[:60]) logger.info("Saved new post id=%d: %s", post.id, post.text[:60])
# Telegram fan-out (fire-and-forget). Only fires if
# signal is buy/short; noise posts are filtered inside.
try:
from app.services.telegram import notify_signal
notify_signal(post)
except Exception as exc:
logger.warning("Telegram notify failed for post %d: %s", post.id, exc)
try: try:
from app.services.bot_engine import process_post from app.services.bot_engine import process_post
await process_post(post, db) await process_post(post, db)
+62 -12
View File
@@ -32,6 +32,7 @@ from app.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_client: Optional[AsyncOpenAI] = None _client: Optional[AsyncOpenAI] = None
_anthropic_client = None
ANALYSIS_VERSION = "v5-extreme-alpha" ANALYSIS_VERSION = "v5-extreme-alpha"
@@ -373,6 +374,19 @@ def _fallback(prefilter: Optional[str] = None, reasoning: str = "") -> dict:
return out return out
def _use_anthropic() -> bool:
"""True if a native Anthropic API key is configured (takes priority over proxy)."""
return bool(settings.anthropic_api_key)
def _get_anthropic_client():
global _anthropic_client
if _anthropic_client is None:
import anthropic as _anthropic
_anthropic_client = _anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
return _anthropic_client
def _get_client() -> AsyncOpenAI: def _get_client() -> AsyncOpenAI:
global _client global _client
if _client is None: if _client is None:
@@ -393,13 +407,21 @@ def _liquidity_note(hour: int) -> str:
return "Off-peak hours, moderate liquidity" return "Off-peak hours, moderate liquidity"
async def analyze_post(text: str) -> dict: async def analyze_post(text: str, model: Optional[str] = None) -> dict:
"""Score a Trump post and return signal + structured reasoning. """Score a Trump post and return signal + structured reasoning.
Args:
text: Raw post text (up to 2000 chars used).
model: Override the model to use. Defaults to settings.ai_live_model
for latency-sensitive live calls; pass settings.ai_model
explicitly for higher-quality batch reanalysis.
Returns the canonical dict shape expected by the rest of the codebase: Returns the canonical dict shape expected by the rest of the codebase:
relevant, asset, sentiment, signal (buy/short/hold), confidence, relevant, asset, sentiment, signal (buy/short/hold), confidence,
reasoning, prefilter_reason, analysis_version. reasoning, prefilter_reason, analysis_version.
""" """
if model is None:
model = settings.ai_live_model # fast flash for live posts
# ── Fast pre-filter (no AI call) ──────────────────────────────── # ── Fast pre-filter (no AI call) ────────────────────────────────
stripped = text.strip() stripped = text.strip()
if not stripped: if not stripped:
@@ -423,17 +445,45 @@ async def analyze_post(text: str) -> dict:
) )
try: try:
client = _get_client() if _use_anthropic():
response = await client.chat.completions.create( # ── Native Anthropic SDK path ────────────────────────────────
model=settings.ai_model, anthropic_client = _get_anthropic_client()
max_tokens=600, # Map OpenAI-style model name → Anthropic model name
temperature=0.1, model_name = model
messages=[ if "haiku" in model_name.lower() and "claude-" not in model_name.lower():
{"role": "system", "content": SYSTEM_PROMPT}, model_name = "claude-haiku-4-5-20251001"
{"role": "user", "content": user_prompt}, msg = await anthropic_client.messages.create(
], model=model_name,
) max_tokens=600,
raw = (response.choices[0].message.content or "").strip() temperature=0.1,
system=SYSTEM_PROMPT,
messages=[{"role": "user", "content": user_prompt}],
)
raw = (msg.content[0].text if msg.content else "").strip()
else:
# ── OpenAI-compatible proxy path (DeepSeek, gptsapi, etc.) ──
# Reasoning models (deepseek-v4-pro / R1) don't support
# temperature and need higher max_tokens for the thinking pass.
model_name = model
is_reasoning = any(x in model_name for x in ("pro", "reasoner", "r1", "think"))
kwargs: dict = {
"model": model_name,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
}
if is_reasoning:
# Reasoning models: large token budget; omit temperature
kwargs["max_tokens"] = 4000
else:
kwargs["max_tokens"] = 1200
kwargs["temperature"] = 0.1
client = _get_client()
response = await client.chat.completions.create(**kwargs)
raw = (response.choices[0].message.content or "").strip()
# Strip ```json fences if the model adds them despite instructions # Strip ```json fences if the model adds them despite instructions
if raw.startswith("```"): if raw.startswith("```"):
+376
View File
@@ -0,0 +1,376 @@
"""
Single-post backtest harness.
Given a Post with a directional signal (buy/short) and a target asset, fetch
the historical 1-minute candles for [published_at, published_at + max_hold_h]
from Binance and replay the new convex-strategy exit rules. Outputs what
WOULD have happened if we had been live at that moment with the current
trailing-stop / stop-loss / max-hold settings.
Why this exists:
The bot's m1h accuracy is 45.7%, but that was measured with a 1-hour
fixed-window snapshot — i.e. assuming we close at exactly the 1-hour mark.
The new exit logic (trailing stop, 7-day max hold) is FUNDAMENTALLY
different. Without backtesting we can't know whether changing exits also
changes the win rate / PnL profile. This harness lets us check.
Scope (deliberately narrow for the MVP):
- One post at a time. Batch runner sits on top.
- Uses 1m HIGH/LOW within each bar (worst-case path) — conservative.
- Ignores fees + slippage (caller is expected to subtract them).
- Assumes immediate fill at the candle's OPEN on entry.
- Does NOT re-evaluate regime gates (the caller decides whether to
include the post). This makes "what would have happened" cleaner.
The 1m granularity matters: with 5m or 1H bars you can't distinguish
"stop loss hit then bounced back" from "rallied straight up", and a
trailing-stop strategy lives or dies on that distinction.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, asdict
from datetime import datetime, timedelta, timezone
from typing import Optional
import httpx
logger = logging.getLogger(__name__)
BINANCE_KLINES_URL = "https://api.binance.com/api/v3/klines"
@dataclass
class BacktestResult:
post_id: int
asset: str
side: str # "long" | "short"
entry_price: float
exit_price: float
exit_reason: str # "trailing_stop" | "stop_loss" | "take_profit" | "max_hold"
hold_minutes: int
pnl_pct: float # net of nothing — apply your own fee model
peak_pct: float # best unrealised gain reached
trough_pct: float # worst unrealised gain reached
bars_evaluated: int
def to_dict(self) -> dict:
return asdict(self)
@dataclass
class BacktestParams:
"""Exit-rule snapshot used for the simulation."""
stop_loss_pct: float = 1.5
trailing_stop_pct: Optional[float] = 2.5
trailing_activate_at_pct: Optional[float] = 5.0
take_profit_pct: Optional[float] = None
max_hold_hours: int = 168
async def fetch_binance_1m(
symbol: str,
start: datetime,
end: datetime,
client: Optional[httpx.AsyncClient] = None,
) -> list[dict]:
"""Pull 1-minute candles from Binance spot for [start, end].
Binance caps a single call at 1000 candles → we page in chunks. Returns
candles in chronological order. Each candle: {time_ms, open, high, low, close}.
"""
own_client = client is None
if own_client:
client = httpx.AsyncClient(timeout=20)
out: list[dict] = []
cursor_ms = int(start.replace(tzinfo=timezone.utc).timestamp() * 1000)
end_ms = int(end.replace(tzinfo=timezone.utc).timestamp() * 1000)
try:
while cursor_ms < end_ms:
params = {
"symbol": symbol,
"interval": "1m",
"startTime": cursor_ms,
"endTime": end_ms,
"limit": 1000,
}
resp = await client.get(BINANCE_KLINES_URL, params=params)
resp.raise_for_status()
chunk = resp.json()
if not chunk:
break
for row in chunk:
out.append({
"time_ms": row[0],
"open": float(row[1]),
"high": float(row[2]),
"low": float(row[3]),
"close": float(row[4]),
})
# Next page starts after the last candle returned.
last_open = chunk[-1][0]
if last_open <= cursor_ms:
break
cursor_ms = last_open + 60_000
if len(chunk) < 1000:
break # final partial page
finally:
if own_client:
await client.aclose()
return out
def _asset_to_symbol(asset: str) -> str:
"""Map our internal asset code to a Binance spot symbol.
Most assets are simply {ASSET}USDT. TRUMP/HYPE/etc. may not exist on
Binance — those will fail at fetch time and we'll return None upstream.
"""
return f"{asset.upper()}USDT"
def simulate_exit(
candles: list[dict],
side: str,
params: BacktestParams,
) -> dict:
"""Walk the candles 1m at a time, applying the exit ladder.
Priority within a bar (worst-case ordering):
1. Stop loss — assume hit via LOW (long) / HIGH (short)
2. Trailing — only if armed; assume hit via the same worst-case extreme
3. Take profit — fixed TP if set
A real bar can't tell us whether the high or low printed first, so we
take the pessimistic path: STOP LOSS BEFORE PROFIT if both could have
triggered. This makes the backtest conservative.
"""
if not candles:
return {"reason": "no_data", "exit_price": 0.0, "bars": 0,
"peak_pct": 0.0, "trough_pct": 0.0, "pnl_pct": 0.0}
entry = candles[0]["open"]
if entry == 0:
return {"reason": "bad_entry", "exit_price": 0.0, "bars": 0,
"peak_pct": 0.0, "trough_pct": 0.0, "pnl_pct": 0.0}
is_long = side == "long"
peak_pct = 0.0
trough_pct = 0.0
armed = False
def pct(price: float) -> float:
raw = (price - entry) / entry
return (raw if is_long else -raw) * 100
for i, bar in enumerate(candles):
# Within-bar extremes in position's favoured direction
good_extreme = bar["high"] if is_long else bar["low"]
bad_extreme = bar["low"] if is_long else bar["high"]
good_pct = pct(good_extreme)
bad_pct = pct(bad_extreme)
# Update running peak / trough using extremes
if good_pct > peak_pct: peak_pct = good_pct
if bad_pct < trough_pct: trough_pct = bad_pct
# 1. Stop loss — pessimistic check first
if bad_pct <= -params.stop_loss_pct:
# Approximate exit at the SL trigger price (not the bar extreme)
sl_price = entry * (1 - params.stop_loss_pct / 100) if is_long \
else entry * (1 + params.stop_loss_pct / 100)
return {
"reason": "stop_loss",
"exit_price": sl_price,
"bars": i + 1,
"peak_pct": peak_pct,
"trough_pct": trough_pct,
"pnl_pct": -params.stop_loss_pct,
}
# 2. Trailing — arm if peak crossed activation
if (params.trailing_stop_pct is not None
and params.trailing_activate_at_pct is not None):
if not armed and peak_pct >= params.trailing_activate_at_pct:
armed = True
if armed:
# Drawdown from peak (using bad_extreme of this bar)
drawdown = peak_pct - bad_pct
if drawdown >= params.trailing_stop_pct:
# Exit at peak trailing_stop_pct (approx)
exit_pct = peak_pct - params.trailing_stop_pct
ts_price = entry * (1 + exit_pct / 100) if is_long \
else entry * (1 - exit_pct / 100)
return {
"reason": "trailing_stop",
"exit_price": ts_price,
"bars": i + 1,
"peak_pct": peak_pct,
"trough_pct": trough_pct,
"pnl_pct": exit_pct,
}
# 3. Fixed TP
if params.take_profit_pct is not None and good_pct >= params.take_profit_pct:
tp_price = entry * (1 + params.take_profit_pct / 100) if is_long \
else entry * (1 - params.take_profit_pct / 100)
return {
"reason": "take_profit",
"exit_price": tp_price,
"bars": i + 1,
"peak_pct": peak_pct,
"trough_pct": trough_pct,
"pnl_pct": params.take_profit_pct,
}
# Walked the whole window without hitting any rule → close at last bar
last_close = candles[-1]["close"]
return {
"reason": "max_hold",
"exit_price": last_close,
"bars": len(candles),
"peak_pct": peak_pct,
"trough_pct": trough_pct,
"pnl_pct": pct(last_close),
}
async def backtest_post(
post_id: int,
params: Optional[BacktestParams] = None,
) -> Optional[BacktestResult]:
"""Backtest a single Post by ID.
Returns None if:
- Post not found
- signal is not buy/short
- target_asset is unknown / not on Binance
- Binance has no data for the window
"""
from app.database import AsyncSessionLocal
from app.models import Post
from sqlalchemy import select
params = params or BacktestParams()
async with AsyncSessionLocal() as db:
result = await db.execute(select(Post).where(Post.id == post_id))
post = result.scalar_one_or_none()
if post is None:
logger.warning("Backtest: post %d not found", post_id)
return None
if post.signal not in ("buy", "short"):
logger.warning("Backtest: post %d signal=%s not actionable", post_id, post.signal)
return None
asset = post.target_asset or post.price_impact_asset
if not asset:
logger.warning("Backtest: post %d has no asset", post_id)
return None
side = "long" if post.signal == "buy" else "short"
symbol = _asset_to_symbol(asset)
# Window: [published_at, published_at + max_hold_hours]
start = post.published_at
end = start + timedelta(hours=params.max_hold_hours)
# Don't backtest a window that hasn't fully elapsed yet
now_naive = datetime.now(timezone.utc).replace(tzinfo=None)
if end > now_naive:
end = now_naive
candles = await fetch_binance_1m(symbol, start, end)
if not candles:
logger.warning("Backtest: no Binance data for %s in [%s, %s]", symbol, start, end)
return None
sim = simulate_exit(candles, side, params)
if sim["reason"] in ("no_data", "bad_entry"):
return None
return BacktestResult(
post_id=post_id,
asset=asset,
side=side,
entry_price=candles[0]["open"],
exit_price=sim["exit_price"],
exit_reason=sim["reason"],
hold_minutes=sim["bars"],
pnl_pct=round(sim["pnl_pct"], 3),
peak_pct=round(sim["peak_pct"], 3),
trough_pct=round(sim["trough_pct"], 3),
bars_evaluated=sim["bars"],
)
async def backtest_batch(
limit: int = 50,
min_confidence: int = 80,
params: Optional[BacktestParams] = None,
) -> dict:
"""Backtest every directional post with confidence ≥ min_confidence.
Returns aggregate stats + per-trade results. Posts whose Binance data
is missing (TRUMP, niche perps) are skipped silently.
"""
from app.database import AsyncSessionLocal
from app.models import Post
from sqlalchemy import select, and_, or_
params = params or BacktestParams()
async with AsyncSessionLocal() as db:
rows = await db.execute(
select(Post.id).where(
and_(
Post.signal.in_(["buy", "short"]),
Post.ai_confidence >= min_confidence,
)
).order_by(Post.published_at.desc()).limit(limit)
)
post_ids = [r[0] for r in rows.all()]
results: list[BacktestResult] = []
skipped: list[int] = []
for pid in post_ids:
try:
r = await backtest_post(pid, params)
if r is None:
skipped.append(pid)
else:
results.append(r)
except Exception as exc:
logger.error("Backtest post %d failed: %s", pid, exc)
skipped.append(pid)
if not results:
return {"params": asdict(params), "results": [], "skipped": skipped,
"summary": {"trades": 0}}
pnls = [r.pnl_pct for r in results]
wins = [p for p in pnls if p > 0]
losses = [p for p in pnls if p <= 0]
by_reason: dict[str, int] = {}
for r in results:
by_reason[r.exit_reason] = by_reason.get(r.exit_reason, 0) + 1
summary = {
"trades": len(results),
"skipped": len(skipped),
"win_rate_pct": round(len(wins) / len(results) * 100, 1),
"avg_pnl_pct": round(sum(pnls) / len(pnls), 3),
"total_pnl_pct": round(sum(pnls), 3),
"best_pct": round(max(pnls), 3),
"worst_pct": round(min(pnls), 3),
"avg_win_pct": round(sum(wins) / len(wins), 3) if wins else 0.0,
"avg_loss_pct": round(sum(losses) / len(losses), 3) if losses else 0.0,
"exit_reasons": by_reason,
}
return {
"params": asdict(params),
"summary": summary,
"skipped": skipped,
"results": [r.to_dict() for r in results],
}
+28 -2
View File
@@ -83,7 +83,21 @@ async def fetch_historical(asset: str, symbol: str, interval: str = "1m", limit:
async def run_binance_ws(): async def run_binance_ws():
"""Connect to Binance kline stream with exponential back-off on disconnect.""" """Connect to Binance kline stream with exponential back-off on disconnect.
Binance routinely cycles connections every ~24h, and intermediate proxies
can drop without sending a close frame — both surface here as exceptions.
The reconnect must be fast (sub-second) so price ticks aren't visibly
stuck on the dashboard during a hiccup.
Resilience defenses:
* ping_interval/ping_timeout — peer-of-record liveness check (websockets lib)
* close_timeout — bound how long we wait for a clean close handshake
* open_timeout — bound the initial connect handshake so a hung TCP doesn't
permanently park this task
* max_queue — drop frames if downstream can't keep up rather than back
the WS receive buffer up indefinitely
"""
# Pre-fill with historical data # Pre-fill with historical data
await fetch_historical("BTC", "btcusdt", limit=500) await fetch_historical("BTC", "btcusdt", limit=500)
await fetch_historical("ETH", "ethusdt", limit=500) await fetch_historical("ETH", "ethusdt", limit=500)
@@ -96,15 +110,27 @@ async def run_binance_ws():
settings.binance_ws_url, settings.binance_ws_url,
ping_interval=20, ping_interval=20,
ping_timeout=10, ping_timeout=10,
close_timeout=5,
open_timeout=10,
max_queue=128,
) as ws: ) as ws:
backoff = 1 # reset on successful connection backoff = 1 # reset on successful connection
logger.info("Binance WebSocket connected.") logger.info("Binance WebSocket connected.")
async for message in ws: async for message in ws:
await _process_message(message) await _process_message(message)
# If we exit the `async with` without an exception, the server
# closed normally — reconnect immediately rather than backing off.
logger.info("Binance WebSocket closed cleanly, reconnecting immediately.")
except asyncio.CancelledError: except asyncio.CancelledError:
logger.info("Binance WebSocket task cancelled.") logger.info("Binance WebSocket task cancelled.")
return return
except Exception as exc: except Exception as exc:
logger.error("Binance WebSocket error: %s. Reconnecting in %ds.", exc, backoff) # Use exception() once so a TRACE-back-with-context is logged the
# first time something interesting happens; subsequent same-error
# retries log compactly to avoid drowning the log.
logger.warning(
"Binance WebSocket error: %s (%s). Reconnecting in %ds.",
type(exc).__name__, exc, backoff,
)
await asyncio.sleep(backoff) await asyncio.sleep(backoff)
backoff = min(backoff * 2, 60) backoff = min(backoff * 2, 60)
+707 -63
View File
@@ -6,7 +6,7 @@ Iterates all active subscribers and executes trades on their behalf.
import asyncio import asyncio
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone, timedelta
from typing import Dict from typing import Dict
from sqlalchemy import select, update from sqlalchemy import select, update
@@ -23,7 +23,9 @@ logger = logging.getLogger(__name__)
# Platform-wide thresholds (per-user values in Subscription override where applicable) # Platform-wide thresholds (per-user values in Subscription override where applicable)
# No global confidence floor — users pick their own 0100 threshold via settings. # No global confidence floor — users pick their own 0100 threshold via settings.
MAX_HOLD_SECONDS = 3600 # force-close after 1 hour (shared across users) # Per-user max hold lives on Subscription.max_hold_hours (default 168h = 7 days
# in the convex-strategy redesign). Old 1-hour cap killed every potential
# runner before it could compound, so it's been removed.
# Hyperliquid perp fees (mainnet, base tier). # Hyperliquid perp fees (mainnet, base tier).
# IOC orders always cross the book → taker fee both on open and close. # IOC orders always cross the book → taker fee both on open and close.
@@ -63,6 +65,17 @@ def _lock_for(trade_id: int) -> asyncio.Lock:
return lock return lock
def _confidence_floor_for(sub: dict) -> int:
if sub.get("_is_system_2"):
from app.services.signal_categories import system2_min_confidence
return system2_min_confidence()
return sub["min_confidence"]
def _should_apply_schedule(sub: dict) -> bool:
return not bool(sub.get("_is_system_2"))
async def process_post(post: Post, db: AsyncSession) -> None: async def process_post(post: Post, db: AsyncSession) -> None:
""" """
Entry point called by the scraper after a new post is saved. Entry point called by the scraper after a new post is saved.
@@ -91,6 +104,87 @@ async def process_post(post: Post, db: AsyncSession) -> None:
logger.info("Signal: post=%d asset=%s side=%s confidence=%d", post.id, asset, side, post.ai_confidence) logger.info("Signal: post=%d asset=%s side=%s confidence=%d", post.id, asset, side, post.ai_confidence)
# ── System routing ─────────────────────────────────────────────────────
# Two independent trading systems share this execution layer but NOT
# their strategy logic. See app/services/signal_categories.py.
from app.services.signal_categories import (
get_exit_profile, get_stop_ladder, is_supported_trading_source,
is_system_2, system2_display_name,
)
if not is_supported_trading_source(post.source):
logger.info("Post %d skipped: unsupported trading source=%s", post.id, post.source)
return
sys2 = is_system_2(post.source)
if sys2:
# SYSTEM 2 — reversal/breakout. The Trump-tuned regime filter
# (recent-move ≤5%, vol-contraction) actively REJECTS valid reversal
# setups, so it's bypassed entirely. The signal's structural gates
# (RSI<30 ×4wk, 200d reclaim, funding extreme) ARE the regime check.
exit_profile = get_exit_profile(post.category)
if exit_profile.stop_ladder:
logger.info(
"%s [%s/%s]: bypassing Trump regime filter. "
"Exit = STAGED stop (no TP/trailing) base sl=%.1f%% "
"ladder=%s maxhold=%dh",
system2_display_name(), post.source, post.category,
exit_profile.stop_loss_pct, exit_profile.stop_ladder,
exit_profile.max_hold_hours,
)
else:
logger.info(
"%s [%s/%s]: bypassing Trump regime filter. "
"Exit profile sl=%.1f%% trail=%s@%s maxhold=%dh inval=%s",
system2_display_name(), post.source, post.category,
exit_profile.stop_loss_pct, exit_profile.trailing_stop_pct,
exit_profile.trailing_activate_at_pct, exit_profile.max_hold_hours,
exit_profile.invalidation,
)
else:
# SYSTEM 1 — Trump. REPOSITIONED: low-frequency, selective, tight
# stop, ≥30-min holds. See signal_categories.py.
exit_profile = None
from app.services.signal_categories import (
TRUMP_MIN_CONFIDENCE, TRUMP_COOLDOWN_HOURS,
)
# (a) Confidence floor — only the very top posts clear the bar.
if (post.ai_confidence or 0) < TRUMP_MIN_CONFIDENCE:
logger.info(
"Post %d skipped: Trump confidence %d < floor %d (low-freq mode)",
post.id, post.ai_confidence or 0, TRUMP_MIN_CONFIDENCE,
)
return
# (b) Cooldown — "don't trade every opportunity". If we opened ANY
# Trump trade in the last TRUMP_COOLDOWN_HOURS, skip this post.
# DB-backed so it survives restarts (same pattern as scanners).
from app.services.scanner_state import last_signal_at
from sqlalchemy import select as _sel, func as _fn
from app.models import Post as _Post, BotTrade as _BT
cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(hours=TRUMP_COOLDOWN_HOURS)
recent_trump = await db.execute(
_sel(_fn.count(_BT.id))
.select_from(_BT)
.join(_Post, _Post.id == _BT.trigger_post_id)
.where(_Post.source == "truth", _BT.opened_at >= cutoff)
)
if (recent_trump.scalar() or 0) > 0:
logger.info(
"Post %d skipped: Trump cooldown active (a Trump trade opened "
"within the last %dh)", post.id, TRUMP_COOLDOWN_HOURS,
)
return
# (c) Regime filter — still applies; tuned for short-term behaviour.
from app.services.regime_filter import passes_regime_filter
regime_ok, regime_reasons = passes_regime_filter(asset, side)
for r in regime_reasons:
logger.info("Regime [%s]: %s", asset, r)
if not regime_ok:
logger.info("Post %d skipped: regime filter rejected (see ✗ lines above)", post.id)
return
result = await db.execute(select(Subscription).where(Subscription.active == True)) # noqa: E712 result = await db.execute(select(Subscription).where(Subscription.active == True)) # noqa: E712
subscribers = result.scalars().all() subscribers = result.scalars().all()
@@ -111,9 +205,80 @@ async def process_post(post: Post, db: AsyncSession) -> None:
daily_budget_usd=s.daily_budget_usd, daily_budget_usd=s.daily_budget_usd,
active_from=s.active_from, active_from=s.active_from,
active_until=s.active_until, active_until=s.active_until,
# Convex-strategy fields (default to legacy values if column null).
trailing_stop_pct=s.trailing_stop_pct,
trailing_activate_at_pct=s.trailing_activate_at_pct,
max_hold_hours=s.max_hold_hours or 168,
manual_window_until=s.manual_window_until,
# Phase 1 safety
paper_mode=bool(s.paper_mode),
circuit_breaker_tripped_at=s.circuit_breaker_tripped_at,
circuit_breaker_reason=s.circuit_breaker_reason,
# Two-system separation
sys2_budget_pct=(s.sys2_budget_pct if s.sys2_budget_pct is not None else 0.7),
sys2_cb_tripped_at=s.sys2_cb_tripped_at,
sys2_cb_reason=s.sys2_cb_reason,
sys2_leverage=s.sys2_leverage,
sys2_mode=s.sys2_mode,
auto_trade=bool(s.auto_trade),
) )
for s in subscribers for s in subscribers
] ]
# SYSTEM 2: override the user's Trump exit params with the category's
# exit profile. The user configures risk for their Trump scalp; the
# reversal system's risk is a property of the SIGNAL, not the user.
if sys2 and exit_profile is not None:
from app.services.signal_categories import (
sys2_effective_leverage, sys2_protective_stop_pct,
sys2_normalize_mode,
)
for snap in subs_snapshot:
mode = sys2_normalize_mode(snap.get("sys2_mode"))
snap["_sys2_mode"] = mode
# Dynamic System-2 leverage (independent of the Trump `leverage`);
# default depends on the risk mode (standard 2× / aggressive 8×).
lev = sys2_effective_leverage(snap.get("sys2_leverage"), mode)
# Protective stop auto-scaled INSIDE the liquidation line for THIS
# leverage → the position is de-risked by our monitor, never
# liquidated by the exchange.
prot = sys2_protective_stop_pct(lev)
snap["_is_system_2"] = True
snap["_category"] = post.category
snap["leverage"] = lev # HL opens at sys2 leverage
snap["take_profit_pct"] = None # pure trailing, no fixed TP
# Base catastrophic floor = leverage-aware protective stop. The
# upside ladder rungs (get_stop_ladder) still ratchet this UP as
# peak gain grows; they never loosen it.
snap["stop_loss_pct"] = prot
snap["trailing_activate_at_pct"] = exit_profile.trailing_activate_at_pct
snap["trailing_stop_pct"] = exit_profile.trailing_stop_pct
snap["max_hold_hours"] = exit_profile.max_hold_hours
# Carried through for the monitor (time-stop / invalidation).
snap["_sys2_time_stop_hours"] = exit_profile.time_stop_hours
snap["_sys2_invalidation"] = exit_profile.invalidation
snap["_sys2_invalidation_price"] = post.invalidation_price
logger.info(
"System-2 dynamic leverage: %dx → protective stop %.2f%% "
"(approx liquidation %.2f%%) for %s",
lev, prot, 100.0 / lev, snap["wallet"],
)
elif not sys2:
# SYSTEM 1 — Trump, repositioned. System-enforced now (not pure
# user params): tight SL always on, but TP/trailing suppressed for
# the first TRUMP_MIN_HOLD_MINUTES so we don't scalp out early.
# User still controls size / leverage / daily budget.
from app.services.signal_categories import (
TRUMP_STOP_LOSS_PCT, TRUMP_MAX_HOLD_HOURS, TRUMP_MIN_HOLD_MINUTES,
)
for snap in subs_snapshot:
snap["stop_loss_pct"] = TRUMP_STOP_LOSS_PCT
snap["max_hold_hours"] = TRUMP_MAX_HOLD_HOURS
snap["_min_hold_minutes"] = TRUMP_MIN_HOLD_MINUTES
# Keep user's take_profit_pct / trailing config — they still pick
# the profit target; we only gate WHEN it can fire.
post_id = post.id post_id = post.id
post_confidence = post.ai_confidence or 0 post_confidence = post.ai_confidence or 0
@@ -135,30 +300,47 @@ async def _execute_for_subscriber(
if not sub["hl_api_key"]: if not sub["hl_api_key"]:
logger.warning("Subscriber %s has no HL API key, skipping", wallet) logger.warning("Subscriber %s has no HL API key, skipping", wallet)
return return
# Required-setup guard: the bot is only allowed to trade when the user # Required-setup guard. System 1 (Trump) needs the user's take-profit,
# has explicitly set take-profit, stop-loss, and a daily budget. Prevents # stop-loss, and daily budget. System 2 (reversal) supplies its own
# accidental trading on partially-configured accounts. # stop-loss + (no) take-profit from the category profile — only the
missing = [k for k in ("take_profit_pct", "stop_loss_pct", "daily_budget_usd") # daily budget remains a user-required risk cap there.
if sub.get(k) is None] if sub.get("_is_system_2"):
required = ("daily_budget_usd",)
else:
required = ("take_profit_pct", "stop_loss_pct", "daily_budget_usd")
missing = [k for k in required if sub.get(k) is None]
if missing: if missing:
logger.info("Sub %s skipped post %d: setup incomplete (missing %s)", logger.info("Sub %s skipped post %d: setup incomplete (missing %s)",
wallet, post_id, ", ".join(missing)) wallet, post_id, ", ".join(missing))
return return
if post_confidence < sub["min_confidence"]: confidence_floor = _confidence_floor_for(sub)
if post_confidence < confidence_floor:
logger.info("Sub %s filters out post %d: conf %d < user min %d", logger.info("Sub %s filters out post %d: conf %d < user min %d",
wallet, post_id, post_confidence, sub["min_confidence"]) wallet, post_id, post_confidence, confidence_floor)
return return
# Active-window check. Both values stored as naive-UTC. # ── Master Auto-Trade gate (D) ─────────────────────────────────────────
af = sub.get("active_from") # The ONE user-facing switch. The signal has ALREADY been ingested as a
au = sub.get("active_until") # Post by the ingest endpoint (so it shows in the feed regardless); here
if af is not None and au is not None: # we only decide whether to OPEN a trade. OFF (default) = monitor-only.
now_utc_naive = datetime.now(timezone.utc).replace(tzinfo=None) # Replaces the old scanner-toggle / timed manual-window / schedule trio.
if now_utc_naive < af or now_utc_naive >= au: # NOTE: this gates NEW entries only — already-open positions keep their
logger.info("Sub %s skipped post %d: outside active window [%s, %s)", # protective de-risk / stop (tp_sl_monitor runs independently of this).
wallet, post_id, af, au) if not sub.get("auto_trade"):
return logger.info("Sub %s: Auto-Trade OFF — post %d recorded, no trade opened",
wallet, post_id)
return
# ── Circuit breaker gate (P1.1) ────────────────────────────────────────
# Checked BEFORE key decryption (cheap fast-fail). If tripped, the wallet
# has lost too much today or hit a losing streak — block until manual reset.
from app.services.circuit_breaker import is_tripped as _cb_tripped
_system = "sys2" if sub.get("_is_system_2") else "sys1"
tripped, cb_reason = _cb_tripped(sub, _system)
if tripped:
logger.info("Sub %s skipped post %d: %s", wallet, post_id, cb_reason)
return
try: try:
api_key = decrypt_api_key(sub["hl_api_key"]) api_key = decrypt_api_key(sub["hl_api_key"])
@@ -166,49 +348,170 @@ async def _execute_for_subscriber(
logger.error("Cannot decrypt key for %s: %s", wallet, exc) logger.error("Cannot decrypt key for %s: %s", wallet, exc)
return return
# ── Position sizing (C) ────────────────────────────────────────────────
# Score-based multiplier on the user's base size. Bigger bet when more
# confluences are in place. Computed BEFORE the lock so the value is
# available for both the budget check and the open call.
# System 2 has its OWN sizing — regime_filter's multiplier would shrink
# reversal bets (it penalises volatility expansion / prior movement,
# which are the SIGNAL for a reversal). See signal_categories.
if sub.get("_is_system_2"):
from app.services.signal_categories import system2_size_multiplier
size_mult = system2_size_multiplier(sub.get("_category"), post_confidence)
size_logic = f"sys2 cat={sub.get('_category')}"
else:
from app.services.regime_filter import calculate_size_multiplier
size_mult = calculate_size_multiplier(post_confidence, asset, side)
size_logic = "sys1 regime-scored"
sized_position_usd = round(sub["position_size_usd"] * size_mult, 2)
logger.info(
"Sub %s sizing [%s]: base=%.2f × %.2fx = %.2f (asset=%s, conf=%d)",
wallet, size_logic, sub["position_size_usd"], size_mult,
sized_position_usd, asset, post_confidence,
)
# Per-wallet lock wraps BOTH the budget re-check and the open, so two # Per-wallet lock wraps BOTH the budget re-check and the open, so two
# concurrent signals can't both pass the daily-budget gate before either # concurrent signals can't both pass the daily-budget gate before either
# trade is written to DB (TOCTOU race under asyncio.gather). # trade is written to DB (TOCTOU race under asyncio.gather).
async with _wallet_lock(wallet): async with _wallet_lock(wallet):
# Re-check daily budget INSIDE the lock so it's atomic with the open. # Re-check daily budget INSIDE the lock so it's atomic with the open.
daily_cap = sub.get("daily_budget_usd") # Budget is SPLIT between the two systems so a Trump scalp can't eat
if daily_cap is not None and daily_cap > 0: # the allocation reserved for a once-a-year reversal signal.
total_cap = sub.get("daily_budget_usd")
if total_cap is not None and total_cap > 0:
sys2_pct = sub.get("sys2_budget_pct", 0.7)
is_s2 = bool(sub.get("_is_system_2"))
daily_cap = total_cap * (sys2_pct if is_s2 else (1.0 - sys2_pct))
start_of_day = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None) start_of_day = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None)
from app.models import Post as _P
from app.services.signal_categories import SYSTEM_2_SOURCES
async with AsyncSessionLocal() as budget_db: async with AsyncSessionLocal() as budget_db:
spent_result = await budget_db.execute( spent_result = await budget_db.execute(
select(BotTrade).where( select(BotTrade, _P.source)
.outerjoin(_P, _P.id == BotTrade.trigger_post_id)
.where(
BotTrade.wallet_address == wallet, BotTrade.wallet_address == wallet,
BotTrade.opened_at >= start_of_day, BotTrade.opened_at >= start_of_day,
) )
) )
spent_trades = spent_result.scalars().all() # Only count spend from THIS system against THIS system's slice.
spent = sum( spent = 0.0
(t.size_usd if t.size_usd is not None else sub["position_size_usd"]) for t, src in spent_result.all():
for t in spent_trades t_is_s2 = (src or "").lower() in SYSTEM_2_SOURCES
if t_is_s2 != is_s2:
continue
spent += (t.size_usd if t.size_usd is not None else sub["position_size_usd"])
if spent + sized_position_usd > daily_cap:
logger.info("Sub %s [%s] daily budget reached: spent=%.2f + new=%.2f > cap=%.2f (%.0f%% of %.2f)",
wallet, _system, spent, sized_position_usd, daily_cap,
(sys2_pct if is_s2 else 1.0 - sys2_pct) * 100, total_cap)
return
# ── System-2 correlation / concentration cap ───────────────────────
# Inside the wallet lock so it's atomic with the open. The whole
# System-2 basket is correlated crypto-beta; cap CONCURRENT open
# positions + total open notional so a synchronised "crypto bottomed"
# week can't stack into one 10x leveraged macro bet.
if sub.get("_is_system_2"):
from app.services.signal_categories import (
SYS2_MAX_CONCURRENT, SYS2_MAX_OPEN_NOTIONAL_MULT, SYSTEM_2_SOURCES,
)
from app.models import Post as _P2
async with AsyncSessionLocal() as conc_db:
open_rows = await conc_db.execute(
select(BotTrade, _P2.source)
.outerjoin(_P2, _P2.id == BotTrade.trigger_post_id)
.where(
BotTrade.wallet_address == wallet,
BotTrade.closed_at.is_(None),
)
) )
if spent + sub["position_size_usd"] > daily_cap: open_sys2 = [
logger.info("Sub %s daily budget reached: spent=%.2f + new=%.2f > cap=%.2f", t for t, src in open_rows.all()
wallet, spent, sub["position_size_usd"], daily_cap) if (src or "").lower() in SYSTEM_2_SOURCES
]
open_count = len(open_sys2)
open_notional = sum(
(t.size_usd if t.size_usd is not None else sub["position_size_usd"])
for t in open_sys2
)
notional_ceiling = SYS2_MAX_OPEN_NOTIONAL_MULT * sub["position_size_usd"]
if open_count >= SYS2_MAX_CONCURRENT:
logger.info(
"Sub %s sys2 concentration cap: %d open positions ≥ max %d "
"(correlated crypto-beta — skipping post %d)",
wallet, open_count, SYS2_MAX_CONCURRENT, post_id)
return
if open_notional + sized_position_usd > notional_ceiling:
logger.info(
"Sub %s sys2 notional cap: open=%.2f + new=%.2f > ceiling=%.2f "
"(%.1f× base) — skipping post %d",
wallet, open_notional, sized_position_usd, notional_ceiling,
SYS2_MAX_OPEN_NOTIONAL_MULT, post_id)
return return
# Each subscriber gets its own session — AsyncSession is NOT concurrency-safe. # Each subscriber gets its own session — AsyncSession is NOT concurrency-safe.
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
try: try:
trader = HyperliquidTrader( # ── Paper-mode branch (P1.3) ──────────────────────────────
api_private_key=api_key, # Skip Hyperliquid entirely. Use the current Binance price as
account_address=wallet, # the synthetic fill. DB row gets hl_order_id="paper" so close
leverage=sub["leverage"], # logic + reconciliation know to skip HL too.
mainnet=settings.hl_mainnet, if sub["paper_mode"]:
) from app.services.price_store import price_store
entry_price = price_store.latest_price(asset)
if not entry_price:
logger.warning("Paper mode: no price for %s, skipping", asset)
return
# Check DB (not HL) for already-open paper position on same asset.
open_dup = await db.execute(
select(BotTrade).where(
BotTrade.wallet_address == wallet,
BotTrade.asset == asset,
BotTrade.closed_at.is_(None),
)
)
if open_dup.scalar_one_or_none():
logger.info("Paper mode: %s already has open %s, skipping", wallet, asset)
return
result = {"fill_price": entry_price, "order_id": "paper"}
logger.info("[PAPER] Open %s %s for %s @ %.4f size=%.2f",
side, asset, wallet, entry_price, sized_position_usd)
else:
trader = HyperliquidTrader(
api_private_key=api_key,
account_address=wallet,
leverage=sub["leverage"],
mainnet=settings.hl_mainnet,
)
open_positions = await trader.get_open_positions() open_positions = await trader.get_open_positions()
if any(p.get('coin') == asset for p in open_positions): if any(p.get('coin') == asset for p in open_positions):
logger.info("Subscriber %s already has open %s position, skipping", wallet, asset) logger.info("Subscriber %s already has open %s position, skipping", wallet, asset)
return return
result = await trader.open_position(asset, side, sub["position_size_usd"]) result = await trader.open_position(asset, side, sized_position_usd)
entry_price = result.get('fill_price', 0.0) entry_price = result.get('fill_price', 0.0)
# Resolve the FROZEN exit profile ONCE. Everything downstream
# (the watchdog AND recovery-after-restart) reads these — never
# the mutable Subscription/category config again.
import time as _time
eff_min_hold_ts = (
_time.time() + sub["_min_hold_minutes"] * 60
if sub.get("_min_hold_minutes") else None
)
eff = dict(
take_profit_pct = sub["take_profit_pct"],
stop_loss_pct = sub["stop_loss_pct"],
trailing_stop_pct = sub.get("trailing_stop_pct"),
trailing_activate_pct = sub.get("trailing_activate_at_pct"),
max_hold_hours = int(sub["max_hold_hours"]),
invalidation = sub.get("_sys2_invalidation"),
invalidation_price = sub.get("_sys2_invalidation_price"),
min_hold_until_ts = eff_min_hold_ts,
)
trade = BotTrade( trade = BotTrade(
asset=asset, asset=asset,
side=side, side=side,
@@ -217,17 +520,40 @@ async def _execute_for_subscriber(
trigger_post_id=post_id, trigger_post_id=post_id,
opened_at=datetime.now(timezone.utc).replace(tzinfo=None), opened_at=datetime.now(timezone.utc).replace(tzinfo=None),
hl_order_id=result.get('order_id'), hl_order_id=result.get('order_id'),
size_usd=sub["position_size_usd"], size_usd=sized_position_usd,
base_size_usd=sized_position_usd, # immutable; pyramiding grows size_usd
sys2_mode=(_sys2_mode if sys2 else None),
leverage=sub["leverage"], leverage=sub["leverage"],
# Freeze the exit profile on the row.
eff_take_profit_pct = eff["take_profit_pct"],
eff_stop_loss_pct = eff["stop_loss_pct"],
eff_trailing_stop_pct = eff["trailing_stop_pct"],
eff_trailing_activate_pct = eff["trailing_activate_pct"],
eff_max_hold_hours = eff["max_hold_hours"],
eff_invalidation = eff["invalidation"],
eff_invalidation_price = eff["invalidation_price"],
eff_min_hold_until_ts = eff["min_hold_until_ts"],
) )
db.add(trade) db.add(trade)
await db.commit() await db.commit()
await db.refresh(trade) await db.refresh(trade)
logger.info("Opened %s %s for %s @ %.2f (trade_id=%d)", logger.info("Opened %s %s for %s @ %.4f size=%.2f (trade_id=%d)",
side, asset, wallet, entry_price, trade.id) side, asset, wallet, entry_price, sized_position_usd, trade.id)
# Register with the watchdog using the FROZEN profile.
from app.services.tp_sl_monitor import register_trade from app.services.tp_sl_monitor import register_trade
_derisk = None
_addon = None
_peak_trail = None
_sys2_mode = sub.get("_sys2_mode", "standard")
if sys2:
from app.services.signal_categories import (
sys2_derisk_ladder, sys2_addon_ladder, sys2_peak_trail,
)
_derisk = sys2_derisk_ladder(sub["leverage"], _sys2_mode)
_addon = sys2_addon_ladder(_sys2_mode)
_peak_trail = sys2_peak_trail(_sys2_mode)
register_trade( register_trade(
trade_id=trade.id, trade_id=trade.id,
wallet=wallet, wallet=wallet,
@@ -236,20 +562,235 @@ async def _execute_for_subscriber(
asset=asset, asset=asset,
side=side, side=side,
entry_price=entry_price, entry_price=entry_price,
take_profit_pct=sub["take_profit_pct"], take_profit_pct=eff["take_profit_pct"],
stop_loss_pct=sub["stop_loss_pct"], stop_loss_pct=eff["stop_loss_pct"],
trailing_stop_pct=eff["trailing_stop_pct"],
trailing_activate_at_pct=eff["trailing_activate_pct"],
invalidation=eff["invalidation"],
invalidation_price=eff.get("invalidation_price"),
min_hold_until_ts=eff["min_hold_until_ts"],
stop_ladder=get_stop_ladder(post.category) if sys2 else None,
derisk_ladder=_derisk,
derisk_done=0,
addon_ladder=_addon,
addon_done=0,
peak_trail=_peak_trail,
grow_mode=bool(getattr(trade, "grow_mode", False)),
) )
# Max hold backstop (per-user for System 1, per-category for
# System 2 — already injected into the snapshot upstream).
max_hold_seconds = int(sub["max_hold_hours"]) * 3600
task = asyncio.create_task(_close_after_hold( task = asyncio.create_task(_close_after_hold(
trade.id, api_key, sub["leverage"], asset, wallet trade.id, api_key, sub["leverage"], asset, wallet, max_hold_seconds,
)) ))
_background_tasks.add(task) _background_tasks.add(task)
task.add_done_callback(_background_tasks.discard) task.add_done_callback(_background_tasks.discard)
# System-2 time-stop: if the position is still ~flat after
# `time_stop_hours`, the thesis is slow/dead — close early
# instead of tying up capital for the full max-hold.
ts_hours = sub.get("_sys2_time_stop_hours")
if ts_hours:
ts_task = asyncio.create_task(_time_stop_check(
trade.id, api_key, sub["leverage"], asset, wallet,
int(ts_hours) * 3600,
))
_background_tasks.add(ts_task)
ts_task.add_done_callback(_background_tasks.discard)
except Exception as e: except Exception as e:
logger.error("Trade execution failed for %s: %s", wallet, e) logger.error("Trade execution failed for %s: %s", wallet, e)
async def partial_derisk(
trade_id: int,
api_key: str,
asset: str,
wallet: str,
step_idx: int,
frac_of_original: float,
reason: str = "derisk",
) -> bool:
"""Staged de-risk: partially close a System-2 trade, realise the slice's
PnL, and KEEP the trade open. Idempotent per step_idx (guarded by the
persisted `derisk_steps_done` counter under the per-trade lock).
Returns True if the step was executed (or already done), False on a
recoverable no-op so the monitor can retry next tick.
"""
async with _lock_for(trade_id):
async with AsyncSessionLocal() as db:
try:
row = await db.execute(select(BotTrade).where(BotTrade.id == trade_id))
trade = row.scalar_one_or_none()
if trade is None or trade.closed_at is not None:
return True # gone / already fully closed — nothing to do
if (trade.derisk_steps_done or 0) > step_idx:
return True # this step already executed (idempotent)
if (trade.derisk_steps_done or 0) != step_idx:
# Steps must run in order; let the monitor catch up.
return False
size_usd = trade.size_usd if trade.size_usd is not None else 0.0
rem_frac = trade.remaining_fraction if trade.remaining_fraction is not None else 1.0
if size_usd <= 0 or rem_frac <= 0:
return True
# frac of the CURRENT open position needed to shed
# `frac_of_original` of the ORIGINAL notional.
frac_of_current = max(0.0, min(1.0, frac_of_original / rem_frac))
if trade.hl_order_id == "paper":
from app.services.price_store import price_store
fill = price_store.latest_price(asset)
if not fill:
logger.error("Paper de-risk: no %s price, retry trade %d", asset, trade_id)
return False
closed_frac_of_current = frac_of_current
else:
trader = HyperliquidTrader(
api_private_key=api_key,
account_address=wallet,
leverage=(trade.leverage or 1),
mainnet=settings.hl_mainnet,
)
r = await trader.reduce_position(asset, frac_of_current)
if r.get("already_closed"):
# Position vanished (manual close / liquidation race).
# Let the reconciler / full-close path handle it.
logger.warning("De-risk: %s position gone for trade %d", asset, trade_id)
return True
fill = r.get("fill_price")
closed_frac_of_current = r.get("closed_fraction") or 0.0
if not fill or closed_frac_of_current <= 0:
return False # no fill — retry next tick
# Fraction of ORIGINAL notional actually shed this step.
closed_frac_of_original = closed_frac_of_current * rem_frac
slice_usd = size_usd * closed_frac_of_original
pct = ((fill - trade.entry_price) / trade.entry_price) if trade.entry_price else 0.0
signed_pct = pct if trade.side == "long" else -pct
# Round-trip taker fee on this slice (open-share + this close).
fees = slice_usd * HL_TAKER_FEE_RATE * 2
slice_pnl = slice_usd * signed_pct - fees
trade.realized_partial_pnl_usd = round((trade.realized_partial_pnl_usd or 0.0) + slice_pnl, 4)
trade.remaining_fraction = max(0.0, round(rem_frac - closed_frac_of_original, 6))
trade.derisk_steps_done = step_idx + 1
await db.commit()
logger.warning(
"De-risk step %d trade %d (%s): closed %.1f%% of original "
"@ %.2f, slice PnL %.2f, remaining %.1f%% (reason=%s)",
step_idx + 1, trade_id, asset, closed_frac_of_original * 100,
fill, slice_pnl, trade.remaining_fraction * 100, reason,
)
return True
except Exception as e:
logger.error("partial_derisk failed for trade %d step %d: %s",
trade_id, step_idx, e)
return False
async def pyramid_add(
trade_id: int,
api_key: str,
asset: str,
wallet: str,
step_idx: int,
frac_of_base: float,
reason: str = "pyramid",
) -> tuple:
"""Pyramiding: add to a CONFIRMED System-2 winner. Returns
(ok: bool, new_entry: float|None, ref_price: float|None).
Guards: only while derisk_steps_done==0 (clean uptrend), idempotent per
step_idx, structural confirmation re-checked at execution, per-trade
notional cap. On success blends the average entry and grows size_usd.
"""
async with _lock_for(trade_id):
async with AsyncSessionLocal() as db:
try:
row = await db.execute(select(BotTrade).where(BotTrade.id == trade_id))
trade = row.scalar_one_or_none()
if trade is None or trade.closed_at is not None:
return (True, None, None)
if (trade.derisk_steps_done or 0) > 0:
return (True, None, None) # went underwater — no pyramiding
if (trade.addon_steps_done or 0) > step_idx:
return (True, None, None) # already done (idempotent)
if (trade.addon_steps_done or 0) != step_idx:
return (False, None, None) # out of order — retry later
base = trade.base_size_usd or trade.size_usd or 0.0
if base <= 0:
return (True, None, None)
from app.services.signal_categories import SYS2_MAX_OPEN_NOTIONAL_MULT
add_usd = round(base * frac_of_base, 2)
cur_notional = trade.size_usd or base
if cur_notional + add_usd > base * SYS2_MAX_OPEN_NOTIONAL_MULT:
logger.warning("Pyramid: notional cap hit trade %d (%.2f+%.2f > %.1fx base)",
trade_id, cur_notional, add_usd, SYS2_MAX_OPEN_NOTIONAL_MULT)
trade.addon_steps_done = step_idx + 1 # stop retrying
await db.commit()
return (True, None, None)
# Structural confirmation — fetched ONCE here, not per tick.
from app.services.market_data import for_asset, drop_in_progress_bar
from app.services.bottom_indicators import trend_confirmed
from app.services.price_store import price_store
prov = for_asset(asset)
raw = await prov.fetch_1d(asset, days=260)
daily = drop_in_progress_bar(raw, "1d")
closes = [float(c["close"]) for c in daily if c.get("close")]
highs = [float(c["high"]) for c in daily if c.get("high")]
px = price_store.latest_price(asset)
if not px:
return (False, None, None)
if not trend_confirmed(closes, highs, px):
return (False, None, None) # not a confirmed uptrend yet
if trade.hl_order_id == "paper":
fill = px
actual_add_usd = add_usd # synthetic full fill
else:
trader = HyperliquidTrader(
api_private_key=api_key,
account_address=wallet,
leverage=(trade.leverage or 1),
mainnet=settings.hl_mainnet,
)
r = await trader.open_position(asset, trade.side, add_usd)
fill = r.get("fill_price")
filled_coins = float(r.get("size_coins") or 0.0)
if not fill or filled_coins <= 0:
return (False, None, None)
# Use the ACTUAL filled notional, not the intended amount —
# an IOC add can under-fill on thin/volatile books; assuming
# the full add_usd would corrupt the blended entry + size.
actual_add_usd = filled_coins * fill
old_notional = trade.size_usd or base
new_notional = old_notional + actual_add_usd
blended = (old_notional * trade.entry_price + actual_add_usd * fill) / new_notional
trade.entry_price = round(blended, 6)
trade.size_usd = round(new_notional, 2)
trade.addon_steps_done = step_idx + 1
await db.commit()
logger.warning(
"Pyramid add step %d trade %d (%s): +$%.2f filled @ %.2f"
"notional $%.2f, blended entry %.4f (reason=%s)",
step_idx + 1, trade_id, asset, actual_add_usd, fill,
new_notional, blended, reason,
)
return (True, round(blended, 6), float(fill))
except Exception as e:
logger.error("pyramid_add failed trade %d step %d: %s",
trade_id, step_idx, e)
return (False, None, None)
async def close_and_finalize( async def close_and_finalize(
trade_id: int, trade_id: int,
api_key: str, api_key: str,
@@ -297,23 +838,46 @@ async def close_and_finalize(
size_usd = sub.position_size_usd if sub else 20.0 size_usd = sub.position_size_usd if sub else 20.0
trade_leverage = trade.leverage if trade.leverage is not None else leverage trade_leverage = trade.leverage if trade.leverage is not None else leverage
trader = HyperliquidTrader( # ── Paper-mode close (P1.3) ──────────────────────────────
api_private_key=api_key, # No Hyperliquid call. Synthesize a fill at the current Binance
account_address=wallet, # price. Same downstream PnL math as a real trade.
leverage=trade_leverage, if trade.hl_order_id == "paper":
mainnet=settings.hl_mainnet, from app.services.price_store import price_store
) paper_exit = price_store.latest_price(asset)
close_result = await trader.close_position(asset) if not paper_exit:
logger.error("Paper close: no price for %s, can't close trade %d",
asset, trade_id)
# Roll back the closed_at claim so we can retry later.
await db.execute(
update(BotTrade).where(BotTrade.id == trade_id).values(closed_at=None)
)
await db.commit()
return
close_result = {"fill_price": paper_exit, "already_closed": False}
else:
trader = HyperliquidTrader(
api_private_key=api_key,
account_address=wallet,
leverage=trade_leverage,
mainnet=settings.hl_mainnet,
)
close_result = await trader.close_position(asset)
# Detect "position was already closed externally" before we even tried # Detect "position was already closed externally" before we even tried
if close_result.get("already_closed"): if close_result.get("already_closed"):
# PnL of the (now-gone) open remainder is unknown, BUT any
# PnL already BANKED by staged de-risk is real money — keep
# it in pnl_usd instead of discarding it as None, else
# analytics / "today realised" silently undercount.
banked = trade.realized_partial_pnl_usd or 0.0
logger.warning( logger.warning(
"Trade %d: no open %s position found on HL — " "Trade %d: no open %s position found on HL — user likely "
"user likely closed it manually. Marking trade closed with no PnL.", "closed it manually. Marking closed; pnl=%s (banked de-risk only).",
trade_id, asset, trade_id, asset, round(banked, 2) if banked else None,
) )
trade.exit_price = None trade.exit_price = None
trade.pnl_usd = None trade.pnl_usd = round(banked, 2) if banked else None
trade.remaining_fraction = 0.0
trade.hold_seconds = int( trade.hold_seconds = int(
(datetime.now(timezone.utc) - (datetime.now(timezone.utc) -
trade.opened_at.replace(tzinfo=timezone.utc)).total_seconds() trade.opened_at.replace(tzinfo=timezone.utc)).total_seconds()
@@ -334,13 +898,24 @@ async def close_and_finalize(
pct = ((exit_price - trade.entry_price) / trade.entry_price) if trade.entry_price else 0.0 pct = ((exit_price - trade.entry_price) / trade.entry_price) if trade.entry_price else 0.0
signed_pct = pct if trade.side == 'long' else -pct signed_pct = pct if trade.side == 'long' else -pct
# size_usd is the NOTIONAL value — leverage affects margin, not PnL. # size_usd is the NOTIONAL value — leverage affects margin, not PnL.
gross_pnl = size_usd * signed_pct # For a staged-de-risk trade only the REMAINING notional is
# Deduct round-trip taker fees (IOC crosses book on both open + close). # closed here; earlier partial reduces already realised their
# Without this, displayed PnL overstates real returns by ~9 bps per trade. # slice into realized_partial_pnl_usd. Legacy/non-partial rows
fees_usd = size_usd * HL_TAKER_FEE_RATE * 2 # have remaining_fraction=1.0 + realized_partial=0.0 → identical
pnl_usd = gross_pnl - fees_usd # math to before.
rem_frac = trade.remaining_fraction if trade.remaining_fraction is not None else 1.0
prior_pnl = trade.realized_partial_pnl_usd or 0.0
remaining_size = size_usd * rem_frac
gross_pnl = remaining_size * signed_pct
# Round-trip taker fees: the OPEN fee was paid on the full
# original notional; partial reduces already charged their
# close-side fee. Here charge the open-share for the remaining
# slice + this final close fee.
fees_usd = remaining_size * HL_TAKER_FEE_RATE * 2
pnl_usd = gross_pnl - fees_usd + prior_pnl
trade.exit_price = exit_price trade.exit_price = exit_price
trade.remaining_fraction = 0.0
trade.pnl_usd = round(pnl_usd, 2) trade.pnl_usd = round(pnl_usd, 2)
trade.hold_seconds = hold_secs trade.hold_seconds = hold_secs
# closed_at already set by the atomic UPDATE # closed_at already set by the atomic UPDATE
@@ -357,6 +932,24 @@ async def close_and_finalize(
gross_pnl, fees_usd, pnl_usd, reason, gross_pnl, fees_usd, pnl_usd, reason,
) )
# ── Circuit breaker check (P1.1) ───────────────────────────
# Run after PnL is written. If daily DD or consecutive-loss
# threshold hit, trip the breaker — that nulls manual_window
# and blocks new trades for CB_LOCKOUT_HOURS.
# Infer which system this trade belonged to from its trigger
# post's source, so the right (independent) breaker is checked.
from app.services.circuit_breaker import check_and_trip
from app.services.signal_categories import SYSTEM_2_SOURCES
_src_row = await db.execute(
select(Post.source).where(Post.id == trade.trigger_post_id)
)
_src = (_src_row.scalar_one_or_none() or "").lower()
_sys = "sys2" if _src in SYSTEM_2_SOURCES else "sys1"
cb_reason = await check_and_trip(wallet, db, _sys)
if cb_reason:
logger.warning("Circuit breaker [%s] tripped for %s: %s",
_sys, wallet, cb_reason)
except Exception as e: except Exception as e:
logger.error("Failed to close trade %d: %s", trade_id, e) logger.error("Failed to close trade %d: %s", trade_id, e)
# Rollback closed_at so recovery can retry this trade on next restart. # Rollback closed_at so recovery can retry this trade on next restart.
@@ -377,7 +970,58 @@ async def close_and_finalize(
async def _close_after_hold( async def _close_after_hold(
trade_id: int, api_key: str, leverage: int, asset: str, wallet: str trade_id: int, api_key: str, leverage: int, asset: str, wallet: str,
max_hold_seconds: int,
) -> None: ) -> None:
await asyncio.sleep(MAX_HOLD_SECONDS) """Per-user max-hold backstop. Forces close after the configured duration.
The trailing-stop monitor will usually close the position long before this
fires, but for trades that drift sideways the cap prevents indefinite
funding-rate bleed.
"""
await asyncio.sleep(max_hold_seconds)
await close_and_finalize(trade_id, api_key, leverage, asset, wallet, reason="max_hold") await close_and_finalize(trade_id, api_key, leverage, asset, wallet, reason="max_hold")
# Flat-zone band: a position whose |unrealised| is within this % at the
# time-stop checkpoint is considered "not working" and closed early.
_TIME_STOP_FLAT_BAND_PCT = 2.0
async def _time_stop_check(
trade_id: int, api_key: str, leverage: int, asset: str, wallet: str,
delay_seconds: int,
) -> None:
"""System-2 time-stop. After `delay_seconds`, if the trade is still open
AND roughly flat (|unrealised| < band), the thesis is slow/dead — close
it so capital isn't parked for the full multi-week max-hold on a dud.
A trade that's already moved (win or stopped) will be closed/gone by
now; the conditional UPDATE in close_and_finalize makes a late fire a
harmless no-op.
"""
await asyncio.sleep(delay_seconds)
from sqlalchemy import select
async with AsyncSessionLocal() as db:
row = await db.execute(select(BotTrade).where(BotTrade.id == trade_id))
trade = row.scalar_one_or_none()
if trade is None or trade.closed_at is not None:
return # already closed by trail / SL / invalidation
entry = trade.entry_price
from app.services.price_store import price_store
cur = price_store.latest_price(asset)
if not cur or not entry:
return # no price → don't act blindly; max-hold will catch it
raw = (cur - entry) / entry
signed_pct = (raw if trade.side == "long" else -raw) * 100
if abs(signed_pct) < _TIME_STOP_FLAT_BAND_PCT:
logger.info(
"Time-stop firing trade %d (%s %s): flat at %.2f%% after %dh",
trade_id, trade.side, asset, signed_pct, delay_seconds // 3600,
)
await close_and_finalize(
trade_id, api_key, leverage, asset, wallet, reason="time_stop",
)
+190
View File
@@ -0,0 +1,190 @@
"""Pure-price BTC bottom indicators (no on-chain data, no API keys).
Three independent, well-known bottom signals. Each is a simple, transparent
function of price history only — no Realized Cap, no MVRV, no paid feeds:
A. AHR999 — value/DCA index. < 0.45 = deep-value bottom zone.
B. 200-Week MA — BTC has bottomed near its 200WMA every cycle.
C. Pi Cycle Bot — 150d EMA vs 471d SMA × 0.745 crossover region.
The combiner fires only when at least 2 of the 3 agree ("三者为二"), which
historically pins genuine macro bottoms while rejecting single-indicator
noise. Long-only, low-frequency, stateless.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from datetime import date, datetime, timezone
# BTC genesis block: 2009-01-03.
_BTC_GENESIS = date(2009, 1, 3)
# AHR999 thresholds.
AHR999_BOTTOM = 0.45 # < this → deep-value / bottom zone (signal A)
AHR999_INVALIDATION = 1.2 # > this → no longer cheap, thesis dead
# 200WMA tolerance: price within +5% of the 200-week mean still counts.
WMA200_TOLERANCE = 1.05
# Pi Cycle Bottom multiplier (the canonical 0.745).
PI_CYCLE_MULT = 0.745
PI_CYCLE_EMA = 150
PI_CYCLE_SMA = 471
def _closes(candles: list[dict]) -> list[float]:
return [float(c["close"]) for c in candles if c.get("close")]
def _sma(values: list[float], n: int) -> float | None:
if len(values) < n:
return None
return sum(values[-n:]) / n
def _ema(values: list[float], n: int) -> float | None:
if len(values) < n:
return None
k = 2.0 / (n + 1)
# Seed with the SMA of the first n values, then walk forward.
ema = sum(values[:n]) / n
for v in values[n:]:
ema = v * k + ema * (1 - k)
return ema
def coin_age_days(today: date | None = None) -> int:
d = today or datetime.now(timezone.utc).date()
return max(1, (d - _BTC_GENESIS).days)
def ahr999(daily_closes: list[float], today: date | None = None) -> float | None:
"""AHR999 = (price / GM200) × (price / growth_val).
GM200 = geometric mean of the last 200 daily closes
growth_val = 10 ** (5.84 * log10(coin_age_days) - 17.01)
< 0.45 deep value · 0.451.2 DCA · > 1.2 expensive.
"""
if len(daily_closes) < 200:
return None
price = daily_closes[-1]
if price <= 0:
return None
window = daily_closes[-200:]
# Geometric mean via log-space (avoids overflow).
log_sum = sum(math.log(c) for c in window if c > 0)
gm200 = math.exp(log_sum / 200)
age = coin_age_days(today)
growth_val = 10 ** (5.84 * math.log10(age) - 17.01)
if gm200 <= 0 or growth_val <= 0:
return None
return (price / gm200) * (price / growth_val)
def below_200wma(
weekly_closes: list[float],
price: float | None = None,
) -> tuple[bool, float | None]:
"""True if price ≤ 200-week mean × 1.05. Returns (signal, wma200).
`price` is the reference price to compare. Pass the latest DAILY close so
all three indicators use the same "current price" — otherwise the last
weekly close can be up to 7 days stale and B would disagree with A/C right
at a fast-moving bottom. Falls back to the last weekly close if omitted.
"""
wma = _sma(weekly_closes, 200)
if wma is None or not weekly_closes:
return False, wma
px = price if price is not None else weekly_closes[-1]
return px <= wma * WMA200_TOLERANCE, wma
def pi_cycle_bottom(daily_closes: list[float]) -> tuple[bool, float | None, float | None]:
"""Pi Cycle Bottom: 150d EMA ≤ 471d SMA × 0.745.
Returns (signal, ema150, sma471_scaled).
"""
ema150 = _ema(daily_closes, PI_CYCLE_EMA)
sma471 = _sma(daily_closes, PI_CYCLE_SMA)
if ema150 is None or sma471 is None:
return False, ema150, None
scaled = sma471 * PI_CYCLE_MULT
return ema150 <= scaled, ema150, scaled
def trend_confirmed(
daily_closes: list[float],
daily_highs: list[float],
price: float,
sma_n: int = 200,
high_lookback: int = 20,
high_tol: float = 0.005,
) -> bool:
"""Structural confirmation that the bottom reversal has turned into an
actual uptrend — gate for pyramiding (add-to-winner):
price ≥ 200-day SMA (trend regime reclaimed)
AND price ≥ recent 20d high·(10.5%) (at/near a fresh local high)
Pure function (no I/O) so it's unit-testable; the caller fetches candles.
Conservative: both conditions must hold, evaluated at add-on time.
"""
sma = _sma(daily_closes, sma_n)
if sma is None or not daily_highs:
return False
recent_high = max(daily_highs[-high_lookback:]) if len(daily_highs) >= 1 else None
if recent_high is None or recent_high <= 0:
return False
return price >= sma and price >= recent_high * (1.0 - high_tol)
@dataclass(frozen=True)
class BottomConfluence:
fired: bool # ≥ 2 of 3 true
votes: int # 0..3
ahr999: float | None
a_value: bool # AHR999 < 0.45
b_200wma: bool # price ≤ 200WMA × 1.05
c_pi_cycle: bool # 150 EMA ≤ 471 SMA × 0.745
detail: dict
def bottom_confluence(
daily_closes: list[float],
weekly_closes: list[float],
today: date | None = None,
) -> BottomConfluence:
"""2-of-3 bottom confluence. Pure price, stateless."""
ah = ahr999(daily_closes, today)
a = ah is not None and ah < AHR999_BOTTOM
# All three indicators compare against the SAME current price (latest
# daily close), so a stale weekly close can't make B disagree with A/C.
cur_price = daily_closes[-1] if daily_closes else None
b, wma200 = below_200wma(weekly_closes, cur_price)
c, ema150, sma471s = pi_cycle_bottom(daily_closes)
votes = int(a) + int(b) + int(c)
detail = {
"ahr999": round(ah, 4) if ah is not None else None,
"ahr999_threshold": AHR999_BOTTOM,
"price": round(daily_closes[-1], 2) if daily_closes else None,
"wma200": round(wma200, 2) if wma200 is not None else None,
"wma200_band": round(wma200 * WMA200_TOLERANCE, 2) if wma200 is not None else None,
"pi_ema150": round(ema150, 2) if ema150 is not None else None,
"pi_sma471_scaled": round(sma471s, 2) if sma471s is not None else None,
"votes": votes,
"signals": {"ahr999_value": a, "below_200wma": b, "pi_cycle_bottom": c},
}
return BottomConfluence(
fired=votes >= 2,
votes=votes,
ahr999=ah,
a_value=a,
b_200wma=b,
c_pi_cycle=c,
detail=detail,
)
+201
View File
@@ -0,0 +1,201 @@
"""
Circuit breaker — autonomous "stop trading" trigger per wallet.
Two trip conditions, evaluated AFTER every closed trade:
1. Daily drawdown: cumulative PnL across all trades closed today (UTC)
drops below -CB_DAILY_DD_PCT × (subscription.position_size_usd × 10).
Default cap: -5% of "expected 10-trade notional" — i.e. if the user's
base bet is $20, the daily DD limit is -$10 net realized.
2. Consecutive losses: last CB_CONSEC_LOSSES closed trades for this wallet
all have pnl_usd < 0. Default = 3.
When tripped:
- manual_window_until is nulled (bot stops trading via manual override)
- circuit_breaker_tripped_at = now
- circuit_breaker_reason = "daily_dd" | "consecutive_losses"
- WS broadcast a 'circuit_breaker_tripped' event so the UI lights up red
Gate: `is_tripped()` is checked by _execute_for_subscriber BEFORE opening any
new trade. A tripped wallet stays blocked for CB_LOCKOUT_HOURS regardless of
schedule, so a buggy scanner or a black-swan move can't drain the account
overnight.
User unblock path:
POST /api/user/{wallet}/manual-window with any hours > 0 clears the trip.
This is a deliberate human-in-the-loop — the user must consciously re-arm.
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import BotTrade, Subscription
logger = logging.getLogger(__name__)
# ─── Tunables ───────────────────────────────────────────────────────────────
CB_DAILY_DD_USD_PER_BASE = 0.5 # Daily DD limit as a multiple of base size.
# base $20 → DD limit -$10. Tune to taste.
CB_CONSEC_LOSSES = 3 # Consecutive losing trades that trip the CB.
CB_LOCKOUT_HOURS = 24 # Once tripped, blocked for this many hours
# unless the user explicitly clears it.
# ─── Trip detection ─────────────────────────────────────────────────────────
# Column indirection so one code path serves both independent breakers.
# system "sys1" → circuit_breaker_tripped_at / circuit_breaker_reason
# system "sys2" → sys2_cb_tripped_at / sys2_cb_reason
_CB_COLS = {
"sys1": ("circuit_breaker_tripped_at", "circuit_breaker_reason"),
"sys2": ("sys2_cb_tripped_at", "sys2_cb_reason"),
}
async def _trades_for_system(db: AsyncSession, wallet: str, since, system: str):
"""Closed, priced trades for `wallet` since `since`, filtered to the
given system by joining the trigger post's source.
sys2 = trigger_post.source in System-2 set; sys1 = everything else
(currently only "truth"). Trades with no trigger post → treated sys1.
"""
from app.models import Post
from app.services.signal_categories import SYSTEM_2_SOURCES
rows = await db.execute(
select(BotTrade, Post.source)
.outerjoin(Post, Post.id == BotTrade.trigger_post_id)
.where(
BotTrade.wallet_address == wallet,
BotTrade.closed_at >= since,
BotTrade.pnl_usd.is_not(None),
)
.order_by(BotTrade.closed_at.desc())
)
out = []
for trade, src in rows.all():
is_s2 = (src or "").lower() in SYSTEM_2_SOURCES
if (system == "sys2") == is_s2:
out.append(trade)
return out
async def check_and_trip(
wallet: str, db: AsyncSession, system: str = "sys1",
) -> Optional[str]:
"""Run trip conditions for `wallet` within ONE system. Independent per
system: a Trump losing streak can't lock out the reversal book.
Called from close_and_finalize after a trade's pnl is written, with the
system inferred from the closing trade's source.
"""
col_at, col_reason = _CB_COLS[system]
sub = (await db.execute(
select(Subscription).where(Subscription.wallet_address == wallet)
)).scalar_one_or_none()
if sub is None:
return None
tripped_at = getattr(sub, col_at)
if tripped_at is not None:
age_hrs = (datetime.now(timezone.utc).replace(tzinfo=None) - tripped_at).total_seconds() / 3600
if age_hrs < CB_LOCKOUT_HOURS:
return None # still in active lockout
reason: Optional[str] = None
now_naive = datetime.now(timezone.utc).replace(tzinfo=None)
start_of_day = now_naive.replace(hour=0, minute=0, second=0, microsecond=0)
today_trades = await _trades_for_system(db, wallet, start_of_day, system)
today_pnl = sum(t.pnl_usd or 0 for t in today_trades)
dd_limit = -CB_DAILY_DD_USD_PER_BASE * sub.position_size_usd * 10
if today_pnl < dd_limit:
reason = "daily_dd"
logger.warning("CB[%s] trip [daily_dd] %s: pnl=%.2f < %.2f",
system, wallet, today_pnl, dd_limit)
if reason is None:
# Consecutive losses within this system (all-time, most recent N).
all_sys = await _trades_for_system(
db, wallet, datetime(1970, 1, 1), system,
)
recent = all_sys[:CB_CONSEC_LOSSES]
if len(recent) >= CB_CONSEC_LOSSES and all((t.pnl_usd or 0) < 0 for t in recent):
reason = "consecutive_losses"
logger.warning("CB[%s] trip [consecutive_losses] %s: last %d all negative",
system, wallet, CB_CONSEC_LOSSES)
if reason is None:
return None
setattr(sub, col_at, now_naive)
setattr(sub, col_reason, reason)
# Only System 1 owns manual_window; nulling it on a sys2 trip would
# wrongly disable the Trump book too. Sys2 trip just blocks sys2 entries.
if system == "sys1":
sub.manual_window_until = None
await db.commit()
try:
from app.ws.manager import manager
await manager.broadcast({
"type": "circuit_breaker_tripped",
"wallet": wallet,
"system": system,
"reason": reason,
"tripped_at": now_naive.isoformat(),
"unlock_at": (now_naive + timedelta(hours=CB_LOCKOUT_HOURS)).isoformat(),
})
except Exception as exc:
logger.warning("CB broadcast failed: %s", exc)
return reason
# ─── Gate (called by _execute_for_subscriber) ───────────────────────────────
def is_tripped(sub_dict: dict, system: str = "sys1") -> tuple[bool, str]:
"""Returns (tripped, reason) for the given system's breaker.
`sub_dict` is the snapshot copy built in bot_engine."""
col_at, col_reason = _CB_COLS[system]
tripped_at = sub_dict.get(col_at)
if tripped_at is None:
return False, ""
age_hrs = (datetime.now(timezone.utc).replace(tzinfo=None) - tripped_at).total_seconds() / 3600
if age_hrs >= CB_LOCKOUT_HOURS:
return False, "" # lockout expired (auto-untrip on next refresh)
reason = sub_dict.get(col_reason) or "unknown"
remaining_hrs = CB_LOCKOUT_HOURS - age_hrs
return True, f"cb[{system}]:{reason} (unlock in {remaining_hrs:.1f}h)"
# ─── Manual reset (called from /manual-window endpoint) ─────────────────────
async def clear_trip(wallet: str, db: AsyncSession) -> bool:
"""Clear the trip state. Returns True if a trip was actually cleared.
Called when the user explicitly re-arms manual_window — that's the
human-in-the-loop unblock. Just letting LOCKOUT_HOURS expire works too,
but most users will hit the button.
"""
sub = (await db.execute(
select(Subscription).where(Subscription.wallet_address == wallet)
)).scalar_one_or_none()
if sub is None or sub.circuit_breaker_tripped_at is None:
return False
sub.circuit_breaker_tripped_at = None
sub.circuit_breaker_reason = None
await db.commit()
logger.info("CB cleared for %s", wallet)
return True
+347
View File
@@ -0,0 +1,347 @@
"""
Deterministic entry pre-filter — runs BEFORE the AI call.
Lesson from the 13-trade backtest: AI gives confidence 85+ to posts like
"I had excellent conversations with President X" and "The Strait is open again",
which are second-derivative news or pure rhetoric — they don't move markets.
The two posts that ACTUALLY produced 10%+ moves both contained explicit
"ACTION TAKEN" language: "STATEMENT OF PRESIDENT", "Military Success that we
have had". The losers all had future-tense or conversational language.
Rather than hoping the LLM internalises this distinction reliably, we enforce
it with a deterministic Python filter. Cheap, debuggable, no false positives
from a hallucinating model.
This filter runs BEFORE analyze_post() in the scraper, short-circuiting the
AI call when the text is obviously non-actionable. Saves API spend AND keeps
the bot from accumulating "confidence 85, no catalyst" trades.
Three independent gates (post must pass ALL):
1. ACTION verbs present — past-tense action OR formal statement marker
2. NO future-tense disqualifiers — "I will", "considering", "thinking about"
3. NOT a duplicate of a recent post on the same topic
"""
from __future__ import annotations
import logging
import re
from datetime import datetime, timedelta, timezone
from typing import Optional
logger = logging.getLogger(__name__)
# ─── Gate 1: explicit action markers ────────────────────────────────────────
# Lower-cased substring matches. A post must contain AT LEAST ONE of these.
ACTION_MARKERS = [
# Formal statement / announcement (past or imperative)
"statement of",
"statement by",
"executive order",
"announcing",
"today i signed",
"i have signed",
"i hereby",
"effective immediately",
"effective today",
# Past-tense action by the administration / military
"have agreed",
"has agreed",
"was signed",
"have signed",
"have implemented",
"have imposed",
"have authorized",
"we have had", # "Military Success that we have had..." (post 684)
"have successfully",
"just imposed",
"just signed",
# NOTE: "i have ordered" was in this list but produced false positives.
# Trump uses it for verbal directives ("ordered Navy to shoot boats")
# that don't move markets. Real catalysts use "I have signed" or "STATEMENT OF".
# If you need to catch a genuine order, list the specific action below:
"ordered the suspension of",
"ordered the cancellation of",
"ordered the deployment of",
"just announced",
# Asset / policy specific catalysts
"strategic reserve",
"tariff",
"sanctions on",
"ceasefire signed",
"rate cut",
"rate hike",
# Regulatory / legal
"sec approval",
"sec has approved",
"supreme court ruled",
"doj filed",
]
# ─── Gate 2: future-tense / hedge disqualifiers ─────────────────────────────
# If ANY of these appear in the first 200 chars, the post is intent not action.
FUTURE_TENSE_BLOCKERS = [
# Pure intent
"i will",
"we will",
"i'm thinking",
"considering",
"looking at",
"may consider",
"could be",
# Conversational (not action)
"had a conversation",
"had conversations",
"had a great call",
"had a great meeting",
"spoke with",
"talked with",
"met with",
"i just had excellent",
# Hedge / partial
"most points were agreed",
"we're close to",
"still working on",
]
# ─── Gate 3: deduplication ──────────────────────────────────────────────────
# Two layers:
#
# Layer A: TOPIC KEYWORDS — curated set of geopolitical / macro nouns. If two
# posts within DEDUP_WINDOW_HOURS share ≥1 topic keyword, they're flagged as
# being about the same event. This catches "Iran/Strait/Hormuz" being repeated
# 4 times across 5 days, which the original token-overlap dedup missed because
# the words ARE different (closed/opened/announced/transited) — only the
# topic is the same.
#
# Layer B (legacy): generic token-overlap fallback for posts that don't hit a
# curated topic. Kept as backup so we don't miss novel re-iterations.
DEDUP_WINDOW_HOURS = 48
DEDUP_KEYWORD_THRESHOLD = 3 # token-overlap backup threshold
# Curated list of "topic anchors". Lower-cased substring match. When a new
# post AND a prior post both contain one of these, we treat the new post as
# a probable update to an existing storyline, not a fresh catalyst.
#
# Tune this list against your backtest results: anything that produced repeat
# false-positives should be on here. Anything that produced a real catalyst
# (post 684 = "Pakistan" — yes, even though we list it, Pakistan being mentioned
# again within 48h would correctly be flagged as redundant) should NOT cause us
# to miss the FIRST occurrence — only subsequent ones.
TOPIC_KEYWORDS = {
# Middle East geopolitics (dominant source of FP in our 13-trade audit)
"iran", "iranian",
"hormuz", "strait of hormuz",
"strait of iran", # Trump-ism on post 62 — same physical geography as Hormuz
"the strait", # generic — catches "the Strait is open/closed" phrasing
"israel", "gaza", "lebanon", "hezbollah", "syria",
"yemen", "houthi",
# Russia / Ukraine
"ukraine", "russia", "putin", "zelensky", "kyiv",
# China
"china", "chinese", "xi jinping", "taiwan",
# North Korea
"north korea", "kim jong",
# Macro / monetary
"tariff", "tariffs",
"fed", "fomc", "powell", "interest rate", "rate cut", "rate hike",
"cpi", "inflation",
# Crypto-specific
"bitcoin", "btc", "ethereum", "crypto", "sec",
"strategic reserve",
# South Asia (post 684 family)
"pakistan", "india",
}
# Stop-words we ignore when comparing
_STOP_WORDS = {
"the","a","an","of","to","and","or","in","on","at","is","are","was",
"were","be","been","have","has","had","will","i","we","you","they",
"this","that","with","for","by","from","my","our","your","their",
"it","its","as","but","not","just","very","more","most","than","also",
"would","could","should","do","does","did","new","now","get","got",
"make","made","go","going","go","want","want","know","like","really",
"us","u","said","say","says",
}
def _significant_tokens(text: str) -> set[str]:
"""Lower-cased alphanumeric words ≥4 chars, with stop-words removed."""
raw = re.findall(r"[a-zA-Z]{4,}", text.lower())
return {w for w in raw if w not in _STOP_WORDS}
# How many leading characters to scan for "dominant" topics. Topics that only
# appear in the tail of a long post are background context, not the catalyst.
# A post that mentions Iran in passing 800 chars in shouldn't dedup against
# a different post whose CORE topic is Iran.
TOPIC_HEAD_CHARS = 200
def _topic_keywords_in(text: str) -> set[str]:
"""Return canonical TOPIC_KEYWORDS in the FIRST TOPIC_HEAD_CHARS of `text`.
Canonicalisation collapses substring matches: if both "hormuz" and
"strait of hormuz" match, only the longer one is kept. This prevents
inflated overlap counts where the same physical concept matches twice.
Limiting to the head ensures we match the post's DOMINANT topic, not
incidental mentions buried in the tail.
"""
t = text[:TOPIC_HEAD_CHARS].lower()
raw = {kw for kw in TOPIC_KEYWORDS if kw in t}
# Drop any keyword that is a proper substring of another matched keyword.
canonical = set(raw)
for a in raw:
for b in raw:
if a != b and a in b:
canonical.discard(a)
break
return canonical
# Threshold for dedup by topic. Requiring ≥2 shared CANONICAL topics avoids
# false positives where two posts both mention Iran but are about different
# events (post 684 = Pakistan-Iran context; post 416 = Iran sanctions —
# they share "iran" but nothing else). Two-topic overlap (e.g. Iran + Strait
# of Hormuz, or Bitcoin + Strategic Reserve) is a much stronger same-event
# signal.
DEDUP_TOPIC_MIN = 2
# ─── Public API ─────────────────────────────────────────────────────────────
def has_action_marker(text: str) -> tuple[bool, Optional[str]]:
"""True iff at least one ACTION_MARKERS substring appears in `text`.
Returns (ok, matched_marker_or_None).
"""
t = text.lower()
for m in ACTION_MARKERS:
if m in t:
return True, m
return False, None
def has_future_tense_blocker(text: str) -> tuple[bool, Optional[str]]:
"""True iff one of FUTURE_TENSE_BLOCKERS appears (in first 200 chars)."""
head = text[:200].lower()
for b in FUTURE_TENSE_BLOCKERS:
if b in head:
return True, b
return False, None
async def is_duplicate_of_recent(text: str, db_session_factory) -> tuple[bool, Optional[int]]:
"""True iff a post within DEDUP_WINDOW_HOURS appears to cover the same event.
Two-layer check:
A. Any shared TOPIC_KEYWORD (Iran, Pakistan, tariff, etc.) → duplicate
B. ≥3 shared significant tokens AND ≥50% overlap → duplicate (catches
novel topics not on the curated list)
Returns (is_dup, dup_post_id_or_None).
"""
from sqlalchemy import select
from app.models import Post
new_topics = _topic_keywords_in(text)
new_tokens = _significant_tokens(text)
cutoff = (datetime.now(timezone.utc) - timedelta(hours=DEDUP_WINDOW_HOURS)).replace(tzinfo=None)
async with db_session_factory() as db:
rows = await db.execute(
select(Post.id, Post.text).where(Post.published_at >= cutoff)
)
for pid, prior_text in rows.all():
# Layer A: canonical topic-keyword overlap (≥ DEDUP_TOPIC_MIN)
if new_topics:
prior_topics = _topic_keywords_in(prior_text)
if len(new_topics & prior_topics) >= DEDUP_TOPIC_MIN:
return True, pid
# Layer B: generic token-overlap fallback for novel topics
if len(new_tokens) >= DEDUP_KEYWORD_THRESHOLD:
prior_tokens = _significant_tokens(prior_text)
shared = new_tokens & prior_tokens
if len(shared) >= DEDUP_KEYWORD_THRESHOLD:
if len(shared) / max(len(new_tokens), 1) >= 0.5:
return True, pid
return False, None
async def passes_entry_filter(
text: str,
db_session_factory,
) -> tuple[bool, str]:
"""Master combiner. Returns (passed, reason_string).
On failure, `reason_string` explains which gate killed it. On success,
`reason_string` is empty.
Run this BEFORE analyze_post() to short-circuit the AI call on obvious
non-catalysts.
"""
ok_action, marker = has_action_marker(text)
if not ok_action:
return False, "no_action_marker (no past-tense / formal-statement keyword found)"
has_block, blocker = has_future_tense_blocker(text)
if has_block:
return False, f"future_tense_blocker: {blocker!r}"
is_dup, dup_id = await is_duplicate_of_recent(text, db_session_factory)
if is_dup:
return False, f"duplicate_of_post_{dup_id}"
return True, f"action_marker={marker!r}"
# ─── Backtest helper — synchronous version using a single text + history ────
def passes_entry_filter_sync(text: str, prior_texts: list[str]) -> tuple[bool, str]:
"""Pure-function variant for backtest replay: pass a list of prior post
texts (within the dedup window) instead of hitting the DB.
Useful for running the filter over the historical dataset to count how
many posts would have been EXECUTED vs FILTERED.
"""
ok_action, marker = has_action_marker(text)
if not ok_action:
return False, "no_action_marker"
has_block, blocker = has_future_tense_blocker(text)
if has_block:
return False, f"future_tense: {blocker}"
new_topics = _topic_keywords_in(text)
new_tokens = _significant_tokens(text)
for pt in prior_texts:
# Layer A: canonical topic overlap (≥ DEDUP_TOPIC_MIN)
if new_topics:
shared_topics = new_topics & _topic_keywords_in(pt)
if len(shared_topics) >= DEDUP_TOPIC_MIN:
return False, f"duplicate_topic: {','.join(sorted(shared_topics))}"
# Layer B: generic token overlap
if len(new_tokens) >= DEDUP_KEYWORD_THRESHOLD:
shared = new_tokens & _significant_tokens(pt)
if (len(shared) >= DEDUP_KEYWORD_THRESHOLD
and len(shared) / max(len(new_tokens), 1) >= 0.5):
return False, "duplicate_tokens"
return True, f"ok: {marker}"
+221
View File
@@ -0,0 +1,221 @@
"""
Breakout Signal Monitor — ETH + LINK (BTC as trend context)
Polls Binance every 5 minutes. When both conditions are met:
1. BB squeeze: BB width in bottom 20% of last 60 candles
2. Volume spike: current volume > 2.5x 20-period average
3. Taker buy ratio > 60%
4. Price closes above upper Bollinger Band
Broadcasts alert via WebSocket. Gated by user on/off toggle.
"""
import collections
import logging
from datetime import datetime, timezone
from typing import Deque, Optional
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
# ── Config ────────────────────────────────────────────────────────────────────
WATCH_SYMBOLS = ["ETHUSDT", "LINKUSDT"]
CONTEXT_SYMBOL = "BTCUSDT"
ALL_SYMBOLS = [CONTEXT_SYMBOL] + WATCH_SYMBOLS
CANDLE_LIMIT = 200 # candles to fetch per poll (200 x 5m = ~16.7h history)
BB_PERIOD = 20
BB_SQUEEZE_PCT = 20 # bottom 20% of BB width history → squeeze
VOLUME_MULT = 2.5
TBR_THRESH = 0.60 # taker buy ratio threshold
BTC_TREND_MA = 288 # 24h trend (needs separate longer fetch for BTC)
# ── State ─────────────────────────────────────────────────────────────────────
_enabled: bool = False
_recent_signals: Deque[dict] = collections.deque(maxlen=50)
_last_fired: dict[str, Optional[datetime]] = {s: None for s in WATCH_SYMBOLS}
# ── Public API ────────────────────────────────────────────────────────────────
def set_enabled(value: bool) -> None:
global _enabled
_enabled = value
logger.info("Funding signal monitor: %s", "ENABLED" if value else "DISABLED")
def is_enabled() -> bool:
return _enabled
def get_recent_signals(limit: int = 20) -> list[dict]:
return list(reversed(list(_recent_signals)))[:limit]
def get_status() -> dict:
return {
"enabled": _enabled,
"symbols": WATCH_SYMBOLS,
"recent_signal_count": len(_recent_signals),
}
# ── Binance fetch ─────────────────────────────────────────────────────────────
async def _fetch_klines(client: httpx.AsyncClient, symbol: str, limit: int) -> list[list]:
url = f"{settings.binance_rest_url}/api/v3/klines"
try:
resp = await client.get(url, params={
"symbol": symbol, "interval": "5m", "limit": limit
}, timeout=15)
resp.raise_for_status()
return resp.json()
except Exception as exc:
logger.warning("Failed to fetch klines for %s: %s", symbol, exc)
return []
def _parse_candle(row: list) -> dict:
total_vol = float(row[5])
taker_buy = float(row[9])
return {
"time": row[0],
"open": float(row[1]),
"high": float(row[2]),
"low": float(row[3]),
"close": float(row[4]),
"volume": total_vol,
"tbr": taker_buy / total_vol if total_vol > 0 else 0.5,
}
# ── Indicators ────────────────────────────────────────────────────────────────
def _compute_signal(candles: list[dict]) -> Optional[dict]:
"""
Returns signal dict if breakout signal fires on the most recent candle,
else None. Requires at least 60 candles.
"""
# Drop the last (current incomplete) candle — always use completed candles
candles = candles[:-1]
if len(candles) < 60:
return None
closes = [c["close"] for c in candles]
volumes = [c["volume"] for c in candles]
tbrs = [c["tbr"] for c in candles]
# ── Bollinger Bands (last BB_PERIOD candles) ──────────────────────────────
window = closes[-BB_PERIOD:]
bb_mid = sum(window) / BB_PERIOD
variance = sum((x - bb_mid) ** 2 for x in window) / BB_PERIOD
bb_std = variance ** 0.5
bb_upper = bb_mid + 2 * bb_std
bb_width = (4 * bb_std) / bb_mid if bb_mid > 0 else 0
# BB width percentile over last 60 candles
widths = []
for i in range(len(candles) - 60, len(candles)):
w_window = closes[max(0, i - BB_PERIOD + 1): i + 1]
if len(w_window) < BB_PERIOD:
continue
m = sum(w_window) / len(w_window)
s = (sum((x - m) ** 2 for x in w_window) / len(w_window)) ** 0.5
widths.append((4 * s) / m if m > 0 else 0)
if not widths:
return None
rank = sum(1 for w in widths if w < bb_width) / len(widths) * 100
in_squeeze = rank < BB_SQUEEZE_PCT
# ── Volume ────────────────────────────────────────────────────────────────
vol_ma = sum(volumes[-BB_PERIOD - 1:-1]) / BB_PERIOD
vol_spike = volumes[-1] > VOLUME_MULT * vol_ma if vol_ma > 0 else False
# ── Taker buy ratio ───────────────────────────────────────────────────────
tbr_ok = tbrs[-1] > TBR_THRESH
# ── Price above upper BB ──────────────────────────────────────────────────
above_bb = closes[-1] > bb_upper
if in_squeeze and vol_spike and tbr_ok and above_bb:
return {
"bb_width_pct": round(rank, 1),
"vol_mult": round(volumes[-1] / vol_ma, 2) if vol_ma > 0 else 0,
"tbr": round(tbrs[-1], 3),
"close": closes[-1],
"bb_upper": round(bb_upper, 4),
}
return None
def _btc_trend(candles: list[dict]) -> Optional[bool]:
"""True = BTC above 24h MA, False = below, None = not enough data."""
if len(candles) < BTC_TREND_MA:
return None
closes = [c["close"] for c in candles]
ma = sum(closes[-BTC_TREND_MA:]) / BTC_TREND_MA
return closes[-1] > ma
# ── Main poll loop ────────────────────────────────────────────────────────────
async def poll_funding_signal() -> None:
"""Called by APScheduler every 5 minutes."""
from app.ws.manager import manager # avoid circular import at module load
async with httpx.AsyncClient(timeout=20) as client:
# Fetch BTC with longer history for 24h trend
btc_rows = await _fetch_klines(client, CONTEXT_SYMBOL, BTC_TREND_MA + 10)
btc_candles = [_parse_candle(r) for r in btc_rows]
btc_up = _btc_trend(btc_candles)
# Fetch ETH + LINK
for symbol in WATCH_SYMBOLS:
rows = await _fetch_klines(client, symbol, CANDLE_LIMIT)
if not rows:
continue
candles = [_parse_candle(r) for r in rows]
sig = _compute_signal(candles)
if sig is None:
continue
# Deduplicate: don't fire same symbol twice within 30 minutes
now = datetime.now(timezone.utc)
last = _last_fired.get(symbol)
if last and (now - last).total_seconds() < 1800:
logger.info("Signal for %s deduplicated (too soon)", symbol)
continue
_last_fired[symbol] = now
alert = {
"type": "funding_signal",
"symbol": symbol,
"time": now.isoformat(),
"close": sig["close"],
"tbr": sig["tbr"],
"vol_mult": sig["vol_mult"],
"bb_pct": sig["bb_width_pct"],
"bb_upper": sig["bb_upper"],
"btc_trend": "↑ uptrend" if btc_up else ("↓ downtrend" if btc_up is False else "unknown"),
"enabled": _enabled,
}
_recent_signals.append(alert)
if _enabled:
logger.info("🚨 SIGNAL %s | price=%.4f tbr=%.2f vol=%.1fx btc=%s",
symbol, sig["close"], sig["tbr"], sig["vol_mult"],
alert["btc_trend"])
await manager.broadcast(alert)
else:
logger.info("Signal %s (monitor OFF — not broadcast)", symbol)
+74
View File
@@ -301,3 +301,77 @@ class HyperliquidTrader:
fill_price = float(filled_info.get("avgPx", mid) or mid) fill_price = float(filled_info.get("avgPx", mid) or mid)
logger.info("Closed %s position @ %.2f (size=%.5f)", coin, fill_price, total_sz) logger.info("Closed %s position @ %.2f (size=%.5f)", coin, fill_price, total_sz)
return {"fill_price": fill_price, "already_closed": False} return {"fill_price": fill_price, "already_closed": False}
async def reduce_position(self, asset: str, fraction: float) -> dict:
"""Partially close `fraction` (0<f<1) of the CURRENT open position
for `asset` with a reduce-only IOC order. Used by System-2 staged
de-risk. Returns {"fill_price": float, "closed_fraction": float,
"already_closed": bool}.
`closed_fraction` is the fraction of the position that was actually
closed (filled_size / pre-existing size) so the caller's PnL +
remaining bookkeeping stays exact even on partial fills.
"""
coin = asset.upper()
f = max(0.0, min(1.0, float(fraction)))
if f <= 0.0:
return {"fill_price": None, "closed_fraction": 0.0, "already_closed": False}
if f >= 1.0:
r = await self.close_position(asset)
r["closed_fraction"] = 0.0 if r.get("already_closed") else 1.0
return r
positions = await self.get_open_positions()
target = next((p for p in positions if p.get("coin") == coin), None)
if target is None:
logger.warning("reduce_position: no open %s on HL — already closed?", coin)
return {"fill_price": None, "closed_fraction": 0.0, "already_closed": True}
szi = float(target["szi"])
is_buy = szi < 0 # reducing a short → buy back
pre_size = abs(szi)
sz_dec = await self._get_sz_decimals(coin)
size = round(pre_size * f, sz_dec)
if size <= 0:
# Fraction rounds to nothing at this asset's size precision —
# treat as a no-op so the caller can advance the step without
# an erroneous PnL slice.
logger.warning("reduce_position: %s fraction %.3f rounds to 0 size (pre=%.6f)",
coin, f, pre_size)
return {"fill_price": None, "closed_fraction": 0.0, "already_closed": False}
mid = await self._mid_price(coin)
slippage = 0.01
raw_px = mid * (1 + slippage) if is_buy else mid * (1 - slippage)
if raw_px >= 10000:
limit_px = float(round(raw_px))
elif raw_px >= 1000:
limit_px = round(raw_px, 1)
elif raw_px >= 100:
limit_px = round(raw_px, 2)
else:
limit_px = round(raw_px, 3)
logger.info("Reducing %s by %.0f%%: size=%.6f @ limit %.2f (pre=%.6f)",
coin, f * 100, size, limit_px, pre_size)
result = await self._run(
self._exchange.order,
coin, is_buy, size, limit_px,
{"limit": {"tif": "Ioc"}},
reduce_only=True,
)
statuses = result.get("response", {}).get("data", {}).get("statuses", [{}])
status = statuses[0] if statuses else {}
if "error" in status:
raise ValueError(f"HL reduce rejected: {status['error']}")
filled_info = status.get("filled") or {}
total_sz = float(filled_info.get("totalSz", 0) or 0)
if total_sz <= 0:
raise ValueError(f"reduce_position {coin}: IOC returned 0 fill")
fill_price = float(filled_info.get("avgPx", mid) or mid)
closed_fraction = (total_sz / pre_size) if pre_size > 0 else 0.0
logger.info("Reduced %s: closed %.6f / %.6f (%.1f%%) @ %.2f",
coin, total_sz, pre_size, closed_fraction * 100, fill_price)
return {"fill_price": fill_price, "closed_fraction": closed_fraction,
"already_closed": False}
+223
View File
@@ -0,0 +1,223 @@
"""KOL post → structured signal extractor.
Takes a long-form post (Substack essay) or tweet and returns:
- summary: one Chinese sentence on what this post is about
- tickers: list of {ticker, action, conviction, quote}
action ∈ bullish | bearish | buy | sell | mention
- buy/sell → KOL explicitly states they bought/sold or are entering/exiting
- bullish/bearish → directional view without an explicit position statement
- mention → ticker appears but no clear stance (don't flood with these)
conviction ∈ 0.01.0
- 0.8+ : explicit, repeated, with sizing / timing
- 0.50.7 : clear view, no commitment
- <0.5 : passing reference
Quote is the shortest verbatim sentence supporting the call.
Uses the same Anthropic client style as analysis.py. Designed to be reused
by the Trump-post AI signal module (TODO #4) — same JSON shape, just with
different KOL context strings.
"""
from __future__ import annotations
import json
import logging
from datetime import datetime, timezone
from typing import Optional
from openai import AsyncOpenAI
from app.config import settings
logger = logging.getLogger(__name__)
ANALYSIS_VERSION = "kol-v1"
ANTHROPIC_MODEL = "claude-haiku-4-5-20251001"
_anthropic_client = None
_openai_client: Optional[AsyncOpenAI] = None
def _use_anthropic() -> bool:
return bool(settings.anthropic_api_key)
def _anth():
global _anthropic_client
if _anthropic_client is None:
import anthropic as _a
_anthropic_client = _a.AsyncAnthropic(api_key=settings.anthropic_api_key)
return _anthropic_client
def _oai() -> AsyncOpenAI:
global _openai_client
if _openai_client is None:
_openai_client = AsyncOpenAI(
api_key=settings.ai_api_key,
base_url=settings.ai_base_url,
)
return _openai_client
SYSTEM_PROMPT = """You are an analyst extracting tradeable signals from crypto KOL posts.
The author is a known crypto KOL. Your job: distill what they said and which tokens they are talking about RIGHT NOW (not historical references).
Output **strict JSON only**, no markdown, no preface. Schema:
{
"summary": "<one sentence, ≤60 chars/字. If signal exists, state the author's current thesis. If no signal, describe the post topic. Match the post's primary language (中文文章用中文, English 用英文).>",
"tickers": [
{
"ticker": "<UPPERCASE symbol, e.g. BTC, ETH, HYPE, SOL>",
"action": "buy" | "sell" | "bullish" | "bearish" | "mention",
"conviction": <float 0.0-1.0>,
"quote": "<shortest verbatim sentence from the post supporting this call, ≤200 chars. Use the post's original language — do not translate.>"
}
]
}
Rules:
- If the post is macro commentary, news recap, or sponsored content with no specific token call, return tickers=[] and summary describing the topic.
- IGNORE historical price references ("BTC bottomed at $60k earlier this year") — these are context, not current calls.
- IGNORE advertising/sponsor sections — look for cues: "sponsor", "partner", "use code", "promo code", "this episode brought to you by", "ad", "广告", "赞助". Skip any ticker only mentioned inside such a section.
- buy/sell only when the author states a position action ("I bought", "we are long", "我们减仓了", "added to my bag"). Otherwise use bullish/bearish for directional views, or mention for passing references.
- Dedupe per ticker — at most one entry per symbol; pick the strongest action.
- Do NOT invent tickers. If you see "$XYZ" but unsure it's a real token, skip it.
- conviction: 0.8+ requires explicit + repeated + sized/timed view; 0.5-0.7 for clear directional view without commitment; <0.5 for passing references.
- Do not include fiat (USD/CNY/JPY) or stablecoins (USDT/USDC/DAI/FRAX) unless the post's main thesis is about them.
"""
USER_TEMPLATE = """Today is {today_utc}.
KOL handle: {handle}
Source: {source}
Title: {title}
Post body:
\"\"\"
{body}
\"\"\"
"""
def _truncate(text: str, max_chars: int = 24000) -> str:
"""Substack essays can be 50K+ chars. Haiku handles it but we cap to
control cost. Keep head + tail since conclusions often appear at the end."""
if len(text) <= max_chars:
return text
head = max_chars * 2 // 3
tail = max_chars - head
return text[:head] + "\n\n[...trimmed...]\n\n" + text[-tail:]
def _parse_json(raw: str) -> dict:
raw = raw.strip()
if raw.startswith("```"):
# strip fenced code block
raw = raw.split("\n", 1)[1] if "\n" in raw else raw
if raw.endswith("```"):
raw = raw.rsplit("```", 1)[0]
raw = raw.strip()
# Some models prepend "json" after the fence
if raw.startswith("json"):
raw = raw[4:].strip()
return json.loads(raw)
async def extract_kol_signal(
*,
handle: str,
source: str,
title: Optional[str],
body: str,
model: Optional[str] = None,
) -> dict:
"""Run the extractor. Returns {summary, tickers, model, version}.
Returns an empty-but-valid dict on parse/API failure rather than raising —
the caller stores the post regardless; an unanalyzed post can be retried.
"""
today_utc = datetime.now(timezone.utc).strftime("%Y-%m-%d")
user = USER_TEMPLATE.format(
today_utc=today_utc,
handle=handle,
source=source,
title=title or "",
body=_truncate(body),
)
use_anth = _use_anthropic()
if model is None:
# KOL analysis is a daily batch job, not latency-sensitive. Use the
# higher-quality `ai_model` (DeepSeek v4 Pro / reasoning) rather than
# the live `ai_live_model` (flash) reserved for Trump real-time path.
model = ANTHROPIC_MODEL if use_anth else settings.ai_model
try:
if use_anth:
msg = await _anth().messages.create(
model=model,
max_tokens=1500,
temperature=0.1,
system=SYSTEM_PROMPT,
messages=[{"role": "user", "content": user}],
)
raw = (msg.content[0].text if msg.content else "").strip()
else:
# OpenAI-compatible (DeepSeek). Reasoning models need higher tokens
# + no temperature; flash/chat models are fine with both.
is_reasoning = any(x in model for x in ("pro", "reasoner", "r1", "think"))
kwargs = {"model": model, "messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user},
], "max_tokens": 4000 if is_reasoning else 1500}
if not is_reasoning:
kwargs["temperature"] = 0.1
# JSON mode — DeepSeek + OpenAI both support response_format.
# Eliminates fenced/preface parse failures. Skipped for reasoning
# models (some don't accept response_format alongside reasoning).
if not is_reasoning:
kwargs["response_format"] = {"type": "json_object"}
resp = await _oai().chat.completions.create(**kwargs)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json(raw)
except Exception as e:
logger.warning("kol_analysis extract failed for %s: %s", handle, e)
return {"summary": None, "tickers": [], "model": model,
"version": ANALYSIS_VERSION, "error": str(e)}
# Normalize
tickers = data.get("tickers") or []
cleaned = []
for t in tickers:
if not isinstance(t, dict):
continue
sym = (t.get("ticker") or "").strip().upper()
if not sym or len(sym) > 12:
continue
action = (t.get("action") or "mention").lower()
if action not in {"buy", "sell", "bullish", "bearish", "mention"}:
action = "mention"
try:
conv = float(t.get("conviction") or 0)
except (TypeError, ValueError):
conv = 0.0
conv = max(0.0, min(1.0, conv))
cleaned.append({
"ticker": sym,
"action": action,
"conviction": round(conv, 2),
"quote": (t.get("quote") or "")[:200],
})
return {
"summary": (data.get("summary") or "").strip() or None,
"tickers": cleaned,
"model": model,
"version": ANALYSIS_VERSION,
}
+285
View File
@@ -0,0 +1,285 @@
"""KOL talks-vs-trades cross-signal detector.
Compares B-tier content signals (Substack / Twitter post → ticker action)
against A-tier on-chain changes (wallet holding changes) for the same
KOL handle + ticker within a ±N-day window.
Two outcomes:
divergence — KOL says bullish but on-chain is selling (or vice versa).
On-chain action is the ground truth; word is noise/manipulation.
Conclusion = opposite of what was said.
alignment — Post and chain agree. Conviction is reinforced.
Conclusion = what was said (and done).
Logic:
post side → action ∈ {buy, bullish} = LONG intent
{sell, bearish} = SHORT intent
{mention} = skip (no clear view)
chain side → change_type ∈ {new_position, increased} = LONG action
{decreased, closed} = SHORT action
LONG intent + SHORT action → divergence, direction='short'
SHORT intent + LONG action → divergence, direction='long'
LONG intent + LONG action → alignment, direction='long'
SHORT intent + SHORT action → alignment, direction='short'
Run cadence: after each onchain poll (02:05 UTC). Also callable manually.
Idempotent: UniqueConstraint(post_id, change_id) prevents double-writes.
"""
from __future__ import annotations
import hashlib
import json
import logging
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import AsyncSessionLocal
from app.models import KolDivergence, KolHoldingChange, KolPost, KolWallet, Post, utcnow
logger = logging.getLogger(__name__)
# Match window: post and chain event must be within this many days of each other
WINDOW_DAYS = 7
# Only surface on-chain changes above this threshold (avoid noise from dust)
MIN_USD_CHANGE = 10_000
# Post actions that map to a directional view (skip 'mention')
_POST_LONG = {"buy", "bullish"}
_POST_SHORT = {"sell", "bearish"}
# On-chain actions that map to a direction
_CHAIN_LONG = {"new_position", "increased"}
_CHAIN_SHORT = {"decreased", "closed"}
# ── Telegram alert gating ────────────────────────────────────────────────────
# Only push divergences (the surprising "they say one thing, do another" case).
# Alignments are confirmations, not unique alpha — skip to keep volume sane.
ALERT_ON_TYPES = {"divergence"}
# Floor on the post's stated conviction. Without this, every "mention"-like
# soft view that happens to coincide with a buy/sell would page users.
ALERT_MIN_POST_CONVICTION = 0.5
# Minimum USD on the on-chain side to bother alerting. The 10K floor in
# MIN_USD_CHANGE keeps dust out of the table; this stricter floor avoids
# alerting on small rebalances by big wallets.
ALERT_MIN_USD = 50_000
def _post_for_divergence(handle: str, ticker: str, direction: str,
post_action: str, chain_action: str,
conviction: float, change_id: int,
usd_after: Optional[float],
days_apart: float) -> Post:
"""Build a Post row carrying a KOL divergence as an actionable signal.
`direction` is the CONCLUSION direction from _classify: 'long' → buy,
'short' → short. ai_confidence is derived from post conviction so the
user's min_confidence floor in TelegramBinding gates noise.
"""
signal = "buy" if direction == "long" else "short"
usd_str = f"${(usd_after or 0)/1000:.0f}K" if usd_after else "?"
text = (
f"KOL {handle} says {post_action.upper()} {ticker}"
f"but on-chain shows {chain_action} ({usd_str}, Δ{days_apart:.1f}d). "
f"Following the chain (the trade, not the talk): {signal.upper()} {ticker}."
)
# external_id must be unique-per-source. Use the change_id as the dedupe
# key — at most one Post per (kol, ticker, chain-event).
ext = hashlib.md5(f"kol_divergence:{handle}:{ticker}:{change_id}".encode()).hexdigest()
return Post(
external_id=ext,
text=text,
source="kol_divergence",
published_at=datetime.now(timezone.utc).replace(tzinfo=None),
sentiment="bullish" if signal == "buy" else "bearish",
ai_confidence=int(round(conviction * 100)),
relevant=True,
signal=signal,
target_asset=ticker,
category=f"kol_divergence_{signal}",
analysis_version="kol_divergence_v1",
prefilter_reason="kol_divergence",
)
def _classify(post_action: str, chain_action: str) -> Optional[tuple[str, str]]:
"""Returns (signal_type, direction) or None if no meaningful pair."""
if post_action in _POST_LONG:
if chain_action in _CHAIN_LONG:
return "alignment", "long"
if chain_action in _CHAIN_SHORT:
return "divergence", "short"
elif post_action in _POST_SHORT:
if chain_action in _CHAIN_LONG:
return "divergence", "long"
if chain_action in _CHAIN_SHORT:
return "alignment", "short"
return None
async def run_divergence_scan(
lookback_days: int = 30,
) -> list[dict]:
"""Scan recent posts × on-chain changes, write new KolDivergence rows.
Returns list of newly written rows as dicts (for logging / API return).
Already-stored pairs are silently skipped (idempotent).
"""
since = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=lookback_days)
results: list[dict] = []
# Posts we created this scan — notify_signal runs AFTER commit so the
# dispatcher's separate DB session can read the row.
alert_post_ids: list[int] = []
async with AsyncSessionLocal() as session:
# ── 1. Load recent posts that have directional ticker signals ──────
posts = (await session.execute(
select(KolPost)
.where(KolPost.published_at >= since)
.where(KolPost.tickers_json.is_not(None))
)).scalars().all()
# ── 2. Load recent on-chain changes (keyed by handle via wallet) ───
changes_rows = (await session.execute(
select(KolHoldingChange, KolWallet.handle)
.join(KolWallet, KolHoldingChange.wallet_id == KolWallet.id)
.where(KolHoldingChange.detected_at >= since)
)).all()
# Pre-load all existing (post_id, change_id) pairs so we can skip
# duplicates client-side instead of relying on a UniqueConstraint
# violation. Catching the violation would force a session rollback
# that wipes ALL pending writes (not just the offending one) — a
# subtle data-loss bug. Pre-check is cheap (the table stays small).
existing_pairs: set[tuple[int, int]] = {
(pid, cid)
for (pid, cid) in (await session.execute(
select(KolDivergence.post_id, KolDivergence.change_id)
)).all()
}
# Index changes by (handle, ticker) → list[KolHoldingChange]
chain_index: dict[tuple[str, str], list[tuple[KolHoldingChange, str]]] = defaultdict(list)
for change, handle in changes_rows:
# Skip dust moves
usd_delta = abs((change.usd_after or 0) - (change.usd_before or 0))
if usd_delta < MIN_USD_CHANGE and (change.usd_after or 0) < MIN_USD_CHANGE:
continue
chain_index[(handle, change.ticker.upper())].append((change, handle))
# ── 3. Match posts → changes ───────────────────────────────────────
for post in posts:
try:
tickers = json.loads(post.tickers_json or "[]")
except json.JSONDecodeError:
continue
for t in tickers:
post_action = (t.get("action") or "").lower()
if post_action not in (_POST_LONG | _POST_SHORT):
continue # skip 'mention'
ticker = (t.get("ticker") or "").upper()
if not ticker:
continue
candidates = chain_index.get((post.kol_handle, ticker), [])
for change, handle in candidates:
# Time-window check
post_dt = post.published_at
change_dt = change.detected_at
days_apart = abs((change_dt - post_dt).total_seconds()) / 86400
if days_apart > WINDOW_DAYS:
continue
result = _classify(post_action, change.change_type)
if result is None:
continue
signal_type, direction = result
# Idempotency: skip if (post_id, change_id) already stored
pair_key = (post.id, change.id)
if pair_key in existing_pairs:
continue
existing_pairs.add(pair_key)
conviction = float(t.get("conviction") or 0)
row = KolDivergence(
handle = post.kol_handle,
ticker = ticker,
post_id = post.id,
post_action = post_action,
post_conviction = conviction,
post_at = post_dt,
change_id = change.id,
onchain_action = change.change_type,
usd_before = change.usd_before,
usd_after = change.usd_after,
onchain_at = change_dt,
signal_type = signal_type,
direction = direction,
days_apart = round(days_apart, 2),
)
session.add(row)
# Emit a Post for Telegram fan-out — but only for the
# interesting case (divergences) with enough conviction
# and chain-size to be worth a push. See ALERT_* tunables.
if (signal_type in ALERT_ON_TYPES
and conviction >= ALERT_MIN_POST_CONVICTION
and (change.usd_after or 0) >= ALERT_MIN_USD):
alert_post = _post_for_divergence(
handle=post.kol_handle, ticker=ticker,
direction=direction, post_action=post_action,
chain_action=change.change_type,
conviction=conviction, change_id=change.id,
usd_after=change.usd_after, days_apart=days_apart,
)
session.add(alert_post)
await session.flush() # populate alert_post.id
alert_post_ids.append(alert_post.id)
info = {
"handle": post.kol_handle,
"ticker": ticker,
"signal_type": signal_type,
"direction": direction,
"post_action": post_action,
"onchain_action": change.change_type,
"days_apart": round(days_apart, 2),
"usd_after": change.usd_after,
}
results.append(info)
emoji = "⚠️" if signal_type == "divergence" else ""
logger.info(
"[kol_divergence] %s %s %s: says %s, onchain %s%s (%s) Δ%.1fd",
emoji, post.kol_handle, ticker,
post_action, change.change_type,
signal_type, direction, days_apart,
)
await session.commit()
# Fire Telegram fan-out AFTER commit so _dispatch's own session can
# actually see the rows. _dispatch only needs the post_id, so we skip
# notify_signal's Post-object wrapper and schedule it directly.
if alert_post_ids:
try:
import asyncio
from app.services.telegram import _dispatch
for pid in alert_post_ids:
asyncio.create_task(_dispatch(pid))
except Exception as exc:
logger.warning("[kol_divergence] notify_signal failed: %s", exc)
logger.info("[kol_divergence] scan done: %d new pairs written, %d alerts queued",
len(results), len(alert_post_ids))
return results
+531
View File
@@ -0,0 +1,531 @@
"""KOL A-tier: daily on-chain holdings snapshot + change detection.
Data sources (all free):
Hyperliquid public API No key. Perp positions + mark prices for any
HL-listed asset (BTC/ETH/SOL/HYPE/...).
Endpoint: POST /info {"type":"clearinghouseState"}
Prices: POST /info {"type":"allMids"}
Etherscan API Free key (etherscan.io/register, email only).
ERC-20 token balances for any Ethereum address.
Env: ETHERSCAN_API_KEY. Skip ETH wallets if unset.
CoinGecko (no key) Fallback price for tokens not listed on HL.
Free tier: 30 req/min, batch by contract address.
Pricing priority per token:
1. HL allMids (by symbol, e.g. "ETH" $2025)
2. CoinGecko by contract address (Ethereum only)
3. Skip (price unknown usd_value=0, excluded from snapshot)
Daily flow (run_onchain_poll at 02:00 UTC):
For each HL wallet fetch HL positions snapshot diff
For each ETH wallet fetch Etherscan ERC-20 list price each token snapshot diff
Diffs with usd_after > $50k (new) or ±25% change written to kol_holding_changes.
"""
from __future__ import annotations
import asyncio
import json
import logging
import time
from datetime import datetime, timezone
from typing import Optional
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import AsyncSessionLocal
from app.models import KolHoldingChange, KolHoldingSnapshot, KolWallet, utcnow
logger = logging.getLogger(__name__)
HL_API_URL = "https://api.hyperliquid.xyz/info"
ETHERSCAN_API_URL = "https://api.etherscan.io/v2/api"
COINGECKO_URL = "https://api.coingecko.com/api/v3"
# Stablecoins — ignored in all snapshots
STABLES = {"USDC", "USDT", "DAI", "BUSD", "TUSD", "USDE", "FRAX", "LUSD", "CRVUSD"}
# Change detection thresholds
_NEW_POSITION_MIN_USD = 50_000 # new token > $50k → alert
_CHANGE_PCT_THRESHOLD = 25.0 # ±25% USD move → alert
_CLOSED_MIN_USD = 10_000 # closed position must have been > $10k to report
# ── Price layer ───────────────────────────────────────────────────────────────
_hl_prices: dict[str, float] = {} # symbol → USD, refreshed each poll
_hl_prices_fetched_at: float = 0.0
async def _get_hl_prices() -> dict[str, float]:
"""Fetch all HL mark prices (free, no auth). Cached for 5 min per poll cycle.
Returns empty dict on network failure callers fall back to CoinGecko."""
global _hl_prices, _hl_prices_fetched_at
now = time.time()
if now - _hl_prices_fetched_at < 300 and _hl_prices:
return _hl_prices
try:
async with httpx.AsyncClient(timeout=10.0) as c:
r = await c.post(HL_API_URL, json={"type": "allMids"})
r.raise_for_status()
raw = r.json() # {"BTC": "74541.0", "ETH": "2025.0", ...}
_hl_prices = {sym: float(px) for sym, px in raw.items()}
_hl_prices_fetched_at = now
logger.info("[kol_onchain] HL prices loaded: %d assets", len(_hl_prices))
except Exception as e:
logger.warning("[kol_onchain] HL price fetch failed (%s) — falling back to CoinGecko", e)
# Don't overwrite a previous good cache on transient failure
if not _hl_prices:
_hl_prices = {}
return _hl_prices
# CoinGecko symbol→id map for common crypto assets (fallback when HL unavailable)
_CG_SYMBOL_IDS = {
"BTC": "bitcoin", "ETH": "ethereum", "SOL": "solana",
"BNB": "binancecoin", "AVAX": "avalanche-2", "MATIC": "matic-network",
"ARB": "arbitrum", "OP": "optimism", "LINK": "chainlink",
"UNI": "uniswap", "AAVE": "aave", "MKR": "maker",
"HYPE": "hyperliquid", "ENA": "ethena", "PENDLE": "pendle",
"WLD": "worldcoin-wld", "JUP": "jupiter-exchange-solana",
"WBTC": "wrapped-bitcoin", "STETH": "staked-ether",
"EETH": "ether-fi-staked-eth", "WEETH": "wrapped-eeth",
}
async def _coingecko_prices_by_symbol(symbols: list[str]) -> dict[str, float]:
"""Batch price lookup by CoinGecko coin ID. No key needed."""
ids_needed = {s: _CG_SYMBOL_IDS[s] for s in symbols if s in _CG_SYMBOL_IDS}
if not ids_needed:
return {}
try:
async with httpx.AsyncClient(timeout=15.0) as c:
r = await c.get(f"{COINGECKO_URL}/simple/price", params={
"ids": ",".join(ids_needed.values()),
"vs_currencies": "usd",
})
if r.status_code != 200:
return {}
data = r.json()
# Invert: coin_id → price, then map back to symbol
id_to_price = {v: data.get(v, {}).get("usd", 0) for v in ids_needed.values()}
return {sym: id_to_price[cid] for sym, cid in ids_needed.items() if id_to_price.get(cid)}
except Exception as e:
logger.warning("[kol_onchain] CoinGecko symbol lookup failed: %s", e)
return {}
async def _coingecko_prices_by_contract(
contract_addresses: list[str],
) -> dict[str, float]:
"""Batch price lookup by Ethereum contract address. No key needed.
Chunks into batches of 25 (CoinGecko free tier limit).
Returns {contract_addr_lower: usd_price}."""
if not contract_addresses:
return {}
result: dict[str, float] = {}
addrs_lower = [a.lower() for a in contract_addresses[:100]]
# CoinGecko free tier caps at ~25 addresses per request
chunk_size = 25
async with httpx.AsyncClient(timeout=15.0) as c:
for i in range(0, len(addrs_lower), chunk_size):
chunk = addrs_lower[i:i + chunk_size]
try:
r = await c.get(
f"{COINGECKO_URL}/simple/token_price/ethereum",
params={"contract_addresses": ",".join(chunk), "vs_currencies": "usd"},
)
if r.status_code != 200:
logger.warning("[kol_onchain] CoinGecko %s: %s", r.status_code, r.text[:80])
continue
data = r.json()
result.update({addr: info["usd"] for addr, info in data.items() if "usd" in info})
except Exception as e:
logger.warning("[kol_onchain] CoinGecko chunk failed: %s", e)
return result
# ── Hyperliquid: perp positions ───────────────────────────────────────────────
async def _fetch_hl_positions(address: str) -> tuple[list[dict], float]:
"""Query HL clearinghouseState. Returns (holdings, account_value_usd).
holdings: [{ticker, amount, usd_value, chain, side}]
"""
async with httpx.AsyncClient(timeout=15.0) as c:
r = await c.post(HL_API_URL, json={"type": "clearinghouseState", "user": address})
r.raise_for_status()
data = r.json()
prices = await _get_hl_prices()
holdings = []
for pos in data.get("assetPositions", []):
p = pos.get("position", {})
coin = p.get("coin", "")
if not coin:
continue
size = float(p.get("szi", 0))
if abs(size) < 1e-8:
continue
price = prices.get(coin, float(p.get("entryPx") or 0))
notional = abs(size) * price
if notional < 1:
continue
holdings.append({
"ticker": coin,
"amount": abs(size),
"usd_value": round(notional, 2),
"chain": "hl",
"side": "long" if size > 0 else "short",
})
margin = data.get("marginSummary", {})
account_value = float(margin.get("accountValue", 0))
return holdings, account_value
# ── Etherscan: ERC-20 balances ────────────────────────────────────────────────
async def _fetch_eth_holdings(address: str) -> tuple[list[dict], float]:
"""Fetch ERC-20 + ETH holdings via Etherscan free tier.
Free-tier strategy (no Pro needed):
Step 1: GET account/balance native ETH amount
Step 2: GET account/tokentx (recent) discover which ERC-20 contracts
the wallet has interacted with
Step 3: GET account/tokenbalance current balance per contract
Step 4: Price via HL allMids, then CoinGecko by contract address fallback
Etherscan free limit: 5 req/s, ~100k req/day well within budget for
daily snapshots of a handful of KOL wallets.
"""
if not settings.etherscan_api_key:
return [], 0.0
prices = await _get_hl_prices()
key = settings.etherscan_api_key
async with httpx.AsyncClient(timeout=20.0) as c:
# ── Step 1: ETH native balance ───────────────────────────────────
eth_r = await c.get(ETHERSCAN_API_URL, params={
"chainid": "1", "module": "account", "action": "balance",
"address": address, "tag": "latest", "apikey": key,
})
eth_data = eth_r.json()
# ── Step 2: ERC-20 tx history → unique (contract, symbol, decimals) ─
# offset=200 is enough to cover all tokens a KOL holds. Vitalik-scale
# addresses with 100k+ txs can still timeout — real KOL wallets are far smaller.
tx_r = await c.get(ETHERSCAN_API_URL, params={
"chainid": "1", "module": "account", "action": "tokentx",
"address": address, "page": "1", "offset": "200",
"sort": "desc", "apikey": key,
}, timeout=30.0)
tx_data = tx_r.json()
# Collect unique contracts from tx history
seen: dict[str, dict] = {} # contract → {symbol, decimals}
txs = tx_data.get("result") or []
if isinstance(txs, list):
for tx in txs:
contract = (tx.get("contractAddress") or "").lower()
if not contract or contract in seen:
continue
symbol = (tx.get("tokenSymbol") or "").upper()
if not symbol or symbol in STABLES:
continue
try:
decimals = int(tx.get("tokenDecimal") or 18)
except ValueError:
decimals = 18
seen[contract] = {"symbol": symbol, "decimals": decimals}
# ── Step 3: current balance per contract ─────────────────────────────
holdings: list[dict] = []
unpriced: list[str] = [] # contracts needing CoinGecko
contract_meta: dict[str, dict] = {}
# Rate-limit: Etherscan free = 5 req/s. Sleep 0.22s between calls to stay
# under burst limit even when looping 80 tokens × 4 wallets in one poll.
async with httpx.AsyncClient(timeout=20.0) as c:
for contract, meta in list(seen.items())[:80]: # cap at 80 tokens
await asyncio.sleep(0.22)
bal_r = await c.get(ETHERSCAN_API_URL, params={
"chainid": "1", "module": "account", "action": "tokenbalance",
"contractaddress": contract, "address": address,
"tag": "latest", "apikey": key,
})
bal_data = bal_r.json()
if bal_data.get("message") != "OK":
continue
raw = int(bal_data.get("result") or 0)
if raw == 0:
continue
balance = raw / (10 ** meta["decimals"])
symbol = meta["symbol"]
hl_price = prices.get(symbol)
if hl_price and hl_price > 0:
usd_value = round(balance * hl_price, 2)
if usd_value >= 500:
holdings.append({
"ticker": symbol, "amount": round(balance, 6),
"usd_value": usd_value, "chain": "ethereum",
"contract": contract,
})
else:
unpriced.append(contract)
contract_meta[contract] = {
"ticker": symbol, "amount": round(balance, 6),
"chain": "ethereum", "contract": contract,
}
# ── Step 4a: ETH native ──────────────────────────────────────────────
eth_raw = int(eth_data.get("result") or 0)
eth_balance = eth_raw / 1e18
if eth_balance > 0.001:
eth_price = prices.get("ETH", 0.0)
# Fallback: CoinGecko by symbol if HL unavailable
if not eth_price:
cg_sym = await _coingecko_prices_by_symbol(["ETH"])
eth_price = cg_sym.get("ETH", 0.0)
eth_usd = round(eth_balance * eth_price, 2)
if eth_usd >= 100:
holdings.append({
"ticker": "ETH", "amount": round(eth_balance, 6),
"usd_value": eth_usd, "chain": "ethereum",
})
# ── Step 4b: CoinGecko fallback for unpriced tokens ──────────────────
if unpriced:
try:
cg_prices = await _coingecko_prices_by_contract(unpriced)
for contract, price in cg_prices.items():
meta = contract_meta.get(contract)
if not meta or price <= 0:
continue
usd_value = round(meta["amount"] * price, 2)
if usd_value >= 500:
holdings.append({**meta, "usd_value": usd_value})
except Exception as e:
logger.warning("[kol_onchain] CoinGecko fallback failed: %s", e)
# Merge duplicate tickers (different contracts, same symbol) by summing USD value
merged: dict[str, dict] = {}
for h in holdings:
t = h["ticker"]
if t in merged:
merged[t]["usd_value"] = round(merged[t]["usd_value"] + h["usd_value"], 2)
merged[t]["amount"] = round(merged[t]["amount"] + h["amount"], 6)
else:
merged[t] = dict(h)
holdings = list(merged.values())
total_usd = round(sum(h["usd_value"] for h in holdings), 2)
logger.info("[kol_onchain] %s ETH holdings: %d tokens, $%.0f total",
address[:10], len(holdings), total_usd)
return holdings, total_usd
# ── Snapshot + diff ───────────────────────────────────────────────────────────
def _diff_holdings(
prev: list[dict], curr: list[dict], wallet_id: int,
) -> list[KolHoldingChange]:
# SUM by ticker — earlier snapshots may contain duplicate ticker rows
# (different contracts, same symbol e.g. two APE tokens). Dict comprehension
# would silently drop one and falsely flag +1900% diffs the next day.
def _sum_by_ticker(rows: list[dict]) -> dict[str, float]:
agg: dict[str, float] = {}
for h in rows:
t = h.get("ticker")
if not t:
continue
agg[t] = agg.get(t, 0.0) + float(h.get("usd_value") or 0)
return agg
prev_map = _sum_by_ticker(prev)
curr_map = _sum_by_ticker(curr)
now = utcnow()
changes = []
for ticker in set(prev_map) | set(curr_map):
before = prev_map.get(ticker, 0.0)
after = curr_map.get(ticker, 0.0)
if before == 0 and after >= _NEW_POSITION_MIN_USD:
changes.append(KolHoldingChange(
wallet_id=wallet_id, detected_at=now, ticker=ticker,
change_type="new_position", usd_before=0, usd_after=after,
))
elif after == 0 and before >= _CLOSED_MIN_USD:
changes.append(KolHoldingChange(
wallet_id=wallet_id, detected_at=now, ticker=ticker,
change_type="closed", usd_before=before, usd_after=0,
pct_change=-100.0,
))
elif before > 0 and after > 0:
pct = (after - before) / before * 100
if abs(pct) >= _CHANGE_PCT_THRESHOLD:
changes.append(KolHoldingChange(
wallet_id=wallet_id, detected_at=now, ticker=ticker,
change_type="increased" if pct > 0 else "decreased",
usd_before=before, usd_after=after, pct_change=round(pct, 1),
))
return changes
async def _snapshot_wallet(
session: AsyncSession,
wallet: KolWallet,
holdings: list[dict],
total_usd: float,
source: str,
) -> dict:
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
# Skip if already snapshotted today
existing = (await session.execute(
select(KolHoldingSnapshot).where(
KolHoldingSnapshot.wallet_id == wallet.id,
KolHoldingSnapshot.snapshot_date == today,
)
)).scalar_one_or_none()
if existing:
return {"wallet_id": wallet.id, "status": "already_snapshotted"}
# Previous snapshot for diff
prev_row = (await session.execute(
select(KolHoldingSnapshot)
.where(KolHoldingSnapshot.wallet_id == wallet.id)
.order_by(KolHoldingSnapshot.snapshot_date.desc())
.limit(1)
)).scalar_one_or_none()
prev_holdings = json.loads(prev_row.holdings_json) if prev_row else []
# Store snapshot (strip contract addresses to keep JSON lean)
clean_holdings = [{k: v for k, v in h.items() if k != "contract"} for h in holdings]
snap = KolHoldingSnapshot(
wallet_id=wallet.id,
snapshot_date=today,
holdings_json=json.dumps(clean_holdings, ensure_ascii=False),
total_usd=total_usd,
source=source,
)
session.add(snap)
# Detect and store changes
changes = _diff_holdings(prev_holdings, holdings, wallet.id)
for c in changes:
session.add(c)
logger.info(
"[kol_onchain] %s %s %s $%.0f→$%.0f",
wallet.handle, c.change_type, c.ticker,
c.usd_before or 0, c.usd_after or 0,
)
await session.flush()
return {
"handle": wallet.handle,
"chain": wallet.chain,
"holdings": len(holdings),
"total_usd": total_usd,
"changes": len(changes),
"source": source,
}
# ── Poll entry points ─────────────────────────────────────────────────────────
async def run_hl_poll() -> list[dict]:
"""Poll HL perp positions for all chain='hl' wallets."""
results = []
async with AsyncSessionLocal() as session:
wallets = (await session.execute(
select(KolWallet).where(KolWallet.chain == "hl", KolWallet.active == True)
)).scalars().all()
if not wallets:
logger.info("[kol_onchain] no HL wallets configured")
return []
for wallet in wallets:
try:
holdings, total = await _fetch_hl_positions(wallet.address)
stat = await _snapshot_wallet(session, wallet, holdings, total, "hl")
results.append(stat)
except Exception as e:
logger.warning("[kol_onchain] HL fetch failed %s: %s", wallet.handle, e)
results.append({"handle": wallet.handle, "error": str(e)})
await session.commit()
logger.info("[kol_onchain] HL poll done: %s", results)
return results
async def run_eth_poll() -> list[dict]:
"""Poll Ethereum ERC-20 holdings for all chain='ethereum' wallets.
No-op if ETHERSCAN_API_KEY not set."""
if not settings.etherscan_api_key:
logger.debug("[kol_onchain] ETHERSCAN_API_KEY not set — skipping ETH poll")
return []
results = []
async with AsyncSessionLocal() as session:
wallets = (await session.execute(
select(KolWallet).where(KolWallet.chain == "ethereum", KolWallet.active == True)
)).scalars().all()
if not wallets:
logger.info("[kol_onchain] no Ethereum wallets configured")
return []
# Pre-warm HL prices once for the whole batch
await _get_hl_prices()
for wallet in wallets:
try:
holdings, total = await _fetch_eth_holdings(wallet.address)
stat = await _snapshot_wallet(session, wallet, holdings, total, "etherscan")
results.append(stat)
except Exception as e:
logger.warning("[kol_onchain] ETH fetch failed %s: %s", wallet.handle, e)
results.append({"handle": wallet.handle, "error": str(e)})
await session.commit()
logger.info("[kol_onchain] ETH poll done: %s", results)
return results
async def run_onchain_poll() -> dict:
"""Combined entry point for APScheduler. Runs HL + ETH polls."""
hl = await run_hl_poll()
eth = await run_eth_poll()
return {"hl": hl, "eth": eth}
async def seed_wallets(entries: list[tuple]) -> int:
"""Bulk-insert (handle, chain, address, label, source_url) tuples, skip dupes."""
count = 0
async with AsyncSessionLocal() as session:
for handle, chain, address, label, source_url in entries:
existing = (await session.execute(
select(KolWallet).where(
KolWallet.chain == chain,
KolWallet.address == address.lower(),
)
)).scalar_one_or_none()
if existing:
continue
session.add(KolWallet(
handle=handle, chain=chain,
address=address.lower(),
label=label, source_url=source_url,
))
count += 1
await session.commit()
return count
+383
View File
@@ -0,0 +1,383 @@
"""KOL Substack RSS ingester.
Polls each tracked KOL's Substack feed, dedupes by URL, stores raw post,
then hands off to kol_analysis.extract_kol_signal and writes the result
back onto the same row.
Substack RSS embeds the full post HTML in <content:encoded>. We strip HTML
to plain text before storage + analysis. Hayes posts are typically 50K+
chars of body the extractor truncates internally.
Daily cadence is plenty (Hayes posts ~monthly, Substack updates feed within
minutes of publish). Call run_substack_poll() from the APScheduler.
"""
from __future__ import annotations
import hashlib
import logging
import re
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from typing import Iterable, Optional
import feedparser
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import AsyncSessionLocal
from app.models import KolPost, utcnow
from app.services import kol_analysis
logger = logging.getLogger(__name__)
# Curated B-tier KOL feeds. Handle is the canonical key. `source` is the
# DB column ("substack" | "podcast" | "blog"); empty defaults to "substack"
# for legacy entries. Twitter-only KOLs come in a separate ingester.
#
# When adding a new feed:
# 1. curl + grep '<item>' to confirm it returns entries.
# 2. Inspect entry summary/content length — AI extraction needs ≥300 chars
# of body per post or it just hallucinates a topic line. (Headlines-only
# feeds like Vitalik's blog need a follow-up HTML fetch, deferred.)
# 3. Add with a sensible handle + display_name.
KOL_FEEDS: list[dict] = [
# ── Substack essayists (long-form thesis pieces) ─────────────────────
{
"handle": "cryptohayes",
"display_name": "Arthur Hayes",
"feed_url": "https://cryptohayes.substack.com/feed",
},
# Placeholder VC (Joel Monegro / Chris Burniske). Token-focused VC, posts
# long-form thesis pieces every 1-3 months that map directly to their
# portfolio bets (Solana staking, L1 monetary premium, etc.).
{
"handle": "placeholder",
"display_name": "Placeholder VC",
"feed_url": "https://www.placeholder.vc/blog?format=rss",
},
# Dragonfly Capital research blog on Medium — free, active (10+ posts).
# dragonfly.xyz/blog/rss.xml returns 0 (paywall). medium.com/dragonfly-research
# is the team's public research arm: airdrops, DeFi, protocol deep-dives.
{
"handle": "dragonfly",
"display_name": "Dragonfly Capital",
"feed_url": "https://medium.com/feed/dragonfly-research",
},
# Andy Constan's Substack is paywalled (RSS returns 0). Keeping for any
# occasional public teaser. Forward Guidance podcast (Blockworks) features
# him weekly but is macro/equities-focused — not crypto-coin-specific enough
# to extract ticker signals from episode descriptions.
{
"handle": "dampedspring",
"display_name": "Damped Spring / Andy Constan",
"feed_url": "https://dampedspring.substack.com/feed",
},
# Nic Carter's Substack is paywalled (RSS returns 0). His Medium feed is
# FREE and active — different URL, same author, real content.
{
"handle": "niccarter",
"display_name": "Nic Carter (Castle Island)",
"feed_url": "https://medium.com/feed/@nic__carter",
},
# Delphi Digital podcast (Buzzsprout) — 478 episodes, active May 2025.
# Public, free. Episode descriptions name specific protocols / tokens with
# thesis framing — good extraction signal. delphidigital.io/feed returns 0.
{
"handle": "delphi",
"display_name": "Delphi Digital (Podcast)",
"feed_url": "https://rss.buzzsprout.com/2609274.rss",
},
# ── Newly added (verified live + active) ─────────────────────────────
# Anthony Pompliano — Pomp Investments. Active monthly+ on macro/crypto.
{
"handle": "pomp",
"display_name": "Anthony Pompliano (Pomp Letter)",
"feed_url": "https://pomp.substack.com/feed",
},
# The DeFi Edge — researcher who writes 1-2 deep dives per month on
# tokens / sectors. Real thesis + position-aware framing.
{
"handle": "thedefiedge",
"display_name": "The DeFi Edge",
"feed_url": "https://thedefiedge.com/feed/",
},
# Eugene Ng Ah Sio — trader/analyst, sporadic but specific.
{
"handle": "eugene",
"display_name": "Eugene Ng Ah Sio",
"feed_url": "https://eugene.substack.com/feed",
},
# ── DeFi journalism (Substack-style RSS) ─────────────────────────────
# The Defiant — Camila Russo's team. DeFi-focused news with frequent
# protocol + token mentions. Free RSS, ~100 entries.
{
"handle": "thedefiant",
"display_name": "The Defiant",
"feed_url": "https://www.thedefiant.io/api/feed",
"source": "blog",
},
# ── Major crypto podcasts (Megaphone / Simplecast RSS) ───────────────
# Show notes are 1-6K chars — long enough for AI to pull out tickers
# and theses. Bootstrap is capped at max_new=20/run so a 600-episode
# backlog spreads across ~30 days.
#
# Empire (Blockworks) — Jason Yanowitz + Santiago Roel Santos. Weekly
# crypto+macro interviews. Show notes name protocols + price calls.
{
"handle": "empire",
"display_name": "Empire Podcast (Blockworks)",
"feed_url": "https://feeds.megaphone.fm/empire",
"source": "podcast",
},
# 0xResearch (Blockworks) — Boccaccio + Dan Smith. Protocol research
# deep-dives, real revenue/usage discussion. Highest signal density of
# the Blockworks shows.
{
"handle": "0xresearch",
"display_name": "0xResearch (Blockworks)",
"feed_url": "https://feeds.megaphone.fm/0xresearch",
"source": "podcast",
},
# Lightspeed (Blockworks) — Mert Mumtaz (Helius CEO) + Garrett Harper.
# Solana ecosystem focus — SOL, JUP, JTO, PUMP, validator economics.
{
"handle": "lightspeed",
"display_name": "Lightspeed (Solana, Blockworks)",
"feed_url": "https://feeds.megaphone.fm/lightspeed",
"source": "podcast",
},
# Unchained — Laura Shin. Long interview format with founders and
# traders. Show notes are 6K+ chars (near-transcript).
{
"handle": "unchained",
"display_name": "Unchained (Laura Shin)",
"feed_url": "https://www.unchainedcrypto.com/feed/",
"source": "podcast",
},
# Bankless podcast — Ryan Sean Adams + David Hoffman. ETH-focused but
# covers all majors. 4K char show notes. Largest crypto-native podcast.
# NOTE: previous feed `simplecast.com/MLdpYXYI` was actually Robert
# Breedlove's "What is Money" show — wrong feed. libsyn is canonical.
{
"handle": "bankless",
"display_name": "Bankless Podcast",
"feed_url": "https://bankless.libsyn.com/rss",
"source": "podcast",
},
# Bell Curve (Multicoin) — Mike Ippolito + Jason Yanowitz + Myles Snider.
# 350 episodes, weekly macro+crypto roundup. Multicoin's portfolio shows
# up frequently (SOL, JTO, JUP, Helium, Render). 1.2K show notes.
{
"handle": "bellcurve",
"display_name": "Bell Curve (Multicoin)",
"feed_url": "https://feeds.megaphone.fm/bellcurve",
"source": "podcast",
},
# The Scoop (The Block) — Frank Chaparro interviews founders + traders.
# 110 episodes, ~700 char show notes. Strong on infrastructure/exchange
# deals (Hyperliquid, Coinbase, Binance dynamics).
{
"handle": "thescoop",
"display_name": "The Scoop (The Block)",
"feed_url": "https://feeds.megaphone.fm/the-scoop",
"source": "podcast",
},
# ── Research newsletters (long-form, high-signal) ────────────────────
# Reflexivity Research — Will Clemente + Sam Rule. On-chain BTC analysis
# and macro pieces. 20 entries, 8K char essays. Concrete on-chain calls.
{
"handle": "reflexivity",
"display_name": "Reflexivity Research (Will Clemente)",
"feed_url": "https://reflexivityresearch.substack.com/feed",
},
# TFTC — Marty Bent's "Bitcoin Brief" newsletter (also a podcast feed).
# 11K char issues, daily Bitcoin + policy. Pure BTC focus but covers
# legislation/macro that moves BTC.
{
"handle": "tftc",
"display_name": "TFTC / Bitcoin Brief (Marty Bent)",
"feed_url": "https://tftc.io/feed",
"source": "blog",
},
]
# Back-compat alias — older imports referenced SUBSTACK_KOLS.
SUBSTACK_KOLS = KOL_FEEDS
_TAG_RE = re.compile(r"<[^>]+>")
_WHITESPACE_RE = re.compile(r"[ \t]+")
_BLANKLINES_RE = re.compile(r"\n{3,}")
def _html_to_text(html: str) -> str:
"""Cheap HTML → text. Good enough for Substack which uses simple markup;
if we ever need real parsing, swap to bs4 (not currently a dep)."""
# Newlines for block-level closes so paragraphs survive
s = re.sub(r"</(p|div|h[1-6]|li|br)\s*>", "\n", html, flags=re.I)
s = re.sub(r"<br\s*/?>", "\n", s, flags=re.I)
s = _TAG_RE.sub("", s)
# HTML entities — feedparser usually decodes these but be safe
s = (s.replace("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">")
.replace("&quot;", '"').replace("&#8217;", "'").replace("&#8220;", '"')
.replace("&#8221;", '"').replace("&nbsp;", " "))
s = _WHITESPACE_RE.sub(" ", s)
s = _BLANKLINES_RE.sub("\n\n", s)
return s.strip()
def _entry_body(entry) -> str:
"""Pull the richest body field available from a feedparser entry."""
if entry.get("content"):
# content is a list of {value, type}
return entry["content"][0].get("value", "") or ""
return entry.get("summary") or entry.get("description") or ""
def _parse_pub(entry) -> Optional[datetime]:
raw = entry.get("published") or entry.get("updated")
if not raw:
return None
try:
dt = parsedate_to_datetime(raw)
# Always normalize to naive UTC. Previously used .astimezone() which
# converts to *local* time → 8-hour skew when server runs in CST.
# Affects: divergence window matching, digest 'since' filter, UI display.
if dt.tzinfo:
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
return dt
except Exception:
return None
async def _fetch_feed(feed_url: str) -> list:
"""feedparser is sync; do the HTTP fetch through httpx for timeout
control + uniformity with the rest of the codebase, then hand bytes
to feedparser."""
async with httpx.AsyncClient(timeout=20.0, follow_redirects=True) as client:
r = await client.get(feed_url, headers={"User-Agent": "TrumpSignal/1.0 KOL-tracker"})
r.raise_for_status()
parsed = feedparser.parse(r.content)
return list(parsed.entries or [])
async def _ingest_kol(
session: AsyncSession,
kol: dict,
*,
analyze: bool = True,
max_new: int = 20,
) -> dict:
"""Ingest one KOL feed. max_new caps first-run cost for high-volume feeds
(e.g. Delphi podcast has 478 episodes). Subsequent runs only see truly new
entries so the cap rarely triggers after bootstrap."""
handle = kol["handle"]
feed_url = kol["feed_url"]
src = kol.get("source") or "substack" # substack | podcast | blog
stats = {"handle": handle, "source": src,
"new": 0, "skipped": 0, "analyzed": 0, "errors": 0}
try:
entries = await _fetch_feed(feed_url)
except Exception as e:
logger.warning("[kol_substack] fetch failed for %s: %s", handle, e)
stats["errors"] += 1
return stats
for entry in entries:
if stats["new"] >= max_new:
logger.info("[kol_substack] %s hit max_new=%d cap; rest deferred to next run",
handle, max_new)
break
# Podcast feeds (Buzzsprout, etc.) have no <link>; use enclosure URL or entry id.
url = entry.get("link")
if not url:
enclosures = entry.get("enclosures") or []
if enclosures:
url = enclosures[0].get("href")
if not url:
url = entry.get("id") # e.g. "Buzzsprout-19123172"
if not url:
continue
# Dedupe by (source, external_id=url). We also check against the
# legacy "substack" source so podcast/blog re-tags don't double-insert
# entries the old code already wrote.
existing = await session.execute(
select(KolPost).where(
KolPost.source.in_([src, "substack"]),
KolPost.external_id == url,
)
)
row = existing.scalar_one_or_none()
if row is not None:
stats["skipped"] += 1
continue
html = _entry_body(entry)
text = _html_to_text(html)
if not text:
continue
pub = _parse_pub(entry) or utcnow()
title = entry.get("title") or None
body_hash = hashlib.sha256(text.encode("utf-8")).hexdigest()
row = KolPost(
kol_handle=handle,
source=src,
external_id=url,
url=url,
title=title,
published_at=pub,
raw_text=text,
content_hash=body_hash,
)
session.add(row)
await session.flush() # get id for logging
stats["new"] += 1
logger.info("[kol_substack] new post %s id=%s title=%r", handle, row.id, title)
if analyze:
try:
result = await kol_analysis.extract_kol_signal(
handle=handle,
source=src,
title=title,
body=text,
)
if result.get("error"):
stats["errors"] += 1
else:
import json as _json
row.summary = result.get("summary")
row.tickers_json = _json.dumps(result.get("tickers") or [],
ensure_ascii=False)
row.analyzed_at = utcnow()
row.analysis_model = result.get("model")
row.analysis_version = result.get("version")
stats["analyzed"] += 1
except Exception as e:
logger.warning("[kol_substack] analysis failed for %s post %s: %s",
handle, row.id, e)
stats["errors"] += 1
await session.commit()
return stats
async def run_substack_poll(*, analyze: bool = True) -> list[dict]:
"""Poll every configured KOL feed once. Despite the legacy name this now
covers Substack essays, Medium blogs, and major crypto podcasts via RSS.
Returns per-KOL stats."""
results = []
async with AsyncSessionLocal() as session:
for kol in KOL_FEEDS:
stats = await _ingest_kol(session, kol, analyze=analyze)
results.append(stats)
logger.info("[kol_substack] poll done: %s", results)
return results
+360
View File
@@ -0,0 +1,360 @@
"""
Market data abstraction pluggable candle sources.
Currently 2 providers:
- Binance : Free public REST, broad coverage of major coins.
- Hyperliquid : SAME venue as execution. Covers HL-native perps that
Binance doesn't list (TRUMP, HYPE, PURR, etc.) and
gives us mark-price-consistent data for those assets.
Routing rule:
- Assets in HL_NATIVE_ASSETS Hyperliquid (no Binance pair exists)
- Everything else Binance (better history, no rate friction)
Override per-call by selecting a provider explicitly:
await BinanceCandles().fetch_4h("BTC", days=30)
await HyperliquidCandles().fetch_4h("HYPE", days=30)
# Or auto-route:
src = for_asset("HYPE") # → HyperliquidCandles
candles = await src.fetch_4h("HYPE", days=30)
Normalized candle shape (returned by ALL providers):
{"time_ms": int, "open": float, "high": float, "low": float,
"close": float, "volume": float}
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Protocol
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
# ─── Provider protocol ──────────────────────────────────────────────────────
class CandleSource(Protocol):
name: str
async def fetch_4h(self, asset: str, days: int) -> list[dict]:
"""Last `days` worth of 4-hour candles for `asset`."""
...
async def fetch_1d(self, asset: str, days: int) -> list[dict]:
"""Last `days` daily candles. Used for SMA reclaim / VCP-Daily."""
...
async def fetch_1w(self, asset: str, weeks: int) -> list[dict]:
"""Last `weeks` weekly candles. Used for weekly-RSI reversal."""
...
async def fetch_1m(self, asset: str, start_ms: int, end_ms: int) -> list[dict]:
"""1-minute candles in [start_ms, end_ms]. Paginated internally."""
...
async def fetch_funding(self, asset: str, days: int) -> list[dict]:
"""Funding-rate history. List of {time_ms, rate}. HL-only for now —
Binance provides funding but the cross-venue rate differs, so we
defer to the execution venue (HL)."""
...
# ─── Binance ────────────────────────────────────────────────────────────────
class BinanceCandles:
name = "binance"
base_url = "https://api.binance.com/api/v3/klines"
@staticmethod
def _symbol(asset: str) -> str:
return f"{asset.upper()}USDT"
async def fetch_4h(self, asset: str, days: int) -> list[dict]:
end_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
start_ms = end_ms - days * 24 * 3600 * 1000
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.get(self.base_url, params={
"symbol": self._symbol(asset),
"interval": "4h",
"startTime": start_ms, "endTime": end_ms,
"limit": 1000,
})
resp.raise_for_status()
rows = resp.json()
return [self._normalize(r) for r in rows]
async def fetch_1d(self, asset: str, days: int) -> list[dict]:
return await self._fetch_simple(asset, "1d", days * 24 * 3600 * 1000)
async def fetch_1w(self, asset: str, weeks: int) -> list[dict]:
return await self._fetch_simple(asset, "1w", weeks * 7 * 24 * 3600 * 1000)
async def _fetch_simple(self, asset: str, interval: str, window_ms: int) -> list[dict]:
"""Single-call fetch for intervals where 1000 bars covers the window."""
end_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
start_ms = end_ms - window_ms
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.get(self.base_url, params={
"symbol": self._symbol(asset),
"interval": interval,
"startTime": start_ms, "endTime": end_ms,
"limit": 1000,
})
resp.raise_for_status()
rows = resp.json()
return [self._normalize(r) for r in rows]
async def fetch_1m(self, asset: str, start_ms: int, end_ms: int) -> list[dict]:
# Binance caps each call at 1000 candles — paginate forward.
out: list[dict] = []
cursor = start_ms
async with httpx.AsyncClient(timeout=20) as client:
while cursor < end_ms:
resp = await client.get(self.base_url, params={
"symbol": self._symbol(asset),
"interval": "1m",
"startTime": cursor, "endTime": end_ms,
"limit": 1000,
})
resp.raise_for_status()
chunk = resp.json()
if not chunk:
break
out.extend(self._normalize(r) for r in chunk)
last_open = chunk[-1][0]
if last_open <= cursor:
break
cursor = last_open + 60_000
if len(chunk) < 1000:
break
return out
async def fetch_funding(self, asset: str, days: int) -> list[dict]:
"""Binance perp funding. Format: list of {time_ms, rate}.
Binance returns rate per 8h funding cycle (matches HL convention).
Note: this is Binance's perp funding, NOT HL's. For HL-traded
positions, prefer HyperliquidCandles.fetch_funding().
"""
end_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
start_ms = end_ms - days * 24 * 3600 * 1000
url = "https://fapi.binance.com/fapi/v1/fundingRate"
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.get(url, params={
"symbol": self._symbol(asset),
"startTime": start_ms, "endTime": end_ms,
"limit": 1000,
})
resp.raise_for_status()
rows = resp.json()
return [
{"time_ms": r["fundingTime"], "rate": float(r["fundingRate"])}
for r in rows
]
@staticmethod
def _normalize(row) -> dict:
return {
"time_ms": row[0],
"open": float(row[1]),
"high": float(row[2]),
"low": float(row[3]),
"close": float(row[4]),
"volume": float(row[5]),
}
# ─── Hyperliquid ────────────────────────────────────────────────────────────
class HyperliquidCandles:
"""HL public /info endpoint — same data the HL UI uses.
Endpoint:
POST https://api.hyperliquid.xyz/info
body { "type": "candleSnapshot",
"req": { "coin": "SOL", "interval": "4h",
"startTime": ms, "endTime": ms } }
Returns array of {t, T, s, i, o, c, h, l, v, n} we normalize to our
standard shape. Useful for HL-native perps Binance doesn't list.
"""
name = "hyperliquid"
def __init__(self, mainnet: bool | None = None):
use_mainnet = settings.hl_mainnet if mainnet is None else mainnet
self.base_url = (
"https://api.hyperliquid.xyz/info" if use_mainnet
else "https://api.hyperliquid-testnet.xyz/info"
)
async def fetch_4h(self, asset: str, days: int) -> list[dict]:
return await self._fetch_window(asset, "4h", days * 24 * 3600 * 1000)
async def fetch_1d(self, asset: str, days: int) -> list[dict]:
return await self._fetch_window(asset, "1d", days * 24 * 3600 * 1000)
async def fetch_1w(self, asset: str, weeks: int) -> list[dict]:
return await self._fetch_window(asset, "1w", weeks * 7 * 24 * 3600 * 1000)
async def fetch_1m(self, asset: str, start_ms: int, end_ms: int) -> list[dict]:
# HL returns ALL candles in the window in one response — no pagination
# needed for typical scanner windows. For multi-day 1m calls HL may
# truncate; the caller should keep windows under ~24h for 1m data.
return await self._fetch(asset.upper(), "1m", start_ms, end_ms)
async def _fetch_window(self, asset: str, interval: str, window_ms: int) -> list[dict]:
end_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
start_ms = end_ms - window_ms
return await self._fetch(asset.upper(), interval, start_ms, end_ms)
async def _fetch(self, coin: str, interval: str, start_ms: int, end_ms: int) -> list[dict]:
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.post(self.base_url, json={
"type": "candleSnapshot",
"req": {
"coin": coin, "interval": interval,
"startTime": start_ms, "endTime": end_ms,
},
})
resp.raise_for_status()
rows = resp.json() or []
return [self._normalize(r) for r in rows]
async def fetch_funding(self, asset: str, days: int) -> list[dict]:
"""HL funding history — HOURLY cadence (1 cycle per hour).
IMPORTANT: HL's /info endpoint caps fundingHistory at 500 rows per
response. 500 rows × 1h cadence = 20.8 days, so a single call CAN'T
return a full 30-day window. We page backwards from `endTime` until
we cover `days` worth of history (or HL runs out of data).
Returns chronologically-sorted list of {time_ms, rate}, deduplicated.
"""
end_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
start_ms = end_ms - days * 24 * 3600 * 1000
cursor = end_ms
collected: dict[int, float] = {} # time_ms → rate (dedup by time)
async with httpx.AsyncClient(timeout=20) as client:
# Safety cap: at most 10 pages (5000 rows ≈ 208 days) — way more
# than any caller could reasonably want, prevents runaway loops
# if HL returns inconsistent data.
for _ in range(10):
if cursor <= start_ms:
break
resp = await client.post(self.base_url, json={
"type": "fundingHistory",
"coin": asset.upper(),
"startTime": start_ms,
"endTime": cursor,
})
resp.raise_for_status()
chunk = resp.json() or []
if not chunk:
break
for r in chunk:
t = r["time"]
if t not in collected:
collected[t] = float(r["fundingRate"])
oldest = min(r["time"] for r in chunk)
if oldest <= start_ms or len(chunk) < 500:
# Either we reached the start of our window, or HL gave
# us a partial page (no more data behind it).
break
cursor = oldest - 1
return [
{"time_ms": t, "rate": rate}
for t, rate in sorted(collected.items())
]
@staticmethod
def _normalize(row: dict) -> dict:
# HL candle keys: t=open_ms, o=open, h/l, c, v
return {
"time_ms": row["t"],
"open": float(row["o"]),
"high": float(row["h"]),
"low": float(row["l"]),
"close": float(row["c"]),
"volume": float(row["v"]),
}
# ─── Routing ────────────────────────────────────────────────────────────────
# HL-native perps that DO NOT have a Binance spot pair. The scanner / backtest
# auto-route these to HL. Add more as you discover them — the list is the
# only place to maintain provider preference.
HL_NATIVE_ASSETS = {
"HYPE", "PURR", "JEFF", "VAPOR",
"PIP", "OMNIX", "PYTH",
# NOTE: TRUMP is now listed on Binance too — using Binance for that gets
# cleaner history. SUI is on both.
}
# Reversal-strategy basket. Major-cap only (no shitcoins), 1 HL-native.
# Used by all three reversal scanners (weekly RSI, SMA reclaim, funding extreme).
REVERSAL_BASKET = ["BTC", "ETH", "SOL", "BNB", "LINK", "AAVE", "DOGE", "HYPE"]
# Bar durations in seconds — used by drop_in_progress_bar() to know whether
# the last candle in a response is still open. Crypto exchanges return the
# CURRENT (in-progress) bar in `klines` responses; for daily/weekly logic
# we usually want the most recent CLOSED bar instead.
INTERVAL_SECONDS = {
"1m": 60, "5m": 300, "15m": 900,
"1h": 3600, "4h": 14400, "8h": 28800,
"1d": 86400, "1w": 604800,
}
def drop_in_progress_bar(candles: list[dict], interval: str) -> list[dict]:
"""Return `candles` minus the last entry if it's still an open bar.
Daily / weekly bars on Binance & HL are returned with `time_ms = open_time`.
The bar's close time is open_time + interval_seconds. If now < close_time,
the bar hasn't closed yet — using its volume/close is misleading (volume
is partial, close is current spot mid-bar).
Use this in scanners that care about CONFIRMED signals (SMA reclaim,
weekly RSI). Use raw candles only when you want to react intra-bar
(rare and usually wrong for position trading).
"""
if not candles:
return candles
dur_s = INTERVAL_SECONDS.get(interval)
if dur_s is None:
return candles # unknown interval — be conservative, return as-is
bar_close_ms = candles[-1]["time_ms"] + dur_s * 1000
now_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
if now_ms < bar_close_ms:
return candles[:-1]
return candles
_BINANCE_SINGLETON = BinanceCandles()
_HL_SINGLETON = HyperliquidCandles()
def for_asset(asset: str) -> CandleSource:
"""Pick the right provider. HL-native → HL, otherwise Binance.
Always returns SOMETHING caller doesn't need to handle None. If the
asset doesn't actually trade on the chosen venue, the underlying HTTP
call will return an empty list and the caller falls back to its
"no data" path.
"""
return _HL_SINGLETON if asset.upper() in HL_NATIVE_ASSETS else _BINANCE_SINGLETON
+177
View File
@@ -0,0 +1,177 @@
"""On-chain metrics for the BTC bottom-reversal scanner.
Two providers, same interface (fetch_mvrv_z_score / fetch_sth_sopr list[float]):
BitcoinDataClient DEFAULT. bitcoin-data.com public API. FREE, no key.
Returns PRE-COMPUTED MVRV-Z and STH-SOPR (we don't
need realized-cap or local math realized cap is an
on-chain quantity that CANNOT be derived from price
data, so a vendor is unavoidable; this is the free one).
Free tier: 10 requests/HOUR. The scanner runs once a
day and needs 2 calls well within budget but we
add a 6-hour in-process cache as a safety net against
restarts / manual re-runs hammering the limit.
GlassnodeClient Optional paid fallback. Used automatically if
GLASSNODE_API_KEY is set (higher resolution / SLA).
get_onchain_client() picks the right one. btc_bottom_reversal.py only
depends on the interface, never the concrete class.
"""
from __future__ import annotations
import json
import logging
import os
import time
from datetime import datetime, timezone
from typing import Optional
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
# ─── Provider 1: bitcoin-data.com (free, default) ───────────────────────────
BITCOIN_DATA_BASE = "https://bitcoin-data.com/v1"
# Endpoint → (path, json value key). Both return a full daily history list
# of {"d","unixTs",<key>} when called with no query params.
_BD_ENDPOINTS = {
"mvrv_z": ("/mvrv-zscore", "mvrvZscore"),
"sth_sopr": ("/sth-sopr", "sthSopr"),
}
# Fresh-fetch window: don't re-hit the API if the on-disk copy is younger
# than this. The scanner runs daily; 18h keeps it to ~1 fetch/day/metric
# (2 req/day total, vs the 10 req/HOUR free cap).
_BD_FRESH_S = 18 * 3600
# How stale on-disk data may be and still be USABLE when the API is down /
# rate-limited. A daily MVRV-Z / STH-SOPR barely moves over 2 days, so
# serving 48h-old data beats skipping a scan or, worse, acting on nothing.
_BD_STALE_OK_S = 48 * 3600
# Disk cache survives restarts — critical given the tight free rate limit.
_BD_CACHE_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".cache")
_BD_CACHE_FILE = os.path.join(_BD_CACHE_DIR, "onchain_bitcoin_data.json")
def _load_disk_cache() -> dict:
try:
with open(_BD_CACHE_FILE) as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError, OSError):
return {}
def _save_disk_cache(cache: dict) -> None:
try:
os.makedirs(_BD_CACHE_DIR, exist_ok=True)
tmp = _BD_CACHE_FILE + ".tmp"
with open(tmp, "w") as f:
json.dump(cache, f)
os.replace(tmp, _BD_CACHE_FILE) # atomic
except OSError as exc:
logger.warning("onchain disk-cache write failed: %s", exc)
class BitcoinDataClient:
"""Free, key-less. Returns pre-computed metric series (already the
Z-score / SOPR value no local computation needed). Disk-cached so the
10 req/hour free cap and process restarts can't starve the scanner."""
async def _series(self, metric: str, days: int) -> list[float]:
now = time.time()
cache = _load_disk_cache()
entry = cache.get(metric) # {"ts": epoch, "values": [...]}
age = (now - entry["ts"]) if entry else None
# Fresh enough → no network call at all.
if entry and age is not None and age < _BD_FRESH_S:
vals = entry["values"]
return vals[-days:] if days else vals
path, key = _BD_ENDPOINTS[metric]
try:
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.get(f"{BITCOIN_DATA_BASE}{path}")
if resp.status_code == 429:
raise RuntimeError("rate_limited")
resp.raise_for_status()
rows = resp.json() or []
values = [float(r[key]) for r in rows if r.get(key) is not None]
if not values:
raise RuntimeError("empty_response")
cache[metric] = {"ts": now, "values": values}
_save_disk_cache(cache)
return values[-days:] if days else values
except Exception as exc:
# Network/limit failure: fall back to disk if it's still usable.
if entry and age is not None and age < _BD_STALE_OK_S:
logger.warning("bitcoin-data.com %s fetch failed (%s) — "
"serving disk cache aged %.1fh",
metric, exc, age / 3600)
vals = entry["values"]
return vals[-days:] if days else vals
raise RuntimeError(
f"bitcoin-data.com {metric} unavailable ({exc}) and no usable cache"
) from exc
async def fetch_mvrv_z_score(self, asset: str = "BTC", days: int = 730) -> list[float]:
if asset.upper() != "BTC":
return [] # bitcoin-data.com is BTC-only
return await self._series("mvrv_z", days)
async def fetch_sth_sopr(self, asset: str = "BTC", days: int = 30) -> list[float]:
if asset.upper() != "BTC":
return []
return await self._series("sth_sopr", days)
# ─── Provider 2: Glassnode (paid, optional) ─────────────────────────────────
GLASSNODE_BASE_URL = "https://api.glassnode.com/v1/metrics"
class GlassnodeClient:
def __init__(self, api_key: Optional[str] = None):
self.api_key = api_key if api_key is not None else settings.glassnode_api_key
async def _get_series(self, path: str, asset: str, days: int) -> list[dict]:
if not self.api_key:
raise RuntimeError("GLASSNODE_API_KEY is not configured")
end = int(datetime.now(timezone.utc).timestamp())
start = end - days * 86_400
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.get(
f"{GLASSNODE_BASE_URL}{path}",
params={"a": asset, "api_key": self.api_key,
"s": start, "u": end, "i": "24h"},
)
resp.raise_for_status()
return resp.json() or []
async def fetch_mvrv_z_score(self, asset: str = "BTC", days: int = 730) -> list[float]:
rows = await self._get_series("/market/mvrv_z_score", asset, days)
return [float(r["v"]) for r in rows if r.get("v") is not None]
async def fetch_sth_sopr(self, asset: str = "BTC", days: int = 30) -> list[float]:
rows = await self._get_series("/indicators/sopr_less_155", asset, days)
return [float(r["v"]) for r in rows if r.get("v") is not None]
# ─── Factory ────────────────────────────────────────────────────────────────
def get_onchain_client():
"""Glassnode if a key is configured (paid, higher SLA), else the free
bitcoin-data.com client. Both expose the same async interface."""
if settings.glassnode_api_key:
logger.info("On-chain provider: Glassnode (paid key configured)")
return GlassnodeClient()
logger.info("On-chain provider: bitcoin-data.com (free, no key)")
return BitcoinDataClient()
+179
View File
@@ -0,0 +1,179 @@
"""
Batch re-analyzer runs Claude analysis on posts that were backfilled
without AI scoring (ai_confidence == 0).
Designed to run as a one-shot background task. Processes posts oldest-first,
1 per second, so 479 posts 8 minutes. Progress is logged at every batch.
Usage (via dev endpoint POST /api/dev/reanalyze):
curl -X POST "http://localhost:8000/api/dev/reanalyze?limit=500&dry_run=false"
"""
import asyncio
import logging
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import select
logger = logging.getLogger(__name__)
# Global so the HTTP endpoint can check progress without storing state externally.
_reanalyze_state: dict = {
"running": False,
"processed": 0,
"updated": 0,
"errors": 0,
"total": 0,
"started_at": None,
"finished_at": None,
}
def get_state() -> dict:
return dict(_reanalyze_state)
async def reanalyze_unscored(
db_session_factory,
limit: int = 500,
dry_run: bool = False,
delay_secs: float = 1.0,
legacy_signals: bool = False,
model: Optional[str] = None,
) -> dict:
"""
Find all posts with ai_confidence == 0, run analyze_post on each,
and write results back to the DB.
Args:
limit: Maximum number of posts to process in this run.
dry_run: If True, analyze but do NOT write to DB (for testing).
delay_secs: Pause between API calls to avoid rate-limiting.
Returns:
Summary dict with processed/updated/errors counts.
"""
global _reanalyze_state
if _reanalyze_state["running"]:
logger.warning("reanalyze already in progress, skipping")
return _reanalyze_state
_reanalyze_state = {
"running": True,
"processed": 0,
"updated": 0,
"errors": 0,
"total": 0,
"started_at": datetime.now(timezone.utc).isoformat(),
"finished_at": None,
}
from app.models import Post
from app.services.analysis import analyze_post
try:
# ── Fetch posts needing (re-)analysis ────────────────────────────
# Two cases:
# 1. Never analyzed (ai_confidence == 0)
# 2. Analyzed before target_asset column was added (has buy/short
# signal but target_asset IS NULL). Pass legacy_signals=True
# to target ONLY these, bypassing unscored posts.
from sqlalchemy import or_, and_
if legacy_signals:
condition = and_(
Post.signal.in_(["buy", "short"]),
Post.target_asset == None, # noqa: E711
)
else:
# Use analysis_version IS NULL (not ai_confidence==0) so that
# posts already analyzed as "hold" (conf=0, version set) are not
# re-run every time. Only truly unanalyzed posts are targeted.
condition = or_(
Post.analysis_version == None, # noqa: E711
and_(
Post.signal.in_(["buy", "short"]),
Post.target_asset == None, # noqa: E711
)
)
async with db_session_factory() as db:
rows = await db.execute(
select(Post.id, Post.text)
.where(condition)
.order_by(Post.published_at.asc())
.limit(limit)
)
posts_to_do = rows.all() # list of (id, text) tuples
_reanalyze_state["total"] = len(posts_to_do)
logger.info("Reanalyze: %d posts queued (limit=%d, dry_run=%s)",
len(posts_to_do), limit, dry_run)
if not posts_to_do:
_reanalyze_state["running"] = False
_reanalyze_state["finished_at"] = datetime.now(timezone.utc).isoformat()
return _reanalyze_state
# ── Process each post ─────────────────────────────────────────────
for post_id, text in posts_to_do:
_reanalyze_state["processed"] += 1
try:
analysis = await analyze_post(text, model=model) # caller decides quality vs speed
if not dry_run:
async with db_session_factory() as db:
result = await db.execute(
select(Post).where(Post.id == post_id)
)
post = result.scalar_one_or_none()
if post is None:
continue
post.sentiment = analysis["sentiment"]
post.signal = analysis.get("signal")
post.ai_confidence = analysis["confidence"]
post.ai_reasoning = analysis.get("reasoning")
post.prefilter_reason = analysis.get("prefilter_reason")
post.analysis_version = analysis.get("analysis_version")
post.relevant = analysis["relevant"]
post.price_impact_asset = analysis["asset"] if analysis["relevant"] else None
post.target_asset = analysis.get("target_asset")
post.category = analysis.get("category")
post.expected_move_pct = analysis.get("expected_move_pct")
await db.commit()
_reanalyze_state["updated"] += 1
if _reanalyze_state["processed"] % 20 == 0:
logger.info(
"Reanalyze progress: %d/%d processed, %d updated, %d errors",
_reanalyze_state["processed"],
_reanalyze_state["total"],
_reanalyze_state["updated"],
_reanalyze_state["errors"],
)
except Exception as exc:
_reanalyze_state["errors"] += 1
logger.error("Reanalyze error on post %d: %s", post_id, exc)
# Rate-limit: wait between API calls
if delay_secs > 0:
await asyncio.sleep(delay_secs)
except Exception as exc:
logger.error("Reanalyze fatal error: %s", exc)
finally:
_reanalyze_state["running"] = False
_reanalyze_state["finished_at"] = datetime.now(timezone.utc).isoformat()
logger.info(
"Reanalyze finished: %d processed, %d updated, %d errors",
_reanalyze_state["processed"],
_reanalyze_state["updated"],
_reanalyze_state["errors"],
)
return _reanalyze_state
+191
View File
@@ -0,0 +1,191 @@
"""
Hyperliquid <-> DB state reconciliation (P1.2).
Runs every RECONCILE_INTERVAL_SECONDS. For each subscription with a saved HL
API key (and NOT in paper mode), fetches actual open positions from HL and
compares them to BotTrade rows with closed_at IS NULL.
Two drift cases:
1. DB has open trade, HL has no matching position
User closed manually on HL UI, or HL liquidated, or our open_position
call failed silently after writing the row. Either way the row is stale.
Action: mark the DB row closed with pnl_usd=NULL, reason="closed_externally".
2. HL has open position, DB has no matching open trade
A trade exists on HL that we didn't open (legacy position, or stale
wallet shared across systems). We can't safely manage it — log a
warning + WS broadcast for the user to handle manually.
NOTE: this does NOT close stray HL positions. Auto-closing positions we didn't
open is exactly the kind of "helpful" behaviour that costs users money. We
report, the human decides.
Cost note:
HL's user_state endpoint is cheap. 1 call per active subscriber per
RECONCILE_INTERVAL. With 10 subscribers at 60s interval that's 600 calls/hr,
well inside HL's free tier.
"""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import select, update
from app.config import settings
from app.database import AsyncSessionLocal
from app.models import BotTrade, Subscription
from app.services.crypto import decrypt_api_key
from app.services.hyperliquid import HyperliquidTrader
logger = logging.getLogger(__name__)
RECONCILE_INTERVAL_SECONDS = 60
# ─── Single-wallet reconcile ────────────────────────────────────────────────
async def _reconcile_wallet(sub: Subscription) -> dict:
"""Compare HL state vs DB state for one subscription.
Returns a small summary dict for logging / WS broadcast. Never raises
network / HL errors are logged and counted but don't halt the cycle.
"""
summary = {
"wallet": sub.wallet_address, "orphan_hl": [], "orphan_db": [],
"marked_closed": 0, "error": None,
}
# Paper-mode subs aren't on HL at all — nothing to reconcile.
if sub.paper_mode:
return summary
try:
api_key = decrypt_api_key(sub.hl_api_key)
except Exception as exc:
summary["error"] = f"decrypt_failed: {exc}"
return summary
try:
trader = HyperliquidTrader(
api_private_key=api_key,
account_address=sub.wallet_address,
leverage=sub.leverage,
mainnet=settings.hl_mainnet,
)
hl_positions = await trader.get_open_positions()
except Exception as exc:
summary["error"] = f"hl_query_failed: {exc}"
return summary
hl_assets_open = {p["coin"]: p for p in hl_positions}
async with AsyncSessionLocal() as db:
# All DB rows we believe are still open for this wallet.
rows = await db.execute(
select(BotTrade).where(
BotTrade.wallet_address == sub.wallet_address,
BotTrade.closed_at.is_(None),
)
)
db_open_trades = rows.scalars().all()
db_assets_open = {t.asset: t for t in db_open_trades}
# Case 1: DB open trade, HL has nothing. Stale row → mark closed.
now_naive = datetime.now(timezone.utc).replace(tzinfo=None)
for asset, trade in db_assets_open.items():
# Paper trades never have HL positions; they're managed by the
# normal close path. Skip here.
if trade.hl_order_id == "paper":
continue
if asset not in hl_assets_open:
# Preserve any de-risk PnL ALREADY banked on this trade — the
# open remainder's PnL is unknown (closed externally) but the
# banked slice is real money on HL; setting pnl_usd=None would
# silently undercount realized PnL in analytics / KPIs.
banked = trade.realized_partial_pnl_usd or 0.0
preserved_pnl = round(banked, 2) if banked else None
# Atomic claim — same conditional UPDATE pattern as close_and_finalize.
claimed = await db.execute(
update(BotTrade)
.where(BotTrade.id == trade.id)
.where(BotTrade.closed_at.is_(None))
.values(closed_at=now_naive, pnl_usd=preserved_pnl,
remaining_fraction=0.0)
)
if claimed.rowcount > 0:
summary["marked_closed"] += 1
summary["orphan_db"].append({"asset": asset, "trade_id": trade.id})
# Stop the TP/SL watcher for this trade
from app.services.tp_sl_monitor import unregister
unregister(trade.id)
logger.warning("Reconcile: trade %d (%s %s) closed externally — marked.",
trade.id, trade.side, asset)
await db.commit()
# Case 2: HL has position we don't know about. Report only — don't auto-trade.
for asset, hl_pos in hl_assets_open.items():
if asset not in db_assets_open:
summary["orphan_hl"].append({
"asset": asset, "szi": hl_pos["szi"],
"entry": hl_pos["entry_px"], "uPnL": hl_pos["unrealized_pnl"],
})
logger.warning("Reconcile: %s has unmanaged HL position %s (szi=%s, entry=%s)",
sub.wallet_address, asset, hl_pos["szi"], hl_pos["entry_px"])
return summary
# ─── Periodic top-level task ────────────────────────────────────────────────
async def reconcile_all_once() -> None:
"""One scan over every subscription. Wired into the APScheduler at startup."""
async with AsyncSessionLocal() as db:
rows = await db.execute(
select(Subscription).where(
Subscription.active == True, # noqa: E712
Subscription.hl_api_key.is_not(None),
)
)
subs = rows.scalars().all()
if not subs:
return
# Snapshot the fields we read so we don't hold the session across HL calls.
snapshots = list(subs)
n_changed = 0
n_orphans = 0
n_errors = 0
for sub in snapshots:
summary = await _reconcile_wallet(sub)
n_changed += summary["marked_closed"]
n_orphans += len(summary["orphan_hl"])
if summary["error"]:
n_errors += 1
# Broadcast significant events so the UI shows a warning banner.
if summary["marked_closed"] or summary["orphan_hl"]:
try:
from app.ws.manager import manager
await manager.broadcast({
"type": "reconcile_drift",
"wallet": summary["wallet"],
"orphan_hl": summary["orphan_hl"],
"marked_closed": summary["marked_closed"],
"at": datetime.now(timezone.utc).isoformat() + "Z",
})
except Exception as exc:
logger.warning("reconcile WS broadcast failed: %s", exc)
if n_changed or n_orphans or n_errors:
logger.info("Reconcile cycle: %d subs scanned, %d trades marked closed, %d HL orphans, %d errors",
len(snapshots), n_changed, n_orphans, n_errors)
+76 -7
View File
@@ -11,15 +11,21 @@ from datetime import datetime, timezone
from sqlalchemy import select from sqlalchemy import select
from app.database import AsyncSessionLocal from app.database import AsyncSessionLocal
from app.models import BotTrade, Subscription from app.models import BotTrade, Post, Subscription
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
async def rehydrate_open_trades() -> None: async def rehydrate_open_trades() -> None:
# Imported locally to avoid circular imports at module load # Imported locally to avoid circular imports at module load
from app.services.bot_engine import MAX_HOLD_SECONDS, close_and_finalize from app.services.bot_engine import close_and_finalize
from app.services.crypto import decrypt_api_key from app.services.crypto import decrypt_api_key
from app.services.signal_categories import (
get_stop_ladder as _get_stop_ladder,
sys2_derisk_ladder as _sys2_derisk_ladder,
sys2_addon_ladder as _sys2_addon_ladder,
sys2_peak_trail as _sys2_peak_trail,
)
from app.services.tp_sl_monitor import register_trade from app.services.tp_sl_monitor import register_trade
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
@@ -53,7 +59,29 @@ async def rehydrate_open_trades() -> None:
# Fall back to current Subscription only for legacy rows (pre-migration 005). # Fall back to current Subscription only for legacy rows (pre-migration 005).
trade_leverage = t.leverage if t.leverage is not None else sub.leverage trade_leverage = t.leverage if t.leverage is not None else sub.leverage
# Re-register TP/SL watcher # Re-register from the trade's FROZEN exit profile (eff_* columns),
# NOT the live Subscription. The trade may be a 90-day System-2
# reversal; using sub.* would rehydrate it with the user's Trump
# stop (1.5%) and 7-day max-hold — silently rewriting its risk on
# every restart. eff_* is stamped at open and never changes.
#
# Legacy rows (opened before this migration) have NULL eff_* —
# fall back to the Subscription so they still get *some* watcher.
eff_sl = t.eff_stop_loss_pct if t.eff_stop_loss_pct is not None else sub.stop_loss_pct
eff_tp = t.eff_take_profit_pct # may legitimately be None (sys2 pure-trail)
eff_tr = t.eff_trailing_stop_pct if t.eff_trailing_stop_pct is not None else sub.trailing_stop_pct
eff_tra = t.eff_trailing_activate_pct if t.eff_trailing_activate_pct is not None else sub.trailing_activate_at_pct
eff_mh = t.eff_max_hold_hours if t.eff_max_hold_hours is not None else (sub.max_hold_hours or 168)
# NOTE: peak_gain_pct is now RESTORED from the row (throttled
# persistence) so a pyramided / in-profit System-2 trade keeps its
# regime across restarts. min_hold still resets on rehydrate, but
# it only matters in the first 30 min of a Trump trade; a restart
# inside that window is rare and the downside (TP can fire early)
# is bounded.
post_res = await db.execute(
select(Post).where(Post.id == t.trigger_post_id)
)
trigger_post = post_res.scalar_one_or_none()
register_trade( register_trade(
trade_id=t.id, trade_id=t.id,
wallet=t.wallet_address, wallet=t.wallet_address,
@@ -62,14 +90,55 @@ async def rehydrate_open_trades() -> None:
asset=t.asset, asset=t.asset,
side=t.side, side=t.side,
entry_price=t.entry_price, entry_price=t.entry_price,
take_profit_pct=sub.take_profit_pct, take_profit_pct=eff_tp,
stop_loss_pct=sub.stop_loss_pct, stop_loss_pct=eff_sl,
trailing_stop_pct=eff_tr,
trailing_activate_at_pct=eff_tra,
invalidation=t.eff_invalidation,
invalidation_price=(
t.eff_invalidation_price
if t.eff_invalidation_price is not None
else (trigger_post.invalidation_price if trigger_post else None)
),
min_hold_until_ts=t.eff_min_hold_until_ts,
stop_ladder=_get_stop_ladder(
trigger_post.category if trigger_post else None
),
# Restore staged de-risk: rebuild the ladder from the trade's
# FROZEN leverage and skip steps already executed before the
# restart (derisk_steps_done is persisted on the row).
# Restore staged de-risk/pyramid/peak-trail with the trade's
# FROZEN risk mode + leverage, skipping steps already executed
# before the restart (persisted on the row).
derisk_ladder=(
_sys2_derisk_ladder(t.leverage or 1, t.sys2_mode)
if _get_stop_ladder(trigger_post.category if trigger_post else None)
else None
),
derisk_done=(t.derisk_steps_done or 0),
addon_ladder=(
_sys2_addon_ladder(t.sys2_mode)
if _get_stop_ladder(trigger_post.category if trigger_post else None)
else None
),
addon_done=(t.addon_steps_done or 0),
# Restore the monotonic peak so a pyramided / in-profit trade
# doesn't fall back to the underwater de-risk regime on restart.
initial_peak=(t.peak_gain_pct or 0.0),
peak_trail=(
_sys2_peak_trail(t.sys2_mode)
if _get_stop_ladder(trigger_post.category if trigger_post else None)
else None
),
grow_mode=bool(getattr(t, "grow_mode", False)),
) )
# Compute remaining hold; if already exceeded, close immediately # Remaining hold from the trade's FROZEN max_hold (e.g. 2160h for
# an sma_reclaim), not the user's Trump setting.
opened_aware = t.opened_at.replace(tzinfo=timezone.utc) opened_aware = t.opened_at.replace(tzinfo=timezone.utc)
elapsed = (now - opened_aware).total_seconds() elapsed = (now - opened_aware).total_seconds()
remaining = MAX_HOLD_SECONDS - elapsed max_hold_seconds = int(eff_mh) * 3600
remaining = max_hold_seconds - elapsed
from app.services.bot_engine import _background_tasks from app.services.bot_engine import _background_tasks
+221
View File
@@ -0,0 +1,221 @@
"""
Regime filter gates entries to convex setups only.
The Trump-signal bot was originally designed to fire on every high-confidence
post. For a convex (asymmetric-payoff) strategy that's wrong: you want to
trade RARELY but with very high quality. This module decides "is the market
in a regime where this signal has a real shot at a big move?"
Four gates, in order of likely impact:
1. Volatility contraction only trade assets that have been quiet recently.
A coin that already moved 10% today is not setting up; it has set up.
2. Recent-move filter don't chase. If the asset moved >5% in the last 24h,
stand down. The fat tail comes AFTER consolidation, not after a run.
3. Thin-liquidity hours UTC 0309 is Asia thin book. Wider spreads, more
noise stop-outs, fewer institutional bids.
4. BTC counter-trend if BTC is moving hard against the signal direction,
alts get dragged. Don't fight beta.
Each gate returns a bool + reason. The combiner short-circuits on the first
failure for clear logging.
All thresholds are intentionally configurable in one place. Tune them in
backtest, don't sprinkle magic numbers across the codebase.
"""
from __future__ import annotations
import logging
import statistics
from datetime import datetime, timezone
from typing import Optional, Tuple
from app.services.price_store import price_store
logger = logging.getLogger(__name__)
# ─── Tunables (one place to change) ─────────────────────────────────────────
ATR_RECENT_WINDOW_HOURS = 24 # "recent" volatility window
ATR_BASELINE_WINDOW_HOURS = 168 # 7-day baseline to compare against
ATR_CONTRACTION_RATIO = 0.7 # recent must be ≤ 70% of baseline → "contracted"
RECENT_MOVE_WINDOW_HOURS = 24
RECENT_MOVE_MAX_PCT = 5.0 # block entries if asset already moved >5%
BTC_COUNTER_TREND_HOURS = 6
BTC_COUNTER_TREND_PCT = 3.0 # block if BTC moved >3% against signal
THIN_LIQUIDITY_HOURS_UTC = range(3, 9) # 03:0008:59 UTC
# ─── Individual gates ───────────────────────────────────────────────────────
def _atr_proxy(asset: str, hours: int) -> Optional[float]:
"""Average true-range proxy using 1-min candles in price_store.
Returns the mean of (high - low) / close over the window, or None if
we don't have enough data yet. Cheap; recomputes on every call.
"""
asset = asset.upper()
buf = price_store._candles.get(asset)
if not buf:
return None
n_minutes = hours * 60
candles = list(buf)[-n_minutes:]
if len(candles) < n_minutes // 2: # need at least half the window populated
return None
ranges = [
(c["high"] - c["low"]) / c["close"]
for c in candles
if c["close"] > 0
]
if not ranges:
return None
return statistics.fmean(ranges)
def is_volatility_contracted(asset: str) -> Tuple[bool, str]:
"""True if recent ATR ≤ ATR_CONTRACTION_RATIO × baseline ATR.
Returns (ok, reason). On insufficient data we PASS (None means "we don't
know, don't block") to avoid breaking the bot on cold-start. Document this
asymmetry in the regime log.
"""
recent = _atr_proxy(asset, ATR_RECENT_WINDOW_HOURS)
base = _atr_proxy(asset, ATR_BASELINE_WINDOW_HOURS)
if recent is None or base is None or base == 0:
return True, f"vol_contraction: insufficient data ({asset}), passing"
ratio = recent / base
ok = ratio <= ATR_CONTRACTION_RATIO
return ok, f"vol_contraction: ratio={ratio:.2f} (threshold {ATR_CONTRACTION_RATIO})"
def is_not_already_moving(asset: str) -> Tuple[bool, str]:
"""Block entries when the asset already ran in the last 24h."""
asset = asset.upper()
buf = price_store._candles.get(asset)
if not buf or len(buf) < RECENT_MOVE_WINDOW_HOURS * 60:
return True, f"recent_move: insufficient data ({asset}), passing"
candles = list(buf)[-RECENT_MOVE_WINDOW_HOURS * 60:]
first_close = candles[0]["close"]
last_close = candles[-1]["close"]
if first_close == 0:
return True, "recent_move: bad first close, passing"
move_pct = abs(last_close - first_close) / first_close * 100
ok = move_pct <= RECENT_MOVE_MAX_PCT
return ok, f"recent_move: {move_pct:.2f}% in {RECENT_MOVE_WINDOW_HOURS}h (max {RECENT_MOVE_MAX_PCT}%)"
def is_not_thin_liquidity_hour() -> Tuple[bool, str]:
"""Block during Asia thin-book hours (UTC 0309)."""
hour_utc = datetime.now(timezone.utc).hour
ok = hour_utc not in THIN_LIQUIDITY_HOURS_UTC
return ok, f"thin_liquidity: UTC {hour_utc:02d}h"
def is_btc_not_countertrend(side: str) -> Tuple[bool, str]:
"""Block if BTC is moving hard against the signal direction.
side = 'long' or 'short'. Long signal + BTC dumping > 3% block.
"""
buf = price_store._candles.get("BTC")
if not buf or len(buf) < BTC_COUNTER_TREND_HOURS * 60:
return True, "btc_trend: insufficient BTC data, passing"
candles = list(buf)[-BTC_COUNTER_TREND_HOURS * 60:]
first_close = candles[0]["close"]
last_close = candles[-1]["close"]
if first_close == 0:
return True, "btc_trend: bad first close, passing"
btc_change_pct = (last_close - first_close) / first_close * 100
# Long signal but BTC dumping → bad. Short signal but BTC pumping → bad.
countertrend = (
(side == "long" and btc_change_pct < -BTC_COUNTER_TREND_PCT) or
(side == "short" and btc_change_pct > BTC_COUNTER_TREND_PCT)
)
ok = not countertrend
return ok, f"btc_trend: BTC {btc_change_pct:+.2f}% in {BTC_COUNTER_TREND_HOURS}h vs {side}"
# ─── Master combiner ────────────────────────────────────────────────────────
def passes_regime_filter(asset: str, side: str) -> Tuple[bool, list[str]]:
"""Run every gate; return (passed_all, list_of_reasons).
Gates are evaluated in cheap-to-expensive order so we short-circuit on
the first failure when iterating callers want fast-rejection paths.
"""
reasons: list[str] = []
checks = [
is_not_thin_liquidity_hour(),
is_not_already_moving(asset),
is_volatility_contracted(asset),
is_btc_not_countertrend(side),
]
passed_all = True
for ok, reason in checks:
reasons.append(("" if ok else "") + reason)
if not ok:
passed_all = False
return passed_all, reasons
# ─── Position sizing (C) ────────────────────────────────────────────────────
def calculate_size_multiplier(
ai_confidence: int,
asset: str,
side: str,
) -> float:
"""Score-based multiplier on `position_size_usd`.
Each green box adds a point; more points bigger bet. This is the
Druckenmiller "when you have conviction, swing for the fences" idea
expressed numerically. Capped at 4× so a single misread doesn't blow
the daily budget.
Scoring:
+1 if AI confidence 90
+1 if volatility is contracted (we're at the start of the spring, not the end)
+1 if asset hasn't already run today (still room to go)
+1 if BTC is supportive of the direction (beta in our favour)
"""
score = 0
if ai_confidence >= 90:
score += 1
vc_ok, _ = is_volatility_contracted(asset)
if vc_ok:
score += 1
nm_ok, _ = is_not_already_moving(asset)
if nm_ok:
score += 1
bt_ok, _ = is_btc_not_countertrend(side)
if bt_ok:
score += 1
multipliers = {0: 0.5, 1: 1.0, 2: 1.5, 3: 2.5, 4: 4.0}
return multipliers[score]
# ─── Manual-window helper (D) ───────────────────────────────────────────────
def is_in_manual_window(manual_window_until: Optional[datetime]) -> bool:
"""True if a manual-enable timestamp is set and still in the future.
`manual_window_until` is stored as naive-UTC in DB.
"""
if manual_window_until is None:
return False
now_naive = datetime.now(timezone.utc).replace(tzinfo=None)
return manual_window_until > now_naive
+175
View File
@@ -0,0 +1,175 @@
"""
Scanner runtime state + control plane.
One module to answer: "what scanners are alive, when did each last fire, and
how do I turn one off without restarting the server?"
Why this exists (3 problems it solves at once):
1. KILL SWITCH Production safety. If a scanner is misbehaving (false
fires, hammering an API, leaking memory) the operator needs to disable
it WITHOUT redeploying. Per-scanner toggle + "stop all" both required.
2. COOLDOWN PERSISTENCE Previously each scanner kept _last_signal_at as
a process-local dict. A backend restart wiped that dict, so a scanner
that fired yesterday would happily re-fire today. Now cooldown is read
from the posts table (source-of-truth) via `last_signal_at()`.
3. OBSERVABILITY Operator needs to see "is my RSI scanner alive?". The
in-memory state tracker records every run (success / fire / error) so
a GET /api/scanners endpoint can show the whole fleet at a glance.
State is intentionally process-local (no DB writes). After restart all
scanners come back ENABLED same as the rest of the system. Cooldowns
survive restart because they're DB-derived. If you want a "permanently
disabled" scanner, comment it out of main.py instead.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field, asdict
from datetime import datetime, timedelta, timezone
from typing import Optional
logger = logging.getLogger(__name__)
# ─── State container ────────────────────────────────────────────────────────
@dataclass
class ScannerState:
name: str
enabled: bool = True
last_run_at: Optional[datetime] = None
last_status: str = "never_ran" # never_ran | ok | fired | error
last_message: Optional[str] = None
last_fired_at: Optional[datetime] = None
consecutive_errors: int = 0
total_runs: int = 0
total_fires: int = 0
def to_dict(self) -> dict:
d = asdict(self)
for k in ("last_run_at", "last_fired_at"):
if d[k] is not None:
# last_run_at is tz-aware (datetime.now(timezone.utc)), so
# isoformat() already emits "...+00:00". Appending "Z" would
# produce the invalid "...+00:00Z" which JS Date can't parse
# (renders as "NaNd ago" in the UI). Just use isoformat().
d[k] = d[k].isoformat()
return d
_STATES: dict[str, ScannerState] = {}
# ─── Registration (called once per scanner at import time) ──────────────────
def register(name: str) -> ScannerState:
if name not in _STATES:
_STATES[name] = ScannerState(name=name)
return _STATES[name]
# ─── Toggle controls ────────────────────────────────────────────────────────
def is_enabled(name: str) -> bool:
"""Return False ONLY if explicitly disabled. Unknown scanners default
to enabled a scanner shouldn't silently refuse to run because its
state wasn't registered (defensive)."""
s = _STATES.get(name)
return s.enabled if s else True
def set_enabled(name: str, enabled: bool) -> Optional[ScannerState]:
s = _STATES.get(name)
if s is None:
return None
s.enabled = enabled
logger.info("Scanner %s %s", name, "ENABLED" if enabled else "DISABLED")
return s
def disable_all() -> int:
"""Kill switch. Returns count of scanners that were running and got
flipped off. Already-disabled scanners are skipped."""
n = 0
for s in _STATES.values():
if s.enabled:
s.enabled = False
n += 1
if n:
logger.warning("Scanner kill switch: disabled %d scanners", n)
return n
def enable_all() -> int:
n = 0
for s in _STATES.values():
if not s.enabled:
s.enabled = True
n += 1
return n
def get_all() -> list[ScannerState]:
return list(_STATES.values())
# ─── Per-run telemetry (called by scanners after each scan) ─────────────────
def record_run(name: str, status: str, message: Optional[str] = None) -> None:
"""status: 'ok' (ran, no signal), 'fired' (emitted a signal), 'error'."""
s = register(name)
s.last_run_at = datetime.now(timezone.utc)
s.last_status = status
s.last_message = message[:240] if message else None
s.total_runs += 1
if status == "fired":
s.total_fires += 1
s.last_fired_at = s.last_run_at
s.consecutive_errors = 0
elif status == "error":
s.consecutive_errors += 1
else:
s.consecutive_errors = 0
# ─── DB-backed cooldown (survives restart) ──────────────────────────────────
async def last_signal_at(source: str, target_asset: str) -> Optional[datetime]:
"""Most recent post with the given source+target_asset. Returns naive UTC.
Used by scanners INSTEAD of in-memory cooldown trackers. The DB is the
authoritative record of "did this scanner already fire?" surviving
restarts, multi-process deploys, and even DB migrations.
"""
from sqlalchemy import select, func
from app.database import AsyncSessionLocal
from app.models import Post
async with AsyncSessionLocal() as db:
result = await db.execute(
select(func.max(Post.published_at)).where(
Post.source == source,
Post.target_asset == target_asset.upper(),
)
)
ts = result.scalar_one_or_none()
return ts
async def in_cooldown(source: str, target_asset: str, cooldown_days: int) -> bool:
"""Convenience wrapper. True iff we fired the same {source, asset} within
the last `cooldown_days`."""
last = await last_signal_at(source, target_asset)
if last is None:
return False
age = datetime.now(timezone.utc).replace(tzinfo=None) - last
return age < timedelta(days=cooldown_days)
View File
+12
View File
@@ -0,0 +1,12 @@
# Archived scanners (not in the live path)
These were generic System-2 scanners superseded by the focused
`btc_bottom_reversal` state machine. Nothing imports them; they are NOT
scheduled and do NOT register with scanner_state. Kept for reference/history.
- vcp_breakout.py — volatility-contraction breakout (bidirectional)
- weekly_rsi_reversal.py — weekly RSI extreme + recovery (bidirectional)
To revive: re-add an APScheduler job in app/main.py and a
scanner_state.register() call, and ensure the source tag is in
signal_categories.SYSTEM_2_SOURCES.
@@ -0,0 +1,251 @@
"""
VCP-style breakout scanner example of an external signal module.
What this scans for (Minervini-light, adapted for crypto perps):
1. Volatility contraction: 7-day ATR 50% of 30-day ATR
(the price has been getting tighter supply is drying up)
2. Breakout confirmation: current 4h close > max(prior 30-day highs)
(a real break of the range, not just touching the top)
3. Volume confirmation: last 24h volume 1.5× 30-day average
(institutional participation, not retail noise)
4. Cooldown: no signal for the same asset in the past 48h
(avoid stacking)
When all 4 conditions hit, the scanner POSTs to /api/signals/ingest. From
there the trade goes through the same convex-strategy pipeline as Trump
signals regime filter, sizing, trailing stop, all of it.
Why this module exists:
Trump signals have 1-2 true catalysts per month. Adding a TA-based source
that finds setups continuously means the bot has SOMETHING to act on most
weeks, instead of waiting for geopolitical fireworks. The user's hypothesis
is that consolidation breakouts have asymmetric upside; the existing
trailing-stop logic is purpose-built to capture that.
Tunables (don't hardcode in caller — change them HERE if you want to test):
ATR_RECENT_DAYS, ATR_BASELINE_DAYS, ATR_RATIO_MAX,
BREAKOUT_LOOKBACK_DAYS, VOLUME_MULT_MIN, COOLDOWN_HOURS.
Asset list:
Default = SOL only. Extend ASSETS_TO_SCAN with more once you've watched
this run for a week and confirmed it doesn't fire-hose noise.
"""
from __future__ import annotations
import logging
import statistics
from datetime import datetime, timedelta, timezone
from typing import Optional
import httpx
from app.config import settings
from app.services.market_data import for_asset, drop_in_progress_bar
from app.services import scanner_state
logger = logging.getLogger(__name__)
SCANNER_NAME = "vcp_breakout"
scanner_state.register(SCANNER_NAME)
# ─── Tunables ───────────────────────────────────────────────────────────────
ATR_RECENT_DAYS = 7
ATR_BASELINE_DAYS = 30
ATR_RATIO_MAX = 0.5 # recent ATR must be ≤ 50% of baseline
BREAKOUT_LOOKBACK_DAYS = 30 # break of this many days' high
VOLUME_MULT_MIN = 1.5 # current 24h vol vs 30-day avg
COOLDOWN_HOURS = 48 # don't re-signal same asset within this window
# Assets to scan. Provider selection (Binance vs HL) is automatic via
# market_data.for_asset() — HL-native perps like TRUMP/HYPE route to HL
# automatically. Add to HL_NATIVE_ASSETS in market_data.py if needed.
ASSETS_TO_SCAN = [
"SOL",
# "ETH", "LINK", "AVAX", # Binance has them
# "TRUMP", "HYPE", # HL-native (routed automatically)
]
# Cooldown via scanner_state.in_cooldown — DB-backed, restart-safe.
# Data fetching is delegated to market_data.for_asset() — see that module
# for Binance vs Hyperliquid routing.
# ─── Signal logic ───────────────────────────────────────────────────────────
def _atr_proxy(candles: list[dict]) -> Optional[float]:
"""Mean of (high-low)/close — simple ATR proxy, dimensionless."""
ranges = [(c["high"] - c["low"]) / c["close"] for c in candles if c["close"] > 0]
return statistics.fmean(ranges) if ranges else None
def evaluate_breakout(candles: list[dict]) -> tuple[bool, dict]:
"""Returns (is_signal, debug_info). All 4 gates must pass.
Pure function no side effects, fully testable.
"""
if len(candles) < ATR_BASELINE_DAYS * 6:
return False, {"reason": "insufficient_data", "bars": len(candles)}
# 1. Volatility contraction
recent_bars = candles[-ATR_RECENT_DAYS * 6:]
baseline_bars = candles[-ATR_BASELINE_DAYS * 6:]
atr_recent = _atr_proxy(recent_bars)
atr_baseline = _atr_proxy(baseline_bars)
if not atr_recent or not atr_baseline:
return False, {"reason": "atr_calc_failed"}
atr_ratio = atr_recent / atr_baseline
if atr_ratio > ATR_RATIO_MAX:
return False, {"reason": "no_contraction", "atr_ratio": round(atr_ratio, 3)}
# 2. Range break — UP through prior high (long) or DOWN through prior
# low (short). Lookback excludes the current bar.
lookback = candles[-BREAKOUT_LOOKBACK_DAYS * 6 : -1]
if not lookback:
return False, {"reason": "no_lookback"}
prior_high = max(c["high"] for c in lookback)
prior_low = min(c["low"] for c in lookback)
current_close = candles[-1]["close"]
if current_close > prior_high:
direction, ref, gap = "buy", prior_high, (current_close - prior_high) / prior_high * 100
elif current_close < prior_low:
direction, ref, gap = "short", prior_low, (prior_low - current_close) / prior_low * 100
else:
return False, {"reason": "no_range_break", "close": current_close,
"prior_high": prior_high, "prior_low": prior_low}
# 3. Volume confirmation (same for both directions — a real break needs
# participation regardless of which way it goes)
recent_vol_24h = sum(c["volume"] for c in candles[-6:])
baseline_avg_24h = sum(c["volume"] for c in candles[-180:]) / 30
if baseline_avg_24h <= 0:
return False, {"reason": "no_volume_data"}
vol_ratio = recent_vol_24h / baseline_avg_24h
if vol_ratio < VOLUME_MULT_MIN:
return False, {"reason": "weak_volume", "vol_ratio": round(vol_ratio, 2)}
return True, {
"direction": direction,
"atr_ratio": round(atr_ratio, 3),
"vol_ratio": round(vol_ratio, 2),
"break_ref": round(ref, 4),
"break_at": round(current_close, 4),
"headroom_pct": round(gap, 2),
}
def _confidence_from(debug: dict) -> int:
"""Map the signal's quality into a 0-100 confidence used by sizing.
The tighter the contraction and the bigger the volume spike, the higher
the confidence. Capped at 95 leave headroom so AI signals at 100 stay
distinguishable.
"""
score = 70 # baseline for "all 4 gates passed"
# Tighter contraction → higher confidence
if debug.get("atr_ratio", 1) < 0.35: score += 10
elif debug.get("atr_ratio", 1) < 0.45: score += 5
# Bigger volume spike → higher confidence
if debug.get("vol_ratio", 0) >= 3.0: score += 10
elif debug.get("vol_ratio", 0) >= 2.0: score += 5
return min(score, 95)
# ─── Ingest call ────────────────────────────────────────────────────────────
async def _emit_signal(asset: str, debug: dict) -> None:
"""POST the signal to /api/signals/ingest. Treats the local server as the
target same machine, no network hop in dev.
"""
if not settings.ingest_api_key:
logger.warning("VCP: signal would fire on %s but INGEST_API_KEY is empty", asset)
return
confidence = _confidence_from(debug)
direction = debug["direction"]
way = "breakout ↑" if direction == "buy" else "breakdown ↓"
rel = "above" if direction == "buy" else "below"
payload = {
"source": "breakout",
"external_id": f"vcp-{asset}-{direction}-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M')}",
"text": (
f"VCP {way} on {asset}: ATR ratio {debug['atr_ratio']} "
f"(7d/30d), {debug['vol_ratio']}× normal volume, "
f"close ${debug['break_at']} {rel} {BREAKOUT_LOOKBACK_DAYS}-day "
f"level ${debug['break_ref']} ({debug['headroom_pct']}%)"
),
"signal": direction,
"target_asset": asset,
"confidence": confidence,
"category": "vcp_breakout",
"expected_move_pct": 5.0, # historical VCP median expansion
}
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
"http://localhost:8000/api/signals/ingest",
json=payload,
headers={"X-Ingest-Key": settings.ingest_api_key},
)
if resp.status_code >= 400:
logger.error("VCP ingest failed (%d): %s", resp.status_code, resp.text[:200])
else:
logger.info("VCP signal emitted for %s, confidence=%d: %s",
asset, confidence, resp.json())
# ─── Scheduler entry point ──────────────────────────────────────────────────
async def scan_once() -> None:
"""Single scan pass over ASSETS_TO_SCAN. Called every 30 minutes.
Kill-switch + DB-backed cooldown. Drops in-progress 4h bar before
evaluating without that, the breakout test ran against a partial bar
and could flicker FIRE / no-FIRE within a 4h window.
"""
if not scanner_state.is_enabled(SCANNER_NAME):
logger.debug("VCP scanner disabled — skipping run")
return
fired_any = False
error_msg: Optional[str] = None
for asset in ASSETS_TO_SCAN:
# Cooldown — DB-backed, restart-safe.
cooldown_days = COOLDOWN_HOURS / 24
if await scanner_state.in_cooldown("breakout", asset, cooldown_days):
continue
provider = for_asset(asset)
try:
candles = await provider.fetch_4h(asset, days=ATR_BASELINE_DAYS + 1)
except Exception as exc:
error_msg = f"{asset}: {exc}"
logger.error("VCP fetch failed for %s via %s: %s", asset, provider.name, exc)
continue
candles = drop_in_progress_bar(candles, "4h")
if not candles:
logger.warning("VCP: %s returned 0 candles from %s — symbol may not exist",
asset, provider.name)
continue
is_signal, debug = evaluate_breakout(candles)
logger.info("VCP scan %s [%s]: %s%s", asset, provider.name,
"FIRE" if is_signal else "no", debug)
if is_signal:
await _emit_signal(asset, debug)
fired_any = True
if error_msg:
scanner_state.record_run(SCANNER_NAME, "error", error_msg)
elif fired_any:
scanner_state.record_run(SCANNER_NAME, "fired")
else:
scanner_state.record_run(SCANNER_NAME, "ok")
@@ -0,0 +1,264 @@
"""
Weekly RSI extreme + recovery scanner.
What it catches:
Severe weekly oversold conditions that flush capitulating holders, followed
by the first sign of strength. Historical examples this would have hit:
BTC 2022-11 ($15.5k bottom)
ETH 2022-06 ($900 bottom)
SOL 2022-12 ($8 bottom)
AAVE 2022-06 ($45 bottom)
Trigger logic (intentionally strict):
PRE-CONDITION: 4 consecutive completed weekly bars with RSI(14) < 30
(this is the capitulation phase sustained extreme weakness)
TRIGGER: current completed week's RSI(14) >= 35
(i.e. the FIRST recovery week RSI has lifted off the floor)
COOLDOWN: 60 days these events happen ~once per year per asset.
No re-firing on the same setup.
Companion exit parameters (passed to the bot via signal payload wider stops
to match the longer holding period and higher single-trade conviction):
SL = 8%
TRAILING_ACTIVATE = 15%
TRAILING_STOP = 6%
MAX_HOLD = 60 days
This is a high-conviction, low-frequency signal. Expect 0-3 fires per asset
per year. Don't tune it for higher frequency — that defeats the point.
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional
import httpx
from app.config import settings
from app.services.market_data import REVERSAL_BASKET, for_asset, drop_in_progress_bar
from app.services import scanner_state
SCANNER_NAME = "weekly_rsi_reversal"
scanner_state.register(SCANNER_NAME)
logger = logging.getLogger(__name__)
# ─── Tunables ───────────────────────────────────────────────────────────────
RSI_PERIOD = 14
RSI_OVERSOLD = 30 # "deeply oversold" floor (long setup)
RSI_RECOVERY_TRIGGER = 35 # bounce-off line for the long
RSI_OVERBOUGHT = 70 # "euphoric" ceiling (short setup)
RSI_ROLLOVER_TRIGGER = 65 # roll-off line for the short
WEEKS_OF_OVERSOLD = 4 # consecutive extreme weeks required (both sides)
WEEKS_TO_FETCH = 40 # enough history for stable Wilder's RSI
COOLDOWN_DAYS = 60
# Exit profile passed to /signals/ingest. Caller can override on the bot side.
PAYLOAD_CONFIDENCE = 88 # high conviction — top of the regime-filter score
PAYLOAD_EXPECTED_MOVE = 30.0 # historical median move after capitulation
# Cooldown is now DB-backed via scanner_state.in_cooldown() — survives
# restart. No more in-memory _last_signal_at dict.
# ─── RSI math (Wilder's smoothed) ───────────────────────────────────────────
def calculate_rsi(closes: list[float], period: int = 14) -> list[Optional[float]]:
"""Wilder's RSI. Returns a list aligned with `closes` where the first
`period` entries are None (not enough history yet).
Implementation note: the first valid RSI uses a SIMPLE mean of the first
`period` gains/losses, subsequent values use exponential smoothing with
alpha = 1/period. This matches TradingView, Binance UI, and most charting
tools the "Cutler's RSI" (pure SMA) variant would give different results.
"""
n = len(closes)
rsi: list[Optional[float]] = [None] * n
if n < period + 1:
return rsi
gains = [max(closes[i] - closes[i - 1], 0) for i in range(1, n)]
losses = [max(closes[i - 1] - closes[i], 0) for i in range(1, n)]
# First valid RSI = simple mean over first `period` deltas
avg_gain = sum(gains[:period]) / period
avg_loss = sum(losses[:period]) / period
if avg_loss == 0:
rsi[period] = 100.0
else:
rs = avg_gain / avg_loss
rsi[period] = 100 - 100 / (1 + rs)
# Subsequent values use Wilder's smoothing
for i in range(period + 1, n):
avg_gain = (avg_gain * (period - 1) + gains[i - 1]) / period
avg_loss = (avg_loss * (period - 1) + losses[i - 1]) / period
if avg_loss == 0:
rsi[i] = 100.0
else:
rs = avg_gain / avg_loss
rsi[i] = 100 - 100 / (1 + rs)
return rsi
# ─── Signal logic ───────────────────────────────────────────────────────────
def evaluate_rsi_reversal(weekly_candles: list[dict]) -> tuple[bool, dict]:
"""Pure function — no side effects, fully testable.
Returns (is_signal, debug_info).
"""
if len(weekly_candles) < RSI_PERIOD + WEEKS_OF_OVERSOLD + 2:
return False, {"reason": "insufficient_data", "bars": len(weekly_candles)}
closes = [c["close"] for c in weekly_candles]
rsi = calculate_rsi(closes, RSI_PERIOD)
# The MOST RECENT weekly bar is `closes[-1]`. We treat it as the "current"
# recovery candidate. Look BACKWARDS for WEEKS_OF_OVERSOLD prior bars all
# with RSI < RSI_OVERSOLD.
current_rsi = rsi[-1]
if current_rsi is None:
return False, {"reason": "rsi_not_computable"}
window = rsi[-(WEEKS_OF_OVERSOLD + 1):-1] # the N weeks before current
if any(r is None for r in window):
return False, {"reason": "history_gaps_in_window"}
this_close = weekly_candles[-1]["close"]
prev_close = weekly_candles[-2]["close"]
# ── LONG: capitulation bottom ──────────────────────────────────────────
# Sustained RSI<30, now lifting ≥35, price turning up.
if (current_rsi >= RSI_RECOVERY_TRIGGER
and all(r < RSI_OVERSOLD for r in window)
and this_close > prev_close):
low_w = min(c["low"] for c in weekly_candles[-(WEEKS_OF_OVERSOLD + 1):])
return True, {
"direction": "buy",
"current_rsi": round(current_rsi, 1),
"window_rsi": [round(r, 1) for r in window],
"move_pct": round((this_close - low_w) / low_w * 100, 2) if low_w > 0 else 0.0,
"trigger_close": round(this_close, 4),
}
# ── SHORT: euphoric top ────────────────────────────────────────────────
# Sustained RSI>70, now rolling ≤65, price turning down. Symmetric mirror.
if (current_rsi <= RSI_ROLLOVER_TRIGGER
and all(r > RSI_OVERBOUGHT for r in window)
and this_close < prev_close):
high_w = max(c["high"] for c in weekly_candles[-(WEEKS_OF_OVERSOLD + 1):])
return True, {
"direction": "short",
"current_rsi": round(current_rsi, 1),
"window_rsi": [round(r, 1) for r in window],
"move_pct": round((high_w - this_close) / high_w * 100, 2) if high_w > 0 else 0.0,
"trigger_close": round(this_close, 4),
}
# Neither side qualified — report the closest miss for the log.
return False, {
"reason": "no_setup",
"current_rsi": round(current_rsi, 1),
"window_rsi": [round(r, 1) for r in window],
}
# ─── Ingest emission ────────────────────────────────────────────────────────
async def _emit_signal(asset: str, debug: dict) -> None:
if not settings.ingest_api_key:
logger.warning("RSI-reversal would fire on %s but INGEST_API_KEY empty", asset)
return
direction = debug["direction"]
side_word = "capitulation bottom" if direction == "buy" else "euphoric top"
payload = {
"source": "rsi_reversal",
"external_id": f"rsi-{asset}-{direction}-{datetime.now(timezone.utc).strftime('%Y%W')}",
"text": (
f"Weekly RSI {side_word} on {asset}: RSI {debug['current_rsi']} after "
f"{WEEKS_OF_OVERSOLD}wk extreme (window: {debug['window_rsi']}). "
f"{debug['move_pct']}% move off the extreme @ ${debug['trigger_close']}."
),
"signal": direction,
"target_asset": asset,
"confidence": PAYLOAD_CONFIDENCE,
"category": "rsi_extreme_reversal",
"expected_move_pct": PAYLOAD_EXPECTED_MOVE,
}
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
"http://localhost:8000/api/signals/ingest",
json=payload,
headers={"X-Ingest-Key": settings.ingest_api_key},
)
if resp.status_code >= 400:
logger.error("RSI-reversal ingest failed (%d): %s", resp.status_code, resp.text[:200])
else:
logger.info("RSI-reversal signal emitted for %s: %s", asset, resp.json())
# ─── Scheduler entry point ──────────────────────────────────────────────────
async def scan_once() -> None:
"""One scan pass over REVERSAL_BASKET. Called by APScheduler.
Kill-switch aware: short-circuits if the operator disabled this scanner.
Cooldown is DB-backed (queries posts table), so restarts don't reset
state.
"""
if not scanner_state.is_enabled(SCANNER_NAME):
logger.debug("RSI-reversal scanner disabled — skipping run")
return
fired_any = False
error_msg: Optional[str] = None
for asset in REVERSAL_BASKET:
# Cooldown — DB-backed.
if await scanner_state.in_cooldown("rsi_reversal", asset, COOLDOWN_DAYS):
continue
provider = for_asset(asset)
try:
candles = await provider.fetch_1w(asset, weeks=WEEKS_TO_FETCH)
except Exception as exc:
error_msg = f"{asset}: {exc}"
logger.error("RSI-reversal fetch failed for %s via %s: %s",
asset, provider.name, exc)
continue
candles = drop_in_progress_bar(candles, "1w")
if not candles:
logger.warning("RSI-reversal: %s returned 0 weekly bars from %s",
asset, provider.name)
continue
is_signal, debug = evaluate_rsi_reversal(candles)
if is_signal:
logger.info("RSI-reversal scan %s [%s]: FIRE — %s", asset, provider.name, debug)
await _emit_signal(asset, debug)
fired_any = True
else:
logger.debug("RSI-reversal scan %s [%s]: no — %s", asset, provider.name, debug)
if error_msg:
scanner_state.record_run(SCANNER_NAME, "error", error_msg)
elif fired_any:
scanner_state.record_run(SCANNER_NAME, "fired")
else:
scanner_state.record_run(SCANNER_NAME, "ok")
@@ -0,0 +1,148 @@
"""BTC bottom-reversal long scanner — 2-of-3 price confluence.
Simple, transparent, no on-chain data and no API keys. Fires only when at
least TWO of these three classic bottom signals agree:
A. AHR999 < 0.45 deep-value / bottom zone
B. price 200-week MA ×1.05 every cycle has bottomed near the 200WMA
C. Pi Cycle Bottom 150d EMA 471d SMA × 0.745
Long only, low frequency, stateless. No tight stop (real macro bottoms wick
1530%); the position is invalidated when AHR999 climbs back above 1.2 i.e.
BTC is no longer cheap and the bottom thesis is spent.
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
import httpx
from app.config import settings
from app.services import scanner_state
from app.services.market_data import for_asset, drop_in_progress_bar
from app.services.bottom_indicators import bottom_confluence
SCANNER_NAME = "btc_bottom_reversal"
scanner_state.register(SCANNER_NAME)
logger = logging.getLogger(__name__)
ASSET = "BTC"
COOLDOWN_DAYS = 30
# 3 votes → max conviction; 2 votes → solid. Scale confidence with agreement.
CONFIDENCE_BY_VOTES = {2: 88, 3: 94}
EXPECTED_MOVE_PCT = 25.0
# Pi Cycle needs 471 daily closes; +buffer for the dropped in-progress bar.
DAILY_LOOKBACK_DAYS = 520
# 200-week MA needs 200 weekly closes; +buffer.
WEEKLY_LOOKBACK_WEEKS = 210
async def evaluate_once() -> tuple[bool, dict]:
provider = for_asset(ASSET)
raw_daily = await provider.fetch_1d(ASSET, days=DAILY_LOOKBACK_DAYS)
daily = drop_in_progress_bar(raw_daily, "1d")
raw_weekly = await provider.fetch_1w(ASSET, weeks=WEEKLY_LOOKBACK_WEEKS)
weekly = drop_in_progress_bar(raw_weekly, "1w")
if not daily:
return False, {"reason": "missing_btc_daily"}
if not weekly:
return False, {"reason": "missing_btc_weekly"}
daily_closes = [float(c["close"]) for c in daily if c.get("close")]
weekly_closes = [float(c["close"]) for c in weekly if c.get("close")]
conf = bottom_confluence(daily_closes, weekly_closes)
debug: dict = dict(conf.detail)
if not conf.fired:
debug["reason"] = "confluence_not_met"
return False, debug
confidence = CONFIDENCE_BY_VOTES.get(conf.votes, 88)
debug["confidence"] = confidence
# No tight stop. Thesis is invalidated when BTC is no longer cheap; the
# 200WMA band is a useful structural reference for the exit monitor.
debug["invalidation_price"] = debug.get("wma200")
return True, debug
def _summary(debug: dict) -> str:
s = debug.get("signals", {})
on = [k for k, v in s.items() if v]
return (
f"BTC bottom confluence ({debug.get('votes')}/3): {', '.join(on)}. "
f"AHR999 {debug.get('ahr999')} (<{debug.get('ahr999_threshold')}), "
f"price {debug.get('price')}, 200WMA {debug.get('wma200')}, "
f"Pi 150EMA {debug.get('pi_ema150')} vs 471SMA×0.745 "
f"{debug.get('pi_sma471_scaled')}. No tight stop; "
f"invalidate if AHR999 > 1.2."
)
async def _emit_signal(debug: dict) -> bool:
"""POST the signal to the ingest endpoint. Returns True iff a Post was
(idempotently) created. False means the signal was NOT recorded caller
must NOT treat the run as 'fired' (cooldown is keyed off the DB Post)."""
if not settings.ingest_api_key:
logger.error("BTC bottom-reversal would fire but INGEST_API_KEY empty "
"— signal NOT recorded, check deploy env")
return False
payload = {
"source": "btc_bottom_reversal",
"external_id": f"btc-bottom-{datetime.now(timezone.utc).strftime('%Y%m%d')}",
"text": _summary(debug),
"signal": "buy",
"target_asset": ASSET,
"confidence": debug["confidence"],
"category": "btc_bottom_reversal_long",
"expected_move_pct": EXPECTED_MOVE_PCT,
"invalidation_price": debug.get("invalidation_price"),
}
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
"http://localhost:8000/api/signals/ingest",
json=payload,
headers={"X-Ingest-Key": settings.ingest_api_key},
)
if resp.status_code >= 400:
logger.error("BTC bottom-reversal ingest failed (%d): %s",
resp.status_code, resp.text[:200])
return False
logger.info("BTC bottom-reversal signal emitted: %s", resp.json())
return True
async def scan_once() -> None:
if not scanner_state.is_enabled(SCANNER_NAME):
logger.debug("BTC bottom-reversal scanner disabled — skipping run")
return
if await scanner_state.in_cooldown("btc_bottom_reversal", ASSET, COOLDOWN_DAYS):
logger.debug("BTC bottom-reversal cooldown active")
scanner_state.record_run(SCANNER_NAME, "ok", "cooldown")
return
try:
should_fire, debug = await evaluate_once()
except Exception as exc:
logger.error("BTC bottom-reversal scan failed: %s", exc)
scanner_state.record_run(SCANNER_NAME, "error", str(exc))
return
if should_fire:
logger.info("BTC bottom-reversal FIRE — %s", debug)
emitted = await _emit_signal(debug)
if emitted:
scanner_state.record_run(SCANNER_NAME, "fired")
else:
# Not recorded → no DB Post → cooldown won't arm. Surface this as
# an error so a misconfigured deploy is obvious, not a silent
# daily "fired" that never actually trades.
scanner_state.record_run(SCANNER_NAME, "error", "ingest_failed_or_key_missing")
else:
logger.info("BTC bottom-reversal no — %s", debug)
scanner_state.record_run(SCANNER_NAME, "ok")
+379
View File
@@ -0,0 +1,379 @@
"""
Funding rate extreme + reversal scanner.
What it catches:
Crowded perp positioning that's about to unwind. When one side has been
paying ridiculous funding for weeks, the eventual unwind ("the squeeze")
is the cleanest single-day move you'll find. Examples:
BTC 2024-08 funding deeply +ve for 30d 14% flush down within a week
SOL 2023-09 funding deeply -ve for 30d 60% rally over the next month
ETH 2024-Q4 funding +3.5% on 30d 12% pullback
Trigger logic:
PRE-CONDITION: Sum of last 30 days of funding rate (per 8h cycle)
exceeds ±FUNDING_EXTREME_PCT. The sign tells us which
side is overcrowded:
sum > +3% longs have been paying bearish setup
(signal = short)
sum < -3% shorts paying bullish setup
(signal = buy)
TRIGGER: Funding direction has STARTED to mean-revert
(last 3 funding cycles closer to zero than the 30d avg)
AND price has begun moving in the contrarian direction
(last 7 days price move at least 3% in that direction).
COOLDOWN: 14 days. Funding extremes can persist for weeks but
each unwind is a distinct event.
Companion exits tighter than RSI/SMA because funding moves play out faster
(days, not weeks):
SL = 4%
TRAILING_ACTIVATE = 10%
TRAILING_STOP = 5%
MAX_HOLD = 30 days
This is the highest-frequency of the three reversal signals expect 3-5
fires per asset per year and the most "alpha-like" (most market participants
ignore funding entirely).
"""
from __future__ import annotations
import logging
import statistics
from datetime import datetime, timedelta, timezone
from typing import Optional
import httpx
from app.config import settings
from app.services import scanner_state
from app.services.market_data import REVERSAL_BASKET, for_asset, drop_in_progress_bar
# Promoted from booster → standalone scanner. Still importable by
# btc_bottom_reversal for confluence boost; ALSO emits its own signals via
# scan_once() registered with the APScheduler in app/main.py.
SCANNER_NAME = "funding_reversal"
scanner_state.register(SCANNER_NAME)
ASSET = "BTC" # BTC-only for now; extend to ETH/SOL after results validate
# How much funding+price history to load each tick.
# - Binance: 8h cadence → 30d ≈ 90 cycles
# - HL: 1h cadence → capped at 500 rows ≈ 20.8d (handled inside evaluate)
FUNDING_DAYS_LOOKBACK = 30
PRICE_DAYS_LOOKBACK = 35 # need 7d confirm + buffer for in-progress bar
logger = logging.getLogger(__name__)
# ─── Tunables ───────────────────────────────────────────────────────────────
FUNDING_HISTORY_DAYS = 30
FUNDING_EXTREME_THRESHOLD = 0.03 # 3% cumulative — extreme one-sided pressure
# CRITICAL: "recent" is in HOURS, not CYCLES. Binance funding is 8h-cadence
# (3 cycles/day) but HL is 1h-cadence (24 cycles/day) — a fixed "3 cycles
# lookback" means 24h on Binance but only 3h on HL. We pick cycles dynamically
# based on the response's actual cadence so 24h of funding history is always
# the comparison window.
FUNDING_REVERSAL_LOOKBACK_HOURS = 24
FUNDING_REVERSAL_RATIO = 0.5 # recent avg must be ≤ 50% of 30d-avg in magnitude
PRICE_CONFIRM_DAYS = 7
PRICE_CONFIRM_PCT = 3.0 # price moved 3%+ in contrarian direction
COOLDOWN_DAYS = 14
PAYLOAD_CONFIDENCE = 82 # slightly lower than RSI/SMA — funding can chop
PAYLOAD_EXPECTED_MOVE = 10.0
EMIT_STANDALONE_SIGNALS = True # promoted from booster → standalone signal source
# Cooldown via scanner_state.in_cooldown — DB-backed, restart-safe.
# ─── Signal logic ───────────────────────────────────────────────────────────
def _detect_cadence_hours(funding: list[dict]) -> float:
"""Infer the funding cadence (hours between cycles) from the response.
Binance returns ~8h intervals; HL returns ~1h. We can't assume — the
response itself is the source of truth. Average over the most recent 10
intervals to smooth out occasional gaps.
"""
if len(funding) < 2:
return 8.0 # safe Binance default
sample = funding[-min(11, len(funding)):]
diffs = [sample[i]["time_ms"] - sample[i - 1]["time_ms"] for i in range(1, len(sample))]
if not diffs:
return 8.0
return statistics.fmean(diffs) / 3_600_000.0 # ms → hours
MIN_COVERAGE_DAYS = 14 # below this, the signal is statistically too noisy
def evaluate_funding_reversal(
funding_history: list[dict],
daily_candles: list[dict],
) -> tuple[bool, dict]:
"""Pure function. Returns (is_signal, debug).
Debug contains `direction` key on real signals 'buy' or 'short'.
Cross-venue safety: HL caps fundingHistory at 500 rows (~20.8 days for
1h cadence), Binance can give a full 30 days. We compute the actual
coverage span from the data and scale the cumulative-funding threshold
proportionally so 3% over 30 days and 2.08% over 20.8 days are
treated as equivalent in daily-average terms.
"""
if not funding_history or len(funding_history) < 2:
return False, {"reason": "insufficient_funding_history",
"cycles": len(funding_history)}
cadence_h = _detect_cadence_hours(funding_history)
# Actual time span the response covers — bounded by HL's 500-row cap
# for HL, or by what we asked for on Binance.
span_ms = funding_history[-1]["time_ms"] - funding_history[0]["time_ms"]
actual_days = span_ms / 86_400_000
if actual_days < MIN_COVERAGE_DAYS:
return False, {"reason": "insufficient_coverage_days",
"actual_days": round(actual_days, 1),
"min_required": MIN_COVERAGE_DAYS}
rates = [f["rate"] for f in funding_history]
cumulative_funding = sum(rates)
avg_full_window = statistics.fmean(rates)
# Scale the extreme threshold by ACTUAL coverage so cross-venue results
# are comparable. Binance: 30d → threshold ×1. HL: 20.8d → threshold ×0.69.
scaled_threshold = FUNDING_EXTREME_THRESHOLD * (actual_days / FUNDING_HISTORY_DAYS)
# "Recent" lookback in TIME units, not cycles. 24h of cycles regardless
# of venue. Min 3 to avoid pathological cases (very short history).
recent_cycles = max(3, int(FUNDING_REVERSAL_LOOKBACK_HOURS / cadence_h))
recent_cycles = min(recent_cycles, len(rates)) # don't over-slice
avg_recent_cycle = statistics.fmean(rates[-recent_cycles:])
# 2. Identify direction (which side is overcrowded)
if cumulative_funding > scaled_threshold:
# Longs have been paying → expect SHORT squeeze
direction = "short"
elif cumulative_funding < -scaled_threshold:
# Shorts have been paying → expect LONG rally
direction = "buy"
else:
return False, {
"reason": "no_extreme",
"cumulative_funding_pct": round(cumulative_funding * 100, 3),
"scaled_threshold_pct": round(scaled_threshold * 100, 3),
"coverage_days": round(actual_days, 1),
}
# 3. Funding must be MEAN-REVERTING (recent cycles softer than 30d avg)
# Use absolute magnitude — what matters is "the pressure is easing",
# not the direction of zero-crossing.
if abs(avg_full_window) == 0:
return False, {"reason": "degenerate_avg"}
revert_ratio = abs(avg_recent_cycle) / abs(avg_full_window)
if revert_ratio > FUNDING_REVERSAL_RATIO:
return False, {
"reason": "funding_still_extreme",
"revert_ratio": round(revert_ratio, 2),
"needed_below": FUNDING_REVERSAL_RATIO,
"direction": direction,
}
# 4. Price confirms the contrarian move (last 7 days)
if not daily_candles or len(daily_candles) < PRICE_CONFIRM_DAYS + 1:
return False, {"reason": "insufficient_price_history",
"have": len(daily_candles), "need": PRICE_CONFIRM_DAYS + 1}
first_close = daily_candles[-(PRICE_CONFIRM_DAYS + 1)]["close"]
last_close = daily_candles[-1]["close"]
if first_close == 0:
return False, {"reason": "bad_first_close"}
pct_change = (last_close - first_close) / first_close * 100
if direction == "buy" and pct_change < PRICE_CONFIRM_PCT:
return False, {"reason": "price_not_yet_recovering",
"pct_7d": round(pct_change, 2), "direction": direction}
if direction == "short" and pct_change > -PRICE_CONFIRM_PCT:
return False, {"reason": "price_not_yet_falling",
"pct_7d": round(pct_change, 2), "direction": direction}
boost = {
"direction": direction,
"cum_funding_30d_pct": round(cumulative_funding * 100, 3),
"recent_avg_cycle_pct": round(avg_recent_cycle * 100, 4),
"revert_ratio": round(revert_ratio, 2),
"price_7d_pct": round(pct_change, 2),
"trigger_close": round(last_close, 4),
}
if not EMIT_STANDALONE_SIGNALS:
return False, {"reason": "boost_only", "boost": boost}
return True, boost
# ─── Standalone scanner plumbing ────────────────────────────────────────────
async def _fetch_inputs() -> tuple[list[dict], list[dict]]:
"""Pull funding history + daily candles for ASSET.
We bypass provider.fetch_1d in favor of fapi.binance.com/fapi/v1/klines
because (a) funding lives on the futures venue anyway same liquidity
pool, same trade flow and (b) the spot api.binance.com host is
occasionally geo-blocked, while fapi is more reliably reachable.
"""
provider = for_asset(ASSET)
funding = await provider.fetch_funding(ASSET, days=FUNDING_DAYS_LOOKBACK)
end_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
start_ms = end_ms - PRICE_DAYS_LOOKBACK * 24 * 3600 * 1000
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.get(
"https://fapi.binance.com/fapi/v1/klines",
params={"symbol": f"{ASSET}USDT", "interval": "1d",
"startTime": start_ms, "endTime": end_ms, "limit": 1000},
)
resp.raise_for_status()
raw_daily = [
{"time_ms": r[0], "open": float(r[1]), "high": float(r[2]),
"low": float(r[3]), "close": float(r[4]), "volume": float(r[5])}
for r in resp.json()
]
daily = drop_in_progress_bar(raw_daily, "1d")
return funding, daily
def _summary(debug: dict) -> str:
direction = debug.get("direction", "?").upper()
cum = debug.get("cum_funding_30d_pct")
recent = debug.get("recent_avg_cycle_pct")
revert = debug.get("revert_ratio")
p7d = debug.get("price_7d_pct")
return (
f"BTC funding extreme reversal — {direction}. "
f"30d cumulative funding {cum}% (crowded {'longs' if direction == 'SHORT' else 'shorts'}); "
f"last-24h cycle avg {recent}% (revert ratio {revert}); "
f"price 7d {p7d:+.2f}% confirms the unwind."
)
async def _emit_signal(debug: dict) -> bool:
"""POST to the ingest endpoint. Idempotent via external_id (per-day)."""
if not settings.ingest_api_key:
logger.error("Funding reversal would fire but INGEST_API_KEY empty — not recorded")
return False
direction = debug["direction"] # 'buy' or 'short'
expected_move = PAYLOAD_EXPECTED_MOVE if direction == "buy" else -PAYLOAD_EXPECTED_MOVE
payload = {
"source": "funding_reversal",
"external_id": f"funding-{ASSET}-{direction}-{datetime.now(timezone.utc).strftime('%Y%m%d')}",
"text": _summary(debug),
"signal": direction,
"target_asset": ASSET,
"confidence": PAYLOAD_CONFIDENCE,
"category": f"funding_reversal_{direction}",
"expected_move_pct": expected_move,
"invalidation_price": debug.get("trigger_close"),
}
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
"http://localhost:8000/api/signals/ingest",
json=payload,
headers={"X-Ingest-Key": settings.ingest_api_key},
)
if resp.status_code >= 400:
logger.error("Funding reversal ingest failed (%d): %s",
resp.status_code, resp.text[:200])
return False
logger.info("Funding reversal signal emitted: %s", resp.json())
return True
async def scan_once() -> None:
"""Hourly tick. Idempotent (cooldown-gated, external_id-dedupe at ingest)."""
if not scanner_state.is_enabled(SCANNER_NAME):
logger.debug("Funding reversal scanner disabled — skipping")
return
if await scanner_state.in_cooldown("funding_reversal", ASSET, COOLDOWN_DAYS):
logger.debug("Funding reversal cooldown active (%dd)", COOLDOWN_DAYS)
scanner_state.record_run(SCANNER_NAME, "ok", "cooldown")
return
try:
funding, daily = await _fetch_inputs()
fired, debug = evaluate_funding_reversal(funding, daily)
except Exception as exc:
# Always include exception type — httpx errors often have empty .args
# which formatted as just "Funding reversal scan failed:" before.
logger.exception("Funding reversal scan failed: %s (%s)",
type(exc).__name__, exc)
scanner_state.record_run(SCANNER_NAME, "error",
f"{type(exc).__name__}: {exc}"[:200])
return
if fired:
logger.info("Funding reversal FIRE — %s", debug)
emitted = await _emit_signal(debug)
scanner_state.record_run(SCANNER_NAME, "fired" if emitted else "error",
None if emitted else "ingest_failed")
else:
logger.info("Funding reversal no — %s", debug.get("reason"))
scanner_state.record_run(SCANNER_NAME, "ok", debug.get("reason"))
# ─── Read API helper — current snapshot for the BTC page tab ────────────────
async def get_current_snapshot() -> dict:
"""Live read for the frontend BTC page funding tab. Returns the latest
funding rate, the 24h running average, cumulative 30d sum, and the verdict
of evaluate_funding_reversal() against current data. Cheap to call only
network cost is two market_data fetches the scanner would do anyway."""
try:
funding, daily = await _fetch_inputs()
except Exception as exc:
logger.exception("funding snapshot fetch failed")
return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
if not funding:
return {"ok": False, "error": "no_funding_data"}
cadence_h = _detect_cadence_hours(funding)
rates = [f["rate"] for f in funding]
cum_30d_pct = sum(rates) * 100
span_days = (funding[-1]["time_ms"] - funding[0]["time_ms"]) / 86_400_000
latest = rates[-1] * 100
# Last-24h equivalent average per-cycle rate (in %)
recent_n = max(3, int(24 / cadence_h)) if cadence_h > 0 else 24
recent_n = min(recent_n, len(rates))
last_24h_avg = (sum(rates[-recent_n:]) / recent_n) * 100
fired, debug = evaluate_funding_reversal(funding, daily)
# 7-day funding history for the sparkline (truncate to keep payload small)
history = [
{"t": int(f["time_ms"]), "rate_pct": round(f["rate"] * 100, 5)}
for f in funding[-int(min(len(funding), 24 * 7 / max(cadence_h, 0.5))) :]
]
return {
"ok": True,
"asset": ASSET,
"cadence_hours": round(cadence_h, 2),
"coverage_days": round(span_days, 1),
"latest_rate_pct": round(latest, 5),
"last_24h_avg_pct": round(last_24h_avg, 5),
"cum_30d_pct": round(cum_30d_pct, 3),
"extreme_threshold_pct": round(FUNDING_EXTREME_THRESHOLD * 100, 3),
"signal_fired": fired,
"debug": debug,
"history": history,
}
+146
View File
@@ -0,0 +1,146 @@
"""
200-day SMA Reclaim scanner.
What it catches:
The moment a price that has been LIVING BELOW its 200-day moving average
for a sustained period climbs back ABOVE it on real volume. Historically
one of the most reliable "trend has changed" markers in any market
hedge fund books, retail TA tools, momentum quants, everyone watches it.
Examples this would have caught:
BTC 2023-01 (~$22k, after the FTX flush)
BTC 2024-09 (after Q3 chop)
ETH 2023-01 (~$1500)
SOL 2023-02 (~$24, after FTX)
Trigger logic:
PRE-CONDITION: For the past DAYS_BELOW_REQUIRED days, daily close has been
BELOW the rolling 200-day SMA. (proves we're reversing a
sustained downtrend, not crossing a flat MA in chop)
TRIGGER: Today's close > 200-day SMA, AND
Today's volume > 1.3 × 30-day avg volume.
COOLDOWN: 30 days false reclaims and shake-outs happen, don't
re-fire on noise.
Companion exit profile:
SL = 6%
TRAILING_ACTIVATE = 12%
TRAILING_STOP = 5%
MAX_HOLD = 90 days
The 90-day max-hold matches the holding period needed for a real trend
change to play out (~3 months is the historical median for a confirmed
200d-SMA-reclaim trend run).
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional
import httpx
from app.config import settings
from app.services.market_data import REVERSAL_BASKET, for_asset, drop_in_progress_bar
# LIBRARY MODULE — NOT a standalone scanner. evaluate_sma_reclaim() is
# imported by btc_bottom_reversal.py as the price-reclaim entry gate. It
# deliberately does NOT register with scanner_state: no UI toggle, no
# schedule. (Old standalone scan_once/_emit_signal removed — see git log.)
logger = logging.getLogger(__name__)
# ─── Tunables ───────────────────────────────────────────────────────────────
SMA_PERIOD = 200
DAYS_BELOW_REQUIRED = 30 # how long the asset must have been under SMA
VOLUME_LOOKBACK_DAYS = 30
VOLUME_MULT_MIN = 1.3
DAYS_TO_FETCH = 260 # SMA(200) + 30d-below check + safety margin
COOLDOWN_DAYS = 30
PAYLOAD_CONFIDENCE = 85
PAYLOAD_EXPECTED_MOVE = 20.0 # historical median 90-day run after reclaim
# Cooldown via scanner_state.in_cooldown — DB-backed, restart-safe.
# ─── Signal logic ───────────────────────────────────────────────────────────
def evaluate_sma_reclaim(daily_candles: list[dict]) -> tuple[bool, dict]:
"""Pure function. Returns (is_signal, debug).
Expects `daily_candles` ordered chronologically (oldest first), each
having keys close, volume.
"""
if len(daily_candles) < SMA_PERIOD + DAYS_BELOW_REQUIRED + 2:
return False, {"reason": "insufficient_data", "bars": len(daily_candles)}
closes = [c["close"] for c in daily_candles]
volumes = [c["volume"] for c in daily_candles]
# Rolling 200-day SMA at each bar from index SMA_PERIOD-1 onwards
smas: list[Optional[float]] = [None] * len(closes)
running_sum = sum(closes[:SMA_PERIOD])
smas[SMA_PERIOD - 1] = running_sum / SMA_PERIOD
for i in range(SMA_PERIOD, len(closes)):
running_sum += closes[i] - closes[i - SMA_PERIOD]
smas[i] = running_sum / SMA_PERIOD
today_close = closes[-1]
today_sma = smas[-1]
if today_sma is None:
return False, {"reason": "sma_not_computable"}
# Bottom-reversal mode is LONG-only:
# reclaim (long): was BELOW the SMA for N days, today closes ABOVE
# We explicitly do not trade symmetric short breakdowns here. Crypto
# top-calling is a different strategy with different risk.
reclaimed = today_close > today_sma
brokedown = today_close < today_sma
if brokedown:
return False, {
"reason": "shorts_disabled",
"close": round(today_close, 4),
"sma": round(today_sma, 4),
}
if not reclaimed:
return False, {"reason": "on_sma_no_cross",
"close": round(today_close, 4), "sma": round(today_sma, 4)}
# Prior DAYS_BELOW_REQUIRED bars must ALL be on the OPPOSITE side of the
# SMA from today (a real regime flip, not chop around a flat MA).
streak = 0
for i in range(2, DAYS_BELOW_REQUIRED + 2):
sma_at = smas[-i]
if sma_at is None:
return False, {"reason": "sma_history_incomplete"}
prior_on_wrong_side = closes[-i] >= sma_at
if prior_on_wrong_side:
return False, {"reason": "regime_period_too_short", "broke_at_day": i}
streak += 1
# Volume confirmation: today >= VOLUME_MULT_MIN × 30-day avg
avg_vol_30d = sum(volumes[-(VOLUME_LOOKBACK_DAYS + 1):-1]) / VOLUME_LOOKBACK_DAYS
if avg_vol_30d <= 0:
return False, {"reason": "no_volume_baseline"}
vol_ratio = volumes[-1] / avg_vol_30d
if vol_ratio < VOLUME_MULT_MIN:
return False, {"reason": "weak_volume", "vol_ratio": round(vol_ratio, 2)}
return True, {
"direction": "buy",
"close": round(today_close, 4),
"sma_200": round(today_sma, 4),
"gap_pct": round(abs(today_close - today_sma) / today_sma * 100, 2),
"streak_days": streak,
"vol_ratio": round(vol_ratio, 2),
}
+430
View File
@@ -0,0 +1,430 @@
"""
Two trading systems, one execution layer.
This module is the formal boundary between:
SYSTEM 1 Trump (event-driven scalp)
source == "truth". Immediate market reaction to a post. Tight stops,
short hold, time-stop if no move. Uses the USER's configured exit
params (take_profit_pct / stop_loss_pct / trailing_*). Goes through
the Trump-tuned regime filter (thin-liquidity hours, recent-move,
vol-contraction).
SYSTEM 2 Bitcoin Bottom (low-frequency bottom-reversal long)
source == "btc_bottom_reversal". Multi-week holds, wide trailing,
signal-invalidation exits.
Exit params come from THIS module (per category), NOT the user.
BYPASSES the Trump regime filter those gates actively reject valid
reversal setups (a reclaim day IS a >5% move; reversals happen on
volatility EXPANSION not contraction).
The two systems share only the low-level execution plumbing (HL connector,
position monitor, reconciler, DB). Everything strategic is separated here.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
# Sources that belong to System 2. Everything else is either System 1 ("truth")
# or unsupported for live trading.
SYSTEM_2_SOURCES = {
"btc_bottom_reversal",
}
SYSTEM_1_SOURCES = {"truth"}
SUPPORTED_TRADING_SOURCES = SYSTEM_1_SOURCES | SYSTEM_2_SOURCES
def is_system_2(source: str) -> bool:
"""True if this signal should run through the reversal pipeline."""
return (source or "").lower() in SYSTEM_2_SOURCES
def is_supported_trading_source(source: str) -> bool:
"""Fail closed for unknown ingest sources instead of treating them as Trump."""
return (source or "").lower() in SUPPORTED_TRADING_SOURCES
def system2_display_name() -> str:
return "Bitcoin Bottom"
def system2_min_confidence() -> int:
return 85
# ── System 1 (Trump) — REPOSITIONED ─────────────────────────────────────────
# Originally "high-frequency, many small bets". Repositioned per operator
# decision to: LOW frequency, SELECTIVE, tight stop, ≥30-min holds.
#
# - Don't trade every post. A Trump trade puts the system on cooldown so
# the next N hours of posts are ignored — only the FIRST qualifying
# high-conviction post in a quiet window gets acted on.
# - Raise the confidence floor: only the very top posts clear the bar.
# - Tight stop, ALWAYS active (capital protection — a post that triggers
# an immediate -1.5% means we read it wrong, get out).
# - But on the winning side, hold ≥30 min: suppress take-profit / trailing
# exits until the floor elapses so we don't scalp out of a developing
# move. Asymmetric by design: losers cut fast, winners get room.
TRUMP_MIN_CONFIDENCE = 88 # was effectively the user's 70-85 setting
TRUMP_COOLDOWN_HOURS = 12 # after a Trump trade, ignore further Trump
# signals this long (the "don't trade every
# opportunity" lever)
TRUMP_MIN_HOLD_MINUTES = 30 # no TP / trailing exit before this; hard SL
# stays active throughout
TRUMP_STOP_LOSS_PCT = 1.5 # tight, kept
TRUMP_MAX_HOLD_HOURS = 6 # backstop force-close
@dataclass(frozen=True)
class CategoryExit:
"""Exit profile for a System-2 signal category.
These OVERRIDE the user's per-subscription exit params. Rationale: the
user picks risk for the Trump scalp; the reversal system's risk is a
property of the SIGNAL TYPE (an RSI capitulation reversal needs 60 days
to play out no matter what the user set their Trump stop-loss to).
Fields:
stop_loss_pct : hard stop, % adverse in position direction
trailing_activate_at_pct : peak gain that arms the trailing stop
trailing_stop_pct : retrace-from-peak that closes once armed
max_hold_hours : backstop force-close
time_stop_hours : if position is still ~flat after this many
hours, the thesis is slow/dead close.
None = no time stop (pure trend capture).
invalidation : symbolic fast-exit condition. Interpreted
by tp_sl_monitor. None = stop-loss only.
"below_entry" exit if price crosses back
through the entry (signal thesis dead).
"""
stop_loss_pct: float
trailing_activate_at_pct: Optional[float]
trailing_stop_pct: Optional[float]
max_hold_hours: int
time_stop_hours: Optional[int] = None
invalidation: Optional[str] = None
# Staged stop-loss ladder (System-2, optional). A list of
# (peak_gain_trigger_pct, new_stop_floor_pct) rungs. As the trade's PEAK
# gain crosses each trigger, the stop FLOOR ratchets up to the rung's
# value (signed gain% vs entry: negative = still a loss, 0 = breakeven,
# positive = locked-in profit). The position is NEVER closed for hitting
# a profit target — it only ever exits when price falls back to the
# current staged floor (or max-hold). This is a pure staged stop, not a
# take-profit and not a from-peak trailing stop. When set, it OVERRIDES
# take_profit_pct + trailing_* for that trade.
stop_ladder: Optional[list] = None
# ── Per-category exit profiles ──────────────────────────────────────────────
# Tuned to each signal's natural time horizon. These are STARTING points —
# refine against forward-test data, not intuition.
_CATEGORY_EXITS: dict[str, CategoryExit] = {
# Weekly RSI capitulation reversal — slowest, biggest target.
"rsi_extreme_reversal": CategoryExit(
stop_loss_pct=8.0, trailing_activate_at_pct=15.0,
trailing_stop_pct=6.0, max_hold_hours=1440, # 60 days
),
# 200d SMA reclaim — trend change. Fast exit if it loses the SMA again.
"sma_reclaim": CategoryExit(
stop_loss_pct=6.0, trailing_activate_at_pct=12.0,
trailing_stop_pct=5.0, max_hold_hours=2160, # 90 days
invalidation="below_entry", # reclaim failed → thesis dead
),
# Funding extreme unwind — faster, days not weeks.
"funding_extreme_reversal": CategoryExit(
stop_loss_pct=4.0, trailing_activate_at_pct=10.0,
trailing_stop_pct=5.0, max_hold_hours=720, # 30 days
time_stop_hours=240, # 10d flat → squeeze didn't materialise
),
# BTC bottom-reversal long — 2-of-3 price confluence (AHR999 + 200WMA +
# Pi Cycle Bottom). NO take-profit, NO from-peak trailing. The ONLY exit
# is a STAGED stop-loss ("阶段止损") that ratchets up as the trade's peak
# gain crosses each rung, plus the 120-day max-hold backstop.
#
# The BASE catastrophic floor is NOT fixed here — it is derived per-trade
# from the chosen System-2 leverage (sys2_protective_stop_pct), so it
# always sits inside the exchange liquidation line. These rungs only
# ratchet that floor UP as peak gain grows (they never loosen it):
# peak ≥ 20% → stop -12%
# peak ≥ 40% → stop 0% (breakeven — free trade)
# peak ≥ 70% → stop +25% (lock profit)
# peak ≥ 110% → stop +55%
# peak ≥ 160% → stop +95%
#
# Rungs are deliberately far apart so normal post-bottom volatility does
# not knock it out, and it never sells just because a target was hit.
"btc_bottom_reversal_long": CategoryExit(
stop_loss_pct=35.0, # fallback only; bot_engine overrides per-lev
trailing_activate_at_pct=None,
trailing_stop_pct=None,
max_hold_hours=12960, # 18 months — a cycle bull runs 618mo;
# the ratchet/peak-trail is the real exit,
# the clock is just a far backstop.
invalidation=None,
stop_ladder=[
(20.0, -12.0),
(40.0, 0.0),
(70.0, 25.0),
(110.0, 55.0),
(160.0, 95.0),
],
),
# VCP breakout — short-term continuation.
"vcp_breakout": CategoryExit(
stop_loss_pct=3.0, trailing_activate_at_pct=6.0,
trailing_stop_pct=2.5, max_hold_hours=168, # 7 days
time_stop_hours=48,
),
}
# Fallback for a System-2 signal whose category isn't explicitly mapped —
# conservative medium profile so an un-mapped scanner doesn't trade huge.
_SYSTEM_2_DEFAULT = CategoryExit(
stop_loss_pct=5.0, trailing_activate_at_pct=10.0,
trailing_stop_pct=4.0, max_hold_hours=336, # 14 days
time_stop_hours=120,
)
def get_exit_profile(category: Optional[str]) -> CategoryExit:
"""Resolve a System-2 category to its exit profile. Unknown → default."""
if not category:
return _SYSTEM_2_DEFAULT
return _CATEGORY_EXITS.get(category.lower(), _SYSTEM_2_DEFAULT)
# ── System-2 dynamic leverage ───────────────────────────────────────────────
# The user picks System-2 leverage freely. The protective stop is then
# auto-scaled to stay INSIDE the exchange liquidation line, so the position
# is de-risked by our own monitor and is never liquidated by the exchange.
#
# liquidation distance (price move) ≈ 100 / leverage (%)
# protective full-exit stop = 85% of that (15% maint/fee/funding buffer)
# …but never wider than SYS2_MAX_STOP_PCT — the bottom thesis only needs to
# tolerate a ~30% wick; risking more buys nothing.
#
# Net effect:
# lev ≤ 2x → stop = 35% (full bottom-wick tolerance, the original design)
# lev = 3x → stop ≈ 28%
# lev = 5x → stop ≈ 17%
# lev = 10x→ stop ≈ 8.5% (you WILL get shaken out by a normal wick — shown
# to the user as the explicit cost of high leverage)
SYS2_DEFAULT_LEVERAGE = 2
SYS2_MIN_LEVERAGE = 1
SYS2_MAX_LEVERAGE = 10
SYS2_MAX_STOP_PCT = 35.0
SYS2_LIQ_BUFFER = 0.85 # exit at 85% of the way to liquidation
# ── System-2 risk MODE ──────────────────────────────────────────────────────
# Two opt-in profiles, frozen onto the trade at signal time. STANDARD is the
# tuned cycle-rider (low leverage, survive bull corrections). AGGRESSIVE is a
# separately-funded high-risk/high-explosiveness sleeve: high leverage, heavier
# + earlier pyramiding, wider peak-trail, lighter early de-risk. BOTH keep the
# two safety invariants: (1) final de-risk rung is a full close INSIDE the
# liquidation line — never exchange-liquidated; (2) post-pyramid breakeven
# floor — a winner can't become a loser.
SYS2_MODE_STANDARD = "standard"
SYS2_MODE_AGGRESSIVE = "aggressive"
SYS2_MODES = (SYS2_MODE_STANDARD, SYS2_MODE_AGGRESSIVE)
# Default leverage when the user hasn't set an explicit sys2_leverage.
SYS2_MODE_DEFAULT_LEV = {SYS2_MODE_STANDARD: 2, SYS2_MODE_AGGRESSIVE: 8}
def sys2_normalize_mode(mode: Optional[str]) -> str:
m = (mode or "").strip().lower()
return m if m in SYS2_MODES else SYS2_MODE_STANDARD
def sys2_effective_leverage(value: Optional[int],
mode: Optional[str] = SYS2_MODE_STANDARD) -> int:
"""Resolve + clamp System-2 leverage. None → the mode's default
(standard 2×, aggressive 8×). Always clamped to [1, 10]."""
m = sys2_normalize_mode(mode)
default = SYS2_MODE_DEFAULT_LEV[m]
if value is None:
return default
try:
v = int(value)
except (TypeError, ValueError):
return default
return max(SYS2_MIN_LEVERAGE, min(SYS2_MAX_LEVERAGE, v))
def sys2_protective_stop_pct(leverage: int) -> float:
"""Protective full-exit distance (positive %), auto-scaled to leverage so
it always triggers INSIDE the exchange liquidation line."""
lev = max(SYS2_MIN_LEVERAGE, min(SYS2_MAX_LEVERAGE, int(leverage)))
liq_distance_pct = 100.0 / lev
return round(min(SYS2_MAX_STOP_PCT, SYS2_LIQ_BUFFER * liq_distance_pct), 2)
def sys2_approx_liquidation_pct(leverage: int) -> float:
"""Rough exchange liquidation distance (positive %) for UX display."""
lev = max(SYS2_MIN_LEVERAGE, min(SYS2_MAX_LEVERAGE, int(leverage)))
return round(100.0 / lev, 2)
# Staged de-risk ("分段式减仓"): instead of one full exit at the protective
# stop, scale OUT in three steps as the trade moves against us. The final
# step is exactly the Phase-1 protective level P (inside liquidation), so the
# never-exchange-liquidated guarantee is preserved — we just bleed out in
# thirds rather than all at once.
#
# 0.60·P → close 1/3 of the original notional
# 0.80·P → close another 1/3
# 1.00·P → close the remaining 1/3 (full exit; same safety as Phase 1)
SYS2_DERISK_FRACTIONS = (0.60, 0.80, 1.00)
# Pyramiding ("做对了往上加仓"): when the bottom call is RIGHT and the trend
# confirms, scale INTO the winner to amplify the move. Mirror of the de-risk
# ladder. Conservative sizing (user-selected): adds at most +0.6× the base
# notional. Each rung requires BOTH a peak-gain trigger AND a structural
# trend confirmation (price ≥ 200d SMA AND at a fresh local high), checked at
# execution time. Pyramiding is DISABLED once any de-risk step has fired
# (a trade that went underwater is not a clean uptrend to add into).
# Extended for a cycle bull: deeper continuation rungs so a multi-x move is
# actually scaled. Each add still needs structural confirmation (price ≥ 200d
# SMA AND a fresh high) so the deep rungs only fire in a genuine sustained
# uptrend, never on a chop fakeout. Total adds ≤ +0.75× base — still well
# inside the 8× notional cap; amplification stays conservative.
SYS2_ADDON_PEAK_TRIGGERS = (25.0, 50.0, 85.0, 140.0, 220.0) # peak-gain % vs blended entry
SYS2_ADDON_FRACTIONS = (0.30, 0.20, 0.10, 0.10, 0.05) # of ORIGINAL base notional
# AGGRESSIVE sleeve: earlier + heavier adds (≤ +1.50× base), so a clean
# multi-x run is meaningfully compounded. Still gated by the same structural
# confirmation (≥200d SMA + fresh high), still inside the per-trade 8×
# notional cap, still floored at breakeven once any add fills.
SYS2_AGGR_ADDON_PEAK_TRIGGERS = (15.0, 35.0, 60.0, 100.0, 160.0)
SYS2_AGGR_ADDON_FRACTIONS = (0.50, 0.40, 0.30, 0.20, 0.10)
# AGGRESSIVE de-risk: shed less early (¼/¼) so a normal correction doesn't
# gut the runner; final rung still a FULL close at the protective level.
SYS2_AGGR_DERISK_STEP_FRACS = (0.25, 0.25, 0.50)
SYS2_STD_DERISK_STEP_FRACS = (1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0)
# Peak-% trailing for the parabolic top. Below the start threshold the fixed
# stop_ladder rungs govern; at/above it the floor also trails the PEAK PRICE
# by at most SYS2_PEAK_TRAIL_DD (price drawdown, scale-invariant) so a +500%
# move isn't capped at the +95% rung, while a normal ~2530% bull correction
# still doesn't knock it out. Floor = max(rung floor, peak-trail floor).
SYS2_PEAK_TRAIL_START_PCT = 80.0 # activate once peak gain ≥ +80%
SYS2_PEAK_TRAIL_DD = 0.30 # give back at most 30% of peak PRICE
# AGGRESSIVE: let it run longer before the trailing top kicks in, and give
# back more, so a multi-x parabolic isn't cut early by a mid-run shakeout.
SYS2_AGGR_PEAK_TRAIL_START_PCT = 60.0
SYS2_AGGR_PEAK_TRAIL_DD = 0.42
def sys2_addon_ladder(mode: Optional[str] = SYS2_MODE_STANDARD) -> list:
"""Pyramiding rungs: (peak_gain_trigger_pct, frac_of_base, is_last)."""
if sys2_normalize_mode(mode) == SYS2_MODE_AGGRESSIVE:
trigs, fracs = SYS2_AGGR_ADDON_PEAK_TRIGGERS, SYS2_AGGR_ADDON_FRACTIONS
else:
trigs, fracs = SYS2_ADDON_PEAK_TRIGGERS, SYS2_ADDON_FRACTIONS
n = len(trigs)
return [(trigs[i], fracs[i], i == n - 1) for i in range(n)]
def sys2_peak_trail(mode: Optional[str] = SYS2_MODE_STANDARD) -> tuple:
"""(activate_peak_gain_pct, price_drawdown_frac) for the parabolic-top
trailing floor. Scale-invariant: works the same at +120% or +900%."""
if sys2_normalize_mode(mode) == SYS2_MODE_AGGRESSIVE:
return (SYS2_AGGR_PEAK_TRAIL_START_PCT, SYS2_AGGR_PEAK_TRAIL_DD)
return (SYS2_PEAK_TRAIL_START_PCT, SYS2_PEAK_TRAIL_DD)
def sys2_derisk_ladder(leverage: int,
mode: Optional[str] = SYS2_MODE_STANDARD) -> list:
"""Downside staged de-risk rungs for the given leverage.
Returns a list of (threshold_signed_pct, frac_of_ORIGINAL_to_close,
is_final), ordered by increasing adversity. threshold is NEGATIVE
(a loss in position direction). The executor converts frac-of-original
into frac-of-current using the trade's remaining_fraction.
"""
p = sys2_protective_stop_pct(leverage)
steps = (SYS2_AGGR_DERISK_STEP_FRACS
if sys2_normalize_mode(mode) == SYS2_MODE_AGGRESSIVE
else SYS2_STD_DERISK_STEP_FRACS)
return [
(round(-SYS2_DERISK_FRACTIONS[0] * p, 4), steps[0], False),
(round(-SYS2_DERISK_FRACTIONS[1] * p, 4), steps[1], False),
(round(-SYS2_DERISK_FRACTIONS[2] * p, 4), steps[2], True),
]
def get_stop_ladder(category: Optional[str]) -> Optional[list]:
"""Staged stop-loss ladder for a category, or None if it uses trailing.
Sorted ascending by trigger so the monitor can walk it cheaply. The
ladder is a CODE constant (not frozen per-trade): if it's retuned, open
positions adopt the new rungs on the next price tick / restart. That is
intentional staged-stop levels are a property of the strategy, not of
an individual fill.
"""
prof = get_exit_profile(category)
if not prof.stop_ladder:
return None
return sorted(prof.stop_ladder, key=lambda r: r[0])
# ── System-2 position sizing ────────────────────────────────────────────────
# CRITICAL: System 2 must NOT use regime_filter.calculate_size_multiplier.
# That function asks "is volatility contracted? has price NOT moved?" — both
# FALSE during a reversal (vol expands, price already moved), which would
# SHRINK our rarest, highest-conviction trades. Sizing here is a function of
# (category conviction × signal confidence), nothing else.
# Base conviction per category. Rarer + cleaner setup → bigger base bet.
_CATEGORY_SIZE_BASE: dict[str, float] = {
"rsi_extreme_reversal": 2.5, # ~1-2×/yr/asset, deepest capitulation
"sma_reclaim": 2.0, # clean trend-change marker
"funding_extreme_reversal": 1.4, # more frequent, choppier unwinds
"btc_bottom_reversal_long": 2.3, # 2-of-3 price confluence (AHR999/200WMA/Pi)
"vcp_breakout": 1.0, # continuation, lowest conviction
}
_SYSTEM_2_SIZE_BASE_DEFAULT = 1.2
SYSTEM_2_SIZE_CAP = 4.0
# ── System-2 correlation / concentration cap ────────────────────────────────
# Every asset in REVERSAL_BASKET is high-beta crypto. When the market bottoms,
# RSI/SMA/funding reversals fire on BTC + ETH + SOL in the SAME week — these
# are NOT independent bets, they're one "crypto reversed" thesis. With Fix #1
# sizing each up to 4x, 3 correlated positions = ~10x effective exposure to a
# single macro call. Treat the whole System-2 book as one correlated bucket
# and cap it.
#
# - SYS2_MAX_CONCURRENT: at most this many open System-2 positions at once.
# Beyond this you're not diversifying, just leveraging the same thesis.
# - SYS2_MAX_OPEN_NOTIONAL_MULT: total open System-2 notional must stay
# under this × the wallet's base position_size_usd × default-ish size.
# Acts as a $ ceiling independent of how many positions.
SYS2_MAX_CONCURRENT = 3
SYS2_MAX_OPEN_NOTIONAL_MULT = 8.0 # × base position_size_usd
def system2_size_multiplier(category: Optional[str], confidence: int) -> float:
"""Position multiplier for a System-2 signal.
base(category) × confidence_scale, capped at SYSTEM_2_SIZE_CAP.
confidence_scale: 701.0, 851.3, 951.5, 1001.6 (linear, floored at 1.0).
A scanner that emits confidence 88 for an rsi_extreme_reversal therefore
sizes 2.5 × 1.36 3.4×. Tune the base table against forward-test data.
"""
base = _CATEGORY_SIZE_BASE.get((category or "").lower(), _SYSTEM_2_SIZE_BASE_DEFAULT)
conf_scale = 1.0 + max(0, confidence - 70) / 50.0
return round(min(base * conf_scale, SYSTEM_2_SIZE_CAP), 2)
# ── BTC bottom-reversal ─────────────────────────────────────────────────────
# The old MVRV-Z / STH-SOPR / drawdown state machine was REMOVED. It depended
# on paid on-chain data and was over-engineered. The strategy is now a pure
# 2-of-3 price confluence (AHR999 + 200-Week MA + Pi Cycle Bottom), implemented
# in app/services/bottom_indicators.py and driven by the btc_bottom_reversal
# scanner. There is no state machine and no on-chain dependency.
+280
View File
@@ -0,0 +1,280 @@
"""
Telegram push alerts send + signal dispatcher.
This module is fire-and-forget by design. `notify_signal()` returns immediately
to the caller (signal ingestion path) and dispatches in a background task. A
DB failure or Telegram API error MUST NOT block a signal from being saved.
The bot token is loaded from `settings.telegram_bot_token`. If empty, every
function in this module degrades into a no-op (and logs once at module load).
notify_signal(post) called from /api/signals/ingest
send_test_message(wallet) called from /api/telegram/test
send_message(chat_id, text) low-level escape hatch
Source user-toggle mapping:
truth alert_trump
btc_bottom_reversal alert_btc_bottom
funding_reversal alert_funding
kol_divergence (future) alert_kol_divergence
"""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timezone
from typing import Optional
import httpx
from sqlalchemy import select, update
from app.config import settings
from app.database import AsyncSessionLocal as async_session
from app.models import Post, TelegramBinding
logger = logging.getLogger(__name__)
TG_API = "https://api.telegram.org/bot{token}/{method}"
# Telegram caps a single message at 4096 chars; we render way below this.
MAX_LEN = 3500
# ── Source → preference column mapping ────────────────────────────────────
def _pref_column_for_source(source: str) -> Optional[str]:
"""Which user-toggle column gates this source. None → unknown source,
don't send."""
if source == "truth":
return "alert_trump"
if source == "btc_bottom_reversal":
return "alert_btc_bottom"
if source == "funding_reversal":
return "alert_funding"
if source == "kol_divergence":
return "alert_kol_divergence"
return None
def _is_in_mute_window(now_hour: int, mute_from: Optional[int],
mute_until: Optional[int]) -> bool:
"""Both null → never mute. start<until → mute inside [start, until).
start>until window wraps midnight (e.g. 23..7 mute 23, 06)."""
if mute_from is None or mute_until is None:
return False
if mute_from == mute_until:
return False
if mute_from < mute_until:
return mute_from <= now_hour < mute_until
# wraps midnight
return now_hour >= mute_from or now_hour < mute_until
# ── Formatting ────────────────────────────────────────────────────────────
def _signal_emoji(post: Post) -> str:
if post.signal == "buy":
return "🟢"
if post.signal == "short":
return "🔴"
return ""
def _source_label(source: str) -> str:
return {
"truth": "Trump · Truth Social",
"btc_bottom_reversal": "BTC · Macro Bottom",
"funding_reversal": "BTC · Funding Reversal",
"kol_divergence": "KOL · Talks vs Trades",
}.get(source, source)
def format_post(post: Post) -> str:
"""Render a Post into a single Telegram message (HTML parse mode).
Keep it scannable: heading, one-line verdict, the underlying text, link."""
emoji = _signal_emoji(post)
src = _source_label(post.source)
sig = (post.signal or "noise").upper()
asset = post.target_asset or "?"
conf = post.ai_confidence or 0
# Heading: emoji + asset + direction + confidence
head = f"{emoji} <b>{asset} · {sig}</b> · conf <b>{conf}</b>"
sub = f"<i>{src}</i>"
body = (post.text or "").strip()
if len(body) > 600:
body = body[:600].rstrip() + ""
# Move size hint if present (BTC bottom & funding emit expected_move_pct)
extra = ""
if post.expected_move_pct is not None:
extra = f"\n📐 expected move: <b>{post.expected_move_pct:+.1f}%</b>"
if post.invalidation_price is not None:
extra += f"\n🛑 invalidation @ <code>{post.invalidation_price:g}</code>"
# Deep-link back to the dashboard. Frontend URL comes from settings.
fe = (settings.frontend_url or "").rstrip("/")
link = ""
if fe:
# Use the section that matches the source
path = {
"truth": "/en/trump",
"btc_bottom_reversal": "/en/btc",
"funding_reversal": "/en/btc",
"kol_divergence": "/en/kol",
}.get(post.source, "/en")
link = f'\n\n<a href="{fe}{path}">→ open in dashboard</a>'
msg = f"{head}\n{sub}\n\n{body}{extra}{link}"
return msg[:MAX_LEN]
# ── Low-level HTTP ────────────────────────────────────────────────────────
async def send_message(chat_id: int, text: str, *,
parse_mode: str = "HTML",
disable_preview: bool = True) -> bool:
"""Single HTTP POST to Telegram Bot API. Returns True on 200, False on
any failure (caller decides whether to bump the failure counter)."""
token = settings.telegram_bot_token
if not token:
return False
url = TG_API.format(token=token, method="sendMessage")
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(url, json={
"chat_id": chat_id, "text": text,
"parse_mode": parse_mode,
"disable_web_page_preview": disable_preview,
})
if r.status_code != 200:
logger.warning("Telegram sendMessage failed chat=%d status=%d body=%s",
chat_id, r.status_code, r.text[:200])
return False
return True
except Exception as exc:
logger.warning("Telegram sendMessage exception chat=%d: %s", chat_id, exc)
return False
# ── Dispatcher ────────────────────────────────────────────────────────────
async def _dispatch(post_id: int) -> None:
"""Fan-out a single Post to every eligible subscriber. Always runs in
its own DB session so signal ingestion's session is unaffected."""
if not settings.telegram_bot_token:
return
async with async_session() as db:
post = await db.get(Post, post_id)
if not post:
logger.warning("Telegram dispatch: post id=%d not found", post_id)
return
# Only fan out for actionable signals (not NOISE / null)
if not post.signal or post.signal not in ("buy", "short"):
return
pref_col = _pref_column_for_source(post.source)
if pref_col is None:
logger.debug("Telegram: unknown source %r — not fanning out", post.source)
return
# Build the query: alerts_enabled + the relevant per-source flag.
# min_confidence applies to every source — scanner-emitted signals
# carry their own confidence in the Post.ai_confidence column.
col = getattr(TelegramBinding, pref_col)
base_filters = [
TelegramBinding.alerts_enabled.is_(True),
col.is_(True),
]
# Only apply confidence gate when the post has a real score (> 0).
# Scanner-generated signals (funding, BTC) always carry a score, but
# a Truth-Social post might be dispatched before Claude scores it (score=0).
# In that edge case we let it through so no alert is silently swallowed.
if post.ai_confidence and post.ai_confidence > 0:
base_filters.append(TelegramBinding.min_confidence <= post.ai_confidence)
q = select(TelegramBinding).where(*base_filters)
bindings = (await db.execute(q)).scalars().all()
if not bindings:
return
text = format_post(post)
now = datetime.now(timezone.utc)
hour = now.hour
sent = 0
for b in bindings:
if _is_in_mute_window(hour, b.mute_from_hour, b.mute_until_hour):
continue
ok = await send_message(b.chat_id, text)
# Update audit counters per binding. Single UPDATE per row keeps
# us out of trouble if one user blocks the bot.
if ok:
await db.execute(
update(TelegramBinding)
.where(TelegramBinding.id == b.id)
.values(
last_alert_at=now,
total_alerts_sent=TelegramBinding.total_alerts_sent + 1,
)
)
sent += 1
else:
await db.execute(
update(TelegramBinding)
.where(TelegramBinding.id == b.id)
.values(
total_alerts_failed=TelegramBinding.total_alerts_failed + 1,
)
)
await db.commit()
logger.info("Telegram fan-out: post=%d source=%s sent=%d/%d",
post_id, post.source, sent, len(bindings))
def notify_signal(post: Post) -> None:
"""Fire-and-forget. Schedules `_dispatch(post.id)` on the running loop
and returns immediately. Safe to call from any async context falls
back to a no-op if telegram is disabled or no event loop is running.
We pass post.id rather than the post object because the caller's DB
session might close before our background task runs."""
if not settings.telegram_bot_token:
return
if not post or not post.id:
return
try:
asyncio.create_task(_dispatch(post.id))
except RuntimeError:
# No running loop — extremely unusual in our FastAPI context.
logger.warning("notify_signal: no running event loop, skipping post=%s", post.id)
async def send_test_message(wallet: str) -> bool:
"""Triggered from the Settings UI to verify the binding works end-to-end."""
if not settings.telegram_bot_token:
return False
async with async_session() as db:
b = (await db.execute(
select(TelegramBinding).where(TelegramBinding.wallet_address == wallet.lower())
)).scalar_one_or_none()
if not b:
return False
return await send_message(
b.chat_id,
"✅ <b>Trump Alpha connected.</b>\n\n"
"You'll get push alerts here when signals fire. "
"Use /trump /btc /funding /kol /conf /quiet in this chat to tune "
"which sources and thresholds apply to you.",
)
if not settings.telegram_bot_token:
logger.info("Telegram bot token not set — push alerts disabled.")
+564
View File
@@ -0,0 +1,564 @@
"""
Telegram bot worker long-polls getUpdates and handles user commands.
Commands supported:
/start CODE bind this chat_id to the wallet that owns CODE
/start friendly hello with instructions
/stop disable alerts (keeps the binding so re-enable is one tap)
/status show binding status and current preferences
/test send self a test message to verify formatting
This runs as a single background asyncio task started from main.py lifespan.
Only one instance should run at a time Telegram's long-poll model assumes
exactly one consumer per bot token. If you horizontally scale the API, switch
to webhook mode (see set_webhook in the Bot API).
The one-time binding codes live in a process-local dict with a 10-minute
TTL. On API restart all pending codes evaporate (the user re-clicks Connect).
This is intentional codes never touch the DB so a compromised dump can't
hijack future bindings.
"""
from __future__ import annotations
import asyncio
import logging
import secrets
import string
from datetime import datetime, timedelta, timezone
from typing import Optional
import httpx
from sqlalchemy import select, update
from app.config import settings
from app.database import AsyncSessionLocal as async_session
from app.models import TelegramBinding, Subscription
from app.services.telegram import send_message, TG_API
logger = logging.getLogger(__name__)
# ── One-time binding codes ────────────────────────────────────────────────
_CODE_TTL_SECONDS = 10 * 60
_CODE_ALPHABET = string.ascii_uppercase + string.digits # 36^6 ≈ 2.1B
_CODE_LEN = 6
# code → (wallet_lower, expires_at)
_pending_codes: dict[str, tuple[str, datetime]] = {}
def _purge_expired_codes() -> None:
now = datetime.now(timezone.utc)
expired = [c for c, (_, exp) in _pending_codes.items() if exp < now]
for c in expired:
_pending_codes.pop(c, None)
def issue_binding_code(wallet: str) -> str:
"""Generate a fresh 6-char code for a wallet. If the same wallet calls
twice within the TTL, the old code is invalidated only the latest
code works (prevents stale shared links)."""
_purge_expired_codes()
wallet_l = wallet.lower()
# Invalidate any previous code for this wallet
for c, (w, _) in list(_pending_codes.items()):
if w == wallet_l:
_pending_codes.pop(c, None)
code = "".join(secrets.choice(_CODE_ALPHABET) for _ in range(_CODE_LEN))
exp = datetime.now(timezone.utc) + timedelta(seconds=_CODE_TTL_SECONDS)
_pending_codes[code] = (wallet_l, exp)
return code
def _consume_code(code: str) -> Optional[str]:
"""Resolve and remove a code. Returns the wallet, or None if invalid/expired."""
_purge_expired_codes()
rec = _pending_codes.pop(code.upper(), None)
if rec is None:
return None
return rec[0]
# ── Command handlers ──────────────────────────────────────────────────────
HELP_TEXT = (
"👋 <b>Trump Alpha bot</b>\n\n"
"Push alerts for high-conviction crypto signals — Trump posts, BTC bottom "
"reversals, funding extremes, KOL divergence.\n\n"
"<b>Just press /start</b> — you're subscribed. No wallet needed.\n\n"
"<b>Configure:</b>\n"
" /trump on|off Trump · Truth Social posts\n"
" /btc on|off BTC · macro bottom\n"
" /funding on|off BTC · funding reversal\n"
" /kol on|off KOL · talks vs trades\n"
" /conf 0-100 min AI confidence (Trump only)\n"
" /quiet 23 7 mute hours UTC (or 'off' to disable)\n\n"
"<b>Status &amp; control:</b>\n"
" /status — show your preferences\n"
" /stop — pause all alerts\n"
" /test — send a sample alert\n\n"
"<b>Pro features</b> (auto-trade on Hyperliquid):\n"
"Open the dashboard → <i>Settings</i> → <i>Connect Telegram</i> to link "
"your wallet."
)
# Default preferences for a freshly-created walletless binding.
_DEFAULT_PREFS = {
"alerts_enabled": True,
"alert_trump": True,
"alert_btc_bottom": True,
"alert_funding": True,
"alert_kol_divergence": False,
"min_confidence": 70,
}
async def _ensure_binding(chat_id: int, username: Optional[str]) -> TelegramBinding:
"""Create or refresh a walletless binding for this chat. Returns the
binding (always with id set)."""
async with async_session() as db:
existing = (await db.execute(
select(TelegramBinding).where(TelegramBinding.chat_id == chat_id)
)).scalar_one_or_none()
if existing:
# Refresh username + re-enable alerts on subsequent /start
await db.execute(
update(TelegramBinding)
.where(TelegramBinding.id == existing.id)
.values(tg_username=username, alerts_enabled=True)
)
await db.commit()
return existing
b = TelegramBinding(
wallet_address=None,
chat_id=chat_id,
tg_username=username,
**_DEFAULT_PREFS,
)
db.add(b)
await db.commit()
await db.refresh(b)
return b
async def _cmd_start(chat_id: int, username: Optional[str], arg: str) -> None:
"""
/start free-tier walletless binding (anyone can subscribe)
/start CODE upgrade to wallet-linked binding (Pro features)
"""
arg = arg.strip()
# No arg → free-tier subscribe. Always idempotent.
if not arg:
b = await _ensure_binding(chat_id, username)
if b.wallet_address:
await send_message(
chat_id,
"✅ Already linked — you're getting alerts.\n"
"Send /status to see your settings, /help for commands.",
)
else:
await send_message(
chat_id,
"🎉 <b>You're subscribed!</b>\n\n"
"You'll get alerts for: Trump posts, BTC bottom signals, "
"funding reversals. KOL divergence is off by default (noisier).\n\n"
"Type /help to see every command, or /test to preview an alert.",
)
return
# Arg present → wallet-binding flow (Pro)
wallet = _consume_code(arg)
if not wallet:
await send_message(
chat_id,
"❌ <b>Invalid or expired code.</b>\n\n"
"The 6-char code only comes from the dashboard's "
"<i>Settings → Connect Telegram</i> panel — and it expires after "
"10 minutes.\n\n"
"Don't need wallet features? Just send /start (no code) — alerts "
"are free for everyone.",
)
return
async with async_session() as db:
sub = (await db.execute(
select(Subscription).where(Subscription.wallet_address == wallet)
)).scalar_one_or_none()
if not sub or not sub.active:
await send_message(
chat_id,
"⚠️ <b>This wallet isn't subscribed yet.</b>\n\n"
"Open Settings on the dashboard, click <i>Start trading</i>, "
"then come back and re-connect.",
)
return
existing_by_wallet = (await db.execute(
select(TelegramBinding).where(TelegramBinding.wallet_address == wallet)
)).scalar_one_or_none()
existing_by_chat = (await db.execute(
select(TelegramBinding).where(TelegramBinding.chat_id == chat_id)
)).scalar_one_or_none()
# Edge: chat already bound to a DIFFERENT wallet — reject loudly.
if existing_by_chat and existing_by_chat.wallet_address and \
existing_by_chat.wallet_address != wallet:
await send_message(
chat_id,
"❌ This Telegram account is already bound to a different "
"wallet. Run /stop on that wallet first (or unbind via the "
"dashboard).",
)
return
if existing_by_wallet and existing_by_wallet.chat_id != chat_id:
# Wallet was previously bound to a different chat — move it here.
# Also clean up any walletless binding for this chat to avoid a
# duplicate row.
if existing_by_chat:
from sqlalchemy import delete as _delete
await db.execute(_delete(TelegramBinding).where(
TelegramBinding.id == existing_by_chat.id))
await db.execute(
update(TelegramBinding)
.where(TelegramBinding.id == existing_by_wallet.id)
.values(chat_id=chat_id, tg_username=username,
alerts_enabled=True,
bound_at=datetime.now(timezone.utc))
)
elif existing_by_chat:
# Free user is upgrading to wallet-linked — just attach the wallet
# to their existing walletless binding, keep their preferences.
await db.execute(
update(TelegramBinding)
.where(TelegramBinding.id == existing_by_chat.id)
.values(wallet_address=wallet, tg_username=username,
alerts_enabled=True,
bound_at=datetime.now(timezone.utc))
)
else:
# Brand-new wallet + chat.
db.add(TelegramBinding(
wallet_address=wallet, chat_id=chat_id,
tg_username=username, **_DEFAULT_PREFS,
))
await db.commit()
short = wallet[:6] + "" + wallet[-4:]
await send_message(
chat_id,
f"✅ <b>Wallet linked: {short}</b>\n\n"
"Pro features unlocked. Your existing alert preferences are kept. "
"Manage everything from the dashboard's Settings page or via bot "
"commands. Send /status to see your current setup.",
)
# ── Preference commands ──────────────────────────────────────────────────
async def _set_pref(chat_id: int, field: str, value: bool | int) -> bool:
"""Returns True if the binding existed and was updated."""
async with async_session() as db:
r = await db.execute(
update(TelegramBinding)
.where(TelegramBinding.chat_id == chat_id)
.values(**{field: value})
)
await db.commit()
return r.rowcount > 0
def _parse_on_off(arg: str) -> Optional[bool]:
a = arg.strip().lower()
if a in ("on", "yes", "enable", "1", "true"): return True
if a in ("off", "no", "disable", "0", "false"): return False
return None
async def _cmd_toggle(chat_id: int, field: str, label: str, arg: str) -> None:
v = _parse_on_off(arg)
if v is None:
await send_message(chat_id,
f"Usage: <code>{label.lower().split()[0]} on</code> or "
f"<code>{label.lower().split()[0]} off</code>")
return
ok = await _set_pref(chat_id, field, v)
if not ok:
await send_message(chat_id, "No binding here. Send /start first.")
return
state = "🟢 ON" if v else "🔴 OFF"
await send_message(chat_id, f"{state} · {label}")
async def _cmd_conf(chat_id: int, arg: str) -> None:
try:
n = int(arg.strip())
if not 0 <= n <= 100:
raise ValueError
except ValueError:
await send_message(chat_id,
"Usage: <code>/conf 70</code> (0100). Only Trump posts above "
"this AI confidence will trigger alerts.")
return
ok = await _set_pref(chat_id, "min_confidence", n)
if not ok:
await send_message(chat_id, "No binding here. Send /start first.")
return
await send_message(chat_id, f"Min confidence set to <b>{n}</b>.")
async def _cmd_quiet(chat_id: int, arg: str) -> None:
a = arg.strip().lower()
if a in ("off", "none", "disable", ""):
async with async_session() as db:
r = await db.execute(
update(TelegramBinding)
.where(TelegramBinding.chat_id == chat_id)
.values(mute_from_hour=None, mute_until_hour=None)
)
await db.commit()
if not r.rowcount:
await send_message(chat_id, "No binding here. Send /start first.")
return
await send_message(chat_id, "🔔 Quiet hours disabled.")
return
parts = arg.split()
if len(parts) != 2:
await send_message(chat_id,
"Usage: <code>/quiet 23 7</code> (UTC, e.g. mute 23:0007:00) "
"or <code>/quiet off</code>.")
return
try:
a_h, b_h = int(parts[0]), int(parts[1])
if not (0 <= a_h <= 23 and 0 <= b_h <= 23):
raise ValueError
except ValueError:
await send_message(chat_id, "Hours must be integers 023.")
return
async with async_session() as db:
r = await db.execute(
update(TelegramBinding)
.where(TelegramBinding.chat_id == chat_id)
.values(mute_from_hour=a_h, mute_until_hour=b_h)
)
await db.commit()
if not r.rowcount:
await send_message(chat_id, "No binding here. Send /start first.")
return
await send_message(chat_id, f"🌙 Quiet hours: {a_h:02d}:00{b_h:02d}:00 UTC.")
async def _cmd_stop(chat_id: int) -> None:
async with async_session() as db:
r = await db.execute(
update(TelegramBinding)
.where(TelegramBinding.chat_id == chat_id)
.values(alerts_enabled=False)
)
await db.commit()
if r.rowcount:
await send_message(chat_id,
"🔕 Alerts paused. Send /start any time to re-enable.")
else:
await send_message(chat_id,
"You don't have an active binding here. Send /start to set one up.")
async def _cmd_status(chat_id: int) -> None:
async with async_session() as db:
b = (await db.execute(
select(TelegramBinding).where(TelegramBinding.chat_id == chat_id)
)).scalar_one_or_none()
if not b:
await send_message(chat_id,
"Not subscribed yet. Send /start to begin (no wallet required).")
return
if b.wallet_address:
short = b.wallet_address[:6] + "" + b.wallet_address[-4:]
tier_line = f"Tier: <b>Pro</b> · wallet <code>{short}</code>"
else:
tier_line = "Tier: <b>Free</b> · no wallet linked"
on = "🟢 ON" if b.alerts_enabled else "🔴 OFF"
srcs = []
if b.alert_trump: srcs.append("Trump")
if b.alert_btc_bottom: srcs.append("BTC bottom")
if b.alert_funding: srcs.append("Funding")
if b.alert_kol_divergence: srcs.append("KOL")
src_line = ", ".join(srcs) or "(none — alerts will be silent)"
mute = ""
if b.mute_from_hour is not None and b.mute_until_hour is not None:
mute = f"\n🌙 Quiet hours: {b.mute_from_hour:02d}{b.mute_until_hour:02d} UTC"
await send_message(
chat_id,
f"📡 <b>Status</b>\n\n"
f"{tier_line}\n"
f"Alerts: {on}\n"
f"Sources: {src_line}\n"
f"Min confidence: {b.min_confidence}{mute}\n\n"
f"Sent: {b.total_alerts_sent} · Failed: {b.total_alerts_failed}\n\n"
f"Toggle anything with /trump /btc /funding /kol /conf /quiet — "
f"send /help for the full list.",
)
async def _cmd_test(chat_id: int) -> None:
async with async_session() as db:
b = (await db.execute(
select(TelegramBinding).where(TelegramBinding.chat_id == chat_id)
)).scalar_one_or_none()
if not b:
await send_message(chat_id, "Send /start first to subscribe (no wallet needed).")
return
await send_message(
chat_id,
"🟢 <b>BTC · BUY</b> · conf <b>88</b>\n"
"<i>Trump · Truth Social</i>\n\n"
"BITCOIN is the FUTURE of money. America will LEAD the world in "
"crypto. BIG things coming very soon!\n\n"
"(this is a test message — your actual alerts will look like this)",
)
# ── Long-poll loop ────────────────────────────────────────────────────────
async def _handle_message(msg: dict) -> None:
chat = msg.get("chat") or {}
chat_id = chat.get("id")
if not chat_id:
return
text = (msg.get("text") or "").strip()
if not text:
return
from_user = msg.get("from") or {}
username = from_user.get("username")
# Parse first word as command
parts = text.split(maxsplit=1)
cmd = parts[0].lower()
arg = parts[1] if len(parts) > 1 else ""
# Normalize /command@botname (group-chat syntax) → /command
if "@" in cmd:
cmd = cmd.split("@", 1)[0]
try:
if cmd == "/start":
await _cmd_start(chat_id, username, arg)
elif cmd in ("/stop", "/pause"):
await _cmd_stop(chat_id)
elif cmd in ("/status", "/me"):
await _cmd_status(chat_id)
elif cmd == "/test":
await _cmd_test(chat_id)
elif cmd in ("/help", "/?"):
await send_message(chat_id, HELP_TEXT)
# ── source toggles ───────────────────────────────────────────
elif cmd == "/trump":
await _cmd_toggle(chat_id, "alert_trump", "Trump alerts", arg)
elif cmd == "/btc":
await _cmd_toggle(chat_id, "alert_btc_bottom", "BTC bottom alerts", arg)
elif cmd == "/funding":
await _cmd_toggle(chat_id, "alert_funding", "Funding reversal alerts", arg)
elif cmd == "/kol":
await _cmd_toggle(chat_id, "alert_kol_divergence", "KOL divergence alerts", arg)
# ── numeric / range prefs ────────────────────────────────────
elif cmd == "/conf":
await _cmd_conf(chat_id, arg)
elif cmd == "/quiet":
await _cmd_quiet(chat_id, arg)
else:
if cmd.startswith("/"):
await send_message(chat_id, "Unknown command. Send /help for the list.")
except Exception:
logger.exception("Telegram handler failed for chat=%s text=%r", chat_id, text[:80])
async def run_bot_loop() -> None:
"""Long-poll forever. Idempotent on offsets — Telegram serves the same
update twice only if we don't acknowledge it. Sleep on errors to avoid
hot-looping if the network is down."""
token = settings.telegram_bot_token
if not token:
logger.info("Telegram bot loop not started — TELEGRAM_BOT_TOKEN empty.")
return
logger.info("Telegram bot loop starting (long-poll mode).")
offset: Optional[int] = None
backoff = 1.0
while True:
try:
url = TG_API.format(token=token, method="getUpdates")
params: dict = {"timeout": 25}
if offset is not None:
params["offset"] = offset
async with httpx.AsyncClient(timeout=35) as client:
r = await client.get(url, params=params)
if r.status_code != 200:
logger.warning("Telegram getUpdates HTTP %d: %s", r.status_code, r.text[:200])
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 30)
continue
backoff = 1.0
data = r.json()
for upd in data.get("result", []):
offset = upd["update_id"] + 1
msg = upd.get("message") or upd.get("edited_message")
if msg:
await _handle_message(msg)
except asyncio.CancelledError:
logger.info("Telegram bot loop cancelled.")
raise
except httpx.ReadTimeout:
# Normal — long-poll returned with no updates within timeout. Just retry.
# (Previously caught by the bare Exception below and logged with an
# empty message — "error: — sleeping" — which was opaque.)
continue
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.RemoteProtocolError) as exc:
# Network-level error — expected on transient connectivity issues.
# Compact log to avoid spamming when the network is down.
logger.warning(
"Telegram bot loop network error: %s (%s) — sleeping %.1fs",
type(exc).__name__, exc, backoff,
)
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 30)
except Exception:
# Anything else is unexpected — log the full traceback so we
# can actually diagnose it. Previously this silently swallowed
# exceptions with no message body.
logger.exception("Telegram bot loop unexpected error — sleeping %.1fs", backoff)
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 30)
# ── Admin helper ──────────────────────────────────────────────────────────
async def unbind_wallet(wallet: str) -> int:
"""Detach a wallet from its Telegram binding (called by /api/telegram/unbind).
Sets wallet_address = NULL rather than deleting the row. The chat stays
subscribed at the free tier matches the UI's promise ("Your free
subscription stays active in the bot"). User wanting a full disconnect
sends /stop in the bot.
"""
async with async_session() as db:
r = await db.execute(
update(TelegramBinding)
.where(TelegramBinding.wallet_address == wallet.lower())
.values(wallet_address=None)
)
await db.commit()
return r.rowcount
+397 -23
View File
@@ -1,21 +1,39 @@
""" """
Take-profit / stop-loss monitor. Take-profit / stop-loss / trailing-stop monitor.
Subscribes to the Binance price stream; for every open trade that has TP/SL set, Subscribes to the Binance price stream; for every open trade, closes the HL
closes the HL position as soon as the live mark price crosses the threshold. position when the live mark price crosses one of:
1. Fixed stop-loss (always active).
2. Fixed take-profit (optional set to None for trend capture).
3. Trailing stop (optional once peak profit trailing_activate_at_pct,
close if price drops trailing_stop_pct from the peak).
The trailing branch is the centrepiece of the convex-payoff redesign:
fixed TP at +2% kills every potential runner before it starts. Instead we
let unrealised PnL grow, watching the peak, and only exit on a real
retracement.
Lifecycle: Lifecycle:
- bot_engine.open_position calls register_trade(...) after each new trade - bot_engine.open_position calls register_trade(...) after each new trade
- price callback in binance.py calls on_price_tick() once per second - binance.py's price callback calls on_price_tick() once per second
- when TP or SL hits, we enqueue close_and_finalize(...) and de-register - when a rule fires, _fire_close(...) calls bot_engine.close_and_finalize
""" """
import asyncio import asyncio
import logging import logging
from dataclasses import dataclass import time
from dataclasses import dataclass, field
from typing import Dict, Optional from typing import Dict, Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Throttled peak persistence: write peak_gain_pct to the DB at most once per
# PEAK_FLUSH_SECS per trade, and only after it advances ≥ PEAK_FLUSH_DELTA pp.
# Peak is monotonic so writes are rare after the initial run-up; this is
# enough to keep the post-restart regime correct without per-tick I/O.
PEAK_FLUSH_DELTA = 1.0
PEAK_FLUSH_SECS = 30.0
@dataclass @dataclass
class WatchedTrade: class WatchedTrade:
@@ -26,14 +44,83 @@ class WatchedTrade:
asset: str asset: str
side: str # "long" | "short" side: str # "long" | "short"
entry_price: float entry_price: float
take_profit_pct: Optional[float] take_profit_pct: Optional[float] # legacy fixed TP (may be None)
stop_loss_pct: Optional[float] stop_loss_pct: Optional[float] # always required in practice
trailing_stop_pct: Optional[float] # trail distance, e.g. 2.5
trailing_activate_at_pct: Optional[float] # activation threshold, e.g. 5.0
# System-2 only. "below_entry": exit immediately if price crosses back
# through entry BEFORE trailing arms — the reversal thesis (e.g. SMA
# reclaim) is dead, no reason to wait for the wide hard stop. None for
# System-1 / unset.
invalidation: Optional[str] = None
invalidation_price: Optional[float] = None
# Staged stop-loss ladder (System-2, optional). List of
# (peak_gain_trigger_pct, stop_floor_pct) sorted ascending. When set,
# this trade has NO take-profit and NO from-peak trailing: the only
# market exit is "price fell back to the highest staged floor that the
# peak gain has unlocked". See signal_categories.get_stop_ladder.
stop_ladder: Optional[list] = None
# Staged DOWNSIDE de-risk ladder (System-2, optional). List of
# (threshold_signed_pct_negative, frac_of_original, is_final) ordered by
# increasing adversity. While the trade is underwater (peak below the
# first stop_ladder trigger) it is scaled OUT in partial reduces at each
# rung; the final rung is a full close at the protective level (inside
# liquidation). See signal_categories.sys2_derisk_ladder.
derisk_ladder: Optional[list] = None
derisk_done: int = 0
derisk_in_flight: bool = field(default=False)
# Pyramiding (做对了往上加仓): add to a confirmed winner. List of
# (peak_gain_trigger_pct, frac_of_base, is_last). Only active in the
# profit regime AND while derisk_done==0. Each add blends entry up; the
# monitor then floors the stop at breakeven (eff_stop ≥ 0) so a pyramided
# winner can never become a loser.
addon_ladder: Optional[list] = None
addon_done: int = 0
addon_in_flight: bool = field(default=False)
# Per-trade "Grow" switch. Pyramiding only runs when True. De-risk +
# ratchet stop are unaffected (safety floor is always on). Toggled live
# by the user via POST /positions/{id}/grow.
grow_mode: bool = field(default=False)
# Parabolic-top trailing: (activate_peak_gain_pct, price_drawdown_frac).
# In the profit regime, once peak ≥ activate, the floor also trails the
# peak PRICE by at most drawdown_frac (scale-invariant). None = use only
# the fixed stop_ladder rungs. See signal_categories.sys2_peak_trail.
peak_trail: Optional[tuple] = None
# Throttled persistence of peak_gain_pct (monotonic). peak_persisted is
# the last value written to the DB; peak_persist_ts the last write time.
peak_persisted: float = field(default=0.0)
peak_persist_ts: float = field(default=0.0)
# System-1 (Trump) min-hold floor. Epoch seconds; before this time,
# take-profit and trailing exits are SUPPRESSED (don't scalp out of a
# developing move). Hard stop-loss + invalidation always fire — capital
# protection isn't deferrable. None = no floor (System 2 / legacy).
min_hold_until_ts: Optional[float] = None
# Runtime state — peak unrealised gain seen so far, in %. Used by the
# trailing branch. Long: peak = max(over time) of pct_gain. Short: same,
# but pct_gain is already signed correctly by the caller.
peak_gain_pct: float = field(default=0.0)
trailing_armed: bool = field(default=False)
# Buffer below entry before "below_entry" invalidation fires. Entry price is
# only a PROXY for the true thesis-invalidation level (e.g. the 200d SMA for
# an sma_reclaim). 0.75% is roughly one normal noise band on a major coin —
# big enough to survive the fill-tick wobble, small enough to still cut a
# failed reclaim well before the wide hard stop.
INVALIDATION_BUFFER_PCT = 0.75
# trade_id -> WatchedTrade # trade_id -> WatchedTrade
_watched: Dict[int, WatchedTrade] = {} _watched: Dict[int, WatchedTrade] = {}
# Strong references to fire-close tasks to prevent GC before completion # Strong refs to fire-close tasks (prevent GC before completion)
_background_tasks: set = set() _background_tasks: set = set()
@@ -45,18 +132,58 @@ def register_trade(
asset: str, asset: str,
side: str, side: str,
entry_price: float, entry_price: float,
take_profit_pct: Optional[float], take_profit_pct: Optional[float],
stop_loss_pct: Optional[float], stop_loss_pct: Optional[float],
trailing_stop_pct: Optional[float] = None,
trailing_activate_at_pct: Optional[float] = None,
invalidation: Optional[str] = None,
invalidation_price: Optional[float] = None,
min_hold_until_ts: Optional[float] = None,
stop_ladder: Optional[list] = None,
derisk_ladder: Optional[list] = None,
derisk_done: int = 0,
addon_ladder: Optional[list] = None,
addon_done: int = 0,
initial_peak: float = 0.0,
peak_trail: Optional[tuple] = None,
grow_mode: bool = False,
) -> None: ) -> None:
if take_profit_pct is None and stop_loss_pct is None: # Nothing to monitor → skip. We require AT LEAST a stop-loss for any
return # nothing to watch # registered trade; protocol still works without TP if trailing or a
# staged-stop / de-risk ladder is set.
if (stop_loss_pct is None
and take_profit_pct is None
and trailing_stop_pct is None
and not stop_ladder
and not derisk_ladder):
return
_watched[trade_id] = WatchedTrade( _watched[trade_id] = WatchedTrade(
trade_id=trade_id, wallet=wallet, api_key=api_key, leverage=leverage, trade_id=trade_id, wallet=wallet, api_key=api_key, leverage=leverage,
asset=asset, side=side, entry_price=entry_price, asset=asset, side=side, entry_price=entry_price,
take_profit_pct=take_profit_pct, stop_loss_pct=stop_loss_pct, take_profit_pct=take_profit_pct,
stop_loss_pct=stop_loss_pct,
trailing_stop_pct=trailing_stop_pct,
trailing_activate_at_pct=trailing_activate_at_pct,
invalidation=invalidation,
invalidation_price=invalidation_price,
min_hold_until_ts=min_hold_until_ts,
stop_ladder=stop_ladder,
derisk_ladder=derisk_ladder,
derisk_done=derisk_done or 0,
addon_ladder=addon_ladder,
addon_done=addon_done or 0,
peak_gain_pct=max(0.0, float(initial_peak or 0.0)),
peak_persisted=max(0.0, float(initial_peak or 0.0)),
peak_trail=peak_trail,
grow_mode=bool(grow_mode),
)
logger.info(
"Watching trade %d (%s %s @ %.4f, sl=%s, tp=%s, trail=%s/@%s)",
trade_id, side, asset, entry_price,
stop_loss_pct, take_profit_pct,
trailing_stop_pct, trailing_activate_at_pct,
) )
logger.info("TP/SL watching trade %d (%s %s @ %.2f, tp=%s, sl=%s)",
trade_id, side, asset, entry_price, take_profit_pct, stop_loss_pct)
def unregister(trade_id: int) -> None: def unregister(trade_id: int) -> None:
@@ -64,21 +191,163 @@ def unregister(trade_id: int) -> None:
def on_price_tick(asset: str, price: float) -> None: def on_price_tick(asset: str, price: float) -> None:
"""Called from binance.py on every price update. Fires close for any trade that hit TP/SL.""" """Called from binance.py on every price update.
Iterates watched trades on this asset, evaluates each rule, and triggers
a close on the first match. Rule evaluation order: stop-loss trailing
fixed TP. (Stop-loss first so a sudden gap kills the position before
we celebrate hitting trailing.)
"""
if not _watched: if not _watched:
return return
triggered = [] triggered = []
derisk_actions = [] # (wt, step_idx, frac_of_original)
addon_actions = [] # (wt, step_idx, frac_of_base)
for tid, wt in list(_watched.items()): for tid, wt in list(_watched.items()):
if wt.asset != asset: if wt.asset != asset:
continue continue
pct = (price - wt.entry_price) / wt.entry_price # Convert raw price → signed gain in position's direction, in %.
signed_pct = pct if wt.side == 'long' else -pct # gain in position's direction raw_pct = (price - wt.entry_price) / wt.entry_price
pct_x100 = signed_pct * 100 signed_pct = (raw_pct if wt.side == "long" else -raw_pct) * 100
if wt.take_profit_pct is not None and pct_x100 >= wt.take_profit_pct: # Update peak BEFORE evaluating trailing — a tick that pushes the peak
triggered.append((wt, "take_profit")) # AND retraces in the same step should still arm trailing at the new peak.
elif wt.stop_loss_pct is not None and pct_x100 <= -wt.stop_loss_pct: if signed_pct > wt.peak_gain_pct:
wt.peak_gain_pct = signed_pct
# Throttled monotonic persistence so a restart keeps the regime.
now_s = time.time()
if (wt.peak_gain_pct - wt.peak_persisted >= PEAK_FLUSH_DELTA
and now_s - wt.peak_persist_ts >= PEAK_FLUSH_SECS):
wt.peak_persisted = wt.peak_gain_pct
wt.peak_persist_ts = now_s
_pt = asyncio.create_task(
_persist_peak(wt.trade_id, wt.peak_gain_pct))
_background_tasks.add(_pt)
_pt.add_done_callback(_background_tasks.discard)
# 0. System-2 self-contained exit model: NO take-profit, NO from-peak
# trailing. Two regimes, fully replacing branches 1/1b/2/3:
# • Underwater → STAGED DE-RISK ("分段式减仓"): partial reduces
# at each downside rung, full close at the final (protective,
# inside-liquidation) rung.
# • In profit → STAGED STOP: floor ratchets up as peak gain
# crosses each upside rung; full close when price falls back.
if wt.stop_ladder or wt.derisk_ladder:
first_up = (min(t for t, _ in wt.stop_ladder)
if wt.stop_ladder else None)
in_profit_regime = (first_up is not None
and wt.peak_gain_pct >= first_up)
if wt.derisk_ladder and not in_profit_regime:
ladder = wt.derisk_ladder
# Gap protection: blown straight through to the final
# protective level → full close NOW regardless of step
# bookkeeping (still inside the exchange liquidation line).
if signed_pct <= ladder[-1][0]:
triggered.append((wt, "staged_stop"))
continue
if (not wt.derisk_in_flight
and 0 <= wt.derisk_done < len(ladder)):
thr, frac, is_final = ladder[wt.derisk_done]
if signed_pct <= thr:
if is_final:
triggered.append((wt, "staged_stop"))
continue
# Partial reduce — spawn async, guard re-entry until
# it completes (done-callback clears the flag).
wt.derisk_in_flight = True
derisk_actions.append((wt, wt.derisk_done, frac))
continue # underwater: no profit-ratchet / TP / trailing
# Profit regime — ratchet floor (base + unlocked upside rungs).
eff_stop = -wt.stop_loss_pct if wt.stop_loss_pct is not None else float("-inf")
if wt.stop_ladder:
for trig, floor in wt.stop_ladder: # sorted ascending
if wt.peak_gain_pct >= trig and floor > eff_stop:
eff_stop = floor
# Parabolic-top trailing: once deep in profit, also trail the PEAK
# PRICE by ≤ drawdown_frac (scale-invariant — self-adjusts to a
# +120% or +900% move so the fixed top rung doesn't cap it, while
# a normal ~2530% bull pullback still doesn't trip it).
if wt.peak_trail is not None:
start_pp, dd = wt.peak_trail
if wt.peak_gain_pct >= start_pp:
trail_floor = ((1.0 + wt.peak_gain_pct / 100.0)
* (1.0 - dd) - 1.0) * 100.0
if trail_floor > eff_stop:
eff_stop = trail_floor
# Pyramided → never let a winner become a loser: blended position
# is floored at breakeven once any add-on has filled.
if wt.addon_done > 0 and eff_stop < 0.0:
eff_stop = 0.0
if signed_pct <= eff_stop:
triggered.append((wt, "staged_stop"))
continue
# Pyramiding — add to a confirmed winner. Gated by the per-trade
# Grow switch (off by default — user opts a position in). Evaluated
# ONLY after the stop check above passed (never add on a tick that's
# also exiting). Peak-gain triggered; only while no de-risk has
# fired. Structural confirmation is re-checked in the executor.
if (wt.grow_mode and wt.addon_ladder and wt.derisk_done == 0
and not wt.addon_in_flight
and 0 <= wt.addon_done < len(wt.addon_ladder)):
a_trig, a_frac, _a_last = wt.addon_ladder[wt.addon_done]
if wt.peak_gain_pct >= a_trig:
wt.addon_in_flight = True
addon_actions.append((wt, wt.addon_done, a_frac))
continue
# 1. Stop loss — first priority.
if wt.stop_loss_pct is not None and signed_pct <= -wt.stop_loss_pct:
triggered.append((wt, "stop_loss")) triggered.append((wt, "stop_loss"))
continue
# 1b. Signal invalidation (System-2 only). Prefer the real thesis
# level when available (e.g. 200d SMA reclaim price). Fall back
# to entry-buffer proxy for legacy rows.
if wt.invalidation == "below_entry" and not wt.trailing_armed:
if wt.invalidation_price is not None:
invalidation_hit = (
(wt.side == "long" and price <= wt.invalidation_price) or
(wt.side == "short" and price >= wt.invalidation_price)
)
if invalidation_hit:
triggered.append((wt, "invalidation"))
continue
elif signed_pct <= -INVALIDATION_BUFFER_PCT:
triggered.append((wt, "invalidation"))
continue
# Min-hold gate (System-1 Trump). Before the floor elapses we let a
# winner develop — suppress TP + trailing. SL / invalidation above
# already ran, so downside is still protected; we only defer the
# PROFIT-side exits here.
if wt.min_hold_until_ts is not None:
import time as _t
if _t.time() < wt.min_hold_until_ts:
continue # still in min-hold; skip TP/trailing this tick
# 2. Trailing stop — only once armed.
if (wt.trailing_stop_pct is not None
and wt.trailing_activate_at_pct is not None):
if not wt.trailing_armed and wt.peak_gain_pct >= wt.trailing_activate_at_pct:
wt.trailing_armed = True
logger.info(
"Trade %d: trailing armed at peak %.2f%% (asset=%s)",
wt.trade_id, wt.peak_gain_pct, asset,
)
if wt.trailing_armed:
drawdown_from_peak = wt.peak_gain_pct - signed_pct
if drawdown_from_peak >= wt.trailing_stop_pct:
triggered.append((wt, "trailing_stop"))
continue
# 3. Fixed TP — legacy / fallback when no trailing config.
if wt.take_profit_pct is not None and signed_pct >= wt.take_profit_pct:
triggered.append((wt, "take_profit"))
continue
for wt, reason in triggered: for wt, reason in triggered:
unregister(wt.trade_id) unregister(wt.trade_id)
@@ -86,10 +355,115 @@ def on_price_tick(asset: str, price: float) -> None:
_background_tasks.add(task) _background_tasks.add(task)
task.add_done_callback(_background_tasks.discard) task.add_done_callback(_background_tasks.discard)
# Partial de-risk steps — trade stays registered (it's not closed).
for wt, step_idx, frac in derisk_actions:
task = asyncio.create_task(_fire_partial(wt, step_idx, frac))
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
# Pyramid add-ons — trade stays registered (size grows, entry blends).
for wt, step_idx, frac in addon_actions:
task = asyncio.create_task(_fire_pyramid(wt, step_idx, frac))
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
async def _fire_partial(wt: WatchedTrade, step_idx: int, frac: float) -> None:
"""Execute one staged de-risk partial reduce. The trade stays open and
registered; on success advance derisk_done, always clear the in-flight
guard so the next rung can fire on a later tick."""
from app.services.bot_engine import partial_derisk
ok = False
try:
ok = await partial_derisk(
trade_id=wt.trade_id,
api_key=wt.api_key,
asset=wt.asset,
wallet=wt.wallet,
step_idx=step_idx,
frac_of_original=frac,
reason="derisk",
)
except Exception as exc:
logger.error("De-risk partial failed for trade %d step %d: %s",
wt.trade_id, step_idx, exc)
finally:
# Re-fetch: the trade may have been closed/unregistered meanwhile.
live = _watched.get(wt.trade_id)
if live is not None:
if ok and live.derisk_done == step_idx:
live.derisk_done = step_idx + 1
live.derisk_in_flight = False
async def _fire_pyramid(wt: WatchedTrade, step_idx: int, frac: float) -> None:
"""Execute one pyramid add-on. On success re-base the in-memory entry to
the blended average and reset peak to the post-add gain so the ratchet /
regime use the NEW entry (and we stay in the profit regime). Always clear
the in-flight guard."""
from app.services.bot_engine import pyramid_add
ok, new_entry, ref_price = False, None, None
try:
ok, new_entry, ref_price = await pyramid_add(
trade_id=wt.trade_id,
api_key=wt.api_key,
asset=wt.asset,
wallet=wt.wallet,
step_idx=step_idx,
frac_of_base=frac,
reason="pyramid",
)
except Exception as exc:
logger.error("Pyramid add failed for trade %d step %d: %s",
wt.trade_id, step_idx, exc)
finally:
live = _watched.get(wt.trade_id)
if live is not None:
if ok and live.addon_done == step_idx:
# Step is RESOLVED (added, notional-capped, or already-done).
# Advance regardless of whether an add actually filled, else
# the monitor re-schedules this step every tick forever.
if new_entry:
live.entry_price = new_entry
if ref_price:
raw = (ref_price - new_entry) / new_entry
g = (raw if live.side == "long" else -raw) * 100.0
else:
g = 0.0
# Stay in the profit regime; ratchet re-accumulates here.
first_up = (min(t for t, _ in live.stop_ladder)
if live.stop_ladder else 0.0)
live.peak_gain_pct = max(g, first_up)
live.addon_done = step_idx + 1
live.addon_in_flight = False
async def _persist_peak(trade_id: int, peak: float) -> None:
"""Monotonic best-effort write of peak_gain_pct. Race-safe: only raises
the stored value (WHERE peak_gain_pct < :peak), never lowers it."""
try:
from sqlalchemy import update
from app.database import AsyncSessionLocal
from app.models import BotTrade
async with AsyncSessionLocal() as db:
await db.execute(
update(BotTrade)
.where(BotTrade.id == trade_id)
.where(BotTrade.peak_gain_pct < peak)
.values(peak_gain_pct=peak)
)
await db.commit()
except Exception as exc: # never let persistence break the monitor
logger.debug("peak persist failed trade %d: %s", trade_id, exc)
async def _fire_close(wt: WatchedTrade, reason: str) -> None: async def _fire_close(wt: WatchedTrade, reason: str) -> None:
from app.services.bot_engine import close_and_finalize from app.services.bot_engine import close_and_finalize
try: try:
logger.info(
"Closing trade %d on %s (peak=%.2f%%, reason=%s)",
wt.trade_id, wt.asset, wt.peak_gain_pct, reason,
)
await close_and_finalize( await close_and_finalize(
trade_id=wt.trade_id, trade_id=wt.trade_id,
api_key=wt.api_key, api_key=wt.api_key,
+43 -7
View File
@@ -1,3 +1,22 @@
"""
WebSocket connection manager.
One global broadcaster that fans out every message to every connected client.
Critical that broadcast NEVER blocks the caller Binance kline ticks arrive
every ~500 ms and the broadcast is inline with the WS read loop. A single
slow / half-closed client could otherwise stall the kline pipeline, miss the
keepalive ping, and tear the upstream Binance socket down (this was the root
cause of the "no close frame received or sent" reconnect storms in prod logs).
Two defences here:
* Per-client send wrapped in `asyncio.wait_for(..., timeout=PER_CLIENT_SEND_TIMEOUT)`.
* All clients dispatched in parallel via `asyncio.gather()` so one slow
client can't delay anyone else.
"""
from __future__ import annotations
import asyncio
import json import json
import logging import logging
from typing import List from typing import List
@@ -6,6 +25,10 @@ from fastapi import WebSocket
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Per-client send timeout. 2 s is plenty for a healthy TCP send of our tiny
# JSON payloads; anything slower means a half-closed or zombie client.
PER_CLIENT_SEND_TIMEOUT = 2.0
class ConnectionManager: class ConnectionManager:
def __init__(self): def __init__(self):
@@ -25,15 +48,28 @@ class ConnectionManager:
if not self.active_connections: if not self.active_connections:
return return
payload = json.dumps(message) payload = json.dumps(message)
dead: List[WebSocket] = [] # Snapshot the list — disconnect() mutates active_connections and we
for connection in self.active_connections: # don't want "list changed during iteration" when we prune.
connections = list(self.active_connections)
async def _send_one(ws: WebSocket):
try: try:
await connection.send_text(payload) await asyncio.wait_for(ws.send_text(payload),
timeout=PER_CLIENT_SEND_TIMEOUT)
return None
except asyncio.TimeoutError:
logger.warning("WebSocket send timed out (>%.1fs) — pruning client",
PER_CLIENT_SEND_TIMEOUT)
return ws
except Exception as exc: except Exception as exc:
logger.warning("Failed to send to WebSocket: %s", exc) logger.warning("WebSocket send failed: %s — pruning client", exc)
dead.append(connection) return ws
for ws in dead:
await self.disconnect(ws) # Fan out concurrently — one slow client can't stall the others.
results = await asyncio.gather(*[_send_one(ws) for ws in connections])
for dead_ws in results:
if dead_ws is not None:
await self.disconnect(dead_ws)
manager = ConnectionManager() manager = ConnectionManager()
+195
View File
@@ -0,0 +1,195 @@
"""
Backtest: replay AI signals against actual price moves stored in the DB.
Zero AI calls. Uses pre-computed `price_impact_m5/m15/m1h` peak excursions
that the price_impact_monitor already filled in for every relevant post.
Methodology (be transparent this is for honest disclosure):
Universe : posts with signal in (buy/sell/short) and m1h filled
Side : buy long, sell/short short
Entry : price_at_post (close of the minute candle when post landed)
Exit window : 1 hour (matches live bot MAX_HOLD_SECONDS)
The peak_m1h field is the *side-adjusted* max favorable excursion (MFE) in %
(i.e. positive means market moved in the bot's predicted direction)
Fees : 9 bps round-trip (HL taker × 2), matches live bot
TP simulation : if peak TP threshold, exit at TP; else conservative 0
(we don't have the actual close price, so 0 = breakeven
on direction captures only the trades that hit TP)
Caveats reported up front:
MFE is the peak during the window. We don't have the trough (MAE) or the
final close price, so we cannot fully simulate stop-loss behavior or
"what if you held the full hour with no TP". This is a TP-only sim.
No slippage modeled beyond the 9 bps fee. Real fills on illiquid moments
add 1-3 bps more.
Sample is whatever's in the DB right now (~70 actionable signals).
"""
import sqlite3
import statistics
from pathlib import Path
DB = Path(__file__).resolve().parents[1] / "trumpsignal.db"
FEE_BPS_ROUND_TRIP = 0.0009 # 9 bps = HL taker × 2
TP_LEVELS = [0.10, 0.20, 0.30, 0.50, 1.00] # in percent
def fetch_signals():
con = sqlite3.connect(DB)
con.row_factory = sqlite3.Row
rows = con.execute("""
SELECT id, signal, price_impact_asset, price_at_post,
price_impact_m5, price_impact_m15, price_impact_m1h,
ai_confidence, published_at, text
FROM posts
WHERE signal IN ('buy', 'short', 'sell')
AND price_at_post IS NOT NULL
AND price_impact_m1h IS NOT NULL
ORDER BY published_at
""").fetchall()
con.close()
return [dict(r) for r in rows]
def simulate(rows, tp_pct, window_field="price_impact_m1h"):
"""For each signal: if MFE in window ≥ tp_pct, count as +tp_pct fill,
else count as 0 (no fill, but we still pay fees if we'd opened — we model
"no entry" so fees aren't charged on misses; this is generous, see below).
Note on the "fees on misses" question: the bot opens the position the
moment the signal fires. Even if TP isn't hit and you exit at the 1h mark,
you paid the round-trip fees. So a stricter sim charges fees on EVERY
trade. We do that this is the conservative interpretation.
"""
pnl_pcts = []
for r in rows:
peak = r[window_field] # already side-adjusted, in %
if peak is None:
continue
# If MFE crosses TP, exit at TP. Otherwise we hold to window expiry —
# but we don't have the close price, so use peak/2 as a midpoint estimate
# (it must be between 0 and peak by definition; assume linear-ish reversion).
if peak >= tp_pct:
gross = tp_pct
else:
gross = peak / 2.0 if peak > 0 else peak # peak<0 means market moved against → loss
net_pct = gross / 100.0 - FEE_BPS_ROUND_TRIP
pnl_pcts.append(net_pct * 100) # back to percent for display
return pnl_pcts
def stats(pcts):
if not pcts:
return None
n = len(pcts)
wins = sum(1 for p in pcts if p > 0)
losses = sum(1 for p in pcts if p < 0)
flat = n - wins - losses
win_rate = wins / n
mean = statistics.mean(pcts)
median = statistics.median(pcts)
pmax = max(pcts)
pmin = min(pcts)
# Profit factor: sum of wins / abs(sum of losses)
gross_win = sum(p for p in pcts if p > 0)
gross_loss = abs(sum(p for p in pcts if p < 0))
pf = gross_win / gross_loss if gross_loss > 0 else float("inf")
# Cumulative PnL (linear sum, not compounded — conservative)
total = sum(pcts)
return {
"n": n,
"wins": wins,
"losses": losses,
"flat": flat,
"win_rate_pct": round(win_rate * 100, 1),
"mean_pct": round(mean, 3),
"median_pct": round(median, 3),
"max_pct": round(pmax, 3),
"min_pct": round(pmin, 3),
"profit_factor": round(pf, 2) if pf != float("inf") else "",
"total_pct": round(total, 2),
}
def main():
rows = fetch_signals()
print(f"BACKTEST — TrumpSignal AI strategy on Truth Social posts")
print(f"=" * 70)
print(f"Universe: {len(rows)} actionable signals (buy/sell/short)")
by_signal = {}
for r in rows:
by_signal[r['signal']] = by_signal.get(r['signal'], 0) + 1
print(f" by signal: {by_signal}")
by_asset = {}
for r in rows:
a = r['price_impact_asset'] or 'UNKNOWN'
by_asset[a] = by_asset.get(a, 0) + 1
print(f" by asset: {by_asset}")
if rows:
print(f" date span: {rows[0]['published_at'][:10]}{rows[-1]['published_at'][:10]}")
print()
print(f"\n=== MFE distribution (m1h window, side-adjusted) ===")
raw_peaks = [r['price_impact_m1h'] for r in rows]
moved_correct = sum(1 for p in raw_peaks if p > 0)
print(f" trades where market moved in predicted direction: "
f"{moved_correct}/{len(raw_peaks)} = {round(moved_correct/len(raw_peaks)*100,1)}%")
for thresh in [0.1, 0.2, 0.3, 0.5, 1.0, 2.0]:
hit = sum(1 for p in raw_peaks if p >= thresh)
print(f" MFE ≥ {thresh:>4.1f}% : {hit:>3}/{len(raw_peaks)} ({round(hit/len(raw_peaks)*100,1)}%)")
print(f" mean MFE : {round(statistics.mean(raw_peaks),3)}%")
print(f" median MFE : {round(statistics.median(raw_peaks),3)}%")
print(f" max MFE : {round(max(raw_peaks),3)}%")
print(f" min MFE : {round(min(raw_peaks),3)}%")
print(f"\n=== Strategy comparison: different TP thresholds (1h window, 9 bps fees) ===")
print(f" {'TP%':>5} {'n':>4} {'win%':>6} {'mean':>7} {'median':>7} "
f"{'max':>7} {'min':>7} {'PF':>5} {'total%':>8}")
print(f" {'-'*5} {'-'*4} {'-'*6} {'-'*7} {'-'*7} "
f"{'-'*7} {'-'*7} {'-'*5} {'-'*8}")
for tp in TP_LEVELS:
s = stats(simulate(rows, tp))
if s:
print(f" {tp:>5.2f} {s['n']:>4} {s['win_rate_pct']:>5.1f}% "
f"{s['mean_pct']:>+6.3f}% {s['median_pct']:>+6.3f}% "
f"{s['max_pct']:>+6.3f}% {s['min_pct']:>+6.3f}% "
f"{str(s['profit_factor']):>5} {s['total_pct']:>+7.2f}%")
# Also break down by signal direction
print(f"\n=== By signal direction (TP=0.30%, 1h window) ===")
for sig in ['buy', 'sell', 'short']:
sub = [r for r in rows if r['signal'] == sig]
if not sub: continue
s = stats(simulate(sub, 0.30))
print(f" {sig:>5}: n={s['n']:>3} win_rate={s['win_rate_pct']:>5.1f}% "
f"mean={s['mean_pct']:>+6.3f}% total={s['total_pct']:>+7.2f}% PF={s['profit_factor']}")
# Confidence-bucket performance
print(f"\n=== By AI confidence bucket (TP=0.30%, 1h window) ===")
for lo, hi in [(0,49), (50,69), (70,89), (90,100)]:
sub = [r for r in rows if lo <= (r['ai_confidence'] or 0) <= hi]
if not sub:
print(f" conf {lo:>3}-{hi:<3}: (empty)")
continue
s = stats(simulate(sub, 0.30))
print(f" conf {lo:>3}-{hi:<3}: n={s['n']:>3} win_rate={s['win_rate_pct']:>5.1f}% "
f"mean={s['mean_pct']:>+6.3f}% total={s['total_pct']:>+7.2f}% PF={s['profit_factor']}")
# Window comparison: which TP horizon best captures the move?
print(f"\n=== Best window comparison (TP=0.30%) ===")
print(f" Same TP, different exit windows. Tells you which horizon to trade.")
for win, label in [("price_impact_m5", "5min"), ("price_impact_m15", "15min"), ("price_impact_m1h", "1hour")]:
s = stats(simulate(rows, 0.30, window_field=win))
if s:
print(f" {label:>6}: n={s['n']:>3} win_rate={s['win_rate_pct']:>5.1f}% "
f"mean={s['mean_pct']:>+6.3f}% total={s['total_pct']:>+7.2f}% PF={s['profit_factor']}")
print(f"\n{'=' * 70}")
print(f"DISCLAIMER: This is a TP-only simulation using max favorable excursion")
print(f" data. Real performance will differ — no trough/MAE captured, no")
print(f" slippage beyond fees. Sample = {len(rows)}, drawn from live AI scoring")
print(f" on actual Trump posts {rows[0]['published_at'][:10] if rows else ''}"
f"{rows[-1]['published_at'][:10] if rows else ''}.")
if __name__ == "__main__":
main()
+214
View File
@@ -0,0 +1,214 @@
"""
Past-7-day backtest under user-specified params:
- Margin: $100, leverage 20x notional $2000 per trade
- SL: -30% on margin = -1.5% on notional move
- TP: "exit at the future peak" (look-ahead see caveat at end)
- Window: 1 hour after publish
- Fees: 9 bps round-trip (HL taker × 2)
We only have the price_impact_m5/m15/m1h fields, which store the MAXIMUM
FAVORABLE EXCURSION (MFE). We do NOT have the trough / max ADVERSE excursion
(MAE), so we cannot detect a real intra-hour SL hit. That makes the
optimistic sim a CEILING, not a real-world outcome.
We compute three scenarios so you can see the honest spread:
A) "Perfect-foresight": exit at MFE peak. SL ignored (assume MAE = 0).
This is what the user asked for. Upper bound only.
B) "Pessimistic": if MFE < 0, assume SL hit (-1.5% × $2000 = -$30).
If MFE 0, exit at MFE peak.
Closer to reality but still optimistic on the wins.
C) "Realistic fixed TP": TP at +1.5%, SL at -1.5% (symmetric).
If MFE TP win. Else unknown end-of-window
price, conservative model: take MFE × 0.5 as exit.
This is the closest to a real bot's behavior.
"""
import sqlite3
import statistics
from pathlib import Path
DB = Path(__file__).resolve().parents[1] / "trumpsignal.db"
NOTIONAL = 2000.0
MARGIN = 100.0
SL_PCT = 1.5 # 1.5% notional move = -30% margin
FEE_BPS_RT = 0.0009 # 9 bps × $2000 = $1.80 per trade
def fetch_week():
"""Last 7 days of actionable signals, anchored to the LATEST post in the
DB (not wall-clock now) so the script works regardless of how stale the
snapshot is."""
con = sqlite3.connect(DB)
con.row_factory = sqlite3.Row
latest = con.execute("SELECT MAX(published_at) FROM posts").fetchone()[0]
rows = con.execute("""
SELECT id, signal, ai_confidence, price_at_post,
price_impact_m5, price_impact_m15, price_impact_m1h,
published_at, text
FROM posts
WHERE signal IN ('buy', 'short') -- exclude sell (semantic bug)
AND price_at_post IS NOT NULL
AND price_impact_m1h IS NOT NULL
AND published_at >= datetime(?, '-7 days')
ORDER BY published_at
""", (latest,)).fetchall()
con.close()
return [dict(r) for r in rows], latest
def trade_pnl_usd(gross_pct: float) -> float:
"""Convert a notional % move into $ PnL on $2000 notional, after fees."""
return NOTIONAL * (gross_pct / 100.0) - NOTIONAL * FEE_BPS_RT
def sim_perfect(rows):
"""A) Exit at peak. No SL. (User's request — look-ahead bias.)"""
pnls = []
for r in rows:
peak = r["price_impact_m1h"] # already side-adjusted, %
# Even peak < 0 still "exits at peak" per user spec
pnls.append(trade_pnl_usd(peak))
return pnls
def sim_pessimistic(rows):
"""B) If peak < 0, assume SL hit. Else exit at peak."""
pnls = []
for r in rows:
peak = r["price_impact_m1h"]
if peak < 0:
pnls.append(trade_pnl_usd(-SL_PCT)) # SL = -$30 + fees
else:
pnls.append(trade_pnl_usd(peak))
return pnls
def sim_fixed_tp(rows, tp_pct=1.5):
"""C) TP=tp_pct, SL=-1.5%. Real-bot behavior."""
pnls = []
for r in rows:
peak = r["price_impact_m1h"]
if peak >= tp_pct:
pnls.append(trade_pnl_usd(tp_pct)) # TP hit
elif peak < 0:
pnls.append(trade_pnl_usd(-SL_PCT)) # SL likely
else:
# Held the hour, peak was below TP → exit somewhere between
# 0 and peak. Use peak/2 as a midpoint estimate (conservative).
pnls.append(trade_pnl_usd(peak / 2.0))
return pnls
def stats(pnls, label):
n = len(pnls)
if n == 0:
return None
wins = sum(1 for p in pnls if p > 0)
losses = sum(1 for p in pnls if p < 0)
total = sum(pnls)
biggest_win = max(pnls)
biggest_loss = min(pnls)
win_rate = wins / n * 100
# Margin return: total $ / total margin used
total_margin_used = MARGIN * n
roi_on_margin = (total / total_margin_used) * 100
return {
"label": label,
"n": n,
"wins": wins,
"losses": losses,
"win_rate_pct": round(win_rate, 1),
"total_usd": round(total, 2),
"avg_per_trade_usd": round(total / n, 2),
"biggest_win_usd": round(biggest_win, 2),
"biggest_loss_usd": round(biggest_loss, 2),
"roi_on_margin_pct": round(roi_on_margin, 1),
}
def print_stats(s):
if s is None:
print(" (no trades)")
return
print(f" n trades: {s['n']}")
print(f" win rate: {s['win_rate_pct']}% ({s['wins']}W / {s['losses']}L)")
print(f" total PnL (USD): ${s['total_usd']:+,.2f}")
print(f" avg per trade: ${s['avg_per_trade_usd']:+,.2f}")
print(f" biggest win: ${s['biggest_win_usd']:+,.2f}")
print(f" biggest loss: ${s['biggest_loss_usd']:+,.2f}")
print(f" ROI on margin: {s['roi_on_margin_pct']:+.1f}% "
f"(${s['total_usd']:+.0f} on ${MARGIN * s['n']:.0f} total margin used)")
def main():
rows, anchor = fetch_week()
print("=" * 72)
print(f"PAST-7-DAY BACKTEST — TrumpSignal AI strategy")
print(f"(7 days back from latest DB post: {anchor[:19]})")
print("=" * 72)
print(f"Position size: ${MARGIN} margin × 20x leverage = ${NOTIONAL:.0f} notional")
print(f"Stop loss: -30% margin = -{SL_PCT}% notional")
print(f"Take profit: see scenarios below")
print(f"Fees: 9 bps round-trip = ${NOTIONAL * FEE_BPS_RT:.2f} per trade")
print(f"Hold window: 1 hour")
print(f"Sample: {len(rows)} actionable signals (buy/short, sell excluded)")
if rows:
print(f"Date span: {rows[0]['published_at'][:10]}{rows[-1]['published_at'][:10]}")
print()
if not rows:
print("⚠️ No actionable signals in the last 7 days.")
print(" Either Trump didn't post anything market-relevant, or all signals")
print(" were 'hold'. Cannot run backtest.")
return
print("=" * 72)
print("SCENARIO A — 'Perfect foresight' (exit at peak, ignore SL) ← user spec")
print(" This is the THEORETICAL CEILING. No real bot can hit this.")
print("=" * 72)
print_stats(stats(sim_perfect(rows), "perfect"))
print()
print("=" * 72)
print("SCENARIO B — 'Pessimistic' (SL hits if peak<0, else exit at peak)")
print(" Closer to honest, but wins are still cherry-picked at peak.")
print("=" * 72)
print_stats(stats(sim_pessimistic(rows), "pessimistic"))
print()
print("=" * 72)
print("SCENARIO C — 'Realistic fixed TP/SL' (TP=+1.5%, SL=-1.5%)")
print(" Closest to what a real bot with these params would yield.")
print(" THIS is the only one defensible for marketing.")
print("=" * 72)
print_stats(stats(sim_fixed_tp(rows, tp_pct=1.5), "realistic"))
print()
# Also try a couple of TP variations to find sweet spot
print("=" * 72)
print("Realistic sim — TP threshold sweep (SL fixed at -1.5%)")
print("=" * 72)
print(f" {'TP%':>6} {'n':>4} {'win%':>6} {'total$':>10} {'avg$':>8} {'ROI%margin':>11}")
for tp in [0.5, 1.0, 1.5, 2.0, 3.0]:
s = stats(sim_fixed_tp(rows, tp), f"tp={tp}")
print(f" {tp:>5.1f}% {s['n']:>4} {s['win_rate_pct']:>5.1f}% "
f"${s['total_usd']:>+8.2f} ${s['avg_per_trade_usd']:>+6.2f} "
f"{s['roi_on_margin_pct']:>+9.1f}%")
print()
print("=" * 72)
print("BOTTOM LINE")
print("=" * 72)
perfect = stats(sim_perfect(rows), "")
realistic = stats(sim_fixed_tp(rows, 1.5), "")
print(f" Perfect-foresight ceiling: ${perfect['total_usd']:+,.2f} "
f"(do NOT put on homepage — look-ahead bias)")
print(f" Realistic fixed TP=1.5%: ${realistic['total_usd']:+,.2f} "
f"(this is the marketable number, IF positive)")
if __name__ == "__main__":
main()
+357
View File
@@ -0,0 +1,357 @@
"""
Backtest: Reversal + Breakout signals on Binance Futures 5m klines
Covers 2023-01-01 to present across SOL, ETH, AVAX, LINK, DOGE
"""
import io
import time
import zipfile
import subprocess
import warnings
import pandas as pd
import numpy as np
from datetime import datetime, timezone
from dateutil.relativedelta import relativedelta
from tabulate import tabulate
warnings.filterwarnings("ignore")
# ── Config ────────────────────────────────────────────────────────────────────
SYMBOLS = ["ETHUSDT", "LINKUSDT", "AVAXUSDT", "SOLUSDT", "DOGEUSDT"]
BTC_SYMBOL = "BTCUSDT"
START_TS = int(datetime(2023, 1, 1, tzinfo=timezone.utc).timestamp() * 1000)
END_TS = int(datetime(2025, 12, 31, tzinfo=timezone.utc).timestamp() * 1000)
STOP_LOSS = -0.03 # -3%
TAKE_PROFIT = 0.035 # +3.5%
MAX_HOLD_CANDLES = 288 # 24h at 5m
TREND_MA_PERIOD = 48 # 4h trend filter (48 x 5m = 4h)
BTC_TREND_MA = 288 # BTC 24h trend filter (288 x 5m = 24h)
# Signal params
REVERSAL_TAKER_BUY_THRESH = 0.65
REVERSAL_PREV_TAKER_MAX = 0.45
REVERSAL_MA_PERIOD = 20
REVERSAL_4H_DECLINE = -0.05
BREAKOUT_BB_PERIOD = 20
BREAKOUT_BB_SQUEEZE_PCT = 20 # bottom 20% of BB width history (60 candles)
BREAKOUT_VOLUME_MULT = 2.5
BREAKOUT_TAKER_BUY_THRESH = 0.60
DATA_BASE = "https://data.binance.vision/data/futures/um/monthly/klines"
CACHE_DIR = "/tmp/binance_klines_cache"
KLINE_COLS = [
"open_time", "open", "high", "low", "close", "volume",
"close_time", "quote_volume", "trades",
"taker_buy_base", "taker_buy_quote", "ignore"
]
# ── Data fetch ────────────────────────────────────────────────────────────────
def _fetch_month(symbol: str, year: int, month: int):
"""Download one monthly zip from data.binance.vision and return DataFrame."""
url = f"{DATA_BASE}/{symbol}/5m/{symbol}-5m-{year}-{month:02d}.zip"
for attempt in range(3):
result = subprocess.run(
["curl", "-s", "-x", "http://127.0.0.1:7890",
"--max-time", "120", "--output", "-", url],
capture_output=True,
)
if result.returncode == 0 and result.stdout:
try:
with zipfile.ZipFile(io.BytesIO(result.stdout)) as zf:
csv_name = zf.namelist()[0]
with zf.open(csv_name) as f:
df = pd.read_csv(f, names=KLINE_COLS, skiprows=1)
return df
except Exception:
pass
time.sleep(2 ** attempt)
return None
def fetch_klines(symbol: str, start_ms: int, end_ms: int) -> pd.DataFrame:
import os
os.makedirs(CACHE_DIR, exist_ok=True)
cache_file = f"{CACHE_DIR}/{symbol}.pkl"
start_dt = datetime.fromtimestamp(start_ms / 1000, tz=timezone.utc)
end_dt = datetime.fromtimestamp(end_ms / 1000, tz=timezone.utc)
# Load from cache if available
if os.path.exists(cache_file):
print(f" Loading {symbol} from cache...", end="", flush=True)
df = pd.read_pickle(cache_file)
df = df[(df.index >= pd.Timestamp(start_dt)) & (df.index <= pd.Timestamp(end_dt))]
print(f" {len(df)} candles (cached)")
return df
frames = []
cur = start_dt.replace(day=1)
print(f" Fetching {symbol}", end="", flush=True)
while cur <= end_dt:
df = _fetch_month(symbol, cur.year, cur.month)
if df is not None:
frames.append(df)
print(".", end="", flush=True)
else:
print("x", end="", flush=True)
cur += relativedelta(months=1)
if not frames:
print(f" FAILED")
return pd.DataFrame()
df = pd.concat(frames, ignore_index=True)
for col in ["open", "high", "low", "close", "volume", "taker_buy_base"]:
df[col] = pd.to_numeric(df[col], errors="coerce")
df["open_time"] = pd.to_datetime(df["open_time"], unit="ms", utc=True)
df = df.set_index("open_time").sort_index()
# Save to cache before filtering
df.to_pickle(cache_file)
df = df[(df.index >= pd.Timestamp(start_dt)) & (df.index <= pd.Timestamp(end_dt))]
print(f" {len(df)} candles")
return df
# ── Indicators ────────────────────────────────────────────────────────────────
def add_indicators(df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
# Taker buy ratio
df["tbr"] = df["taker_buy_base"] / df["volume"].replace(0, np.nan)
# MA20
df["ma20"] = df["close"].rolling(REVERSAL_MA_PERIOD).mean()
# 4h trend MA (48 x 5m candles)
df["ma_trend"] = df["close"].rolling(TREND_MA_PERIOD).mean()
# 4h decline: compare current close to close 48 candles ago (48 * 5m = 4h)
df["decline_4h"] = df["close"].pct_change(48)
# Bollinger Bands
rolling = df["close"].rolling(BREAKOUT_BB_PERIOD)
df["bb_mid"] = rolling.mean()
df["bb_std"] = rolling.std()
df["bb_upper"] = df["bb_mid"] + 2 * df["bb_std"]
df["bb_width"] = (4 * df["bb_std"]) / df["bb_mid"]
# BB width percentile over past 60 candles
df["bb_width_pct"] = df["bb_width"].rolling(60).rank(pct=True) * 100
# Volume MA20
df["vol_ma20"] = df["volume"].rolling(20).mean()
return df
# ── Signal detection ──────────────────────────────────────────────────────────
def detect_signals(df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
# Previous 3 candle TBR max
df["tbr_prev3_max"] = df["tbr"].shift(1).rolling(3).max()
# Trend up filter: price above 4h MA
trend_up = df["close"] > df["ma_trend"]
# Reversal signal
df["sig_reversal"] = (
(df["tbr"] > REVERSAL_TAKER_BUY_THRESH) &
(df["tbr_prev3_max"] < REVERSAL_PREV_TAKER_MAX) &
(df["close"] < df["ma20"]) &
(df["decline_4h"] < REVERSAL_4H_DECLINE) &
trend_up
)
# Breakout signal
df["sig_breakout"] = (
(df["bb_width_pct"] < BREAKOUT_BB_SQUEEZE_PCT) &
(df["volume"] > BREAKOUT_VOLUME_MULT * df["vol_ma20"]) &
(df["tbr"] > BREAKOUT_TAKER_BUY_THRESH) &
(df["close"] > df["bb_upper"]) &
trend_up
)
df["signal"] = np.where(
df["sig_reversal"], "reversal",
np.where(df["sig_breakout"], "breakout", None)
)
return df
# ── Trade simulation ──────────────────────────────────────────────────────────
def simulate_trades(df: pd.DataFrame, symbol: str) -> list[dict]:
trades = []
in_trade = False
entry_price = 0.0
entry_time = None
entry_signal = None
hold_count = 0
closes = df["close"].values
signals = df["signal"].values
times = df.index
for i in range(1, len(df)):
if in_trade:
hold_count += 1
pct = (closes[i] - entry_price) / entry_price
hit_sl = pct <= STOP_LOSS
hit_tp = pct >= TAKE_PROFIT
hit_max = hold_count >= MAX_HOLD_CANDLES
if hit_sl or hit_tp or hit_max:
reason = "SL" if hit_sl else ("TP" if hit_tp else "MAX")
trades.append({
"symbol": symbol,
"signal": entry_signal,
"entry_time": entry_time,
"exit_time": times[i],
"entry_price": entry_price,
"exit_price": closes[i],
"pct": pct,
"result": "win" if pct > 0 else "loss",
"reason": reason,
"month": entry_time.strftime("%Y-%m"),
})
in_trade = False
# Enter on next candle after signal
if not in_trade and i > 0 and signals[i - 1] is not None:
in_trade = True
entry_price = closes[i] # next candle open ≈ prev close (5m)
entry_time = times[i]
entry_signal = signals[i - 1]
hold_count = 0
return trades
# ── Analysis ──────────────────────────────────────────────────────────────────
def analyze(trades: list[dict]) -> None:
if not trades:
print("No trades found.")
return
df = pd.DataFrame(trades)
print("\n" + "=" * 70)
print("OVERALL SUMMARY")
print("=" * 70)
for sig_type in ["reversal", "breakout", "all"]:
sub = df if sig_type == "all" else df[df["signal"] == sig_type]
if sub.empty:
continue
wins = (sub["result"] == "win").sum()
total = len(sub)
avg_pct = sub["pct"].mean() * 100
total_pct = sub["pct"].sum() * 100
print(f"\n[{sig_type.upper()}] trades={total} win_rate={wins/total:.1%}"
f" avg={avg_pct:.2f}% total_return={total_pct:.1f}%")
print("\n" + "=" * 70)
print("MONTHLY BREAKDOWN (all signals combined)")
print("=" * 70)
monthly = (
df.groupby("month")
.agg(
trades=("pct", "count"),
wins=("result", lambda x: (x == "win").sum()),
avg_pct=("pct", lambda x: x.mean() * 100),
total_pct=("pct", lambda x: x.sum() * 100),
)
.reset_index()
)
monthly["win_rate"] = monthly["wins"] / monthly["trades"]
monthly["avg_pct"] = monthly["avg_pct"].map("{:.2f}%".format)
monthly["total_pct"] = monthly["total_pct"].map("{:.1f}%".format)
monthly["win_rate"] = monthly["win_rate"].map("{:.1%}".format)
print(tabulate(monthly, headers="keys", tablefmt="simple", showindex=False))
print("\n" + "=" * 70)
print("PER SYMBOL SUMMARY")
print("=" * 70)
by_sym = (
df.groupby("symbol")
.agg(
trades=("pct", "count"),
wins=("result", lambda x: (x == "win").sum()),
avg_pct=("pct", lambda x: x.mean() * 100),
total_pct=("pct", lambda x: x.sum() * 100),
)
.reset_index()
)
by_sym["win_rate"] = by_sym["wins"] / by_sym["trades"]
by_sym["avg_pct"] = by_sym["avg_pct"].map("{:.2f}%".format)
by_sym["total_pct"] = by_sym["total_pct"].map("{:.1f}%".format)
by_sym["win_rate"] = by_sym["win_rate"].map("{:.1%}".format)
print(tabulate(by_sym, headers="keys", tablefmt="simple", showindex=False))
print("\n" + "=" * 70)
print("EXIT REASON BREAKDOWN")
print("=" * 70)
print(df.groupby(["signal", "reason"]).size().to_string())
# ── Main ──────────────────────────────────────────────────────────────────────
def main():
all_trades = []
# ── Load BTC trend ────────────────────────────────────────────────────────
print("Loading BTC trend data...")
btc = fetch_klines(BTC_SYMBOL, START_TS, END_TS)
if btc.empty:
print(" BTC data failed, running without BTC filter")
btc_trend = None
else:
btc["btc_ma"] = btc["close"].rolling(BTC_TREND_MA).mean()
btc_trend = (btc["close"] > btc["btc_ma"]).rename("btc_uptrend")
print(f" BTC uptrend {btc_trend.mean():.1%} of the time")
# ── Per-symbol backtest ───────────────────────────────────────────────────
for symbol in SYMBOLS:
df = fetch_klines(symbol, START_TS, END_TS)
if df.empty:
print(f" → skipped")
continue
df = add_indicators(df)
df = detect_signals(df)
# Apply BTC trend filter
if btc_trend is not None:
btc_aligned = btc_trend.reindex(df.index, method="ffill")
df.loc[~btc_aligned.fillna(False), "signal"] = None
filtered = df["signal"].notna().sum()
else:
filtered = df["signal"].notna().sum()
sig_count = df["signal"].notna().sum()
print(f"{sig_count} signals after BTC filter (was {filtered})")
trades = simulate_trades(df, symbol)
print(f"{len(trades)} trades executed")
all_trades.extend(trades)
analyze(all_trades)
if __name__ == "__main__":
main()
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
#
# Daily DB backup. Cron-friendly. Detects SQLite vs Postgres from DATABASE_URL.
#
# Example crontab:
# 15 3 * * * /path/to/scripts/backup_db.sh >> /var/log/trumpsignal-backup.log 2>&1
#
# Retention: keeps the last RETAIN_DAYS daily backups (default 14).
# Storage: writes to $BACKUP_DIR (default ./backups/).
#
# DATABASE_URL must be available in env. Loads .env if present.
set -euo pipefail
cd "$(dirname "$0")/.."
# Load .env if it exists (so cron jobs without inherited env still work)
if [[ -f .env ]]; then
set -a
# shellcheck disable=SC1091
source .env
set +a
fi
: "${DATABASE_URL:?DATABASE_URL not set — refusing to back up nothing}"
BACKUP_DIR="${BACKUP_DIR:-./backups}"
RETAIN_DAYS="${RETAIN_DAYS:-14}"
TS="$(date -u +%Y%m%d_%H%M%S)"
mkdir -p "$BACKUP_DIR"
if [[ "$DATABASE_URL" == sqlite* ]]; then
# Extract path from sqlite+aiosqlite:///./trumpsignal.db
DB_PATH="$(echo "$DATABASE_URL" | sed -E 's#^sqlite(\+[^:]+)?:///+##')"
if [[ ! -f "$DB_PATH" ]]; then
echo "[backup] SQLite file not found: $DB_PATH" >&2
exit 1
fi
OUT="$BACKUP_DIR/sqlite_${TS}.db"
# sqlite3 .backup is online-safe (uses backup API, not a raw copy).
# Fall back to cp if sqlite3 CLI isn't installed.
if command -v sqlite3 >/dev/null 2>&1; then
sqlite3 "$DB_PATH" ".backup '$OUT'"
else
cp "$DB_PATH" "$OUT"
fi
gzip -f "$OUT"
echo "[backup] wrote $OUT.gz ($(du -h "$OUT.gz" | cut -f1))"
elif [[ "$DATABASE_URL" == postgres* ]] || [[ "$DATABASE_URL" == postgresql* ]]; then
OUT="$BACKUP_DIR/pg_${TS}.sql.gz"
# pg_dump reads DATABASE_URL natively if we strip the driver prefix.
PG_URL="${DATABASE_URL/+asyncpg/}"
PG_URL="${PG_URL/+psycopg2/}"
pg_dump "$PG_URL" --no-owner --no-privileges | gzip > "$OUT"
echo "[backup] wrote $OUT ($(du -h "$OUT" | cut -f1))"
else
echo "[backup] unsupported DATABASE_URL scheme: $DATABASE_URL" >&2
exit 1
fi
# Prune backups older than RETAIN_DAYS days
find "$BACKUP_DIR" -name "*.gz" -mtime "+$RETAIN_DAYS" -print -delete
+239
View File
@@ -0,0 +1,239 @@
#!/usr/bin/env python3
"""
Pre-launch readiness check.
Run this RIGHT BEFORE flipping production traffic. Verifies that:
* Every required env var is set
* KEK / shared secrets have plausible entropy (not "change_me")
* DB is reachable + schema is at the latest Alembic head
* The Telegram bot token authenticates with the actual @username
* AI provider answers with a real model id
* No leftover open positions in bot_trades that the bot doesn't know about
Exits non-zero on any failure so you can wire it into CI / a deploy gate.
DATABASE_URL=... venv/bin/python scripts/preflight.py
"""
from __future__ import annotations
import asyncio
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import httpx
from sqlalchemy import text
from app.config import settings
from app.database import AsyncSessionLocal, engine
# Vars that MUST be non-empty for production. If you genuinely don't need one
# (e.g. running without Telegram), comment it out — don't push junk values.
REQUIRED = [
("database_url", "Where the API stores everything"),
("frontend_url", "Used as the single CORS origin"),
("encryption_key", "KEK for HL API key envelope encryption"),
("ingest_api_key", "Shared secret for /api/signals/ingest"),
]
# Required for the listed feature; non-fatal but logged as a warning.
OPTIONAL_BUT_RECOMMENDED = [
("ai_api_key", "Trump signal AI scoring (or anthropic_api_key)"),
("anthropic_api_key", "Required if ai_api_key empty AND you want KOL analysis"),
("telegram_bot_token", "Telegram push alerts (whole feature disabled if empty)"),
("telegram_bot_username","Required for the dashboard's Telegram connect deep link"),
("etherscan_api_key", "KOL on-chain (talks-vs-trades) — Ethereum side"),
# NOTE: glassnode_api_key is no longer required — the BTC bottom-reversal
# scanner switched to AHR999 + 200-week MA + Pi Cycle Bottom (all derived
# from public price candles, no Glassnode call). The setting is kept on
# config.Settings for future Glassnode-backed signals but isn't checked.
]
# These should NEVER appear unchanged in production.
SUSPECT_DEFAULTS = {
"change_me_in_production",
"your_key_here",
"CHANGE_ME",
"",
}
def red(s): return f"\033[31m{s}\033[0m"
def green(s): return f"\033[32m{s}\033[0m"
def yellow(s): return f"\033[33m{s}\033[0m"
async def check_env() -> list[str]:
errors = []
print("── env vars ──────────────────────────────────────")
for name, why in REQUIRED:
v = getattr(settings, name, "")
if not v or v in SUSPECT_DEFAULTS:
print(red(f"{name:25s} EMPTY or default — required: {why}"))
errors.append(f"{name} empty")
else:
shown = v if len(v) < 30 else v[:8] + "" + v[-4:]
print(green(f"{name:25s} = {shown}"))
print()
print("── recommended (warnings only) ────────────────────")
for name, why in OPTIONAL_BUT_RECOMMENDED:
v = getattr(settings, name, "")
if not v:
print(yellow(f" ! {name:25s} empty — {why}"))
else:
print(green(f"{name:25s} set"))
return errors
async def check_db() -> list[str]:
errors = []
print()
print("── database ──────────────────────────────────────")
try:
async with AsyncSessionLocal() as db:
await db.execute(text("SELECT 1"))
print(green(" ✓ DB reachable"))
except Exception as exc:
print(red(f" ✗ DB unreachable: {exc}"))
errors.append("db unreachable")
return errors
# Schema check — current head
try:
async with engine.begin() as conn:
r = await conn.execute(text("SELECT version_num FROM alembic_version"))
row = r.fetchone()
current = row[0] if row else None
# Read the latest version from alembic/versions/
versions_dir = Path(__file__).resolve().parent.parent / "alembic" / "versions"
head_files = sorted(p.stem for p in versions_dir.glob("*.py")
if p.stem != "__init__")
latest_prefix = max(int(f.split("_")[0]) for f in head_files
if f.split("_")[0].isdigit())
if current and current.startswith(f"{latest_prefix:03d}".rstrip("0") or "0"):
print(green(f" ✓ alembic at head {current}"))
elif current:
print(yellow(f" ! alembic at {current} but versions/ has up to {latest_prefix:03d}"))
print(yellow(f" → run: alembic upgrade head"))
else:
print(red(" ✗ alembic_version table empty — schema unmanaged"))
errors.append("schema unmanaged")
except Exception as exc:
print(yellow(f" ! schema version check failed: {exc}"))
# Orphan open positions
try:
async with AsyncSessionLocal() as db:
r = await db.execute(text(
"SELECT COUNT(*) FROM bot_trades WHERE closed_at IS NULL"
))
n = r.scalar() or 0
if n:
print(yellow(f" ! {n} open bot_trades rows — verify these match HL state before launch"))
else:
print(green(" ✓ no orphan open positions"))
except Exception as exc:
print(yellow(f" ! open-positions check failed: {exc}"))
return errors
async def check_telegram() -> list[str]:
errors = []
print()
print("── telegram ──────────────────────────────────────")
if not settings.telegram_bot_token:
print(yellow(" ! token empty — skipping"))
return errors
try:
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(
f"https://api.telegram.org/bot{settings.telegram_bot_token}/getMe"
)
if r.status_code != 200:
print(red(f" ✗ getMe HTTP {r.status_code}: {r.text[:120]}"))
errors.append("telegram auth failed")
return errors
data = r.json()
if not data.get("ok"):
print(red(f" ✗ getMe returned ok=false: {data}"))
errors.append("telegram auth failed")
return errors
bot_username = data["result"]["username"]
print(green(f" ✓ token authenticates as @{bot_username}"))
if settings.telegram_bot_username and bot_username != settings.telegram_bot_username:
print(red(f" ✗ TELEGRAM_BOT_USERNAME='{settings.telegram_bot_username}'"
f" but token is for @{bot_username}"))
errors.append("telegram username mismatch")
except Exception as exc:
print(red(f" ✗ telegram check failed: {exc}"))
errors.append("telegram unreachable")
return errors
async def check_ai() -> list[str]:
errors = []
print()
print("── ai provider ───────────────────────────────────")
if settings.anthropic_api_key:
# Cheap, free models endpoint check.
try:
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(
"https://api.anthropic.com/v1/models",
headers={
"x-api-key": settings.anthropic_api_key,
"anthropic-version": "2023-06-01",
},
)
if r.status_code == 200:
print(green(" ✓ anthropic_api_key authenticates"))
else:
print(red(f" ✗ anthropic /v1/models HTTP {r.status_code}: {r.text[:120]}"))
errors.append("anthropic auth failed")
except Exception as exc:
print(red(f" ✗ anthropic check failed: {exc}"))
elif settings.ai_api_key:
try:
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(
f"{settings.ai_base_url.rstrip('/')}/models",
headers={"Authorization": f"Bearer {settings.ai_api_key}"},
)
if r.status_code == 200:
print(green(f" ✓ ai_api_key authenticates at {settings.ai_base_url}"))
else:
print(red(f" ✗ /models HTTP {r.status_code}: {r.text[:120]}"))
errors.append("ai auth failed")
except Exception as exc:
print(red(f" ✗ ai check failed: {exc}"))
else:
print(yellow(" ! no ai provider key set — Trump signals will not be scored"))
return errors
async def main() -> int:
print(f"Preflight check — env: {settings.environment}\n")
if settings.environment != "production":
print(yellow("WARNING: settings.environment is not 'production'. Some dev-only"))
print(yellow(" endpoints (/api/dev/*) and auto-create_all() are enabled.\n"))
all_errors: list[str] = []
all_errors += await check_env()
all_errors += await check_db()
all_errors += await check_telegram()
all_errors += await check_ai()
await engine.dispose()
print()
if all_errors:
print(red(f"{len(all_errors)} fatal issue(s):"))
for e in all_errors:
print(red(f" - {e}"))
return 1
print(green("✅ All checks passed. Safe to launch."))
return 0
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""
Seed the kol_wallets table with publicly attributed on-chain addresses.
Why this exists:
The talks-vs-trades divergence module can only fire on KOLs whose wallets
we know. As of 2026-05-24 only 3 KOLs had wallets configured, which is
why we had ~3 divergence detections across the whole dataset.
Adding a new wallet here REQUIRES:
1. Public attribution Arkham label, the KOL's own X bio, a public
investigation by ZachXBT / Inspex / similar, or the KOL's ENS clearly
visible in transactions.
2. The `handle` field MUST exactly match a `handle` value in
app/services/kol_substack.py KOL_FEEDS otherwise the divergence
scanner cannot match "post by handle X" against "wallet activity by
handle X".
3. The `source_url` must link to that public attestation. Don't add
speculative addresses, even if "everyone knows" misattribution
damages both the KOL and our credibility.
Idempotent: runs UPSERT-style. Existing rows for (handle, address) are
preserved; only new ones are inserted.
Usage:
DATABASE_URL='sqlite+aiosqlite:///./trumpsignal.db' \\
venv/bin/python scripts/seed_kol_wallets.py
"""
from __future__ import annotations
import asyncio
import sys
from datetime import datetime, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from sqlalchemy import select
from app.database import AsyncSessionLocal
from app.models import KolWallet
# ────────────────────────────────────────────────────────────────────────────
# Verified seed list.
#
# Each entry MUST have a working source_url. If you can't link to a public
# attestation, DON'T add it.
#
# To find more candidates yourself:
# • https://platform.arkhamintelligence.com/ — search by handle, click
# "labels" tab. Labels prefixed with "Entity:" are Arkham-verified.
# • https://etherscan.io/labelcloud — Etherscan public label registry.
# • ZachXBT investigations on X — he posts the underlying tx evidence.
# • Many KOLs put their address in their X bio or pinned tweet.
#
# When the KOL's handle in KOL_FEEDS doesn't match the on-chain handle here,
# add it under the handle that's IN KOL_FEEDS — divergence join is by handle.
# ────────────────────────────────────────────────────────────────────────────
SEED_WALLETS: list[dict] = [
# ── Already in DB (kept here so the script is self-documenting) ──────
{
"handle": "cryptohayes",
"chain": "ethereum",
"address": "0xa86e3d1c80a750a310b484fb9bdc470753a7506f",
"label": "Arthur Hayes (main)",
"source_url": "https://etherscan.io/address/0xa86e3d1c80a750a310b484fb9bdc470753a7506f",
},
{
"handle": "cryptohayes",
"chain": "ethereum",
"address": "0x534a0076fb7c2b1f83fa21497429ad7ad3bd7587",
"label": "Arthur Hayes (secondary)",
"source_url": "https://etherscan.io/address/0x534a0076fb7c2b1f83fa21497429ad7ad3bd7587",
},
{
"handle": "andrewkang",
"chain": "ethereum",
"address": "0xff3879b8a363aed92a6eaba8f61f1a96a9ec3c1e",
"label": "Andrew Kang (beanwhale.eth)",
"source_url": "https://etherscan.io/address/0xff3879b8a363aed92a6eaba8f61f1a96a9ec3c1e",
},
{
"handle": "murad",
"chain": "ethereum",
"address": "0x93f019699ef400df7dc3477dbb6400ed9445a657",
"label": "Murad Mahmudov (via ZachXBT investigation)",
"source_url": "https://www.blocmates.com/news-posts/24million-in-memecoins-zachxbt-unveils-murad-mahmudov-s-alleged-wallets",
},
# ── Add new VERIFIED entries below this line ─────────────────────────
#
# Template — copy, replace fields, push only after verifying source_url:
#
# {
# "handle": "<matches handle in KOL_FEEDS>",
# "chain": "ethereum", # or "solana", "base", "arbitrum"
# "address": "0x...",
# "label": "<KOL display name (annotation)>",
# "source_url": "<public link proving attribution>",
# },
#
# Candidates to research (NOT seeded — verify before adding):
#
# • niccarter — Nic Carter has spoken openly about his wallet
# history on podcasts; check Coin Center filings.
# • pomp — Pompliano has a public BTC-only treasury; less
# useful for ETH-side divergence detection.
# • dragonfly — Dragonfly Capital portfolio wallets often
# labeled on Arkham as "Dragonfly Fund".
# • placeholder — Placeholder VC fund wallets per their public
# investment disclosures.
# • eugene — Eugene Ng Ah Sio — verify before adding.
#
# Also worth tracking even if not in KOL_FEEDS (would need to add the
# corresponding feed entry first or they'll never have post-side data):
#
# • justinsuntron — Tron founder, very active ETH trader, well-labeled.
# • cobie — Jordan Fish, address known via Arkham labels.
# • gcr / sam — anon traders, no reliably verifiable address.
]
async def main() -> int:
inserted = 0
skipped = 0
async with AsyncSessionLocal() as session:
for entry in SEED_WALLETS:
# Idempotency: skip if (handle, address) already present.
existing = await session.execute(
select(KolWallet).where(
KolWallet.handle == entry["handle"],
KolWallet.address == entry["address"].lower(),
)
)
if existing.scalar_one_or_none():
skipped += 1
continue
row = KolWallet(
handle=entry["handle"],
chain=entry["chain"],
address=entry["address"].lower(),
label=entry["label"],
source_url=entry["source_url"],
active=True,
added_at=datetime.now(timezone.utc).replace(tzinfo=None),
)
session.add(row)
inserted += 1
print(f" + {entry['handle']:18s} {entry['address']} ({entry['label']})")
await session.commit()
print()
print(f"Inserted {inserted} wallets, skipped {skipped} existing.")
print()
print("Next step: edit SEED_WALLETS above and re-run. Each new wallet")
print("MUST cite a public attestation in source_url — see the docstring.")
return 0
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))
+196
View File
@@ -0,0 +1,196 @@
"""
System-2 生命周期·小额真单端到端验证
验证我们新写的三个动钱路径在真实 Hyperliquid 上的行为并和账面记账对账
开仓 加仓(pyramid) 部分减仓(de-risk) 全平
每一步都把"预期""HL 实际"并排打印并用和 bot_engine 完全一致的
公式做 PnL 自洽校验
用法:
source venv/bin/activate
HL_API_PRIVATE_KEY="0x..." HL_ACCOUNT_ADDRESS="0x..." \
python scripts/verify_sys2_lifecycle.py # 干跑(不下单, 只打印计划)
... python scripts/verify_sys2_lifecycle.py --live # 真下单(小额, 需确认)
安全:
- 默认 DRY-RUN, 不下任何单
- --live 才真下单, 且会要求手动输入 YES 确认
- 名义金额默认 $20, 上限 $40 (超过需 --force), 杠杆默认 2x
- 任何异常 / 结束都会尝试把仓位平掉 (best-effort flatten)
- mainnet/testnet 跟随 settings.hl_mainnet
"""
import argparse
import asyncio
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.config import settings
from app.services.hyperliquid import HyperliquidTrader
from app.services.bot_engine import HL_TAKER_FEE_RATE
ASSET = "BTC"
SIDE = "long"
HARD_CAP_USD = 40.0
def _slice_pnl(notional: float, entry: float, exit_px: float, side: str) -> float:
"""Identical to bot_engine: notional × signed move round-trip taker."""
pct = (exit_px - entry) / entry if entry else 0.0
signed = pct if side == "long" else -pct
return notional * signed - notional * HL_TAKER_FEE_RATE * 2
def _pos(positions: list):
return next((p for p in positions if p.get("coin") == ASSET), None)
def _row(label, expected, actual):
print(f" {label:<28} expected={expected!s:<22} actual={actual!s}")
async def _flatten(trader, why: str):
try:
pos = _pos(await trader.get_open_positions())
if pos:
print(f"\n🧹 安全平仓 ({why}) — 当前 szi={pos['szi']}")
r = await trader.close_position(ASSET)
print(f" 平仓结果: {r}")
else:
print(f"\n🧹 无残留仓位 ({why})")
except Exception as exc:
print(f"\n⚠️ 安全平仓失败 ({why}): {exc} — 请手动检查 HL!")
async def main(live: bool, size_usd: float, leverage: int, force: bool):
api_key = os.getenv("HL_API_PRIVATE_KEY") or os.getenv("HL_API_KEY", "")
account = os.getenv("HL_ACCOUNT_ADDRESS", "")
if not api_key or not account:
print("❌ 需要 HL_API_PRIVATE_KEY 和 HL_ACCOUNT_ADDRESS 环境变量")
sys.exit(1)
if size_usd > HARD_CAP_USD and not force:
print(f"❌ size_usd ${size_usd} 超过安全上限 ${HARD_CAP_USD}(加 --force 才允许)")
sys.exit(1)
net = "MAINNET 真钱" if settings.hl_mainnet else "TESTNET"
add_usd = round(size_usd * 0.30, 2) # 模拟 pyramid 第1档 (+30% base)
derisk_frac_of_cur = 1.0 / 3.0 # 模拟 de-risk 第1档 (减当前 1/3)
print("=" * 64)
print(f"System-2 生命周期验证 · {net} · {ASSET} {SIDE} {leverage}x")
print(f"计划: 开 ${size_usd} → 加 ${add_usd} → 减当前 1/3 → 全平")
print(f"模式: {'🔴 LIVE 真下单' if live else '🟢 DRY-RUN 仅打印'}")
print("=" * 64)
if not live:
print("\n干跑结束。确认计划无误后加 --live 真跑(小额)。")
return
confirm = input(f"\n⚠️ 将在 {net} 下真单(约 ${size_usd})。输入大写 YES 继续: ")
if confirm.strip() != "YES":
print("已取消。")
return
trader = HyperliquidTrader(
api_private_key=api_key, account_address=account,
leverage=leverage, mainnet=settings.hl_mainnet,
)
bal = await trader.get_balance()
print(f"\n账户可用 USDC: ${bal:.2f}")
if bal < size_usd:
print("❌ 余额不足,放弃。")
return
if _pos(await trader.get_open_positions()):
print(f"❌ 已存在 {ASSET} 持仓 — 为避免干扰,请先手动清空后再跑。")
return
try:
# ── 1. 开仓 ──────────────────────────────────────────────────────
print("\n[1] 开仓 open_position")
o = await trader.open_position(ASSET, SIDE, size_usd)
entry = float(o["fill_price"])
base_coins = float(o["size_coins"])
base_notional = base_coins * entry
pos = _pos(await trader.get_open_positions())
_row("entry fill", "~mkt", entry)
_row("size_coins", f"~{size_usd/entry:.6f}", base_coins)
_row("HL szi", f"~{base_coins:.6f}", pos and pos["szi"])
assert pos and abs(abs(pos["szi"]) - base_coins) / base_coins < 0.05, "开仓后 HL 仓位不符"
# ── 2. 加仓 (pyramid 第1档) ──────────────────────────────────────
print("\n[2] 加仓 open_position(模拟 pyramid +30%")
a = await trader.open_position(ASSET, SIDE, add_usd)
add_fill = float(a["fill_price"])
add_coins = float(a["size_coins"])
actual_add_notional = add_coins * add_fill # ← 和修过的 pyramid_add 一致
# 混合均价:按名义加权(与 bot_engine.pyramid_add 完全相同的公式)
old_notional = base_notional
new_notional = old_notional + actual_add_notional
blended = (old_notional * entry + actual_add_notional * add_fill) / new_notional
pos = _pos(await trader.get_open_positions())
exp_coins = base_coins + add_coins
_row("add fill", "~mkt", add_fill)
_row("add size_coins", f"~{add_usd/add_fill:.6f}", add_coins)
_row("blended entry", f"{blended:.2f}", "(账面)")
_row("HL szi", f"~{exp_coins:.6f}", pos and pos["szi"])
assert pos and abs(abs(pos["szi"]) - exp_coins) / exp_coins < 0.05, "加仓后 HL 仓位不符"
# ── 3. 部分减仓 (de-risk 第1档:减当前 1/3) ──────────────────────
print("\n[3] 部分减仓 reduce_position(1/3)")
pre_coins = abs(pos["szi"])
r = await trader.reduce_position(ASSET, derisk_frac_of_cur)
cut_fill = float(r["fill_price"])
closed_frac = float(r["closed_fraction"])
pos = _pos(await trader.get_open_positions())
post_coins = abs(pos["szi"]) if pos else 0.0
actually_cut = pre_coins - post_coins
slice_notional = actually_cut * cut_fill
slice_pnl = _slice_pnl(slice_notional, blended, cut_fill, SIDE)
_row("reduce fill", "~mkt", cut_fill)
_row("closed_fraction", f"~{derisk_frac_of_cur:.3f}", round(closed_frac, 4))
_row("coins cut", f"~{pre_coins/3:.6f}", round(actually_cut, 6))
_row("剩余 szi", f"~{pre_coins*2/3:.6f}", pos and pos["szi"])
_row("该片已实现PnL($)", "", round(slice_pnl, 4))
assert 0.25 < closed_frac < 0.42, "减仓比例偏离 1/3 过大"
# ── 4. 全平 ──────────────────────────────────────────────────────
print("\n[4] 全平 close_position")
c = await trader.close_position(ASSET)
close_fill = float(c["fill_price"])
remaining_notional = post_coins * close_fill
remaining_pnl = _slice_pnl(remaining_notional, blended, close_fill, SIDE)
pos = _pos(await trader.get_open_positions())
_row("close fill", "~mkt", close_fill)
_row("收尾后持仓", "None", pos)
assert pos is None, "全平后仍有残留仓位!"
total_pnl = slice_pnl + remaining_pnl
print("\n" + "=" * 64)
print("对账小结(账面 vs 行为)")
print(f" 混合均价 : {blended:.2f}")
print(f" 分片PnL(减1/3) : {slice_pnl:+.4f} USD")
print(f" 收尾PnL(剩2/3) : {remaining_pnl:+.4f} USD")
print(f" 合计PnL : {total_pnl:+.4f} USD(应≈HL账户实际变动,含滑点/费)")
print(" ✅ 开/加/减/平 四步与 HL 实际持仓全部一致")
print("=" * 64)
print("\n说明: 这里用市价瞬时往返,PnL≈ -往返taker费(~0.09%)±滑点,属正常。")
except AssertionError as e:
print(f"\n❌ 校验失败: {e}")
except Exception as e:
print(f"\n❌ 异常: {e}")
finally:
await _flatten(trader, "收尾")
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--live", action="store_true", help="真下单(默认干跑)")
ap.add_argument("--size", type=float, default=20.0, help="开仓名义USD(默认20")
ap.add_argument("--leverage", type=int, default=2, help="杠杆(默认2x")
ap.add_argument("--force", action="store_true", help="允许 size 超过安全上限")
a = ap.parse_args()
asyncio.run(main(a.live, a.size, a.leverage, a.force))
+13
View File
@@ -0,0 +1,13 @@
import os
import sys
from pathlib import Path
BACKEND_ROOT = Path(__file__).resolve().parents[1]
if str(BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(BACKEND_ROOT))
# Tests import app.config at module import time. Provide a harmless default
# DB URL so collection doesn't depend on the caller's shell env.
os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///./test.db")
@@ -0,0 +1,425 @@
"""Tests for the current BTC bottom strategy:
- pure-price indicators (AHR999 / 200WMA / Pi Cycle) + 2-of-3 confluence
- leverage-aware dynamic System-2 leverage + protective stop
- staged stop-loss ladder math (no take-profit, ratchet-up only)
"""
import math
from app.services.bottom_indicators import (
ahr999, below_200wma, pi_cycle_bottom, bottom_confluence,
AHR999_BOTTOM,
)
from app.services.signal_categories import (
sys2_effective_leverage, sys2_protective_stop_pct,
sys2_approx_liquidation_pct, get_stop_ladder, sys2_derisk_ladder,
sys2_addon_ladder, sys2_peak_trail, get_exit_profile,
SYS2_DEFAULT_LEVERAGE, SYS2_MAX_STOP_PCT,
)
from app.services.bottom_indicators import trend_confirmed
# ── Indicators ──────────────────────────────────────────────────────────────
def test_ahr999_none_when_insufficient_history():
assert ahr999([100.0] * 199) is None
def test_ahr999_low_in_deep_decline():
# Long steady decline → price far below the geometric mean & growth model.
daily = [60000 * math.exp(-0.002 * i) for i in range(400)]
v = ahr999(daily)
assert v is not None and v < AHR999_BOTTOM
def test_below_200wma_uses_explicit_price_not_stale_weekly():
weekly = [100.0] * 199 + [130.0] # 200-wk mean ≈ 100.15, last close 130
# Using the stale weekly close → above band → False.
sig_stale, wma = below_200wma(weekly)
assert sig_stale is False and wma is not None
# Passing the fresh (lower) daily price → below band → True.
sig_fresh, _ = below_200wma(weekly, price=95.0)
assert sig_fresh is True
def test_pi_cycle_bottom_none_when_short():
assert pi_cycle_bottom([1.0] * 470)[0] is False
def test_confluence_fires_only_with_two_of_three():
# Deep decline: AHR999 deep-value + 200WMA both true → ≥2 → fired.
daily = [60000 * math.exp(-0.002 * i) for i in range(520)]
weekly = [60000 * math.exp(-0.01 * i) for i in range(210)]
c = bottom_confluence(daily, weekly)
assert c.votes >= 2 and c.fired is True
# Strong bull: AHR999 expensive (>0.45) and Pi not in bottom region →
# at most 1 vote → confluence requires ≥2 so it must NOT fire.
up_d = [20000 * math.exp(0.004 * i) for i in range(520)]
up_w = [20000 * math.exp(0.02 * i) for i in range(210)]
c2 = bottom_confluence(up_d, up_w)
assert c2.detail["signals"]["ahr999_value"] is False
assert c2.detail["signals"]["pi_cycle_bottom"] is False
assert c2.votes < 2 and c2.fired is False
def test_confluence_b_uses_latest_daily_price():
# weekly close stale-high but the latest daily close is at the lows.
daily = [60000 * math.exp(-0.002 * i) for i in range(520)]
weekly = [30000.0] * 209 + [90000.0] # last weekly close artificially high
c = bottom_confluence(daily, weekly)
# B must compare the 200wma against the latest DAILY close, not 90000.
assert c.detail["price"] == round(daily[-1], 2)
# ── Dynamic System-2 leverage ────────────────────────────────────────────────
def test_effective_leverage_clamps_and_defaults():
assert sys2_effective_leverage(None) == SYS2_DEFAULT_LEVERAGE
assert sys2_effective_leverage(0) == 1
assert sys2_effective_leverage(99) == 10
assert sys2_effective_leverage(5) == 5
assert sys2_effective_leverage("bad") == SYS2_DEFAULT_LEVERAGE
def test_protective_stop_always_inside_liquidation():
# The protective full-exit must trigger BEFORE the exchange liquidates,
# for every allowed leverage.
for lev in range(1, 11):
stop = sys2_protective_stop_pct(lev)
liq = sys2_approx_liquidation_pct(lev)
assert stop < liq, f"lev {lev}: stop {stop} not inside liq {liq}"
# Low leverage keeps the full bottom-wick tolerance.
assert sys2_protective_stop_pct(1) == SYS2_MAX_STOP_PCT
assert sys2_protective_stop_pct(2) == SYS2_MAX_STOP_PCT
# High leverage tightens automatically.
assert sys2_protective_stop_pct(5) < 20
assert sys2_protective_stop_pct(10) < 10
def test_stop_ladder_has_no_negative_base_rung_and_only_ratchets_up():
ladder = get_stop_ladder("btc_bottom_reversal_long")
assert ladder is not None
triggers = [t for t, _ in ladder]
floors = [f for _, f in ladder]
# Sorted ascending by trigger.
assert triggers == sorted(triggers)
# Floors strictly increase (pure ratchet-up: never loosens).
assert floors == sorted(floors)
# No 0%-trigger catastrophic rung — that floor is leverage-derived now.
assert triggers[0] > 0
# Reaches locked-in profit (a positive floor exists).
assert max(floors) > 0
def test_non_bottom_category_has_no_ladder():
assert get_stop_ladder("sma_reclaim") is None
assert get_stop_ladder(None) is None
# ── Staged-stop monitor math (mirror of tp_sl_monitor branch 0) ──────────────
def _eff_stop(peak: float, base_stop_pct: float, ladder) -> float:
eff = -base_stop_pct
for trig, floor in ladder:
if peak >= trig and floor > eff:
eff = floor
return eff
def test_staged_stop_no_take_profit_and_locks_profit():
ladder = get_stop_ladder("btc_bottom_reversal_long")
base = sys2_protective_stop_pct(2) # 35%
# Never exits for a big unrealised gain (no take-profit).
assert 300.0 > _eff_stop(peak=300.0, base_stop_pct=base, ladder=ladder)
# Catastrophic floor before any rung: -35% at 2x.
assert _eff_stop(peak=0.0, base_stop_pct=base, ladder=ladder) == -35.0
# After +70% peak the floor has ratcheted to a locked-in profit.
locked = _eff_stop(peak=75.0, base_stop_pct=base, ladder=ladder)
assert locked > 0
# High leverage → tighter base floor, ladder still ratchets identically.
base10 = sys2_protective_stop_pct(10)
assert _eff_stop(peak=0.0, base_stop_pct=base10, ladder=ladder) == -base10
assert _eff_stop(peak=75.0, base_stop_pct=base10, ladder=ladder) == locked
# ── Staged de-risk ladder (分段式减仓) ───────────────────────────────────────
def test_derisk_ladder_shape_and_safety():
for lev in (1, 2, 3, 5, 10):
p = sys2_protective_stop_pct(lev)
liq = sys2_approx_liquidation_pct(lev)
ladder = sys2_derisk_ladder(lev)
assert len(ladder) == 3
thrs = [t for t, _, _ in ladder]
fracs = [f for _, f, _ in ladder]
finals = [fin for _, _, fin in ladder]
# All thresholds negative, increasing in adversity.
assert all(t < 0 for t in thrs)
assert thrs == sorted(thrs, reverse=True) # -21, -28, -35 …
# Exactly the last rung is the full close.
assert finals == [False, False, True]
# Fractions sum to the whole position (thirds of original).
assert abs(sum(fracs) - 1.0) < 1e-9
# Final rung == the protective level == inside liquidation.
assert abs(thrs[-1] - (-p)) < 1e-6
assert -thrs[-1] < liq, f"lev {lev}: final {thrs[-1]} not inside liq {liq}"
def test_derisk_pnl_accounting_matches_single_close():
"""Summing the staged slices + the remaining close must equal a single
full close at the same final price (the staged path must not create or
destroy PnL vs closing all at once)."""
notional = 1000.0
entry = 100.0
final_price = 70.0 # -30% (long)
def slice_pnl(frac, px):
return notional * frac * ((px - entry) / entry)
# Staged: 1/3 closed at 85, 1/3 at 78, final 1/3 at 70.
staged = (
slice_pnl(1/3, 85.0) +
slice_pnl(1/3, 78.0) +
slice_pnl(1/3, 70.0)
)
# If instead all three thirds were closed at the final price:
single = slice_pnl(1.0, final_price)
# Staged exits EARLIER on the way down, so it must lose LESS than a
# single close at the worst price (the whole point of de-risking).
assert staged > single
# And closing all at entry-price slices would be zero — sanity.
assert abs(slice_pnl(1/3, entry) * 3) < 1e-9
def test_derisk_regime_switch_underwater_vs_profit():
"""Mirror the monitor's regime decision: underwater → de-risk ladder,
in-profit (peak first upside rung) ratchet stop."""
stop_ladder = get_stop_ladder("btc_bottom_reversal_long")
first_up = min(t for t, _ in stop_ladder) # 20
def regime(peak):
return "profit" if peak >= first_up else "derisk"
assert regime(0.0) == "derisk"
assert regime(19.9) == "derisk"
assert regime(20.0) == "profit"
assert regime(150.0) == "profit"
# ── Pyramiding (做对了往上加仓) ──────────────────────────────────────────────
def test_addon_ladder_shape_conservative():
ladder = sys2_addon_ladder()
trigs = [t for t, _, _ in ladder]
fracs = [f for _, f, _ in ladder]
lasts = [x for _, _, x in ladder]
assert trigs == sorted(trigs) and all(t > 0 for t in trigs) # peak-gain rungs
assert fracs[:3] == [0.30, 0.20, 0.10] # conservative base
assert sum(fracs) <= 0.80 # still modest total
assert lasts[-1] is True and lasts.count(True) == 1 # exactly one final
def test_trend_confirmed_requires_sma_and_new_high():
# Uptrend: price above 200d SMA and at a fresh 20d high → confirmed.
up = [100.0 + i for i in range(260)]
highs = [c + 1 for c in up]
price = up[-1] + 1
assert trend_confirmed(up, highs, price) is True
# Below the 200d SMA → not confirmed even at a local high.
assert trend_confirmed(up, highs, price=50.0) is False
# Above SMA but well below the recent high (still chopping) → not confirmed.
mid = sum(up[-200:]) / 200
assert trend_confirmed(up, highs, price=mid + 1) is False
# Too little history → fail closed.
assert trend_confirmed([1.0] * 50, [1.0] * 50, 1.0) is False
def test_pyramiding_blended_entry_math():
"""Blended entry after an add must be the notional-weighted average, and
it must sit BELOW the add price for a long that added higher (so the
aggregate is still in profit)."""
base = 1000.0
entry = 100.0
add_usd = base * 0.30
fill = 130.0 # added after +30%
new_notional = base + add_usd
blended = (base * entry + add_usd * fill) / new_notional
assert entry < blended < fill # weighted average
# Aggregate still profitable at the add price.
assert (fill - blended) / blended > 0
# Conservative sizing only modestly lifts the average (< 7% here).
assert (blended - entry) / entry < 0.07
def test_recovery_restores_peak_keeps_profit_regime():
"""A pyramided / in-profit trade rehydrated after a restart must seed the
monitor's peak from the persisted value so it stays in the profit regime
(not fall back to the underwater de-risk regime)."""
from app.services.tp_sl_monitor import register_trade, _watched, unregister
from app.services.signal_categories import (
sys2_derisk_ladder, sys2_addon_ladder, get_stop_ladder,
)
tid = 990011
try:
register_trade(
trade_id=tid, wallet="0xabc", api_key="k", leverage=2,
asset="BTC", side="long", entry_price=100.0,
take_profit_pct=None, stop_loss_pct=35.0,
stop_ladder=get_stop_ladder("btc_bottom_reversal_long"),
derisk_ladder=sys2_derisk_ladder(2),
addon_ladder=sys2_addon_ladder(), addon_done=1,
initial_peak=90.0,
)
wt = _watched[tid]
assert wt.peak_gain_pct == 90.0
assert wt.peak_persisted == 90.0
first_up = min(t for t, _ in wt.stop_ladder)
# peak 90 ≥ first upside rung → profit regime, NOT underwater de-risk.
assert wt.peak_gain_pct >= first_up
finally:
unregister(tid)
def test_addon_ladder_extended_for_cycle_bull():
ladder = sys2_addon_ladder()
trigs = [t for t, _, _ in ladder]
fracs = [f for _, f, _ in ladder]
lasts = [x for _, _, x in ladder]
assert len(ladder) == 5 # deeper continuation rungs
assert trigs == sorted(trigs) and trigs[-1] >= 200
assert lasts == [False, False, False, False, True]
assert abs(sum(fracs) - 0.75) < 1e-9 # ≤ +0.75× base, still modest
def test_btc_bottom_maxhold_is_18_months():
p = get_exit_profile("btc_bottom_reversal_long")
assert p.max_hold_hours == 12960 # 540 days ≈ 18 months
def _peak_trail_floor(peak_pp: float):
start, dd = sys2_peak_trail()
if peak_pp < start:
return None
return ((1.0 + peak_pp / 100.0) * (1.0 - dd) - 1.0) * 100.0
def test_peak_trail_scale_invariant_and_never_loosens():
start, dd = sys2_peak_trail()
# Inactive below the start threshold.
assert _peak_trail_floor(start - 1) is None
# Self-scales: a +500% move locks far above the old fixed +95% top rung.
f500 = _peak_trail_floor(500.0)
assert f500 is not None and f500 > 300.0 # ≈ +320%
# A +900% move scales further still (not capped).
assert _peak_trail_floor(900.0) > f500
# Survives a normal bull pullback: at peak +120% the floor is a ≤30%
# PRICE drawdown from the peak, i.e. price 2.2→1.54 (gain +54%), so a
# typical 2025% dip (still >+54%) does NOT trip it.
f120 = _peak_trail_floor(120.0)
assert 50.0 < f120 < 60.0 # ((2.2*0.7)-1)*100 = 54
# Combined with the fixed rungs the floor only ratchets UP (max of both).
rung_at_160 = 95.0
assert max(rung_at_160, _peak_trail_floor(160.0)) == rung_at_160
assert max(95.0, _peak_trail_floor(400.0)) == _peak_trail_floor(400.0)
def test_sys2_risk_mode_params():
from app.services.signal_categories import (
sys2_normalize_mode, sys2_addon_ladder, sys2_derisk_ladder,
sys2_peak_trail,
)
assert sys2_normalize_mode("AGGRESSIVE") == "aggressive"
assert sys2_normalize_mode("garbage") == "standard"
assert sys2_normalize_mode(None) == "standard"
# Leverage default depends on mode; explicit value still clamped.
assert sys2_effective_leverage(None, "standard") == 2
assert sys2_effective_leverage(None, "aggressive") == 8
assert sys2_effective_leverage(99, "aggressive") == 10
# Aggressive pyramiding: earlier + heavier, ≤ +1.5× base, one final.
ag = sys2_addon_ladder("aggressive")
st = sys2_addon_ladder("standard")
assert [t for t, _, _ in ag] == [15.0, 35.0, 60.0, 100.0, 160.0]
assert abs(sum(f for _, f, _ in ag) - 1.50) < 1e-9
assert [x for _, _, x in ag][-1] is True
assert ag != st # genuinely different
assert abs(sum(f for _, f, _ in st) - 0.75) < 1e-9 # standard unchanged
# Aggressive de-risk keeps a bigger runner (¼/¼/½) but final still FULL.
agd = sys2_derisk_ladder(8, "aggressive")
assert [round(f, 4) for _, f, _ in agd] == [0.25, 0.25, 0.5]
assert agd[-1][2] is True # final = full close
std_d = sys2_derisk_ladder(8, "standard")
assert abs(std_d[0][1] - 1.0 / 3.0) < 1e-9 # standard unchanged
assert std_d[-1][2] is True
# Aggressive peak-trail: earlier start, wider give-back.
assert sys2_peak_trail("aggressive") == (60.0, 0.42)
assert sys2_peak_trail("standard") == (80.0, 0.30)
assert sys2_peak_trail() == (80.0, 0.30) # default = standard
# Safety invariant holds in BOTH modes: final de-risk rung is the
# protective level (inside liquidation) and is a full close.
for m in ("standard", "aggressive"):
for lev in (2, 5, 8, 10):
lad = sys2_derisk_ladder(lev, m)
assert lad[-1][2] is True
assert -lad[-1][0] < sys2_approx_liquidation_pct(lev)
def test_register_trade_default_peak_is_zero():
from app.services.tp_sl_monitor import register_trade, _watched, unregister
tid = 990012
try:
register_trade(
trade_id=tid, wallet="0xabc", api_key="k", leverage=3,
asset="BTC", side="long", entry_price=100.0,
take_profit_pct=None, stop_loss_pct=6.0,
)
assert _watched[tid].peak_gain_pct == 0.0
finally:
unregister(tid)
def test_register_trade_grow_mode_default_off_and_settable():
"""Per-trade Grow defaults OFF (pyramiding opt-in) and is settable."""
from app.services.tp_sl_monitor import register_trade, _watched, unregister
a, b = 990021, 990022
try:
register_trade(
trade_id=a, wallet="0xabc", api_key="k", leverage=3,
asset="BTC", side="long", entry_price=100.0,
take_profit_pct=None, stop_loss_pct=6.0,
)
assert _watched[a].grow_mode is False # default OFF
register_trade(
trade_id=b, wallet="0xabc", api_key="k", leverage=2,
asset="BTC", side="long", entry_price=100.0,
take_profit_pct=None, stop_loss_pct=35.0,
grow_mode=True,
)
assert _watched[b].grow_mode is True
finally:
unregister(a); unregister(b)
def test_auto_trade_gate_skips_when_off():
"""The master gate: process_post must NOT open a trade when the sub has
auto_trade falsy (the signal Post is still created upstream by ingest)."""
import inspect
from app.services import bot_engine
# The per-subscriber executor holds the gate: keyed off auto_trade,
# returns (no trade opened) when off.
gate = inspect.getsource(bot_engine._execute_for_subscriber)
assert 'sub.get("auto_trade")' in gate
assert "Auto-Trade OFF" in gate
assert "return" in gate.split('sub.get("auto_trade")')[1][:260]
# process_post builds the snapshot carrying auto_trade so the gate reads it.
pp = inspect.getsource(bot_engine.process_post)
assert "auto_trade=bool(s.auto_trade)" in pp
+95
View File
@@ -0,0 +1,95 @@
from app.services.scanners.funding_reversal import evaluate_funding_reversal
from app.services.scanners.sma_reclaim import evaluate_sma_reclaim
from app.services.signal_categories import (
is_system_2,
is_supported_trading_source,
system2_display_name,
system2_min_confidence,
system2_size_multiplier,
)
from app.services.bot_engine import _confidence_floor_for, _should_apply_schedule
from app.services.tp_sl_monitor import register_trade, _watched
def _daily(close: float, volume: float = 100.0) -> dict:
return {"close": close, "volume": volume}
def test_sma_reclaim_no_longer_emits_short_breakdowns():
candles = [_daily(110.0) for _ in range(200)]
candles.extend(_daily(110.0) for _ in range(31))
candles.append(_daily(90.0, volume=200.0))
is_signal, debug = evaluate_sma_reclaim(candles)
assert is_signal is False
assert debug["reason"] == "shorts_disabled"
def test_funding_reversal_reports_boost_without_standalone_signal():
funding = [
{"time_ms": i * 3_600_000, "rate": -0.00005}
for i in range(24 * 29)
]
funding.extend(
{"time_ms": (24 * 29 + i) * 3_600_000, "rate": -0.00001}
for i in range(24)
)
candles = [_daily(100.0) for _ in range(8)]
candles[-1] = _daily(104.0)
is_signal, debug = evaluate_funding_reversal(funding, candles)
assert is_signal is True
assert debug["direction"] == "buy"
def test_bottom_reversal_source_routes_to_system_2():
assert is_system_2("btc_bottom_reversal")
def test_old_scanner_sources_no_longer_route_to_module_2():
for source in ("rsi_reversal", "sma_reclaim", "funding_reversal", "breakout", "vcp_breakout"):
assert not is_system_2(source)
def test_unknown_external_sources_are_not_supported_for_trading():
assert is_supported_trading_source("truth")
assert is_supported_trading_source("btc_bottom_reversal")
assert not is_supported_trading_source("manual")
assert not is_supported_trading_source("sma_reclaim")
def test_module_2_uses_own_confidence_floor_and_bypasses_schedule():
sys2_sub = {"_is_system_2": True, "min_confidence": 95}
sys1_sub = {"_is_system_2": False, "min_confidence": 95}
assert system2_display_name() == "Bitcoin Bottom"
assert _confidence_floor_for(sys2_sub) == system2_min_confidence()
assert _confidence_floor_for(sys1_sub) == 95
assert _should_apply_schedule(sys2_sub) is False
assert _should_apply_schedule(sys1_sub) is True
def test_register_trade_keeps_invalidation_price():
register_trade(
trade_id=99991,
wallet="0xabc",
api_key="key",
leverage=3,
asset="BTC",
side="long",
entry_price=100.0,
take_profit_pct=None,
stop_loss_pct=6.0,
trailing_stop_pct=5.0,
trailing_activate_at_pct=12.0,
invalidation="below_entry",
invalidation_price=92.5,
min_hold_until_ts=None,
)
try:
wt = _watched[99991]
assert wt.invalidation_price == 92.5
finally:
_watched.pop(99991, None)
+110
View File
@@ -0,0 +1,110 @@
import pytest
from app.config import Settings
from app.api import positions
class _Scalar:
def __init__(self, value):
self._value = value
def scalar_one_or_none(self):
return self._value
def scalar_one(self):
return self._value
def scalars(self):
return self
def all(self):
return self._value
class _Request:
async def json(self):
return {
"wallet": "0xabc",
"timestamp": 123,
"signature": "0xsig",
}
class _Trade:
id = 7
wallet_address = "0xabc"
closed_at = None
hl_order_id = "paper"
leverage = 3
asset = "BTC"
exit_price = 101.5
pnl_usd = 2.25
class _Sub:
leverage = 3
hl_api_key = None
class _Db:
def __init__(self, responses):
self._responses = list(responses)
async def execute(self, _stmt):
return _Scalar(self._responses.pop(0))
@pytest.mark.asyncio
async def test_manual_close_returns_close_result(monkeypatch):
async def fake_close_and_finalize(**_kwargs):
return None
monkeypatch.setattr(positions, "verify_signed_request", lambda **_kwargs: None)
from app.services import bot_engine
monkeypatch.setattr(bot_engine, "close_and_finalize", fake_close_and_finalize)
db = _Db([_Trade(), _Sub(), _Trade()])
result = await positions.manual_close(7, _Request(), db)
assert result.status == "ok"
assert result.trade_id == 7
assert result.exit_price == 101.5
assert result.pnl_usd == 2.25
assert result.reason == "manual"
@pytest.mark.asyncio
async def test_open_positions_requires_signed_wallet_read(monkeypatch):
calls = []
def fake_verify(**kwargs):
calls.append(kwargs)
monkeypatch.setattr(positions, "verify_signed_request", fake_verify)
db = _Db([[]])
result = await positions.get_open_positions(
wallet="0xABC",
ts=123,
sig="0xsig",
db=db,
)
assert result.wallet == "0xabc"
assert calls == [{
"action": positions.ACTION_VIEW_POSITIONS,
"wallet": "0xabc",
"timestamp_ms": 123,
"signature": "0xsig",
"body": None,
"allow_replay": True,
}]
def test_settings_default_to_production_when_environment_not_explicit():
settings = Settings(database_url="sqlite+aiosqlite:///./test.db")
assert settings.environment == "production"
+83
View File
@@ -0,0 +1,83 @@
import pytest
from fastapi import HTTPException
from app.api import user
def _make_sub():
class Sub:
wallet_address = "0xabc"
hl_api_key = None
return Sub()
class _Scalar:
def __init__(self, value):
self._value = value
def scalar_one_or_none(self):
return self._value
class _Db:
def __init__(self, sub):
self.sub = sub
self.committed = False
async def execute(self, _stmt):
return _Scalar(self.sub)
async def commit(self):
self.committed = True
class _Body:
wallet = "0xabc"
timestamp = 123
signature = "0xsig"
api_key = "0x" + "1" * 64
@pytest.mark.asyncio
async def test_set_hl_api_key_saves_only_after_hyperliquid_verification(monkeypatch):
sub = _make_sub()
db = _Db(sub)
monkeypatch.setattr(user, "verify_signed_request", lambda **_kwargs: None)
monkeypatch.setattr(user, "encrypt_api_key", lambda value: f"encrypted:{value[-6:]}")
calls = []
async def verify_key(**kwargs):
calls.append(kwargs)
monkeypatch.setattr(user, "verify_hl_api_key_can_trade", verify_key)
result = await user.set_hl_api_key("0xabc", _Body(), db)
assert db.committed is True
assert sub.hl_api_key == "encrypted:111111"
assert result.status == "ok"
assert result.verified is True
assert calls == [{"api_key": _Body.api_key, "account_address": "0xabc"}]
@pytest.mark.asyncio
async def test_set_hl_api_key_does_not_save_when_hyperliquid_verification_fails(monkeypatch):
sub = _make_sub()
db = _Db(sub)
monkeypatch.setattr(user, "verify_signed_request", lambda **_kwargs: None)
async def fail_verify(**_kwargs):
raise HTTPException(422, "Hyperliquid rejected this API key")
monkeypatch.setattr(user, "verify_hl_api_key_can_trade", fail_verify)
with pytest.raises(HTTPException) as exc:
await user.set_hl_api_key("0xabc", _Body(), db)
assert exc.value.status_code == 422
assert db.committed is False
assert sub.hl_api_key is None
BIN
View File
Binary file not shown.