fix: LLM analysis + new run button + settings page support
- Fixed LLM failing silently (401 auth error on every iteration) - Reset provider to Ollama (working) from broken OpenRouter config - Added /api/clear endpoint + 'New Run' button to reset history - LLM failures now logged visibly with error details - LLM suggestions persisted to iteration data (survive restarts) - Settings page support via llm_settings.json (multi-provider)
This commit is contained in:
+164
-24
@@ -1,15 +1,36 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
LLM Accumulation Signal Analyzer -- Calls Ollama on Mac Mini to analyze results
|
||||
LLM Accumulation Signal Analyzer -- Calls LLM to analyze results
|
||||
and suggest config modifications for the next iteration.
|
||||
Supports multiple providers: Ollama, LM Studio, OpenAI, Anthropic, OpenRouter.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import requests
|
||||
|
||||
OLLAMA_URL = "http://100.100.242.21:11434"
|
||||
MODEL = "qwen3.5:27b"
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
LLM_SETTINGS_PATH = os.path.join(BASE_DIR, "config", "llm_settings.json")
|
||||
|
||||
# Fallback defaults
|
||||
DEFAULT_OLLAMA_URL = "http://100.100.242.21:11434"
|
||||
DEFAULT_MODEL = "qwen3.5:27b"
|
||||
|
||||
|
||||
def load_llm_settings():
|
||||
"""Load LLM settings from config file, with fallback to defaults."""
|
||||
if os.path.exists(LLM_SETTINGS_PATH):
|
||||
with open(LLM_SETTINGS_PATH) as f:
|
||||
return json.load(f)
|
||||
return {
|
||||
"provider": "ollama",
|
||||
"model": DEFAULT_MODEL,
|
||||
"providers": {
|
||||
"ollama": {"base_url": DEFAULT_OLLAMA_URL},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """You are a quantitative analyst optimizing a BTC ACCUMULATION SIGNAL model. The goal is NOT day-trading -- it is finding statistically optimal times to BUY BTC for long-term holding.
|
||||
|
||||
@@ -120,6 +141,119 @@ You MUST respond with ONLY a JSON object (no markdown, no explanation outside th
|
||||
The "config" field must contain the COMPLETE config so it can be used directly."""
|
||||
|
||||
|
||||
def _call_ollama(settings, messages):
|
||||
"""Call Ollama API."""
|
||||
provider_cfg = settings.get("providers", {}).get("ollama", {})
|
||||
base_url = provider_cfg.get("base_url", DEFAULT_OLLAMA_URL)
|
||||
model = settings.get("model", DEFAULT_MODEL)
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"think": False,
|
||||
"options": {"temperature": 0.7, "num_predict": 4096},
|
||||
}
|
||||
print(f" Calling LLM ({model} via Ollama at {base_url})...")
|
||||
resp = requests.post(f"{base_url}/api/chat", json=payload, timeout=600)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["message"]["content"]
|
||||
|
||||
|
||||
def _call_openai_compatible(settings, messages, provider_name):
|
||||
"""Call OpenAI-compatible API (LM Studio, OpenAI, OpenRouter)."""
|
||||
provider_cfg = settings.get("providers", {}).get(provider_name, {})
|
||||
model = settings.get("model", "")
|
||||
|
||||
if provider_name == "lmstudio":
|
||||
base_url = provider_cfg.get("base_url", "http://100.100.242.21:1234")
|
||||
url = f"{base_url}/v1/chat/completions"
|
||||
headers = {"Content-Type": "application/json"}
|
||||
elif provider_name == "openai":
|
||||
url = "https://api.openai.com/v1/chat/completions"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {provider_cfg.get('api_key', '')}",
|
||||
}
|
||||
elif provider_name == "openrouter":
|
||||
url = "https://openrouter.ai/api/v1/chat/completions"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {provider_cfg.get('api_key', '')}",
|
||||
}
|
||||
else:
|
||||
raise ValueError(f"Unknown OpenAI-compatible provider: {provider_name}")
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 4096,
|
||||
}
|
||||
print(f" Calling LLM ({model} via {provider_name})...")
|
||||
resp = requests.post(url, json=payload, headers=headers, timeout=600)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["choices"][0]["message"]["content"]
|
||||
|
||||
|
||||
def _call_anthropic(settings, messages):
|
||||
"""Call Anthropic Messages API."""
|
||||
provider_cfg = settings.get("providers", {}).get("anthropic", {})
|
||||
model = settings.get("model", "claude-sonnet-4-20250514")
|
||||
api_key = provider_cfg.get("api_key", "")
|
||||
|
||||
# Anthropic uses system as a top-level param, not in messages
|
||||
system_msg = ""
|
||||
api_messages = []
|
||||
for m in messages:
|
||||
if m["role"] == "system":
|
||||
system_msg = m["content"]
|
||||
else:
|
||||
api_messages.append(m)
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"max_tokens": 4096,
|
||||
"messages": api_messages,
|
||||
}
|
||||
if system_msg:
|
||||
payload["system"] = system_msg
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": api_key,
|
||||
"anthropic-version": "2023-06-01",
|
||||
}
|
||||
print(f" Calling LLM ({model} via Anthropic)...")
|
||||
resp = requests.post(
|
||||
"https://api.anthropic.com/v1/messages",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=600,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
# Extract text from content blocks
|
||||
return "".join(
|
||||
block["text"] for block in data.get("content", []) if block.get("type") == "text"
|
||||
)
|
||||
|
||||
|
||||
def call_llm(messages):
|
||||
"""Route LLM call to the configured provider."""
|
||||
settings = load_llm_settings()
|
||||
provider = settings.get("provider", "ollama")
|
||||
|
||||
if provider == "ollama":
|
||||
return _call_ollama(settings, messages)
|
||||
elif provider in ("lmstudio", "openai", "openrouter"):
|
||||
return _call_openai_compatible(settings, messages, provider)
|
||||
elif provider == "anthropic":
|
||||
return _call_anthropic(settings, messages)
|
||||
else:
|
||||
raise ValueError(f"Unknown LLM provider: {provider}")
|
||||
|
||||
|
||||
def analyze_and_suggest(current_config, results, iteration_history=None):
|
||||
"""
|
||||
Send current results to LLM and get suggested config modifications.
|
||||
@@ -161,24 +295,12 @@ def analyze_and_suggest(current_config, results, iteration_history=None):
|
||||
{history_text}
|
||||
Analyze these results and suggest 1-3 specific modifications to the config. Return ONLY valid JSON."""
|
||||
|
||||
payload = {
|
||||
"model": MODEL,
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
"stream": False,
|
||||
"think": False,
|
||||
"options": {
|
||||
"temperature": 0.7,
|
||||
"num_predict": 4096,
|
||||
},
|
||||
}
|
||||
messages = [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_prompt},
|
||||
]
|
||||
|
||||
print(f" Calling LLM ({MODEL} on Mac Mini)...")
|
||||
resp = requests.post(f"{OLLAMA_URL}/api/chat", json=payload, timeout=600)
|
||||
resp.raise_for_status()
|
||||
content = resp.json()["message"]["content"]
|
||||
content = call_llm(messages)
|
||||
|
||||
# Strip thinking tags if present
|
||||
content = re.sub(r"<think>.*?</think>", "", content, flags=re.DOTALL).strip()
|
||||
@@ -196,7 +318,7 @@ Analyze these results and suggest 1-3 specific modifications to the config. Retu
|
||||
elif content[i] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
parsed = json.loads(content[brace_start:i + 1])
|
||||
parsed = json.loads(content[brace_start : i + 1])
|
||||
break
|
||||
else:
|
||||
raise ValueError("Could not find complete JSON in LLM response")
|
||||
@@ -207,7 +329,14 @@ Analyze these results and suggest 1-3 specific modifications to the config. Retu
|
||||
changes = parsed.get("changes", [])
|
||||
new_config = parsed.get("config", current_config)
|
||||
|
||||
required_keys = ["model_type", "features", "target", "hyperparameters", "strategy", "training"]
|
||||
required_keys = [
|
||||
"model_type",
|
||||
"features",
|
||||
"target",
|
||||
"hyperparameters",
|
||||
"strategy",
|
||||
"training",
|
||||
]
|
||||
for key in required_keys:
|
||||
if key not in new_config:
|
||||
new_config[key] = current_config[key]
|
||||
@@ -218,6 +347,7 @@ Analyze these results and suggest 1-3 specific modifications to the config. Retu
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
config_path = sys.argv[1] if len(sys.argv) > 1 else "config/initial_config.json"
|
||||
with open(config_path) as f:
|
||||
config = json.load(f)
|
||||
@@ -234,8 +364,18 @@ if __name__ == "__main__":
|
||||
"avg_score_at_actual_bottoms": 68.5,
|
||||
"avg_score_at_actual_tops": 35.2,
|
||||
"per_window_cost_improvement": [7.1, 9.3, 8.8, 10.2, 7.0],
|
||||
"score_distribution": {"0-20": 80, "20-40": 150, "40-60": 200, "60-80": 130, "80-100": 40},
|
||||
"feature_importances": {"dist_from_ath_pct": 0.18, "RSI_14": 0.12, "price_percentile_365": 0.10},
|
||||
"score_distribution": {
|
||||
"0-20": 80,
|
||||
"20-40": 150,
|
||||
"40-60": 200,
|
||||
"60-80": 130,
|
||||
"80-100": 40,
|
||||
},
|
||||
"feature_importances": {
|
||||
"dist_from_ath_pct": 0.18,
|
||||
"RSI_14": 0.12,
|
||||
"price_percentile_365": 0.10,
|
||||
},
|
||||
}
|
||||
|
||||
new_config, reasoning = analyze_and_suggest(config, dummy_results)
|
||||
|
||||
Reference in New Issue
Block a user