Files
Trademind/tests/test_risk_portfolio.py
T
Tobias Zimmermann 65ed73977e Initial commit: TradeMind – Krypto-Trading-Bot mit Lernmodus
Per Podman deploybarer Bot, der Käufe und Verkäufe simuliert ausführt und sich
aus den Ergebnissen weiter antrainiert.

Aufbau
- Einheitliche Bar-Verarbeitung für paper, backtest und live; ausgetauscht werden
  nur Datenquelle und Broker.
- Börsenanbindung über ccxt: rund 100 Börsen allein über exchange.id erreichbar.
  Zugangsdaten kommen über ENV-Platzhalter, der Live-Modus ist doppelt abgesichert.
- Paper-Broker mit Gebühren, Slippage, Börsenpräzision und Volumengrenzen.
- Online trainierte logistische Regression bewertet jedes Einstiegssignal. Sie lernt
  aus realen Trade-Ergebnissen, aus Shadow-Labels aller Kandidaten – auch der
  abgelehnten – und aus Hintergrund-Stichproben; beim Kaltstart wird sie aus der
  Kurshistorie vorgelernt.
- Risikomanagement: Positions- und Exposure-Grenzen, ATR-Stops, Cooldown sowie
  Tagesverlust- und Drawdown-Notbremsen.
- SQLite-Persistenz, HTTP-Status mit Prometheus-Metriken und Dashboard, Webhooks.

Deployment
- Containerfile (zweistufig, non-root UID 10001), podman-compose, systemd-Quadlet.
- Modell und Datenbank liegen im Volume /data und überleben Neustarts.

128 Tests, ruff sauber. Verifiziert gegen echte Marktdaten sowie im gebauten
Container inklusive Healthcheck und Zustandswiederherstellung.
2026-08-22 08:53:04 +02:00

