feat(kol-v2): upgrade KOL analysis prompt — float divergence score, post_type, richer signals

Prompt v1 → v2 changes:

1. talks_vs_trades_score (float 0-1) replaces boolean flag
   - 0.7-1.0: strong divergence (bullish thesis + reducing, public buy + silent now)
   - 0.4-0.69: moderate (risk mgmt language alongside bullish, tone shift)
   - 0.1-0.39: weak (minor hedges)
   - 0.0: consistent or no position signals
   - talks_vs_trades_flag (bool) kept as backward-compat alias (score >= 0.5)

2. post_type field: trade_update | macro_thesis | research | news_recap | opinion | other
   Lets downstream filter by content quality before acting on signals.

3. summary cap raised 80→200 chars (2-3 sentences)
   80 chars was too tight for 5000-word Substack essays.

4. Chinese-language divergence cues added to prompt
   减仓了/止盈/降低了仓位/观望/风控 etc. — most KOL posts are in Chinese or
   English with Chinese asides; the old prompt only covered English patterns.

5. max_tokens raised 1500→2000 (Anthropic) and 1500→2000 (OAI non-reasoning)
   Complex essays with 5+ tickers were truncating.

Backward-compatible: callers checking talks_vs_trades_flag bool still work.
Version bumped kol-v1→kol-v2 for re-analysis tracking.

