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.
This commit is contained in:
Tobias Zimmermann
2026-08-22 08:53:04 +02:00
commit 65ed73977e
40 changed files with 7033 additions and 0 deletions
+226
View File
@@ -0,0 +1,226 @@
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