fix: pre-launch hardening — HYPE price feed, KOL wallet cleanup, Telegram Trump alert, rate limiting, brittle test

Batch of the pre-launch audit campaign (BUG-01…14 plus three new features):

Pricing / TP-SL protection
- Add app/services/hl_price_feed.py: supplemental HL allMids poller for
  HL-native assets (HYPE, PURR) not listed on Binance. Pumps price_store +
  tp_sl_monitor.on_price_tick so bot trades on these assets keep full
  stop-loss / take-profit / trailing protection instead of max-hold only.
- Wire feed into main.py lifespan (startup task + graceful shutdown cancel).

Telegram
- Add format_trump_mention + PATH B in _dispatch: crypto-relevant Trump
  posts with no directional signal (relevant=True, signal=hold) now alert
  the public channel only (no per-subscriber noise).
- Rate limiter (slowapi) on the API; assorted bot/digest fixes.

KOL on-chain
- seed_kol_wallets.py: KOL_FEEDS coverage cross-check; reversibly deactivate
  orphaned wallets (handle not in KOL_FEEDS → can never produce divergence)
  so the scanner stops burning cycles on them.

Tests / misc
- Fix brittle test_macro_ahr999_uses_same_formula_as_scanner: mock now uses
  realistic ms timestamps so the in-progress-day drop fires, matching the
  fetcher's bar count (was 0.3179 vs 0.3178 off-by-one).
- Refresh stale notify_signal comment in truth_social.py.

