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:
@@ -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
|
||||
Reference in New Issue
Block a user