65ed73977e
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.
227 lines
8.8 KiB
Python
227 lines
8.8 KiB
Python
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
|