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.
This commit is contained in:
Tobias Zimmermann
2026-08-23 14:46:24 +02:00
parent db31bc93f7
commit d36ed142cf
13 changed files with 1403 additions and 23 deletions
+221
View File
@@ -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": "<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