KOL feeds: fix dead/blocked sources, drop stale feeds (29→25)
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>
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
"""Tests for the X (Twitter) KOL ingester (kol_x).
|
||||
|
||||
The twitterapi.io fetch and the AI scorer are mocked so we exercise the
|
||||
storage / dedup / mapping logic deterministically — no network, no AI spend.
|
||||
|
||||
The contract these lock down:
|
||||
- tweets land as KolPost(source="twitter")
|
||||
- kol_handle is the CANONICAL handle (joins to KolWallet), not the X username
|
||||
- bare retweets are skipped before scoring
|
||||
- tickers_json keeps the {ticker, action, conviction} shape kol_divergence reads
|
||||
- tier / post_type / talks_vs_trades_flag / sentiment are persisted (migration 027)
|
||||
- re-running is idempotent (dedup by tweet id)
|
||||
- page-level early-stop fires when a full page is all-dedup
|
||||
- no twitterapi key → full no-op
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession, async_sessionmaker, create_async_engine,
|
||||
)
|
||||
|
||||
from app.config import settings
|
||||
from app.models import Base, KolPost
|
||||
from app.services import kol_x
|
||||
|
||||
|
||||
_FAKE_TWEETS = [
|
||||
{ # actionable position statement → should be stored + scored
|
||||
"id": "111",
|
||||
"text": "I just dumped my entire $HYPE position",
|
||||
"url": "https://x.com/CryptoHayes/status/111",
|
||||
"createdAt": "Thu Jun 04 05:49:13 +0000 2026",
|
||||
"author": {"followers": 797330},
|
||||
},
|
||||
{ # bare retweet → skipped before the AI call
|
||||
"id": "222",
|
||||
"text": "RT @someone: not my words",
|
||||
"createdAt": "Thu Jun 04 06:00:00 +0000 2026",
|
||||
"author": {"followers": 797330},
|
||||
},
|
||||
{ # low-signal but original → stored (AI decides noise/not)
|
||||
"id": "333",
|
||||
"text": "gm crypto fam",
|
||||
"createdAt": "Thu Jun 04 07:00:00 +0000 2026",
|
||||
"author": {"followers": 797330},
|
||||
},
|
||||
]
|
||||
|
||||
_FAKE_SCORE = {
|
||||
"post_type": "original",
|
||||
"tier": "trade_signal",
|
||||
"summary": "Dumped HYPE",
|
||||
"tickers": [{"ticker": "HYPE", "action": "sell", "conviction": 0.95}],
|
||||
"talks_vs_trades_flag": True,
|
||||
"sentiment": "bearish",
|
||||
"model": "test-model",
|
||||
"version": "x-test",
|
||||
"error": None,
|
||||
}
|
||||
|
||||
_KOL = {"handle": "cryptohayes", "x_username": "CryptoHayes", "display_name": "Hayes"}
|
||||
|
||||
|
||||
async def _fresh_session_factory():
|
||||
"""Each test gets its own isolated in-memory DB."""
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:", future=True)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
return async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
def _make_page_iter(pages: list[list[dict]]):
|
||||
"""Return an async generator that yields one page at a time from `pages`."""
|
||||
async def fake_iter(_username, max_pages=3) -> AsyncGenerator[list[dict], None]:
|
||||
for page in pages:
|
||||
yield page
|
||||
return fake_iter
|
||||
|
||||
|
||||
def _patch_io(monkeypatch, pages: list[list[dict]] | None = None):
|
||||
if pages is None:
|
||||
pages = [list(_FAKE_TWEETS)] # one page containing all fake tweets
|
||||
|
||||
async def fake_score(**_kw):
|
||||
return dict(_FAKE_SCORE)
|
||||
|
||||
monkeypatch.setattr(settings, "twitterapi_io_key", "test-key")
|
||||
monkeypatch.setattr(kol_x, "_iter_tweet_pages", _make_page_iter(pages))
|
||||
monkeypatch.setattr(kol_x.x_analysis, "analyze_x_post", fake_score)
|
||||
|
||||
|
||||
# ── Core contract ─────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_x_poll_noop_without_key(monkeypatch):
|
||||
monkeypatch.setattr(settings, "twitterapi_io_key", "")
|
||||
assert await kol_x.run_x_poll() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_writes_divergence_ready_kolposts(monkeypatch):
|
||||
_patch_io(monkeypatch)
|
||||
sf = await _fresh_session_factory()
|
||||
|
||||
async with sf() as s:
|
||||
stats = await kol_x._ingest_kol_x(s, _KOL)
|
||||
|
||||
# 111 + 333 stored; 222 (bare RT) skipped before scoring
|
||||
assert stats["new"] == 2
|
||||
assert stats["skipped"] == 1
|
||||
assert stats["analyzed"] == 2
|
||||
assert stats["errors"] == 0
|
||||
|
||||
rows = (await s.execute(
|
||||
select(KolPost).where(KolPost.source == "twitter")
|
||||
)).scalars().all()
|
||||
assert {r.external_id for r in rows} == {"111", "333"}
|
||||
# canonical handle (joins to KolWallet), NOT the X screen name
|
||||
assert all(r.kol_handle == "cryptohayes" for r in rows)
|
||||
|
||||
signal = next(r for r in rows if r.external_id == "111")
|
||||
tk = json.loads(signal.tickers_json)
|
||||
assert tk[0]["ticker"] == "HYPE"
|
||||
assert tk[0]["action"] == "sell" # in kol_divergence._POST_SHORT
|
||||
assert tk[0]["conviction"] == 0.95
|
||||
assert signal.analysis_model == "test-model"
|
||||
assert signal.analysis_version == "x-test"
|
||||
# published_at parsed from the X date string into a naive datetime
|
||||
assert signal.published_at.year == 2026 and signal.published_at.month == 6
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_stores_extended_analysis_fields(monkeypatch):
|
||||
"""migration 027 fields — tier / post_type / talks_vs_trades_flag / sentiment."""
|
||||
_patch_io(monkeypatch)
|
||||
sf = await _fresh_session_factory()
|
||||
|
||||
async with sf() as s:
|
||||
await kol_x._ingest_kol_x(s, _KOL)
|
||||
signal = (await s.execute(
|
||||
select(KolPost).where(KolPost.external_id == "111")
|
||||
)).scalar_one()
|
||||
|
||||
assert signal.tier == "trade_signal"
|
||||
assert signal.post_type == "original"
|
||||
assert signal.talks_vs_trades_flag is True
|
||||
assert signal.sentiment == "bearish"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_is_idempotent_on_rerun(monkeypatch):
|
||||
_patch_io(monkeypatch)
|
||||
sf = await _fresh_session_factory()
|
||||
|
||||
async with sf() as s:
|
||||
first = await kol_x._ingest_kol_x(s, _KOL)
|
||||
second = await kol_x._ingest_kol_x(s, _KOL)
|
||||
|
||||
assert first["new"] == 2
|
||||
assert second["new"] == 0 # everything already stored
|
||||
assert second["skipped"] == 3 # 2 existing + 1 bare RT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_failure_does_not_raise(monkeypatch):
|
||||
"""A dead/blocked KOL must not abort the run — empty pages → empty stats."""
|
||||
monkeypatch.setattr(settings, "twitterapi_io_key", "test-key")
|
||||
_patch_io(monkeypatch, pages=[]) # generator yields nothing
|
||||
sf = await _fresh_session_factory()
|
||||
async with sf() as s:
|
||||
stats = await kol_x._ingest_kol_x(s, _KOL)
|
||||
assert stats["new"] == 0 and stats["errors"] == 0
|
||||
|
||||
|
||||
# ── Multi-page & early-stop ───────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multipage_fetches_all_new_tweets(monkeypatch):
|
||||
"""Two pages of distinct tweets → both pages are stored."""
|
||||
page1 = [{"id": "p1_1", "text": "SOL is the play", "createdAt": "Thu Jun 04 05:00:00 +0000 2026", "author": {"followers": 100000}}]
|
||||
page2 = [{"id": "p2_1", "text": "ETH too slow ngmi", "createdAt": "Wed Jun 03 10:00:00 +0000 2026", "author": {"followers": 100000}}]
|
||||
_patch_io(monkeypatch, pages=[page1, page2])
|
||||
sf = await _fresh_session_factory()
|
||||
|
||||
async with sf() as s:
|
||||
stats = await kol_x._ingest_kol_x(s, _KOL)
|
||||
|
||||
assert stats["new"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_early_stop_when_page_fully_deduped(monkeypatch):
|
||||
"""After page1 is stored, a second run should detect all-dedup on page1
|
||||
and break before page2 is consumed — verified by keeping a 'new' tweet
|
||||
on page2 that must NOT appear in the DB if early-stop fired correctly."""
|
||||
page1 = [{"id": "old1", "text": "BTC to the moon",
|
||||
"createdAt": "Thu Jun 04 05:00:00 +0000 2026", "author": {}}]
|
||||
page2 = [{"id": "brand_new", "text": "Sold everything",
|
||||
"createdAt": "Wed Jun 03 09:00:00 +0000 2026", "author": {}}]
|
||||
|
||||
run = 0
|
||||
pages_yielded: list[list] = []
|
||||
|
||||
async def counting_iter(_username, max_pages=3):
|
||||
nonlocal run
|
||||
run += 1
|
||||
if run == 1:
|
||||
# First run: only page1 (simulates single-page normal daily run)
|
||||
pages_yielded.append(page1)
|
||||
yield page1
|
||||
else:
|
||||
# Second run: both pages available, but early-stop should fire at page1
|
||||
for page in [page1, page2]:
|
||||
pages_yielded.append(page)
|
||||
yield page
|
||||
|
||||
async def fake_score(**_kw):
|
||||
return dict(_FAKE_SCORE)
|
||||
|
||||
monkeypatch.setattr(settings, "twitterapi_io_key", "test-key")
|
||||
monkeypatch.setattr(kol_x, "_iter_tweet_pages", counting_iter)
|
||||
monkeypatch.setattr(kol_x.x_analysis, "analyze_x_post", fake_score)
|
||||
|
||||
sf = await _fresh_session_factory()
|
||||
async with sf() as s:
|
||||
first = await kol_x._ingest_kol_x(s, _KOL)
|
||||
assert first["new"] == 1 # page1 stored (1 tweet)
|
||||
|
||||
pages_yielded.clear()
|
||||
second = await kol_x._ingest_kol_x(s, _KOL)
|
||||
assert second["new"] == 0 # page1 all deduped → early stop
|
||||
# Only page1 should have been yielded — early-stop broke before page2
|
||||
assert pages_yielded == [page1]
|
||||
|
||||
# page2's tweet must NOT be in the DB (generator closed before it was yielded)
|
||||
rows = (await s.execute(
|
||||
select(KolPost).where(KolPost.source == "twitter")
|
||||
)).scalars().all()
|
||||
assert {r.external_id for r in rows} == {"old1"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_retweet_page_does_not_early_stop(monkeypatch):
|
||||
"""A page that is ALL bare retweets (page_new=0, page_deduped=0) must NOT
|
||||
trigger early-stop — the next page can still hold original posts. Regression
|
||||
guard: previously 'page_new == 0' alone stopped paging on RT-heavy pages,
|
||||
silently dropping originals on older pages."""
|
||||
page1 = [
|
||||
{"id": "rt1", "text": "RT @a: gm", "createdAt": "Thu Jun 04 05:00:00 +0000 2026", "author": {}},
|
||||
{"id": "rt2", "text": "RT @b: wagmi", "createdAt": "Thu Jun 04 04:00:00 +0000 2026", "author": {}},
|
||||
]
|
||||
page2 = [
|
||||
{"id": "orig1", "text": "Just sold all my $SOL", "createdAt": "Wed Jun 03 09:00:00 +0000 2026", "author": {}},
|
||||
]
|
||||
_patch_io(monkeypatch, pages=[page1, page2])
|
||||
sf = await _fresh_session_factory()
|
||||
|
||||
async with sf() as s:
|
||||
stats = await kol_x._ingest_kol_x(s, _KOL)
|
||||
# page1 all-RT → skipped 2, NO early-stop → page2 consumed → orig1 stored
|
||||
assert stats["new"] == 1
|
||||
assert stats["skipped"] == 2
|
||||
rows = (await s.execute(
|
||||
select(KolPost).where(KolPost.source == "twitter")
|
||||
)).scalars().all()
|
||||
assert {r.external_id for r in rows} == {"orig1"}
|
||||
@@ -0,0 +1,232 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.api.posts import router as posts_router
|
||||
from app.database import get_db
|
||||
from app.models import Base, Post
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_posts_paged_filters_and_counts():
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:", future=True)
|
||||
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
now = datetime(2026, 5, 30, 12, 0, 0)
|
||||
async with session_factory() as session:
|
||||
session.add_all([
|
||||
Post(
|
||||
external_id="truth-buy",
|
||||
text="Bullish signal",
|
||||
source="truth",
|
||||
published_at=now,
|
||||
sentiment="bullish",
|
||||
signal="buy",
|
||||
ai_confidence=91,
|
||||
ai_reasoning="AI saw upside",
|
||||
relevant=True,
|
||||
),
|
||||
Post(
|
||||
external_id="truth-short",
|
||||
text="Bearish signal",
|
||||
source="truth",
|
||||
published_at=now - timedelta(minutes=1),
|
||||
sentiment="bearish",
|
||||
signal="short",
|
||||
ai_confidence=88,
|
||||
ai_reasoning="AI saw downside",
|
||||
relevant=True,
|
||||
),
|
||||
Post(
|
||||
external_id="truth-hold",
|
||||
text="Neutral but scored",
|
||||
source="truth",
|
||||
published_at=now - timedelta(minutes=2),
|
||||
sentiment="neutral",
|
||||
signal="hold",
|
||||
ai_confidence=45,
|
||||
ai_reasoning="No trade edge",
|
||||
relevant=True,
|
||||
),
|
||||
Post(
|
||||
external_id="truth-noise",
|
||||
text="Off-topic golf post",
|
||||
source="truth",
|
||||
published_at=now - timedelta(minutes=3),
|
||||
sentiment="neutral",
|
||||
signal=None,
|
||||
ai_confidence=0,
|
||||
ai_reasoning=None,
|
||||
relevant=False,
|
||||
),
|
||||
Post(
|
||||
external_id="macro-buy",
|
||||
text="Macro buy",
|
||||
source="btc_bottom_reversal",
|
||||
published_at=now - timedelta(minutes=4),
|
||||
sentiment="bullish",
|
||||
signal="buy",
|
||||
ai_confidence=97,
|
||||
ai_reasoning="Macro setup",
|
||||
relevant=True,
|
||||
),
|
||||
Post(
|
||||
external_id="archive-breakout",
|
||||
text="Legacy breakout signal",
|
||||
source="breakout",
|
||||
published_at=now - timedelta(minutes=5),
|
||||
sentiment="bullish",
|
||||
signal="buy",
|
||||
ai_confidence=73,
|
||||
ai_reasoning="Old scanner",
|
||||
relevant=True,
|
||||
),
|
||||
Post(
|
||||
external_id="archive-sma",
|
||||
text="Legacy sma reclaim signal",
|
||||
source="sma_reclaim",
|
||||
published_at=now - timedelta(minutes=6),
|
||||
sentiment="bullish",
|
||||
signal="short",
|
||||
ai_confidence=64,
|
||||
ai_reasoning="Old scanner 2",
|
||||
relevant=True,
|
||||
),
|
||||
])
|
||||
await session.commit()
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(posts_router, prefix="/api")
|
||||
|
||||
async def override_get_db():
|
||||
async with session_factory() as session:
|
||||
yield session
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://testserver") as client:
|
||||
res = await client.get("/api/posts-paged", params={"source": "truth", "limit": 10, "page": 1})
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["total"] == 4
|
||||
assert len(data["items"]) == 4
|
||||
assert data["counts"] == {
|
||||
"all": 4,
|
||||
"actionable": 2,
|
||||
"buy": 1,
|
||||
"short": 1,
|
||||
"off_topic": 1,
|
||||
}
|
||||
assert data["source_counts"] == [{"source": "truth", "count": 4, "latest": "2026-05-30T12:00:00.000Z"}]
|
||||
|
||||
actionable = await client.get(
|
||||
"/api/posts-paged",
|
||||
params={"source": "truth", "signal": "actionable", "limit": 10, "page": 1},
|
||||
)
|
||||
assert actionable.status_code == 200
|
||||
actionable_data = actionable.json()
|
||||
assert actionable_data["total"] == 2
|
||||
assert {item["signal"] for item in actionable_data["items"]} == {"buy", "short"}
|
||||
assert actionable_data["counts"]["all"] == 4
|
||||
|
||||
scored_only = await client.get(
|
||||
"/api/posts-paged",
|
||||
params={"source": "truth", "ai_scored_only": "true", "limit": 10, "page": 1},
|
||||
)
|
||||
assert scored_only.status_code == 200
|
||||
scored_data = scored_only.json()
|
||||
assert scored_data["total"] == 3
|
||||
assert all(item["ai_confidence"] > 0 or item["ai_reasoning"] for item in scored_data["items"])
|
||||
assert scored_data["counts"] == {
|
||||
"all": 3,
|
||||
"actionable": 2,
|
||||
"buy": 1,
|
||||
"short": 1,
|
||||
"off_topic": 1,
|
||||
}
|
||||
|
||||
bearish_buy = await client.get(
|
||||
"/api/posts-paged",
|
||||
params={
|
||||
"source": "truth",
|
||||
"sentiment": "bearish",
|
||||
"signal": "buy",
|
||||
"limit": 10,
|
||||
"page": 1,
|
||||
},
|
||||
)
|
||||
assert bearish_buy.status_code == 200
|
||||
bearish_buy_data = bearish_buy.json()
|
||||
assert bearish_buy_data["total"] == 0
|
||||
assert bearish_buy_data["counts"] == {
|
||||
"all": 1,
|
||||
"actionable": 1,
|
||||
"buy": 0,
|
||||
"short": 1,
|
||||
"off_topic": 0,
|
||||
}
|
||||
|
||||
archive = await client.get(
|
||||
"/api/posts-paged",
|
||||
params={
|
||||
"archive_only": "true",
|
||||
"limit": 10,
|
||||
"page": 1,
|
||||
},
|
||||
)
|
||||
assert archive.status_code == 200
|
||||
archive_data = archive.json()
|
||||
assert archive_data["total"] == 2
|
||||
assert len(archive_data["items"]) == 2
|
||||
assert {item["source"] for item in archive_data["items"]} == {"breakout", "sma_reclaim"}
|
||||
assert archive_data["source_counts"] == [
|
||||
{"source": "breakout", "count": 1, "latest": "2026-05-30T11:55:00.000Z"},
|
||||
{"source": "sma_reclaim", "count": 1, "latest": "2026-05-30T11:54:00.000Z"},
|
||||
]
|
||||
|
||||
# Regression: selecting one archive source via source_in must narrow the
|
||||
# paged items/total, but source_counts (the chip bar) must STILL list
|
||||
# every archived source so the UI can offer a way back to "all".
|
||||
archive_one = await client.get(
|
||||
"/api/posts-paged",
|
||||
params={
|
||||
"archive_only": "true",
|
||||
"source_in": "breakout",
|
||||
"limit": 10,
|
||||
"page": 1,
|
||||
},
|
||||
)
|
||||
assert archive_one.status_code == 200
|
||||
archive_one_data = archive_one.json()
|
||||
assert archive_one_data["total"] == 1
|
||||
assert {item["source"] for item in archive_one_data["items"]} == {"breakout"}
|
||||
# Chip bar is NOT collapsed to the selected source.
|
||||
assert archive_one_data["source_counts"] == [
|
||||
{"source": "breakout", "count": 1, "latest": "2026-05-30T11:55:00.000Z"},
|
||||
{"source": "sma_reclaim", "count": 1, "latest": "2026-05-30T11:54:00.000Z"},
|
||||
]
|
||||
|
||||
archive_compat = await client.get(
|
||||
"/api/posts-paged",
|
||||
params={
|
||||
"archive_only": "true",
|
||||
"source_not_in": "truth",
|
||||
"limit": 10,
|
||||
"page": 1,
|
||||
},
|
||||
)
|
||||
assert archive_compat.status_code == 200
|
||||
archive_compat_data = archive_compat.json()
|
||||
assert archive_compat_data["total"] == 2
|
||||
assert {item["source"] for item in archive_compat_data["items"]} == {"breakout", "sma_reclaim"}
|
||||
|
||||
await engine.dispose()
|
||||
@@ -41,6 +41,12 @@ class _Trade:
|
||||
pnl_usd = 2.25
|
||||
|
||||
|
||||
class _ClosedTrade(_Trade):
|
||||
"""Same as _Trade but with closed_at set, simulating a committed close."""
|
||||
from datetime import datetime, timezone
|
||||
closed_at = datetime(2026, 1, 1, 0, 0, 0)
|
||||
|
||||
|
||||
class _Sub:
|
||||
leverage = 3
|
||||
hl_api_key = None
|
||||
@@ -65,7 +71,10 @@ async def test_manual_close_returns_close_result(monkeypatch):
|
||||
|
||||
monkeypatch.setattr(bot_engine, "close_and_finalize", fake_close_and_finalize)
|
||||
|
||||
db = _Db([_Trade(), _Sub(), _Trade()])
|
||||
# Responses in order: load trade → load sub → populate_existing re-read
|
||||
# (B45 fix: one query with populate_existing=True instead of old stale cache).
|
||||
# _ClosedTrade has closed_at set so the B46 success guard passes.
|
||||
db = _Db([_Trade(), _Sub(), _ClosedTrade()])
|
||||
|
||||
result = await positions.manual_close(7, _Request(), db)
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ change accidentally turns "BTC bottom triggers: 3/3 firing" into a less
|
||||
clear phrasing, CI catches it.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.telegram_digest import (
|
||||
GlobalDigest, MacroBlock, KolBlock, TrumpBlock, format_digest,
|
||||
)
|
||||
@@ -174,3 +176,48 @@ def test_total_length_well_under_telegram_cap():
|
||||
"open_pnl_summary": "3 open (+125.3 USD)",
|
||||
})
|
||||
assert len(out) < 4096
|
||||
|
||||
|
||||
# ── build_global_digest DB aggregation (twitter-noise exclusion) ───────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_global_digest_excludes_twitter_noise(monkeypatch):
|
||||
"""KOL posts_24h must NOT count twitter noise (gm / RT / jokes). Substack
|
||||
essays (tier=NULL) and twitter signal posts (tier!='noise') count; twitter
|
||||
noise (tier='noise') is excluded. Regression guard for the digest count
|
||||
being inflated by a KOL's gm/RT spam after X ingestion landed."""
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession, async_sessionmaker, create_async_engine,
|
||||
)
|
||||
from app.models import Base, KolPost
|
||||
from app.services import telegram_digest
|
||||
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:", future=True)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
sf = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
now = datetime(2026, 6, 4, 12, 0, 0)
|
||||
recent = now - timedelta(hours=2)
|
||||
|
||||
def _post(ext, source, tier=None, handle="cryptohayes"):
|
||||
return KolPost(
|
||||
kol_handle=handle, source=source, external_id=ext,
|
||||
url=f"https://x.com/x/status/{ext}", published_at=recent,
|
||||
raw_text="body", content_hash=ext, tier=tier,
|
||||
)
|
||||
|
||||
async with sf() as s:
|
||||
s.add_all([
|
||||
_post("sub1", "substack", tier=None, handle="raoulpal"), # essay → count
|
||||
_post("tw1", "twitter", tier="directional"), # signal → count
|
||||
_post("tw2", "twitter", tier="noise"), # gm noise → exclude
|
||||
_post("tw3", "twitter", tier="noise"), # RT noise → exclude
|
||||
])
|
||||
await s.commit()
|
||||
|
||||
monkeypatch.setattr(telegram_digest, "async_session", sf)
|
||||
g = await telegram_digest.build_global_digest(now=now)
|
||||
# 2 real posts (substack essay + twitter signal); 2 noise excluded
|
||||
assert g.kol.posts_24h == 2
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Tests for trade-alert broadcasts and balance pre-check logic added 2026-06-01.
|
||||
|
||||
Coverage targets:
|
||||
1. _broadcast_trade_alert — fire-and-forget, must not raise
|
||||
2. Balance pre-check maths — required_margin = (notional / leverage) * 1.1
|
||||
3. Startup drain in the Telegram bot loop — offset advances past pending updates
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import pytest
|
||||
|
||||
# ── 1. _broadcast_trade_alert ─────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_trade_alert_no_connections(monkeypatch):
|
||||
"""broadcast_trade_alert must silently succeed when no WS clients are connected."""
|
||||
from app.services.bot_engine import _broadcast_trade_alert
|
||||
from app.ws import manager as mgr_mod
|
||||
|
||||
# Patch manager.broadcast to verify it's called with correct payload
|
||||
calls: list[dict] = []
|
||||
async def fake_broadcast(msg: dict):
|
||||
calls.append(msg)
|
||||
|
||||
monkeypatch.setattr(mgr_mod.manager, "broadcast", fake_broadcast)
|
||||
|
||||
await _broadcast_trade_alert("0xabc", "execution_failed", asset="BTC", reason="test error")
|
||||
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["type"] == "trade_alert"
|
||||
assert calls[0]["wallet"] == "0xabc"
|
||||
assert calls[0]["event"] == "execution_failed"
|
||||
assert calls[0]["asset"] == "BTC"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_trade_alert_swallows_exceptions(monkeypatch):
|
||||
"""broadcast_trade_alert must not propagate exceptions — trade flow must continue."""
|
||||
from app.services.bot_engine import _broadcast_trade_alert
|
||||
from app.ws import manager as mgr_mod
|
||||
|
||||
async def exploding_broadcast(msg: dict):
|
||||
raise RuntimeError("WS layer crashed")
|
||||
|
||||
monkeypatch.setattr(mgr_mod.manager, "broadcast", exploding_broadcast)
|
||||
|
||||
# Must not raise
|
||||
await _broadcast_trade_alert("0xabc", "budget_reached", asset="BTC")
|
||||
|
||||
|
||||
# ── 2. Balance pre-check maths ────────────────────────────────────────────────
|
||||
|
||||
def test_required_margin_formula():
|
||||
"""required_margin = (notional / leverage) * 1.1 — verify key scenarios."""
|
||||
def required_margin(notional: float, leverage: int) -> float:
|
||||
return round((notional / max(leverage, 1)) * 1.1, 2)
|
||||
|
||||
# 2× leverage, $100 position → $50 margin + 10% buffer = $55
|
||||
assert required_margin(100, 2) == 55.0
|
||||
|
||||
# 5× leverage, $500 position → $100 margin + 10% = $110
|
||||
assert required_margin(500, 5) == 110.0
|
||||
|
||||
# 1× leverage, $20 position → $20 + 10% = $22
|
||||
assert required_margin(20, 1) == 22.0
|
||||
|
||||
# edge: leverage=0 treated as 1 (no divide-by-zero)
|
||||
assert required_margin(100, 0) == 110.0
|
||||
|
||||
|
||||
def test_balance_check_does_not_block_low_leverage():
|
||||
"""With $100 balance and 10× leverage on a $200 notional, margin = $22 — should PASS."""
|
||||
balance = 100.0
|
||||
notional = 200.0
|
||||
leverage = 10
|
||||
required = (notional / max(leverage, 1)) * 1.1
|
||||
assert balance >= required, (
|
||||
f"Balance ${balance} should cover ${required:.2f} margin "
|
||||
f"(${notional} notional at {leverage}×)"
|
||||
)
|
||||
|
||||
|
||||
def test_balance_check_fires_at_truly_insufficient_balance():
|
||||
"""With $5 balance and 2× on $20 notional, margin = $11 — should BLOCK."""
|
||||
balance = 5.0
|
||||
notional = 20.0
|
||||
leverage = 2
|
||||
required = (notional / max(leverage, 1)) * 1.1
|
||||
assert balance < required, (
|
||||
f"Balance ${balance} should NOT cover ${required:.2f} margin"
|
||||
)
|
||||
|
||||
|
||||
# ── 3. Telegram startup drain ────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_startup_drain_advances_offset(monkeypatch):
|
||||
"""Verify the drain calls getUpdates with timeout=0 and ACKs the last update_id."""
|
||||
import httpx
|
||||
|
||||
drain_calls: list[dict] = []
|
||||
|
||||
class FakeResponse:
|
||||
status_code = 200
|
||||
def json(self):
|
||||
if len(drain_calls) == 1: # first call: return pending updates
|
||||
return {"result": [{"update_id": 100}, {"update_id": 101}]}
|
||||
return {"result": []} # second call (ACK): no pending
|
||||
|
||||
class FakeClient:
|
||||
async def __aenter__(self): return self
|
||||
async def __aexit__(self, *a): pass
|
||||
async def get(self, url, params=None):
|
||||
drain_calls.append(params or {})
|
||||
return FakeResponse()
|
||||
|
||||
monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: FakeClient())
|
||||
|
||||
# Simulate only the drain portion (extract the logic)
|
||||
from app.config import settings
|
||||
monkeypatch.setattr(settings, "telegram_bot_token", "test-token")
|
||||
|
||||
import httpx as _httpx
|
||||
|
||||
# Re-run the drain logic inline (mirrors telegram_bot.py startup drain)
|
||||
token = "test-token"
|
||||
TG_API = "https://api.telegram.org/bot{token}/{method}"
|
||||
|
||||
async with _httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.get(
|
||||
TG_API.format(token=token, method="getUpdates"),
|
||||
params={"timeout": 0, "limit": 100},
|
||||
)
|
||||
if r.status_code == 200:
|
||||
pending = r.json().get("result", [])
|
||||
if pending:
|
||||
drain_offset = pending[-1]["update_id"] + 1
|
||||
async with _httpx.AsyncClient(timeout=10) as client:
|
||||
await client.get(
|
||||
TG_API.format(token=token, method="getUpdates"),
|
||||
params={"timeout": 0, "offset": drain_offset},
|
||||
)
|
||||
|
||||
# First call: drain request (timeout=0, limit=100)
|
||||
assert drain_calls[0].get("timeout") == 0
|
||||
assert drain_calls[0].get("limit") == 100
|
||||
|
||||
# Second call: ACK with offset = last_update_id + 1
|
||||
assert drain_calls[1].get("offset") == 102 # 101 + 1
|
||||
@@ -0,0 +1,217 @@
|
||||
"""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"]
|
||||
Reference in New Issue
Block a user