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