Files
Trademind/tests/test_llm.py
T
Tobias Zimmermann d36ed142cf Terminmarktmerkmale, Ollama-Erklärungen, schnelleres Lernen
Drei Erweiterungen aus der vorangegangenen Analyse.

Lerngeschwindigkeit
- background_sample_every_n_bars von 10 auf 5. Gemessen stammen nur rund 3 % der
  Beobachtungen aus echten Trades; diese Stichproben sind der wirksamste Hebel.
  Nicht weiter gesenkt, weil benachbarte Kerzen stark korreliert sind und sich die
  Label-Fenster überlappen - mehr Gradientenschritte heißt dort nicht mehr
  Information.

Funding Rate und Open Interest als Merkmale (strategy.derivatives, standardmäßig aus)
- Vier zusätzliche Merkmale vom Perpetual zum jeweiligen Spot-Paar. Gehandelt wird
  weiterhin Spot, die Kennzahlen kommen über einen zweiten ccxt-Client mit
  defaultType=future.
- Die Zuordnung ist lookahead-frei: Für jede Kerze gilt nur der Wert, der zu diesem
  Zeitpunkt bereits veröffentlicht war.
- Fällt eine Quelle aus oder deckt sie weniger als min_coverage ab, bleiben die
  Spalten neutral. Die Modelldimension bleibt dabei stabil.
- Gemessene API-Grenzen bei Binance: Open Interest reicht 30 Tage zurück, 500 Zeilen
  je Abruf; Funding Rate über ein Jahr. Beide Merkmale sind deshalb einzeln
  abschaltbar.

  ERGEBNIS: kein Nutzen. Zwei Backtests mit identischen Kerzen und Seed -
  5m/20 Tage: Rendite -1,79 % auf -1,90 %, Accuracy 51,2 % auf 50,4 %;
  15m/28 Tage: Rendite -2,22 % auf -2,67 %, LogLoss praktisch unverändert.
  Die Anbindung arbeitet einwandfrei (100 % Datenabdeckung), das Modell gewichtet
  die neuen Merkmale aber nur mit 0,01 bis 0,09 gegenüber 0,39 für ema_spread.
  Die Funktion bleibt aus und ist dafür da, das auf anderen Zeiträumen selbst zu
  prüfen - nicht weil sie sich bewährt hätte.

Ollama-Erklärungen (llm, standardmäßig aus)
- Neuer Dashboard-Bereich und POST /control/explain. Das Modell bekommt den Zustand
  als Text und gibt Text zurück; es entscheidet nichts, beeinflusst keine Order und
  wird nie aus dem Handels-Loop heraus aufgerufen. Der System-Prompt untersagt
  Anlageempfehlungen und Kursprognosen.
- Bewusst nicht als Entscheider: nicht reproduzierbar, kaum backtestbar, und es
  würde die Nachvollziehbarkeit des linearen Modells zerstören.
- Beim Test an qwen3.8:27b zeigte sich ein echter Fehler: Reasoning-Modelle legen
  ihre Denkschritte in ein eigenes Antwortfeld und verbrauchten dafür das gesamte
  Token-Budget, response blieb leer. llm.think ist jetzt standardmäßig false, die
  Fehlermeldung nennt Ursache und Ausweg statt nur "leere Antwort", und für ältere
  Ollama-Versionen ohne das Feld gibt es einen Wiederholungsversuch ohne es.

Bewusst nicht enthalten: News- und Google-Trends-Sentiment. Es fehlt eine Quelle mit
Point-in-Time-Historie; ohne die lässt sich das Merkmal nicht backtesten. Nach dem
Ergebnis oben wäre ein unvalidiertes Merkmal der falsche Schritt.

test_stop_loss_bounds_the_worst_trade läuft jetzt mit strategy.name: rules. Er hing
über die geänderte Voreinstellung an zufälligem Modellverhalten, geprüft werden soll
aber die Stop-Logik.

270 Tests (36 neue), ruff sauber. Gegen echte Binance-Daten und eine laufende
Ollama-Instanz geprüft, Dashboard-Bereich im Browser bedient.
2026-08-23 14:46:24 +02:00

222 lines
7.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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": "<think>erst überlegen</think>Das 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