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:
+53
-10
@@ -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
@@ -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")
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Phase 1 safety fields: paper mode + circuit breaker."""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "008"
|
||||||
|
down_revision: Union[str, None] = "007"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("subscriptions") as batch_op:
|
||||||
|
# Paper mode: trades are simulated, no Hyperliquid call.
|
||||||
|
batch_op.add_column(sa.Column("paper_mode", sa.Boolean(), nullable=False, server_default="0"))
|
||||||
|
# Circuit breaker state.
|
||||||
|
batch_op.add_column(sa.Column("circuit_breaker_tripped_at", sa.DateTime(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column("circuit_breaker_reason", sa.String(32), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("subscriptions") as batch_op:
|
||||||
|
batch_op.drop_column("circuit_breaker_reason")
|
||||||
|
batch_op.drop_column("circuit_breaker_tripped_at")
|
||||||
|
batch_op.drop_column("paper_mode")
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Two-system separation + frozen per-trade exit profile.
|
||||||
|
|
||||||
|
subscriptions:
|
||||||
|
- sys2_budget_pct : daily-budget slice for System 2 (default 0.7)
|
||||||
|
- sys2_cb_tripped_at/reason : independent System-2 circuit breaker
|
||||||
|
|
||||||
|
bot_trades:
|
||||||
|
- eff_* : the exit profile FROZEN at open. Recovery rehydrates from these
|
||||||
|
instead of the (mutable) Subscription, so a 90-day reversal
|
||||||
|
isn't silently rewritten to the user's Trump stop on restart.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "009"
|
||||||
|
down_revision: Union[str, None] = "008"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("subscriptions") as b:
|
||||||
|
b.add_column(sa.Column("sys2_budget_pct", sa.Float(), nullable=False, server_default="0.7"))
|
||||||
|
b.add_column(sa.Column("sys2_cb_tripped_at", sa.DateTime(), nullable=True))
|
||||||
|
b.add_column(sa.Column("sys2_cb_reason", sa.String(32), nullable=True))
|
||||||
|
with op.batch_alter_table("bot_trades") as b:
|
||||||
|
b.add_column(sa.Column("eff_take_profit_pct", sa.Float(), nullable=True))
|
||||||
|
b.add_column(sa.Column("eff_stop_loss_pct", sa.Float(), nullable=True))
|
||||||
|
b.add_column(sa.Column("eff_trailing_stop_pct", sa.Float(), nullable=True))
|
||||||
|
b.add_column(sa.Column("eff_trailing_activate_pct", sa.Float(), nullable=True))
|
||||||
|
b.add_column(sa.Column("eff_max_hold_hours", sa.Integer(), nullable=True))
|
||||||
|
b.add_column(sa.Column("eff_invalidation", sa.String(24), nullable=True))
|
||||||
|
b.add_column(sa.Column("eff_invalidation_price", sa.Float(), nullable=True))
|
||||||
|
b.add_column(sa.Column("eff_min_hold_until_ts", sa.Float(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("bot_trades") as b:
|
||||||
|
for c in ("eff_min_hold_until_ts", "eff_invalidation_price", "eff_invalidation", "eff_max_hold_hours",
|
||||||
|
"eff_trailing_activate_pct", "eff_trailing_stop_pct",
|
||||||
|
"eff_stop_loss_pct", "eff_take_profit_pct"):
|
||||||
|
b.drop_column(c)
|
||||||
|
with op.batch_alter_table("subscriptions") as b:
|
||||||
|
for c in ("sys2_cb_reason", "sys2_cb_tripped_at", "sys2_budget_pct"):
|
||||||
|
b.drop_column(c)
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Add invalidation_price fields for BTC bottom-reversal trades.
|
||||||
|
|
||||||
|
Revision ID: 010
|
||||||
|
Revises: 009
|
||||||
|
Create Date: 2026-05-16 00:00:00.000000
|
||||||
|
|
||||||
|
NOTE: `bot_trades.eff_invalidation_price` is ALSO created by migration 009
|
||||||
|
(two_system_and_frozen_exits). The original 010 added it unconditionally,
|
||||||
|
which made `alembic upgrade head` on a FRESH database fail at 010 with
|
||||||
|
"duplicate column name: eff_invalidation_price" (009 already added it).
|
||||||
|
|
||||||
|
This revision is now IDEMPOTENT: each column is added only if it is not
|
||||||
|
already present, so it applies cleanly on both fresh DBs (009 already made
|
||||||
|
eff_invalidation_price → skip; posts.invalidation_price is new → add) and
|
||||||
|
DBs that pre-date 009's column. The genuinely-new column introduced by 010
|
||||||
|
is `posts.invalidation_price`; `eff_invalidation_price` is owned by 009 and
|
||||||
|
left to 009's downgrade.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
revision: str = "010"
|
||||||
|
down_revision: Union[str, None] = "009"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _columns(table: str) -> set:
|
||||||
|
return {c["name"] for c in inspect(op.get_bind()).get_columns(table)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if "invalidation_price" not in _columns("posts"):
|
||||||
|
with op.batch_alter_table("posts") as b:
|
||||||
|
b.add_column(sa.Column("invalidation_price", sa.Float(), nullable=True))
|
||||||
|
# eff_invalidation_price may already exist (created by 009). Only add it
|
||||||
|
# if some older DB reached 010 without it.
|
||||||
|
if "eff_invalidation_price" not in _columns("bot_trades"):
|
||||||
|
with op.batch_alter_table("bot_trades") as b:
|
||||||
|
b.add_column(sa.Column("eff_invalidation_price", sa.Float(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Only undo 010's genuine addition. eff_invalidation_price is owned by
|
||||||
|
# 009 — dropping it here would double-drop in `downgrade base`.
|
||||||
|
if "invalidation_price" in _columns("posts"):
|
||||||
|
with op.batch_alter_table("posts") as b:
|
||||||
|
b.drop_column("invalidation_price")
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Add System-2 dynamic leverage.
|
||||||
|
|
||||||
|
subscriptions.sys2_leverage: per-user leverage for System-2 (bottom reversal),
|
||||||
|
independent of the Trump `leverage` field. NULL → platform default.
|
||||||
|
|
||||||
|
Revision ID: 011
|
||||||
|
Revises: 010
|
||||||
|
Create Date: 2026-05-18 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "011"
|
||||||
|
down_revision: Union[str, None] = "010"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("subscriptions") as b:
|
||||||
|
b.add_column(sa.Column("sys2_leverage", sa.Integer(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("subscriptions") as b:
|
||||||
|
b.drop_column("sys2_leverage")
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Add System-2 staged de-risk (分段式减仓) bookkeeping to bot_trades.
|
||||||
|
|
||||||
|
realized_partial_pnl_usd : cumulative PnL from partial reduces (default 0)
|
||||||
|
remaining_fraction : fraction of original notional still open (default 1)
|
||||||
|
derisk_steps_done : number of partial de-risk steps executed (default 0)
|
||||||
|
|
||||||
|
Defaults make every existing row behave exactly as before (no partials).
|
||||||
|
|
||||||
|
Revision ID: 012
|
||||||
|
Revises: 011
|
||||||
|
Create Date: 2026-05-18 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "012"
|
||||||
|
down_revision: Union[str, None] = "011"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("bot_trades") as b:
|
||||||
|
b.add_column(sa.Column("realized_partial_pnl_usd", sa.Float(),
|
||||||
|
nullable=False, server_default="0"))
|
||||||
|
b.add_column(sa.Column("remaining_fraction", sa.Float(),
|
||||||
|
nullable=False, server_default="1"))
|
||||||
|
b.add_column(sa.Column("derisk_steps_done", sa.Integer(),
|
||||||
|
nullable=False, server_default="0"))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("bot_trades") as b:
|
||||||
|
b.drop_column("derisk_steps_done")
|
||||||
|
b.drop_column("remaining_fraction")
|
||||||
|
b.drop_column("realized_partial_pnl_usd")
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Add System-2 pyramiding (做对了往上加仓) bookkeeping to bot_trades.
|
||||||
|
|
||||||
|
base_size_usd : original notional at open, immutable (NULL → legacy,
|
||||||
|
falls back to size_usd)
|
||||||
|
addon_steps_done : number of pyramid add-ons executed (default 0)
|
||||||
|
|
||||||
|
Defaults make every existing row behave exactly as before (no add-ons).
|
||||||
|
|
||||||
|
Revision ID: 013
|
||||||
|
Revises: 012
|
||||||
|
Create Date: 2026-05-18 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "013"
|
||||||
|
down_revision: Union[str, None] = "012"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("bot_trades") as b:
|
||||||
|
b.add_column(sa.Column("base_size_usd", sa.Float(), nullable=True))
|
||||||
|
b.add_column(sa.Column("addon_steps_done", sa.Integer(),
|
||||||
|
nullable=False, server_default="0"))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("bot_trades") as b:
|
||||||
|
b.drop_column("addon_steps_done")
|
||||||
|
b.drop_column("base_size_usd")
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Persist tp_sl_monitor peak gain so a restart doesn't reset the regime.
|
||||||
|
|
||||||
|
bot_trades.peak_gain_pct : monotonic peak unrealised gain % (default 0)
|
||||||
|
|
||||||
|
Without this, a pyramided / in-profit System-2 trade rehydrates with peak=0
|
||||||
|
on restart → wrongly drops into the underwater de-risk regime.
|
||||||
|
|
||||||
|
Revision ID: 014
|
||||||
|
Revises: 013
|
||||||
|
Create Date: 2026-05-18 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "014"
|
||||||
|
down_revision: Union[str, None] = "013"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("bot_trades") as b:
|
||||||
|
b.add_column(sa.Column("peak_gain_pct", sa.Float(),
|
||||||
|
nullable=False, server_default="0"))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("bot_trades") as b:
|
||||||
|
b.drop_column("peak_gain_pct")
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Add System-2 risk mode (standard / aggressive).
|
||||||
|
|
||||||
|
subscriptions.sys2_mode : user-selected mode ("standard" default)
|
||||||
|
bot_trades.sys2_mode : frozen mode for the trade (NULL → standard)
|
||||||
|
|
||||||
|
Aggressive is a separately-funded high-risk/high-explosiveness sleeve. Both
|
||||||
|
modes keep the safety invariants (never exchange-liquidated; post-pyramid
|
||||||
|
breakeven floor). Defaults make every existing row behave as before.
|
||||||
|
|
||||||
|
Revision ID: 015
|
||||||
|
Revises: 014
|
||||||
|
Create Date: 2026-05-18 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "015"
|
||||||
|
down_revision: Union[str, None] = "014"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("subscriptions") as b:
|
||||||
|
b.add_column(sa.Column("sys2_mode", sa.String(16),
|
||||||
|
nullable=False, server_default="standard"))
|
||||||
|
with op.batch_alter_table("bot_trades") as b:
|
||||||
|
b.add_column(sa.Column("sys2_mode", sa.String(16), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("bot_trades") as b:
|
||||||
|
b.drop_column("sys2_mode")
|
||||||
|
with op.batch_alter_table("subscriptions") as b:
|
||||||
|
b.drop_column("sys2_mode")
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Simplified operator model: master Auto-Trade + per-trade Grow.
|
||||||
|
|
||||||
|
subscriptions.auto_trade : ONE persistent gate. OFF (default) = scan +
|
||||||
|
ingest signals (feed only), no trades. ON = auto-open on signal.
|
||||||
|
bot_trades.grow_mode : per-trade pyramiding switch (default off).
|
||||||
|
|
||||||
|
Defaults are safe (false) so existing rows do NOT auto-trade / auto-pyramid
|
||||||
|
until the user explicitly opts in — no behavior change on upgrade.
|
||||||
|
|
||||||
|
Revision ID: 016
|
||||||
|
Revises: 015
|
||||||
|
Create Date: 2026-05-18 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
revision: str = "016"
|
||||||
|
down_revision: Union[str, None] = "015"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _columns(table: str) -> set:
|
||||||
|
return {c["name"] for c in inspect(op.get_bind()).get_columns(table)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if "auto_trade" not in _columns("subscriptions"):
|
||||||
|
with op.batch_alter_table("subscriptions") as b:
|
||||||
|
b.add_column(sa.Column("auto_trade", sa.Boolean(),
|
||||||
|
nullable=False, server_default=sa.false()))
|
||||||
|
if "grow_mode" not in _columns("bot_trades"):
|
||||||
|
with op.batch_alter_table("bot_trades") as b:
|
||||||
|
b.add_column(sa.Column("grow_mode", sa.Boolean(),
|
||||||
|
nullable=False, server_default=sa.false()))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if "grow_mode" in _columns("bot_trades"):
|
||||||
|
with op.batch_alter_table("bot_trades") as b:
|
||||||
|
b.drop_column("grow_mode")
|
||||||
|
if "auto_trade" in _columns("subscriptions"):
|
||||||
|
with op.batch_alter_table("subscriptions") as b:
|
||||||
|
b.drop_column("auto_trade")
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""KOL module — Substack/Twitter post ingestion + AI analysis.
|
||||||
|
|
||||||
|
Standalone module, no FK into Trump posts/trades. First slice (B-tier):
|
||||||
|
- kol_posts: raw ingested posts from Substack/Twitter, keyed by URL
|
||||||
|
- (kol_holdings + kol_wallets reserved for A-tier in later migration)
|
||||||
|
|
||||||
|
Each post carries its own AI analysis inline (1:1, no separate table):
|
||||||
|
summary (one-sentence), tickers_json (list of {ticker, action,
|
||||||
|
conviction, quote}), model, analysis_version.
|
||||||
|
|
||||||
|
Revision ID: 017
|
||||||
|
Revises: 016
|
||||||
|
Create Date: 2026-05-23 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "017"
|
||||||
|
down_revision: Union[str, None] = "016"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"kol_posts",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("kol_handle", sa.String(64), nullable=False, index=True),
|
||||||
|
sa.Column("source", sa.String(16), nullable=False), # substack | twitter
|
||||||
|
sa.Column("external_id", sa.String(512), nullable=False), # canonical URL / tweet id
|
||||||
|
sa.Column("title", sa.String(512), nullable=True),
|
||||||
|
sa.Column("url", sa.String(512), nullable=False),
|
||||||
|
sa.Column("published_at", sa.DateTime(), nullable=False, index=True),
|
||||||
|
sa.Column("raw_text", sa.Text(), nullable=False),
|
||||||
|
sa.Column("content_hash", sa.String(64), nullable=False),
|
||||||
|
|
||||||
|
# ── Inline analysis (1:1, populated by extractor) ─────────────
|
||||||
|
sa.Column("summary", sa.Text(), nullable=True),
|
||||||
|
sa.Column("tickers_json", sa.Text(), nullable=True), # JSON list
|
||||||
|
sa.Column("analyzed_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("analysis_model", sa.String(64), nullable=True),
|
||||||
|
sa.Column("analysis_version", sa.String(16), nullable=True),
|
||||||
|
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False,
|
||||||
|
server_default=sa.func.current_timestamp()),
|
||||||
|
sa.UniqueConstraint("source", "external_id", name="uq_kol_post_src_extid"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("kol_posts")
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""KOL A-tier: on-chain wallet tracking + daily holdings snapshots.
|
||||||
|
|
||||||
|
Three new tables (standalone, no FK into Trump tables):
|
||||||
|
|
||||||
|
kol_wallets — curated KOL wallet addresses (one row per chain-addr pair)
|
||||||
|
kol_holdings_snapshots — daily portfolio snapshot per wallet (holdings_json)
|
||||||
|
kol_holding_changes — detected significant moves (new / closed / ±25%)
|
||||||
|
|
||||||
|
Source column distinguishes where the snapshot came from:
|
||||||
|
"arkham" — Arkham Intelligence API portfolio endpoint
|
||||||
|
"hl" — Hyperliquid public clearinghouseState (perps only)
|
||||||
|
"manual" — hand-entered for testing
|
||||||
|
|
||||||
|
Revision ID: 018
|
||||||
|
Revises: 017
|
||||||
|
Create Date: 2026-05-23
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "018"
|
||||||
|
down_revision: Union[str, None] = "017"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"kol_wallets",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("handle", sa.String(64), nullable=False, index=True),
|
||||||
|
sa.Column("chain", sa.String(16), nullable=False), # ethereum|solana|hl
|
||||||
|
sa.Column("address", sa.String(128), nullable=False),
|
||||||
|
sa.Column("label", sa.String(128), nullable=True), # e.g. "Arthur Hayes hot"
|
||||||
|
sa.Column("source_url", sa.String(256), nullable=True), # Arkham entity URL for reference
|
||||||
|
sa.Column("active", sa.Boolean(), nullable=False, server_default="1"),
|
||||||
|
sa.Column("added_at", sa.DateTime(), nullable=False,
|
||||||
|
server_default=sa.func.current_timestamp()),
|
||||||
|
sa.UniqueConstraint("chain", "address", name="uq_kol_wallet_chain_addr"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"kol_holdings_snapshots",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("wallet_id", sa.Integer(), sa.ForeignKey("kol_wallets.id"), nullable=False, index=True),
|
||||||
|
sa.Column("snapshot_date",sa.String(10), nullable=False), # YYYY-MM-DD UTC
|
||||||
|
sa.Column("holdings_json",sa.Text(), nullable=False), # [{ticker,amount,usd_value,chain}]
|
||||||
|
sa.Column("total_usd", sa.Float(), nullable=True),
|
||||||
|
sa.Column("source", sa.String(16), nullable=False), # arkham|hl|manual
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False,
|
||||||
|
server_default=sa.func.current_timestamp()),
|
||||||
|
sa.UniqueConstraint("wallet_id", "snapshot_date", name="uq_kol_snapshot_wallet_date"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"kol_holding_changes",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("wallet_id", sa.Integer(), sa.ForeignKey("kol_wallets.id"), nullable=False, index=True),
|
||||||
|
sa.Column("detected_at", sa.DateTime(), nullable=False, index=True),
|
||||||
|
sa.Column("ticker", sa.String(32), nullable=False),
|
||||||
|
# new_position | closed | increased | decreased
|
||||||
|
sa.Column("change_type", sa.String(16), nullable=False),
|
||||||
|
sa.Column("usd_before", sa.Float(), nullable=True),
|
||||||
|
sa.Column("usd_after", sa.Float(), nullable=True),
|
||||||
|
sa.Column("pct_change", sa.Float(), nullable=True), # signed %
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False,
|
||||||
|
server_default=sa.func.current_timestamp()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("kol_holding_changes")
|
||||||
|
op.drop_table("kol_holdings_snapshots")
|
||||||
|
op.drop_table("kol_wallets")
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""kol_divergence table — talks-vs-trades cross-signal
|
||||||
|
|
||||||
|
Revision ID: 019
|
||||||
|
Revises: 018
|
||||||
|
Create Date: 2026-05-23
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "019"
|
||||||
|
down_revision = "018"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"kol_divergence",
|
||||||
|
sa.Column("id", sa.Integer, primary_key=True),
|
||||||
|
# Which KOL + asset
|
||||||
|
sa.Column("handle", sa.String(64), nullable=False, index=True),
|
||||||
|
sa.Column("ticker", sa.String(32), nullable=False),
|
||||||
|
# Content side (B-tier)
|
||||||
|
sa.Column("post_id", sa.Integer, sa.ForeignKey("kol_posts.id"), nullable=False),
|
||||||
|
sa.Column("post_action", sa.String(16), nullable=False), # buy|sell|bullish|bearish
|
||||||
|
sa.Column("post_conviction", sa.Float, nullable=True),
|
||||||
|
sa.Column("post_at", sa.DateTime, nullable=False), # post published_at
|
||||||
|
# On-chain side (A-tier)
|
||||||
|
sa.Column("change_id", sa.Integer, sa.ForeignKey("kol_holding_changes.id"), nullable=False),
|
||||||
|
sa.Column("onchain_action", sa.String(16), nullable=False), # new_position|increased|decreased|closed
|
||||||
|
sa.Column("usd_before", sa.Float, nullable=True),
|
||||||
|
sa.Column("usd_after", sa.Float, nullable=True),
|
||||||
|
sa.Column("onchain_at", sa.DateTime, nullable=False),
|
||||||
|
# Classification
|
||||||
|
sa.Column("signal_type", sa.String(16), nullable=False), # divergence | alignment
|
||||||
|
sa.Column("direction", sa.String(8), nullable=True), # long | short (net conclusion)
|
||||||
|
sa.Column("days_apart", sa.Float, nullable=True), # abs days between post and onchain event
|
||||||
|
sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||||
|
# Unique: one row per (post, change) pair so re-runs are idempotent
|
||||||
|
sa.UniqueConstraint("post_id", "change_id", name="uq_kol_divergence_pair"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_kol_divergence_handle_ticker", "kol_divergence", ["handle", "ticker"])
|
||||||
|
op.create_index("ix_kol_divergence_signal_type", "kol_divergence", ["signal_type"])
|
||||||
|
op.create_index("ix_kol_divergence_created_at", "kol_divergence", ["created_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("kol_divergence")
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""telegram_bindings — wallet ↔ Telegram chat_id mapping with per-source prefs.
|
||||||
|
|
||||||
|
Revision ID: 020
|
||||||
|
Revises: 019
|
||||||
|
Create Date: 2026-05-24
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "020"
|
||||||
|
down_revision = "019"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"telegram_bindings",
|
||||||
|
sa.Column("id", sa.Integer, primary_key=True),
|
||||||
|
sa.Column("wallet_address", sa.String(64), nullable=False, unique=True, index=True),
|
||||||
|
# Telegram chat_id is an int64 in practice; store as BigInteger to be safe.
|
||||||
|
sa.Column("chat_id", sa.BigInteger, nullable=False, unique=True, index=True),
|
||||||
|
sa.Column("tg_username", sa.String(64), nullable=True), # display only
|
||||||
|
sa.Column("bound_at", sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||||
|
# Master switch
|
||||||
|
sa.Column("alerts_enabled", sa.Boolean, nullable=False, server_default=sa.true()),
|
||||||
|
# Per-source toggles. KOL divergence is noisier so default-off.
|
||||||
|
sa.Column("alert_trump", sa.Boolean, nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("alert_btc_bottom", sa.Boolean, nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("alert_funding", sa.Boolean, nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("alert_kol_divergence", sa.Boolean, nullable=False, server_default=sa.false()),
|
||||||
|
# Quality floor — don't ping for low-confidence Trump posts.
|
||||||
|
sa.Column("min_confidence", sa.Integer, nullable=False, server_default="70"),
|
||||||
|
# Quiet hours (UTC). 0..23. Both null = always on. If start<end, mute
|
||||||
|
# [start, end); if start>end, mute window wraps midnight.
|
||||||
|
sa.Column("mute_from_hour", sa.Integer, nullable=True),
|
||||||
|
sa.Column("mute_until_hour", sa.Integer, nullable=True),
|
||||||
|
# Audit
|
||||||
|
sa.Column("last_alert_at", sa.DateTime, nullable=True),
|
||||||
|
sa.Column("total_alerts_sent", sa.Integer, nullable=False, server_default="0"),
|
||||||
|
sa.Column("total_alerts_failed", sa.Integer, nullable=False, server_default="0"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("telegram_bindings")
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Allow walletless Telegram bindings.
|
||||||
|
|
||||||
|
Before: every binding had to be tied to a subscribed wallet.
|
||||||
|
After: anyone who DMs the bot can subscribe to public alerts; the
|
||||||
|
wallet_address column becomes nullable. Uniqueness is on chat_id
|
||||||
|
alone — the same Telegram chat can never have two bindings.
|
||||||
|
|
||||||
|
A wallet can still have at most one binding (enforced by a partial unique
|
||||||
|
index on wallet_address WHERE wallet_address IS NOT NULL).
|
||||||
|
|
||||||
|
Revision ID: 021
|
||||||
|
Revises: 020
|
||||||
|
Create Date: 2026-05-24
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "021"
|
||||||
|
down_revision = "020"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# SQLite + batch mode → recreates the table preserving rows.
|
||||||
|
with op.batch_alter_table("telegram_bindings") as batch:
|
||||||
|
batch.alter_column("wallet_address",
|
||||||
|
existing_type=sa.String(64),
|
||||||
|
nullable=True)
|
||||||
|
|
||||||
|
# Drop the old unique constraint on wallet_address (was created via
|
||||||
|
# `unique=True`), then add a partial unique index that allows multiple
|
||||||
|
# NULL wallets but still prevents two bindings for the same wallet.
|
||||||
|
# SQLite quirk: the auto-generated unique index name is
|
||||||
|
# `ix_telegram_bindings_wallet_address` (because we also indexed it).
|
||||||
|
# We replace it with the partial-unique form.
|
||||||
|
with op.batch_alter_table("telegram_bindings") as batch:
|
||||||
|
batch.drop_index("ix_telegram_bindings_wallet_address")
|
||||||
|
op.create_index(
|
||||||
|
"ix_telegram_bindings_wallet_address",
|
||||||
|
"telegram_bindings",
|
||||||
|
["wallet_address"],
|
||||||
|
unique=True,
|
||||||
|
sqlite_where=sa.text("wallet_address IS NOT NULL"),
|
||||||
|
postgresql_where=sa.text("wallet_address IS NOT NULL"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Note: downgrade FAILS if any walletless rows exist — caller must
|
||||||
|
# delete them first. This is intentional (data preservation > graceful
|
||||||
|
# downgrade for an opt-in safety net).
|
||||||
|
op.drop_index("ix_telegram_bindings_wallet_address", table_name="telegram_bindings")
|
||||||
|
with op.batch_alter_table("telegram_bindings") as batch:
|
||||||
|
batch.alter_column("wallet_address",
|
||||||
|
existing_type=sa.String(64),
|
||||||
|
nullable=False)
|
||||||
|
batch.create_index("ix_telegram_bindings_wallet_address",
|
||||||
|
["wallet_address"], unique=True)
|
||||||
+158
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
@@ -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
@@ -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())
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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="0–100. 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
@@ -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)
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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)
|
||||||
|
|||||||
+174
-2
@@ -15,6 +15,7 @@ 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()
|
||||||
@@ -24,6 +25,20 @@ logger = logging.getLogger(__name__)
|
|||||||
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.1–50)")
|
raise HTTPException(422, "stop_loss_pct is required (0.1–50)")
|
||||||
if not (0 <= s.min_confidence <= 100):
|
if not (0 <= s.min_confidence <= 100):
|
||||||
raise HTTPException(422, "min_confidence must be 0–100")
|
raise HTTPException(422, "min_confidence must be 0–100")
|
||||||
|
if s.sys2_leverage is not None and not (1 <= s.sys2_leverage <= 10):
|
||||||
|
raise HTTPException(422, "sys2_leverage must be 1–10")
|
||||||
|
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 0–168. 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 0–168")
|
||||||
|
|
||||||
|
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
@@ -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()
|
||||||
|
|||||||
+127
-7
@@ -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
|
||||||
|
# ownership stays explicit and startup never mutates the DB implicitly.
|
||||||
|
if settings.environment == "development":
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
logger.info("Database tables ensured.")
|
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
|
||||||
|
# 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: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
@@ -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)
|
||||||
|
|||||||
+25
-1
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,16 +445,44 @@ 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()
|
||||||
|
# Map OpenAI-style model name → Anthropic model name
|
||||||
|
model_name = model
|
||||||
|
if "haiku" in model_name.lower() and "claude-" not in model_name.lower():
|
||||||
|
model_name = "claude-haiku-4-5-20251001"
|
||||||
|
msg = await anthropic_client.messages.create(
|
||||||
|
model=model_name,
|
||||||
max_tokens=600,
|
max_tokens=600,
|
||||||
temperature=0.1,
|
temperature=0.1,
|
||||||
messages=[
|
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": "system", "content": SYSTEM_PROMPT},
|
||||||
{"role": "user", "content": user_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()
|
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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
+689
-45
@@ -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 0–100 threshold via settings.
|
# No global confidence floor — users pick their own 0–100 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,29 +300,46 @@ 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"):
|
||||||
|
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
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -166,34 +348,136 @@ 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,
|
||||||
)
|
)
|
||||||
if spent + sub["position_size_usd"] > daily_cap:
|
from app.models import Post as _P2
|
||||||
logger.info("Sub %s daily budget reached: spent=%.2f + new=%.2f > cap=%.2f",
|
async with AsyncSessionLocal() as conc_db:
|
||||||
wallet, spent, sub["position_size_usd"], daily_cap)
|
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),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
open_sys2 = [
|
||||||
|
t for t, src in open_rows.all()
|
||||||
|
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:
|
||||||
|
# ── Paper-mode branch (P1.3) ──────────────────────────────
|
||||||
|
# Skip Hyperliquid entirely. Use the current Binance price as
|
||||||
|
# the synthetic fill. DB row gets hl_order_id="paper" so close
|
||||||
|
# logic + reconciliation know to skip HL too.
|
||||||
|
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(
|
trader = HyperliquidTrader(
|
||||||
api_private_key=api_key,
|
api_private_key=api_key,
|
||||||
account_address=wallet,
|
account_address=wallet,
|
||||||
@@ -206,9 +490,28 @@ async def _execute_for_subscriber(
|
|||||||
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,6 +838,23 @@ 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
|
||||||
|
|
||||||
|
# ── Paper-mode close (P1.3) ──────────────────────────────
|
||||||
|
# No Hyperliquid call. Synthesize a fill at the current Binance
|
||||||
|
# price. Same downstream PnL math as a real trade.
|
||||||
|
if trade.hl_order_id == "paper":
|
||||||
|
from app.services.price_store import price_store
|
||||||
|
paper_exit = price_store.latest_price(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(
|
trader = HyperliquidTrader(
|
||||||
api_private_key=api_key,
|
api_private_key=api_key,
|
||||||
account_address=wallet,
|
account_address=wallet,
|
||||||
@@ -307,13 +865,19 @@ async def close_and_finalize(
|
|||||||
|
|
||||||
# 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",
|
||||||
|
)
|
||||||
|
|||||||
@@ -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.45–1.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·(1−0.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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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}"
|
||||||
@@ -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)
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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.0–1.0
|
||||||
|
- 0.8+ : explicit, repeated, with sizing / timing
|
||||||
|
- 0.5–0.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,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
.replace(""", '"').replace("’", "'").replace("“", '"')
|
||||||
|
.replace("”", '"').replace(" ", " "))
|
||||||
|
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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 03–09 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:00–08: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 03–09)."""
|
||||||
|
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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
15–30%); 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")
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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 6–18mo;
|
||||||
|
# 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 ~25–30% 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: 70→1.0, 85→1.3, 95→1.5, 100→1.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.
|
||||||
@@ -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, 0–6)."""
|
||||||
|
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.")
|
||||||
@@ -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 & 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> (0–100). 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:00–07: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 0–23.")
|
||||||
|
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
|
||||||
+395
-21
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
@@ -47,16 +134,56 @@ def register_trade(
|
|||||||
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 ~25–30% 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
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Executable
+62
@@ -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
|
||||||
Executable
+239
@@ -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()))
|
||||||
Executable
+162
@@ -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()))
|
||||||
@@ -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))
|
||||||
@@ -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 20–25% 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
|
||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
Binary file not shown.
Reference in New Issue
Block a user