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:
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user