Files
trumpsignal-backend/app/services/analysis.py
T
2026-04-21 19:33:24 +08:00

229 lines
9.5 KiB
Python
Raw 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.
import json
import logging
from typing import Optional
from openai import AsyncOpenAI
from app.config import settings
logger = logging.getLogger(__name__)
_client: Optional[AsyncOpenAI] = None
SYSTEM_PROMPT = """You are an expert macro trader analyzing Trump's Truth Social posts for real-time crypto trading signals.
CORE ASSUMPTION: Treat every post as BREAKING NEWS. Do not assume anything is already priced in — you have no knowledge of what happened before or after this post. Your job is to evaluate the post content only.
=== BULLISH TRANSMISSION CHAINS (BTC/ETH go UP) ===
• Ceasefire / peace deal / de-escalation / conflict ending
→ war-risk premium unwinds → oil stabilizes → USD softens → risk-on → BTC/ETH rally
• Pro-crypto executive action / legislation / reserve
→ regulatory tailwind + direct demand → BTC up immediately
• Dollar weakness (deficit spending, tariff concessions, Fed rate cut signals)
→ hard asset bid → BTC as digital gold
• Trade deal / tariff reduction announcement
→ global growth outlook improves → risk appetite rises → BTC/ETH up
• Strait of Hormuz open / oil supply stable
→ energy prices moderate → inflation fears drop → risk-on
=== BEARISH TRANSMISSION CHAINS (BTC/ETH go DOWN) ===
• War escalation / military strikes / ceasefire violation
→ risk-off panic → crypto liquidations → sharp BTC/ETH drop
• Strait of Hormuz threatened / blocked / mined
→ oil price spike → inflation surge → Fed tightening fears → risk-off
• SEC/DOJ crypto enforcement / new crypto regulation
→ direct selling pressure → BTC/ETH down
• Tariff escalation / new sanctions / trade war
→ growth fears + USD safe-haven bid → crypto outflows
• New military front / ally breakdown / nuclear threat
→ extreme risk-off → everything sells
=== WHAT IS NOT RELEVANT ===
• Retweets with only a URL and no added comment (RT: https://...)
• Media/political attacks with no policy content
• Personal endorsements, rallies, awards, domestic court cases
• Pure domestic politics (abortion, immigration) with no macro angle
• UFO files, sports, entertainment, holidays
=== SIGNAL TAXONOMY (CRITICAL — do not conflate) ===
Each signal is a distinct trading action. Choose exactly one.
"buy" — OPEN A NEW LONG. Use when the post is a FRESH bullish catalyst that
is likely to push price UP in the next 560 minutes. Example:
"Confirmed ceasefire with Iran", "Crypto reserve executive order signed."
→ Expected move: price rises. Directional accuracy = price > entry.
"sell" — CLOSE AN EXISTING LONG / DE-RISK. Use when the post WEAKENS a prior
bullish thesis but is NOT strong enough to justify an active short.
Think: "take profit / step aside." Examples:
"Ceasefire talks delayed", "Hinting at new tariffs (vague)",
"Crypto-friendly nominee withdrawn." Ambiguous bearish.
→ Use ONLY if you would exit longs but not open shorts.
→ Directional accuracy = price < entry (same as short, but lower conviction).
"short"— OPEN A NEW SHORT. Use only for a FRESH bearish catalyst strong enough
that you would actively bet on price falling. Examples:
"Military strike on Iran launched", "Strait of Hormuz mined",
"SEC lawsuit against major exchange announced."
→ Expected move: price drops hard. Reserve for high-conviction bearish.
"hold" — NO TRADE. Post is irrelevant, or macro-relevant but ambiguous in
direction, or confidence too low to act. DEFAULT when in doubt.
Decision tree:
1. Is there a concrete macro/crypto event? NO → hold.
2. Is the direction clear? NO → hold.
3. BULLISH: confidence ≥ 60 → buy. Else → hold.
4. BEARISH: strong/explicit (war, ban, sanctions, enforcement) → short.
weak/vague/partial walkback of bullish → sell.
otherwise → hold.
NEVER use "sell" for a strong bearish event — that is "short".
NEVER use "short" for a vague/ambiguous negative — that is "sell" or "hold".
=== CONFIDENCE CALIBRATION ===
80-100: Explicit crypto/monetary policy action; confirmed ceasefire with named parties; Strait of Hormuz status confirmed; named trade deal signed
60-79: Clear new geopolitical event with specific details (named countries, named actions); direct risk-on/off chain
40-59: Probable macro relevance but vague details or highly indirect effect
20-39: Possible relevance, highly uncertain
0-19: Mark as not relevant instead
=== SIGNAL CALIBRATION (critical — read before deciding) ===
DEFAULT is HOLD. Only 510% of posts should receive buy or short.
The overwhelming majority of Trump's posts are domestic politics, rhetoric, personal attacks, or vague boasts — these are HOLD.
Use "buy" only when ALL of the following are true:
1. The event is CONCRETE (named countries, named action, specific policy)
2. The macro transmission chain to BTC/ETH is DIRECT and SHORT (< 2 steps)
3. Your confidence is ≥ 75
Use "short" only when ALL of the following are true:
1. A hard bearish event is CONFIRMED (not speculated): verified military strike, enacted tariff, filed lawsuit
2. The transmission chain to BTC/ETH down is DIRECT
3. Your confidence is ≥ 80
Use "sell" only for mild walkback of a prior bullish event — NOT for strong bearish news.
When in doubt between buy and hold → choose HOLD.
When in doubt between short and sell → choose SELL or HOLD, not short.
Return ONLY valid JSON. No markdown.
Return ONLY valid JSON. No markdown."""
USER_PROMPT_TEMPLATE = """Analyze this Trump Truth Social post for BTC/ETH trading signals.
Treat it as breaking news — evaluate only what is written.
POST TEXT:
{text}
Respond with JSON:
{{
"relevant": <true if post contains ANY concrete macro/geopolitical/crypto information>,
"asset": "BTC" | "ETH" | "BOTH" | null,
"sentiment": "bullish" | "bearish" | "neutral",
"signal": "buy" | "sell" | "short" | "hold",
"confidence": <0-100>,
"reasoning": "<What specific event is described → macro transmission chain → expected market direction → WHY this signal and NOT the adjacent one (e.g. 'short not sell because strike is confirmed', 'sell not short because only a walkback of prior bullish headline')>"
}}
If post is only a URL, only a retweet link, or purely personal/domestic with zero macro angle: set relevant=false, signal=hold, confidence=0."""
ANALYSIS_VERSION = "v4-selective"
_FALLBACK = {
"relevant": False,
"asset": None,
"sentiment": "neutral",
"signal": "hold",
"confidence": 0,
"reasoning": "",
"prefilter_reason": None,
"analysis_version": ANALYSIS_VERSION,
}
def _fallback(prefilter: Optional[str] = None, reasoning: str = "") -> dict:
out = dict(_FALLBACK)
out["prefilter_reason"] = prefilter
out["reasoning"] = reasoning
return out
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
async def analyze_post(text: str) -> dict:
# Fast pre-filter: pure RT/URL with no content
stripped = text.strip()
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.")
if not stripped:
return _fallback("empty", "Pre-filtered: empty post body.")
try:
client = _get_client()
response = await client.chat.completions.create(
model=settings.ai_model,
max_tokens=450,
temperature=0.1,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": USER_PROMPT_TEMPLATE.format(text=text[:2000])},
],
)
raw = response.choices[0].message.content.strip()
if raw.startswith("```"):
lines = raw.split("\n")
raw = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:])
result = json.loads(raw)
sentiment = result.get("sentiment", "neutral")
if sentiment not in ("bullish", "bearish", "neutral"):
sentiment = "neutral"
signal = result.get("signal", "hold")
if signal not in ("buy", "sell", "short", "hold"):
signal = "hold"
confidence = int(result.get("confidence", 0))
confidence = max(0, min(100, confidence))
relevant = bool(result.get("relevant", False))
asset = result.get("asset")
if asset == "BOTH":
asset = "BTC"
if asset not in ("BTC", "ETH", None):
asset = None
reasoning = str(result.get("reasoning", ""))[:600]
return {
"relevant": relevant,
"asset": asset,
"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}")