227 lines
8.8 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.
import pytest
from trademind.config import RiskConfig
from trademind.models import ExitReason, Fill, Position, Side
from trademind.portfolio import Portfolio
from trademind.risk import RiskManager
DAY_ONE = 1_700_000_000_000
DAY_TWO = DAY_ONE + 86_400_000
def make_fill(symbol="BTC/USDT", side=Side.BUY, amount=0.1, price=30_000.0, fee=3.0, ts=DAY_ONE):
return Fill(symbol=symbol, side=side, amount=amount, price=price, fee_quote=fee, timestamp=ts)
def open_position(portfolio: Portfolio, **kwargs) -> Position:
return portfolio.open_position(
make_fill(**kwargs), stop_loss=None, take_profit=None, features=None,
confidence=0.6, exploratory=False,
)
# ------------------------------------------------------------------ Portfolio
def test_profitable_round_trip_accounts_for_both_fees():
portfolio = Portfolio(10_000.0)
open_position(portfolio)
exit_fill = make_fill(side=Side.SELL, price=31_000.0, fee=3.1)
trade = portfolio.close_position(exit_fill, ExitReason.TAKE_PROFIT)
assert trade.pnl_quote == pytest.approx((31_000 - 30_000) * 0.1 - 6.1)
assert trade.is_win
assert portfolio.stats.trades == 1
assert portfolio.stats.wins == 1
assert not portfolio.has_position("BTC/USDT")
def test_losing_trade_is_counted_as_loss():
portfolio = Portfolio(10_000.0)
open_position(portfolio)
trade = portfolio.close_position(make_fill(side=Side.SELL, price=29_000.0), ExitReason.STOP_LOSS)
assert trade.pnl_quote < 0
assert portfolio.stats.losses == 1
assert portfolio.stats.win_rate == 0.0
def test_profit_factor_and_expectancy():
portfolio = Portfolio(10_000.0)
for exit_price, reason in ((31_000.0, ExitReason.TAKE_PROFIT), (29_500.0, ExitReason.STOP_LOSS)):
open_position(portfolio)
portfolio.close_position(make_fill(side=Side.SELL, price=exit_price, fee=0.0), reason)
stats = portfolio.stats
assert stats.gross_profit == pytest.approx(97.0) # 100 3 Einstiegsgebühr
assert stats.gross_loss == pytest.approx(53.0) # 50 + 3
assert stats.profit_factor == pytest.approx(97.0 / 53.0)
assert stats.expectancy == pytest.approx((97.0 - 53.0) / 2)
def test_exposure_and_equity_use_mark_prices():
portfolio = Portfolio(10_000.0)
open_position(portfolio, amount=0.1, price=30_000.0)
portfolio.update_mark("BTC/USDT", 32_000.0)
assert portfolio.exposure() == pytest.approx(3_200.0)
assert portfolio.equity(7_000.0) == pytest.approx(10_200.0)
assert portfolio.unrealized_pnl() == pytest.approx(200.0)
def test_drawdown_tracks_the_peak():
portfolio = Portfolio(10_000.0)
portfolio.record_equity(DAY_ONE, 12_000.0)
portfolio.record_equity(DAY_ONE + 1000, 9_000.0)
assert portfolio.peak_equity == pytest.approx(12_000.0)
assert portfolio.max_drawdown == pytest.approx(0.25)
def test_daily_pnl_resets_on_a_new_utc_day():
portfolio = Portfolio(10_000.0)
portfolio.record_equity(DAY_ONE, 10_000.0)
portfolio.record_equity(DAY_ONE + 3_600_000, 9_500.0)
assert portfolio.daily_pnl_pct(9_500.0) == pytest.approx(-0.05)
portfolio.record_equity(DAY_TWO, 9_500.0)
assert portfolio.daily_pnl_pct(9_500.0) == pytest.approx(0.0)
def test_cooldown_counts_down_per_bar():
portfolio = Portfolio(10_000.0)
portfolio.start_cooldown("BTC/USDT", 2)
assert portfolio.in_cooldown("BTC/USDT")
portfolio.on_new_bar("BTC/USDT", 1.0, 1.0, 1.0)
assert portfolio.in_cooldown("BTC/USDT")
portfolio.on_new_bar("BTC/USDT", 1.0, 1.0, 1.0)
assert not portfolio.in_cooldown("BTC/USDT")
def test_bars_held_increases_while_a_position_is_open():
portfolio = Portfolio(10_000.0)
open_position(portfolio)
for _ in range(3):
portfolio.on_new_bar("BTC/USDT", 30_500.0, 29_800.0, 30_200.0)
assert portfolio.positions["BTC/USDT"].bars_held == 3
assert portfolio.positions["BTC/USDT"].highest_price == pytest.approx(30_500.0)
# ------------------------------------------------------------------- Risiko
def test_position_size_respects_the_position_cap():
risk = RiskManager(RiskConfig(max_position_pct=0.2, max_total_exposure_pct=1.0))
portfolio = Portfolio(10_000.0)
amount, reason = risk.position_size(portfolio, 10_000.0, price=100.0)
assert reason == ""
assert amount == pytest.approx(20.0) # 2000 USDT / 100
def test_position_size_respects_the_exposure_cap():
risk = RiskManager(RiskConfig(max_position_pct=0.5, max_total_exposure_pct=0.6))
portfolio = Portfolio(10_000.0)
open_position(portfolio, amount=0.15, price=30_000.0) # 4500 belegt
portfolio.update_mark("BTC/USDT", 30_000.0)
amount, _ = risk.position_size(portfolio, 5_500.0, price=100.0)
assert amount == pytest.approx(15.0) # 0,6 × 10 000 4 500 = 1 500
def test_position_size_rejected_below_minimum_notional():
risk = RiskManager(RiskConfig(max_position_pct=0.2, min_notional=100.0))
amount, reason = risk.position_size(Portfolio(100.0), 100.0, price=50.0)
assert amount == 0.0
assert "Minimum" in reason
def test_position_size_rejected_below_exchange_minimum():
risk = RiskManager(RiskConfig(max_position_pct=1.0, min_notional=1.0))
amount, reason = risk.position_size(Portfolio(50.0), 50.0, price=30_000.0, min_amount=0.01)
assert amount == 0.0
assert "Börsen-Minimum" in reason
def test_can_open_blocks_duplicates_cooldown_and_limits():
risk = RiskManager(RiskConfig(max_open_positions=1))
portfolio = Portfolio(10_000.0)
assert risk.can_open("BTC/USDT", portfolio, 10_000.0, 30_000.0)
open_position(portfolio)
assert not risk.can_open("BTC/USDT", portfolio, 7_000.0, 30_000.0) # schon offen
assert not risk.can_open("ETH/USDT", portfolio, 7_000.0, 2_000.0) # Positionslimit
portfolio.positions.clear()
portfolio.start_cooldown("BTC/USDT", 3)
decision = risk.can_open("BTC/USDT", portfolio, 10_000.0, 30_000.0)
assert not decision and "Cooldown" in decision.reason
def test_stop_levels_are_derived_from_atr():
risk = RiskManager(RiskConfig(stop_loss_atr_mult=2.0, take_profit_atr_mult=3.0))
stop, target = risk.stop_levels(100.0, atr=2.0)
assert stop == pytest.approx(96.0)
assert target == pytest.approx(106.0)
def test_stop_levels_can_be_switched_off():
risk = RiskManager(RiskConfig(stop_loss_atr_mult=0.0, take_profit_atr_mult=0.0))
assert risk.stop_levels(100.0, 2.0) == (None, None)
def test_exit_prefers_the_stop_when_both_are_touched():
risk = RiskManager(RiskConfig())
position = Position("BTC/USDT", 1.0, 100.0, DAY_ONE, stop_loss=96.0, take_profit=106.0)
reason, price = risk.check_exit(position, high=107.0, low=95.0, close=101.0)
assert reason is ExitReason.STOP_LOSS
assert price == pytest.approx(96.0)
def test_take_profit_triggers_alone():
risk = RiskManager(RiskConfig())
position = Position("BTC/USDT", 1.0, 100.0, DAY_ONE, stop_loss=96.0, take_profit=106.0)
reason, price = risk.check_exit(position, high=107.0, low=99.0, close=106.5)
assert reason is ExitReason.TAKE_PROFIT
assert price == pytest.approx(106.0)
def test_trailing_stop_only_moves_upwards():
risk = RiskManager(RiskConfig(trailing_stop_atr_mult=1.0))
position = Position("BTC/USDT", 1.0, 100.0, DAY_ONE, highest_price=110.0)
risk.update_trailing(position, atr=2.0)
assert position.trailing_stop == pytest.approx(108.0)
position.highest_price = 105.0
risk.update_trailing(position, atr=2.0)
assert position.trailing_stop == pytest.approx(108.0) # zieht nicht zurück
def test_max_holding_bars_forces_an_exit():
risk = RiskManager(RiskConfig(max_holding_bars=5))
position = Position("BTC/USDT", 1.0, 100.0, DAY_ONE, bars_held=5)
reason, _ = risk.check_exit(position, 101.0, 99.0, 100.0)
assert reason is ExitReason.MAX_HOLDING
def test_daily_loss_halts_until_the_next_day():
risk = RiskManager(RiskConfig(max_daily_loss_pct=0.05, max_drawdown_pct=0.9))
portfolio = Portfolio(10_000.0)
portfolio.record_equity(DAY_ONE, 10_000.0)
portfolio.record_equity(DAY_ONE + 60_000, 9_400.0)
assert risk.evaluate_halt(portfolio, 9_400.0, DAY_ONE + 60_000) is not None
assert risk.trading_halted
assert not risk.force_liquidation()
portfolio.record_equity(DAY_TWO, 9_400.0)
risk.evaluate_halt(portfolio, 9_400.0, DAY_TWO)
assert not risk.trading_halted
def test_max_drawdown_halts_permanently_and_liquidates():
risk = RiskManager(RiskConfig(max_drawdown_pct=0.2))
portfolio = Portfolio(10_000.0)
portfolio.record_equity(DAY_ONE, 10_000.0)
portfolio.record_equity(DAY_ONE + 1000, 7_000.0)
reason = risk.evaluate_halt(portfolio, 7_000.0, DAY_ONE + 1000)
assert reason and "Drawdown" in reason
assert risk.force_liquidation()
portfolio.record_equity(DAY_TWO, 7_000.0)
risk.evaluate_halt(portfolio, 7_000.0, DAY_TWO)
assert risk.trading_halted # bleibt bis zum Neustart gesperrt