diff --git a/__pycache__/orchestrator.cpython-313.pyc b/__pycache__/orchestrator.cpython-313.pyc index b604ee0..b0bb017 100644 Binary files a/__pycache__/orchestrator.cpython-313.pyc and b/__pycache__/orchestrator.cpython-313.pyc differ diff --git a/config/best_config.json b/config/best_config.json new file mode 100644 index 0000000..b6bbe12 --- /dev/null +++ b/config/best_config.json @@ -0,0 +1,68 @@ +{ + "model_type": "xgboost", + "features": { + "use_price_position": true, + "use_momentum": true, + "use_volatility": true, + "use_volume": true, + "use_cycle": true, + "use_pca": false, + "pca_variance": 0.95, + "use_scaler": true + }, + "target": { + "type": "regression", + "forward_periods_1h": [ + 168, + 720, + 2160 + ], + "forward_periods_4h": [ + 42, + 180, + 540 + ], + "weights": [ + 0.2, + 0.3, + 0.5 + ], + "score_range": [ + 0, + 100 + ] + }, + "hyperparameters": { + "learning_rate": 0.01, + "max_depth": 4, + "n_estimators": 300, + "subsample": 0.8, + "colsample_bytree": 0.8, + "min_child_weight": 20, + "gamma": 0.3, + "reg_alpha": 0.5, + "reg_lambda": 3.0, + "lstm_hidden_size": 128, + "lstm_num_layers": 2, + "lstm_dropout": 0.3, + "lstm_epochs": 100, + "lstm_batch_size": 64, + "lstm_sequence_length": 30, + "lstm_patience": 10 + }, + "strategy": { + "strong_buy_threshold": 65, + "good_buy_threshold": 55, + "poor_threshold": 35 + }, + "training": { + "rolling_window": true, + "rolling_train_size": 2500, + "rolling_test_size": 300, + "walk_forward_windows": 5, + "train_pct": 0.7, + "validation_pct": 0.15, + "test_pct": 0.15 + }, + "timeframe": "4h" +} \ No newline at end of file diff --git a/config/current_config.json b/config/current_config.json index 9ac6e87..b6bbe12 100644 --- a/config/current_config.json +++ b/config/current_config.json @@ -1,92 +1,68 @@ { - "model_type": "hybrid", + "model_type": "xgboost", "features": { - "technical_indicators": [ - "RSI_14", - "RSI_7", - "MACD_line", - "MACD_signal", - "MACD_hist", - "BB_upper", - "BB_lower", - "BB_width", - "ATR_14", - "SMA_20", - "SMA_50", - "EMA_10", - "EMA_20", - "OBV", - "stoch_k", - "stoch_d", - "williams_r", - "CCI_20", - "ROC_10" - ], - "lookback_periods": [ - 3, - 5, - 10, - 20 - ], - "use_volume_features": true, - "use_volatility_features": true, - "use_candle_patterns": false, - "use_lag_features": true, - "lag_periods": [ - 1, - 2, - 3, - 5 - ], - "use_pca": true, + "use_price_position": true, + "use_momentum": true, + "use_volatility": true, + "use_volume": true, + "use_cycle": true, + "use_pca": false, "pca_variance": 0.95, "use_scaler": true }, "target": { - "type": "classification", - "direction": "both", - "horizon_candles": 4, - "threshold_pct": 1.0 + "type": "regression", + "forward_periods_1h": [ + 168, + 720, + 2160 + ], + "forward_periods_4h": [ + 42, + 180, + 540 + ], + "weights": [ + 0.2, + 0.3, + 0.5 + ], + "score_range": [ + 0, + 100 + ] }, "hyperparameters": { - "learning_rate": 0.001, - "max_depth": 5, + "learning_rate": 0.01, + "max_depth": 4, "n_estimators": 300, "subsample": 0.8, "colsample_bytree": 0.8, - "min_child_weight": 5, + "min_child_weight": 20, "gamma": 0.3, - "reg_alpha": 0.1, - "reg_lambda": 5.0, + "reg_alpha": 0.5, + "reg_lambda": 3.0, "lstm_hidden_size": 128, "lstm_num_layers": 2, "lstm_dropout": 0.3, "lstm_epochs": 100, "lstm_batch_size": 64, - "lstm_sequence_length": 20, + "lstm_sequence_length": 30, "lstm_patience": 10 }, "strategy": { - "entry_threshold": 0.65, - "exit_type": "trailing_stop", - "stop_loss_pct": 2.0, - "take_profit_pct": 4.0, - "trailing_stop_pct": 1.5, - "position_sizing": "confidence_scaled", - "max_position_pct": 100, - "min_confidence_to_trade": 0.5, - "dynamic_sl_tp": true, - "atr_sl_multiplier": 1.2, - "atr_tp_multiplier": 3.0 + "strong_buy_threshold": 65, + "good_buy_threshold": 55, + "poor_threshold": 35 }, "training": { + "rolling_window": true, + "rolling_train_size": 2500, + "rolling_test_size": 300, "walk_forward_windows": 5, "train_pct": 0.7, "validation_pct": 0.15, - "test_pct": 0.15, - "rolling_window": true, - "rolling_train_size": 3000, - "rolling_test_size": 200 + "test_pct": 0.15 }, "timeframe": "4h" } \ No newline at end of file diff --git a/config/llm_settings.json b/config/llm_settings.json new file mode 100644 index 0000000..2e532c5 --- /dev/null +++ b/config/llm_settings.json @@ -0,0 +1,21 @@ +{ + "provider": "ollama", + "model": "qwen3.5:27b", + "providers": { + "ollama": { + "base_url": "http://100.100.242.21:11434" + }, + "lmstudio": { + "base_url": "http://100.100.242.21:1234" + }, + "openai": { + "api_key": "" + }, + "anthropic": { + "api_key": "" + }, + "openrouter": { + "api_key": "" + } + } +} diff --git a/dashboard/server.py b/dashboard/server.py index 72aa0e8..1df07ab 100644 --- a/dashboard/server.py +++ b/dashboard/server.py @@ -9,6 +9,7 @@ import os import sys import threading +import requests from fastapi import FastAPI from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from pydantic import BaseModel @@ -23,6 +24,7 @@ app = FastAPI(title="BTC Accumulation Signal Optimizer") CONFIG_DIR = os.path.join(BASE_DIR, "config") RESULTS_DIR = os.path.join(BASE_DIR, "results") ITERATIONS_LOG = os.path.join(RESULTS_DIR, "iterations.jsonl") +LLM_SETTINGS_PATH = os.path.join(CONFIG_DIR, "llm_settings.json") _opt_thread = None @@ -31,6 +33,64 @@ class ConfigUpdate(BaseModel): config: dict +class LLMSettingsUpdate(BaseModel): + provider: str + model: str + providers: dict + + +class TestConnectionRequest(BaseModel): + provider: str + providers: dict + + +class FetchModelsRequest(BaseModel): + provider: str + providers: dict + + +def _load_llm_settings(): + if os.path.exists(LLM_SETTINGS_PATH): + with open(LLM_SETTINGS_PATH) as f: + return json.load(f) + return { + "provider": "ollama", + "model": "qwen3.5:27b", + "providers": { + "ollama": {"base_url": "http://100.100.242.21:11434"}, + "lmstudio": {"base_url": "http://100.100.242.21:1234"}, + "openai": {"api_key": ""}, + "anthropic": {"api_key": ""}, + "openrouter": {"api_key": ""}, + }, + } + + +def _mask_api_key(key): + if not key or len(key) < 8: + return "" + return "••••••••" + key[-4:] + + +def _safe_settings(settings): + """Return settings with API keys masked.""" + out = json.loads(json.dumps(settings)) + for name, cfg in out.get("providers", {}).items(): + if "api_key" in cfg: + cfg["api_key"] = _mask_api_key(cfg["api_key"]) + return out + + +def _merge_api_keys(new_providers, existing_providers): + """Preserve existing API keys when the incoming value is masked.""" + for name, cfg in new_providers.items(): + if "api_key" in cfg: + masked = cfg["api_key"] + if masked.startswith("••••") or masked == "": + existing_key = existing_providers.get(name, {}).get("api_key", "") + cfg["api_key"] = existing_key + + @app.get("/api/status") def api_status(): return orchestrator.get_status() @@ -91,7 +151,11 @@ def api_best(): with open(best_path) as f: config = json.load(f) iterations = orchestrator.load_iteration_history() - best_iter = max(iterations, key=lambda x: x.get("cost_improvement", 0)) if iterations else {} + best_iter = ( + max(iterations, key=lambda x: x.get("cost_improvement", 0)) + if iterations + else {} + ) return {"config": config, "best_iteration": best_iter} @@ -110,32 +174,155 @@ def api_download_best_config(): return JSONResponse({"error": "No best config yet"}, status_code=404) -DASHBOARD_HTML = """ - - - - -BTC Accumulation Signal Optimizer - - - - @@ -190,14 +411,16 @@ textarea.config-editor{width:100%;height:300px;background:var(--bg);color:var(--

Accumulation Signal Optimizer

-
+
Idle + """ + NAV_HTML + """
+
Best Cost Improvement
@@ -262,6 +485,8 @@ textarea.config-editor{width:100%;height:300px;background:var(--bg);color:var(--
+ +""" + + @app.get("/", response_class=HTMLResponse) def dashboard(): return DASHBOARD_HTML +@app.get("/settings", response_class=HTMLResponse) +def settings_page(): + return SETTINGS_HTML + + if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=3088) + + +@app.post("/api/clear") +def api_clear(): + """Clear iteration history for a fresh run.""" + import glob + results_dir = os.path.join(BASE_DIR, "results") + + # Clear iterations log + log_path = os.path.join(results_dir, "iterations.jsonl") + if os.path.exists(log_path): + os.remove(log_path) + + # Clear individual result files + for f in glob.glob(os.path.join(results_dir, "results_iter_*.json")): + os.remove(f) + + # Reset best config to initial + initial = os.path.join(BASE_DIR, "config", "initial_config.json") + best = os.path.join(BASE_DIR, "config", "best_config.json") + current = os.path.join(BASE_DIR, "config", "current_config.json") + if os.path.exists(initial): + import shutil + shutil.copy(initial, best) + shutil.copy(initial, current) + + # Clear in-memory status + orchestrator._status["llm_suggestions"] = [] + orchestrator._status["best_score"] = 0.0 + orchestrator._status["iteration"] = 0 + + return {"ok": True, "message": "History cleared. Ready for fresh run."} diff --git a/llm_client/__pycache__/analyzer.cpython-313.pyc b/llm_client/__pycache__/analyzer.cpython-313.pyc index 8bf7f7a..28a945a 100644 Binary files a/llm_client/__pycache__/analyzer.cpython-313.pyc and b/llm_client/__pycache__/analyzer.cpython-313.pyc differ diff --git a/llm_client/analyzer.py b/llm_client/analyzer.py index 6d0c985..2b6e101 100755 --- a/llm_client/analyzer.py +++ b/llm_client/analyzer.py @@ -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".*?", "", 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) diff --git a/orchestrator.py b/orchestrator.py index daec0f2..4234a64 100755 --- a/orchestrator.py +++ b/orchestrator.py @@ -461,9 +461,22 @@ def run_optimization_loop(callback=None, config_override=None): "iteration": iteration, "reasoning": reasoning, }) + # Also persist LLM suggestion to iteration log + iter_data["llm_reasoning"] = reasoning + iter_data["llm_applied"] = True config = new_config - except Exception: - import random + except Exception as e: + import random, traceback + err_msg = f"LLM call failed: {type(e).__name__}: {e}" + print(f" WARNING: {err_msg}") + traceback.print_exc() + with _status_lock: + _status["llm_suggestions"].append({ + "iteration": iteration, + "reasoning": f"ERROR: {err_msg} — using random perturbation", + }) + iter_data["llm_reasoning"] = err_msg + iter_data["llm_applied"] = False hp = config.get("hyperparameters", {}) hp["learning_rate"] = hp.get("learning_rate", 0.01) * random.uniform(0.8, 1.2) hp["max_depth"] = max(3, min(10, hp.get("max_depth", 5) + random.choice([-1, 0, 1])))