72 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
k
2026-05-29 18:06:46 +08:00
parent 06d367f073
commit cc1dda832b
+76 -23
View File
@@ -35,7 +35,7 @@ from app.config import settings
logger = logging.getLogger(__name__)
ANALYSIS_VERSION = "kol-v1"
ANALYSIS_VERSION = "kol-v2"
ANTHROPIC_MODEL = "claude-haiku-4-5-20251001"
_anthropic_client = None
@@ -71,7 +71,8 @@ The author is a known crypto KOL. Your job: distill what they said and which tok
Output **strict JSON only**, no markdown, no preface. Schema:
{
"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.>",
"summary": "<2-3 sentences in ENGLISH, ≤200 chars total. State the author's current market thesis if they have one, or describe the post topic. Capture the key directional call if any. Always English regardless of the post's original language.>",
"post_type": "trade_update" | "macro_thesis" | "research" | "news_recap" | "opinion" | "other",
"tickers": [
{
"ticker": "<UPPERCASE symbol, e.g. BTC, ETH, HYPE, SOL>",
@@ -82,29 +83,62 @@ Output **strict JSON only**, no markdown, no preface. Schema:
"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>
"talks_vs_trades_score": <float 0.0-1.0. 0 = no divergence detected. 0.5 = moderate mismatch signals. 1.0 = clear contradiction between stated narrative and implied position action. See rules below.>
}
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.
- 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.
POST TYPE:
- "trade_update" → author explicitly describes entering, exiting, or adjusting a position
- "macro_thesis" → broad market view, cycle analysis, regime commentary without specific trade action
- "research" → data-driven, analytical, with sources/charts (typical of Delphi, Glassnode, Blockworks)
- "news_recap" → summarising recent events without strong personal view
- "opinion" → personal take without data backing or explicit position
- "other" → doesn't fit the above
SUMMARY:
- Always English regardless of post language.
- 2-3 sentences, ≤200 chars. Lead with the directional call if there is one.
- If no signal: describe what the post covers in plain terms.
TICKERS:
- IGNORE historical price references ("BTC bottomed at $60k earlier this year") — context, not current calls.
- IGNORE advertising/sponsor sections — cues: "sponsor", "partner", "use code", "promo code", "this episode brought to you by", "ad", "广告", "赞助", "合作方". Skip any ticker only mentioned inside such a section.
- 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)
"buy"/"sell" → author explicitly states a position action ("I bought", "we are long", "我们减仓了", "added to my bag", "taking profits", "已建仓", "我加仓了", "建了仓")
"reduce" → partially exiting or taking profits on an existing long ("trimming", "taking some off", "减了一部分", "止盈了一些")
"bullish"/"bearish" → directional view without explicit position statement
"mention" → ticker appears but no clear stance
"mention" → ticker appears but no clear stance — use sparingly, only when ticker is central to the post
- Dedupe per ticker — at most one entry per symbol; pick the strongest action.
- Do NOT invent tickers. If you see "$XYZ" but unsure it's a real token, skip it.
- conviction: 0.8+ requires explicit + repeated + sized/timed view; 0.5-0.7 for clear directional view without commitment; <0.5 for passing references.
- 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 invent tickers. Skip "$XYZ" if unsure it is a real crypto token.
- conviction: 0.8+ = explicit + repeated + sized or timed; 0.50.7 = clear view, no commitment; <0.5 = passing reference.
- timeframe: "immediate" = acting now or within 24h; "days" = 17d; "weeks" = 14w; "months" = 1+ months; "unspecified" = not stated.
- Do not include fiat (USD/CNY/JPY) or stablecoins (USDT/USDC/DAI/FRAX/USDE) unless the post's main thesis is about them.
TALKS-VS-TRADES SCORE (talks_vs_trades_score):
This is the platform's most important signal. Score 0.01.0. Raise the score when you detect narrative-position mismatches:
Score 0.71.0 (strong divergence):
- Author writes a bullish thesis but describes reducing, trimming, or taking profits on the same asset
- Author is publicly bearish but mentions "accumulating", "adding at these levels", "买了一些"
- Post is notably silent on an asset the author has been loudly bullish on recently (avoidance signal)
- Stated high conviction ("this is THE entry", "strongest conviction of my career") but disclosed position size is very small
- Author hedges every bullish statement with "but I could be wrong", "just my opinion", "not financial advice" applied unevenly (selective hedging)
Score 0.40.69 (moderate divergence):
- Author mentions "risk management" or "waiting for confirmation" alongside a bullish thesis
- Mixed signals: bullish long-term but explicitly neutral or cautious short-term on the same asset
- Language shift: previous posts were assertive, this one uses softer language without explanation
Score 0.10.39 (weak divergence):
- Minor hedges in an otherwise consistent narrative
- Vague "taking profits" without clear contradiction of the main thesis
Score 0.0:
- Narrative and any position signals are fully consistent
- Post has no position signals at all (pure macro commentary)
Chinese-language divergence cues to detect: "减仓了" (reduced position), "止盈" (taking profit), "降低了仓位" (lowered position), "观望" (watching/waiting), "不着急" (in no hurry), "先不动" (staying put), "谨慎" (cautious), "控制仓位" (managing position size), "风控" (risk management).
"""
@@ -177,7 +211,7 @@ async def extract_kol_signal(
if use_anth:
msg = await _anth().messages.create(
model=model,
max_tokens=1500,
max_tokens=2000, # raised: complex essays with many tickers were truncating at 1500
temperature=0.1,
system=SYSTEM_PROMPT,
messages=[{"role": "user", "content": user}],
@@ -190,7 +224,7 @@ async def extract_kol_signal(
kwargs = {"model": model, "messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user},
], "max_tokens": 4000 if is_reasoning else 1500}
], "max_tokens": 4000 if is_reasoning else 2000}
if not is_reasoning:
kwargs["temperature"] = 0.1
# JSON mode — DeepSeek + OpenAI both support response_format.
@@ -211,6 +245,8 @@ async def extract_kol_signal(
cleaned = []
valid_actions = {"buy", "sell", "reduce", "bullish", "bearish", "mention"}
valid_timeframes = {"immediate", "days", "weeks", "months", "unspecified"}
valid_post_types = {"trade_update", "macro_thesis", "research", "news_recap", "opinion", "other"}
for t in tickers:
if not isinstance(t, dict):
continue
@@ -238,12 +274,29 @@ async def extract_kol_signal(
"quote": (t.get("quote") or "")[:200],
})
talks_vs_trades = bool(data.get("talks_vs_trades_flag", False))
# talks_vs_trades_score: new float field (v2). Backward-compat: if the
# model returns the old boolean talks_vs_trades_flag, convert it so callers
# that stored the old field still work. Clamp to [0, 1].
raw_score = data.get("talks_vs_trades_score")
if raw_score is None:
# Fallback: old boolean field from v1 responses
raw_score = 1.0 if bool(data.get("talks_vs_trades_flag", False)) else 0.0
try:
tvt_score = round(max(0.0, min(1.0, float(raw_score))), 2)
except (TypeError, ValueError):
tvt_score = 0.0
post_type = (data.get("post_type") or "other").lower()
if post_type not in valid_post_types:
post_type = "other"
return {
"summary": (data.get("summary") or "").strip() or None,
"post_type": post_type,
"tickers": cleaned,
"talks_vs_trades_flag": talks_vs_trades,
"talks_vs_trades_score": tvt_score,
# Keep old boolean for any callers that still check it
"talks_vs_trades_flag": tvt_score >= 0.5,
"model": model,
"version": ANALYSIS_VERSION,
}