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