Konfiguration
@@ -197,6 +213,7 @@ async function refresh() {
// Der Abschnitt muss aus dem Statuslauf heraus sichtbar werden – sein Aufklapp-Knopf
// sitzt darin, er könnte sich sonst nie selbst einblenden.
$("config-section").hidden = !(s.trading || {}).control_enabled;
+ $("explain-section").hidden = !((s.trading || {}).control_enabled && (s.llm || {}).enabled);
} catch (e) { document.getElementById("sub").textContent = "Status nicht erreichbar: " + e; }
}
@@ -323,6 +340,25 @@ $("token-btn").addEventListener("click", () => {
refresh();
});
+// -------------------------------------------------------------- Erklärung
+$("explain-btn").addEventListener("click", async () => {
+ const btn = $("explain-btn"), out = $("explain-out");
+ btn.disabled = true;
+ setHint($("explain-hint"), "Das Modell denkt nach – das dauert je nach Modellgröße etwas …");
+ try {
+ const r = await fetch("control/explain", { method: "POST", headers: headers() });
+ const d = await r.json().catch(() => ({}));
+ if (d.ok) {
+ out.hidden = false;
+ out.textContent = d.text;
+ setHint($("explain-hint"), `${d.model}, ${d.duration_seconds}s`);
+ } else {
+ setHint($("explain-hint"), d.error || `HTTP ${r.status}`, true);
+ }
+ } catch (e) { setHint($("explain-hint"), e.message, true); }
+ btn.disabled = false;
+});
+
// ----------------------------------------------------------- Konfiguration
let cfgOpen = false, cfgLoaded = null;
@@ -489,6 +525,7 @@ class StatusServer:
web.post("/control/train/live", self._train_live),
web.get("/control/trading", self._trading_state),
web.post("/control/trading", self._set_trading),
+ web.post("/control/explain", self._explain),
web.get("/control/config", self._config_state),
web.post("/control/config", self._update_config),
web.post("/control/config/reset", self._reset_config),
@@ -631,6 +668,14 @@ class StatusServer:
status = 428 if result.get("requires_confirmation") and payload["enabled"] else 409
return web.json_response(result, status=status, dumps=_dumps)
+ async def _explain(self, request: web.Request) -> web.Response:
+ denied = await self._guard(request)
+ if denied is not None:
+ return denied
+ assert self._controller is not None
+ result = await self._controller.explain()
+ return web.json_response(result, status=200 if result.get("ok") else 503, dumps=_dumps)
+
async def _config_state(self, request: web.Request) -> web.Response:
denied = await self._guard(request)
if denied is not None:
diff --git a/tests/test_derivatives.py b/tests/test_derivatives.py
new file mode 100644
index 0000000..08c67a8
--- /dev/null
+++ b/tests/test_derivatives.py
@@ -0,0 +1,262 @@
+"""Terminmarktdaten: Zuordnung ohne Blick in die Zukunft, Merkmale, Ausfallverhalten."""
+
+from __future__ import annotations
+
+import numpy as np
+import pytest
+
+from trademind.config import Config, DerivativesConfig
+from trademind.derivatives import (
+ DerivativeSeries,
+ DerivativesProvider,
+ forward_fill_to_bars,
+ perpetual_symbol,
+)
+from trademind.features import (
+ BASE_FEATURE_NAMES,
+ build_feature_matrix,
+ feature_names,
+ n_features,
+)
+from trademind.models import Candles
+
+from .conftest import make_candles
+
+BAR = 300_000
+
+
+# ------------------------------------------------------------------ Symbolik
+
+
+@pytest.mark.parametrize(
+ ("spot", "perp"),
+ [
+ ("BTC/USDT", "BTC/USDT:USDT"),
+ ("ETH/USDC", "ETH/USDC:USDC"),
+ ("SOL/USDT:USDT", "SOL/USDT:USDT"), # schon ein Perpetual
+ ],
+)
+def test_perpetual_symbol(spot: str, perp: str):
+ assert perpetual_symbol(spot) == perp
+
+
+# ------------------------------------------------- Zuordnung ohne Lookahead
+
+
+def test_forward_fill_uses_only_past_values():
+ bars = np.array([1000, 2000, 3000, 4000], dtype=np.int64)
+ src_ts = np.array([1500, 3500], dtype=np.int64)
+ src_val = np.array([10.0, 20.0])
+
+ values, known = forward_fill_to_bars(bars, src_ts, src_val)
+
+ assert not known[0], "vor dem ersten Quellwert darf nichts bekannt sein"
+ assert values[1] == 10.0 # 1500 <= 2000
+ assert values[2] == 10.0 # 3500 liegt in der Zukunft von 3000
+ assert values[3] == 20.0 # 3500 <= 4000
+ assert list(known) == [False, True, True, True]
+
+
+def test_value_exactly_on_the_bar_counts_as_known():
+ values, known = forward_fill_to_bars(
+ np.array([2000], dtype=np.int64), np.array([2000], dtype=np.int64), np.array([7.0])
+ )
+ assert known[0] and values[0] == 7.0
+
+
+def test_unsorted_source_is_handled():
+ values, _ = forward_fill_to_bars(
+ np.array([5000], dtype=np.int64),
+ np.array([3000, 1000, 2000], dtype=np.int64),
+ np.array([30.0, 10.0, 20.0]),
+ )
+ assert values[0] == 30.0, "der jüngste Wert vor der Kerze zählt"
+
+
+def test_empty_source_yields_nothing_known():
+ values, known = forward_fill_to_bars(
+ np.arange(3, dtype=np.int64), np.empty(0, np.int64), np.empty(0)
+ )
+ assert not known.any()
+ assert np.isnan(values).all()
+
+
+# --------------------------------------------------------- Merkmalsanzahl
+
+
+def test_feature_count_depends_on_configuration():
+ assert n_features(None) == len(BASE_FEATURE_NAMES) == 18
+ assert n_features(DerivativesConfig(enabled=False)) == 18
+ assert n_features(DerivativesConfig(enabled=True)) == 22
+ assert n_features(DerivativesConfig(enabled=True, open_interest=False)) == 20
+ assert n_features(DerivativesConfig(enabled=True, funding_rate=False)) == 20
+
+
+def test_feature_names_are_unique_and_ordered():
+ names = feature_names(DerivativesConfig(enabled=True))
+ assert names[:18] == BASE_FEATURE_NAMES
+ assert len(set(names)) == len(names)
+ assert names[18:] == ("funding_bps", "funding_trend", "oi_change", "oi_price_divergence")
+
+
+# ------------------------------------------------------ Merkmalsberechnung
+
+
+def series_for(candles, funding: float = 0.0001, oi_growth: float = 0.0) -> DerivativeSeries:
+ n = len(candles)
+ oi = 100_000.0 * (1.0 + oi_growth * np.arange(n) / max(n - 1, 1))
+ return DerivativeSeries(
+ symbol=candles.symbol,
+ timestamp=candles.timestamp,
+ funding_rate=np.full(n, funding),
+ open_interest=oi,
+ funding_coverage=1.0,
+ oi_coverage=1.0,
+ )
+
+
+def test_matrix_gains_columns_when_enabled(candles, rules):
+ plain = build_feature_matrix(candles, rules)
+ enriched = build_feature_matrix(
+ candles, rules, DerivativesConfig(enabled=True), series_for(candles)
+ )
+ assert plain.values.shape[1] == 18
+ assert enriched.values.shape[1] == 22
+ assert np.allclose(plain.values, enriched.values[:, :18]), "Basismerkmale dürfen sich nicht ändern"
+
+
+def test_funding_is_converted_to_basis_points(candles, rules):
+ matrix = build_feature_matrix(
+ candles, rules, DerivativesConfig(enabled=True), series_for(candles, funding=0.0003)
+ )
+ snapshot = matrix.snapshot(-1).as_dict()
+ assert snapshot["funding_bps"] == pytest.approx(3.0) # 0,03 % = 3 bps
+ assert snapshot["funding_trend"] == pytest.approx(0.0, abs=1e-9) # konstant, kein Trend
+
+
+def test_rising_open_interest_shows_up_as_positive_change(candles, rules):
+ matrix = build_feature_matrix(
+ candles, rules, DerivativesConfig(enabled=True), series_for(candles, oi_growth=0.5)
+ )
+ assert matrix.snapshot(-1).as_dict()["oi_change"] > 0
+
+
+def test_divergence_sign_follows_open_interest(rules):
+ up = make_candles(n=400, trend=0.002, noise=0.0002, seed=3)
+ rising = build_feature_matrix(
+ up, rules, DerivativesConfig(enabled=True), series_for(up, oi_growth=0.5)
+ ).snapshot(-1).as_dict()
+ falling = build_feature_matrix(
+ up, rules, DerivativesConfig(enabled=True), series_for(up, oi_growth=-0.3)
+ ).snapshot(-1).as_dict()
+ # Steigender Kurs mit steigendem OI = neue Positionen, mit fallendem OI = Glattstellung.
+ assert rising["oi_price_divergence"] > 0
+ assert falling["oi_price_divergence"] < 0
+
+
+def test_missing_series_keeps_the_dimension_stable(candles, rules):
+ """Fällt die Datenquelle aus, bleiben die Spalten erhalten – neutral gefüllt."""
+ matrix = build_feature_matrix(candles, rules, DerivativesConfig(enabled=True), None)
+ assert matrix.values.shape[1] == 22
+ assert np.isfinite(matrix.values).all()
+ snapshot = matrix.snapshot(-1).as_dict()
+ assert snapshot["funding_bps"] == 0.0
+ assert snapshot["oi_change"] == 0.0
+
+
+def test_values_stay_within_the_clip_limit(candles, rules):
+ """Auch absurde Terminmarktwerte dürfen die Normierung nicht sprengen."""
+ n = len(candles)
+ extreme = DerivativeSeries(
+ symbol=candles.symbol,
+ timestamp=candles.timestamp,
+ funding_rate=np.full(n, 0.75), # 7500 bps
+ open_interest=np.geomspace(1.0, 1e9, n),
+ funding_coverage=1.0,
+ oi_coverage=1.0,
+ )
+ matrix = build_feature_matrix(candles, rules, DerivativesConfig(enabled=True), extreme)
+ assert np.abs(matrix.values).max() <= 8.0
+ assert np.isfinite(matrix.values).all()
+
+
+# ---------------------------------------------------------- Ausfallverhalten
+
+
+class FlakyExchange:
+ """Börse, die für Funding funktioniert und bei Open Interest scheitert."""
+
+ rateLimit = 0
+
+ def __init__(self, funding_rows=None):
+ self.funding_rows = funding_rows or []
+ self.oi_calls = 0
+
+ async def fetch_funding_rate_history(self, symbol, since=None, limit=None):
+ rows = [r for r in self.funding_rows if since is None or r["timestamp"] >= since]
+ return rows[:limit] if limit else rows
+
+ async def fetch_open_interest_history(self, symbol, timeframe, since=None, limit=None):
+ self.oi_calls += 1
+ raise RuntimeError("startTime is invalid")
+
+ async def close(self):
+ return None
+
+
+async def test_failing_source_is_recorded_not_raised(candles):
+ start = int(candles.timestamp[0])
+ rows = [{"timestamp": start - 3_600_000, "fundingRate": 0.0002}]
+ provider = DerivativesProvider(FlakyExchange(rows), DerivativesConfig(enabled=True))
+
+ series = await provider.series_for(candles)
+
+ assert series.funding_coverage == 1.0
+ assert series.oi_coverage == 0.0
+ assert any("open_interest" in key for key in provider.failures)
+ assert provider.snapshot()["failures"]
+
+
+async def test_provider_returns_empty_series_for_empty_candles():
+ blank = Candles.from_rows("BTC/USDT", "5m", [])
+ provider = DerivativesProvider(FlakyExchange(), DerivativesConfig(enabled=True))
+ series = await provider.series_for(blank)
+ assert series.timestamp.size == 0
+ assert not series.usable
+
+
+async def test_incremental_fetch_does_not_refetch_everything(candles):
+ start = int(candles.timestamp[0])
+ rows = [{"timestamp": start - 3_600_000 + i * 8 * 3_600_000, "fundingRate": 0.0001}
+ for i in range(4)]
+
+ class Counting(FlakyExchange):
+ def __init__(self, rows):
+ super().__init__(rows)
+ self.funding_calls = 0
+
+ async def fetch_funding_rate_history(self, symbol, since=None, limit=None):
+ self.funding_calls += 1
+ return await super().fetch_funding_rate_history(symbol, since, limit)
+
+ exchange = Counting(rows)
+ provider = DerivativesProvider(exchange, DerivativesConfig(enabled=True, open_interest=False))
+ await provider.series_for(candles)
+ first = exchange.funding_calls
+ await provider.series_for(candles)
+ assert exchange.funding_calls - first <= 1, "der zweite Lauf darf nur nachladen"
+
+
+# -------------------------------------------------------------- Konfiguration
+
+
+def test_derivatives_switches_require_a_restart():
+ from trademind.config import requires_restart
+
+ for path in ("strategy.derivatives.enabled", "strategy.derivatives.funding_rate",
+ "strategy.derivatives.open_interest"):
+ assert requires_restart(path), f"{path} ändert die Modelldimension"
+
+
+def test_derivatives_are_off_by_default():
+ assert Config().strategy.derivatives.enabled is False
diff --git a/tests/test_engine.py b/tests/test_engine.py
index 15a8dc7..f460d1d 100644
--- a/tests/test_engine.py
+++ b/tests/test_engine.py
@@ -95,10 +95,15 @@ async def test_cash_and_equity_stay_consistent(base_config):
async def test_stop_loss_bounds_the_worst_trade(base_config):
+ # Bewusst ohne Lernmodell: Geprüft wird die Stop-Logik, nicht welche Signale das
+ # Modell gerade durchlässt. Mit "adaptive" hinge das Ergebnis daran, wie weit das
+ # Modell aufgewärmt ist – das hat mit Stops nichts zu tun.
config = Config.model_validate(
- {**base_config.model_dump(), "risk": {**base_config.risk.model_dump(),
- "stop_loss_atr_mult": 1.0,
- "take_profit_atr_mult": 10.0}}
+ {**base_config.model_dump(),
+ "strategy": {"name": "rules"},
+ "risk": {**base_config.risk.model_dump(),
+ "stop_loss_atr_mult": 1.0,
+ "take_profit_atr_mult": 10.0}}
)
engine = build_engine(config)
await engine.prepare()
diff --git a/tests/test_llm.py b/tests/test_llm.py
new file mode 100644
index 0000000..1c3b281
--- /dev/null
+++ b/tests/test_llm.py
@@ -0,0 +1,221 @@
+"""Ollama-Anbindung: Antwortverarbeitung, Fehlerdiagnose, Prompt-Aufbau."""
+
+from __future__ import annotations
+
+import asyncio
+
+import pytest
+from aiohttp import web
+
+from trademind.config import OllamaConfig
+from trademind.llm import SYSTEM_PROMPT, OllamaClient, build_status_prompt
+
+STATUS = {
+ "mode": "paper",
+ "exchange": "binance",
+ "symbols": ["BTC/USDT", "ETH/USDT"],
+ "timeframe": "5m",
+ "quote_currency": "USDT",
+ "portfolio": {"equity": 10123.45, "total_return_pct": 1.23, "trades": 7, "win_rate": 0.42,
+ "profit_factor": 1.1, "max_drawdown_pct": 2.5, "open_positions": 1},
+ "strategy": {"candidates_seen": 40, "candidates_accepted": 9,
+ "learner": {"samples_seen": 900, "trade_samples": 7, "online_accuracy": 0.53}},
+ "risk": {"halted": False, "halt_reason": "", "max_open_positions": 3, "max_position_pct": 0.2},
+ "trading": {"active": True, "simulated": True},
+ "feature_weights": {"ema_spread": 0.39, "trend_dist": -0.29, "rsi_norm": 0.01},
+ "positions": [{"symbol": "BTC/USDT", "entry_price": 70000.0, "mark_price": 70500.0,
+ "unrealized_pct": 0.71, "bars_held": 4, "confidence": 0.62}],
+ "recent_trades": [{"symbol": "ETH/USDT", "pnl_pct": -0.004, "exit_reason": "stop_loss",
+ "bars_held": 9}],
+}
+
+
+# ------------------------------------------------------------------- Prompt
+
+
+def test_prompt_contains_the_real_numbers():
+ prompt = build_status_prompt(STATUS)
+ for needle in ("paper", "binance", "BTC/USDT", "10123.45", "ema_spread", "stop_loss"):
+ assert needle in prompt, f"{needle} fehlt im Prompt"
+
+
+def test_prompt_ranks_weights_by_magnitude():
+ prompt = build_status_prompt(STATUS, top_weights=2)
+ assert "ema_spread" in prompt and "trend_dist" in prompt
+ assert "rsi_norm" not in prompt, "das schwächste Gewicht sollte wegfallen"
+
+
+def test_prompt_survives_a_bare_status():
+ prompt = build_status_prompt({})
+ assert "PORTFOLIO" in prompt and "LERNMODELL" in prompt
+
+
+def test_system_prompt_forbids_advice():
+ for needle in ("keine Anlageempfehlung", "keine Kursprognose", "Erfinde keine Zahlen"):
+ assert needle in SYSTEM_PROMPT
+
+
+# ------------------------------------------------------------ Falsches Ollama
+
+
+def fake_ollama(handler):
+ app = web.Application()
+ app.router.add_post("/api/generate", handler)
+ app.router.add_get("/api/tags", lambda _: web.json_response({"models": [{"name": "testmodell"}]}))
+ return app
+
+
+@pytest.fixture
+async def client_for(aiohttp_server):
+ async def _make(handler, **overrides) -> OllamaClient:
+ server = await aiohttp_server(fake_ollama(handler))
+ options = {"model": "testmodell", "timeout_seconds": 5, **overrides}
+ config = OllamaConfig(
+ enabled=True, base_url=str(server.make_url("/")).rstrip("/"), **options
+ )
+ return OllamaClient(config)
+
+ return _make
+
+
+async def test_plain_answer_is_returned(client_for):
+ async def handler(request):
+ assert (await request.json())["stream"] is False
+ return web.json_response({"response": "Alles ruhig.", "done_reason": "stop"})
+
+ client = await client_for(handler)
+ result = await client.generate("frage")
+ assert result.ok and result.text == "Alles ruhig."
+ assert result.model == "testmodell"
+ await client.close()
+
+
+async def test_thinking_block_is_stripped(client_for):
+ async def handler(_):
+ return web.json_response(
+ {"response": "erst überlegenDas Ergebnis.", "done_reason": "stop"}
+ )
+
+ client = await client_for(handler)
+ result = await client.generate("frage")
+ assert result.ok and result.text == "Das Ergebnis."
+ await client.close()
+
+
+async def test_reasoning_model_without_answer_is_diagnosed(client_for):
+ """Der reale Fall: qwen3 verbraucht das Token-Budget für 'thinking'."""
+
+ async def handler(_):
+ return web.json_response({"response": "", "thinking": "x" * 1800, "done_reason": "length"})
+
+ client = await client_for(handler)
+ result = await client.generate("frage")
+ assert not result.ok
+ assert "Denkschritte" in result.error
+ assert "llm.think" in result.error, "die Meldung muss den Ausweg nennen"
+ await client.close()
+
+
+async def test_truncated_answer_is_diagnosed(client_for):
+ async def handler(_):
+ return web.json_response({"response": "", "done_reason": "length"})
+
+ client = await client_for(handler)
+ result = await client.generate("frage")
+ assert not result.ok and "max_tokens" in result.error
+ await client.close()
+
+
+async def test_think_flag_is_sent(client_for):
+ seen = {}
+
+ async def handler(request):
+ seen.update(await request.json())
+ return web.json_response({"response": "ok", "done_reason": "stop"})
+
+ client = await client_for(handler)
+ await client.generate("frage")
+ assert seen["think"] is False, "Reasoning ist standardmäßig aus"
+ await client.close()
+
+
+async def test_old_ollama_without_think_field_still_works(client_for):
+ """Ältere Versionen lehnen das Feld ab – dann ohne es erneut versuchen."""
+ calls = []
+
+ async def handler(request):
+ body = await request.json()
+ calls.append("think" in body)
+ if "think" in body:
+ return web.json_response({"error": "unknown field think"}, status=400)
+ return web.json_response({"response": "Klappt doch.", "done_reason": "stop"})
+
+ client = await client_for(handler)
+ result = await client.generate("frage")
+ assert result.ok and result.text == "Klappt doch."
+ assert calls == [True, False]
+ await client.close()
+
+
+async def test_http_error_is_reported(client_for):
+ async def handler(_):
+ return web.json_response({"error": "model not found"}, status=404)
+
+ client = await client_for(handler)
+ result = await client.generate("frage")
+ assert not result.ok and "404" in result.error
+ await client.close()
+
+
+async def test_timeout_is_reported_with_a_hint(client_for):
+ async def handler(_):
+ await asyncio.sleep(2)
+ return web.json_response({"response": "zu spät"})
+
+ client = await client_for(handler, timeout_seconds=0.2)
+ result = await client.generate("frage")
+ assert not result.ok
+ assert "Zeitüberschreitung" in result.error and "timeout_seconds" in result.error
+ await client.close()
+
+
+async def test_unreachable_server_is_reported(aiohttp_server):
+ """Server starten, wieder beenden, dann anfragen – die Verbindung wird abgelehnt."""
+
+ async def handler(_):
+ return web.json_response({"response": "nie erreicht"})
+
+ server = await aiohttp_server(fake_ollama(handler))
+ url = str(server.make_url("/")).rstrip("/")
+ await server.close()
+
+ client = OllamaClient(OllamaConfig(enabled=True, base_url=url, timeout_seconds=5))
+ result = await client.generate("frage")
+ assert not result.ok
+ assert "nicht erreichbar" in result.error and url in result.error
+ await client.close()
+
+
+async def test_answer_is_capped(client_for):
+ async def handler(_):
+ return web.json_response({"response": "y" * 5000, "done_reason": "stop"})
+
+ client = await client_for(handler, max_answer_chars=100)
+ result = await client.generate("frage")
+ assert result.ok and len(result.text) == 100
+ await client.close()
+
+
+async def test_model_listing(client_for):
+ async def handler(_):
+ return web.json_response({"response": "ok"})
+
+ client = await client_for(handler)
+ assert await client.available_models() == ["testmodell"]
+ await client.close()
+
+
+def test_llm_is_off_by_default():
+ from trademind.config import Config
+
+ assert Config().llm.enabled is False