d6c802ef26
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>
637 lines
31 KiB
Python
637 lines
31 KiB
Python
"""
|
||
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}")
|