|
@@ -6,11 +6,10 @@ This is the first decision slice. Hermes is currently acting as a supervisor for
|
|
|
existing trader strategies, not as a direct trading engine.
|
|
existing trader strategies, not as a direct trading engine.
|
|
|
|
|
|
|
|
Design intent:
|
|
Design intent:
|
|
|
-- prefer keeping a suitable strategy active over unnecessary switching
|
|
|
|
|
|
|
+- prefer one active posture at a time over layered companions
|
|
|
- detect when grid trading becomes unsafe because market posture or wallet
|
|
- detect when grid trading becomes unsafe because market posture or wallet
|
|
|
balance no longer supports it
|
|
balance no longer supports it
|
|
|
-- hand off toward directional or rebalancing strategies without collapsing the
|
|
|
|
|
- decision layer into execution details
|
|
|
|
|
|
|
+- switch cleanly between directional, range, and rebalancing phases
|
|
|
"""
|
|
"""
|
|
|
|
|
|
|
|
from dataclasses import dataclass
|
|
from dataclasses import dataclass
|
|
@@ -42,7 +41,22 @@ def _safe_float(value: Any) -> float | None:
|
|
|
return None
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
-def assess_wallet_state(*, account_info: dict[str, Any], concern: dict[str, Any], price: float | None) -> dict[str, Any]:
|
|
|
|
|
|
|
+def _infer_market_pair(concern: dict[str, Any]) -> tuple[str, str]:
|
|
|
|
|
+ base = str(concern.get("base_currency") or "").strip().upper()
|
|
|
|
|
+ quote = str(concern.get("quote_currency") or "").strip().upper()
|
|
|
|
|
+ if base and quote:
|
|
|
|
|
+ return base, quote
|
|
|
|
|
+
|
|
|
|
|
+ market = str(concern.get("market_symbol") or "").strip().upper().replace("/", "").replace("-", "")
|
|
|
|
|
+ for suffix in ("USDT", "USDC", "USD", "EUR", "BTC", "ETH"):
|
|
|
|
|
+ if market.endswith(suffix) and len(market) > len(suffix):
|
|
|
|
|
+ inferred_base = market[:-len(suffix)]
|
|
|
|
|
+ inferred_quote = suffix
|
|
|
|
|
+ return base or inferred_base, quote or inferred_quote
|
|
|
|
|
+ return base or market, quote or "USD"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def assess_wallet_state(*, account_info: dict[str, Any], concern: dict[str, Any], price: float | None, strategies: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
|
|
"""Summarize inventory health for strategy switching.
|
|
"""Summarize inventory health for strategy switching.
|
|
|
|
|
|
|
|
The key output is whether the wallet is balanced enough for range/grid
|
|
The key output is whether the wallet is balanced enough for range/grid
|
|
@@ -50,8 +64,7 @@ def assess_wallet_state(*, account_info: dict[str, Any], concern: dict[str, Any]
|
|
|
rebalancing before grid is allowed again.
|
|
rebalancing before grid is allowed again.
|
|
|
"""
|
|
"""
|
|
|
balances = account_info.get("balances") if isinstance(account_info.get("balances"), list) else []
|
|
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, quote = _infer_market_pair(concern)
|
|
|
|
|
|
|
|
base_available = 0.0
|
|
base_available = 0.0
|
|
|
quote_available = 0.0
|
|
quote_available = 0.0
|
|
@@ -67,9 +80,38 @@ def assess_wallet_state(*, account_info: dict[str, Any], concern: dict[str, Any]
|
|
|
elif asset == quote:
|
|
elif asset == quote:
|
|
|
quote_available = amount
|
|
quote_available = amount
|
|
|
|
|
|
|
|
|
|
+ reserved_base = 0.0
|
|
|
|
|
+ reserved_quote = 0.0
|
|
|
|
|
+ for strategy in strategies or []:
|
|
|
|
|
+ if not isinstance(strategy, dict):
|
|
|
|
|
+ continue
|
|
|
|
|
+ if str(strategy.get("account_id") or "").strip() != str(concern.get("account_id") or "").strip():
|
|
|
|
|
+ continue
|
|
|
|
|
+ market_symbol = str(strategy.get("market_symbol") or "").strip().lower()
|
|
|
|
|
+ if market_symbol and market_symbol != str(concern.get("market_symbol") or "").strip().lower():
|
|
|
|
|
+ continue
|
|
|
|
|
+ if str(strategy.get("mode") or "off") == "off":
|
|
|
|
|
+ continue
|
|
|
|
|
+ state = strategy.get("state") if isinstance(strategy.get("state"), dict) else {}
|
|
|
|
|
+ orders = state.get("orders") if isinstance(state.get("orders"), list) else []
|
|
|
|
|
+ for order in orders:
|
|
|
|
|
+ if not isinstance(order, dict):
|
|
|
|
|
+ continue
|
|
|
|
|
+ if str(order.get("status") or "open").lower() not in {"open", "live", "active"}:
|
|
|
|
|
+ continue
|
|
|
|
|
+ side = str(order.get("side") or "").lower()
|
|
|
|
|
+ amount = _safe_float(order.get("amount") or order.get("amount_remaining")) or 0.0
|
|
|
|
|
+ order_price = _safe_float(order.get("price")) or price or 0.0
|
|
|
|
|
+ if side == "sell":
|
|
|
|
|
+ reserved_base += amount
|
|
|
|
|
+ elif side == "buy":
|
|
|
|
|
+ reserved_quote += amount * order_price
|
|
|
|
|
+
|
|
|
price = price or 0.0
|
|
price = price or 0.0
|
|
|
- base_value = base_available * price if price > 0 else 0.0
|
|
|
|
|
- quote_value = quote_available
|
|
|
|
|
|
|
+ effective_base = base_available + reserved_base
|
|
|
|
|
+ effective_quote = quote_available + reserved_quote
|
|
|
|
|
+ base_value = effective_base * price if price > 0 else 0.0
|
|
|
|
|
+ quote_value = effective_quote
|
|
|
total_value = base_value + quote_value
|
|
total_value = base_value + quote_value
|
|
|
base_ratio = (base_value / total_value) if total_value > 0 else 0.5
|
|
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
|
|
quote_ratio = (quote_value / total_value) if total_value > 0 else 0.5
|
|
@@ -78,9 +120,9 @@ def assess_wallet_state(*, account_info: dict[str, Any], concern: dict[str, Any]
|
|
|
if total_value <= 0:
|
|
if total_value <= 0:
|
|
|
inventory_state = "unknown"
|
|
inventory_state = "unknown"
|
|
|
elif base_ratio < 0.08:
|
|
elif base_ratio < 0.08:
|
|
|
- inventory_state = "depleted_base_side"
|
|
|
|
|
|
|
+ inventory_state = "critically_unbalanced"
|
|
|
elif quote_ratio < 0.08:
|
|
elif quote_ratio < 0.08:
|
|
|
- inventory_state = "depleted_quote_side"
|
|
|
|
|
|
|
+ inventory_state = "critically_unbalanced"
|
|
|
elif imbalance >= 0.35:
|
|
elif imbalance >= 0.35:
|
|
|
inventory_state = "critically_unbalanced"
|
|
inventory_state = "critically_unbalanced"
|
|
|
elif base_ratio > 0.62:
|
|
elif base_ratio > 0.62:
|
|
@@ -99,6 +141,10 @@ def assess_wallet_state(*, account_info: dict[str, Any], concern: dict[str, Any]
|
|
|
"quote_currency": quote,
|
|
"quote_currency": quote,
|
|
|
"base_available": round(base_available, 8),
|
|
"base_available": round(base_available, 8),
|
|
|
"quote_available": round(quote_available, 8),
|
|
"quote_available": round(quote_available, 8),
|
|
|
|
|
+ "base_reserved": round(reserved_base, 8),
|
|
|
|
|
+ "quote_reserved": round(reserved_quote, 8),
|
|
|
|
|
+ "base_effective": round(effective_base, 8),
|
|
|
|
|
+ "quote_effective": round(effective_quote, 8),
|
|
|
"base_value": round(base_value, 4),
|
|
"base_value": round(base_value, 4),
|
|
|
"quote_value": round(quote_value, 4),
|
|
"quote_value": round(quote_value, 4),
|
|
|
"total_value": round(total_value, 4),
|
|
"total_value": round(total_value, 4),
|
|
@@ -116,6 +162,10 @@ def normalize_strategy_snapshot(strategy: dict[str, Any]) -> dict[str, Any]:
|
|
|
mode = str(strategy.get("mode") or "off")
|
|
mode = str(strategy.get("mode") or "off")
|
|
|
state = strategy.get("state") if isinstance(strategy.get("state"), dict) else {}
|
|
state = strategy.get("state") if isinstance(strategy.get("state"), dict) else {}
|
|
|
config = strategy.get("config") if isinstance(strategy.get("config"), dict) else {}
|
|
config = strategy.get("config") if isinstance(strategy.get("config"), dict) else {}
|
|
|
|
|
+ report = strategy.get("report") if isinstance(strategy.get("report"), dict) else {}
|
|
|
|
|
+ report_fit = report.get("fit") if isinstance(report.get("fit"), dict) else {}
|
|
|
|
|
+ report_supervision = report.get("supervision") if isinstance(report.get("supervision"), dict) else {}
|
|
|
|
|
+ report_state = report.get("state") if isinstance(report.get("state"), dict) else {}
|
|
|
|
|
|
|
|
# Stable minimum contract used by Hermes while the trader-side strategy
|
|
# Stable minimum contract used by Hermes while the trader-side strategy
|
|
|
# metadata evolves. These values can later be sourced directly from richer
|
|
# metadata evolves. These values can later be sourced directly from richer
|
|
@@ -135,15 +185,15 @@ def normalize_strategy_snapshot(strategy: dict[str, Any]) -> dict[str, Any]:
|
|
|
"requires_rebalance_before_start": False,
|
|
"requires_rebalance_before_start": False,
|
|
|
"requires_rebalance_before_stop": False,
|
|
"requires_rebalance_before_stop": False,
|
|
|
"safe_when_unbalanced": True,
|
|
"safe_when_unbalanced": True,
|
|
|
- "can_run_with": ["exposure_protector"],
|
|
|
|
|
|
|
+ "can_run_with": [],
|
|
|
},
|
|
},
|
|
|
"exposure_protector": {
|
|
"exposure_protector": {
|
|
|
- "role": "defensive",
|
|
|
|
|
|
|
+ "role": "rebalancing",
|
|
|
"inventory_behavior": "rebalancing",
|
|
"inventory_behavior": "rebalancing",
|
|
|
"requires_rebalance_before_start": False,
|
|
"requires_rebalance_before_start": False,
|
|
|
"requires_rebalance_before_stop": False,
|
|
"requires_rebalance_before_stop": False,
|
|
|
"safe_when_unbalanced": True,
|
|
"safe_when_unbalanced": True,
|
|
|
- "can_run_with": ["grid_trader", "trend_follower"],
|
|
|
|
|
|
|
+ "can_run_with": [],
|
|
|
},
|
|
},
|
|
|
}
|
|
}
|
|
|
contract = defaults.get(strategy_type, {
|
|
contract = defaults.get(strategy_type, {
|
|
@@ -154,6 +204,7 @@ def normalize_strategy_snapshot(strategy: dict[str, Any]) -> dict[str, Any]:
|
|
|
"safe_when_unbalanced": True,
|
|
"safe_when_unbalanced": True,
|
|
|
"can_run_with": [],
|
|
"can_run_with": [],
|
|
|
})
|
|
})
|
|
|
|
|
+ contract = {**contract, **report_fit}
|
|
|
|
|
|
|
|
return {
|
|
return {
|
|
|
"id": strategy.get("id"),
|
|
"id": strategy.get("id"),
|
|
@@ -163,12 +214,13 @@ def normalize_strategy_snapshot(strategy: dict[str, Any]) -> dict[str, Any]:
|
|
|
"status": strategy.get("status") or ("running" if mode != "off" else "stopped"),
|
|
"status": strategy.get("status") or ("running" if mode != "off" else "stopped"),
|
|
|
"market_symbol": strategy.get("market_symbol"),
|
|
"market_symbol": strategy.get("market_symbol"),
|
|
|
"account_id": strategy.get("account_id"),
|
|
"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 "",
|
|
|
|
|
|
|
+ "open_order_count": int(state.get("open_order_count") or report_state.get("open_order_count") or strategy.get("open_order_count") or 0),
|
|
|
|
|
+ "last_action": state.get("last_action") or report_state.get("last_action") or strategy.get("last_side"),
|
|
|
|
|
+ "last_error": state.get("last_error") or report_state.get("last_error") or "",
|
|
|
"contract": contract,
|
|
"contract": contract,
|
|
|
|
|
+ "supervision": report_supervision,
|
|
|
"config": config,
|
|
"config": config,
|
|
|
- "state": state,
|
|
|
|
|
|
|
+ "state": {**report_state, **state},
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@@ -182,6 +234,9 @@ def score_strategy_fit(*, strategy: dict[str, Any], narrative: dict[str, Any], w
|
|
|
inventory_state = str(wallet_state.get("inventory_state") or "unknown")
|
|
inventory_state = str(wallet_state.get("inventory_state") or "unknown")
|
|
|
|
|
|
|
|
strategy_type = strategy["strategy_type"]
|
|
strategy_type = strategy["strategy_type"]
|
|
|
|
|
+ supervision = strategy.get("supervision") if isinstance(strategy.get("supervision"), dict) else {}
|
|
|
|
|
+ switch_readiness = str(supervision.get("switch_readiness") or "")
|
|
|
|
|
+ inventory_pressure = str(supervision.get("inventory_pressure") or "")
|
|
|
score = 0.0
|
|
score = 0.0
|
|
|
reasons: list[str] = []
|
|
reasons: list[str] = []
|
|
|
blocks: list[str] = []
|
|
blocks: list[str] = []
|
|
@@ -199,6 +254,9 @@ def score_strategy_fit(*, strategy: dict[str, Any], narrative: dict[str, Any], w
|
|
|
blocks.append(f"wallet is not grid-ready: {inventory_state}")
|
|
blocks.append(f"wallet is not grid-ready: {inventory_state}")
|
|
|
else:
|
|
else:
|
|
|
reasons.append("wallet is balanced enough for two-sided harvesting")
|
|
reasons.append("wallet is balanced enough for two-sided harvesting")
|
|
|
|
|
+ if switch_readiness == "ready_for_handoff":
|
|
|
|
|
+ score -= 0.35
|
|
|
|
|
+ blocks.append("grid reports handoff readiness")
|
|
|
elif strategy_type == "trend_follower":
|
|
elif strategy_type == "trend_follower":
|
|
|
score += continuation * 1.9
|
|
score += continuation * 1.9
|
|
|
if stance in {"constructive_bullish", "cautious_bullish", "constructive_bearish", "cautious_bearish"}:
|
|
if stance in {"constructive_bullish", "cautious_bullish", "constructive_bearish", "cautious_bearish"}:
|
|
@@ -210,6 +268,9 @@ def score_strategy_fit(*, strategy: dict[str, Any], narrative: dict[str, Any], w
|
|
|
if inventory_state in {"depleted_quote_side", "critically_unbalanced"}:
|
|
if inventory_state in {"depleted_quote_side", "critically_unbalanced"}:
|
|
|
score -= 0.25
|
|
score -= 0.25
|
|
|
blocks.append("wallet may be too skewed for clean directional scaling")
|
|
blocks.append("wallet may be too skewed for clean directional scaling")
|
|
|
|
|
+ if inventory_pressure in {"base_heavy", "quote_heavy"}:
|
|
|
|
|
+ score -= 0.1
|
|
|
|
|
+ blocks.append("trend report shows rising inventory pressure")
|
|
|
elif strategy_type == "exposure_protector":
|
|
elif strategy_type == "exposure_protector":
|
|
|
score += reversal * 0.4 + wait * 0.5
|
|
score += reversal * 0.4 + wait * 0.5
|
|
|
if wallet_state.get("rebalance_needed"):
|
|
if wallet_state.get("rebalance_needed"):
|
|
@@ -220,10 +281,16 @@ def score_strategy_fit(*, strategy: dict[str, Any], narrative: dict[str, Any], w
|
|
|
reasons.append("inventory drift is high enough to justify defensive action")
|
|
reasons.append("inventory drift is high enough to justify defensive action")
|
|
|
if stance in {"constructive_bullish", "constructive_bearish"} and continuation > 0.65:
|
|
if stance in {"constructive_bullish", "constructive_bearish"} and continuation > 0.65:
|
|
|
score -= 0.2
|
|
score -= 0.2
|
|
|
|
|
+ if inventory_pressure in {"critical", "elevated"}:
|
|
|
|
|
+ score += 0.25
|
|
|
|
|
+ reasons.append("protector reports active inventory pressure")
|
|
|
|
|
|
|
|
if strategy.get("last_error"):
|
|
if strategy.get("last_error"):
|
|
|
score -= 0.25
|
|
score -= 0.25
|
|
|
blocks.append("strategy recently reported an error")
|
|
blocks.append("strategy recently reported an error")
|
|
|
|
|
+ if bool(supervision.get("degraded")):
|
|
|
|
|
+ score -= 0.15
|
|
|
|
|
+ blocks.append("strategy self-reports degraded supervision state")
|
|
|
|
|
|
|
|
return {
|
|
return {
|
|
|
"strategy_id": strategy.get("id"),
|
|
"strategy_id": strategy.get("id"),
|
|
@@ -235,46 +302,224 @@ def score_strategy_fit(*, strategy: dict[str, Any], narrative: dict[str, Any], w
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def _grid_breakout_pressure(narrative_payload: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
+ scoped = narrative_payload.get("scoped_state") if isinstance(narrative_payload.get("scoped_state"), dict) else {}
|
|
|
|
|
+ cross = narrative_payload.get("cross_scope_summary") if isinstance(narrative_payload.get("cross_scope_summary"), dict) else {}
|
|
|
|
|
+ micro = scoped.get("micro") if isinstance(scoped.get("micro"), dict) else {}
|
|
|
|
|
+ meso = scoped.get("meso") if isinstance(scoped.get("meso"), dict) else {}
|
|
|
|
|
+ macro = scoped.get("macro") if isinstance(scoped.get("macro"), dict) else {}
|
|
|
|
|
+
|
|
|
|
|
+ micro_impulse = str(micro.get("impulse") or "mixed")
|
|
|
|
|
+ micro_bias = str(micro.get("trend_bias") or "mixed")
|
|
|
|
|
+ meso_structure = str(meso.get("structure") or "rotation")
|
|
|
|
|
+ meso_bias = str(meso.get("momentum_bias") or "neutral")
|
|
|
|
|
+ macro_bias = str(macro.get("bias") or "mixed")
|
|
|
|
|
+ alignment = str(cross.get("alignment") or "partial_alignment")
|
|
|
|
|
+ friction = str(cross.get("friction") or "medium")
|
|
|
|
|
+
|
|
|
|
|
+ micro_directional = micro_impulse in {"up", "down"} and micro_bias in {"bullish", "bearish"}
|
|
|
|
|
+ meso_directional = meso_structure == "trend_continuation" and meso_bias in {"bullish", "bearish"}
|
|
|
|
|
+ macro_supportive = macro_bias in {"bullish", "bearish"}
|
|
|
|
|
+ persistent = micro_directional and meso_directional and macro_supportive and alignment == "micro_meso_macro_aligned" and friction == "low"
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ "persistent": persistent,
|
|
|
|
|
+ "micro_impulse": micro_impulse,
|
|
|
|
|
+ "micro_bias": micro_bias,
|
|
|
|
|
+ "meso_structure": meso_structure,
|
|
|
|
|
+ "meso_bias": meso_bias,
|
|
|
|
|
+ "macro_bias": macro_bias,
|
|
|
|
|
+ "alignment": alignment,
|
|
|
|
|
+ "friction": friction,
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _select_current_primary(strategies: list[dict[str, Any]]) -> dict[str, Any] | None:
|
|
|
|
|
+ primaries = [s for s in strategies if s["strategy_type"] in {"grid_trader", "trend_follower", "exposure_protector"} and s.get("mode") != "off"]
|
|
|
|
|
+ if not primaries:
|
|
|
|
|
+ return None
|
|
|
|
|
+ active = next((s for s in primaries if s.get("mode") == "active"), None)
|
|
|
|
|
+ if active:
|
|
|
|
|
+ return active
|
|
|
|
|
+ return primaries[0]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _inventory_breakout_is_directionally_compatible(inventory_state: str, breakout: dict[str, Any]) -> bool:
|
|
|
|
|
+ macro_bias = str(breakout.get("macro_bias") or "mixed")
|
|
|
|
|
+ meso_bias = str(breakout.get("meso_bias") or "neutral")
|
|
|
|
|
+ bullish = macro_bias == "bullish" and meso_bias == "bullish"
|
|
|
|
|
+ bearish = macro_bias == "bearish" and meso_bias == "bearish"
|
|
|
|
|
+ if bullish and inventory_state in {"depleted_base_side", "quote_heavy"}:
|
|
|
|
|
+ return True
|
|
|
|
|
+ if bearish and inventory_state in {"depleted_quote_side", "base_heavy"}:
|
|
|
|
|
+ return True
|
|
|
|
|
+ return False
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _trend_cooling_edge(narrative_payload: dict[str, Any], wallet_state: dict[str, Any]) -> bool:
|
|
|
|
|
+ if not wallet_state.get("rebalance_needed"):
|
|
|
|
|
+ return False
|
|
|
|
|
+ scoped = narrative_payload.get("scoped_state") if isinstance(narrative_payload.get("scoped_state"), dict) else {}
|
|
|
|
|
+ micro = scoped.get("micro") if isinstance(scoped.get("micro"), dict) else {}
|
|
|
|
|
+ meso = scoped.get("meso") if isinstance(scoped.get("meso"), dict) else {}
|
|
|
|
|
+
|
|
|
|
|
+ micro_impulse = str(micro.get("impulse") or "mixed")
|
|
|
|
|
+ micro_bias = str(micro.get("trend_bias") or "mixed")
|
|
|
|
|
+ micro_location = str(micro.get("location") or "unknown")
|
|
|
|
|
+ meso_bias = str(meso.get("momentum_bias") or "neutral")
|
|
|
|
|
+ meso_structure = str(meso.get("structure") or "rotation")
|
|
|
|
|
+ inventory_state = str(wallet_state.get("inventory_state") or "unknown")
|
|
|
|
|
+
|
|
|
|
|
+ bullish_cooling = (
|
|
|
|
|
+ inventory_state in {"base_heavy", "critically_unbalanced"}
|
|
|
|
|
+ and meso_structure == "trend_continuation"
|
|
|
|
|
+ and meso_bias == "bullish"
|
|
|
|
|
+ and micro_impulse == "mixed"
|
|
|
|
|
+ and micro_bias in {"mixed", "bearish"}
|
|
|
|
|
+ and micro_location in {"near_upper_band", "upper_half", "centered"}
|
|
|
|
|
+ )
|
|
|
|
|
+ bearish_cooling = (
|
|
|
|
|
+ inventory_state in {"quote_heavy", "critically_unbalanced"}
|
|
|
|
|
+ and meso_structure == "trend_continuation"
|
|
|
|
|
+ and meso_bias == "bearish"
|
|
|
|
|
+ and micro_impulse == "mixed"
|
|
|
|
|
+ and micro_bias in {"mixed", "bullish"}
|
|
|
|
|
+ and micro_location in {"near_lower_band", "lower_half", "centered"}
|
|
|
|
|
+ )
|
|
|
|
|
+ return bullish_cooling or bearish_cooling
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _grid_fill_proximity(strategy: dict[str, Any], narrative_payload: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
+ state = strategy.get("state") if isinstance(strategy.get("state"), dict) else {}
|
|
|
|
|
+ orders = state.get("orders") if isinstance(state.get("orders"), list) else []
|
|
|
|
|
+ features = narrative_payload.get("features_by_timeframe") if isinstance(narrative_payload.get("features_by_timeframe"), dict) else {}
|
|
|
|
|
+ micro_raw = features.get("1m", {}).get("raw", {}) if isinstance(features.get("1m"), dict) else {}
|
|
|
|
|
+ current_price = _safe_float(micro_raw.get("price") or state.get("last_price") or state.get("center_price"))
|
|
|
|
|
+ atr_percent = _safe_float(micro_raw.get("atr_percent")) or 0.0
|
|
|
|
|
+ if not current_price or current_price <= 0:
|
|
|
|
|
+ return {"near_fill": False}
|
|
|
|
|
+
|
|
|
|
|
+ sell_prices: list[float] = []
|
|
|
|
|
+ buy_prices: list[float] = []
|
|
|
|
|
+ for order in orders:
|
|
|
|
|
+ if not isinstance(order, dict):
|
|
|
|
|
+ continue
|
|
|
|
|
+ if str(order.get("status") or "open").lower() not in {"open", "live", "active"}:
|
|
|
|
|
+ continue
|
|
|
|
|
+ price = _safe_float(order.get("price"))
|
|
|
|
|
+ if price is None or price <= 0:
|
|
|
|
|
+ continue
|
|
|
|
|
+ side = str(order.get("side") or "").lower()
|
|
|
|
|
+ if side == "sell" and price >= current_price:
|
|
|
|
|
+ sell_prices.append(price)
|
|
|
|
|
+ elif side == "buy" and price <= current_price:
|
|
|
|
|
+ buy_prices.append(price)
|
|
|
|
|
+
|
|
|
|
|
+ next_sell = min(sell_prices) if sell_prices else None
|
|
|
|
|
+ next_buy = max(buy_prices) if buy_prices else None
|
|
|
|
|
+ next_sell_distance_pct = (((next_sell - current_price) / current_price) * 100.0) if next_sell else None
|
|
|
|
|
+ next_buy_distance_pct = (((current_price - next_buy) / current_price) * 100.0) if next_buy else None
|
|
|
|
|
+ threshold_pct = max(0.25, atr_percent * 1.5)
|
|
|
|
|
+ near_fill = bool(
|
|
|
|
|
+ next_sell_distance_pct is not None
|
|
|
|
|
+ and next_sell_distance_pct >= 0
|
|
|
|
|
+ and next_sell_distance_pct <= threshold_pct
|
|
|
|
|
+ and next_buy is not None
|
|
|
|
|
+ )
|
|
|
|
|
+ return {
|
|
|
|
|
+ "near_fill": near_fill,
|
|
|
|
|
+ "current_price": current_price,
|
|
|
|
|
+ "next_sell": next_sell,
|
|
|
|
|
+ "next_buy": next_buy,
|
|
|
|
|
+ "next_sell_distance_pct": round(next_sell_distance_pct, 4) if next_sell_distance_pct is not None else None,
|
|
|
|
|
+ "next_buy_distance_pct": round(next_buy_distance_pct, 4) if next_buy_distance_pct is not None else None,
|
|
|
|
|
+ "threshold_pct": round(threshold_pct, 4),
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any], wallet_state: dict[str, Any], strategies: list[dict[str, Any]]) -> DecisionSnapshot:
|
|
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 "")]
|
|
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]
|
|
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)
|
|
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)
|
|
|
|
|
|
|
+ current_primary = _select_current_primary(normalized)
|
|
|
best = ranked[0] if ranked else None
|
|
best = ranked[0] if ranked else None
|
|
|
stance = str(narrative_payload.get("stance") or "neutral_rotational")
|
|
stance = str(narrative_payload.get("stance") or "neutral_rotational")
|
|
|
inventory_state = str(wallet_state.get("inventory_state") or "unknown")
|
|
inventory_state = str(wallet_state.get("inventory_state") or "unknown")
|
|
|
|
|
+ breakout = _grid_breakout_pressure(narrative_payload)
|
|
|
|
|
+ grid_fill = _grid_fill_proximity(current_primary, narrative_payload) if current_primary and current_primary["strategy_type"] == "grid_trader" else {"near_fill": False}
|
|
|
|
|
|
|
|
action = "hold"
|
|
action = "hold"
|
|
|
mode = "observe"
|
|
mode = "observe"
|
|
|
target_strategy = current_primary.get("id") if current_primary else (best.get("strategy_id") if best else None)
|
|
target_strategy = current_primary.get("id") if current_primary else (best.get("strategy_id") if best else None)
|
|
|
reasons: list[str] = []
|
|
reasons: list[str] = []
|
|
|
blocks: list[str] = []
|
|
blocks: list[str] = []
|
|
|
|
|
+ trend = next((r for r in ranked if r["strategy_type"] == "trend_follower"), None)
|
|
|
|
|
+ rebalance = next((r for r in ranked if r["strategy_type"] == "exposure_protector"), None)
|
|
|
|
|
+ grid = next((r for r in ranked if r["strategy_type"] == "grid_trader"), None)
|
|
|
|
|
|
|
|
if current_primary and current_primary["strategy_type"] == "grid_trader":
|
|
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:
|
|
|
|
|
|
|
+ severe_imbalance = inventory_state in {"depleted_base_side", "depleted_quote_side", "critically_unbalanced"}
|
|
|
|
|
+ grid_friendly_stance = stance in {"neutral_rotational", "breakout_watch", "cautious_bullish", "cautious_bearish", "fragile_bullish", "fragile_bearish"}
|
|
|
|
|
+ if severe_imbalance and breakout["persistent"]:
|
|
|
|
|
+ reasons.append("grid imbalance now coincides with persistent breakout pressure")
|
|
|
|
|
+ directional_inventory = _inventory_breakout_is_directionally_compatible(inventory_state, breakout)
|
|
|
|
|
+ if trend and trend["score"] > 0.45 and (
|
|
|
|
|
+ not wallet_state.get("rebalance_needed")
|
|
|
|
|
+ or directional_inventory
|
|
|
|
|
+ or not rebalance
|
|
|
|
|
+ or trend["score"] >= rebalance["score"]
|
|
|
|
|
+ ):
|
|
|
|
|
+ action = "replace_with_trend_follower"
|
|
|
|
|
+ target_strategy = trend["strategy_id"]
|
|
|
|
|
+ mode = "act"
|
|
|
|
|
+ if directional_inventory:
|
|
|
|
|
+ reasons.append("inventory posture can be absorbed by the directional handoff")
|
|
|
|
|
+ elif wallet_state.get("rebalance_needed") and rebalance and rebalance["score"] > 0.35:
|
|
|
action = "replace_with_exposure_protector"
|
|
action = "replace_with_exposure_protector"
|
|
|
- target_strategy = protector["id"]
|
|
|
|
|
|
|
+ target_strategy = rebalance["strategy_id"]
|
|
|
mode = "act"
|
|
mode = "act"
|
|
|
else:
|
|
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"
|
|
|
|
|
|
|
+ action = "suspend_grid"
|
|
|
|
|
+ target_strategy = current_primary["id"]
|
|
|
|
|
+ mode = "warn"
|
|
|
|
|
+ elif wallet_state.get("rebalance_needed") and rebalance and rebalance["score"] > 0.6 and not grid_fill.get("near_fill") and severe_imbalance:
|
|
|
|
|
+ action = "replace_with_exposure_protector"
|
|
|
|
|
+ target_strategy = rebalance["strategy_id"]
|
|
|
|
|
+ mode = "act"
|
|
|
|
|
+ reasons.append("grid is no longer safe to nurse because inventory repair now matters more than waiting for self-heal")
|
|
|
|
|
+ elif breakout["persistent"] and grid_fill.get("near_fill") and inventory_state in {"balanced", "base_heavy", "quote_heavy"}:
|
|
|
|
|
+ action = "keep_grid"
|
|
|
|
|
+ target_strategy = current_primary["id"]
|
|
|
|
|
+ mode = "observe"
|
|
|
|
|
+ reasons.append("grid is still close to a working fill, so immediate handoff would be premature")
|
|
|
|
|
+ elif not grid_friendly_stance and breakout["persistent"]:
|
|
|
|
|
+ reasons.append("grid should yield because directional pressure is persistent across scopes")
|
|
|
|
|
+ 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 = "keep_grid"
|
|
|
|
|
+ target_strategy = current_primary["id"]
|
|
|
|
|
+ mode = "warn"
|
|
|
|
|
+ blocks.append("directional pressure is rising but no strong trend handoff is ready")
|
|
|
else:
|
|
else:
|
|
|
action = "keep_grid"
|
|
action = "keep_grid"
|
|
|
mode = "observe"
|
|
mode = "observe"
|
|
|
- reasons.append("grid still matches a balanced rotational regime")
|
|
|
|
|
|
|
+ reasons.append("grid can likely self-heal because breakout pressure is not yet persistent")
|
|
|
elif current_primary and current_primary["strategy_type"] == "trend_follower":
|
|
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 _trend_cooling_edge(narrative_payload, wallet_state):
|
|
|
|
|
+ if rebalance and rebalance["score"] > 0.35:
|
|
|
|
|
+ action = "replace_with_exposure_protector"
|
|
|
|
|
+ target_strategy = rebalance["strategy_id"]
|
|
|
|
|
+ mode = "act"
|
|
|
|
|
+ reasons.append("micro trend has cooled at the edge while inventory is still skewed, so repair should start before the move fully mean-reverts")
|
|
|
|
|
+ else:
|
|
|
|
|
+ action = "keep_trend"
|
|
|
|
|
+ mode = "warn"
|
|
|
|
|
+ blocks.append("edge cooling is visible but no rebalancer is ready")
|
|
|
|
|
+ elif stance == "neutral_rotational" and wallet_state.get("grid_ready"):
|
|
|
if grid and grid["score"] >= 0.5:
|
|
if grid and grid["score"] >= 0.5:
|
|
|
action = "replace_with_grid"
|
|
action = "replace_with_grid"
|
|
|
target_strategy = grid["strategy_id"]
|
|
target_strategy = grid["strategy_id"]
|
|
@@ -284,15 +529,46 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
|
|
|
action = "hold_trend"
|
|
action = "hold_trend"
|
|
|
mode = "observe"
|
|
mode = "observe"
|
|
|
blocks.append("grid candidate not strong enough yet")
|
|
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")
|
|
|
|
|
|
|
+ elif stance == "neutral_rotational" and wallet_state.get("rebalance_needed"):
|
|
|
|
|
+ if rebalance and rebalance["score"] > 0.35:
|
|
|
|
|
+ action = "replace_with_exposure_protector"
|
|
|
|
|
+ target_strategy = rebalance["strategy_id"]
|
|
|
|
|
+ mode = "act"
|
|
|
|
|
+ reasons.append("trend has cooled and inventory should be normalized before grid resumes")
|
|
|
|
|
+ else:
|
|
|
|
|
+ action = "keep_trend"
|
|
|
|
|
+ mode = "warn"
|
|
|
|
|
+ blocks.append("trend has cooled but no rebalancer is ready")
|
|
|
else:
|
|
else:
|
|
|
action = "keep_trend"
|
|
action = "keep_trend"
|
|
|
mode = "observe"
|
|
mode = "observe"
|
|
|
reasons.append("trend strategy still fits the directional narrative")
|
|
reasons.append("trend strategy still fits the directional narrative")
|
|
|
|
|
+ elif current_primary and current_primary["strategy_type"] == "exposure_protector":
|
|
|
|
|
+ if wallet_state.get("grid_ready") and stance == "neutral_rotational":
|
|
|
|
|
+ if grid and grid["score"] >= 0.5:
|
|
|
|
|
+ action = "replace_with_grid"
|
|
|
|
|
+ target_strategy = grid["strategy_id"]
|
|
|
|
|
+ mode = "act"
|
|
|
|
|
+ reasons.append("rebalance is complete and rotational conditions support grid again")
|
|
|
|
|
+ else:
|
|
|
|
|
+ action = "keep_rebalancer"
|
|
|
|
|
+ mode = "observe"
|
|
|
|
|
+ blocks.append("wallet is ready but grid fit is still too weak")
|
|
|
|
|
+ elif not wallet_state.get("rebalance_needed") and stance in {"constructive_bullish", "cautious_bullish", "constructive_bearish", "cautious_bearish"}:
|
|
|
|
|
+ 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"
|
|
|
|
|
+ reasons.append("rebalance is done and directional conditions favor trend capture")
|
|
|
|
|
+ else:
|
|
|
|
|
+ action = "keep_rebalancer"
|
|
|
|
|
+ mode = "observe"
|
|
|
|
|
+ blocks.append("trend candidate is not strong enough yet")
|
|
|
|
|
+ else:
|
|
|
|
|
+ action = "keep_rebalancer"
|
|
|
|
|
+ mode = "observe"
|
|
|
|
|
+ reasons.append("rebalancing should continue until wallet posture improves")
|
|
|
else:
|
|
else:
|
|
|
if best and best["score"] >= 0.55:
|
|
if best and best["score"] >= 0.55:
|
|
|
action = f"enable_{best['strategy_type']}"
|
|
action = f"enable_{best['strategy_type']}"
|
|
@@ -318,6 +594,8 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
|
|
|
"narrative_stance": stance,
|
|
"narrative_stance": stance,
|
|
|
"strategy_fit_ranking": ranked,
|
|
"strategy_fit_ranking": ranked,
|
|
|
"current_primary_strategy": current_primary.get("id") if current_primary else None,
|
|
"current_primary_strategy": current_primary.get("id") if current_primary else None,
|
|
|
|
|
+ "grid_breakout_pressure": breakout,
|
|
|
|
|
+ "grid_fill_context": grid_fill,
|
|
|
"reason_chain": reasons,
|
|
"reason_chain": reasons,
|
|
|
"blocks": blocks,
|
|
"blocks": blocks,
|
|
|
"decision_version": 1,
|
|
"decision_version": 1,
|