File size: 12,081 Bytes
e898f62 ae0135c e898f62 ae0135c e898f62 ae0135c e898f62 ae0135c e898f62 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 | """
test_validator.py — Tests für den Backend-Validator.
KRITISCH: Der Validator ist der letzte Schutz bevor Geld bewegt wird.
Jede Regel hier schützt vor echten finanziellen Fehlern.
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
import config
from ai.validator import validate_entry_decision, validate_exit_decision, calculate_position_size
CURRENT_PRICE = 100.0
# ──────────────────────────────────────────────
# Entry Validation Tests
# ──────────────────────────────────────────────
class TestEntryValidation:
def test_valid_entry_passes(self, valid_entry_decision):
"""Eine korrekte Entry-Entscheidung soll durchgehen."""
is_valid, reason = validate_entry_decision(valid_entry_decision, CURRENT_PRICE)
assert is_valid is True, f"Valide Entry wurde abgelehnt: {reason}"
assert reason == "OK"
def test_not_buy_decision_rejected(self):
"""Alles außer BUY soll abgelehnt werden."""
for decision_type in ["SELL", "HOLD", "SKIP", "", None]:
decision = {
"decision": decision_type,
"confidence": 9,
"entry_price": 100.0,
"stop_loss": 90.0,
"take_profit_1": 120.0,
"position_size_usd": 50.0,
}
is_valid, reason = validate_entry_decision(decision, CURRENT_PRICE)
assert (
is_valid is False
), f"Entscheidung '{decision_type}' hätte abgelehnt werden sollen"
assert "NOT_BUY" in reason
def test_low_confidence_rejected(self, valid_entry_decision):
"""Confidence unter Minimum soll abgelehnt werden."""
valid_entry_decision["confidence"] = config.ENTRY_MIN_CONFIDENCE - 1
is_valid, reason = validate_entry_decision(valid_entry_decision, CURRENT_PRICE)
assert is_valid is False
assert "CONFIDENCE" in reason
def test_minimum_confidence_passes(self, valid_entry_decision):
"""Genau das Minimum soll noch durchgehen."""
valid_entry_decision["confidence"] = config.ENTRY_MIN_CONFIDENCE
is_valid, _ = validate_entry_decision(valid_entry_decision, CURRENT_PRICE)
assert is_valid is True
def test_stop_loss_above_entry_rejected(self, valid_entry_decision):
"""Stop Loss über Entry → sofort ablehnen (würde sofort getriggert)."""
valid_entry_decision["stop_loss"] = valid_entry_decision["entry_price"] + 5.0
is_valid, reason = validate_entry_decision(valid_entry_decision, CURRENT_PRICE)
assert is_valid is False
assert "STOP_ABOVE_ENTRY" in reason
def test_stop_loss_equal_to_entry_rejected(self, valid_entry_decision):
"""Stop Loss = Entry → auch ungültig."""
valid_entry_decision["stop_loss"] = valid_entry_decision["entry_price"]
is_valid, _ = validate_entry_decision(valid_entry_decision, CURRENT_PRICE)
assert is_valid is False
def test_zero_stop_loss_rejected(self, valid_entry_decision):
"""Stop Loss = 0 → abgelehnt."""
valid_entry_decision["stop_loss"] = 0
is_valid, reason = validate_entry_decision(valid_entry_decision, CURRENT_PRICE)
assert is_valid is False
assert "INVALID_STOP_LOSS" in reason
def test_rr_too_low_rejected(self, valid_entry_decision):
"""R:R unter Minimum → abgelehnt (schlechtes Chance-Risiko-Verhältnis)."""
# Entry: 100, SL: 95 (dist: 5), TP1 muss > 110 für R:R >= 2.0
# Setze TP1 auf 102 → R:R = (102-100)/(100-95) = 0.4 → abgelehnt
valid_entry_decision["entry_price"] = 100.0
valid_entry_decision["stop_loss"] = 95.0
valid_entry_decision["take_profit_1"] = 102.0 # R:R = 0.4
is_valid, reason = validate_entry_decision(valid_entry_decision, CURRENT_PRICE)
assert is_valid is False
assert "RR_TOO_LOW" in reason
def test_exact_minimum_rr_passes(self, valid_entry_decision):
"""Genau das Minimum R:R soll noch durchgehen."""
entry = 100.0
sl = 95.0 # Abstand: 5
# Minimum R:R = 2.0 → TP1 mindestens bei 100 + 5*2 = 110
valid_entry_decision["entry_price"] = entry
valid_entry_decision["stop_loss"] = sl
valid_entry_decision["take_profit_1"] = 110.0
is_valid, _ = validate_entry_decision(valid_entry_decision, CURRENT_PRICE)
assert is_valid is True
def test_position_too_large_rejected(self, valid_entry_decision):
"""Position über 10% des Portfolios → abgelehnt."""
max_allowed = config.PORTFOLIO_VALUE_USD * 0.10
valid_entry_decision["position_size_usd"] = max_allowed + 1.0
is_valid, reason = validate_entry_decision(valid_entry_decision, CURRENT_PRICE)
assert is_valid is False
assert "POSITION_TOO_LARGE" in reason
def test_zero_position_size_rejected(self, valid_entry_decision):
"""Positionsgröße 0 → abgelehnt."""
valid_entry_decision["position_size_usd"] = 0
is_valid, reason = validate_entry_decision(valid_entry_decision, CURRENT_PRICE)
assert is_valid is False
assert "INVALID_POSITION_SIZE" in reason
def test_negative_position_size_rejected(self, valid_entry_decision):
"""Negative Positionsgröße → abgelehnt."""
valid_entry_decision["position_size_usd"] = -10.0
is_valid, _ = validate_entry_decision(valid_entry_decision, CURRENT_PRICE)
assert is_valid is False
# ──────────────────────────────────────────────
# Exit Validation Tests
# ──────────────────────────────────────────────
class TestExitValidation:
def test_hold_count_exceeded_forces_sell(self):
"""Nach MAX_HOLD_COUNT HOLDs → Zwangsverkauf."""
decision = {"decision": "HOLD", "confidence": 9, "new_trailing_stop_usd": None}
result = validate_exit_decision(
decision=decision,
current_price=2100.0,
current_trailing_stop=None,
hold_count=config.MAX_HOLD_COUNT, # genau am Limit
)
assert result == "SELL", "Zwangsverkauf bei MAX_HOLD_COUNT nicht ausgelöst"
def test_exactly_max_hold_count_forces_sell(self):
"""Exakt MAX_HOLD_COUNT soll auch Zwangsverkauf auslösen."""
decision = {"decision": "HOLD", "confidence": 8, "new_trailing_stop_usd": None}
result = validate_exit_decision(
decision=decision,
current_price=2100.0,
current_trailing_stop=None,
hold_count=config.MAX_HOLD_COUNT,
)
assert result == "SELL"
def test_below_max_hold_count_respects_ai(self):
"""Unter MAX_HOLD_COUNT soll KI-Entscheidung respektiert werden."""
decision = {"decision": "HOLD", "confidence": 8, "new_trailing_stop_usd": None}
result = validate_exit_decision(
decision=decision,
current_price=2100.0,
current_trailing_stop=None,
hold_count=config.MAX_HOLD_COUNT - 1,
)
assert result == "HOLD"
def test_low_exit_confidence_forces_sell(self):
"""Niedrige Confidence beim Exit → SELL (lieber zu früh raus)."""
decision = {
"decision": "HOLD",
"confidence": config.EXIT_MIN_CONFIDENCE - 1, # zu niedrig
"new_trailing_stop_usd": None,
}
result = validate_exit_decision(
decision=decision, current_price=2100.0, current_trailing_stop=None, hold_count=0
)
assert result == "SELL"
def test_trailing_stop_above_price_ignored(self):
"""Neuer Trailing Stop über aktuellem Preis → darf nicht gesetzt werden."""
current_price = 2000.0
invalid_stop = 2100.0 # über aktuellem Preis!
decision = {"decision": "HOLD", "confidence": 9, "new_trailing_stop_usd": invalid_stop}
validate_exit_decision(
decision=decision, current_price=current_price, current_trailing_stop=None, hold_count=0
)
# Nach Validation muss der ungültige Stop gelöscht sein
assert (
decision["new_trailing_stop_usd"] is None
), "Trailing Stop über Preis hätte gelöscht werden müssen"
def test_trailing_stop_lower_than_current_ignored(self):
"""Neuer Trailing Stop darf nicht schlechter als aktueller sein."""
current_trailing = 1950.0
worse_stop = 1900.0 # schlechter (tiefer) als aktuell
decision = {"decision": "HOLD", "confidence": 9, "new_trailing_stop_usd": worse_stop}
validate_exit_decision(
decision=decision,
current_price=2000.0,
current_trailing_stop=current_trailing,
hold_count=0,
)
assert (
decision["new_trailing_stop_usd"] is None
), "Schlechterer Trailing Stop hätte ignoriert werden müssen"
def test_valid_improved_trailing_stop_kept(self):
"""Verbesserter Trailing Stop (höher, unter Preis) soll behalten werden."""
decision = {
"decision": "HOLD",
"confidence": 9,
"new_trailing_stop_usd": 1980.0, # höher als aktuell (1950), unter Preis (2000) ✓
}
validate_exit_decision(
decision=decision, current_price=2000.0, current_trailing_stop=1950.0, hold_count=0
)
assert (
decision["new_trailing_stop_usd"] == 1980.0
), "Gültiger Trailing Stop-Verbesserung hätte behalten werden sollen"
# ──────────────────────────────────────────────
# Position Size Calculation Tests
# ──────────────────────────────────────────────
class TestPositionSizeCalculation:
def test_zero_entry_returns_zero(self):
"""Entry Price = 0 → keine Position."""
assert calculate_position_size(0.0, 90.0) == 0.0
def test_zero_stop_loss_returns_zero(self):
"""Stop Loss = 0 → keine Position."""
assert calculate_position_size(100.0, 0.0) == 0.0
def test_stop_above_entry_returns_zero(self):
"""Stop über Entry → kein sinnvoller Trade."""
assert calculate_position_size(100.0, 110.0) == 0.0
def test_stop_equal_to_entry_returns_zero(self):
"""Stop = Entry → Division durch 0 vermeiden."""
assert calculate_position_size(100.0, 100.0) == 0.0
def test_position_capped_at_10_percent(self):
"""Position darf nie mehr als 10% des Portfolios sein."""
max_allowed = config.PORTFOLIO_VALUE_USD * 0.10
# Sehr enges SL → würde riesige Position berechnen
size = calculate_position_size(10000.0, 9999.99)
assert size <= max_allowed, f"Position ({size}) überschreitet 10% Cap ({max_allowed})"
def test_normal_case_returns_sensible_value(self):
"""Normale Parameter → vernünftige Positionsgröße."""
# Portfolio: $1000, Risk: 1.5%
# Entry: $100, SL: $95 (5% Abstand)
# risk_amount = 1000 * 0.015 = $15
# units = 15 / 5 = 3
# position_value = 3 * 100 = $300 → aber Cap bei $100 (10% von $1000)
size = calculate_position_size(100.0, 95.0)
assert size > 0, "Positionsgröße muss positiv sein"
assert size <= config.PORTFOLIO_VALUE_USD * 0.10
assert size == round(size, 2), "Positionsgröße soll auf 2 Stellen gerundet sein"
def test_position_size_positive(self):
"""Positionsgröße darf nie negativ sein."""
size = calculate_position_size(500.0, 490.0)
assert size >= 0.0
|