Files
trumpsignal-backend/tests/test_trade_alerts.py
T
k 54884f3e24 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>
2026-06-09 22:55:16 +08:00

151 lines
5.7 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.
"""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