54884f3e24
Feed-health pass over KOL_FEEDS: - raoulpal: stale Substack (last 2024-05) → Real Vision podcast feed - dampedspring: paywalled (0 entries) → free "Damped Spring 101" Substack - unchained: Cloudflare 403 → canonical Megaphone podcast feed - lynalden: Cloudflare 202 → FeedBurner mirror - glassnode: recovered via httpx http2=True (was 403 on HTTP/1.1) - browser User-Agent + Accept headers on feed fetch - removed dead feeds with no active replacement: placeholder, dragonfly, niccarter, eugene - pin h2==4.3.0 (required by http2=True) All 25 remaining feeds verified fetching real body content; newest post per feed ≤88d. Bundles in-flight KOL-module work already in the working tree (kol_x ingest, migration 027, tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
218 lines
7.5 KiB
Python
218 lines
7.5 KiB
Python
"""Tests for x_analysis — the DETERMINISTIC normalization layer.
|
|
|
|
The LLM call is mocked so each test feeds a controlled raw model response and
|
|
asserts how analyze_x_post normalizes it. This locks down the enforcement rules
|
|
that protect downstream consumers (kol_x → kol_divergence), independent of
|
|
whatever the model actually returns:
|
|
|
|
- retweet post_type → forced noise
|
|
- trade_signal requires a buy/sell/reduce ticker with conviction ≥ 0.7,
|
|
else it downgrades to directional (never silently dropped)
|
|
- noise → tickers cleared + talks_vs_trades_flag forced false
|
|
- ticker hygiene: conviction clamped 0..1, bad action → mention,
|
|
overlong symbol dropped
|
|
- invalid tier/post_type → safe defaults
|
|
- bad JSON / empty text → graceful fallback, never raises
|
|
|
|
These are pure logic (no network, no AI spend) so they're fast and stable.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from app.config import settings
|
|
from app.services import x_analysis
|
|
|
|
|
|
# ── helpers ────────────────────────────────────────────────────────────────
|
|
|
|
def _patch_llm(monkeypatch, raw: dict):
|
|
"""Force analyze_x_post's LLM call to return `raw` (serialized). We patch
|
|
the OpenAI path (anthropic key blanked) since that's the default in CI."""
|
|
payload = json.dumps(raw)
|
|
|
|
class _Msg:
|
|
content = payload
|
|
|
|
class _Choice:
|
|
message = _Msg()
|
|
|
|
class _Resp:
|
|
choices = [_Choice()]
|
|
|
|
class _Completions:
|
|
async def create(self, **_kw):
|
|
return _Resp()
|
|
|
|
class _Chat:
|
|
completions = _Completions()
|
|
|
|
class _Client:
|
|
chat = _Chat()
|
|
|
|
monkeypatch.setattr(settings, "anthropic_api_key", "") # → OpenAI path
|
|
monkeypatch.setattr(x_analysis, "_oai", lambda: _Client())
|
|
|
|
|
|
def _raw(**over):
|
|
"""Minimal well-formed raw model response; override per test."""
|
|
base = {
|
|
"post_type": "original",
|
|
"tier": "directional",
|
|
"summary": "x",
|
|
"tickers": [],
|
|
"talks_vs_trades_flag": False,
|
|
"has_price_target": False,
|
|
"price_targets": [],
|
|
"sentiment": "neutral",
|
|
"reasoning": "y",
|
|
}
|
|
base.update(over)
|
|
return base
|
|
|
|
|
|
# ── tier enforcement ───────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_retweet_post_type_forced_to_noise(monkeypatch):
|
|
_patch_llm(monkeypatch, _raw(
|
|
post_type="retweet", tier="trade_signal",
|
|
tickers=[{"ticker": "BTC", "action": "buy", "conviction": 0.9}],
|
|
))
|
|
r = await x_analysis.analyze_x_post(handle="k", text="some original-looking text body")
|
|
assert r["tier"] == "noise"
|
|
assert r["tickers"] == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trade_signal_downgrades_without_strong_action(monkeypatch):
|
|
# tier=trade_signal but only a 'bullish' ticker (not buy/sell/reduce)
|
|
_patch_llm(monkeypatch, _raw(
|
|
tier="trade_signal",
|
|
tickers=[{"ticker": "SOL", "action": "bullish", "conviction": 0.9}],
|
|
))
|
|
r = await x_analysis.analyze_x_post(handle="k", text="SOL looking strong")
|
|
assert r["tier"] == "directional"
|
|
assert r["tickers"][0]["ticker"] == "SOL"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trade_signal_downgrades_on_low_conviction(monkeypatch):
|
|
_patch_llm(monkeypatch, _raw(
|
|
tier="trade_signal",
|
|
tickers=[{"ticker": "SOL", "action": "buy", "conviction": 0.5}],
|
|
))
|
|
r = await x_analysis.analyze_x_post(handle="k", text="bought a little SOL maybe")
|
|
assert r["tier"] == "directional" # 0.5 < 0.7 floor
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trade_signal_kept_when_strong(monkeypatch):
|
|
_patch_llm(monkeypatch, _raw(
|
|
tier="trade_signal",
|
|
tickers=[{"ticker": "SOL", "action": "buy", "conviction": 0.9}],
|
|
))
|
|
r = await x_analysis.analyze_x_post(handle="k", text="aped SOL full size lfg")
|
|
assert r["tier"] == "trade_signal"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_tier_falls_back_to_noise(monkeypatch):
|
|
_patch_llm(monkeypatch, _raw(tier="超级买入"))
|
|
r = await x_analysis.analyze_x_post(handle="k", text="ambiguous content here")
|
|
assert r["tier"] == "noise"
|
|
|
|
|
|
# ── noise enforcement ──────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_noise_clears_tickers_and_flag(monkeypatch):
|
|
_patch_llm(monkeypatch, _raw(
|
|
tier="noise", talks_vs_trades_flag=True,
|
|
tickers=[{"ticker": "BTC", "action": "buy", "conviction": 0.9}],
|
|
))
|
|
r = await x_analysis.analyze_x_post(handle="k", text="gm frens beautiful day")
|
|
assert r["tickers"] == []
|
|
assert r["talks_vs_trades_flag"] is False
|
|
|
|
|
|
# ── ticker hygiene ─────────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_conviction_clamped_to_unit_interval(monkeypatch):
|
|
_patch_llm(monkeypatch, _raw(
|
|
tier="directional",
|
|
tickers=[{"ticker": "BTC", "action": "bullish", "conviction": 1.8}],
|
|
))
|
|
r = await x_analysis.analyze_x_post(handle="k", text="BTC going parabolic")
|
|
assert r["tickers"][0]["conviction"] == 1.0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_action_becomes_mention(monkeypatch):
|
|
_patch_llm(monkeypatch, _raw(
|
|
tier="directional",
|
|
tickers=[{"ticker": "BTC", "action": "yolo", "conviction": 0.5}],
|
|
))
|
|
r = await x_analysis.analyze_x_post(handle="k", text="BTC yolo time")
|
|
assert r["tickers"][0]["action"] == "mention"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_overlong_ticker_dropped(monkeypatch):
|
|
_patch_llm(monkeypatch, _raw(
|
|
tier="directional",
|
|
tickers=[{"ticker": "THISISWAYTOOLONG", "action": "bullish", "conviction": 0.5}],
|
|
))
|
|
r = await x_analysis.analyze_x_post(handle="k", text="some long token mention")
|
|
assert r["tickers"] == []
|
|
|
|
|
|
# ── graceful failure ───────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_text_short_circuits_without_llm(monkeypatch):
|
|
# no _patch_llm → if it called the LLM it would error; it must not.
|
|
r = await x_analysis.analyze_x_post(handle="k", text=" ")
|
|
assert r["tier"] == "noise"
|
|
assert r["error"] == "empty post"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bare_retweet_prefiltered_without_llm(monkeypatch):
|
|
r = await x_analysis.analyze_x_post(handle="k", text="RT @someone: gm")
|
|
assert r["tier"] == "noise"
|
|
assert r["post_type"] == "retweet"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bad_json_returns_fallback(monkeypatch):
|
|
class _Msg:
|
|
content = "not json at all {{{ "
|
|
|
|
class _Choice:
|
|
message = _Msg()
|
|
|
|
class _Resp:
|
|
choices = [_Choice()]
|
|
|
|
class _Completions:
|
|
async def create(self, **_kw):
|
|
return _Resp()
|
|
|
|
class _Chat:
|
|
completions = _Completions()
|
|
|
|
class _Client:
|
|
chat = _Chat()
|
|
|
|
monkeypatch.setattr(settings, "anthropic_api_key", "")
|
|
monkeypatch.setattr(x_analysis, "_oai", lambda: _Client())
|
|
|
|
r = await x_analysis.analyze_x_post(handle="k", text="real content that triggers llm")
|
|
assert r["tier"] == "noise"
|
|
assert r["error"] and "parse_error" in r["error"]
|