|
|
@@ -446,6 +446,222 @@ def _grid_fill_proximity(strategy: dict[str, Any], narrative_payload: dict[str,
|
|
|
}
|
|
|
|
|
|
|
|
|
+def _grid_trend_pressure(strategy: dict[str, Any], narrative_payload: dict[str, Any]) -> dict[str, Any]:
|
|
|
+ state = strategy.get("state") if isinstance(strategy.get("state"), dict) else {}
|
|
|
+ config = strategy.get("config") if isinstance(strategy.get("config"), dict) 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"))
|
|
|
+ center_price = _safe_float(state.get("center_price") or state.get("last_price"))
|
|
|
+ step_pct = _safe_float(config.get("grid_step_pct") or state.get("grid_step_pct") or state.get("recenter_pct_live")) or 0.0
|
|
|
+ if not current_price or not center_price or current_price <= 0 or center_price <= 0 or step_pct <= 0:
|
|
|
+ return {"levels": 0.0, "rounded_levels": 0, "direction": "unknown", "current_price": current_price, "center_price": center_price, "step_pct": step_pct}
|
|
|
+
|
|
|
+ distance_pct = abs(current_price - center_price) / center_price
|
|
|
+ levels = distance_pct / step_pct
|
|
|
+ direction = "bullish" if current_price > center_price else "bearish" if current_price < center_price else "flat"
|
|
|
+ return {
|
|
|
+ "levels": round(levels, 4),
|
|
|
+ "rounded_levels": int(levels),
|
|
|
+ "direction": direction,
|
|
|
+ "current_price": current_price,
|
|
|
+ "center_price": center_price,
|
|
|
+ "step_pct": step_pct,
|
|
|
+ "distance_pct": round(distance_pct, 4),
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def _grid_can_still_work(strategy: dict[str, Any], wallet_state: dict[str, Any], grid_fill: dict[str, Any]) -> bool:
|
|
|
+ supervision = strategy.get("supervision") if isinstance(strategy.get("supervision"), dict) else {}
|
|
|
+ side_capacity = supervision.get("side_capacity") if isinstance(supervision.get("side_capacity"), dict) else {}
|
|
|
+ buy_capacity = bool(side_capacity.get("buy", False))
|
|
|
+ sell_capacity = bool(side_capacity.get("sell", False))
|
|
|
+ open_order_count = int(strategy.get("open_order_count") or 0)
|
|
|
+ degraded = bool(supervision.get("degraded"))
|
|
|
+ inventory_state = str(wallet_state.get("inventory_state") or "unknown")
|
|
|
+
|
|
|
+ if degraded:
|
|
|
+ return False
|
|
|
+ if buy_capacity or sell_capacity:
|
|
|
+ return True
|
|
|
+ if open_order_count > 0:
|
|
|
+ return True
|
|
|
+ if grid_fill.get("near_fill"):
|
|
|
+ return True
|
|
|
+ return inventory_state not in {"depleted_base_side", "depleted_quote_side"}
|
|
|
+
|
|
|
+
|
|
|
+def _grid_is_truly_stuck_for_recovery(strategy: dict[str, Any], wallet_state: dict[str, Any], grid_fill: dict[str, Any]) -> bool:
|
|
|
+ if _grid_can_still_work(strategy, wallet_state, grid_fill):
|
|
|
+ return False
|
|
|
+ inventory_state = str(wallet_state.get("inventory_state") or "unknown")
|
|
|
+ return wallet_state.get("rebalance_needed") and inventory_state in {"depleted_base_side", "depleted_quote_side", "critically_unbalanced"}
|
|
|
+
|
|
|
+
|
|
|
+def _decide_for_grid(*,
|
|
|
+ current_primary: dict[str, Any],
|
|
|
+ stance: str,
|
|
|
+ inventory_state: str,
|
|
|
+ wallet_state: dict[str, Any],
|
|
|
+ breakout: dict[str, Any],
|
|
|
+ grid_fill: dict[str, Any],
|
|
|
+ grid_pressure: dict[str, Any],
|
|
|
+ directional_micro_clear: bool,
|
|
|
+ severe_imbalance: bool,
|
|
|
+ trend: dict[str, Any] | None,
|
|
|
+ rebalance: dict[str, Any] | None,
|
|
|
+) -> tuple[str, str, str | None, list[str], list[str]]:
|
|
|
+ action = "keep_grid"
|
|
|
+ mode = "observe"
|
|
|
+ target_strategy = current_primary["id"]
|
|
|
+ reasons: list[str] = []
|
|
|
+ blocks: list[str] = []
|
|
|
+
|
|
|
+ # Grid is the base mode. Leave it only for a persistent breakout or when
|
|
|
+ # the grid has genuinely lost its ability to recover on its own.
|
|
|
+ grid_friendly_stance = stance in {"neutral_rotational", "breakout_watch", "cautious_bullish", "cautious_bearish", "fragile_bullish", "fragile_bearish"}
|
|
|
+ grid_can_work = _grid_can_still_work(current_primary, wallet_state, grid_fill)
|
|
|
+ grid_stuck_for_recovery = _grid_is_truly_stuck_for_recovery(current_primary, wallet_state, grid_fill)
|
|
|
+ persistent_breakout = bool(breakout["persistent"])
|
|
|
+
|
|
|
+ if severe_imbalance and persistent_breakout:
|
|
|
+ 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 directional_micro_clear and grid_pressure.get("levels", 0.0) >= 2.75 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"
|
|
|
+ target_strategy = rebalance["strategy_id"]
|
|
|
+ mode = "act"
|
|
|
+ else:
|
|
|
+ action = "suspend_grid"
|
|
|
+ mode = "warn"
|
|
|
+ elif severe_imbalance and grid_stuck_for_recovery and not persistent_breakout and rebalance and rebalance["score"] > 0.6:
|
|
|
+ action = "replace_with_exposure_protector"
|
|
|
+ target_strategy = rebalance["strategy_id"]
|
|
|
+ mode = "act"
|
|
|
+ reasons.append("grid has lost practical recovery capacity, so inventory repair should take over")
|
|
|
+ elif not persistent_breakout and grid_can_work:
|
|
|
+ reasons.append("grid can still operate and self-heal, so inventory skew alone should not force a rebalance handoff")
|
|
|
+ elif persistent_breakout and grid_fill.get("near_fill") and inventory_state in {"balanced", "base_heavy", "quote_heavy"}:
|
|
|
+ reasons.append("grid is still close to a working fill, so immediate handoff would be premature")
|
|
|
+ elif not grid_friendly_stance and persistent_breakout:
|
|
|
+ reasons.append("grid should yield because directional pressure is persistent across scopes")
|
|
|
+ if trend and trend["score"] > 0.45 and directional_micro_clear and grid_pressure.get("levels", 0.0) >= 2.75:
|
|
|
+ action = "replace_with_trend_follower"
|
|
|
+ target_strategy = trend["strategy_id"]
|
|
|
+ mode = "act"
|
|
|
+ else:
|
|
|
+ mode = "warn"
|
|
|
+ if grid_pressure.get("levels", 0.0) < 2.75:
|
|
|
+ blocks.append("grid has not yet been eaten by enough levels to justify leaving it")
|
|
|
+ else:
|
|
|
+ blocks.append("directional pressure is rising but the micro layer is not clear enough for a trend handoff")
|
|
|
+ else:
|
|
|
+ reasons.append("grid can likely self-heal because breakout pressure is not yet persistent")
|
|
|
+
|
|
|
+ return action, mode, target_strategy, reasons, blocks
|
|
|
+
|
|
|
+
|
|
|
+def _decide_for_trend(*,
|
|
|
+ current_primary: dict[str, Any],
|
|
|
+ stance: str,
|
|
|
+ narrative_payload: dict[str, Any],
|
|
|
+ wallet_state: dict[str, Any],
|
|
|
+ grid: dict[str, Any] | None,
|
|
|
+) -> tuple[str, str, str | None, list[str], list[str]]:
|
|
|
+ action = "keep_trend"
|
|
|
+ mode = "observe"
|
|
|
+ target_strategy = current_primary["id"]
|
|
|
+ reasons: list[str] = []
|
|
|
+ blocks: list[str] = []
|
|
|
+
|
|
|
+ # Trend is allowed to cool back into grid, but it should not bounce into
|
|
|
+ # the rebalancer as an intermediate hop.
|
|
|
+ if _trend_cooling_edge(narrative_payload, wallet_state):
|
|
|
+ if grid and wallet_state.get("grid_ready"):
|
|
|
+ action = "replace_with_grid"
|
|
|
+ target_strategy = grid["strategy_id"]
|
|
|
+ mode = "act"
|
|
|
+ reasons.append("trend has cooled and grid can resume instead of ping-ponging into rebalancing")
|
|
|
+ else:
|
|
|
+ mode = "warn"
|
|
|
+ blocks.append("edge cooling is visible but the wallet should not bounce straight into rebalancing")
|
|
|
+ elif stance == "neutral_rotational" and wallet_state.get("grid_ready"):
|
|
|
+ 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"
|
|
|
+ blocks.append("grid candidate not strong enough yet")
|
|
|
+ elif stance == "neutral_rotational" and wallet_state.get("rebalance_needed"):
|
|
|
+ if grid and wallet_state.get("grid_ready"):
|
|
|
+ action = "replace_with_grid"
|
|
|
+ target_strategy = grid["strategy_id"]
|
|
|
+ mode = "act"
|
|
|
+ reasons.append("trend has cooled and grid should recover the wallet instead of bouncing through rebalancing")
|
|
|
+ else:
|
|
|
+ mode = "warn"
|
|
|
+ blocks.append("trend has cooled but rebalancing should not be the immediate next hop")
|
|
|
+ else:
|
|
|
+ reasons.append("trend strategy still fits the directional narrative")
|
|
|
+
|
|
|
+ return action, mode, target_strategy, reasons, blocks
|
|
|
+
|
|
|
+
|
|
|
+def _decide_for_rebalancer(*,
|
|
|
+ current_primary: dict[str, Any],
|
|
|
+ stance: str,
|
|
|
+ wallet_state: dict[str, Any],
|
|
|
+ grid: dict[str, Any] | None,
|
|
|
+) -> tuple[str, str, str | None, list[str], list[str]]:
|
|
|
+ action = "keep_rebalancer"
|
|
|
+ mode = "observe"
|
|
|
+ target_strategy = current_primary["id"]
|
|
|
+ reasons: list[str] = []
|
|
|
+ blocks: list[str] = []
|
|
|
+
|
|
|
+ # Rebalancing is a repair phase. Once the wallet is usable again, Hermes
|
|
|
+ # should prefer handing back to grid, not directly to trend.
|
|
|
+ if str(wallet_state.get("inventory_state") or "").lower() == "balanced":
|
|
|
+ if grid:
|
|
|
+ action = "replace_with_grid"
|
|
|
+ target_strategy = grid["strategy_id"]
|
|
|
+ mode = "act"
|
|
|
+ reasons.append("wallet is balanced, so grid should run until a strong trend is detected")
|
|
|
+ else:
|
|
|
+ blocks.append("wallet is balanced but no grid candidate is available")
|
|
|
+ elif 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:
|
|
|
+ blocks.append("wallet is ready but grid fit is still too weak")
|
|
|
+ elif grid and grid["score"] >= 0.5:
|
|
|
+ action = "replace_with_grid"
|
|
|
+ target_strategy = grid["strategy_id"]
|
|
|
+ mode = "act"
|
|
|
+ reasons.append("trend is directional but not yet sustained, so grid can resume first")
|
|
|
+ else:
|
|
|
+ blocks.append("trend candidate is not strong enough yet and grid fit is not ready, so rebalancer should not hand directly back to trend")
|
|
|
+
|
|
|
+ return action, mode, target_strategy, reasons, blocks
|
|
|
+
|
|
|
+
|
|
|
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]
|
|
|
@@ -466,6 +682,8 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
|
|
|
stance_is_bearish = "bearish" in stance
|
|
|
directional_micro_clear = bullish_micro_clear if stance_is_bullish else bearish_micro_clear if stance_is_bearish else False
|
|
|
grid_fill = _grid_fill_proximity(current_primary, narrative_payload) if current_primary and current_primary["strategy_type"] == "grid_trader" else {"near_fill": False}
|
|
|
+ grid_pressure = _grid_trend_pressure(current_primary, narrative_payload) if current_primary and current_primary["strategy_type"] == "grid_trader" else {"levels": 0.0, "rounded_levels": 0, "direction": "unknown"}
|
|
|
+ severe_imbalance = inventory_state in {"depleted_base_side", "depleted_quote_side", "critically_unbalanced"}
|
|
|
|
|
|
action = "hold"
|
|
|
mode = "observe"
|
|
|
@@ -477,128 +695,34 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
|
|
|
grid = next((r for r in ranked if r["strategy_type"] == "grid_trader"), None)
|
|
|
|
|
|
if current_primary and current_primary["strategy_type"] == "grid_trader":
|
|
|
- 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 directional_micro_clear 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"
|
|
|
- target_strategy = rebalance["strategy_id"]
|
|
|
- mode = "act"
|
|
|
- else:
|
|
|
- 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 and directional_micro_clear:
|
|
|
- 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 the micro layer is not clear enough for a trend handoff")
|
|
|
- else:
|
|
|
- action = "keep_grid"
|
|
|
- mode = "observe"
|
|
|
- reasons.append("grid can likely self-heal because breakout pressure is not yet persistent")
|
|
|
+ action, mode, target_strategy, reasons, blocks = _decide_for_grid(
|
|
|
+ current_primary=current_primary,
|
|
|
+ stance=stance,
|
|
|
+ inventory_state=inventory_state,
|
|
|
+ wallet_state=wallet_state,
|
|
|
+ breakout=breakout,
|
|
|
+ grid_fill=grid_fill,
|
|
|
+ grid_pressure=grid_pressure,
|
|
|
+ directional_micro_clear=directional_micro_clear,
|
|
|
+ severe_imbalance=severe_imbalance,
|
|
|
+ trend=trend,
|
|
|
+ rebalance=rebalance,
|
|
|
+ )
|
|
|
elif current_primary and current_primary["strategy_type"] == "trend_follower":
|
|
|
- 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:
|
|
|
- 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 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:
|
|
|
- action = "keep_trend"
|
|
|
- mode = "observe"
|
|
|
- reasons.append("trend strategy still fits the directional narrative")
|
|
|
+ action, mode, target_strategy, reasons, blocks = _decide_for_trend(
|
|
|
+ current_primary=current_primary,
|
|
|
+ stance=stance,
|
|
|
+ narrative_payload=narrative_payload,
|
|
|
+ wallet_state=wallet_state,
|
|
|
+ grid=grid,
|
|
|
+ )
|
|
|
elif current_primary and current_primary["strategy_type"] == "exposure_protector":
|
|
|
- trend = next((r for r in ranked if r["strategy_type"] == "trend_follower"), None)
|
|
|
- trend_is_sustained = bool(trend and trend["score"] > 0.85 and breakout["persistent"])
|
|
|
- if trend_is_sustained and stance in {"constructive_bullish", "cautious_bullish", "constructive_bearish", "cautious_bearish"}:
|
|
|
- action = "replace_with_trend_follower"
|
|
|
- target_strategy = trend["strategy_id"]
|
|
|
- mode = "act"
|
|
|
- reasons.append("trend is sustained strongly enough that the rebalancer should hand back to trend immediately")
|
|
|
- elif str(wallet_state.get("inventory_state") or "").lower() == "balanced":
|
|
|
- if grid:
|
|
|
- action = "replace_with_grid"
|
|
|
- target_strategy = grid["strategy_id"]
|
|
|
- mode = "act"
|
|
|
- reasons.append("wallet is balanced, so grid should run until a strong trend is detected")
|
|
|
- else:
|
|
|
- action = "keep_rebalancer"
|
|
|
- mode = "observe"
|
|
|
- blocks.append("wallet is balanced but no grid candidate is available")
|
|
|
- elif 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 grid and grid["score"] >= 0.5:
|
|
|
- action = "replace_with_grid"
|
|
|
- target_strategy = grid["strategy_id"]
|
|
|
- mode = "act"
|
|
|
- reasons.append("trend is directional but not yet sustained, so grid can resume first")
|
|
|
- else:
|
|
|
- action = "keep_rebalancer"
|
|
|
- mode = "observe"
|
|
|
- blocks.append("trend candidate is not strong enough yet and grid fit is not ready")
|
|
|
+ action, mode, target_strategy, reasons, blocks = _decide_for_rebalancer(
|
|
|
+ current_primary=current_primary,
|
|
|
+ stance=stance,
|
|
|
+ wallet_state=wallet_state,
|
|
|
+ grid=grid,
|
|
|
+ )
|
|
|
else:
|
|
|
if best and best["score"] >= 0.55:
|
|
|
action = f"enable_{best['strategy_type']}"
|