2026-08-12 19:54:12 +07:00 | | | #!/usr/bin/env python3
|
| | | # -*- coding: utf-8 -*-
|
| | | """
|
| | | models/analyzer.py — MarketAnalyzer | LLM AI Evaluation Engine (Track A)
|
| | |
|
| | | Converts a raw SDP Setup_Detected envelope (symbol, setup_type, prices,
|
| | | algorithmic confidence, historical_context swings) into a strict quant
|
| | | prompt, calls an external LLM through a modular backend (OpenAI or
|
| | | Google GenAI SDK), and enforces the structured JSON contract:
|
| | |
|
| | | {"score": <float 0-100>, "reason": "<string, max 10 words>"}
|
| | |
|
| | | Anti-Veto safety (charter):
|
| | | Any timeout (>1.5 s hard budget), API error, missing SDK, or malformed
|
| | | reply returns the neutral FALLBACK_SCORE = 40.0. Because 40.0 < 50,
|
| | | the MT5 executor drops the trade to quarter-risk and still executes —
|
| | | the AI advises, it never vetoes.
|
| | |
|
| | | Provider selection (environment driven):
|
| | | PROVIDER=auto|openai|google (default auto; falls back across providers)
|
| | | MODEL | OPENAI_MODEL | GEMINI_MODEL (model overrides)
|
| | | OPENAI_API_KEY | GEMINI_API_KEY (credentials)
|
2026-08-12 22:56:38 +07:00 | | | OPENAI_BASE_URL (optional custom OpenAI-compatible gateway,
|
| | | e.g. https://api4.inferdeck.net/v1 + MODEL="MQL5 Lite")
|
2026-08-12 19:54:12 +07:00 | | | """
|
| | |
|
| | | import json
|
| | | import logging
|
| | | import os
|
| | | import queue
|
| | | import re
|
| | | import threading
|
| | | import time
|
| | | from typing import Any, Dict, Optional, Tuple
|
| | |
|
| | | logger = logging.getLogger(__name__)
|
| | |
|
| | | # --------------------------------------------------------------------------
|
| | | # Constants
|
| | | # --------------------------------------------------------------------------
|
| | | DEFAULT_TIMEOUT_S = 1.5 # strict wall-clock budget for the LLM call
|
| | | FALLBACK_SCORE = 40.0 # neutral score -> triggers MT5 quarter-risk path
|
| | | MAX_REASON_WORDS = 10 # the "reason" contract: max 10 words
|
| | |
|
| | | # --------------------------------------------------------------------------
|
| | | # Quant system prompt (single source of truth for LLM behaviour)
|
| | | # --------------------------------------------------------------------------
|
| | | SYSTEM_PROMPT = (
|
| | | "You are a senior Smart Money Concepts (SMC) quant analyst evaluating a single "
|
| | | "Order Block + Fair Value Gap (OB/FVG) trade setup.\n\n"
|
| | | "Evaluate BOTH dimensions:\n"
|
| | | "1. MOMENTUM — is the displacement candle that created the FVG strong and "
|
| | | "recent? A larger imbalance relative to context means stronger momentum.\n"
|
| | | "2. TREND ALIGNMENT — is the setup in the direction of the prevailing swing "
|
| | | "structure (higher highs / higher lows for buys, the reverse for sells)?\n\n"
|
| | | "Reply with EXACTLY ONE JSON object and nothing else. No markdown, no prose, "
|
| | | "no code fences:\n"
|
| | | '{"score": <float 0-100>, "reason": "<string, max 10 words>"}\n\n'
|
| | | "Rules:\n"
|
| | | "- score = probability (0-100) the setup resolves in its intended direction "
|
| | | "after a retracement into the unmitigated zone.\n"
|
| | | "- reason must be 10 words or fewer.\n"
|
| | | "- If evidence is ambiguous, score toward the middle (40-60)."
|
| | | )
|
| | |
|
| | |
|
| | | # --------------------------------------------------------------------------
|
| | | # Modular SDK backends (lazy imports keep this module importable everywhere)
|
| | | # --------------------------------------------------------------------------
|
| | | class _BaseBackend:
|
| | | """Common interface every SDK adapter implements."""
|
| | |
|
| | | name = "base"
|
| | |
|
| | | def __init__(self, model: str, timeout: float) -> None:
|
| | | self._model = model
|
| | | self._timeout = timeout
|
| | |
|
| | | def generate(self, system_prompt: str, user_prompt: str) -> str:
|
| | | raise NotImplementedError
|
| | |
|
| | |
|
| | | class _OpenAIBackend(_BaseBackend):
|
| | | """OpenAI chat.completions adapter (openai SDK)."""
|
| | |
|
| | | name = "openai"
|
| | |
|
| | | def __init__(self, model: Optional[str], timeout: float) -> None:
|
| | | super().__init__(model or os.environ.get("OPENAI_MODEL", "gpt-4o-mini"), timeout)
|
| | | from openai import OpenAI # lazy: module stays importable without the SDK
|
| | | self._client = OpenAI(
|
| | | api_key=os.environ.get("OPENAI_API_KEY"),
|
2026-08-12 22:56:38 +07:00 | | | base_url=os.environ.get("OPENAI_BASE_URL") or None,
|
2026-08-12 19:54:12 +07:00 | | | timeout=self._timeout,
|
| | | )
|
| | |
|
| | | def generate(self, system_prompt: str, user_prompt: str) -> str:
|
| | | resp = self._client.chat.completions.create(
|
| | | model=self._model,
|
| | | messages=[
|
| | | {"role": "system", "content": system_prompt},
|
| | | {"role": "user", "content": user_prompt},
|
| | | ],
|
| | | temperature=0.0,
|
| | | max_tokens=120,
|
| | | )
|
| | | return resp.choices[0].message.content or ""
|
| | |
|
| | |
|
| | | class _GoogleGenAIBackend(_BaseBackend):
|
| | | """Google GenAI adapter (google-genai SDK)."""
|
| | |
|
| | | name = "google"
|
| | |
|
| | | def __init__(self, model: Optional[str], timeout: float) -> None:
|
2026-08-13 07:05:42 +07:00 | | | super().__init__(model or os.environ.get("GEMINI_MODEL", "gemini-3.1-flash-lite"), timeout)
|
2026-08-12 19:54:12 +07:00 | | | from google import genai # lazy import
|
| | | from google.genai import types
|
| | | self._types = types
|
| | | self._client = genai.Client(api_key=os.environ.get("GEMINI_API_KEY"))
|
| | |
|
| | | def generate(self, system_prompt: str, user_prompt: str) -> str:
|
| | | config = self._types.GenerateContentConfig(
|
| | | system_instruction=system_prompt,
|
| | | temperature=0.0,
|
| | | max_output_tokens=120,
|
| | | )
|
| | | try:
|
| | | resp = self._client.models.generate_content(
|
| | | model=self._model, contents=user_prompt, config=config,
|
| | | timeout=self._timeout,
|
| | | )
|
| | | except TypeError:
|
| | | # older SDK versions do not accept the timeout kwarg
|
| | | resp = self._client.models.generate_content(
|
| | | model=self._model, contents=user_prompt, config=config,
|
| | | )
|
| | | return getattr(resp, "text", "") or ""
|
| | |
|
| | |
|
| | | _PROVIDERS: Dict[str, Any] = {
|
| | | "openai": _OpenAIBackend,
|
| | | "google": _GoogleGenAIBackend,
|
| | | }
|
| | |
|
| | |
|
| | | def _build_backend(provider: str, model: Optional[str],
|
| | | timeout: float) -> Optional[_BaseBackend]:
|
| | | """Instantiate the requested provider; gracefully try the other on failure."""
|
| | | if provider in _PROVIDERS:
|
| | | candidates = [provider] + [p for p in _PROVIDERS if p != provider]
|
| | | else:
|
| | | candidates = list(_PROVIDERS) # provider="auto" or unknown -> try all
|
| | |
|
| | | for name in candidates:
|
| | | try:
|
| | | backend = _PROVIDERS[name](model, timeout)
|
| | | logger.info("MarketAnalyzer: provider '%s' ready (model=%s).",
|
| | | name, backend._model)
|
| | | return backend
|
| | | except ImportError as exc:
|
| | | logger.warning("MarketAnalyzer: provider '%s' unavailable (%s); trying next.",
|
| | | name, exc)
|
| | | except Exception as exc: # noqa: BLE001 - bad key/init must not crash the router
|
| | | logger.warning("MarketAnalyzer: provider '%s' init failed: %s", name, exc)
|
| | |
|
| | | logger.warning("MarketAnalyzer: no LLM backend available — fallback-only mode (score %.1f).",
|
| | | FALLBACK_SCORE)
|
| | | return None
|
| | |
|
| | |
|
| | | def _run_with_deadline(fn, timeout: float, *args, **kwargs) -> Tuple[str, Any]:
|
| | | """Run fn in a daemon thread under a HARD wall-clock budget.
|
| | |
|
| | | Returns ("ok", result) | ("error", exception) | ("timeout", None).
|
| | | The worker thread is daemonized, so a hung SDK call can never block
|
| | | process shutdown.
|
| | | """
|
| | | result_q: "queue.Queue[Tuple[str, Any]]" = queue.Queue(maxsize=1)
|
| | |
|
| | | def _worker() -> None:
|
| | | try:
|
| | | result_q.put(("ok", fn(*args, **kwargs)))
|
| | | except Exception as exc: # noqa: BLE001 - must never leak from the worker
|
| | | result_q.put(("error", exc))
|
| | |
|
| | | threading.Thread(target=_worker, daemon=True).start()
|
| | |
|
| | | try:
|
| | | return result_q.get(timeout=timeout)
|
| | | except queue.Empty:
|
| | | return ("timeout", None)
|
| | |
|
| | |
|
| | | # --------------------------------------------------------------------------
|
| | | # MarketAnalyzer — the AI evaluation engine consumed by the router
|
| | | # --------------------------------------------------------------------------
|
| | | class MarketAnalyzer:
|
| | | """
|
| | | Core AI evaluation engine for Setup_Detected envelopes.
|
| | |
|
| | | Usage:
|
| | | analyzer = MarketAnalyzer() # provider/model from env
|
| | | result = analyzer.analyze(setup_payload) # SDP Setup_Detected dict
|
| | | score = result["score"] # 0-100, or 40.0 fallback
|
| | | """
|
| | |
|
| | | def __init__(self,
|
| | | provider: str = "auto",
|
| | | model: Optional[str] = None,
|
| | | timeout: float = DEFAULT_TIMEOUT_S,
|
2026-08-12 23:42:34 +07:00 | | | retries: int = 1,
|
2026-08-12 19:54:12 +07:00 | | | fallback_score: float = FALLBACK_SCORE) -> None:
|
| | | if provider in ("", "auto"):
|
| | | provider = os.environ.get("PROVIDER", "auto")
|
| | | self._timeout = max(0.1, float(timeout))
|
| | | self._fallback_score = max(0.0, min(100.0, float(fallback_score)))
|
2026-08-12 23:42:34 +07:00 | | | self._retries = max(0, int(retries))
|
2026-08-12 19:54:12 +07:00 | | | self._backend = _build_backend(provider, model, self._timeout)
|
| | |
|
| | | # ------------------------------------------------------------------
|
| | | # Prompt engineering
|
| | | # ------------------------------------------------------------------
|
| | | def build_prompt(self, payload: Dict[str, Any]) -> Tuple[str, str]:
|
| | | """
|
| | | Translate the raw Setup_Detected envelope into (system, user) prompts.
|
| | |
|
| | | The user prompt embeds every quant fact the LLM needs: symbol,
|
| | | timeframe, setup type, prices, structural confidence, and the
|
| | | historical_context swing array (compact, oldest -> newest).
|
| | | """
|
| | | p = payload.get("payload") or {}
|
| | | hist = payload.get("historical_context") or []
|
| | |
|
| | | swings = []
|
| | | for h in hist[-12:]: # cap context size; newest swings matter most
|
| | | if isinstance(h, dict):
|
| | | swings.append("H=%s L=%s @ %s" % (
|
| | | h.get("swing_high", "?"),
|
| | | h.get("swing_low", "?"),
|
| | | h.get("time", "?"),
|
| | | ))
|
| | | ctx = "; ".join(swings) if swings else "none"
|
| | |
|
| | | user_prompt = (
|
| | | "SDP Setup_Detected envelope:\n"
|
| | | "Symbol: %s | Timeframe: %s | Setup: %s\n"
|
| | | "Entry: %s | SL: %s | TP: %s | R:R: %s\n"
|
| | | "Algorithmic confidence: %s\n"
|
| | | "Historical context (swing highs/lows, oldest -> newest):\n%s\n\n"
|
| | | "Evaluate OB/FVG momentum and trend alignment. "
|
| | | "Reply with the strict JSON object only."
|
| | | ) % (
|
| | | payload.get("symbol", "?"),
|
| | | payload.get("timeframe", "?"),
|
| | | p.get("setup_type", "?"),
|
| | | p.get("entry", "?"),
|
| | | p.get("sl", "?"),
|
| | | p.get("tp", "?"),
|
| | | p.get("risk_reward", "?"),
|
| | | payload.get("algorithmic_confidence_score", "?"),
|
| | | ctx,
|
| | | )
|
| | | return SYSTEM_PROMPT, user_prompt
|
| | |
|
| | | # ------------------------------------------------------------------
|
| | | # Public evaluation entry point
|
| | | # ------------------------------------------------------------------
|
2026-08-12 23:42:34 +07:00 | | | @staticmethod
|
| | | def _is_transient(exc: Exception) -> bool:
|
| | | """True bila error transien (rate limit 429 / 5xx / koneksi) — layak retry."""
|
| | | code = getattr(exc, "status_code", None) or getattr(exc, "code", None)
|
| | | if code in (429, 500, 502, 503, 504):
|
| | | return True
|
| | | msg = str(exc).lower()
|
| | | if any(k in msg for k in ("rate limit", "429", "too many requests",
|
| | | "connection", "timeout", "temporarily unavailable")):
|
| | | return True
|
| | | return isinstance(exc, (ConnectionError, TimeoutError))
|
| | |
|
| | | def _retry_or_fallback(self, system_prompt: str, user_prompt: str,
|
| | | last_error: Exception, started: float) -> Dict[str, Any]:
|
| | | """Retry transien dengan backoff eksponensial; habis -> fallback 40.0 (Anti-Veto)."""
|
| | | for attempt in range(1, self._retries + 1):
|
| | | if not self._is_transient(last_error):
|
| | | break
|
| | | backoff = min(2.0 * attempt, 8.0)
|
| | | logger.warning("MarketAnalyzer: error transien %r — retry %d/%d dalam %.1fs",
|
| | | last_error, attempt, self._retries, backoff)
|
| | | time.sleep(backoff)
|
| | | status, value = _run_with_deadline(
|
| | | self._backend.generate, self._timeout, system_prompt, user_prompt
|
| | | )
|
| | | if status == "ok":
|
| | | parsed = self._parse_json_reply(str(value))
|
| | | if parsed is not None:
|
| | | score, reason = parsed
|
| | | return {
|
| | | "score": score, "reason": reason,
|
| | | "used_fallback": False,
|
| | | "provider": self._backend.name,
|
| | | "latency_ms": int((time.perf_counter() - started) * 1000),
|
| | | }
|
| | | return self._fallback("malformed/unparseable LLM reply", started)
|
| | | if status == "timeout":
|
| | | return self._fallback("LLM timeout > %.1fs" % self._timeout, started)
|
| | | last_error = value
|
| | | return self._fallback("LLM error: %s" % last_error, started)
|
| | |
|
2026-08-12 19:54:12 +07:00 | | | def analyze(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
| | | """
|
| | | Evaluate one setup; always returns a dict with 'score' (0-100).
|
| | |
|
| | | Never raises: every failure path collapses to the neutral fallback
|
| | | score so the MT5 Anti-Veto pipeline keeps running.
|
| | | """
|
| | | started = time.perf_counter()
|
| | |
|
| | | if self._backend is None:
|
| | | return self._fallback("no LLM backend available", started)
|
| | |
|
| | | system_prompt, user_prompt = self.build_prompt(payload)
|
| | |
|
| | | status, value = _run_with_deadline(
|
| | | self._backend.generate, self._timeout, system_prompt, user_prompt
|
| | | )
|
| | |
|
| | | if status == "timeout":
|
| | | return self._fallback("LLM timeout > %.1fs" % self._timeout, started)
|
| | | if status == "error":
|
2026-08-12 23:42:34 +07:00 | | | return self._retry_or_fallback(system_prompt, user_prompt, value, started)
|
2026-08-12 19:54:12 +07:00 | | |
|
| | | parsed = self._parse_json_reply(str(value))
|
| | | if parsed is None:
|
| | | return self._fallback("malformed/unparseable LLM reply", started)
|
| | |
|
| | | score, reason = parsed
|
| | | return {
|
| | | "score": score,
|
| | | "reason": reason,
|
| | | "used_fallback": False,
|
| | | "provider": self._backend.name,
|
| | | "latency_ms": int((time.perf_counter() - started) * 1000),
|
| | | }
|
| | |
|
| | | def is_ready(self) -> bool:
|
| | | """True when a real LLM backend is configured and importable."""
|
| | | return self._backend is not None
|
| | |
|
| | | # ------------------------------------------------------------------
|
| | | # Internals
|
| | | # ------------------------------------------------------------------
|
| | | @staticmethod
|
| | | def _parse_json_reply(raw: str) -> Optional[Tuple[float, str]]:
|
| | | """
|
| | | Extract and validate the structured JSON reply.
|
| | |
|
| | | Tolerates markdown fences and surrounding prose; rejects replies
|
| | | that violate the schema (missing/invalid score, score out of range).
|
| | | """
|
| | | cleaned = raw.strip()
|
| | | if cleaned.startswith("```"):
|
| | | cleaned = re.sub(r"^```[a-zA-Z0-9_-]*\s*", "", cleaned)
|
| | | cleaned = re.sub(r"\s*```$", "", cleaned)
|
| | |
|
| | | start = cleaned.find("{")
|
| | | end = cleaned.rfind("}")
|
| | | if start == -1 or end <= start:
|
| | | return None
|
| | |
|
| | | try:
|
| | | obj = json.loads(cleaned[start:end + 1])
|
| | | except json.JSONDecodeError:
|
| | | return None
|
| | |
|
| | | if not isinstance(obj, dict):
|
| | | return None
|
| | |
|
| | | try:
|
| | | score = float(obj.get("score"))
|
| | | except (TypeError, ValueError):
|
| | | return None
|
| | | if not (0.0 <= score <= 100.0):
|
| | | return None
|
| | |
|
| | | reason = str(obj.get("reason", "")).strip()
|
| | | words = reason.split()
|
| | | if len(words) > MAX_REASON_WORDS:
|
| | | reason = " ".join(words[:MAX_REASON_WORDS]) # enforce the 10-word cap
|
| | |
|
| | | return round(score, 2), reason
|
| | |
|
| | | def _fallback(self, why: str, started: float) -> Dict[str, Any]:
|
| | | """Neutral fallback result — triggers MT5 quarter-risk execution."""
|
| | | logger.warning("MarketAnalyzer: fallback score %.1f used — %s",
|
| | | self._fallback_score, why)
|
| | | return {
|
| | | "score": self._fallback_score,
|
| | | "reason": "neutral fallback",
|
| | | "used_fallback": True,
|
| | | "fallback_reason": why,
|
| | | "provider": self._backend.name if self._backend is not None else "none",
|
| | | "latency_ms": int((time.perf_counter() - started) * 1000),
|
| | | }
|