|
@@ -0,0 +1,334 @@
|
|
|
|
|
+from __future__ import annotations
|
|
|
|
|
+
|
|
|
|
|
+"""Deterministic strategy-supervision logic for Hermes.
|
|
|
|
|
+
|
|
|
|
|
+This is the first decision slice. Hermes is currently acting as a supervisor for
|
|
|
|
|
+existing trader strategies, not as a direct trading engine.
|
|
|
|
|
+
|
|
|
|
|
+Design intent:
|
|
|
|
|
+- prefer keeping a suitable strategy active over unnecessary switching
|
|
|
|
|
+- detect when grid trading becomes unsafe because market posture or wallet
|
|
|
|
|
+ balance no longer supports it
|
|
|
|
|
+- hand off toward directional or rebalancing strategies without collapsing the
|
|
|
|
|
+ decision layer into execution details
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+from dataclasses import dataclass
|
|
|
|
|
+from datetime import datetime, timezone
|
|
|
|
|
+from typing import Any
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@dataclass(frozen=True)
|
|
|
|
|
+class DecisionSnapshot:
|
|
|
|
|
+ mode: str
|
|
|
|
|
+ action: str
|
|
|
|
|
+ target_strategy: str | None
|
|
|
|
|
+ reason_summary: str
|
|
|
|
|
+ confidence: float
|
|
|
|
|
+ requires_action: bool
|
|
|
|
|
+ payload: dict[str, Any]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _clamp(value: float, lower: float, upper: float) -> float:
|
|
|
|
|
+ return max(lower, min(upper, value))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _safe_float(value: Any) -> float | None:
|
|
|
|
|
+ try:
|
|
|
|
|
+ if value is None:
|
|
|
|
|
+ return None
|
|
|
|
|
+ return float(value)
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def assess_wallet_state(*, account_info: dict[str, Any], concern: dict[str, Any], price: float | None) -> dict[str, Any]:
|
|
|
|
|
+ """Summarize inventory health for strategy switching.
|
|
|
|
|
+
|
|
|
|
|
+ The key output is whether the wallet is balanced enough for range/grid
|
|
|
|
|
+ harvesting, or so skewed that Hermes should prefer trend capture or
|
|
|
|
|
+ rebalancing before grid is allowed again.
|
|
|
|
|
+ """
|
|
|
|
|
+ balances = account_info.get("balances") if isinstance(account_info.get("balances"), list) else []
|
|
|
|
|
+ base = str(concern.get("base_currency") or concern.get("market_symbol") or "").split("/")[0].upper()
|
|
|
|
|
+ quote = str(concern.get("quote_currency") or "USD").upper()
|
|
|
|
|
+
|
|
|
|
|
+ base_available = 0.0
|
|
|
|
|
+ quote_available = 0.0
|
|
|
|
|
+ for item in balances:
|
|
|
|
|
+ if not isinstance(item, dict):
|
|
|
|
|
+ continue
|
|
|
|
|
+ asset = str(item.get("asset_code") or item.get("asset") or "").upper()
|
|
|
|
|
+ amount = _safe_float(item.get("available") if item.get("available") is not None else item.get("total"))
|
|
|
|
|
+ if amount is None:
|
|
|
|
|
+ continue
|
|
|
|
|
+ if asset == base:
|
|
|
|
|
+ base_available = amount
|
|
|
|
|
+ elif asset == quote:
|
|
|
|
|
+ quote_available = amount
|
|
|
|
|
+
|
|
|
|
|
+ price = price or 0.0
|
|
|
|
|
+ base_value = base_available * price if price > 0 else 0.0
|
|
|
|
|
+ quote_value = quote_available
|
|
|
|
|
+ total_value = base_value + quote_value
|
|
|
|
|
+ base_ratio = (base_value / total_value) if total_value > 0 else 0.5
|
|
|
|
|
+ quote_ratio = (quote_value / total_value) if total_value > 0 else 0.5
|
|
|
|
|
+ imbalance = abs(base_ratio - 0.5)
|
|
|
|
|
+
|
|
|
|
|
+ if total_value <= 0:
|
|
|
|
|
+ inventory_state = "unknown"
|
|
|
|
|
+ elif base_ratio < 0.08:
|
|
|
|
|
+ inventory_state = "depleted_base_side"
|
|
|
|
|
+ elif quote_ratio < 0.08:
|
|
|
|
|
+ inventory_state = "depleted_quote_side"
|
|
|
|
|
+ elif imbalance >= 0.35:
|
|
|
|
|
+ inventory_state = "critically_unbalanced"
|
|
|
|
|
+ elif base_ratio > 0.62:
|
|
|
|
|
+ inventory_state = "base_heavy"
|
|
|
|
|
+ elif quote_ratio > 0.62:
|
|
|
|
|
+ inventory_state = "quote_heavy"
|
|
|
|
|
+ else:
|
|
|
|
|
+ inventory_state = "balanced"
|
|
|
|
|
+
|
|
|
|
|
+ grid_ready = inventory_state == "balanced"
|
|
|
|
|
+ rebalance_needed = inventory_state in {"base_heavy", "quote_heavy", "critically_unbalanced", "depleted_base_side", "depleted_quote_side"}
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ "generated_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
+ "base_currency": base,
|
|
|
|
|
+ "quote_currency": quote,
|
|
|
|
|
+ "base_available": round(base_available, 8),
|
|
|
|
|
+ "quote_available": round(quote_available, 8),
|
|
|
|
|
+ "base_value": round(base_value, 4),
|
|
|
|
|
+ "quote_value": round(quote_value, 4),
|
|
|
|
|
+ "total_value": round(total_value, 4),
|
|
|
|
|
+ "base_ratio": round(base_ratio, 4),
|
|
|
|
|
+ "quote_ratio": round(quote_ratio, 4),
|
|
|
|
|
+ "imbalance_score": round(imbalance, 4),
|
|
|
|
|
+ "inventory_state": inventory_state,
|
|
|
|
|
+ "grid_ready": grid_ready,
|
|
|
|
|
+ "rebalance_needed": rebalance_needed,
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def normalize_strategy_snapshot(strategy: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
+ strategy_type = str(strategy.get("strategy_type") or "unknown")
|
|
|
|
|
+ mode = str(strategy.get("mode") or "off")
|
|
|
|
|
+ state = strategy.get("state") if isinstance(strategy.get("state"), dict) else {}
|
|
|
|
|
+ config = strategy.get("config") if isinstance(strategy.get("config"), dict) else {}
|
|
|
|
|
+
|
|
|
|
|
+ # Stable minimum contract used by Hermes while the trader-side strategy
|
|
|
|
|
+ # metadata evolves. These values can later be sourced directly from richer
|
|
|
|
|
+ # reports, but the decision layer keeps a normalized shape from day one.
|
|
|
|
|
+ defaults = {
|
|
|
|
|
+ "grid_trader": {
|
|
|
|
|
+ "role": "primary",
|
|
|
|
|
+ "inventory_behavior": "balanced",
|
|
|
|
|
+ "requires_rebalance_before_start": False,
|
|
|
|
|
+ "requires_rebalance_before_stop": False,
|
|
|
|
|
+ "safe_when_unbalanced": False,
|
|
|
|
|
+ "can_run_with": ["exposure_protector"],
|
|
|
|
|
+ },
|
|
|
|
|
+ "trend_follower": {
|
|
|
|
|
+ "role": "primary",
|
|
|
|
|
+ "inventory_behavior": "accumulative_long",
|
|
|
|
|
+ "requires_rebalance_before_start": False,
|
|
|
|
|
+ "requires_rebalance_before_stop": False,
|
|
|
|
|
+ "safe_when_unbalanced": True,
|
|
|
|
|
+ "can_run_with": ["exposure_protector"],
|
|
|
|
|
+ },
|
|
|
|
|
+ "exposure_protector": {
|
|
|
|
|
+ "role": "defensive",
|
|
|
|
|
+ "inventory_behavior": "rebalancing",
|
|
|
|
|
+ "requires_rebalance_before_start": False,
|
|
|
|
|
+ "requires_rebalance_before_stop": False,
|
|
|
|
|
+ "safe_when_unbalanced": True,
|
|
|
|
|
+ "can_run_with": ["grid_trader", "trend_follower"],
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+ contract = defaults.get(strategy_type, {
|
|
|
|
|
+ "role": "primary",
|
|
|
|
|
+ "inventory_behavior": "unknown",
|
|
|
|
|
+ "requires_rebalance_before_start": False,
|
|
|
|
|
+ "requires_rebalance_before_stop": False,
|
|
|
|
|
+ "safe_when_unbalanced": True,
|
|
|
|
|
+ "can_run_with": [],
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ "id": strategy.get("id"),
|
|
|
|
|
+ "strategy_type": strategy_type,
|
|
|
|
|
+ "mode": mode,
|
|
|
|
|
+ "enabled": mode != "off",
|
|
|
|
|
+ "status": strategy.get("status") or ("running" if mode != "off" else "stopped"),
|
|
|
|
|
+ "market_symbol": strategy.get("market_symbol"),
|
|
|
|
|
+ "account_id": strategy.get("account_id"),
|
|
|
|
|
+ "open_order_count": int(state.get("open_order_count") or strategy.get("open_order_count") or 0),
|
|
|
|
|
+ "last_action": state.get("last_action") or strategy.get("last_side"),
|
|
|
|
|
+ "last_error": state.get("last_error") or "",
|
|
|
|
|
+ "contract": contract,
|
|
|
|
|
+ "config": config,
|
|
|
|
|
+ "state": state,
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def score_strategy_fit(*, strategy: dict[str, Any], narrative: dict[str, Any], wallet_state: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
+ stance = str(narrative.get("stance") or "neutral_rotational")
|
|
|
|
|
+ opportunity_map = narrative.get("opportunity_map") if isinstance(narrative.get("opportunity_map"), dict) else {}
|
|
|
|
|
+ continuation = float(opportunity_map.get("continuation") or 0.0)
|
|
|
|
|
+ mean_reversion = float(opportunity_map.get("mean_reversion") or 0.0)
|
|
|
|
|
+ reversal = float(opportunity_map.get("reversal") or 0.0)
|
|
|
|
|
+ wait = float(opportunity_map.get("wait") or 0.0)
|
|
|
|
|
+ inventory_state = str(wallet_state.get("inventory_state") or "unknown")
|
|
|
|
|
+
|
|
|
|
|
+ strategy_type = strategy["strategy_type"]
|
|
|
|
|
+ score = 0.0
|
|
|
|
|
+ reasons: list[str] = []
|
|
|
|
|
+ blocks: list[str] = []
|
|
|
|
|
+
|
|
|
|
|
+ if strategy_type == "grid_trader":
|
|
|
|
|
+ score += mean_reversion * 1.8
|
|
|
|
|
+ if stance in {"neutral_rotational", "breakout_watch"}:
|
|
|
|
|
+ score += 0.45
|
|
|
|
|
+ reasons.append("narrative still supports rotational structure")
|
|
|
|
|
+ if continuation >= 0.45:
|
|
|
|
|
+ score -= 0.8
|
|
|
|
|
+ blocks.append("continuation pressure is too strong for safe grid harvesting")
|
|
|
|
|
+ if inventory_state != "balanced":
|
|
|
|
|
+ score -= 1.0
|
|
|
|
|
+ blocks.append(f"wallet is not grid-ready: {inventory_state}")
|
|
|
|
|
+ else:
|
|
|
|
|
+ reasons.append("wallet is balanced enough for two-sided harvesting")
|
|
|
|
|
+ elif strategy_type == "trend_follower":
|
|
|
|
|
+ score += continuation * 1.9
|
|
|
|
|
+ if stance in {"constructive_bullish", "cautious_bullish", "constructive_bearish", "cautious_bearish"}:
|
|
|
|
|
+ score += 0.5
|
|
|
|
|
+ reasons.append("narrative supports directional continuation")
|
|
|
|
|
+ if wait >= 0.45:
|
|
|
|
|
+ score -= 0.35
|
|
|
|
|
+ blocks.append("market still has too much wait/uncertainty for trend commitment")
|
|
|
|
|
+ if inventory_state in {"depleted_quote_side", "critically_unbalanced"}:
|
|
|
|
|
+ score -= 0.25
|
|
|
|
|
+ blocks.append("wallet may be too skewed for clean directional scaling")
|
|
|
|
|
+ elif strategy_type == "exposure_protector":
|
|
|
|
|
+ score += reversal * 0.4 + wait * 0.5
|
|
|
|
|
+ if wallet_state.get("rebalance_needed"):
|
|
|
|
|
+ score += 1.1
|
|
|
|
|
+ reasons.append("wallet imbalance calls for rebalancing protection")
|
|
|
|
|
+ if inventory_state in {"depleted_base_side", "depleted_quote_side", "critically_unbalanced"}:
|
|
|
|
|
+ score += 0.45
|
|
|
|
|
+ reasons.append("inventory drift is high enough to justify defensive action")
|
|
|
|
|
+ if stance in {"constructive_bullish", "constructive_bearish"} and continuation > 0.65:
|
|
|
|
|
+ score -= 0.2
|
|
|
|
|
+
|
|
|
|
|
+ if strategy.get("last_error"):
|
|
|
|
|
+ score -= 0.25
|
|
|
|
|
+ blocks.append("strategy recently reported an error")
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ "strategy_id": strategy.get("id"),
|
|
|
|
|
+ "strategy_type": strategy_type,
|
|
|
|
|
+ "score": round(score, 4),
|
|
|
|
|
+ "reasons": reasons,
|
|
|
|
|
+ "blocks": blocks,
|
|
|
|
|
+ "enabled": strategy.get("enabled", False),
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any], wallet_state: dict[str, Any], strategies: list[dict[str, Any]]) -> DecisionSnapshot:
|
|
|
|
|
+ normalized = [normalize_strategy_snapshot(s) for s in strategies if str(s.get("account_id") or "") == str(concern.get("account_id") or "")]
|
|
|
|
|
+ fit_reports = [score_strategy_fit(strategy=s, narrative=narrative_payload, wallet_state=wallet_state) for s in normalized]
|
|
|
|
|
+ ranked = sorted(fit_reports, key=lambda item: item["score"], reverse=True)
|
|
|
|
|
+ current_primary = next((s for s in normalized if s["enabled"] and s["strategy_type"] in {"grid_trader", "trend_follower"}), None)
|
|
|
|
|
+ protector = next((s for s in normalized if s["strategy_type"] == "exposure_protector"), None)
|
|
|
|
|
+ best = ranked[0] if ranked else None
|
|
|
|
|
+ stance = str(narrative_payload.get("stance") or "neutral_rotational")
|
|
|
|
|
+ inventory_state = str(wallet_state.get("inventory_state") or "unknown")
|
|
|
|
|
+
|
|
|
|
|
+ action = "hold"
|
|
|
|
|
+ mode = "observe"
|
|
|
|
|
+ target_strategy = current_primary.get("id") if current_primary else (best.get("strategy_id") if best else None)
|
|
|
|
|
+ reasons: list[str] = []
|
|
|
|
|
+ blocks: list[str] = []
|
|
|
|
|
+
|
|
|
|
|
+ if current_primary and current_primary["strategy_type"] == "grid_trader":
|
|
|
|
|
+ if inventory_state != "balanced" or stance not in {"neutral_rotational", "breakout_watch"}:
|
|
|
|
|
+ reasons.append("grid no longer matches market posture or wallet balance")
|
|
|
|
|
+ if wallet_state.get("rebalance_needed") and protector:
|
|
|
|
|
+ action = "replace_with_exposure_protector"
|
|
|
|
|
+ target_strategy = protector["id"]
|
|
|
|
|
+ mode = "act"
|
|
|
|
|
+ else:
|
|
|
|
|
+ trend = next((r for r in ranked if r["strategy_type"] == "trend_follower"), None)
|
|
|
|
|
+ if trend and trend["score"] > 0.45:
|
|
|
|
|
+ action = "replace_with_trend_follower"
|
|
|
|
|
+ target_strategy = trend["strategy_id"]
|
|
|
|
|
+ mode = "act"
|
|
|
|
|
+ else:
|
|
|
|
|
+ action = "suspend_grid"
|
|
|
|
|
+ target_strategy = current_primary["id"]
|
|
|
|
|
+ mode = "warn"
|
|
|
|
|
+ else:
|
|
|
|
|
+ action = "keep_grid"
|
|
|
|
|
+ mode = "observe"
|
|
|
|
|
+ reasons.append("grid still matches a balanced rotational regime")
|
|
|
|
|
+ elif current_primary and current_primary["strategy_type"] == "trend_follower":
|
|
|
|
|
+ if stance == "neutral_rotational" and wallet_state.get("grid_ready"):
|
|
|
|
|
+ grid = next((r for r in ranked if r["strategy_type"] == "grid_trader"), None)
|
|
|
|
|
+ if grid and grid["score"] >= 0.5:
|
|
|
|
|
+ action = "replace_with_grid"
|
|
|
|
|
+ target_strategy = grid["strategy_id"]
|
|
|
|
|
+ mode = "act"
|
|
|
|
|
+ reasons.append("trend conditions have cooled and wallet is grid-ready again")
|
|
|
|
|
+ else:
|
|
|
|
|
+ action = "hold_trend"
|
|
|
|
|
+ mode = "observe"
|
|
|
|
|
+ blocks.append("grid candidate not strong enough yet")
|
|
|
|
|
+ elif wallet_state.get("rebalance_needed") and protector:
|
|
|
|
|
+ action = "attach_exposure_protector"
|
|
|
|
|
+ target_strategy = protector["id"]
|
|
|
|
|
+ mode = "warn"
|
|
|
|
|
+ reasons.append("trend can continue, but wallet drift now needs protection")
|
|
|
|
|
+ else:
|
|
|
|
|
+ action = "keep_trend"
|
|
|
|
|
+ mode = "observe"
|
|
|
|
|
+ reasons.append("trend strategy still fits the directional narrative")
|
|
|
|
|
+ else:
|
|
|
|
|
+ if best and best["score"] >= 0.55:
|
|
|
|
|
+ action = f"enable_{best['strategy_type']}"
|
|
|
|
|
+ target_strategy = best["strategy_id"]
|
|
|
|
|
+ mode = "act"
|
|
|
|
|
+ reasons.extend(best["reasons"])
|
|
|
|
|
+ else:
|
|
|
|
|
+ action = "wait"
|
|
|
|
|
+ mode = "observe"
|
|
|
|
|
+ blocks.append("no strategy is yet a strong enough fit")
|
|
|
|
|
+
|
|
|
|
|
+ reason_summary = reasons[0] if reasons else (blocks[0] if blocks else "strategy posture unchanged")
|
|
|
|
|
+ confidence = float(narrative_payload.get("confidence") or 0.4)
|
|
|
|
|
+ if action.startswith("replace_with") or action.startswith("enable_"):
|
|
|
|
|
+ confidence += 0.08
|
|
|
|
|
+ if wallet_state.get("rebalance_needed") and "grid" in action:
|
|
|
|
|
+ confidence -= 0.08
|
|
|
|
|
+ confidence = round(_clamp(confidence, 0.2, 0.95), 3)
|
|
|
|
|
+
|
|
|
|
|
+ payload = {
|
|
|
|
|
+ "generated_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
+ "wallet_state": wallet_state,
|
|
|
|
|
+ "narrative_stance": stance,
|
|
|
|
|
+ "strategy_fit_ranking": ranked,
|
|
|
|
|
+ "current_primary_strategy": current_primary.get("id") if current_primary else None,
|
|
|
|
|
+ "reason_chain": reasons,
|
|
|
|
|
+ "blocks": blocks,
|
|
|
|
|
+ "decision_version": 1,
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return DecisionSnapshot(
|
|
|
|
|
+ mode=mode,
|
|
|
|
|
+ action=action,
|
|
|
|
|
+ target_strategy=target_strategy,
|
|
|
|
|
+ reason_summary=reason_summary,
|
|
|
|
|
+ confidence=confidence,
|
|
|
|
|
+ requires_action=mode == "act",
|
|
|
|
|
+ payload=payload,
|
|
|
|
|
+ )
|