Pre-launch hardening: KOL module, Telegram, scanners, WS resilience
Big-picture changes since b941223:
KOL pipeline (new) — Substack/podcast/blog RSS → AI ticker extraction →
on-chain wallet diff → talks-vs-trades divergence detection. Daily polls,
19 feeds, divergence emits Post + Telegram fan-out.
Telegram push (new) — walletless free tier + wallet-linked Pro upgrade,
in-bot preference commands (/trump /btc /funding /kol /conf /quiet),
signed-envelope API for dashboard. Disconnect-wallet keeps free
subscription.
BTC funding-rate reversal scanner (new) — hourly cron, 30d cumulative
funding threshold + mean-revert + 7d price confirm, emits via
/api/signals/ingest. BTC bottom-reversal scanner promoted to System 2.
WS broadcast rewrite — per-client send timeout + parallel fan-out
(asyncio.gather). Fixes "Binance WS no close frame" reconnect storms +
APScheduler 11-min job misses, both caused by one slow client stalling
the kline loop.
Error visibility — three silent-error sites (trumpstruth/truth_social
fetchers, funding_reversal scanner) now include exception type name so
httpx ConnectError-style empty-message errors stop logging blank lines.
Telegram bot loop now classifies ReadTimeout vs network vs unknown +
logger.exception for the unknown bucket.
Security hygiene — trumpsignal.db untracked from git (held subscriber
wallets + encrypted HL keys + 22 bot trades); .gitignore now blocks
*.db/.next/backups. CORS only allows FRONTEND_URL in production.
New ops scripts —
- scripts/preflight.py: env/DB/Telegram/AI auth verification gate
- scripts/backup_db.sh: cron-friendly daily DB backup (SQLite + Postgres)
- scripts/seed_kol_wallets.py: idempotent KOL on-chain wallet seeder
15 new Alembic migrations (007-021) covering convex strategy fields,
phase-1 safety, two-system frozen exits, invalidation prices, dynamic
SYS2 leverage, staged de-risk + pyramiding, peak gain tracking, risk
mode, auto-trade + grow flags, KOL module, KOL on-chain, KOL divergence,
Telegram bindings + walletless.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
if str(BACKEND_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
# Tests import app.config at module import time. Provide a harmless default
|
||||
# DB URL so collection doesn't depend on the caller's shell env.
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///./test.db")
|
||||
@@ -0,0 +1,425 @@
|
||||
"""Tests for the current BTC bottom strategy:
|
||||
|
||||
- pure-price indicators (AHR999 / 200WMA / Pi Cycle) + 2-of-3 confluence
|
||||
- leverage-aware dynamic System-2 leverage + protective stop
|
||||
- staged stop-loss ladder math (no take-profit, ratchet-up only)
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
from app.services.bottom_indicators import (
|
||||
ahr999, below_200wma, pi_cycle_bottom, bottom_confluence,
|
||||
AHR999_BOTTOM,
|
||||
)
|
||||
from app.services.signal_categories import (
|
||||
sys2_effective_leverage, sys2_protective_stop_pct,
|
||||
sys2_approx_liquidation_pct, get_stop_ladder, sys2_derisk_ladder,
|
||||
sys2_addon_ladder, sys2_peak_trail, get_exit_profile,
|
||||
SYS2_DEFAULT_LEVERAGE, SYS2_MAX_STOP_PCT,
|
||||
)
|
||||
from app.services.bottom_indicators import trend_confirmed
|
||||
|
||||
|
||||
# ── Indicators ──────────────────────────────────────────────────────────────
|
||||
|
||||
def test_ahr999_none_when_insufficient_history():
|
||||
assert ahr999([100.0] * 199) is None
|
||||
|
||||
|
||||
def test_ahr999_low_in_deep_decline():
|
||||
# Long steady decline → price far below the geometric mean & growth model.
|
||||
daily = [60000 * math.exp(-0.002 * i) for i in range(400)]
|
||||
v = ahr999(daily)
|
||||
assert v is not None and v < AHR999_BOTTOM
|
||||
|
||||
|
||||
def test_below_200wma_uses_explicit_price_not_stale_weekly():
|
||||
weekly = [100.0] * 199 + [130.0] # 200-wk mean ≈ 100.15, last close 130
|
||||
# Using the stale weekly close → above band → False.
|
||||
sig_stale, wma = below_200wma(weekly)
|
||||
assert sig_stale is False and wma is not None
|
||||
# Passing the fresh (lower) daily price → below band → True.
|
||||
sig_fresh, _ = below_200wma(weekly, price=95.0)
|
||||
assert sig_fresh is True
|
||||
|
||||
|
||||
def test_pi_cycle_bottom_none_when_short():
|
||||
assert pi_cycle_bottom([1.0] * 470)[0] is False
|
||||
|
||||
|
||||
def test_confluence_fires_only_with_two_of_three():
|
||||
# Deep decline: AHR999 deep-value + 200WMA both true → ≥2 → fired.
|
||||
daily = [60000 * math.exp(-0.002 * i) for i in range(520)]
|
||||
weekly = [60000 * math.exp(-0.01 * i) for i in range(210)]
|
||||
c = bottom_confluence(daily, weekly)
|
||||
assert c.votes >= 2 and c.fired is True
|
||||
# Strong bull: AHR999 expensive (>0.45) and Pi not in bottom region →
|
||||
# at most 1 vote → confluence requires ≥2 so it must NOT fire.
|
||||
up_d = [20000 * math.exp(0.004 * i) for i in range(520)]
|
||||
up_w = [20000 * math.exp(0.02 * i) for i in range(210)]
|
||||
c2 = bottom_confluence(up_d, up_w)
|
||||
assert c2.detail["signals"]["ahr999_value"] is False
|
||||
assert c2.detail["signals"]["pi_cycle_bottom"] is False
|
||||
assert c2.votes < 2 and c2.fired is False
|
||||
|
||||
|
||||
def test_confluence_b_uses_latest_daily_price():
|
||||
# weekly close stale-high but the latest daily close is at the lows.
|
||||
daily = [60000 * math.exp(-0.002 * i) for i in range(520)]
|
||||
weekly = [30000.0] * 209 + [90000.0] # last weekly close artificially high
|
||||
c = bottom_confluence(daily, weekly)
|
||||
# B must compare the 200wma against the latest DAILY close, not 90000.
|
||||
assert c.detail["price"] == round(daily[-1], 2)
|
||||
|
||||
|
||||
# ── Dynamic System-2 leverage ────────────────────────────────────────────────
|
||||
|
||||
def test_effective_leverage_clamps_and_defaults():
|
||||
assert sys2_effective_leverage(None) == SYS2_DEFAULT_LEVERAGE
|
||||
assert sys2_effective_leverage(0) == 1
|
||||
assert sys2_effective_leverage(99) == 10
|
||||
assert sys2_effective_leverage(5) == 5
|
||||
assert sys2_effective_leverage("bad") == SYS2_DEFAULT_LEVERAGE
|
||||
|
||||
|
||||
def test_protective_stop_always_inside_liquidation():
|
||||
# The protective full-exit must trigger BEFORE the exchange liquidates,
|
||||
# for every allowed leverage.
|
||||
for lev in range(1, 11):
|
||||
stop = sys2_protective_stop_pct(lev)
|
||||
liq = sys2_approx_liquidation_pct(lev)
|
||||
assert stop < liq, f"lev {lev}: stop {stop} not inside liq {liq}"
|
||||
# Low leverage keeps the full bottom-wick tolerance.
|
||||
assert sys2_protective_stop_pct(1) == SYS2_MAX_STOP_PCT
|
||||
assert sys2_protective_stop_pct(2) == SYS2_MAX_STOP_PCT
|
||||
# High leverage tightens automatically.
|
||||
assert sys2_protective_stop_pct(5) < 20
|
||||
assert sys2_protective_stop_pct(10) < 10
|
||||
|
||||
|
||||
def test_stop_ladder_has_no_negative_base_rung_and_only_ratchets_up():
|
||||
ladder = get_stop_ladder("btc_bottom_reversal_long")
|
||||
assert ladder is not None
|
||||
triggers = [t for t, _ in ladder]
|
||||
floors = [f for _, f in ladder]
|
||||
# Sorted ascending by trigger.
|
||||
assert triggers == sorted(triggers)
|
||||
# Floors strictly increase (pure ratchet-up: never loosens).
|
||||
assert floors == sorted(floors)
|
||||
# No 0%-trigger catastrophic rung — that floor is leverage-derived now.
|
||||
assert triggers[0] > 0
|
||||
# Reaches locked-in profit (a positive floor exists).
|
||||
assert max(floors) > 0
|
||||
|
||||
|
||||
def test_non_bottom_category_has_no_ladder():
|
||||
assert get_stop_ladder("sma_reclaim") is None
|
||||
assert get_stop_ladder(None) is None
|
||||
|
||||
|
||||
# ── Staged-stop monitor math (mirror of tp_sl_monitor branch 0) ──────────────
|
||||
|
||||
def _eff_stop(peak: float, base_stop_pct: float, ladder) -> float:
|
||||
eff = -base_stop_pct
|
||||
for trig, floor in ladder:
|
||||
if peak >= trig and floor > eff:
|
||||
eff = floor
|
||||
return eff
|
||||
|
||||
|
||||
def test_staged_stop_no_take_profit_and_locks_profit():
|
||||
ladder = get_stop_ladder("btc_bottom_reversal_long")
|
||||
base = sys2_protective_stop_pct(2) # 35%
|
||||
|
||||
# Never exits for a big unrealised gain (no take-profit).
|
||||
assert 300.0 > _eff_stop(peak=300.0, base_stop_pct=base, ladder=ladder)
|
||||
|
||||
# Catastrophic floor before any rung: -35% at 2x.
|
||||
assert _eff_stop(peak=0.0, base_stop_pct=base, ladder=ladder) == -35.0
|
||||
|
||||
# After +70% peak the floor has ratcheted to a locked-in profit.
|
||||
locked = _eff_stop(peak=75.0, base_stop_pct=base, ladder=ladder)
|
||||
assert locked > 0
|
||||
|
||||
# High leverage → tighter base floor, ladder still ratchets identically.
|
||||
base10 = sys2_protective_stop_pct(10)
|
||||
assert _eff_stop(peak=0.0, base_stop_pct=base10, ladder=ladder) == -base10
|
||||
assert _eff_stop(peak=75.0, base_stop_pct=base10, ladder=ladder) == locked
|
||||
|
||||
|
||||
# ── Staged de-risk ladder (分段式减仓) ───────────────────────────────────────
|
||||
|
||||
def test_derisk_ladder_shape_and_safety():
|
||||
for lev in (1, 2, 3, 5, 10):
|
||||
p = sys2_protective_stop_pct(lev)
|
||||
liq = sys2_approx_liquidation_pct(lev)
|
||||
ladder = sys2_derisk_ladder(lev)
|
||||
assert len(ladder) == 3
|
||||
thrs = [t for t, _, _ in ladder]
|
||||
fracs = [f for _, f, _ in ladder]
|
||||
finals = [fin for _, _, fin in ladder]
|
||||
# All thresholds negative, increasing in adversity.
|
||||
assert all(t < 0 for t in thrs)
|
||||
assert thrs == sorted(thrs, reverse=True) # -21, -28, -35 …
|
||||
# Exactly the last rung is the full close.
|
||||
assert finals == [False, False, True]
|
||||
# Fractions sum to the whole position (thirds of original).
|
||||
assert abs(sum(fracs) - 1.0) < 1e-9
|
||||
# Final rung == the protective level == inside liquidation.
|
||||
assert abs(thrs[-1] - (-p)) < 1e-6
|
||||
assert -thrs[-1] < liq, f"lev {lev}: final {thrs[-1]} not inside liq {liq}"
|
||||
|
||||
|
||||
def test_derisk_pnl_accounting_matches_single_close():
|
||||
"""Summing the staged slices + the remaining close must equal a single
|
||||
full close at the same final price (the staged path must not create or
|
||||
destroy PnL vs closing all at once)."""
|
||||
notional = 1000.0
|
||||
entry = 100.0
|
||||
final_price = 70.0 # -30% (long)
|
||||
|
||||
def slice_pnl(frac, px):
|
||||
return notional * frac * ((px - entry) / entry)
|
||||
|
||||
# Staged: 1/3 closed at 85, 1/3 at 78, final 1/3 at 70.
|
||||
staged = (
|
||||
slice_pnl(1/3, 85.0) +
|
||||
slice_pnl(1/3, 78.0) +
|
||||
slice_pnl(1/3, 70.0)
|
||||
)
|
||||
# If instead all three thirds were closed at the final price:
|
||||
single = slice_pnl(1.0, final_price)
|
||||
# Staged exits EARLIER on the way down, so it must lose LESS than a
|
||||
# single close at the worst price (the whole point of de-risking).
|
||||
assert staged > single
|
||||
# And closing all at entry-price slices would be zero — sanity.
|
||||
assert abs(slice_pnl(1/3, entry) * 3) < 1e-9
|
||||
|
||||
|
||||
def test_derisk_regime_switch_underwater_vs_profit():
|
||||
"""Mirror the monitor's regime decision: underwater → de-risk ladder,
|
||||
in-profit (peak ≥ first upside rung) → ratchet stop."""
|
||||
stop_ladder = get_stop_ladder("btc_bottom_reversal_long")
|
||||
first_up = min(t for t, _ in stop_ladder) # 20
|
||||
|
||||
def regime(peak):
|
||||
return "profit" if peak >= first_up else "derisk"
|
||||
|
||||
assert regime(0.0) == "derisk"
|
||||
assert regime(19.9) == "derisk"
|
||||
assert regime(20.0) == "profit"
|
||||
assert regime(150.0) == "profit"
|
||||
|
||||
|
||||
# ── Pyramiding (做对了往上加仓) ──────────────────────────────────────────────
|
||||
|
||||
def test_addon_ladder_shape_conservative():
|
||||
ladder = sys2_addon_ladder()
|
||||
trigs = [t for t, _, _ in ladder]
|
||||
fracs = [f for _, f, _ in ladder]
|
||||
lasts = [x for _, _, x in ladder]
|
||||
assert trigs == sorted(trigs) and all(t > 0 for t in trigs) # peak-gain rungs
|
||||
assert fracs[:3] == [0.30, 0.20, 0.10] # conservative base
|
||||
assert sum(fracs) <= 0.80 # still modest total
|
||||
assert lasts[-1] is True and lasts.count(True) == 1 # exactly one final
|
||||
|
||||
|
||||
def test_trend_confirmed_requires_sma_and_new_high():
|
||||
# Uptrend: price above 200d SMA and at a fresh 20d high → confirmed.
|
||||
up = [100.0 + i for i in range(260)]
|
||||
highs = [c + 1 for c in up]
|
||||
price = up[-1] + 1
|
||||
assert trend_confirmed(up, highs, price) is True
|
||||
# Below the 200d SMA → not confirmed even at a local high.
|
||||
assert trend_confirmed(up, highs, price=50.0) is False
|
||||
# Above SMA but well below the recent high (still chopping) → not confirmed.
|
||||
mid = sum(up[-200:]) / 200
|
||||
assert trend_confirmed(up, highs, price=mid + 1) is False
|
||||
# Too little history → fail closed.
|
||||
assert trend_confirmed([1.0] * 50, [1.0] * 50, 1.0) is False
|
||||
|
||||
|
||||
def test_pyramiding_blended_entry_math():
|
||||
"""Blended entry after an add must be the notional-weighted average, and
|
||||
it must sit BELOW the add price for a long that added higher (so the
|
||||
aggregate is still in profit)."""
|
||||
base = 1000.0
|
||||
entry = 100.0
|
||||
add_usd = base * 0.30
|
||||
fill = 130.0 # added after +30%
|
||||
new_notional = base + add_usd
|
||||
blended = (base * entry + add_usd * fill) / new_notional
|
||||
assert entry < blended < fill # weighted average
|
||||
# Aggregate still profitable at the add price.
|
||||
assert (fill - blended) / blended > 0
|
||||
# Conservative sizing only modestly lifts the average (< 7% here).
|
||||
assert (blended - entry) / entry < 0.07
|
||||
|
||||
|
||||
def test_recovery_restores_peak_keeps_profit_regime():
|
||||
"""A pyramided / in-profit trade rehydrated after a restart must seed the
|
||||
monitor's peak from the persisted value so it stays in the profit regime
|
||||
(not fall back to the underwater de-risk regime)."""
|
||||
from app.services.tp_sl_monitor import register_trade, _watched, unregister
|
||||
from app.services.signal_categories import (
|
||||
sys2_derisk_ladder, sys2_addon_ladder, get_stop_ladder,
|
||||
)
|
||||
tid = 990011
|
||||
try:
|
||||
register_trade(
|
||||
trade_id=tid, wallet="0xabc", api_key="k", leverage=2,
|
||||
asset="BTC", side="long", entry_price=100.0,
|
||||
take_profit_pct=None, stop_loss_pct=35.0,
|
||||
stop_ladder=get_stop_ladder("btc_bottom_reversal_long"),
|
||||
derisk_ladder=sys2_derisk_ladder(2),
|
||||
addon_ladder=sys2_addon_ladder(), addon_done=1,
|
||||
initial_peak=90.0,
|
||||
)
|
||||
wt = _watched[tid]
|
||||
assert wt.peak_gain_pct == 90.0
|
||||
assert wt.peak_persisted == 90.0
|
||||
first_up = min(t for t, _ in wt.stop_ladder)
|
||||
# peak 90 ≥ first upside rung → profit regime, NOT underwater de-risk.
|
||||
assert wt.peak_gain_pct >= first_up
|
||||
finally:
|
||||
unregister(tid)
|
||||
|
||||
|
||||
def test_addon_ladder_extended_for_cycle_bull():
|
||||
ladder = sys2_addon_ladder()
|
||||
trigs = [t for t, _, _ in ladder]
|
||||
fracs = [f for _, f, _ in ladder]
|
||||
lasts = [x for _, _, x in ladder]
|
||||
assert len(ladder) == 5 # deeper continuation rungs
|
||||
assert trigs == sorted(trigs) and trigs[-1] >= 200
|
||||
assert lasts == [False, False, False, False, True]
|
||||
assert abs(sum(fracs) - 0.75) < 1e-9 # ≤ +0.75× base, still modest
|
||||
|
||||
|
||||
def test_btc_bottom_maxhold_is_18_months():
|
||||
p = get_exit_profile("btc_bottom_reversal_long")
|
||||
assert p.max_hold_hours == 12960 # 540 days ≈ 18 months
|
||||
|
||||
|
||||
def _peak_trail_floor(peak_pp: float):
|
||||
start, dd = sys2_peak_trail()
|
||||
if peak_pp < start:
|
||||
return None
|
||||
return ((1.0 + peak_pp / 100.0) * (1.0 - dd) - 1.0) * 100.0
|
||||
|
||||
|
||||
def test_peak_trail_scale_invariant_and_never_loosens():
|
||||
start, dd = sys2_peak_trail()
|
||||
# Inactive below the start threshold.
|
||||
assert _peak_trail_floor(start - 1) is None
|
||||
# Self-scales: a +500% move locks far above the old fixed +95% top rung.
|
||||
f500 = _peak_trail_floor(500.0)
|
||||
assert f500 is not None and f500 > 300.0 # ≈ +320%
|
||||
# A +900% move scales further still (not capped).
|
||||
assert _peak_trail_floor(900.0) > f500
|
||||
# Survives a normal bull pullback: at peak +120% the floor is a ≤30%
|
||||
# PRICE drawdown from the peak, i.e. price 2.2→1.54 (gain +54%), so a
|
||||
# typical 20–25% dip (still >+54%) does NOT trip it.
|
||||
f120 = _peak_trail_floor(120.0)
|
||||
assert 50.0 < f120 < 60.0 # ((2.2*0.7)-1)*100 = 54
|
||||
# Combined with the fixed rungs the floor only ratchets UP (max of both).
|
||||
rung_at_160 = 95.0
|
||||
assert max(rung_at_160, _peak_trail_floor(160.0)) == rung_at_160
|
||||
assert max(95.0, _peak_trail_floor(400.0)) == _peak_trail_floor(400.0)
|
||||
|
||||
|
||||
def test_sys2_risk_mode_params():
|
||||
from app.services.signal_categories import (
|
||||
sys2_normalize_mode, sys2_addon_ladder, sys2_derisk_ladder,
|
||||
sys2_peak_trail,
|
||||
)
|
||||
assert sys2_normalize_mode("AGGRESSIVE") == "aggressive"
|
||||
assert sys2_normalize_mode("garbage") == "standard"
|
||||
assert sys2_normalize_mode(None) == "standard"
|
||||
|
||||
# Leverage default depends on mode; explicit value still clamped.
|
||||
assert sys2_effective_leverage(None, "standard") == 2
|
||||
assert sys2_effective_leverage(None, "aggressive") == 8
|
||||
assert sys2_effective_leverage(99, "aggressive") == 10
|
||||
|
||||
# Aggressive pyramiding: earlier + heavier, ≤ +1.5× base, one final.
|
||||
ag = sys2_addon_ladder("aggressive")
|
||||
st = sys2_addon_ladder("standard")
|
||||
assert [t for t, _, _ in ag] == [15.0, 35.0, 60.0, 100.0, 160.0]
|
||||
assert abs(sum(f for _, f, _ in ag) - 1.50) < 1e-9
|
||||
assert [x for _, _, x in ag][-1] is True
|
||||
assert ag != st # genuinely different
|
||||
assert abs(sum(f for _, f, _ in st) - 0.75) < 1e-9 # standard unchanged
|
||||
|
||||
# Aggressive de-risk keeps a bigger runner (¼/¼/½) but final still FULL.
|
||||
agd = sys2_derisk_ladder(8, "aggressive")
|
||||
assert [round(f, 4) for _, f, _ in agd] == [0.25, 0.25, 0.5]
|
||||
assert agd[-1][2] is True # final = full close
|
||||
std_d = sys2_derisk_ladder(8, "standard")
|
||||
assert abs(std_d[0][1] - 1.0 / 3.0) < 1e-9 # standard unchanged
|
||||
assert std_d[-1][2] is True
|
||||
|
||||
# Aggressive peak-trail: earlier start, wider give-back.
|
||||
assert sys2_peak_trail("aggressive") == (60.0, 0.42)
|
||||
assert sys2_peak_trail("standard") == (80.0, 0.30)
|
||||
assert sys2_peak_trail() == (80.0, 0.30) # default = standard
|
||||
|
||||
# Safety invariant holds in BOTH modes: final de-risk rung is the
|
||||
# protective level (inside liquidation) and is a full close.
|
||||
for m in ("standard", "aggressive"):
|
||||
for lev in (2, 5, 8, 10):
|
||||
lad = sys2_derisk_ladder(lev, m)
|
||||
assert lad[-1][2] is True
|
||||
assert -lad[-1][0] < sys2_approx_liquidation_pct(lev)
|
||||
|
||||
|
||||
def test_register_trade_default_peak_is_zero():
|
||||
from app.services.tp_sl_monitor import register_trade, _watched, unregister
|
||||
tid = 990012
|
||||
try:
|
||||
register_trade(
|
||||
trade_id=tid, wallet="0xabc", api_key="k", leverage=3,
|
||||
asset="BTC", side="long", entry_price=100.0,
|
||||
take_profit_pct=None, stop_loss_pct=6.0,
|
||||
)
|
||||
assert _watched[tid].peak_gain_pct == 0.0
|
||||
finally:
|
||||
unregister(tid)
|
||||
|
||||
|
||||
def test_register_trade_grow_mode_default_off_and_settable():
|
||||
"""Per-trade Grow defaults OFF (pyramiding opt-in) and is settable."""
|
||||
from app.services.tp_sl_monitor import register_trade, _watched, unregister
|
||||
a, b = 990021, 990022
|
||||
try:
|
||||
register_trade(
|
||||
trade_id=a, wallet="0xabc", api_key="k", leverage=3,
|
||||
asset="BTC", side="long", entry_price=100.0,
|
||||
take_profit_pct=None, stop_loss_pct=6.0,
|
||||
)
|
||||
assert _watched[a].grow_mode is False # default OFF
|
||||
register_trade(
|
||||
trade_id=b, wallet="0xabc", api_key="k", leverage=2,
|
||||
asset="BTC", side="long", entry_price=100.0,
|
||||
take_profit_pct=None, stop_loss_pct=35.0,
|
||||
grow_mode=True,
|
||||
)
|
||||
assert _watched[b].grow_mode is True
|
||||
finally:
|
||||
unregister(a); unregister(b)
|
||||
|
||||
|
||||
def test_auto_trade_gate_skips_when_off():
|
||||
"""The master gate: process_post must NOT open a trade when the sub has
|
||||
auto_trade falsy (the signal Post is still created upstream by ingest)."""
|
||||
import inspect
|
||||
from app.services import bot_engine
|
||||
# The per-subscriber executor holds the gate: keyed off auto_trade,
|
||||
# returns (no trade opened) when off.
|
||||
gate = inspect.getsource(bot_engine._execute_for_subscriber)
|
||||
assert 'sub.get("auto_trade")' in gate
|
||||
assert "Auto-Trade OFF" in gate
|
||||
assert "return" in gate.split('sub.get("auto_trade")')[1][:260]
|
||||
# process_post builds the snapshot carrying auto_trade so the gate reads it.
|
||||
pp = inspect.getsource(bot_engine.process_post)
|
||||
assert "auto_trade=bool(s.auto_trade)" in pp
|
||||
@@ -0,0 +1,95 @@
|
||||
from app.services.scanners.funding_reversal import evaluate_funding_reversal
|
||||
from app.services.scanners.sma_reclaim import evaluate_sma_reclaim
|
||||
from app.services.signal_categories import (
|
||||
is_system_2,
|
||||
is_supported_trading_source,
|
||||
system2_display_name,
|
||||
system2_min_confidence,
|
||||
system2_size_multiplier,
|
||||
)
|
||||
from app.services.bot_engine import _confidence_floor_for, _should_apply_schedule
|
||||
from app.services.tp_sl_monitor import register_trade, _watched
|
||||
|
||||
|
||||
def _daily(close: float, volume: float = 100.0) -> dict:
|
||||
return {"close": close, "volume": volume}
|
||||
|
||||
|
||||
def test_sma_reclaim_no_longer_emits_short_breakdowns():
|
||||
candles = [_daily(110.0) for _ in range(200)]
|
||||
candles.extend(_daily(110.0) for _ in range(31))
|
||||
candles.append(_daily(90.0, volume=200.0))
|
||||
|
||||
is_signal, debug = evaluate_sma_reclaim(candles)
|
||||
|
||||
assert is_signal is False
|
||||
assert debug["reason"] == "shorts_disabled"
|
||||
|
||||
|
||||
def test_funding_reversal_reports_boost_without_standalone_signal():
|
||||
funding = [
|
||||
{"time_ms": i * 3_600_000, "rate": -0.00005}
|
||||
for i in range(24 * 29)
|
||||
]
|
||||
funding.extend(
|
||||
{"time_ms": (24 * 29 + i) * 3_600_000, "rate": -0.00001}
|
||||
for i in range(24)
|
||||
)
|
||||
candles = [_daily(100.0) for _ in range(8)]
|
||||
candles[-1] = _daily(104.0)
|
||||
|
||||
is_signal, debug = evaluate_funding_reversal(funding, candles)
|
||||
|
||||
assert is_signal is True
|
||||
assert debug["direction"] == "buy"
|
||||
|
||||
|
||||
def test_bottom_reversal_source_routes_to_system_2():
|
||||
assert is_system_2("btc_bottom_reversal")
|
||||
|
||||
|
||||
def test_old_scanner_sources_no_longer_route_to_module_2():
|
||||
for source in ("rsi_reversal", "sma_reclaim", "funding_reversal", "breakout", "vcp_breakout"):
|
||||
assert not is_system_2(source)
|
||||
|
||||
|
||||
def test_unknown_external_sources_are_not_supported_for_trading():
|
||||
assert is_supported_trading_source("truth")
|
||||
assert is_supported_trading_source("btc_bottom_reversal")
|
||||
assert not is_supported_trading_source("manual")
|
||||
assert not is_supported_trading_source("sma_reclaim")
|
||||
|
||||
|
||||
def test_module_2_uses_own_confidence_floor_and_bypasses_schedule():
|
||||
sys2_sub = {"_is_system_2": True, "min_confidence": 95}
|
||||
sys1_sub = {"_is_system_2": False, "min_confidence": 95}
|
||||
|
||||
assert system2_display_name() == "Bitcoin Bottom"
|
||||
assert _confidence_floor_for(sys2_sub) == system2_min_confidence()
|
||||
assert _confidence_floor_for(sys1_sub) == 95
|
||||
assert _should_apply_schedule(sys2_sub) is False
|
||||
assert _should_apply_schedule(sys1_sub) is True
|
||||
|
||||
|
||||
def test_register_trade_keeps_invalidation_price():
|
||||
register_trade(
|
||||
trade_id=99991,
|
||||
wallet="0xabc",
|
||||
api_key="key",
|
||||
leverage=3,
|
||||
asset="BTC",
|
||||
side="long",
|
||||
entry_price=100.0,
|
||||
take_profit_pct=None,
|
||||
stop_loss_pct=6.0,
|
||||
trailing_stop_pct=5.0,
|
||||
trailing_activate_at_pct=12.0,
|
||||
invalidation="below_entry",
|
||||
invalidation_price=92.5,
|
||||
min_hold_until_ts=None,
|
||||
)
|
||||
try:
|
||||
wt = _watched[99991]
|
||||
assert wt.invalidation_price == 92.5
|
||||
finally:
|
||||
_watched.pop(99991, None)
|
||||
@@ -0,0 +1,110 @@
|
||||
import pytest
|
||||
|
||||
from app.config import Settings
|
||||
from app.api import positions
|
||||
|
||||
|
||||
class _Scalar:
|
||||
def __init__(self, value):
|
||||
self._value = value
|
||||
|
||||
def scalar_one_or_none(self):
|
||||
return self._value
|
||||
|
||||
def scalar_one(self):
|
||||
return self._value
|
||||
|
||||
def scalars(self):
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return self._value
|
||||
|
||||
|
||||
class _Request:
|
||||
async def json(self):
|
||||
return {
|
||||
"wallet": "0xabc",
|
||||
"timestamp": 123,
|
||||
"signature": "0xsig",
|
||||
}
|
||||
|
||||
|
||||
class _Trade:
|
||||
id = 7
|
||||
wallet_address = "0xabc"
|
||||
closed_at = None
|
||||
hl_order_id = "paper"
|
||||
leverage = 3
|
||||
asset = "BTC"
|
||||
exit_price = 101.5
|
||||
pnl_usd = 2.25
|
||||
|
||||
|
||||
class _Sub:
|
||||
leverage = 3
|
||||
hl_api_key = None
|
||||
|
||||
|
||||
class _Db:
|
||||
def __init__(self, responses):
|
||||
self._responses = list(responses)
|
||||
|
||||
async def execute(self, _stmt):
|
||||
return _Scalar(self._responses.pop(0))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manual_close_returns_close_result(monkeypatch):
|
||||
async def fake_close_and_finalize(**_kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(positions, "verify_signed_request", lambda **_kwargs: None)
|
||||
|
||||
from app.services import bot_engine
|
||||
|
||||
monkeypatch.setattr(bot_engine, "close_and_finalize", fake_close_and_finalize)
|
||||
|
||||
db = _Db([_Trade(), _Sub(), _Trade()])
|
||||
|
||||
result = await positions.manual_close(7, _Request(), db)
|
||||
|
||||
assert result.status == "ok"
|
||||
assert result.trade_id == 7
|
||||
assert result.exit_price == 101.5
|
||||
assert result.pnl_usd == 2.25
|
||||
assert result.reason == "manual"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_positions_requires_signed_wallet_read(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake_verify(**kwargs):
|
||||
calls.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(positions, "verify_signed_request", fake_verify)
|
||||
db = _Db([[]])
|
||||
|
||||
result = await positions.get_open_positions(
|
||||
wallet="0xABC",
|
||||
ts=123,
|
||||
sig="0xsig",
|
||||
db=db,
|
||||
)
|
||||
|
||||
assert result.wallet == "0xabc"
|
||||
assert calls == [{
|
||||
"action": positions.ACTION_VIEW_POSITIONS,
|
||||
"wallet": "0xabc",
|
||||
"timestamp_ms": 123,
|
||||
"signature": "0xsig",
|
||||
"body": None,
|
||||
"allow_replay": True,
|
||||
}]
|
||||
|
||||
|
||||
def test_settings_default_to_production_when_environment_not_explicit():
|
||||
settings = Settings(database_url="sqlite+aiosqlite:///./test.db")
|
||||
|
||||
assert settings.environment == "production"
|
||||
@@ -0,0 +1,83 @@
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.api import user
|
||||
|
||||
|
||||
def _make_sub():
|
||||
class Sub:
|
||||
wallet_address = "0xabc"
|
||||
hl_api_key = None
|
||||
|
||||
return Sub()
|
||||
|
||||
|
||||
class _Scalar:
|
||||
def __init__(self, value):
|
||||
self._value = value
|
||||
|
||||
def scalar_one_or_none(self):
|
||||
return self._value
|
||||
|
||||
|
||||
class _Db:
|
||||
def __init__(self, sub):
|
||||
self.sub = sub
|
||||
self.committed = False
|
||||
|
||||
async def execute(self, _stmt):
|
||||
return _Scalar(self.sub)
|
||||
|
||||
async def commit(self):
|
||||
self.committed = True
|
||||
|
||||
|
||||
class _Body:
|
||||
wallet = "0xabc"
|
||||
timestamp = 123
|
||||
signature = "0xsig"
|
||||
api_key = "0x" + "1" * 64
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_hl_api_key_saves_only_after_hyperliquid_verification(monkeypatch):
|
||||
sub = _make_sub()
|
||||
db = _Db(sub)
|
||||
|
||||
monkeypatch.setattr(user, "verify_signed_request", lambda **_kwargs: None)
|
||||
monkeypatch.setattr(user, "encrypt_api_key", lambda value: f"encrypted:{value[-6:]}")
|
||||
|
||||
calls = []
|
||||
|
||||
async def verify_key(**kwargs):
|
||||
calls.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(user, "verify_hl_api_key_can_trade", verify_key)
|
||||
|
||||
result = await user.set_hl_api_key("0xabc", _Body(), db)
|
||||
|
||||
assert db.committed is True
|
||||
assert sub.hl_api_key == "encrypted:111111"
|
||||
assert result.status == "ok"
|
||||
assert result.verified is True
|
||||
assert calls == [{"api_key": _Body.api_key, "account_address": "0xabc"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_hl_api_key_does_not_save_when_hyperliquid_verification_fails(monkeypatch):
|
||||
sub = _make_sub()
|
||||
db = _Db(sub)
|
||||
|
||||
monkeypatch.setattr(user, "verify_signed_request", lambda **_kwargs: None)
|
||||
|
||||
async def fail_verify(**_kwargs):
|
||||
raise HTTPException(422, "Hyperliquid rejected this API key")
|
||||
|
||||
monkeypatch.setattr(user, "verify_hl_api_key_can_trade", fail_verify)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await user.set_hl_api_key("0xabc", _Body(), db)
|
||||
|
||||
assert exc.value.status_code == 422
|
||||
assert db.committed is False
|
||||
assert sub.hl_api_key is None
|
||||
Reference in New Issue
Block a user