Frontend reduce-action type fix lives in the sibling repo.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
k
2026-05-29 11:57:19 +08:00
parent 6471e44aac
commit d6c802ef26
40 changed files with 1833 additions and 209 deletions
+97 -12
View File
@@ -96,7 +96,7 @@ linked) = Trump auto-trade + /adopt manage-only flow for sys2.
- **Python 3.9+** / FastAPI / async SQLAlchemy 2.x / APScheduler - **Python 3.9+** / FastAPI / async SQLAlchemy 2.x / APScheduler
- **DB**: SQLite dev, **Postgres prod**. All schema lives in - **DB**: SQLite dev, **Postgres prod**. All schema lives in
`alembic/versions/NNN_*.py`, ordered. Currently at head **024**. `alembic/versions/NNN_*.py`, ordered. Currently at head **026**.
- **AI**: DeepSeek via OpenAI-compatible API (`AI_BASE_URL`, `AI_MODEL`). - **AI**: DeepSeek via OpenAI-compatible API (`AI_BASE_URL`, `AI_MODEL`).
- Live scoring uses `AI_LIVE_MODEL` (~2s, latency-critical) - Live scoring uses `AI_LIVE_MODEL` (~2s, latency-critical)
- Batch / reanalysis uses `AI_MODEL` (quality, ~10s) - Batch / reanalysis uses `AI_MODEL` (quality, ~10s)
@@ -106,6 +106,9 @@ linked) = Trump auto-trade + /adopt manage-only flow for sys2.
`tp_sl_monitor` per-tick evaluator. `tp_sl_monitor` per-tick evaluator.
- **Telegram**: long-poll mode (single instance), HTML messages, inline - **Telegram**: long-poll mode (single instance), HTML messages, inline
keyboards. `telegram.py` send/edit/answer + `telegram_bot.py` commands. keyboards. `telegram.py` send/edit/answer + `telegram_bot.py` commands.
Public channel broadcast (`TELEGRAM_PUBLIC_CHANNEL_ID` env) sends a
sanitised `format_public_post` version (no execution details, tier label
instead of raw confidence) after every per-user fan-out in `_dispatch`.
--- ---
@@ -165,7 +168,8 @@ app/
├── config.py Pydantic Settings — reads .env ├── config.py Pydantic Settings — reads .env
└── main.py FastAPI lifespan, scheduler setup, route mount └── main.py FastAPI lifespan, scheduler setup, route mount
alembic/versions/ Migrations (numbered NNN). Latest = 024 alembic/versions/ Migrations (numbered NNN). Latest = 026
026 = composite index (wallet_address, closed_at) on bot_trades
scripts/ One-shot ops scripts/ One-shot ops
├── preflight.py Pre-launch readiness gate (env / DB / TG / AI) ├── preflight.py Pre-launch readiness gate (env / DB / TG / AI)
├── launch_smoke.py End-to-end smoke (14 checks against running API) ├── launch_smoke.py End-to-end smoke (14 checks against running API)
@@ -304,6 +308,8 @@ minimalism — don't extend them.
7. Add a `/yoursource on|off` command in `telegram_bot.py`. 7. Add a `/yoursource on|off` command in `telegram_bot.py`.
8. If sys2: extend `signal_categories._CATEGORY_EXITS` if it needs a custom 8. If sys2: extend `signal_categories._CATEGORY_EXITS` if it needs a custom
exit profile (otherwise default works). exit profile (otherwise default works).
9. Add the deep-link path in `telegram.format_post` AND `telegram.format_public_post`
(both the per-user private alert and the public channel use the same path map).
### Add a new bot command ### Add a new bot command
@@ -399,29 +405,108 @@ this for you" to "you opened it, bot manages your discipline".
- **Adopted trades have `hl_order_id` starting with `"adopted:"`.** Distinct - **Adopted trades have `hl_order_id` starting with `"adopted:"`.** Distinct
from auto-opened (HL order id integer) and paper (`"paper"` literal). from auto-opened (HL order id integer) and paper (`"paper"` literal).
Useful for telemetry filtering. Useful for telemetry filtering.
- **`telegram.send_message` accepts `int | str` for `chat_id`.** Intentional.
Integer = private chat, string = public channel username (e.g. `"@trumpalpha"`).
The public channel uses the string form; per-user alerts still pass integers.
- **`format_public_post` deliberately omits `expected_move_pct`,
`invalidation_price`, and `/adopt` CTA.** Execution-sensitive data stays
private (per-user). The public version shows confidence tier (HIGH/MED/LOW)
instead of the raw score for readability.
- **`_adopt_locks` in adoption.py looks like it should have a cap like
`_wallet_open_locks` (512).** It doesn't yet — see Known Issues below.
--- ---
## Open known issues (not blocking launch but worth fixing later) ## Open known issues (not blocking launch but worth fixing later)
- **Trump daily budget split**: `_execute_for_subscriber` still computes - ~~**`_time_stop_check` tasks not rehydrated on restart** (BUG-01, FIXED 2026-05-29):~~
`daily_cap = total × (1 - sys2_pct)` for sys1 — i.e. Trump gets only 30% `recovery.py` now imports `_time_stop_check` + `_background_tasks` from
of the configured daily budget by default. With sys2 manage-only there's `bot_engine` and `get_exit_profile` from `signal_categories`. After each
no reason to split. Fix: when not sys2, use full `total_cap`. (Low-prio: `register_trade()` call for a sys2 trade, it checks `exit_profile.time_stop_hours`,
users who notice can just set `sys2_budget_pct = 0` in DB.) computes remaining seconds, and reschedules the task. Elapsed windows (backend
- **HL high-leverage adoption**: if a user has a 25× position on HL, our was down longer than the time-stop period) fire immediately with `delay_seconds=0`.
`sys2_protective_stop_pct` clamps to 10× (the strategy's design max),
which gives a stop FURTHER from entry than the real liquidation point. - **Rate limit bypass via proxy x-forwarded-for deletion** (BUG-02, HIGH):
Position could get HL-liquidated before our stop fires. Fix: reject `app/api/proxy/[...path]/route.ts` (frontend) strips `x-forwarded-for`
adoptions where actual HL leverage > `SYS2_MAX_LEVERAGE`. before forwarding. Backend `slowapi` sees the Next.js server IP and applies
one rate-limit bucket to ALL users. Fix: relay the real client IP from
`req.headers.get('x-forwarded-for')` or `x-real-ip` in the proxy.
- ~~**`close_and_finalize` double-failure leaves DB/HL state inconsistent**
(BUG-03, MITIGATED 2026-05-29):~~ Full two-phase-commit is out of scope.
Mitigation: `reconciler._reconcile_wallet` now runs a "ghost position" pass —
queries DB-closed trades from the last 2h and cross-checks against HL open
positions. Mismatches are logged at ERROR level and broadcast via WS
(`reconcile_drift.ghost_positions`). Manual close required on HL UI.
- ~~**`_adopt_locks` has no capacity cap** (BUG-04, FIXED 2026-05-29):~~
`adoption._adopt_locks` is now an `OrderedDict` capped at `_ADOPT_LOCK_MAX=512`
with LRU eviction — matches the `_WALLET_LOCK_MAX` pattern in `bot_engine`.
- ~~**Reconciler runs wallets sequentially** (BUG-05, FIXED 2026-05-29):~~
`reconcile_all_once` now fans out with `asyncio.gather` + `asyncio.Semaphore(10)`.
Worst-case tail latency is `ceil(N/10) × 30s` instead of `N × 30s`.
- ~~**`/stop` also silently disables daily digest** (BUG-07, FIXED 2026-05-29):~~
`send_daily_digest` no longer filters on `alerts_enabled` — digest and
real-time alerts are independent. `/stop` reply updated to say "Real-time
alerts paused … send `/digest off` to stop the daily brief separately".
- ~~**`binance.py` ASSET_MAP only had BTC + ETH** (BUG-08, CRITICAL, FIXED 2026-05-29):~~
Trump's AI can set `target_asset` to SOL/TRUMP/BNB/DOGE/LINK/AAVE.
`ASSET_MAP` now covers all those; `BINANCE_WS_URL` is derived from it
automatically so the stream list and routing table can never diverge.
**Still missing**: HYPE (HL-native, not on Binance) — trades on HYPE fall
back to max-hold only until a supplemental HL price feed is added.
- ~~**`_close_locks` leaked one entry per concurrent-close loser** (BUG-11, FIXED 2026-05-29):~~
`close_and_finalize` now pops `_close_locks[trade_id]` in the
`rowcount == 0` early-return path, not just on the success paths.
- ~~**`signed_request._seen` O(n) purge at 5000 entries** (BUG-12, FIXED 2026-05-29):~~
Threshold reduced to `_SEEN_PURGE_THRESHOLD = 1000` and the purge now
builds an `expired` list in one pass rather than calling `dict.pop` in a loop.
- ~~**`_get_max_leverage` makes a fresh HL `meta()` call on every trade open**
(BUG-10, FIXED 2026-05-29):~~ `_MAX_LEV_CACHE` added (same 300s TTL as
`_SZ_DECIMALS_CACHE`). Cache miss populates ALL coins from the single
`meta()` response, so concurrent opens pay one API call, not N.
- ~~**Trump daily budget split**~~ (FIXED 2026-05-29): System-2 is manage-only
so the `sys2_pct` reservation no longer makes sense for auto-opens.
`daily_cap` for System-1 (Trump) is now `total_cap × 1.0` instead of
`total_cap × (1 - sys2_pct)`. The split logic remains intact so it would
work correctly if sys2 auto-open is ever re-enabled via ADR.
- ~~**HL high-leverage adoption**~~ (BUG-09, FIXED 2026-05-29): `adopt_position`
now rejects positions where `leverage > SYS2_MAX_LEVERAGE` with error code
`leverage_too_high`. Previously the protective stop was computed for 10×
but applied to a 25× position — the stop was OUTSIDE the liquidation band.
- **`adopt:choose:BTC` callback may show stale prices** if user takes >60s - **`adopt:choose:BTC` callback may show stale prices** if user takes >60s
to tap (HL fees, partial fills can change entry/size). adopt_position to tap (HL fees, partial fills can change entry/size). adopt_position
re-reads HL at mode-tap time so the FROZEN BotTrade is always fresh, re-reads HL at mode-tap time so the FROZEN BotTrade is always fresh,
but the picker label could be outdated. but the picker label could be outdated.
- **Telegram bot offset on restart**: `getUpdates` may replay last 24h of - **Telegram bot offset on restart**: `getUpdates` may replay last 24h of
messages on backend restart. Stale `/adopt` could re-fire. User can messages on backend restart. Stale `/adopt` could re-fire. User can
/release to recover. /release to recover.
- ~~**`pyramid_add` double-add on DB commit failure** (BUG-13, FIXED 2026-05-29):~~
Same pattern as BUG-03 / partial_derisk. HL `open_position` succeeds but the
subsequent `db.commit()` of `addon_steps_done` fails → retrigger sees same
`step_idx` and double-adds. Fix: pre-claim `addon_steps_done = step_idx + 1`
with a conditional UPDATE (WHERE `addon_steps_done == step_idx`) BEFORE calling
HL. If `rowcount == 0`, return early. Second UPDATE writes `entry_price` and
`size_usd` after fill confirmation.
- ~~**`price_impact_monitor` measured wrong asset** (BUG-14, FIXED 2026-05-29):~~
`truth_social.py` passed `analysis["asset"]` (BTC/ETH sentiment proxy) to
`register_post` instead of `target_asset` (SOL/TRUMP/etc. — the perp we actually
trade). Impact % was measuring BTC/ETH not the traded coin. Fix: introduced
`tracked_asset = analysis.get("target_asset") or asset` and used it for
`price_at_post`, `price_impact_asset`, and `register_post(asset=…)`.
--- ---
## Repos in this project ## Repos in this project
@@ -0,0 +1,24 @@
"""Add composite index ix_bot_trades_wallet_closed
Revision ID: 026
Revises: 025
Create Date: 2026-05-28
"""
from alembic import op
revision = '026'
down_revision = '025'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_index(
'ix_bot_trades_wallet_closed',
'bot_trades',
['wallet_address', 'closed_at'],
)
def downgrade() -> None:
op.drop_index('ix_bot_trades_wallet_closed', table_name='bot_trades')
+4 -3
View File
@@ -8,13 +8,14 @@ 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 from app.services.signed_request import verify_signed_request_any
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
PERIOD_DAYS = 30 PERIOD_DAYS = 30
ACTION_VIEW_PERFORMANCE = "view_performance" ACTION_VIEW_PERFORMANCE = "view_performance"
ACTION_VIEW_USER = "view_user"
@router.get("/performance", response_model=BotPerformance) @router.get("/performance", response_model=BotPerformance)
@@ -25,8 +26,8 @@ async def get_performance(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
wallet = wallet.lower().strip() wallet = wallet.lower().strip()
verify_signed_request( verify_signed_request_any(
action=ACTION_VIEW_PERFORMANCE, actions=[ACTION_VIEW_PERFORMANCE, ACTION_VIEW_USER],
wallet=wallet, wallet=wallet,
timestamp_ms=ts, timestamp_ms=ts,
signature=sig, signature=sig,
+6 -5
View File
@@ -35,7 +35,7 @@ from app.database import get_db
from app.models import BotTrade, Subscription, iso_utc from app.models import BotTrade, Subscription, iso_utc
from app.services.crypto import decrypt_api_key from app.services.crypto import decrypt_api_key
from app.services.price_store import price_store from app.services.price_store import price_store
from app.services.signed_request import verify_signed_request from app.services.signed_request import verify_signed_request, verify_signed_request_any
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -150,8 +150,8 @@ async def get_open_positions(
): ):
"""Live open positions for the wallet, with mark-to-market PnL.""" """Live open positions for the wallet, with mark-to-market PnL."""
wallet = wallet.lower().strip() wallet = wallet.lower().strip()
verify_signed_request( verify_signed_request_any(
action=ACTION_VIEW_POSITIONS, actions=[ACTION_VIEW_POSITIONS, ACTION_VIEW_USER],
wallet=wallet, wallet=wallet,
timestamp_ms=ts, timestamp_ms=ts,
signature=sig, signature=sig,
@@ -187,8 +187,8 @@ async def get_today_stats(
one-shot count for opens. one-shot count for opens.
""" """
wallet = wallet.lower().strip() wallet = wallet.lower().strip()
verify_signed_request( verify_signed_request_any(
action=ACTION_VIEW_POSITIONS, actions=[ACTION_VIEW_POSITIONS, ACTION_VIEW_USER],
wallet=wallet, wallet=wallet,
timestamp_ms=ts, timestamp_ms=ts,
signature=sig, signature=sig,
@@ -242,6 +242,7 @@ async def get_today_stats(
ACTION_CLOSE_TRADE = "close_trade" ACTION_CLOSE_TRADE = "close_trade"
ACTION_SET_GROW = "set_trade_grow" ACTION_SET_GROW = "set_trade_grow"
ACTION_VIEW_POSITIONS = "view_positions" ACTION_VIEW_POSITIONS = "view_positions"
ACTION_VIEW_USER = "view_user"
ACTION_ADOPT_POSITION = "adopt_position" ACTION_ADOPT_POSITION = "adopt_position"
ACTION_RELEASE_TRADE = "release_trade" ACTION_RELEASE_TRADE = "release_trade"
+14 -1
View File
@@ -1,7 +1,12 @@
import logging import logging
from typing import List, Optional from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import Response
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -66,16 +71,24 @@ def _post_to_schema(post: Post) -> TrumpPost:
@router.get("/posts", response_model=List[TrumpPost]) @router.get("/posts", response_model=List[TrumpPost])
@limiter.limit("60/minute")
async def get_posts( async def get_posts(
request: Request,
limit: int = Query(default=20, ge=1, le=500), limit: int = Query(default=20, ge=1, le=500),
page: int = Query(default=1, ge=1), page: int = Query(default=1, ge=1),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
response: Response = None,
): ):
offset = (page - 1) * limit offset = (page - 1) * limit
result = await db.execute( result = await db.execute(
select(Post).order_by(Post.published_at.desc()).offset(offset).limit(limit) select(Post).order_by(Post.published_at.desc()).offset(offset).limit(limit)
) )
posts = result.scalars().all() posts = result.scalars().all()
# Posts are scraped every 5s but rarely change once written — allow CDN/browser
# to cache for 30s. stale-while-revalidate=60 means stale content is served
# while a fresh fetch happens in the background (no loading flash).
if response is not None:
response.headers["Cache-Control"] = "public, max-age=30, stale-while-revalidate=60"
return [_post_to_schema(p) for p in posts] return [_post_to_schema(p) for p in posts]
+17 -3
View File
@@ -2,7 +2,12 @@ import logging
from typing import List from typing import List
import httpx import httpx
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import Response
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
from app.config import settings from app.config import settings
from app.schemas import Candle from app.schemas import Candle
@@ -48,10 +53,13 @@ async def fetch_binance_candles(asset: str, tf: str, limit: int) -> List[Candle]
@router.get("/prices/{asset}", response_model=List[Candle]) @router.get("/prices/{asset}", response_model=List[Candle])
@limiter.limit("30/minute")
async def get_prices( async def get_prices(
request: Request,
asset: str, asset: str,
tf: str = Query(default="4H"), tf: str = Query(default="4H"),
limit: int = Query(default=200, ge=1, le=1000), limit: int = Query(default=200, ge=1, le=1000),
response: Response = None,
): ):
asset = asset.upper() asset = asset.upper()
if asset not in VALID_ASSETS: if asset not in VALID_ASSETS:
@@ -59,12 +67,18 @@ async def get_prices(
if tf not in VALID_TIMEFRAMES: if tf not in VALID_TIMEFRAMES:
raise HTTPException(status_code=400, detail=f"Timeframe must be one of {VALID_TIMEFRAMES}") raise HTTPException(status_code=400, detail=f"Timeframe must be one of {VALID_TIMEFRAMES}")
# 1m: use in-memory store (updated in real-time) # 1m: use in-memory store (updated in real-time) — don't cache at CDN level
if tf == "1m": if tf == "1m":
candles = price_store.get_candles(asset, "1m", limit) candles = price_store.get_candles(asset, "1m", limit)
if response is not None:
response.headers["Cache-Control"] = "public, max-age=5, stale-while-revalidate=10"
return [Candle(**c) for c in candles] return [Candle(**c) for c in candles]
# All other timeframes: fetch directly from Binance # Larger timeframes change infrequently — safe to cache longer
if response is not None:
ttl = 60 if tf in ("5m", "15m") else 300 # 1m for short TFs, 5m for 1H+
response.headers["Cache-Control"] = f"public, max-age={ttl}, stale-while-revalidate={ttl * 2}"
try: try:
return await fetch_binance_candles(asset, tf, limit) return await fetch_binance_candles(asset, tf, limit)
except Exception as exc: except Exception as exc:
+4 -3
View File
@@ -9,12 +9,13 @@ 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 from app.services.signed_request import verify_signed_request_any
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ACTION_VIEW_TRADES = "view_trades" ACTION_VIEW_TRADES = "view_trades"
ACTION_VIEW_USER = "view_user"
def _trade_to_schema(trade: BotTrade) -> BotTradeSchema: def _trade_to_schema(trade: BotTrade) -> BotTradeSchema:
@@ -54,8 +55,8 @@ async def get_trades(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
wallet = wallet.lower().strip() wallet = wallet.lower().strip()
verify_signed_request( verify_signed_request_any(
action=ACTION_VIEW_TRADES, actions=[ACTION_VIEW_TRADES, ACTION_VIEW_USER],
wallet=wallet, wallet=wallet,
timestamp_ms=ts, timestamp_ms=ts,
signature=sig, signature=sig,
+4 -3
View File
@@ -231,8 +231,8 @@ async def set_user_settings(
raise HTTPException(422, "min_confidence must be 0100") raise HTTPException(422, "min_confidence must be 0100")
if s.sys2_leverage is not None and not (1 <= s.sys2_leverage <= 10): if s.sys2_leverage is not None and not (1 <= s.sys2_leverage <= 10):
raise HTTPException(422, "sys2_leverage must be 110") raise HTTPException(422, "sys2_leverage must be 110")
if s.sys2_mode is not None and s.sys2_mode not in ("standard", "aggressive"): 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'") raise HTTPException(422, "sys2_mode must be 'standard', 'aggressive', or '' (reset)")
if s.daily_budget_usd is not None and not (0 < s.daily_budget_usd <= 100000): if s.daily_budget_usd is not None and not (0 < s.daily_budget_usd <= 100000):
raise HTTPException(422, "daily_budget_usd must be >0 and ≤100,000 if provided") raise HTTPException(422, "daily_budget_usd must be >0 and ≤100,000 if provided")
@@ -280,7 +280,8 @@ async def set_user_settings(
sub.daily_budget_usd = s.daily_budget_usd sub.daily_budget_usd = s.daily_budget_usd
sub.sys2_leverage = s.sys2_leverage sub.sys2_leverage = s.sys2_leverage
if s.sys2_mode is not None: if s.sys2_mode is not None:
sub.sys2_mode = s.sys2_mode # Empty string = reset to default; otherwise store the chosen mode.
sub.sys2_mode = "standard" if s.sys2_mode == "" else s.sys2_mode
sub.active_from = af sub.active_from = af
sub.active_until = au sub.active_until = au
if s.trump_enabled is not None: if s.trump_enabled is not None:
+11 -4
View File
@@ -6,10 +6,10 @@ class Settings(BaseSettings):
frontend_url: str = "http://localhost:3001" frontend_url: str = "http://localhost:3001"
truth_social_rss: str = "https://truthsocial.com/@realDonaldTrump.rss" truth_social_rss: str = "https://truthsocial.com/@realDonaldTrump.rss"
truth_social_poll_seconds: int = 15 truth_social_poll_seconds: int = 15
binance_ws_url: str = ( # WS URL is now built programmatically in binance.py from ASSET_MAP so the
"wss://data-stream.binance.vision/stream" # stream list and the routing table are always in sync. This setting is kept
"?streams=btcusdt@kline_1m/ethusdt@kline_1m" # for external callers that still reference it; binance.py ignores it.
) binance_ws_url: str = "wss://data-stream.binance.vision/stream" # base only
binance_rest_url: str = "https://data-api.binance.vision" binance_rest_url: str = "https://data-api.binance.vision"
environment: str = "production" environment: str = "production"
ai_api_key: str = "" ai_api_key: str = ""
@@ -38,6 +38,9 @@ class Settings(BaseSettings):
# in the X-Ingest-Key header. Empty (default) = ingest endpoint is REJECTED # in the X-Ingest-Key header. Empty (default) = ingest endpoint is REJECTED
# entirely until a key is configured (fail-closed). # entirely until a key is configured (fail-closed).
ingest_api_key: str = "" ingest_api_key: str = ""
# Base URL for internal signal ingest (used by scanners). In Docker, set to
# the service name (e.g. http://api:8000); bare-metal defaults to localhost.
ingest_base_url: str = "http://localhost:8000"
# Glassnode on-chain data. Used only by the BTC bottom-reversal state # Glassnode on-chain data. Used only by the BTC bottom-reversal state
# machine (MVRV-Z + STH-SOPR). Empty = scanner logs and skips fail-closed. # machine (MVRV-Z + STH-SOPR). Empty = scanner logs and skips fail-closed.
@@ -56,6 +59,10 @@ class Settings(BaseSettings):
# Bot username (no @) — used by the Settings UI to render the deep link # Bot username (no @) — used by the Settings UI to render the deep link
# t.me/<username>?start=<code>. Example: "trumpalpha_bot". # t.me/<username>?start=<code>. Example: "trumpalpha_bot".
telegram_bot_username: str = "" telegram_bot_username: str = ""
# Public broadcast channel chat_id (e.g. "-1001234567890" or "@trumpalpha").
# When set, every actionable signal is also posted here in sanitised form
# (no trading details, no /adopt CTA). Leave empty to disable.
telegram_public_channel_id: str = ""
model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"} model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"}
+25 -2
View File
@@ -3,8 +3,11 @@ import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address
from app.config import settings from app.config import settings
from app.database import AsyncSessionLocal, engine from app.database import AsyncSessionLocal, engine
@@ -12,6 +15,7 @@ from app.models import Base
from app.scrapers.truth_social import poll_truth_social, backfill_history from app.scrapers.truth_social import poll_truth_social, backfill_history
from app.scrapers.trumpstruth import poll_trumpstruth from app.scrapers.trumpstruth import poll_trumpstruth
from app.services.binance import run_binance_ws from app.services.binance import run_binance_ws
from app.services.hl_price_feed import run_hl_price_feed
from app.ws.manager import manager from app.ws.manager import manager
# Import all routers # Import all routers
@@ -38,13 +42,14 @@ logger = logging.getLogger(__name__)
from typing import Optional from typing import Optional
_binance_task: Optional[asyncio.Task] = None _binance_task: Optional[asyncio.Task] = None
_hl_price_task: Optional[asyncio.Task] = None
_telegram_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, _telegram_task, _scheduler global _binance_task, _hl_price_task, _telegram_task, _scheduler
# 1. Dev convenience only. Production should rely on Alembic so schema # 1. Dev convenience only. Production should rely on Alembic so schema
# ownership stays explicit and startup never mutates the DB implicitly. # ownership stays explicit and startup never mutates the DB implicitly.
@@ -67,6 +72,10 @@ async def lifespan(app: FastAPI):
_binance_task = asyncio.create_task(run_binance_ws(), name="binance_ws") _binance_task = asyncio.create_task(run_binance_ws(), name="binance_ws")
logger.info("Binance WebSocket task started.") logger.info("Binance WebSocket task started.")
# 3b. Supplemental HL price feed for assets not on Binance (HYPE, PURR, …)
_hl_price_task = asyncio.create_task(run_hl_price_feed(), name="hl_price_feed")
logger.info("HL supplemental price feed task started.")
# 3. Start Truth Social poller via APScheduler # 3. Start Truth Social poller via APScheduler
_scheduler = AsyncIOScheduler() _scheduler = AsyncIOScheduler()
# Signal monitor — polls every 5 minutes # Signal monitor — polls every 5 minutes
@@ -251,6 +260,12 @@ async def lifespan(app: FastAPI):
await _binance_task await _binance_task
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
if _hl_price_task and not _hl_price_task.done():
_hl_price_task.cancel()
try:
await _hl_price_task
except asyncio.CancelledError:
pass
if _telegram_task and not _telegram_task.done(): if _telegram_task and not _telegram_task.done():
_telegram_task.cancel() _telegram_task.cancel()
try: try:
@@ -261,12 +276,20 @@ async def lifespan(app: FastAPI):
logger.info("Shutdown complete.") logger.info("Shutdown complete.")
# Rate limiter — keyed by client IP (X-Forwarded-For → remote_addr fallback).
# Public read endpoints: 60 req/min. Signed mutations: 20 req/min (enforced
# per-route). Limits are generous enough for normal use but block scrapers
# and accidental polling loops.
limiter = Limiter(key_func=get_remote_address, default_limits=["60/minute"])
app = FastAPI( app = FastAPI(
title="TrumpSignal API", title="TrumpSignal API",
version="1.0.0", version="1.0.0",
description="Crypto trading signals derived from Trump's Truth Social posts.", description="Crypto trading signals derived from Trump's Truth Social posts.",
lifespan=lifespan, lifespan=lifespan,
) )
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# CORS # CORS
# In production we only allow the canonical frontend origin (FRONTEND_URL). # In production we only allow the canonical frontend origin (FRONTEND_URL).
+13
View File
@@ -8,6 +8,7 @@ from sqlalchemy import (
DateTime, DateTime,
Float, Float,
ForeignKey, ForeignKey,
Index,
Integer, Integer,
String, String,
Text, Text,
@@ -84,6 +85,12 @@ class Candle(Base):
class BotTrade(Base): class BotTrade(Base):
__tablename__ = "bot_trades" __tablename__ = "bot_trades"
__table_args__ = (
# Composite index for the two most common query patterns:
# circuit_breaker: WHERE wallet_address=? AND closed_at>=? AND pnl_usd IS NOT NULL
# trades API: WHERE wallet_address=? ORDER BY closed_at DESC
Index("ix_bot_trades_wallet_closed", "wallet_address", "closed_at"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
asset: Mapped[str] = mapped_column(String(8), nullable=False) asset: Mapped[str] = mapped_column(String(8), nullable=False)
@@ -242,6 +249,12 @@ class Subscription(Base):
# acknowledges + clears a tripped circuit breaker (human-in-the-loop). # acknowledges + clears a tripped circuit breaker (human-in-the-loop).
auto_trade: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) auto_trade: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# ── Module on/off toggles ───────────────────────────────────────────────
# Both default False so newly-subscribed users start with the bot idle
# until they explicitly enable a module and save settings.
trump_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
macro_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
class KolPost(Base): class KolPost(Base):
"""A long-form post (Substack) or tweet from a tracked KOL, with inline """A long-form post (Substack) or tweet from a tracked KOL, with inline
+1
View File
@@ -131,6 +131,7 @@ class UserSettings(BaseModel):
sys2_leverage: Optional[int] = None sys2_leverage: Optional[int] = None
# System-2 risk mode: "standard" (default) or "aggressive" (separately # System-2 risk mode: "standard" (default) or "aggressive" (separately
# funded high-risk/high-explosiveness sleeve). None → unchanged/standard. # funded high-risk/high-explosiveness sleeve). None → unchanged/standard.
# "standard" | "aggressive" | "" (empty string = reset to default "standard")
sys2_mode: Optional[str] = None 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
+16 -8
View File
@@ -112,12 +112,19 @@ async def _process_entry(entry: dict, db: AsyncSession) -> Optional[Post]:
analysis = await analyze_post(text) analysis = await analyze_post(text)
asset = analysis["asset"] asset = analysis["asset"]
# `tracked_asset`: the asset whose price impact we measure and display.
# Use target_asset (the perp we actually trade — may be SOL/TRUMP/etc.)
# when available; fall back to the sentiment asset (BTC/ETH) otherwise.
# Bug fix: previously always used `asset` (BTC/ETH), which measured the
# wrong price move when the bot traded a different perp.
tracked_asset = analysis.get("target_asset") or asset
# Only capture the price AT post time. The m5/m15/m1h peaks are filled in # Only capture the price AT post time. The m5/m15/m1h peaks are filled in
# asynchronously by price_impact_monitor as the windows elapse — avoids # asynchronously by price_impact_monitor as the windows elapse — avoids
# recording 0.00% because future candles don't exist yet at entry time. # recording 0.00% because future candles don't exist yet at entry time.
price_at_post = None price_at_post = None
if asset and analysis["relevant"]: if tracked_asset and analysis["relevant"]:
price_at_post = price_store.get_price_at(asset, published_at) price_at_post = price_store.get_price_at(tracked_asset, published_at)
post = Post( post = Post(
external_id=external_id, external_id=external_id,
@@ -131,8 +138,8 @@ async def _process_entry(entry: dict, db: AsyncSession) -> Optional[Post]:
prefilter_reason=analysis.get("prefilter_reason"), prefilter_reason=analysis.get("prefilter_reason"),
analysis_version=analysis.get("analysis_version"), analysis_version=analysis.get("analysis_version"),
relevant=analysis["relevant"], relevant=analysis["relevant"],
# `asset` (BTC/ETH only) feeds the existing price_impact tracker. # Track the actually-traded asset (target_asset ?? sentiment_asset).
price_impact_asset=asset if analysis["relevant"] else None, price_impact_asset=tracked_asset if analysis["relevant"] else None,
price_impact_m5=None, # filled by price_impact_monitor after 5 m price_impact_m5=None, # filled by price_impact_monitor after 5 m
price_impact_m15=None, # filled by price_impact_monitor after 15 m price_impact_m15=None, # filled by price_impact_monitor after 15 m
price_impact_m1h=None, # filled by price_impact_monitor after 1 h price_impact_m1h=None, # filled by price_impact_monitor after 1 h
@@ -146,11 +153,11 @@ async def _process_entry(entry: dict, db: AsyncSession) -> Optional[Post]:
await db.flush() await db.flush()
# Register with the live peak tracker so it starts watching immediately. # Register with the live peak tracker so it starts watching immediately.
if asset and analysis["relevant"] and price_at_post: if tracked_asset and analysis["relevant"] and price_at_post:
from app.services.price_impact_monitor import register_post from app.services.price_impact_monitor import register_post
register_post( register_post(
post_id=post.id, post_id=post.id,
asset=asset, asset=tracked_asset,
signal=analysis.get("signal"), signal=analysis.get("signal"),
entry_price=price_at_post, entry_price=price_at_post,
published_at=published_at, published_at=published_at,
@@ -219,8 +226,9 @@ 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 # Telegram fan-out (fire-and-forget). _dispatch filters
# signal is buy/short; noise posts are filtered inside. # internally: buy/short → per-subscriber + public channel;
# relevant-but-hold → public channel only; noise → dropped.
try: try:
from app.services.telegram import notify_signal from app.services.telegram import notify_signal
notify_signal(post) notify_signal(post)
+31 -2
View File
@@ -31,9 +31,10 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from collections import OrderedDict
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Dict, Optional from typing import Optional
from sqlalchemy import select, update from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -66,14 +67,28 @@ ADOPTED_CATEGORY = "btc_bottom_reversal_long"
# (each row would race to reduce the position). Process-local; with the # (each row would race to reduce the position). Process-local; with the
# atomic duplicate check inside the lock, multi-process deploys are still # atomic duplicate check inside the lock, multi-process deploys are still
# safe (losers would just see "already_adopted" on the second attempt). # safe (losers would just see "already_adopted" on the second attempt).
_adopt_locks: Dict[str, asyncio.Lock] = {} #
# Capacity-capped at _ADOPT_LOCK_MAX (matches _WALLET_LOCK_MAX in bot_engine)
# to prevent unbounded growth if many unique wallets pass through. LRU
# eviction: evicting an in-flight lock is safe — the holder already holds a
# reference; new adopt for that wallet just gets a fresh lock.
_ADOPT_LOCK_MAX = 512
_adopt_locks: OrderedDict[str, asyncio.Lock] = OrderedDict()
def _wallet_adopt_lock(wallet: str) -> asyncio.Lock: def _wallet_adopt_lock(wallet: str) -> asyncio.Lock:
lock = _adopt_locks.get(wallet) lock = _adopt_locks.get(wallet)
if lock is None: if lock is None:
if len(_adopt_locks) >= _ADOPT_LOCK_MAX:
# Evict LRU entry. The evicted lock object stays alive as long as
# any coroutine holds a reference to it — eviction only means it
# won't be reused, not that it's freed mid-acquire.
_adopt_locks.popitem(last=False)
lock = asyncio.Lock() lock = asyncio.Lock()
_adopt_locks[wallet] = lock _adopt_locks[wallet] = lock
else:
# Promote to MRU position.
_adopt_locks.move_to_end(wallet)
return lock return lock
@@ -311,6 +326,20 @@ async def _adopt_locked(wallet_l: str, asset_u: str, mode_n: str) -> AdoptionRes
lev_info = target.get("leverage") or {} lev_info = target.get("leverage") or {}
leverage = int(lev_info.get("value") or sub.leverage or 2) leverage = int(lev_info.get("value") or sub.leverage or 2)
# BUG-09 guard: sys2_protective_stop_pct clamps leverage to SYS2_MAX_LEVERAGE
# when computing the stop distance. If the actual position is above that cap
# the computed stop is WIDER than the real liquidation band — the user could
# get HL-liquidated before our stop fires. Reject up front with a clear
# error so the user knows to reduce leverage on HL before adopting.
if leverage > SYS2_MAX_LEVERAGE:
raise AdoptionError(
"leverage_too_high",
f"{asset_u} position is at {leverage}× leverage. "
f"The bot's System-2 strategy supports up to {SYS2_MAX_LEVERAGE}×. "
f"Reduce leverage on Hyperliquid to ≤{SYS2_MAX_LEVERAGE}× first, "
"then try /adopt again.",
)
# Build the System-2 exit profile against the ACTUAL HL leverage so the # Build the System-2 exit profile against the ACTUAL HL leverage so the
# protective stop is always inside the real liquidation line. Identical # protective stop is always inside the real liquidation line. Identical
# math to bot_engine's old sys2 open path; reused so behaviour stays # math to bot_engine's old sys2 open path; reused so behaviour stays
+6 -2
View File
@@ -307,8 +307,9 @@ HARD CONSTRAINTS on the JSON:
USER_PROMPT_TEMPLATE = """\ USER_PROMPT_TEMPLATE = """\
Trump just posted on Truth Social. Score it for an immediate BTC/ETH Trump just posted on Truth Social. Score it for an immediate crypto
trade per your system rules. trade per your system rules. The tradeable universe includes BTC, ETH,
SOL, named alts, and meme coins — not just BTC/ETH.
Current UTC hour: {hour} ({liquidity_note}) Current UTC hour: {hour} ({liquidity_note})
@@ -344,6 +345,9 @@ HL_PERPS = {
"BTC", "ETH", "SOL", "AVAX", "ARB", "OP", "DOGE", "SHIB", "PEPE", "BTC", "ETH", "SOL", "AVAX", "ARB", "OP", "DOGE", "SHIB", "PEPE",
"WIF", "TRUMP", "SUI", "APT", "INJ", "ATOM", "ADA", "MATIC", "WIF", "TRUMP", "SUI", "APT", "INJ", "ATOM", "ADA", "MATIC",
"FARTCOIN", "BNB", "LINK", "LTC", "XRP", "FARTCOIN", "BNB", "LINK", "LTC", "XRP",
# Added 2025-2026: Hyperliquid native + high-volume memes/alts
"HYPE", "BONK", "RENDER", "FET", "TAO", "JUP", "PYTH", "TIA",
"SEI", "STRK", "MANTA", "ALT", "PIXEL", "PORTAL", "PENDLE",
} }
# Chain → its native token's HL ticker. Used when a meme isn't on HL — # Chain → its native token's HL ticker. Used when a meme isn't on HL —
+37 -6
View File
@@ -11,11 +11,38 @@ from app.ws.manager import manager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ASSET_MAP = { # symbol (lowercase Binance ticker) → asset (HL perp name, uppercase).
#
# WHY THIS MATTERS: tp_sl_monitor.on_price_tick(asset, price) is the sole
# mechanism that fires stop-loss, take-profit, and trailing-stop for live
# trades. If an asset is missing here, every trade on that asset silently
# loses all TP/SL protection — only the asyncio max-hold timer remains.
#
# Trump's AI picks target_asset from the full HL perp universe. The assets
# below cover the realistic set (BTC/ETH always, SOL/TRUMP/BNB/DOGE/LINK/AAVE
# for direct-named or thematic posts). HL-native assets (HYPE, PURR) are NOT
# on Binance; those require a separate HL price feed (see BUG-08 note in CLAUDE.md).
# Until that feed is added, HYPE trades fall back to max-hold only.
ASSET_MAP: dict[str, str] = {
"btcusdt": "BTC", "btcusdt": "BTC",
"ethusdt": "ETH", "ethusdt": "ETH",
"solusdt": "SOL",
"trumpusdt": "TRUMP",
"bnbusdt": "BNB",
"dogeusdt": "DOGE",
"linkusdt": "LINK",
"aaveusdt": "AAVE",
} }
# Build the combined-stream WS URL from ASSET_MAP so the two are always in sync.
# Format: wss://...?streams=sym1@kline_1m/sym2@kline_1m/...
_BINANCE_WS_BASE = "wss://data-stream.binance.vision/stream"
BINANCE_WS_URL = (
_BINANCE_WS_BASE
+ "?streams="
+ "/".join(f"{sym}@kline_1m" for sym in ASSET_MAP)
)
async def _process_message(raw: str): async def _process_message(raw: str):
try: try:
@@ -98,16 +125,20 @@ async def run_binance_ws():
* max_queue — drop frames if downstream can't keep up rather than back * max_queue — drop frames if downstream can't keep up rather than back
the WS receive buffer up indefinitely the WS receive buffer up indefinitely
""" """
# Pre-fill with historical data # Pre-fill historical candles for every tracked asset so price_store has
await fetch_historical("BTC", "btcusdt", limit=500) # a warm baseline before the first live tick arrives.
await fetch_historical("ETH", "ethusdt", limit=500) await asyncio.gather(*(
fetch_historical(asset, symbol, limit=500)
for symbol, asset in ASSET_MAP.items()
), return_exceptions=True)
backoff = 1 backoff = 1
while True: while True:
try: try:
logger.info("Connecting to Binance WebSocket: %s", settings.binance_ws_url) logger.info("Connecting to Binance WebSocket (%d assets): %s",
len(ASSET_MAP), BINANCE_WS_URL)
async with websockets.connect( async with websockets.connect(
settings.binance_ws_url, BINANCE_WS_URL,
ping_interval=20, ping_interval=20,
ping_timeout=10, ping_timeout=10,
close_timeout=5, close_timeout=5,
+111 -18
View File
@@ -46,12 +46,19 @@ _background_tasks: set = set()
# Per-wallet locks for the open-position critical section. # Per-wallet locks for the open-position critical section.
# Prevents two concurrent signals from both passing the daily-budget check # Prevents two concurrent signals from both passing the daily-budget check
# before either trade is written to DB (TOCTOU race). # before either trade is written to DB (TOCTOU race).
# Cap at 512 wallets; evict the oldest entry when full (simple FIFO approximation
# — a wallet that hasn't traded in a long time doesn't need a warm lock).
_WALLET_LOCK_MAX = 512
_wallet_open_locks: Dict[str, asyncio.Lock] = {} _wallet_open_locks: Dict[str, asyncio.Lock] = {}
def _wallet_lock(wallet: str) -> asyncio.Lock: def _wallet_lock(wallet: str) -> asyncio.Lock:
lock = _wallet_open_locks.get(wallet) lock = _wallet_open_locks.get(wallet)
if lock is None: if lock is None:
if len(_wallet_open_locks) >= _WALLET_LOCK_MAX:
# Evict the first (oldest) key — dict preserves insertion order in Python 3.7+
oldest = next(iter(_wallet_open_locks))
del _wallet_open_locks[oldest]
lock = asyncio.Lock() lock = asyncio.Lock()
_wallet_open_locks[wallet] = lock _wallet_open_locks[wallet] = lock
return lock return lock
@@ -375,13 +382,21 @@ async def _execute_for_subscriber(
# 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.
# Budget is SPLIT between the two systems so a Trump scalp can't eat # System-2 is now MANAGE-ONLY (no auto-open), so the budget split is
# the allocation reserved for a once-a-year reversal signal. # only meaningful when a sys2 auto-open path could actually run. For
# System-1 (Trump), give the full daily budget — sys2_pct is no longer
# "reserved" for auto-opens that can't happen. Users who want a tighter
# Trump cap can lower daily_budget_usd directly.
total_cap = sub.get("daily_budget_usd") total_cap = sub.get("daily_budget_usd")
if total_cap is not None and total_cap > 0: if total_cap is not None and total_cap > 0:
sys2_pct = sub.get("sys2_budget_pct", 0.7) sys2_pct = sub.get("sys2_budget_pct", 0.7)
is_s2 = bool(sub.get("_is_system_2")) is_s2 = bool(sub.get("_is_system_2"))
daily_cap = total_cap * (sys2_pct if is_s2 else (1.0 - sys2_pct)) # sys2 auto-opens are disabled; no System-2 trades will ever run
# this code path (process_post returns early for sys2 sources).
# Keep the split logic here so it would still work correctly if
# sys2 auto-open is ever re-enabled via an ADR, but for sys1
# (Trump) use the full cap rather than the 30% remainder.
daily_cap = total_cap * (sys2_pct if is_s2 else 1.0)
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.models import Post as _P
from app.services.signal_categories import SYSTEM_2_SOURCES from app.services.signal_categories import SYSTEM_2_SOURCES
@@ -404,7 +419,7 @@ async def _execute_for_subscriber(
if spent + sized_position_usd > daily_cap: if spent + sized_position_usd > daily_cap:
logger.info("Sub %s [%s] daily budget reached: spent=%.2f + new=%.2f > cap=%.2f (%.0f%% of %.2f)", 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, wallet, _system, spent, sized_position_usd, daily_cap,
(sys2_pct if is_s2 else 1.0 - sys2_pct) * 100, total_cap) (sys2_pct if is_s2 else 100.0), total_cap)
return return
# ── System-2 correlation / concentration cap ─────────────────────── # ── System-2 correlation / concentration cap ───────────────────────
@@ -685,12 +700,35 @@ async def partial_derisk(
# `frac_of_original` of the ORIGINAL notional. # `frac_of_original` of the ORIGINAL notional.
frac_of_current = max(0.0, min(1.0, frac_of_original / rem_frac)) frac_of_current = max(0.0, min(1.0, frac_of_original / rem_frac))
# ── Pre-claim the step BEFORE touching HL (BUG-03 analog) ───
# If we call HL first and THEN commit, a db.commit() failure
# leaves derisk_steps_done unchanged → next tick re-triggers
# reduce_position on a SMALLER position → double-reduce.
# Pre-claiming burns the step on DB failure but that's safer
# than over-reducing a live position. A burned step leaves
# the position slightly larger than the ladder intended but
# the NEXT rung still fires correctly.
claim = await db.execute(
update(BotTrade)
.where(BotTrade.id == trade_id)
.where(BotTrade.derisk_steps_done == step_idx)
.values(derisk_steps_done=step_idx + 1)
)
await db.commit()
if claim.rowcount == 0:
# Another coroutine already advanced past this step.
return True
if trade.hl_order_id == "paper": if trade.hl_order_id == "paper":
from app.services.price_store import price_store from app.services.price_store import price_store
fill = price_store.latest_price(asset) fill = price_store.latest_price(asset)
if not fill: if not fill:
logger.error("Paper de-risk: no %s price, retry trade %d", asset, trade_id) logger.error("Paper de-risk: no %s price, skip trade %d step %d",
return False asset, trade_id, step_idx)
# Step already claimed — don't retry (would skip the
# next rung). Remaining_fraction stays at pre-reduce
# value; reconciler will catch the drift if material.
return True
closed_frac_of_current = frac_of_current closed_frac_of_current = frac_of_current
else: else:
trader = HyperliquidTrader( trader = HyperliquidTrader(
@@ -708,7 +746,16 @@ async def partial_derisk(
fill = r.get("fill_price") fill = r.get("fill_price")
closed_frac_of_current = r.get("closed_fraction") or 0.0 closed_frac_of_current = r.get("closed_fraction") or 0.0
if not fill or closed_frac_of_current <= 0: if not fill or closed_frac_of_current <= 0:
return False # no fill — retry next tick # HL returned no fill — position is unchanged on HL but
# derisk_steps_done is already incremented. Log loudly
# so the operator can investigate; returning True skips
# a double-reduce at the cost of this rung being silent.
logger.error(
"De-risk step %d trade %d: HL returned no fill "
"(step already claimed). Position unchanged on HL.",
step_idx, trade_id,
)
return True
# Fraction of ORIGINAL notional actually shed this step. # Fraction of ORIGINAL notional actually shed this step.
closed_frac_of_original = closed_frac_of_current * rem_frac closed_frac_of_original = closed_frac_of_current * rem_frac
@@ -719,16 +766,27 @@ async def partial_derisk(
fees = slice_usd * HL_TAKER_FEE_RATE * 2 fees = slice_usd * HL_TAKER_FEE_RATE * 2
slice_pnl = slice_usd * signed_pct - fees slice_pnl = slice_usd * signed_pct - fees
trade.realized_partial_pnl_usd = round((trade.realized_partial_pnl_usd or 0.0) + slice_pnl, 4) # Second DB write: update PnL + remaining fraction now that
trade.remaining_fraction = max(0.0, round(rem_frac - closed_frac_of_original, 6)) # we know the actual fill. derisk_steps_done is already committed.
trade.derisk_steps_done = step_idx + 1 async with AsyncSessionLocal() as post_db:
await db.commit() post_row = await post_db.execute(
select(BotTrade).where(BotTrade.id == trade_id)
)
post_trade = post_row.scalar_one_or_none()
if post_trade is not None and post_trade.closed_at is None:
post_trade.realized_partial_pnl_usd = round(
(post_trade.realized_partial_pnl_usd or 0.0) + slice_pnl, 4
)
post_trade.remaining_fraction = max(
0.0, round((post_trade.remaining_fraction or 1.0) - closed_frac_of_original, 6)
)
await post_db.commit()
logger.warning( logger.warning(
"De-risk step %d trade %d (%s): closed %.1f%% of original " "De-risk step %d trade %d (%s): closed %.1f%% of original "
"@ %.2f, slice PnL %.2f, remaining %.1f%% (reason=%s)", "@ %.2f, slice PnL %.2f (reason=%s)",
step_idx + 1, trade_id, asset, closed_frac_of_original * 100, step_idx + 1, trade_id, asset, closed_frac_of_original * 100,
fill, slice_pnl, trade.remaining_fraction * 100, reason, fill, slice_pnl, reason,
) )
return True return True
except Exception as e: except Exception as e:
@@ -799,6 +857,20 @@ async def pyramid_add(
if not trend_confirmed(closes, highs, px): if not trend_confirmed(closes, highs, px):
return (False, None, None) # not a confirmed uptrend yet return (False, None, None) # not a confirmed uptrend yet
# Pre-claim the step BEFORE touching HL (BUG-03 analog).
# Prevents double-add when HL open_position succeeds but the
# subsequent DB commit fails — the next retrigger sees the step
# already claimed and returns early instead of re-opening.
claim = await db.execute(
update(BotTrade)
.where(BotTrade.id == trade_id)
.where(BotTrade.addon_steps_done == step_idx)
.values(addon_steps_done=step_idx + 1)
)
await db.commit()
if claim.rowcount == 0:
return (True, None, None) # already claimed by another coroutine
if trade.hl_order_id == "paper": if trade.hl_order_id == "paper":
fill = px fill = px
actual_add_usd = add_usd # synthetic full fill actual_add_usd = add_usd # synthetic full fill
@@ -822,9 +894,15 @@ async def pyramid_add(
old_notional = trade.size_usd or base old_notional = trade.size_usd or base
new_notional = old_notional + actual_add_usd new_notional = old_notional + actual_add_usd
blended = (old_notional * trade.entry_price + actual_add_usd * fill) / new_notional blended = (old_notional * trade.entry_price + actual_add_usd * fill) / new_notional
trade.entry_price = round(blended, 6) # addon_steps_done already committed via pre-claim above
trade.size_usd = round(new_notional, 2) await db.execute(
trade.addon_steps_done = step_idx + 1 update(BotTrade)
.where(BotTrade.id == trade_id)
.values(
entry_price=round(blended, 6),
size_usd=round(new_notional, 2),
)
)
await db.commit() await db.commit()
logger.warning( logger.warning(
@@ -884,6 +962,9 @@ async def close_and_finalize(
# Either someone else closed it, or (automated path) the # Either someone else closed it, or (automated path) the
# user released it between the price-tick capture and # user released it between the price-tick capture and
# this close attempt. Either way: bail without touching HL. # this close attempt. Either way: bail without touching HL.
# Clean up the lock we just created so _close_locks doesn't
# accumulate one entry per losing-racer per trade_id.
_close_locks.pop(trade_id, None)
return return
# Reload the row we just claimed # Reload the row we just claimed
@@ -1001,10 +1082,22 @@ async def close_and_finalize(
# Run after PnL is written. If daily DD or consecutive-loss # Run after PnL is written. If daily DD or consecutive-loss
# threshold hit, trip the breaker — that nulls manual_window # threshold hit, trip the breaker — that nulls manual_window
# and blocks new trades for CB_LOCKOUT_HOURS. # and blocks new trades for CB_LOCKOUT_HOURS.
# Infer which system this trade belonged to from its trigger # Infer which system this trade belonged to so the right
# post's source, so the right (independent) breaker is checked. # independent circuit breaker is checked.
#
# Three hl_order_id shapes:
# - integer string → System-1 auto-open (Trump)
# - "paper" → paper-mode (System-1 or legacy sys2)
# - "adopted:<ts>" → System-2 adopted position
# Adopted trades have trigger_post_id=NULL, so querying
# Post.source returns nothing and would incorrectly identify
# them as sys1. Check the hl_order_id prefix FIRST.
from app.services.circuit_breaker import check_and_trip from app.services.circuit_breaker import check_and_trip
from app.services.signal_categories import SYSTEM_2_SOURCES from app.services.signal_categories import SYSTEM_2_SOURCES
_is_adopted = (trade.hl_order_id or "").startswith("adopted:")
if _is_adopted or trade.sys2_mode is not None:
_sys = "sys2"
else:
_src_row = await db.execute( _src_row = await db.execute(
select(Post.source).where(Post.id == trade.trigger_post_id) select(Post.source).where(Post.id == trade.trigger_post_id)
) )
+19 -6
View File
@@ -61,12 +61,17 @@ _CB_COLS = {
} }
async def _trades_for_system(db: AsyncSession, wallet: str, since, system: str): async def _trades_for_system(db: AsyncSession, wallet: str, since, system: str, limit: int | None = None):
"""Closed, priced trades for `wallet` since `since`, filtered to the """Closed, priced trades for `wallet` since `since`, filtered to the
given system by joining the trigger post's source. given system by joining the trigger post's source.
sys2 = trigger_post.source in System-2 set; sys1 = everything else sys2 = trigger_post.source in System-2 set OR adopted trade (identified
(currently only "truth"). Trades with no trigger post → treated sys1. by hl_order_id starting with "adopted:" or sys2_mode being non-NULL).
sys1 = everything else (currently only "truth").
Important: adopted trades have trigger_post_id=NULL so their Post join
returns NULL source — they must be identified by the trade row itself,
not the linked post.
""" """
from app.models import Post from app.models import Post
from app.services.signal_categories import SYSTEM_2_SOURCES from app.services.signal_categories import SYSTEM_2_SOURCES
@@ -80,10 +85,17 @@ async def _trades_for_system(db: AsyncSession, wallet: str, since, system: str):
BotTrade.pnl_usd.is_not(None), BotTrade.pnl_usd.is_not(None),
) )
.order_by(BotTrade.closed_at.desc()) .order_by(BotTrade.closed_at.desc())
.limit(limit * 4 if limit else None) # fetch a small multiple to account for system filtering
) )
out = [] out = []
for trade, src in rows.all(): for trade, src in rows.all():
is_s2 = (src or "").lower() in SYSTEM_2_SOURCES # Adopted trades: trigger_post_id=NULL, hl_order_id starts with "adopted:"
# or sys2_mode is set. Check trade row directly before falling back to source.
is_s2 = (
(trade.hl_order_id or "").startswith("adopted:")
or trade.sys2_mode is not None
or (src or "").lower() in SYSTEM_2_SOURCES
)
if (system == "sys2") == is_s2: if (system == "sys2") == is_s2:
out.append(trade) out.append(trade)
return out return out
@@ -124,9 +136,10 @@ async def check_and_trip(
system, wallet, today_pnl, dd_limit) system, wallet, today_pnl, dd_limit)
if reason is None: if reason is None:
# Consecutive losses within this system (all-time, most recent N). # Consecutive losses within this system — only need the most recent
# CB_CONSEC_LOSSES trades; no reason to scan full history.
all_sys = await _trades_for_system( all_sys = await _trades_for_system(
db, wallet, datetime(1970, 1, 1), system, db, wallet, datetime(1970, 1, 1), system, limit=CB_CONSEC_LOSSES,
) )
recent = all_sys[:CB_CONSEC_LOSSES] recent = all_sys[:CB_CONSEC_LOSSES]
if len(recent) >= CB_CONSEC_LOSSES and all((t.pnl_usd or 0) < 0 for t in recent): if len(recent) >= CB_CONSEC_LOSSES and all((t.pnl_usd or 0) < 0 for t in recent):
+139
View File
@@ -0,0 +1,139 @@
"""Supplemental price feed for HL-native assets not listed on Binance.
Polls HL allMids endpoint every 2 seconds and pumps price_store +
tp_sl_monitor for assets in HL_PRICE_ASSETS. Runs alongside run_binance_ws().
WHY THIS EXISTS:
tp_sl_monitor.on_price_tick(asset, price) is the sole mechanism that fires
stop-loss, take-profit, and trailing-stop for live bot trades.
HL-native assets (HYPE, PURR, …) are not listed on Binance, so the
Binance WebSocket in binance.py cannot provide their prices.
Without this feed, any bot trade on an HL-native asset silently loses ALL
TP/SL protection — only the max_hold asyncio timer remains as an exit.
COVERAGE:
• Binance WS → BTC, ETH, SOL, TRUMP, BNB, DOGE, LINK, AAVE (ASSET_MAP)
• This feed → HYPE, PURR (HL_PRICE_ASSETS)
Adding a new HL-native asset:
1. Add its symbol (uppercase) to HL_PRICE_ASSETS below.
2. Verify the symbol appears in HL allMids by running:
curl -s -X POST https://api.hyperliquid.xyz/info \\
-H 'Content-Type: application/json' \\
-d '{"type":"allMids"}' | python3 -m json.tool | grep HYPE
"""
from __future__ import annotations
import asyncio
import logging
import time
from typing import Optional
import httpx
from app.services.price_store import price_store
from app.ws.manager import manager
logger = logging.getLogger(__name__)
HL_API_URL = "https://api.hyperliquid.xyz/info"
# HL-native assets not covered by the Binance WS. Add new ones here as needed.
HL_PRICE_ASSETS: frozenset[str] = frozenset({"HYPE", "PURR"})
# Poll cadence — 2 s gives ~same freshness as a Binance 1-min kline close
# without hammering the free HL REST endpoint (30 req/min well within limit).
_POLL_INTERVAL = 2.0
# Exponential backoff caps: start at 5 s, max at 60 s, reset on clean loop.
_BACKOFF_START = 5
_BACKOFF_MAX = 60
async def run_hl_price_feed() -> None:
"""Long-running loop. Retries with exponential backoff on connection failure.
Designed to be created once as an asyncio task at startup and cancelled at
shutdown — matches the run_binance_ws() lifecycle pattern."""
logger.info("HL supplemental price feed starting for %s", sorted(HL_PRICE_ASSETS))
backoff = _BACKOFF_START
while True:
try:
await _poll_loop()
except asyncio.CancelledError:
logger.info("HL price feed cancelled — shutting down.")
return
except Exception as exc:
logger.warning(
"HL price feed outer error: %s — retrying in %ds", exc, backoff
)
await asyncio.sleep(backoff)
backoff = min(backoff * 2, _BACKOFF_MAX)
else:
backoff = _BACKOFF_START # reset on clean exit (shouldn't happen normally)
async def _poll_loop() -> None:
"""Inner loop: tick every 2 s. Individual tick failures are swallowed so
a transient HTTP 5xx doesn't break the whole loop."""
while True:
try:
await _tick()
except asyncio.CancelledError:
raise
except Exception as exc:
# Transient: network blip, HL rate limit, malformed JSON.
logger.debug("HL price tick error (suppressed): %s", exc)
await asyncio.sleep(_POLL_INTERVAL)
async def _tick() -> None:
"""Single price fetch + dispatch cycle for all HL_PRICE_ASSETS."""
now_ms = int(time.time() * 1000)
async with httpx.AsyncClient(timeout=4.0) as c:
r = await c.post(HL_API_URL, json={"type": "allMids"})
r.raise_for_status()
mids: dict = r.json() # {"BTC": "74541.0", "HYPE": "13.5", …}
for asset in HL_PRICE_ASSETS:
raw: Optional[str] = mids.get(asset)
if raw is None:
# Asset not listed on HL yet — skip silently.
continue
try:
price = float(raw)
except (TypeError, ValueError):
logger.warning("HL price feed: bad value for %s: %r", asset, raw)
continue
if price <= 0:
continue
# Synthetic 1-minute candle aligned to the current minute bucket.
# All OHLC set to the mid price (no spread data from allMids).
# price_store.update() handles the same-bucket replace logic.
candle = {
"time": (now_ms // 60_000) * 60_000,
"open": price,
"high": price,
"low": price,
"close": price,
"volume": 0.0,
}
price_store.update(asset, candle)
# TP/SL + trailing-stop evaluation on every tick.
from app.services.tp_sl_monitor import on_price_tick
on_price_tick(asset, price)
# Price-impact peak tracker for Trump-post windows.
from app.services.price_impact_monitor import on_price_tick as pi_tick
pi_tick(asset, price, price, price)
# Live price broadcast to the frontend dashboard.
await manager.broadcast({
"type": "price",
"asset": asset,
"price": price,
"time": now_ms,
})
+38 -7
View File
@@ -12,6 +12,7 @@ Both are read from config (hl_account_address, hl_api_private_key).
import asyncio import asyncio
import logging import logging
import time
from functools import partial from functools import partial
from typing import Optional from typing import Optional
@@ -28,6 +29,17 @@ HL_TESTNET_URL = "https://api.hyperliquid-testnet.xyz"
# Fetched dynamically via meta() but cached here as fallback # Fetched dynamically via meta() but cached here as fallback
SZ_DECIMALS_FALLBACK = {"BTC": 5, "ETH": 4} SZ_DECIMALS_FALLBACK = {"BTC": 5, "ETH": 4}
# Module-level cache: coin → (szDecimals, expiry_timestamp)
_SZ_DECIMALS_CACHE: dict[str, tuple[int, float]] = {}
_SZ_DECIMALS_TTL = 300 # 5 minutes
# Max-leverage cache: coin → (maxLeverage, expiry_timestamp).
# meta() is a full-universe call (~1 KB JSON), cheap but wasteful on every
# trade open when the leverage caps change at most once a month. 5-min TTL
# matches _SZ_DECIMALS_TTL so a single meta() response can refresh both.
_MAX_LEV_CACHE: dict[str, tuple[int, float]] = {}
_MAX_LEV_TTL = 300 # 5 minutes
class HyperliquidTrader: class HyperliquidTrader:
def __init__( def __init__(
@@ -65,11 +77,18 @@ class HyperliquidTrader:
return await loop.run_in_executor(None, partial(fn, *args, **kwargs)) return await loop.run_in_executor(None, partial(fn, *args, **kwargs))
async def _get_sz_decimals(self, coin: str) -> int: async def _get_sz_decimals(self, coin: str) -> int:
now = time.monotonic()
cached = _SZ_DECIMALS_CACHE.get(coin)
if cached is not None and now < cached[1]:
return cached[0]
try: try:
meta = await self._run(self._info.meta) meta = await self._run(self._info.meta)
for u in meta.get("universe", []): for u in meta.get("universe", []):
if u.get("name") == coin: name = u.get("name")
return int(u.get("szDecimals", SZ_DECIMALS_FALLBACK.get(coin, 4))) val = int(u.get("szDecimals", SZ_DECIMALS_FALLBACK.get(name, 4)))
_SZ_DECIMALS_CACHE[name] = (val, now + _SZ_DECIMALS_TTL)
if coin in _SZ_DECIMALS_CACHE:
return _SZ_DECIMALS_CACHE[coin][0]
except Exception: except Exception:
pass pass
return SZ_DECIMALS_FALLBACK.get(coin, 4) return SZ_DECIMALS_FALLBACK.get(coin, 4)
@@ -77,14 +96,26 @@ class HyperliquidTrader:
async def _get_max_leverage(self, coin: str) -> int: async def _get_max_leverage(self, coin: str) -> int:
"""Hyperliquid caps max leverage per asset (BTC/ETH 50×, SOL 20×, """Hyperliquid caps max leverage per asset (BTC/ETH 50×, SOL 20×,
memes typically 3-5×). Querying meta() returns each asset's memes typically 3-5×). Querying meta() returns each asset's
`maxLeverage`. We cache nothing — meta() is fast and HL can change `maxLeverage`. Returns a conservative 3 if lookup fails (memes default).
tiers. Returns a conservative 3 if lookup fails (memes default)."""
Results are cached for _MAX_LEV_TTL seconds (same TTL as szDecimals).
meta() returns the full universe in one call so we populate all coins
on a cache miss to amortise the cost across concurrent opens.
"""
import time as _time
now = _time.time()
cached = _MAX_LEV_CACHE.get(coin)
if cached is not None and cached[1] > now:
return cached[0]
try: try:
meta = await self._run(self._info.meta) meta = await self._run(self._info.meta)
expiry = now + _MAX_LEV_TTL
for u in meta.get("universe", []): for u in meta.get("universe", []):
if u.get("name") == coin: name = u.get("name")
ml = int(u.get("maxLeverage", 3)) if name:
return max(1, ml) _MAX_LEV_CACHE[name] = (max(1, int(u.get("maxLeverage", 3))), expiry)
if coin in _MAX_LEV_CACHE:
return _MAX_LEV_CACHE[coin][0]
except Exception as exc: except Exception as exc:
logger.warning("_get_max_leverage failed for %s: %s", coin, exc) logger.warning("_get_max_leverage failed for %s: %s", coin, exc)
return 3 # safe fallback for unknown / illiquid coin return 3 # safe fallback for unknown / illiquid coin
+35 -9
View File
@@ -4,8 +4,9 @@ Takes a long-form post (Substack essay) or tweet and returns:
- summary: one Chinese sentence on what this post is about - summary: one Chinese sentence on what this post is about
- tickers: list of {ticker, action, conviction, quote} - tickers: list of {ticker, action, conviction, quote}
action ∈ bullish | bearish | buy | sell | mention action ∈ bullish | bearish | buy | sell | reduce | mention
- buy/sell → KOL explicitly states they bought/sold or are entering/exiting - buy/sell → KOL explicitly states they bought/sold or are entering/exiting
- reduce → KOL is partially exiting / taking profits on an existing long
- bullish/bearish → directional view without an explicit position statement - bullish/bearish → directional view without an explicit position statement
- mention → ticker appears but no clear stance (don't flood with these) - mention → ticker appears but no clear stance (don't flood with these)
@@ -63,32 +64,46 @@ def _oai() -> AsyncOpenAI:
return _openai_client return _openai_client
SYSTEM_PROMPT = """You are an analyst extracting tradeable signals from crypto KOL posts. SYSTEM_PROMPT = """You are an analyst extracting tradeable signals from crypto KOL (Key Opinion Leader) 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). 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). Pay special attention to DIVERGENCE between what they say publicly and what they imply about their actual position — this is the platform's highest-conviction signal.
Output **strict JSON only**, no markdown, no preface. Schema: 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 用英文).>", "summary": "<one sentence in ENGLISH, ≤80 chars. State the author's current market thesis if they have one, or describe the post topic if no clear signal. Always English regardless of the post's original language.>",
"tickers": [ "tickers": [
{ {
"ticker": "<UPPERCASE symbol, e.g. BTC, ETH, HYPE, SOL>", "ticker": "<UPPERCASE symbol, e.g. BTC, ETH, HYPE, SOL>",
"action": "buy" | "sell" | "bullish" | "bearish" | "mention", "action": "buy" | "sell" | "bullish" | "bearish" | "reduce" | "mention",
"conviction": <float 0.0-1.0>, "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.>" "timeframe": "immediate" | "days" | "weeks" | "months" | "unspecified",
"stance_change": <true if this contradicts or reverses what the author said in earlier parts of the same post, or walks back a previously stated position | false>,
"quote": "<shortest verbatim sentence from the post supporting this call, ≤200 chars. Keep original language — do not translate.>"
} }
] ],
"talks_vs_trades_flag": <true if you detect a mismatch between the KOL's stated bullish/bearish narrative and signals of the opposite actual position (e.g. claiming bullish while describing reducing size, or bearish while mentioning recent buys) | false>
} }
Rules: Rules:
- Always return summary in ENGLISH regardless of the post language.
- If the post is macro commentary, news recap, or sponsored content with no specific token call, return tickers=[] and summary describing the topic. - 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 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. - 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. - action values:
"buy"/"sell" → author explicitly states a position action ("I bought", "we are long", "我们减仓了", "added to my bag", "taking profits", "已建仓")
"reduce" → author is partially exiting or taking profits on a long-held position (distinct from "sell" which implies closing)
"bullish"/"bearish" → directional view without explicit position statement
"mention" → ticker appears but no clear stance
- Dedupe per ticker — at most one entry per symbol; pick the strongest action. - 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. - 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. - conviction: 0.8+ requires explicit + repeated + sized/timed view; 0.5-0.7 for clear directional view without commitment; <0.5 for passing references.
- timeframe: "immediate" = author is acting now or within 24h; "days" = 1-7 days; "weeks" = 1-4 weeks; "months" = 1+ months; "unspecified" = no timeframe given.
- talks_vs_trades_flag: set true when narrative reads one way but position signals read the other. Examples:
- Author writes a bullish thesis but mentions "reducing", "taking profits", "trimming", "risk management"
- Author is bearish on macro but "accumulating at these levels"
- High-conviction public call ("this is THE entry") but with very low disclosed position size
- Previously loudly bullish post, but this post avoids reaffirming the position
- Do not include fiat (USD/CNY/JPY) or stablecoins (USDT/USDC/DAI/FRAX) unless the post's main thesis is about them. - Do not include fiat (USD/CNY/JPY) or stablecoins (USDT/USDC/DAI/FRAX) unless the post's main thesis is about them.
""" """
@@ -194,6 +209,8 @@ async def extract_kol_signal(
# Normalize # Normalize
tickers = data.get("tickers") or [] tickers = data.get("tickers") or []
cleaned = [] cleaned = []
valid_actions = {"buy", "sell", "reduce", "bullish", "bearish", "mention"}
valid_timeframes = {"immediate", "days", "weeks", "months", "unspecified"}
for t in tickers: for t in tickers:
if not isinstance(t, dict): if not isinstance(t, dict):
continue continue
@@ -201,23 +218,32 @@ async def extract_kol_signal(
if not sym or len(sym) > 12: if not sym or len(sym) > 12:
continue continue
action = (t.get("action") or "mention").lower() action = (t.get("action") or "mention").lower()
if action not in {"buy", "sell", "bullish", "bearish", "mention"}: if action not in valid_actions:
action = "mention" action = "mention"
try: try:
conv = float(t.get("conviction") or 0) conv = float(t.get("conviction") or 0)
except (TypeError, ValueError): except (TypeError, ValueError):
conv = 0.0 conv = 0.0
conv = max(0.0, min(1.0, conv)) conv = max(0.0, min(1.0, conv))
timeframe = (t.get("timeframe") or "unspecified").lower()
if timeframe not in valid_timeframes:
timeframe = "unspecified"
stance_change = bool(t.get("stance_change", False))
cleaned.append({ cleaned.append({
"ticker": sym, "ticker": sym,
"action": action, "action": action,
"conviction": round(conv, 2), "conviction": round(conv, 2),
"timeframe": timeframe,
"stance_change": stance_change,
"quote": (t.get("quote") or "")[:200], "quote": (t.get("quote") or "")[:200],
}) })
talks_vs_trades = bool(data.get("talks_vs_trades_flag", False))
return { return {
"summary": (data.get("summary") or "").strip() or None, "summary": (data.get("summary") or "").strip() or None,
"tickers": cleaned, "tickers": cleaned,
"talks_vs_trades_flag": talks_vs_trades,
"model": model, "model": model,
"version": ANALYSIS_VERSION, "version": ANALYSIS_VERSION,
} }
+79
View File
@@ -202,6 +202,85 @@ KOL_FEEDS: list[dict] = [
"feed_url": "https://tftc.io/feed", "feed_url": "https://tftc.io/feed",
"source": "blog", "source": "blog",
}, },
# ── Macro research & on-chain analysis (deep, independent thinkers) ───
# Checkmate (James Check) — ex-Glassnode lead analyst. Combines on-chain
# data with macro (bonds, energy, commodities). Very active (2-3x/week).
# Concrete BTC cycle calls with high conviction.
{
"handle": "checkmate",
"display_name": "Checkmate (James Check)",
"feed_url": "https://newsletter.checkonchain.com/feed",
"source": "blog",
},
# CoinMetrics "State of the Network" — weekly data-driven research.
# Covers specific protocols/tokens with on-chain metrics. Stable Tuesday
# cadence, institutional quality.
{
"handle": "coinmetrics",
"display_name": "CoinMetrics (State of the Network)",
"feed_url": "https://coinmetrics.substack.com/feed",
},
# Willy Woo — one of the most recognized BTC on-chain analysts. Models
# like NVT, difficulty ribbon. Clear directional calls on BTC cycle phase.
{
"handle": "willywoo",
"display_name": "Willy Woo (Bitcoin Vector)",
"feed_url": "https://willywoo.substack.com/feed",
},
# Glassnode Insights — institutional on-chain research. Monthly deep
# dives on holder behavior, market structure, accumulation/distribution.
{
"handle": "glassnode",
"display_name": "Glassnode Research",
"feed_url": "https://insights.glassnode.com/rss/",
"source": "blog",
},
# Lyn Alden — independent macro analyst. Analyzes BTC through the lens of
# global liquidity, fiscal dominance, sovereign debt. Infrequent (monthly)
# but extremely high quality. Mostly BTC-only signals.
{
"handle": "lynalden",
"display_name": "Lyn Alden",
"feed_url": "https://www.lynalden.com/feed/",
"source": "blog",
},
# Bitcoin Magazine — high-frequency news + institutional adoption analysis.
# AI will extract tickers from policy/corporate BTC reserve articles.
{
"handle": "bitcoinmag",
"display_name": "Bitcoin Magazine",
"feed_url": "https://bitcoinmagazine.com/feed",
"source": "blog",
},
# ── Derivatives positioning & institutional weeklies (2026-05-29) ────
# Deribit Insights / Block Scholes — weekly options & derivatives data
# (gamma exposure, IV skew, BTC/ETH positioning). Fills the derivatives
# coverage gap — no other feed in this list tracks options flow.
{
"handle": "deribit",
"display_name": "Deribit Insights (Block Scholes)",
"feed_url": "https://insights.deribit.com/feed/",
"source": "blog",
},
# Bitfinex Alpha — weekly BTC structure analysis (leverage, ETF flows,
# on-chain profit-taking). Institutional voice, weekly cadence, concrete
# directional framing. Complements Reflexivity's monthly cadence.
{
"handle": "bitfinex",
"display_name": "Bitfinex Alpha",
"feed_url": "https://blog.bitfinex.com/feed/",
"source": "blog",
},
# Forward Guidance (Blockworks) — Felix Jauvin macro-interview podcast.
# Fed policy, AI/liquidity, rates ↔ crypto. Same Blockworks quality bar
# as Empire / Bell Curve / 0xResearch. Replaces dampedspring as the
# primary macro signal source (her substack is paywalled).
{
"handle": "forwardguidance",
"display_name": "Forward Guidance (Blockworks)",
"feed_url": "https://feeds.megaphone.fm/forwardguidance",
"source": "podcast",
},
] ]
# Back-compat alias — older imports referenced SUBSTACK_KOLS. # Back-compat alias — older imports referenced SUBSTACK_KOLS.
+16 -12
View File
@@ -28,6 +28,7 @@ from datetime import datetime, timedelta, timezone
from typing import Any, Optional from typing import Any, Optional
import httpx import httpx
from app.services.bottom_indicators import ahr999 as compute_ahr999
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -119,17 +120,20 @@ def _parse_farside_latest_total(html: str) -> dict:
# ── 1. AHR999 ─────────────────────────────────────────────────────────────── # ── 1. AHR999 ───────────────────────────────────────────────────────────────
# Formula: AHR999 = (price / 200d MA) × (price / age_fit_price) # IMPORTANT: this macro-panel AHR999 MUST stay formula-identical to the
# age_fit_price = 10 ** (5.84 * log10(days_since_2009_01_03) - 17.01) # live BTC bottom scanner, otherwise the displayed value can disagree with the
# Below 0.45 historically marks accumulation zones; above 1.2 marks # actual trigger logic. Reuse the canonical implementation from
# "expensive" regime that invalidates a bottom thesis. # app.services.bottom_indicators instead of re-implementing it here.
_AHR999_GENESIS = datetime(2009, 1, 3, tzinfo=timezone.utc)
@_none_on_fail("ahr999") @_none_on_fail("ahr999")
async def fetch_ahr999() -> dict: async def fetch_ahr999() -> dict:
"""Compute AHR999 from the last 200 daily BTC closes (Binance fapi).""" """Compute the SAME AHR999 used by the BTC bottom scanner.
Raw input source stays the same (Binance daily BTCUSDT closes); only the
formula source-of-truth is centralized so the UI cannot drift from the
trading logic.
"""
end_ms = int(datetime.now(timezone.utc).timestamp() * 1000) end_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
start_ms = end_ms - 260 * 24 * 3600 * 1000 # extra buffer after dropping in-progress day start_ms = end_ms - 260 * 24 * 3600 * 1000 # extra buffer after dropping in-progress day
async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as c: async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as c:
@@ -144,12 +148,12 @@ async def fetch_ahr999() -> dict:
if len(closes) < 200: if len(closes) < 200:
return {"value": None, "raw": {"error": "insufficient candles", "have": len(closes)}} return {"value": None, "raw": {"error": "insufficient candles", "have": len(closes)}}
price = closes[-1] price = closes[-1]
ma200 = sum(closes[-200:]) / 200 ma200 = sum(closes[-200:]) / 200 # kept in raw for operator intuition only
days = (datetime.now(timezone.utc) - datetime(2009, 1, 3, tzinfo=timezone.utc)).total_seconds() / 86400
days = (datetime.now(timezone.utc) - _AHR999_GENESIS).total_seconds() / 86400
age_fit = 10 ** (5.84 * math.log10(days) - 17.01) age_fit = 10 ** (5.84 * math.log10(days) - 17.01)
ahr = compute_ahr999(closes)
ahr = (price / ma200) * (price / age_fit) if ahr is None:
return {"value": None, "raw": {"error": "ahr999 computation returned null"}}
return { return {
"value": round(ahr, 4), "value": round(ahr, 4),
"raw": {"price": price, "ma200": round(ma200, 2), "raw": {"price": price, "ma200": round(ma200, 2),
+18 -36
View File
@@ -1,6 +1,11 @@
""" """
历史帖子价格回溯 Historical price backfill for already-relevant posts.
DB 中没有价格数据的帖子 Binance 拉历史 K 线计算涨跌幅
Important safety rule:
This job only fills missing price fields. It must NEVER rewrite the post's
semantic classification (`relevant`, `sentiment`, signal direction) from a
keyword heuristic, otherwise historical truth rows become a mixed dataset of
AI-scored rows + script-guessed rows.
""" """
import asyncio import asyncio
@@ -53,17 +58,21 @@ def _pct_change(klines: list, from_ms: int, delta_minutes: int) -> Optional[floa
async def backfill_price_impact(db_session_factory, asset: str = "BTC") -> None: async def backfill_price_impact(db_session_factory, asset: str = "BTC") -> None:
""" """Fill price-impact fields for posts that are already relevant.
DB relevant=True 但没有价格数据的帖子补充价格回溯
relevant=False 的帖子做简单判断标题含 crypto/bitcoin/btc 关键词则标记为相关 The semantic label must come from the original analyzer, not from this
backfill job. This task only enriches rows with price-at-post and
subsequent returns.
""" """
symbol = "BTCUSDT" if asset == "BTC" else "ETHUSDT" symbol = "BTCUSDT" if asset == "BTC" else "ETHUSDT"
async with db_session_factory() as db: async with db_session_factory() as db:
# 拿所有没有价格数据的帖子 # Only touch rows whose semantic classification already exists.
result = await db.execute( result = await db.execute(
select(Post) select(Post)
.where(Post.relevant == True)
.where(Post.price_at_post == None) .where(Post.price_at_post == None)
.where(Post.price_impact_asset == asset)
.order_by(Post.published_at.asc()) .order_by(Post.published_at.asc())
) )
posts = result.scalars().all() posts = result.scalars().all()
@@ -72,18 +81,6 @@ async def backfill_price_impact(db_session_factory, asset: str = "BTC") -> None:
if not posts: if not posts:
return return
# 关键词判断是否与加密相关(快速,不用 Claude API)
crypto_keywords = [
"bitcoin", "btc", "crypto", "cryptocurrency", "blockchain",
"ethereum", "eth", "digital currency", "defi", "coinbase",
"sec", "regulation", "tariff", "dollar", "inflation", "fed",
"economy", "market", "trade", "sanctions", "iran", "china",
]
def is_relevant(text: str) -> bool:
t = text.lower()
return any(kw in t for kw in crypto_keywords)
saved = 0 saved = 0
errors = 0 errors = 0
@@ -95,18 +92,6 @@ async def backfill_price_impact(db_session_factory, asset: str = "BTC") -> None:
start_ms = int(published_at.timestamp() * 1000) start_ms = int(published_at.timestamp() * 1000)
# 判断相关性(用关键词,不消耗 Claude API)
relevant = is_relevant(post.text)
sentiment = "neutral"
if relevant:
t = post.text.lower()
bullish_kw = ["great", "win", "winning", "strong", "best", "love", "beautiful", "tremendous", "amazing", "pro-crypto", "bitcoin reserve"]
bearish_kw = ["bad", "terrible", "war", "crisis", "sanction", "ban", "regulate", "crack", "fraud", "scam"]
if any(k in t for k in bullish_kw):
sentiment = "bullish"
elif any(k in t for k in bearish_kw):
sentiment = "bearish"
# 拉 Binance 历史价格 # 拉 Binance 历史价格
klines = await _fetch_klines(symbol, start_ms, limit=FETCH_WINDOW_MINUTES) klines = await _fetch_klines(symbol, start_ms, limit=FETCH_WINDOW_MINUTES)
@@ -120,13 +105,10 @@ async def backfill_price_impact(db_session_factory, asset: str = "BTC") -> None:
result = await db.execute(select(Post).where(Post.id == post.id)) result = await db.execute(select(Post).where(Post.id == post.id))
p = result.scalar_one_or_none() p = result.scalar_one_or_none()
if p: if p:
p.relevant = relevant
p.sentiment = sentiment
p.price_impact_asset = asset if relevant else None
p.price_at_post = price_at_post p.price_at_post = price_at_post
p.price_impact_m5 = m5 if relevant else None p.price_impact_m5 = m5
p.price_impact_m15 = m15 if relevant else None p.price_impact_m15 = m15
p.price_impact_m1h = m1h if relevant else None p.price_impact_m1h = m1h
await db.commit() await db.commit()
saved += 1 saved += 1
+85 -9
View File
@@ -31,7 +31,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
from sqlalchemy import select, update from sqlalchemy import select, update
@@ -47,6 +47,16 @@ logger = logging.getLogger(__name__)
RECONCILE_INTERVAL_SECONDS = 60 RECONCILE_INTERVAL_SECONDS = 60
# Max concurrent HL API calls per reconcile cycle. Each call can take up to
# ~30s on timeout; with 10 in flight the worst-case tail latency for N wallets
# is ceil(N/10) × 30s instead of N × 30s.
_RECONCILE_CONCURRENCY = 10
# How far back to look for the BUG-03 ghost-position check (DB closed but HL
# still open). 2 hours covers most close_and_finalize failure windows without
# scanning the entire trades history.
_GHOST_LOOKBACK_HOURS = 2
# ─── Single-wallet reconcile ──────────────────────────────────────────────── # ─── Single-wallet reconcile ────────────────────────────────────────────────
@@ -59,7 +69,7 @@ async def _reconcile_wallet(sub: Subscription) -> dict:
""" """
summary = { summary = {
"wallet": sub.wallet_address, "orphan_hl": [], "orphan_db": [], "wallet": sub.wallet_address, "orphan_hl": [], "orphan_db": [],
"marked_closed": 0, "error": None, "marked_closed": 0, "ghost_positions": [], "error": None,
} }
# Paper-mode subs aren't on HL at all — nothing to reconcile. # Paper-mode subs aren't on HL at all — nothing to reconcile.
@@ -142,6 +152,44 @@ async def _reconcile_wallet(sub: Subscription) -> dict:
logger.warning("Reconcile: %s has unmanaged HL position %s (szi=%s, entry=%s)", logger.warning("Reconcile: %s has unmanaged HL position %s (szi=%s, entry=%s)",
sub.wallet_address, asset, hl_pos["szi"], hl_pos["entry_px"]) sub.wallet_address, asset, hl_pos["szi"], hl_pos["entry_px"])
# Case 3 (BUG-03 mitigation): DB says closed recently but HL still shows
# the position open — the "ghost" state caused by close_and_finalize
# writing closed_at to DB while the HL market order fails. We can't
# auto-re-close (risk of double-execution) so we alert loudly and let
# the human decide. Look back _GHOST_LOOKBACK_HOURS only; older discrepancies
# are likely stale HL data or positions the user re-opened manually.
ghost_cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(
hours=_GHOST_LOOKBACK_HOURS
)
async with AsyncSessionLocal() as db:
ghost_rows = (await db.execute(
select(BotTrade).where(
BotTrade.wallet_address == sub.wallet_address,
BotTrade.closed_at.is_not(None),
BotTrade.closed_at >= ghost_cutoff,
)
)).scalars().all()
for trade in ghost_rows:
# Paper trades and adopted/released trades have no HL position to check.
if trade.hl_order_id == "paper" or trade.released_at is not None:
continue
if trade.asset in hl_assets_open:
ghost_entry = {
"asset": trade.asset,
"trade_id": trade.id,
"closed_at": trade.closed_at.isoformat() if trade.closed_at else None,
"hl_szi": hl_assets_open[trade.asset].get("szi"),
}
summary["ghost_positions"].append(ghost_entry)
logger.error(
"GHOST POSITION [BUG-03]: trade %d (%s %s) has closed_at=%s in DB "
"but HL still shows an open position (szi=%s). "
"Manual close required on HL UI.",
trade.id, trade.side, trade.asset, trade.closed_at,
hl_assets_open[trade.asset].get("szi"),
)
return summary return summary
@@ -149,7 +197,12 @@ async def _reconcile_wallet(sub: Subscription) -> dict:
async def reconcile_all_once() -> None: async def reconcile_all_once() -> None:
"""One scan over every subscription. Wired into the APScheduler at startup.""" """One scan over every subscription. Wired into the APScheduler at startup.
Runs all per-wallet checks concurrently (up to _RECONCILE_CONCURRENCY in
flight at once) so tail latency scales as ceil(N / concurrency) × per-call
timeout rather than N × per-call timeout.
"""
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
rows = await db.execute( rows = await db.execute(
select(Subscription).where( select(Subscription).where(
@@ -165,18 +218,37 @@ async def reconcile_all_once() -> None:
# Snapshot the fields we read so we don't hold the session across HL calls. # Snapshot the fields we read so we don't hold the session across HL calls.
snapshots = list(subs) snapshots = list(subs)
sem = asyncio.Semaphore(_RECONCILE_CONCURRENCY)
async def _bounded(sub: Subscription) -> dict:
async with sem:
return await _reconcile_wallet(sub)
results = await asyncio.gather(
*[_bounded(sub) for sub in snapshots],
return_exceptions=True,
)
n_changed = 0 n_changed = 0
n_orphans = 0 n_orphans = 0
n_ghosts = 0
n_errors = 0 n_errors = 0
for sub in snapshots: for sub, result in zip(snapshots, results):
summary = await _reconcile_wallet(sub) if isinstance(result, BaseException):
n_errors += 1
logger.error("Reconcile: unexpected exception for %s: %s",
sub.wallet_address, result)
continue
summary = result
n_changed += summary["marked_closed"] n_changed += summary["marked_closed"]
n_orphans += len(summary["orphan_hl"]) n_orphans += len(summary["orphan_hl"])
n_ghosts += len(summary.get("ghost_positions", []))
if summary["error"]: if summary["error"]:
n_errors += 1 n_errors += 1
# Broadcast significant events so the UI shows a warning banner. # Broadcast significant events so the UI shows a warning banner.
if summary["marked_closed"] or summary["orphan_hl"]: if summary["marked_closed"] or summary["orphan_hl"] or summary.get("ghost_positions"):
try: try:
from app.ws.manager import manager from app.ws.manager import manager
await manager.broadcast({ await manager.broadcast({
@@ -184,11 +256,15 @@ async def reconcile_all_once() -> None:
"wallet": summary["wallet"], "wallet": summary["wallet"],
"orphan_hl": summary["orphan_hl"], "orphan_hl": summary["orphan_hl"],
"marked_closed": summary["marked_closed"], "marked_closed": summary["marked_closed"],
"ghost_positions": summary.get("ghost_positions", []),
"at": datetime.now(timezone.utc).isoformat() + "Z", "at": datetime.now(timezone.utc).isoformat() + "Z",
}) })
except Exception as exc: except Exception as exc:
logger.warning("reconcile WS broadcast failed: %s", exc) logger.warning("reconcile WS broadcast failed: %s", exc)
if n_changed or n_orphans or n_errors: if n_changed or n_orphans or n_ghosts or n_errors:
logger.info("Reconcile cycle: %d subs scanned, %d trades marked closed, %d HL orphans, %d errors", logger.info(
len(snapshots), n_changed, n_orphans, n_errors) "Reconcile cycle: %d subs scanned, %d trades marked closed, "
"%d HL orphans, %d ghost positions, %d errors",
len(snapshots), n_changed, n_orphans, n_ghosts, n_errors,
)
+49 -1
View File
@@ -18,13 +18,14 @@ 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 close_and_finalize from app.services.bot_engine import close_and_finalize, _time_stop_check, _background_tasks
from app.services.crypto import decrypt_api_key from app.services.crypto import decrypt_api_key
from app.services.signal_categories import ( from app.services.signal_categories import (
get_stop_ladder as _get_stop_ladder, get_stop_ladder as _get_stop_ladder,
sys2_derisk_ladder as _sys2_derisk_ladder, sys2_derisk_ladder as _sys2_derisk_ladder,
sys2_addon_ladder as _sys2_addon_ladder, sys2_addon_ladder as _sys2_addon_ladder,
sys2_peak_trail as _sys2_peak_trail, sys2_peak_trail as _sys2_peak_trail,
get_exit_profile as _get_exit_profile,
) )
from app.services.tp_sl_monitor import register_trade from app.services.tp_sl_monitor import register_trade
@@ -183,5 +184,52 @@ async def rehydrate_open_trades() -> None:
_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 rehydration ──────────────────────────────
# _time_stop_check is a background task created at open time that
# closes the trade if it's still flat (|unrealised| < 2%) after
# `time_stop_hours`. It is NOT stored in DB — it must be rebuilt
# here on every restart or open sys2 trades silently lose this guard.
# btc_bottom_reversal_long has time_stop_hours=None (no time stop).
if _stop_ladder and t.sys2_mode:
_exit_profile = _get_exit_profile(_cat_for_ladders)
ts_hours = _exit_profile.time_stop_hours
if ts_hours:
ts_elapsed_h = elapsed / 3600
ts_remaining_s = max(0.0, ts_hours - ts_elapsed_h) * 3600
if ts_remaining_s > 0:
ts_task = asyncio.create_task(_time_stop_check(
trade_id=t.id,
api_key=api_key,
leverage=trade_leverage,
asset=t.asset,
wallet=t.wallet_address,
delay_seconds=int(ts_remaining_s),
))
_background_tasks.add(ts_task)
ts_task.add_done_callback(_background_tasks.discard)
logger.info(
"Rehydrated time-stop for trade %d: %.1fh remaining",
t.id, ts_remaining_s / 3600,
)
else:
# Time-stop window already elapsed while backend was down.
# Fire it now — close_and_finalize is idempotent (WHERE
# closed_at IS NULL) so a trade that already closed via
# another path is a safe no-op.
logger.info(
"Trade %d time-stop elapsed during downtime — checking now",
t.id,
)
ts_task = asyncio.create_task(_time_stop_check(
trade_id=t.id,
api_key=api_key,
leverage=trade_leverage,
asset=t.asset,
wallet=t.wallet_address,
delay_seconds=0,
))
_background_tasks.add(ts_task)
ts_task.add_done_callback(_background_tasks.discard)
await db.commit() await db.commit()
logger.info("Rehydrated %d open trades.", len(open_trades)) logger.info("Rehydrated %d open trades.", len(open_trades))
+1 -1
View File
@@ -105,7 +105,7 @@ async def _emit_signal(debug: dict) -> bool:
} }
async with httpx.AsyncClient(timeout=10) as client: async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post( resp = await client.post(
"http://localhost:8000/api/signals/ingest", f"{settings.ingest_base_url}/api/signals/ingest",
json=payload, json=payload,
headers={"X-Ingest-Key": settings.ingest_api_key}, headers={"X-Ingest-Key": settings.ingest_api_key},
) )
+1 -1
View File
@@ -285,7 +285,7 @@ async def _emit_signal(debug: dict) -> bool:
} }
async with httpx.AsyncClient(timeout=10) as client: async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post( resp = await client.post(
"http://localhost:8000/api/signals/ingest", f"{settings.ingest_base_url}/api/signals/ingest",
json=payload, json=payload,
headers={"X-Ingest-Key": settings.ingest_api_key}, headers={"X-Ingest-Key": settings.ingest_api_key},
) )
+44 -5
View File
@@ -52,15 +52,22 @@ def build_message(action: str, wallet: str, timestamp_ms: int, body_hash: str) -
# In-memory; fine for a single-process deploy. For multi-worker, move to Redis. # In-memory; fine for a single-process deploy. For multi-worker, move to Redis.
_seen: dict[str, float] = {} # sha256(signature) → expiry epoch seconds _seen: dict[str, float] = {} # sha256(signature) → expiry epoch seconds
# Purge when the dict exceeds this size. At 5 minutes TTL and ~10 req/s peak
# that's ~3000 entries at saturation; 1000 gives comfortable headroom while
# keeping purge cost tiny (<1 ms for 1000-entry linear scan).
_SEEN_PURGE_THRESHOLD = 1000
def _cache_put(sig: str, ttl: float) -> bool: def _cache_put(sig: str, ttl: float) -> bool:
"""Return True if sig was new, False if replay.""" """Return True if sig was new, False if replay."""
now = time.time() now = time.time()
# lazy purge # Lazy purge: only scan when we've crossed the threshold, not every call.
if len(_seen) > 5000: # Scanning 1000 vs 5000 entries keeps individual request latency negligible
for k, v in list(_seen.items()): # even under burst traffic.
if v < now: if len(_seen) >= _SEEN_PURGE_THRESHOLD:
_seen.pop(k, None) expired = [k for k, v in _seen.items() if v < now]
for k in expired:
del _seen[k]
key = hashlib.sha256(sig.encode("utf-8")).hexdigest() key = hashlib.sha256(sig.encode("utf-8")).hexdigest()
if key in _seen and _seen[key] > now: if key in _seen and _seen[key] > now:
return False return False
@@ -105,3 +112,35 @@ def verify_signed_request(
if not allow_replay: if not allow_replay:
if not _cache_put(signature, ttl=MAX_SKEW_SECONDS): if not _cache_put(signature, ttl=MAX_SKEW_SECONDS):
raise HTTPException(401, "Signature already used (replay blocked)") raise HTTPException(401, "Signature already used (replay blocked)")
def verify_signed_request_any(
*,
actions: list[str],
wallet: str,
timestamp_ms: int,
signature: str,
body: Optional[Any],
allow_replay: bool = False,
) -> None:
"""Accept any one of several signed read-actions.
Useful for wallet-owned read endpoints where the frontend may already hold
a fresh cached envelope for a sibling read action such as `view_user`.
"""
last_exc: Optional[HTTPException] = None
for action in actions:
try:
verify_signed_request(
action=action,
wallet=wallet,
timestamp_ms=timestamp_ms,
signature=signature,
body=body,
allow_replay=allow_replay,
)
return
except HTTPException as exc:
last_exc = exc
if last_exc is not None:
raise last_exc
+137 -9
View File
@@ -146,16 +146,100 @@ def format_post(post: Post) -> str:
return msg[:MAX_LEN] return msg[:MAX_LEN]
def format_trump_mention(post: Post) -> str:
"""Public-channel-only alert for Trump posts that are crypto-relevant
but don't carry a clear directional signal (signal=hold, relevant=True).
These posts can still move markets (e.g. "I support crypto development")
so we surface them as low-urgency monitoring alerts. Never sent per-user
to avoid subscriber spam; only broadcast to the public channel.
"""
body = (post.text or "").strip()
if len(body) > 300:
body = body[:300].rstrip() + ""
fe = (settings.frontend_url or "").rstrip("/")
link = f'\n\n<a href="{fe}/en/trump">→ view on TrumpAlpha</a>' if fe else ""
msg = (
f"📢 <b>Trump mentioned crypto</b>\n"
f"<i>Trump · Truth Social — no clear trade signal</i>\n\n"
f"{body}"
f"{link}"
)
return msg[:MAX_LEN]
def format_public_post(post: Post) -> str:
"""Render a sanitised version of a Post for the public broadcast channel.
Intentionally omits:
- expected_move_pct / invalidation_price (execution-sensitive data)
- /adopt CTA (requires a private bot session)
- Any wallet address or subscriber-only details
Keeps: signal direction, asset, confidence tier, source label, post
excerpt ( 280 chars), and a link back to the relevant dashboard section.
"""
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
# Confidence tier label instead of raw number (less intimidating publicly)
if conf >= 80:
conf_label = "HIGH"
elif conf >= 60:
conf_label = "MED"
elif conf > 0:
conf_label = "LOW"
else:
conf_label = ""
head = f"{emoji} <b>{asset} · {sig}</b> · conf <b>{conf_label}</b>"
sub = f"<i>{src}</i>"
body = (post.text or "").strip()
if len(body) > 280:
body = body[:280].rstrip() + ""
# KOL divergence: strip wallet address from the text if present
# (the raw text shouldn't contain one, but belt-and-suspenders)
if post.source == "kol_divergence" and post.ai_reasoning:
reason = post.ai_reasoning.strip()
if len(reason) > 200:
reason = reason[:200].rstrip() + ""
body = reason
fe = (settings.frontend_url or "").rstrip("/")
link = ""
if fe:
path = {
"truth": "/en/trump",
"btc_bottom_reversal": "/en/macro",
"funding_reversal": "/en/macro",
"kol_divergence": "/en/kol",
}.get(post.source, "/en")
link = f'\n\n<a href="{fe}{path}">→ view on TrumpAlpha</a>'
msg = f"{head}\n{sub}\n\n{body}{link}"
return msg[:MAX_LEN]
# ── Low-level HTTP ──────────────────────────────────────────────────────── # ── Low-level HTTP ────────────────────────────────────────────────────────
async def send_message(chat_id: int, text: str, *, async def send_message(chat_id: int | str, text: str, *,
parse_mode: str = "HTML", parse_mode: str = "HTML",
disable_preview: bool = True, disable_preview: bool = True,
reply_markup: Optional[dict] = None) -> bool: reply_markup: Optional[dict] = None) -> bool:
"""Single HTTP POST to Telegram Bot API. Returns True on 200, False on """Single HTTP POST to Telegram Bot API. Returns True on 200, False on
any failure (caller decides whether to bump the failure counter). any failure (caller decides whether to bump the failure counter).
`chat_id` may be an integer user/group ID or a string channel username
such as ``"@trumpalpha"`` (with or without the leading @).
`reply_markup` is the standard Bot-API inline keyboard / reply keyboard `reply_markup` is the standard Bot-API inline keyboard / reply keyboard
payload, e.g. payload, e.g.
{"inline_keyboard": [ {"inline_keyboard": [
@@ -179,12 +263,12 @@ async def send_message(chat_id: int, text: str, *,
async with httpx.AsyncClient(timeout=10) as client: async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(url, json=payload) r = await client.post(url, json=payload)
if r.status_code != 200: if r.status_code != 200:
logger.warning("Telegram sendMessage failed chat=%d status=%d body=%s", logger.warning("Telegram sendMessage failed chat=%s status=%d body=%s",
chat_id, r.status_code, r.text[:200]) chat_id, r.status_code, r.text[:200])
return False return False
return True return True
except Exception as exc: except Exception as exc:
logger.warning("Telegram sendMessage exception chat=%d: %s", chat_id, exc) logger.warning("Telegram sendMessage exception chat=%s: %s", chat_id, exc)
return False return False
@@ -246,7 +330,20 @@ async def answer_callback(callback_query_id: str, text: str = "",
async def _dispatch(post_id: int) -> None: async def _dispatch(post_id: int) -> None:
"""Fan-out a single Post to every eligible subscriber. Always runs in """Fan-out a single Post to every eligible subscriber. Always runs in
its own DB session so signal ingestion's session is unaffected.""" its own DB session so signal ingestion's session is unaffected.
Two distinct paths:
PATH A actionable signal (signal=buy/short):
Per-subscriber fan-out filtered by alert preference + min_confidence
Public channel broadcast (format_public_post)
PATH B crypto mention (source=truth, relevant=True, signal=hold):
Public channel ONLY (format_trump_mention) no per-subscriber push
to avoid noise for individual users who subscribed for trade signals.
These posts are crypto-relevant but lack a clear directional call.
They can still move markets so we surface them as monitoring alerts.
"""
if not settings.telegram_bot_token: if not settings.telegram_bot_token:
return return
@@ -256,10 +353,32 @@ async def _dispatch(post_id: int) -> None:
logger.warning("Telegram dispatch: post id=%d not found", post_id) logger.warning("Telegram dispatch: post id=%d not found", post_id)
return return
# Only fan out for actionable signals (not NOISE / null) is_actionable = post.signal in ("buy", "short")
if not post.signal or post.signal not in ("buy", "short"): is_trump_mention = (
post.source == "truth"
and bool(post.relevant)
and post.signal == "hold"
)
if not is_actionable and not is_trump_mention:
# Non-relevant noise — nothing to send.
return return
# ── PATH B: crypto mention (public channel only) ──────────────────
if is_trump_mention and not is_actionable:
channel_id = settings.telegram_public_channel_id
if channel_id:
mention_text = format_trump_mention(post)
ok = await send_message(channel_id, mention_text)
if ok:
logger.info("Telegram mention alert: post=%d sent to %s",
post_id, channel_id)
else:
logger.warning("Telegram mention alert: post=%d send FAILED to %s",
post_id, channel_id)
return
# ── PATH A: actionable signal (buy/short) ─────────────────────────
pref_col = _pref_column_for_source(post.source) pref_col = _pref_column_for_source(post.source)
if pref_col is None: if pref_col is None:
logger.debug("Telegram: unknown source %r — not fanning out", post.source) logger.debug("Telegram: unknown source %r — not fanning out", post.source)
@@ -282,9 +401,6 @@ async def _dispatch(post_id: int) -> None:
q = select(TelegramBinding).where(*base_filters) q = select(TelegramBinding).where(*base_filters)
bindings = (await db.execute(q)).scalars().all() bindings = (await db.execute(q)).scalars().all()
if not bindings:
return
text = format_post(post) text = format_post(post)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
hour = now.hour hour = now.hour
@@ -318,6 +434,18 @@ async def _dispatch(post_id: int) -> None:
logger.info("Telegram fan-out: post=%d source=%s sent=%d/%d", logger.info("Telegram fan-out: post=%d source=%s sent=%d/%d",
post_id, post.source, sent, len(bindings)) post_id, post.source, sent, len(bindings))
# ── Public channel broadcast ──────────────────────────────────────
channel_id = settings.telegram_public_channel_id
if channel_id:
public_text = format_public_post(post)
ok = await send_message(channel_id, public_text)
if ok:
logger.info("Telegram public channel: post=%d sent to %s",
post_id, channel_id)
else:
logger.warning("Telegram public channel: post=%d send FAILED to %s",
post_id, channel_id)
def notify_signal(post: Post) -> None: def notify_signal(post: Post) -> None:
"""Fire-and-forget. Schedules `_dispatch(post.id)` on the running loop """Fire-and-forget. Schedules `_dispatch(post.id)` on the running loop
+6 -2
View File
@@ -377,8 +377,12 @@ async def _cmd_stop(chat_id: int) -> None:
) )
await db.commit() await db.commit()
if r.rowcount: if r.rowcount:
await send_message(chat_id, await send_message(
"🔕 Alerts paused. Send /start any time to re-enable.") chat_id,
"🔕 Real-time alerts paused. Send /start any time to re-enable.\n\n"
"Your daily brief is unaffected — send <code>/digest off</code> "
"to stop that separately.",
)
else: else:
await send_message(chat_id, await send_message(chat_id,
"You don't have an active binding here. Send /start to set one up.") "You don't have an active binding here. Send /start to set one up.")
+5 -1
View File
@@ -413,10 +413,14 @@ async def send_daily_digest(current_hour: Optional[int] = None) -> int:
sent = 0 sent = 0
async with async_session() as db: async with async_session() as db:
# NOTE: alerts_enabled is intentionally NOT gated here. /stop pauses
# real-time signal alerts but the daily digest is a separate feature
# (/digest on|off). Filtering on alerts_enabled would silently kill
# the digest for any user who typed /stop, contradicting the bot's
# own message ("Alerts paused — send /start to re-enable").
bindings = (await db.execute( bindings = (await db.execute(
select(TelegramBinding).where( select(TelegramBinding).where(
TelegramBinding.digest_enabled.is_(True), TelegramBinding.digest_enabled.is_(True),
TelegramBinding.alerts_enabled.is_(True),
TelegramBinding.digest_hour_utc == hour, TelegramBinding.digest_hour_utc == hour,
or_( or_(
TelegramBinding.last_digest_sent_at.is_(None), TelegramBinding.last_digest_sent_at.is_(None),
+12 -1
View File
@@ -187,7 +187,18 @@ def register_trade(
def unregister(trade_id: int) -> None: def unregister(trade_id: int) -> None:
_watched.pop(trade_id, None) wt = _watched.pop(trade_id, None)
# Flush the latest peak to DB on unregister so a restart between the last
# throttled write and this point doesn't lose progress.
if wt is not None and wt.peak_gain_pct > wt.peak_persisted:
task = asyncio.create_task(_persist_peak(trade_id, wt.peak_gain_pct))
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
# Clean up the per-trade close lock from bot_engine to prevent memory leak.
# Trades closed via reconciler bypass close_and_finalize, so the lock would
# otherwise remain in the dict forever.
from app.services.bot_engine import _close_locks
_close_locks.pop(trade_id, None)
def on_price_tick(asset: str, price: float) -> None: def on_price_tick(asset: str, price: float) -> None:
+509
View File
@@ -0,0 +1,509 @@
"""
X (Twitter) post semantic analysis KOL real-time signals.
X posts are fundamentally different from Substack long-form articles:
- 280 chars: conclusion IS the content, no room for hedging
- Latency matters: "just bought SOL" is a minutes-level signal
- Noise ratio is extreme: 95%+ of posts are irrelevant
- Retweet/quote patterns must be detected and handled differently
- Position statements ("added", "trimmed", "exit") are high-value
- Thread context matters: a post may continue a thought from above
Design philosophy:
- Strict NOISE default (opposite of buy/sell). Most X posts should
be filtered out. The cost of false-positive on X is high because
KOLs tweet constantly.
- Three tiers of output:
TRADE_SIGNAL actionable now (position statement + specific asset)
DIRECTIONAL clear view, no explicit position action
NOISE everything else (no tickers, no stance, filler)
- talks_vs_trades_flag: X posts often reveal real positions
("taking profits" while publicly bullish = divergence)
Output feeds into kol_divergence.py for cross-referencing with on-chain.
"""
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 = "x-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
# ─────────────────────────────────────────────────────────────────────
SYSTEM_PROMPT = """\
You are a signal extraction system for a crypto trading platform. You read
X (Twitter) posts from known crypto KOLs and decide whether they contain
a tradeable signal.
NOISE IS THE DEFAULT internalize this
KOLs post 20-50 times per day. Most of it is:
- gm / wagmi / vibes / lifestyle
- macro commentary with no specific call
- reactions to news without a position
- vague encouragement ("BTC is king")
- promotional content / sponsored
- replies to followers (context-dependent, usually not standalone)
- jokes, memes, personal life
ALL of the above tier: "noise", tickers: []
Only extract signals when the post contains CLEAR, EXPLICIT information
about what the KOL is doing or believes RIGHT NOW. Ambiguity noise.
THREE SIGNAL TIERS
TIER 1 TRADE_SIGNAL (rarest, highest value)
The KOL states an ACTUAL POSITION ACTION in this post:
"just bought", "added more", "long from here", "trimming my",
"sold my", "exit", "took profits", "cutting", "building position",
"DCA'd", "我建仓了", "已经上车", "减仓了", "清仓了"
Must reference a SPECIFIC asset (not just "the market").
Conviction 0.7 required for TRADE_SIGNAL.
TIER 2 DIRECTIONAL (moderate value)
The KOL expresses a SPECIFIC, CURRENT directional view on a named asset:
"SOL looking strong here", "ETH is going to $5k", "BTC at support",
"I think [TICKER] breaks down", "watching [TICKER] for entry"
No explicit position action, but a clear current opinion.
Conviction 0.40.7 typical.
TIER 3 NOISE (default)
Everything else. When in doubt, NOISE.
POST TYPE DETECTION apply before scoring
Detect what kind of post this is before scoring content:
"original" KOL's own thought, highest signal value
"reply" reply to another user (context missing usually noise
unless the post is fully self-contained)
"retweet" RT of someone else's post without commentary → NOISE
"quote" RT with commentary score the commentary only,
ignore the original post content
"thread_cont" continues a thread (may lack context lower conviction)
Retweets without commentary are ALWAYS noise regardless of content.
TALKS VS TRADES FLAG the platform's core value
Set talks_vs_trades_flag=true when you detect a MISMATCH between:
- What the KOL says publicly (bullish/bearish narrative)
- What they reveal about their ACTUAL position in the same post
Classic divergence patterns in X posts:
1. "BTC is going to $200k 🚀" + "took some chips off the table"
Narrative: ultra bullish. Action: selling. FLAG.
2. "This market is trash, crypto is dead" + "accumulated more $ETH"
Narrative: bearish. Action: buying. FLAG.
3. "Stay strong, don't sell!" + reveals they have <5% allocation
Narrative: encouraging others to hold. Position: minimal. FLAG.
4. "Not financial advice but..." followed by extreme conviction call
Common hedge used right before a large directional call. Reduce
conviction by 0.1. Not a flag by itself.
5. "I was wrong about X, but now I think Y"
Explicit reversal/stance_change. Mark stance_change=true.
CRYPTO SLANG DECODER handle these correctly
"aping in" / "aped" bought (often large, impulsive)
"degen'd" took a risky position
"paper hands" sold too early (about someone else, not actionable)
"diamond hands" holding through drawdown (not a new position)
"ngmi" bearish on a token or person
"wagmi" general optimism, NOT a signal
"rekt" lost money, position closed
"accumulated" / "acc" bought (ongoing or past)
"trimmed" / "trim" partially sold
"bags" / "holding bags" holding a position
"flipped" changed direction (stance_change=true)
"this is the one" / "THE entry" high conviction call
"under the radar" bullish on an obscure asset
"cook" doing something well, bullish signal about a project
"cooked" (about a token) bearish, likely dying/failed
EXAMPLES calibrate against these
[TRADE_SIGNAL fire]
POST: "Aped into $SOL at $145. Full size. We go."
tier: "trade_signal", action: "buy", asset: SOL, conviction: 0.92,
timeframe: "immediate", talks_vs_trades_flag: false
POST: "Trimming half my $ETH here. Not selling the thesis but locking
profits at 3x. Will re-add lower."
tier: "trade_signal", action: "reduce", asset: ETH, conviction: 0.85,
timeframe: "immediate", talks_vs_trades_flag: false
POST: "This is cooked. Sold everything. Moving to stable until macro clears."
tier: "trade_signal", action: "sell", asset: null (multiple/all),
conviction: 0.88, timeframe: "immediate"
[DIRECTIONAL extract but lower weight]
POST: "SOL is setting up for a breakout. $180 is the line to watch."
tier: "directional", action: "bullish", asset: SOL, conviction: 0.58,
timeframe: "days"
POST: "ETH dominance will crush alts this cycle. The flippening is real."
tier: "directional", action: "bullish", asset: ETH, conviction: 0.52,
timeframe: "months"
[NOISE output noise, empty tickers]
POST: "gm everyone 🌅"
tier: "noise", tickers: []
POST: "The market is wild lmao. Stay safe everyone."
tier: "noise", tickers: []
POST: "Interesting take by @SomeAnalyst on the Fed" [with RT of article]
tier: "noise" (retweet of someone else, no original content)
POST: "Not financial advice but BTC is really interesting here 👀"
tier: "noise" (too vague, "interesting" is not a stance)
POST: "Just attended [Conference]. Amazing speakers. Building in crypto
is incredible."
tier: "noise" (no market signal)
OUTPUT FORMAT (strict JSON, no markdown)
{
"post_type": "original" | "reply" | "retweet" | "quote" | "thread_cont",
"tier": "trade_signal" | "directional" | "noise",
"summary": "<one sentence in ENGLISH, ≤80 chars. State what the KOL
is saying/doing. 'Noise: gm post' if tier is noise.>",
"tickers": [
{
"ticker": "<UPPERCASE symbol>",
"action": "buy" | "sell" | "reduce" | "bullish" | "bearish" | "mention",
"conviction": <float 0.0-1.0>,
"timeframe": "immediate" | "hours" | "days" | "weeks" | "months" | "unspecified",
"stance_change": <true if this reverses a previously stated position in this post>,
"quote": "<verbatim phrase from the post supporting this, ≤100 chars>"
}
],
"talks_vs_trades_flag": <true | false>,
"has_price_target": <true if a specific price level is mentioned>,
"price_targets": [
{ "ticker": "SOL", "price": 180, "direction": "up" | "down" }
],
"sentiment": "bullish" | "bearish" | "neutral",
"reasoning": "<one sentence: why this tier? What specific phrase drove the decision?>"
}
HARD RULES:
tier == "noise" tickers MUST be [], talks_vs_trades_flag MUST be false
tier == "trade_signal" at least one ticker with action in {buy, sell, reduce}
AND conviction 0.7
post_type == "retweet" (RT without commentary) tier MUST be "noise"
If no explicit price target price_targets: []
conviction < 0.4 tier cannot be "trade_signal"
"""
# ─────────────────────────────────────────────────────────────────────
# USER PROMPT TEMPLATE
# ─────────────────────────────────────────────────────────────────────
USER_TEMPLATE = """\
KOL handle: @{handle}
Follower tier: {follower_tier}
Post time (UTC): {posted_at}
Current UTC hour: {hour} ({liquidity_note})
POST:
\"\"\"{text}\"\"\"
{thread_context}
Score this post. Default to NOISE unless there is explicit, specific signal."""
# ─────────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────────
def _liquidity_note(hour: int) -> str:
if 3 <= hour < 9:
return "Asia overnight — thin liquidity, be extra strict"
if 13 <= hour < 21:
return "US session — normal strictness"
return "Off-peak hours"
def _follower_tier(follower_count: Optional[int]) -> str:
if follower_count is None:
return "unknown"
if follower_count >= 500_000:
return "mega (500k+)"
if follower_count >= 100_000:
return "large (100k-500k)"
if follower_count >= 20_000:
return "mid (20k-100k)"
return "small (<20k)"
_FALLBACK = {
"post_type": "original",
"tier": "noise",
"summary": None,
"tickers": [],
"talks_vs_trades_flag": False,
"has_price_target": False,
"price_targets": [],
"sentiment": "neutral",
"reasoning": "",
"model": None,
"version": ANALYSIS_VERSION,
"error": None,
}
VALID_ACTIONS = {"buy", "sell", "reduce", "bullish", "bearish", "mention"}
VALID_TIMEFRAMES = {"immediate", "hours", "days", "weeks", "months", "unspecified"}
VALID_TIERS = {"trade_signal", "directional", "noise"}
VALID_POST_TYPES = {"original", "reply", "retweet", "quote", "thread_cont"}
async def analyze_x_post(
*,
handle: str,
text: str,
posted_at: Optional[str] = None,
follower_count: Optional[int] = None,
thread_context: Optional[str] = None,
model: Optional[str] = None,
) -> dict:
"""Score an X post from a crypto KOL.
Args:
handle: Twitter/X handle (without @)
text: Post text (already stripped of URLs if desired)
posted_at: ISO UTC timestamp of the post
follower_count: Follower count for context (affects tier label)
thread_context: Optional: preceding posts in thread, 500 chars
model: Override model. Defaults to ai_model (quality path).
Returns dict with: post_type, tier, summary, tickers, talks_vs_trades_flag,
has_price_target, price_targets, sentiment, reasoning, model, version.
Errors return a noise-safe fallback (never raises).
"""
stripped = (text or "").strip()
if not stripped:
out = dict(_FALLBACK)
out["error"] = "empty post"
return out
# Fast pre-filter: bare RT with no added text is always noise
low = stripped.lower()
if low.startswith("rt @") and len(stripped) < 300:
out = dict(_FALLBACK)
out["post_type"] = "retweet"
out["reasoning"] = "Pre-filtered: bare retweet with no commentary."
return out
hour = datetime.now(timezone.utc).hour
thread_block = (
f"Thread context (preceding posts):\n\"\"\"\n{thread_context[:500]}\n\"\"\""
if thread_context else ""
)
user_prompt = USER_TEMPLATE.format(
handle=handle,
follower_tier=_follower_tier(follower_count),
posted_at=posted_at or "unknown",
hour=hour,
liquidity_note=_liquidity_note(hour),
text=stripped[:1000],
thread_context=thread_block,
)
use_anth = _use_anthropic()
if model is None:
# X analysis is near-real-time but less latency-critical than Trump.
# Use the quality model for better signal/noise discrimination.
model = ANTHROPIC_MODEL if use_anth else settings.ai_model
try:
if use_anth:
msg = await _anth().messages.create(
model=model,
max_tokens=800,
temperature=0.1,
system=SYSTEM_PROMPT,
messages=[{"role": "user", "content": user_prompt}],
)
raw = (msg.content[0].text if msg.content else "").strip()
else:
is_reasoning = any(x in model for x in ("pro", "reasoner", "r1", "think"))
kwargs: dict = {
"model": model,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
"max_tokens": 2000 if is_reasoning else 800,
}
if not is_reasoning:
kwargs["temperature"] = 0.1
kwargs["response_format"] = {"type": "json_object"}
resp = await _oai().chat.completions.create(**kwargs)
raw = (resp.choices[0].message.content or "").strip()
# Strip fences
if raw.startswith("```"):
lines = raw.split("\n")
raw = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:])
result = json.loads(raw)
except json.JSONDecodeError as exc:
logger.error("x_analysis JSON parse error for @%s: %s", handle, exc)
out = dict(_FALLBACK)
out["error"] = f"parse_error: {exc}"
return out
except Exception as exc:
logger.error("x_analysis API error for @%s: %s", handle, exc)
out = dict(_FALLBACK)
out["error"] = f"api_error: {exc}"
return out
# ── Normalize ────────────────────────────────────────────────────
post_type = (result.get("post_type") or "original").lower()
if post_type not in VALID_POST_TYPES:
post_type = "original"
tier = (result.get("tier") or "noise").lower()
if tier not in VALID_TIERS:
tier = "noise"
# Enforce: retweet → noise
if post_type == "retweet":
tier = "noise"
# Normalize tickers
raw_tickers = result.get("tickers") or []
cleaned_tickers = []
if tier != "noise":
for t in raw_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 VALID_ACTIONS:
action = "mention"
try:
conv = float(t.get("conviction") or 0)
except (TypeError, ValueError):
conv = 0.0
conv = max(0.0, min(1.0, conv))
timeframe = (t.get("timeframe") or "unspecified").lower()
if timeframe not in VALID_TIMEFRAMES:
timeframe = "unspecified"
cleaned_tickers.append({
"ticker": sym,
"action": action,
"conviction": round(conv, 2),
"timeframe": timeframe,
"stance_change": bool(t.get("stance_change", False)),
"quote": (t.get("quote") or "")[:100],
})
# Enforce: trade_signal requires ≥1 ticker with buy/sell/reduce + conviction ≥ 0.7
if tier == "trade_signal":
strong = [
t for t in cleaned_tickers
if t["action"] in {"buy", "sell", "reduce"} and t["conviction"] >= 0.7
]
if not strong:
tier = "directional" # downgrade rather than drop
# Enforce: noise → empty tickers
if tier == "noise":
cleaned_tickers = []
sentiment = (result.get("sentiment") or "neutral").lower()
if sentiment not in ("bullish", "bearish", "neutral"):
sentiment = "neutral"
talks_vs_trades = bool(result.get("talks_vs_trades_flag", False))
if tier == "noise":
talks_vs_trades = False
# Price targets
has_price_target = bool(result.get("has_price_target", False))
raw_pts = result.get("price_targets") or []
price_targets = []
for pt in raw_pts:
if not isinstance(pt, dict):
continue
ticker = (pt.get("ticker") or "").upper()
try:
price = float(pt.get("price") or 0)
except (TypeError, ValueError):
continue
direction = (pt.get("direction") or "up").lower()
if ticker and price > 0 and direction in ("up", "down"):
price_targets.append({"ticker": ticker, "price": price, "direction": direction})
if not price_targets:
has_price_target = False
return {
"post_type": post_type,
"tier": tier,
"summary": (result.get("summary") or "").strip() or None,
"tickers": cleaned_tickers,
"talks_vs_trades_flag": talks_vs_trades,
"has_price_target": has_price_target,
"price_targets": price_targets,
"sentiment": sentiment,
"reasoning": str(result.get("reasoning", ""))[:500],
"model": model,
"version": ANALYSIS_VERSION,
"error": None,
}
+2 -1
View File
@@ -30,7 +30,8 @@ services:
ENCRYPTION_KEY: ${ENCRYPTION_KEY} ENCRYPTION_KEY: ${ENCRYPTION_KEY}
AI_API_KEY: ${AI_API_KEY} AI_API_KEY: ${AI_API_KEY}
AI_BASE_URL: ${AI_BASE_URL:-https://api.gptsapi.net/v1} AI_BASE_URL: ${AI_BASE_URL:-https://api.gptsapi.net/v1}
AI_MODEL: ${AI_MODEL:-claude-sonnet-4-6} AI_MODEL: ${AI_MODEL:-deepseek-v4-pro}
INGEST_BASE_URL: http://localhost:8000
HL_MAINNET: ${HL_MAINNET:-true} HL_MAINNET: ${HL_MAINNET:-true}
ENVIRONMENT: ${ENVIRONMENT:-production} ENVIRONMENT: ${ENVIRONMENT:-production}
healthcheck: healthcheck:
+1
View File
@@ -22,3 +22,4 @@ redis==7.0.1
apscheduler==3.11.2 apscheduler==3.11.2
python-dotenv==1.2.1 python-dotenv==1.2.1
cryptography==46.0.7 cryptography==46.0.7
slowapi==0.1.9
+17 -7
View File
@@ -1,8 +1,12 @@
""" """
AI 信号写回数据库 Backfill AI analysis onto historical Truth posts.
跳过纯RT/URL帖子和已有signal的帖子
并发执行提速自动限速避免API超限 Safety rules:
用法: python scripts/backfill_signals.py [--limit 500] [--overwrite] * Only re-analyze `source='truth'` rows. Technical/scanner posts already
carry their own signal payloads and must not be sent through the Trump
text analyzer.
* Persist the full analysis payload so history doesn't become a mixed schema
of old partial rows and new v5 rows.
""" """
import asyncio import asyncio
import argparse import argparse
@@ -38,8 +42,11 @@ async def process_one(post: Post, semaphore: asyncio.Semaphore, overwrite: bool)
p.relevant = analysis["relevant"] p.relevant = analysis["relevant"]
p.prefilter_reason = analysis.get("prefilter_reason") p.prefilter_reason = analysis.get("prefilter_reason")
p.analysis_version = analysis.get("analysis_version") p.analysis_version = analysis.get("analysis_version")
if analysis["relevant"] and analysis["asset"] and not p.price_impact_asset: p.price_impact_asset = analysis["asset"] if analysis["relevant"] else None
p.price_impact_asset = analysis["asset"] p.target_asset = analysis.get("target_asset")
p.category = analysis.get("category")
p.expected_move_pct = analysis.get("expected_move_pct")
p.invalidation_price = analysis.get("invalidation_price")
await db.commit() await db.commit()
sig = analysis["signal"] sig = analysis["signal"]
@@ -49,7 +56,10 @@ async def process_one(post: Post, semaphore: asyncio.Semaphore, overwrite: bool)
async def main(limit: int, overwrite: bool): async def main(limit: int, overwrite: bool):
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
result = await db.execute( result = await db.execute(
select(Post).order_by(Post.published_at.desc()).limit(limit) select(Post)
.where(Post.source == "truth")
.order_by(Post.published_at.desc())
.limit(limit)
) )
posts = result.scalars().all() posts = result.scalars().all()
+65 -3
View File
@@ -13,7 +13,7 @@ What it does:
* KOL Substack / podcast / blog polls * KOL Substack / podcast / blog polls
* KOL on-chain snapshots (HL perps + Etherscan ERC-20 balances) * KOL on-chain snapshots (HL perps + Etherscan ERC-20 balances)
* KOL divergence detector * KOL divergence detector
* BTC bottom-reversal + funding-reversal scanners * Optional: BTC bottom-reversal + funding-reversal scanners
What it INTENTIONALLY DOES NOT touch: What it INTENTIONALLY DOES NOT touch:
* posts where source IN ('truth', 'btc_bottom_reversal', 'funding_reversal', * posts where source IN ('truth', 'btc_bottom_reversal', 'funding_reversal',
@@ -33,6 +33,10 @@ Usage:
DATABASE_URL='sqlite+aiosqlite:///./trumpsignal.db' \\ DATABASE_URL='sqlite+aiosqlite:///./trumpsignal.db' \\
venv/bin/python scripts/launch_seed.py --dry-run venv/bin/python scripts/launch_seed.py --dry-run
# Seed only (pure pre-launch warmup, no truncation / deletion):
DATABASE_URL='sqlite+aiosqlite:///./trumpsignal.db' \\
venv/bin/python scripts/launch_seed.py --seed-only
# Execute: # Execute:
DATABASE_URL='sqlite+aiosqlite:///./trumpsignal.db' \\ DATABASE_URL='sqlite+aiosqlite:///./trumpsignal.db' \\
venv/bin/python scripts/launch_seed.py --yes venv/bin/python scripts/launch_seed.py --yes
@@ -198,7 +202,29 @@ async def wipe_phase(dry_run: bool) -> None:
print(green(" ✓ wipe committed")) print(green(" ✓ wipe committed"))
async def seed_real_data() -> None: async def abort_if_live_user_state() -> bool:
"""Refuse destructive launch seeding if the DB already looks user-live."""
async with AsyncSessionLocal() as db:
active_subs = (await db.execute(
select(func.count(Subscription.id)).where(Subscription.active == True)
)).scalar() or 0
tg_bindings = (await db.execute(
select(func.count(TelegramBinding.id))
)).scalar() or 0
if active_subs or tg_bindings:
print(red(
"Refusing to run launch_seed on a DB that already has live user state "
f"(active_subscriptions={active_subs}, telegram_bindings={tg_bindings})."
))
print(yellow(
"Use --dry-run to inspect only. If you truly intend to clean this DB, "
"do it manually with a one-off migration/backup plan."
))
return True
return False
async def seed_real_data(*, exercise_scanners: bool = False) -> None:
"""Re-fetch every upstream source. All are idempotent on (source, external_id).""" """Re-fetch every upstream source. All are idempotent on (source, external_id)."""
print(bold("\n── SEED (re-running upstream fetches) ──")) print(bold("\n── SEED (re-running upstream fetches) ──"))
@@ -237,6 +263,7 @@ async def seed_real_data() -> None:
except Exception as e: except Exception as e:
print(red(f" ✗ FAILED: {type(e).__name__}: {e}")) print(red(f" ✗ FAILED: {type(e).__name__}: {e}"))
if exercise_scanners:
print("\n [5/5] BTC bottom + funding reversal scanners (exercise the path)...") print("\n [5/5] BTC bottom + funding reversal scanners (exercise the path)...")
from app.services.scanners.btc_bottom_reversal import scan_once as btc_scan from app.services.scanners.btc_bottom_reversal import scan_once as btc_scan
from app.services.scanners.funding_reversal import scan_once as funding_scan from app.services.scanners.funding_reversal import scan_once as funding_scan
@@ -246,6 +273,11 @@ async def seed_real_data() -> None:
print(green(f"{name} scan completed (fire conditional on market state)")) print(green(f"{name} scan completed (fire conditional on market state)"))
except Exception as e: except Exception as e:
print(red(f"{name} FAILED: {type(e).__name__}: {e}")) print(red(f"{name} FAILED: {type(e).__name__}: {e}"))
else:
print(yellow(
"\n [5/5] Scanner exercise skipped by default. "
"Use --exercise-scanners only if you explicitly want signal emission side-effects."
))
async def main() -> int: async def main() -> int:
@@ -253,16 +285,46 @@ async def main() -> int:
formatter_class=argparse.RawDescriptionHelpFormatter) formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--dry-run", action="store_true", p.add_argument("--dry-run", action="store_true",
help="show what would be deleted, no DB writes") help="show what would be deleted, no DB writes")
p.add_argument("--seed-only", action="store_true",
help="run upstream fetch/warmup only; skip all wipe/truncation steps")
p.add_argument("--yes", action="store_true", p.add_argument("--yes", action="store_true",
help="actually perform the wipe (required without --dry-run)") help="actually perform the wipe (required without --dry-run)")
p.add_argument("--no-seed", action="store_true", p.add_argument("--no-seed", action="store_true",
help="skip the upstream re-fetch step") help="skip the upstream re-fetch step")
p.add_argument("--exercise-scanners", action="store_true",
help="also run standalone scanners during seed (may emit signals/alerts)")
args = p.parse_args() args = p.parse_args()
if args.seed_only and args.dry_run:
print(red("Choose either --dry-run or --seed-only, not both."))
return 2
if args.seed_only and args.no_seed:
print(red("--seed-only conflicts with --no-seed."))
return 2
if args.seed_only:
before = await report_counts("BEFORE")
await seed_real_data(exercise_scanners=args.exercise_scanners)
after = await report_counts("AFTER")
print(bold("\n── DELTA ──"))
for k in sorted(before):
if k.startswith("_"): continue
d = after[k] - before[k]
arrow = green(f"+{d}") if d > 0 else (red(str(d)) if d < 0 else " ·")
print(f" {k:25s} {before[k]:6d}{after[k]:6d} ({arrow})")
print(bold(green("\n✓ seed-only warmup complete. Next: start backend and run scripts/launch_smoke.py.")))
return 0
if not args.dry_run and not args.yes: if not args.dry_run and not args.yes:
print(red("Refusing to run without --yes (or use --dry-run to preview).")) print(red("Refusing to run without --yes (or use --dry-run to preview)."))
return 2 return 2
if not args.dry_run and await abort_if_live_user_state():
return 2
before = await report_counts("BEFORE") before = await report_counts("BEFORE")
await wipe_phase(dry_run=args.dry_run) await wipe_phase(dry_run=args.dry_run)
@@ -271,7 +333,7 @@ async def main() -> int:
return 0 return 0
if not args.no_seed: if not args.no_seed:
await seed_real_data() await seed_real_data(exercise_scanners=args.exercise_scanners)
after = await report_counts("AFTER") after = await report_counts("AFTER")
+61
View File
@@ -73,6 +73,16 @@ SEED_WALLETS: list[dict] = [
"label": "Arthur Hayes (secondary)", "label": "Arthur Hayes (secondary)",
"source_url": "https://etherscan.io/address/0x534a0076fb7c2b1f83fa21497429ad7ad3bd7587", "source_url": "https://etherscan.io/address/0x534a0076fb7c2b1f83fa21497429ad7ad3bd7587",
}, },
# ⚠️ ORPHANED: the two entries below have VERIFIED on-chain attribution
# but their `handle` does NOT match any handle in KOL_FEEDS, so the
# divergence scanner has no post-side data to join against — they produce
# ZERO divergence detections today. They are kept (a) because the wallet
# attribution is sound and (b) so that if/when X/Twitter ingestion or a
# matching RSS feed is added under these handles, the wallets light up
# automatically. The cross-check in main() prints a warning for these.
# • andrewkang — publishes on X/@Rewkang only (no Substack) → needs
# X ingestion before post-side data exists.
# • murad — not currently in KOL_FEEDS at all.
{ {
"handle": "andrewkang", "handle": "andrewkang",
"chain": "ethereum", "chain": "ethereum",
@@ -152,6 +162,57 @@ async def main() -> int:
print() print()
print(f"Inserted {inserted} wallets, skipped {skipped} existing.") print(f"Inserted {inserted} wallets, skipped {skipped} existing.")
# ── Coverage cross-check ─────────────────────────────────────────────
# A wallet is only useful for divergence detection if its handle has a
# matching post-side feed in KOL_FEEDS. Flag any orphans loudly, and
# report which feeds still have no wallet at all (the real coverage gap).
try:
from app.services.kol_substack import KOL_FEEDS
feed_handles = {f["handle"] for f in KOL_FEEDS}
except Exception as exc: # pragma: no cover - diagnostic only
print(f"\n(could not import KOL_FEEDS for cross-check: {exc})")
return 0
wallet_handles = {e["handle"] for e in SEED_WALLETS}
orphaned = sorted(wallet_handles - feed_handles)
feeds_without_wallet = sorted(feed_handles - wallet_handles)
print()
print(f"Coverage: {len(wallet_handles - set(orphaned))}/{len(feed_handles)} "
f"KOL_FEEDS handles have ≥1 wallet.")
# ── Orphan reconciliation ────────────────────────────────────────────
# An orphaned wallet (handle ∉ KOL_FEEDS) can NEVER produce a divergence
# because there's no post-side data to join against — but kol_onchain
# still burns a scan cycle (HL clearinghouseState + Etherscan) on it every
# run. Park such rows as active=False so the scanner skips them. This is
# REVERSIBLE: the row + its verified attribution stay in the DB, and the
# moment a matching feed (e.g. X/Twitter ingestion) is added, flip it back.
if orphaned:
async with AsyncSessionLocal() as session:
res = await session.execute(
select(KolWallet).where(
KolWallet.handle.in_(orphaned),
KolWallet.active.is_(True),
)
)
rows = res.scalars().all()
for row in rows:
row.active = False
await session.commit()
deactivated = len(rows)
print()
print(f"⚠️ ORPHANED wallets (handle not in KOL_FEEDS → ZERO divergence) "
f"— deactivated {deactivated} so the scanner skips them:")
for h in orphaned:
print(f" {h} — re-activate once a feed under this handle exists.")
if feeds_without_wallet:
print()
print(f"📭 {len(feeds_without_wallet)} feeds have NO wallet (no on-chain "
f"divergence possible):")
print(" " + ", ".join(feeds_without_wallet))
print() print()
print("Next step: edit SEED_WALLETS above and re-run. Each new wallet") 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.") print("MUST cite a public attestation in source_url — see the docstring.")
+47
View File
@@ -5,6 +5,8 @@ from app.services.macro.fetchers import (
_latest_closed_daily_point, _latest_closed_daily_point,
_parse_farside_latest_total, _parse_farside_latest_total,
) )
from app.services.bottom_indicators import ahr999 as scanner_ahr999
from app.services.macro import fetchers
def test_drop_in_progress_daily_klines_removes_today_open_bar(): def test_drop_in_progress_daily_klines_removes_today_open_bar():
@@ -48,3 +50,48 @@ def test_parse_farside_latest_total_uses_newest_date_not_first_row():
assert parsed["value"] == 321_000_000.0 assert parsed["value"] == 321_000_000.0
assert parsed["raw"]["date"] == "25 May 2026" assert parsed["raw"]["date"] == "25 May 2026"
def test_macro_ahr999_uses_same_formula_as_scanner(monkeypatch):
# Build 300 daily bars with realistic OPEN timestamps so the fetcher's
# in-progress-day drop (_drop_in_progress_daily_klines) actually fires on
# the last bar (today's 00:00 UTC). With fake integer timestamps the drop
# is a no-op, so the fetcher would compute AHR999 over one MORE bar than
# the scanner below — producing a spurious 4th-decimal mismatch (the
# fetcher and scanner share the exact same ahr999() function, so identical
# inputs must yield identical output).
now = datetime.now(timezone.utc)
midnight_ms = int(
now.replace(hour=0, minute=0, second=0, microsecond=0).timestamp() * 1000
)
day_ms = 86_400_000
# Row i opens at (299 - i) days before today's midnight; the last row
# (i=299) opens at today's midnight → still in progress → dropped.
closes = [
[midnight_ms - (299 - i) * day_ms, 0, 0, 0, str(50_000 + i)]
for i in range(300)
]
class _Resp:
def raise_for_status(self):
return None
def json(self):
return closes
class _Client:
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return None
async def get(self, *_args, **_kwargs):
return _Resp()
monkeypatch.setattr(fetchers.httpx, "AsyncClient", lambda *a, **kw: _Client())
result = __import__("asyncio").run(fetchers.fetch_ahr999())
expected = scanner_ahr999([float(r[4]) for r in closes[:-1]])
assert result["value"] == round(expected or 0, 4)