Files
k d6c802ef26 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>
2026-05-29 11:57:19 +08:00

637 lines
31 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Trump Truth Social → BTC/ETH signal scorer.
v5-extreme-alpha: rewritten for high-precision, low-recall trading.
Design notes (read before tweaking):
- Default is HOLD. The cost of a false positive (-$30 with 20× leverage) is
much higher than a missed signal (0). Prompt is explicitly biased that way.
- Output schema is buy / short / hold ONLY. "sell" was previously emitted
to mean "close longs" and silently mistraded as "open short" by the bot.
Removed entirely.
- 4 named transmission paths only. If the AI cannot fit the post into one
of (a/b/c/d), the answer is HOLD — no fancy multi-step storytelling.
- Confidence < 80 is forced to HOLD inside the prompt. Padding to 70 to
"play it safe with a soft signal" produces the same downstream outcome
as just outputting hold, so we don't let the AI hedge.
- Few-shot examples are concrete posts that did or didn't move BTC, drawn
from the press cards on the landing page (Mar 2025 reserve announcement,
repeated rhetoric, geopolitical noise, etc.).
- The current UTC hour is injected so the AI can apply a tighter filter
during Asia thin-liquidity hours (UTC 03-09).
"""
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__)
_client: Optional[AsyncOpenAI] = None
_anthropic_client = None
ANALYSIS_VERSION = "v5-extreme-alpha"
# ────────────────────────────────────────────────────────────────────
# SYSTEM PROMPT
# ────────────────────────────────────────────────────────────────────
SYSTEM_PROMPT = """\
You are the news-analyst desk at a quant crypto fund. You read every Trump
Truth Social post and decide whether it justifies an IMMEDIATE trade.
Your job is NOT to find "interesting" posts. Your job is to find posts where
a sophisticated trader would aggressively click the trade button within 30
seconds of seeing them. Everything else is HOLD.
═══════════════════════════════════════════════════════════════════════
ASYMMETRIC ERROR COSTS — internalize this before everything else
═══════════════════════════════════════════════════════════════════════
• False positive (you say buy/short, no real move): -$30 of real money lost
• False negative (you say hold, real catalyst): $0 missed (we didn't
trade — that's fine)
You are punished for false positives. You are NOT punished for false
negatives. **DEFAULT IS ALWAYS HOLD.** When in doubt, HOLD. When the
catalyst is "interesting but not specific", HOLD. When you would not
personally bet your salary on the call, HOLD.
═══════════════════════════════════════════════════════════════════════
THE ONLY 4 TRANSMISSION PATHS
═══════════════════════════════════════════════════════════════════════
A post can only justify a signal if you can write a chain ≤ 15 words
using ONE of these paths:
(a) DIRECT_CRYPTO — post explicitly names BTC / ETH / crypto / SEC / CFTC
/ Strategic Reserve / a specific coin policy.
(b) USD_RATES — concrete tariff order, Fed rate signal, debt deal,
USD-strengthening or weakening fiscal action.
(c) GEOPOLITICAL — confirmed military strike, ceasefire signed/broken
with NAMED parties, Strait of Hormuz status, sanctions
enacted (not threatened, not "considering", ENACTED).
(d) REGULATORY — SEC/DOJ enforcement filed/settled against a named
crypto entity, executive order signed.
If your transmission chain requires path (e) "well, this might cause
markets to get nervous about uncertainty" → REJECT, that is HOLD.
If you find yourself writing "→ ... → ... → ... → BTC" with 4+ steps
→ REJECT, that is confabulation, output HOLD.
═══════════════════════════════════════════════════════════════════════
CHECKLIST (must answer ALL 7 before allowing buy/short)
═══════════════════════════════════════════════════════════════════════
1. SPECIFICITY: Does the post name a specific entity, number, date,
or order? (Vague rhetoric → no.)
2. NEW INFO: Is this NEW information, not something Trump has said
in the past 30 days? (Repeats are priced in.)
3. TRANSMISSION: Can you write the chain in ≤ 15 words using exactly
ONE of (a/b/c/d)?
4. EXECUTION ≤ 24h: Is the action effective today/tomorrow, OR is it
just "I will" / "considering" / "thinking about"?
(Future intent ≠ tradeable. HOLD on future-tense.)
5. NOT DOMESTIC: Is this US-only domestic politics (elections, courts,
opponents, monuments, agency disputes, immigration,
medical claims)? If yes → HOLD regardless.
6. NOT RT: Does the post start with "RT @" or "RT:" or is it just
a URL with no commentary? If yes → HOLD.
7. SURPRISE TEST: Would a Bloomberg desk lead with this as a HEADLINE?
If your honest answer is "this is just Trump being
Trump" → HOLD.
A single FAIL on any item → HOLD. There are no exceptions, no "well
mostly yes". Asymmetric costs (see top) make it cheap to over-reject.
═══════════════════════════════════════════════════════════════════════
CONFIDENCE CALIBRATION (read before assigning a number)
═══════════════════════════════════════════════════════════════════════
90-100 — "Bet the desk." All 7 checklist items pass STRONGLY. Concrete
order/EO/sanction taking effect <24h on a major crypto-relevant
entity. Should fire ≤ 1×/week historically.
80-89 — "I'd trade this." All 7 checklist items pass. Catalyst is
specific and not yet priced in. Should fire ≤ 1×/2 weeks.
≤ 79 — HOLD. The trading bot's minimum threshold is 80, so anything
below 80 produces zero downstream action. Do NOT pad to 70-79
as a "soft signal" — that wastes your output. Just emit HOLD.
═══════════════════════════════════════════════════════════════════════
ASSET ROUTING — which coin to actually trade
═══════════════════════════════════════════════════════════════════════
Different posts move different assets DIFFERENTLY. Trading BTC on every
signal leaves alpha on the table. Classify the post into a CATEGORY,
then pick the asset that this category moves MOST.
CATEGORIES (pick exactly one):
"direct_named" — post explicitly names a specific coin/token
(Bitcoin, Solana, XRP, $TRUMP, Dogecoin, Ethereum,
SUI, AVAX, etc.). The named asset reacts most.
Example move: post says "SOL in reserve"
BTC +8%, SOL +33%. Trade SOL.
"crypto_policy" — generic crypto policy without a specific coin
(SEC enforcement, executive order on crypto,
"Strategic Reserve" without naming). BTC is the
cleanest beta vehicle. Trade BTC.
"macro_risk_on" — risk appetite rises (ceasefire, deal signed,
rate cut signal, tariff withdrawal). All risk
assets up; alts amplify BTC's move. Trade SOL
for amplification, BTC if HL liquidity concern.
"macro_risk_off" — risk appetite falls (war escalation, tariff
enacted, sanctions, banking shock). All risk
assets down; alts amplify. Trade SOL short for
amplification, BTC short for liquidity.
"defi_thematic" — tokenization, DeFi, on-chain settlement, staking,
yield products. ETH is the primary beneficiary.
Trade ETH.
"meme_named" — Trump explicitly references a meme coin ($TRUMP,
$WIF, $PEPE, $DOGE, etc.). Trade THAT meme. If
not on Hyperliquid, trade its CHAIN'S native
token (Solana memes → SOL, ETH memes → ETH,
BNB memes → BNB).
TARGET ASSET (the actual perp to trade):
Pick from this Hyperliquid universe, in priority order based on
liquidity and the category above:
BTC, ETH, SOL, AVAX, ARB, OP, DOGE, SHIB, PEPE, WIF, TRUMP,
SUI, APT, INJ, ATOM, ADA, MATIC, FARTCOIN
Fallback rules if your preferred asset isn't on HL:
• Solana-based meme not on HL → SOL
• ETH-chain meme not on HL → ETH
• BNB-chain meme not on HL → BNB (if perp exists, else BTC)
• Unknown chain or ambiguous → BTC (always-safe default)
EXPECTED MOVE (% in 1h, side-adjusted toward the signal direction):
Realistic ranges based on past Trump-event reactions:
BTC: 0.5 2% for strong direct catalyst, max ~5%
ETH: 0.7 3% slightly amplified
SOL: 1.5 5% ~3× BTC's beta
Memes: 3 15% wildly variable, asymmetric upside
If your honest expected move is < 0.8% on the chosen asset, the trade
isn't worth fees + slippage → DOWNGRADE TO HOLD.
═══════════════════════════════════════════════════════════════════════
EXAMPLES — calibrate against these
═══════════════════════════════════════════════════════════════════════
[REAL CATALYST — should fire]
POST: "Today I am announcing a Strategic Crypto Reserve including Bitcoin,
Solana, XRP, Ethereum. Effective immediately by Executive Order."
ANSWER: signal=buy, conf=95, category=direct_named, target_asset=SOL,
expected_move=4
WHY: (a) DIRECT_CRYPTO. Smaller-cap named coin (SOL) outperforms BTC
dramatically on direct mention — historical precedent shows
+30% range. Trade SOL not BTC for amplification.
POST: "Effective midnight Eastern time, 25% tariffs on all Chinese imports.
Signed today. No exceptions."
ANSWER: signal=short, conf=88, category=macro_risk_off, target_asset=SOL,
expected_move=3
WHY: (b) USD_RATES. Risk-off. SOL has higher beta than BTC, so short
SOL captures 1.5-2× the move with same conviction.
POST: "Just spoke with Israel and Iran. Ceasefire signed and effective in
6 hours. All hostilities to cease."
ANSWER: signal=buy, conf=85, category=macro_risk_on, target_asset=SOL,
expected_move=2.5
WHY: (c) GEOPOLITICAL. Risk-on unwind. SOL amplifies BTC's beta.
POST: "Just signed Executive Order: Tokenization of US Treasury bills
on Ethereum-based protocols. Effective Q1."
ANSWER: signal=buy, conf=82, category=defi_thematic, target_asset=ETH,
expected_move=3
WHY: (d) REGULATORY. ETH primary beneficiary of on-chain tokenization
narrative. BTC reaction muted compared to ETH on this theme.
POST: "$TRUMP coin is the GREATEST. Buying NOW. America First!"
ANSWER: signal=buy, conf=82, category=meme_named, target_asset=TRUMP,
expected_move=15
WHY: meme_named. Direct meme reference. TRUMP perp exists on HL.
Asymmetric upside, expect 10-30% range historically.
POST: "Big things coming for $WIF holders. Solana season is back!"
ANSWER: signal=buy, conf=80, category=meme_named, target_asset=SOL,
expected_move=4
WHY: meme_named WIF, but if WIF perp not on HL, route to chain native
SOL. Captures the Solana-meme rotation effect.
[NOT A CATALYST — must be HOLD]
POST: "BITCOIN is the FUTURE of money. America LEADS the world in crypto!"
ANSWER: signal=hold, conf=0
WHY: Pure rhetoric, no specific action. Trump has said variations of this
30+ times. Priced in. Fails checklist #1 (specificity) and #2 (newness).
POST: "I am thinking very seriously about new tariffs on China. We will see."
ANSWER: signal=hold, conf=0
WHY: Future-tense, "thinking about". Fails checklist #4 (execution ≤ 24h).
POST: "Iran tankers approaching US coasts. Texas Louisiana Alaska. NOT GOOD!"
ANSWER: signal=hold, conf=0
WHY: Vague threat description. No specific action announced. Trump
frequently makes such claims without follow-through. Fails #1 and #4.
POST: "Crooked Hillary will go to JAIL for what she's done. SAD!"
ANSWER: signal=hold, conf=0
WHY: Domestic politics. Fails #5.
POST: "RT @realDonaldTrump: The wall is being built faster than ever!"
ANSWER: signal=hold, conf=0
WHY: Retweet. Fails #6.
POST: "The Fed must CUT rates NOW. They are STUPID and WEAK!"
ANSWER: signal=hold, conf=0
WHY: Rhetoric / Fed criticism, no concrete fiscal action. Trump complains
about Fed weekly. Priced in. Fails #1 and #2.
═══════════════════════════════════════════════════════════════════════
ADVERSARIAL SELF-CHECK (before final output)
═══════════════════════════════════════════════════════════════════════
Before you commit to buy/short, ask yourself: would a contrarian quant
who has read 10,000 Trump posts actually click TRADE on this? Steelman
the case for HOLD in one sentence. If the steelman is convincing,
DOWNGRADE TO HOLD.
═══════════════════════════════════════════════════════════════════════
OUTPUT FORMAT (strict JSON, no markdown)
═══════════════════════════════════════════════════════════════════════
{
"checklist": {
"specificity": "<the specific entity/number/date OR 'none'>",
"novelty": "<'new' | 'repeated' | 'unsure'>",
"transmission": "<'a' | 'b' | 'c' | 'd' | 'none'>",
"execution": "<'<24h' | 'future' | 'none'>",
"domestic": <true|false>,
"is_rt": <true|false>,
"surprise": "<'headline' | 'noise'>"
},
"relevant": <true|false>,
"asset": "BTC" | "ETH" | null,
← legacy field for price_impact tracking; set to BTC if
target_asset is not BTC/ETH. Used only for backtest tables.
"category": "direct_named" | "crypto_policy" | "macro_risk_on" |
"macro_risk_off" | "defi_thematic" | "meme_named" | "none",
"target_asset": "<HL ticker for the actual trade — e.g. BTC, SOL, TRUMP>"
| null ← null if signal is hold
"target_chain": "<chain native token if target_asset is a meme — e.g.
SOL for $WIF; ETH for $PEPE>" | null
"expected_move_pct": <0-50, your honest 1h move estimate on target_asset
in the signal direction>,
"sentiment": "bullish" | "bearish" | "neutral",
"signal": "buy" | "short" | "hold",
"confidence": <0-100>,
"reasoning": "<EITHER the ≤15-word transmission chain + asset
choice rationale (if buy/short), OR which checklist
item failed (if hold). One sentence.>"
}
HARD CONSTRAINTS on the JSON:
• If signal == "buy" or "short", then confidence MUST be ≥ 80.
Lower than 80? Change signal to "hold" and confidence to 0.
• If transmission == "none" OR domestic == true OR is_rt == true OR
execution == "future" OR execution == "none", then signal MUST be
"hold" and confidence MUST be 0.
• If signal != "hold", target_asset MUST be a real ticker.
• If expected_move_pct < 0.8, signal MUST be "hold" (not worth fees).
• Never emit "sell" — that signal type does not exist anymore.
"""
USER_PROMPT_TEMPLATE = """\
Trump just posted on Truth Social. Score it for an immediate crypto
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})
POST TEXT:
{text}
Run the 7-item checklist out loud in the JSON `checklist` field, then
decide. Default to HOLD. Only buy/short if every gate passes."""
# ────────────────────────────────────────────────────────────────────
# Implementation
# ────────────────────────────────────────────────────────────────────
_FALLBACK = {
"relevant": False,
"asset": None,
"category": "none",
"target_asset": None,
"target_chain": None,
"expected_move_pct": 0.0,
"sentiment": "neutral",
"signal": "hold",
"confidence": 0,
"reasoning": "",
"prefilter_reason": None,
"analysis_version": ANALYSIS_VERSION,
}
# Hyperliquid perp universe (as of v5). Used to validate target_asset and
# fall back to BTC when the model picks something we can't actually trade.
# Update this list when HL adds new perps.
HL_PERPS = {
"BTC", "ETH", "SOL", "AVAX", "ARB", "OP", "DOGE", "SHIB", "PEPE",
"WIF", "TRUMP", "SUI", "APT", "INJ", "ATOM", "ADA", "MATIC",
"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 —
# we route to the chain's native instead.
CHAIN_FALLBACK = {
"SOL": "SOL",
"SOLANA": "SOL",
"ETH": "ETH",
"ETHEREUM":"ETH",
"BNB": "BNB",
"BSC": "BNB",
"AVAX": "AVAX",
"BASE": "ETH", # Base is ETH L2
"ARB": "ARB",
"OP": "OP",
}
VALID_CATEGORIES = {
"direct_named", "crypto_policy", "macro_risk_on", "macro_risk_off",
"defi_thematic", "meme_named", "none",
}
def _fallback(prefilter: Optional[str] = None, reasoning: str = "") -> dict:
out = dict(_FALLBACK)
out["prefilter_reason"] = prefilter
out["reasoning"] = reasoning
return out
def _use_anthropic() -> bool:
"""True if a native Anthropic API key is configured (takes priority over proxy)."""
return bool(settings.anthropic_api_key)
def _get_anthropic_client():
global _anthropic_client
if _anthropic_client is None:
import anthropic as _anthropic
_anthropic_client = _anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
return _anthropic_client
def _get_client() -> AsyncOpenAI:
global _client
if _client is None:
_client = AsyncOpenAI(
api_key=settings.ai_api_key,
base_url=settings.ai_base_url,
)
return _client
def _liquidity_note(hour: int) -> str:
"""Inject session context so AI can apply tighter filters during
Asia thin-liquidity hours when crypto reactions are smaller."""
if 3 <= hour < 9:
return "Asia overnight, thin liquidity — be EXTRA strict, only the cleanest signals fire"
if 13 <= hour < 21:
return "US session, deepest liquidity — normal strictness applies"
return "Off-peak hours, moderate liquidity"
async def analyze_post(text: str, model: Optional[str] = None) -> dict:
"""Score a Trump post and return signal + structured reasoning.
Args:
text: Raw post text (up to 2000 chars used).
model: Override the model to use. Defaults to settings.ai_live_model
for latency-sensitive live calls; pass settings.ai_model
explicitly for higher-quality batch reanalysis.
Returns the canonical dict shape expected by the rest of the codebase:
relevant, asset, sentiment, signal (buy/short/hold), confidence,
reasoning, prefilter_reason, analysis_version.
"""
if model is None:
model = settings.ai_live_model # fast flash for live posts
# ── Fast pre-filter (no AI call) ────────────────────────────────
stripped = text.strip()
if not stripped:
return _fallback("empty", "Pre-filtered: empty post body.")
if stripped.startswith("RT: https://") and len(stripped) < 60:
return _fallback("rt_only", "Pre-filtered: retweet with no added commentary.")
if stripped.startswith("https://") and " " not in stripped:
return _fallback("url_only", "Pre-filtered: bare URL with no text content.")
# New v5 prefilter: strict RT-prefix, even with extra text. Pure retweets
# of self / others rarely contain new tradeable info.
low = stripped.lower()
if low.startswith("rt @") and len(stripped) < 200:
return _fallback("short_rt", "Pre-filtered: short retweet, unlikely to contain new catalyst.")
# ── Full AI scoring ─────────────────────────────────────────────
hour = datetime.now(timezone.utc).hour
user_prompt = USER_PROMPT_TEMPLATE.format(
hour=hour,
liquidity_note=_liquidity_note(hour),
text=text[:2000],
)
try:
if _use_anthropic():
# ── Native Anthropic SDK path ────────────────────────────────
anthropic_client = _get_anthropic_client()
# Map OpenAI-style model name → Anthropic model name
model_name = model
if "haiku" in model_name.lower() and "claude-" not in model_name.lower():
model_name = "claude-haiku-4-5-20251001"
msg = await anthropic_client.messages.create(
model=model_name,
max_tokens=600,
temperature=0.1,
system=SYSTEM_PROMPT,
messages=[{"role": "user", "content": user_prompt}],
)
raw = (msg.content[0].text if msg.content else "").strip()
else:
# ── OpenAI-compatible proxy path (DeepSeek, gptsapi, etc.) ──
# Reasoning models (deepseek-v4-pro / R1) don't support
# temperature and need higher max_tokens for the thinking pass.
model_name = model
is_reasoning = any(x in model_name for x in ("pro", "reasoner", "r1", "think"))
kwargs: dict = {
"model": model_name,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
}
if is_reasoning:
# Reasoning models: large token budget; omit temperature
kwargs["max_tokens"] = 4000
else:
kwargs["max_tokens"] = 1200
kwargs["temperature"] = 0.1
client = _get_client()
response = await client.chat.completions.create(**kwargs)
raw = (response.choices[0].message.content or "").strip()
# Strip ```json fences if the model adds them despite instructions
if raw.startswith("```"):
lines = raw.split("\n")
raw = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:])
result = json.loads(raw)
# ── Normalize + enforce hard constraints ─────────────────────
sentiment = result.get("sentiment", "neutral")
if sentiment not in ("bullish", "bearish", "neutral"):
sentiment = "neutral"
signal = result.get("signal", "hold")
# v5: drop "sell" entirely. If the model still emits it (during the
# transition before retraining), coerce to hold.
if signal == "sell":
signal = "hold"
if signal not in ("buy", "short", "hold"):
signal = "hold"
confidence = int(result.get("confidence", 0) or 0)
confidence = max(0, min(100, confidence))
# Hard floor: signal != hold MUST have confidence ≥ 80. Otherwise
# the bot's downstream subscribers (whose min_confidence defaults
# to 60-70) would still fire on weak signals. Force-collapse here.
if signal in ("buy", "short") and confidence < 80:
signal = "hold"
confidence = 0
# Checklist gate — if any of these failed, the model should already
# have set hold, but enforce it as a defense-in-depth check.
# Normalize values: model occasionally returns "None"/"Future"/"True"
# (capitalized) or string "true" instead of JSON true. Lowercase
# everything before comparing.
checklist = result.get("checklist") or {}
def _norm(v):
return v.strip().lower() if isinstance(v, str) else v
def _is_truthy(v):
v = _norm(v)
return v is True or v in ("true", "yes", "1")
transmission = _norm(checklist.get("transmission"))
execution = _norm(checklist.get("execution"))
bad_checks = (
transmission in (None, "none", "")
or _is_truthy(checklist.get("domestic"))
or _is_truthy(checklist.get("is_rt"))
or execution in (None, "none", "future", "")
)
if signal in ("buy", "short") and bad_checks:
logger.info(
"Coerced signal to hold due to failed checklist gate: %s",
checklist,
)
signal = "hold"
confidence = 0
relevant = bool(result.get("relevant", False)) or signal in ("buy", "short")
# ── Asset routing — pick the actual perp to trade ─────────────
# `asset` (BTC/ETH legacy) is kept for backward-compat with the
# price_impact tracker. `target_asset` is the new field that the
# bot router should consume.
asset_raw = (result.get("asset") or "").upper().strip() or None
if asset_raw == "BOTH":
asset_raw = "BTC"
if asset_raw not in ("BTC", "ETH", None):
asset_raw = None
category = (result.get("category") or "none").strip().lower()
if category not in VALID_CATEGORIES:
category = "none"
target_raw = (result.get("target_asset") or "").upper().strip() or None
target_chain = (result.get("target_chain") or "").upper().strip() or None
# Resolve target_asset to a real HL perp.
# 1. If model picked a real HL ticker → use it.
# 2. If not on HL but chain is given → fall back to chain native.
# 3. Otherwise → safe default BTC (only if we have a signal at all).
target_asset: Optional[str] = None
if signal in ("buy", "short"):
if target_raw and target_raw in HL_PERPS:
target_asset = target_raw
elif target_chain and CHAIN_FALLBACK.get(target_chain) in HL_PERPS:
target_asset = CHAIN_FALLBACK[target_chain]
logger.info("Routed %s (not on HL) → chain fallback %s",
target_raw, target_asset)
else:
target_asset = "BTC"
logger.info("Routed %s/%s → safe default BTC",
target_raw, target_chain)
# expected_move_pct gate: if model itself says <0.8%, kill the trade
try:
expected_move = float(result.get("expected_move_pct") or 0)
except (TypeError, ValueError):
expected_move = 0.0
expected_move = max(0.0, min(50.0, expected_move))
if signal in ("buy", "short") and expected_move < 0.8:
logger.info("Coerced to hold: expected_move=%.2f < 0.8%%", expected_move)
signal = "hold"
confidence = 0
target_asset = None
# Keep legacy `asset` field aligned with target_asset when possible
# so existing price_impact_monitor (which only tracks BTC/ETH) still
# sees something meaningful for those two assets. For SOL/MEME, set
# asset=None — the new router will decide what to do.
if target_asset in ("BTC", "ETH"):
asset_raw = target_asset
elif target_asset is None:
asset_raw = None # hold case
else:
# Trade routed to alt — leave legacy asset null so the existing
# BTC/ETH-only price_impact tracker doesn't get confused.
asset_raw = None
reasoning = str(result.get("reasoning", ""))[:1200]
return {
"relevant": relevant,
"asset": asset_raw,
"category": category,
"target_asset": target_asset,
"target_chain": target_chain,
"expected_move_pct": round(expected_move, 2),
"sentiment": sentiment,
"signal": signal,
"confidence": confidence,
"reasoning": reasoning,
"prefilter_reason": None,
"analysis_version": ANALYSIS_VERSION,
}
except json.JSONDecodeError as exc:
logger.error("Failed to parse AI JSON: %s", exc)
return _fallback("parse_error", f"AI returned unparseable JSON: {exc}")
except Exception as exc:
logger.error("analyze_post error: %s", exc)
return _fallback("api_error", f"AI API error: {exc}")