|
|
@@ -406,6 +406,31 @@ def _parse_timestamp(value: Any) -> datetime | None:
|
|
|
return parsed.astimezone(timezone.utc)
|
|
|
|
|
|
|
|
|
+def _recent_switch_cooldown_active(history_window: dict[str, Any] | None, concern_id: str, cooldown_seconds: int) -> tuple[bool, float | None, str | None]:
|
|
|
+ if cooldown_seconds <= 0:
|
|
|
+ return False, None, None
|
|
|
+ rows = history_window.get("recent_decisions") if isinstance(history_window, dict) and isinstance(history_window.get("recent_decisions"), list) else []
|
|
|
+ now = datetime.now(timezone.utc)
|
|
|
+ for row in rows:
|
|
|
+ if not isinstance(row, dict):
|
|
|
+ continue
|
|
|
+ if concern_id and str(row.get("concern_id") or "") != concern_id:
|
|
|
+ continue
|
|
|
+ mode = str(row.get("mode") or "").lower()
|
|
|
+ action = str(row.get("action") or "")
|
|
|
+ target = str(row.get("target_strategy") or "")
|
|
|
+ if mode != "act" or not action or not target:
|
|
|
+ continue
|
|
|
+ created = _parse_timestamp(row.get("created_at"))
|
|
|
+ if not created:
|
|
|
+ continue
|
|
|
+ elapsed = (now - created).total_seconds()
|
|
|
+ if elapsed < cooldown_seconds:
|
|
|
+ return True, round(max(cooldown_seconds - elapsed, 0.0), 1), action
|
|
|
+ return False, None, action
|
|
|
+ return False, None, None
|
|
|
+
|
|
|
+
|
|
|
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 {}
|
|
|
@@ -676,9 +701,8 @@ def _inventory_breakout_is_directionally_compatible(inventory_state: str, breako
|
|
|
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
|
|
|
+def _trend_cooling_edge(narrative_payload: dict[str, Any], wallet_state: dict[str, Any], profile_config: dict[str, Any] | None = None) -> bool:
|
|
|
+ profile_config = profile_config if isinstance(profile_config, dict) else {}
|
|
|
scoped = narrative_payload.get("scoped_state") if isinstance(narrative_payload.get("scoped_state"), dict) else {}
|
|
|
short_term_dislocated = _short_term_trend_dislocated(narrative_payload)
|
|
|
micro = scoped.get("micro") if isinstance(scoped.get("micro"), dict) else {}
|
|
|
@@ -693,24 +717,61 @@ def _trend_cooling_edge(narrative_payload: dict[str, Any], wallet_state: dict[st
|
|
|
inventory_state = _inventory_state_label(wallet_state.get("inventory_state"))
|
|
|
early_reversal_warning = micro_reversal_risk in {"medium", "high"}
|
|
|
short_term_warning = short_term_dislocated and meso_structure == "trend_continuation"
|
|
|
+ micro_weight = _safe_float(profile_config.get("micro_trend_weight"))
|
|
|
+ if micro_weight is None:
|
|
|
+ micro_weight = 0.8
|
|
|
+ meso_weight = _safe_float(profile_config.get("meso_trend_weight"))
|
|
|
+ if meso_weight is None:
|
|
|
+ meso_weight = 1.0
|
|
|
+ cooling_threshold = _safe_float(profile_config.get("trend_cooling_threshold"))
|
|
|
+ if cooling_threshold is None:
|
|
|
+ cooling_threshold = 0.45
|
|
|
bullish_inventory_pressure = inventory_state in {"base_heavy", "critically_unbalanced", "depleted_quote_side"}
|
|
|
bearish_inventory_pressure = inventory_state in {"quote_heavy", "critically_unbalanced", "depleted_base_side"}
|
|
|
|
|
|
+ bullish_cooling_score = 0.0
|
|
|
+ if meso_structure == "trend_continuation" and meso_bias == "bullish":
|
|
|
+ bullish_cooling_score += 0.15 * meso_weight
|
|
|
+ if micro_impulse == "mixed":
|
|
|
+ bullish_cooling_score += 0.15 * micro_weight
|
|
|
+ if early_reversal_warning:
|
|
|
+ bullish_cooling_score += 0.25 * micro_weight
|
|
|
+ if short_term_warning:
|
|
|
+ bullish_cooling_score += 0.32 * micro_weight
|
|
|
+ if micro_bias == "bearish":
|
|
|
+ bullish_cooling_score += 0.15 * micro_weight
|
|
|
+ if micro_location in {"near_upper_band", "upper_half", "centered"}:
|
|
|
+ bullish_cooling_score += 0.15 * micro_weight
|
|
|
+
|
|
|
+ bearish_cooling_score = 0.0
|
|
|
+ if meso_structure == "trend_continuation" and meso_bias == "bearish":
|
|
|
+ bearish_cooling_score += 0.15 * meso_weight
|
|
|
+ if micro_impulse == "mixed":
|
|
|
+ bearish_cooling_score += 0.15 * micro_weight
|
|
|
+ if early_reversal_warning:
|
|
|
+ bearish_cooling_score += 0.25 * micro_weight
|
|
|
+ if short_term_warning:
|
|
|
+ bearish_cooling_score += 0.32 * micro_weight
|
|
|
+ if micro_bias == "bullish":
|
|
|
+ bearish_cooling_score += 0.15 * micro_weight
|
|
|
+ if micro_location in {"near_lower_band", "lower_half", "centered"}:
|
|
|
+ bearish_cooling_score += 0.15 * micro_weight
|
|
|
+
|
|
|
bullish_cooling = (
|
|
|
- bullish_inventory_pressure
|
|
|
- and meso_structure == "trend_continuation"
|
|
|
+ meso_structure == "trend_continuation"
|
|
|
and meso_bias == "bullish"
|
|
|
and (micro_impulse == "mixed" or early_reversal_warning or short_term_warning)
|
|
|
and micro_bias in {"mixed", "bearish", "bullish"}
|
|
|
and (short_term_warning or micro_location in {"near_upper_band", "upper_half", "centered"})
|
|
|
+ and bullish_cooling_score >= cooling_threshold
|
|
|
)
|
|
|
bearish_cooling = (
|
|
|
- bearish_inventory_pressure
|
|
|
- and meso_structure == "trend_continuation"
|
|
|
+ meso_structure == "trend_continuation"
|
|
|
and meso_bias == "bearish"
|
|
|
and (micro_impulse == "mixed" or early_reversal_warning or short_term_warning)
|
|
|
and micro_bias in {"mixed", "bullish", "bearish"}
|
|
|
and (short_term_warning or micro_location in {"near_lower_band", "lower_half", "centered"})
|
|
|
+ and bearish_cooling_score >= cooling_threshold
|
|
|
)
|
|
|
return bullish_cooling or bearish_cooling
|
|
|
|
|
|
@@ -859,6 +920,7 @@ def _extract_decision_signals(*,
|
|
|
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 {}
|
|
|
features = narrative_payload.get("features_by_timeframe") if isinstance(narrative_payload.get("features_by_timeframe"), dict) else {}
|
|
|
+ opportunity_map = narrative_payload.get("opportunity_map") if isinstance(narrative_payload.get("opportunity_map"), dict) else {}
|
|
|
embedded = narrative_payload.get("decision_inputs") if isinstance(narrative_payload.get("decision_inputs"), dict) else {}
|
|
|
|
|
|
micro = scoped.get("micro") if isinstance(scoped.get("micro"), dict) else {}
|
|
|
@@ -868,6 +930,7 @@ def _extract_decision_signals(*,
|
|
|
micro_vol = micro_features.get("volatility") if isinstance(micro_features.get("volatility"), dict) else {}
|
|
|
micro_raw = micro_features.get("raw") if isinstance(micro_features.get("raw"), dict) else {}
|
|
|
recent_prices = _recent_1m_price_trace(history_window)
|
|
|
+ continuation = float(opportunity_map.get("continuation") or 0.0)
|
|
|
|
|
|
alignment = str(cross.get("alignment") or "partial_alignment")
|
|
|
friction = str(cross.get("friction") or "medium")
|
|
|
@@ -880,11 +943,16 @@ def _extract_decision_signals(*,
|
|
|
macro_bias = str(macro.get("bias") or "mixed")
|
|
|
profile_config = _decision_profile_config(decision_profile)
|
|
|
short_term_trend_min_score = _safe_float(profile_config.get("short_term_trend_min_score"))
|
|
|
+ if short_term_trend_min_score is None:
|
|
|
+ short_term_trend_min_score = _safe_float(profile_config.get("short_term_confirmation_min"))
|
|
|
if short_term_trend_min_score is None:
|
|
|
short_term_trend_min_score = 0.32
|
|
|
breakout_persistence_min = _safe_float(profile_config.get("breakout_persistence_min"))
|
|
|
if breakout_persistence_min is None:
|
|
|
breakout_persistence_min = 0.65
|
|
|
+ trend_hold_threshold = _safe_float(profile_config.get("trend_hold_threshold"))
|
|
|
+ if trend_hold_threshold is None:
|
|
|
+ trend_hold_threshold = 0.56
|
|
|
grid_release_threshold = _safe_float(profile_config.get("grid_release_threshold"))
|
|
|
if grid_release_threshold is None:
|
|
|
grid_release_threshold = 0.35
|
|
|
@@ -1049,13 +1117,36 @@ def _extract_decision_signals(*,
|
|
|
else:
|
|
|
wallet_grid_usability = 0.3
|
|
|
|
|
|
+ if scoped:
|
|
|
+ trend_hold_strength = (
|
|
|
+ structural_strength * 0.34
|
|
|
+ + tactical_strength * 0.24
|
|
|
+ + breakout_persistence * 0.14
|
|
|
+ + min(short_term_trend_score, 1.0) * 0.10
|
|
|
+ + continuation * 0.18
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ trend_hold_strength = continuation * 0.9 + breakout_persistence * 0.1
|
|
|
+ if tactical_easing:
|
|
|
+ trend_hold_strength -= 0.18
|
|
|
+ if tactical_direction not in {"mixed", structural_direction}:
|
|
|
+ trend_hold_strength -= 0.16
|
|
|
+ if short_term_trend_score < short_term_trend_min_score:
|
|
|
+ trend_hold_strength -= min(short_term_trend_min_score - short_term_trend_score, 0.25)
|
|
|
+ trend_hold_strength = round(_clamp(trend_hold_strength, 0.0, 1.0), 4)
|
|
|
+
|
|
|
trend_following_pressure = bool(
|
|
|
- structural_strength >= 0.58
|
|
|
- and breakout_persistence >= breakout_persistence_min
|
|
|
- and tactical_strength >= 0.35
|
|
|
- and tactical_direction == structural_direction
|
|
|
- and not tactical_easing
|
|
|
- and short_term_trend_score >= short_term_trend_min_score
|
|
|
+ (
|
|
|
+ structural_direction in {"bullish", "bearish"}
|
|
|
+ and tactical_direction == structural_direction
|
|
|
+ and breakout_persistence >= breakout_persistence_min
|
|
|
+ and trend_hold_strength >= trend_hold_threshold
|
|
|
+ )
|
|
|
+ or (
|
|
|
+ not scoped
|
|
|
+ and continuation >= 0.7
|
|
|
+ and not tactical_easing
|
|
|
+ )
|
|
|
)
|
|
|
grid_harvestable_now = bool(
|
|
|
harvestability_score >= 0.48
|
|
|
@@ -1091,6 +1182,8 @@ def _extract_decision_signals(*,
|
|
|
"within_rebalance_tolerance": within_rebalance_tolerance,
|
|
|
"rebalance_tolerance": 0.3,
|
|
|
"trend_following_pressure": trend_following_pressure,
|
|
|
+ "trend_hold_strength": trend_hold_strength,
|
|
|
+ "trend_hold_threshold": round(trend_hold_threshold, 4),
|
|
|
"rapid_directional_pressure": rapid_directional_pressure,
|
|
|
"rapid_downside_pressure": rapid_downside_pressure,
|
|
|
"recent_move_pct": round(recent_move_pct, 4),
|
|
|
@@ -1484,30 +1577,52 @@ def _decide_for_trend(*,
|
|
|
wallet_state: dict[str, Any],
|
|
|
grid: dict[str, Any] | None,
|
|
|
rebalance: dict[str, Any] | None = None,
|
|
|
+ profile_config: dict[str, Any] | None = None,
|
|
|
+ decision_signals: dict[str, Any] | None = 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] = []
|
|
|
+ decision_signals = decision_signals if isinstance(decision_signals, dict) else {}
|
|
|
+ trend_pressure = bool(decision_signals.get("trend_following_pressure"))
|
|
|
+ trend_hold_strength = float(decision_signals.get("trend_hold_strength") or 0.0)
|
|
|
+ trend_hold_threshold = float(decision_signals.get("trend_hold_threshold") or 0.56)
|
|
|
+ grid_harvestable_now = bool(decision_signals.get("grid_harvestable_now"))
|
|
|
|
|
|
# Trend should cool into rebalancing first when the wallet is skewed, then
|
|
|
# let rebalancer hand back to grid once the inventory is healthy again.
|
|
|
- cooling = _trend_cooling_edge(narrative_payload, wallet_state)
|
|
|
+ cooling = _trend_cooling_edge(narrative_payload, wallet_state, profile_config)
|
|
|
if cooling:
|
|
|
if wallet_state.get("rebalance_needed") and rebalance:
|
|
|
action = "replace_with_exposure_protector"
|
|
|
target_strategy = rebalance["strategy_id"]
|
|
|
mode = "act"
|
|
|
- reasons.append("trend has cooled and rebalancing should repair the wallet before grid resumes")
|
|
|
+ reasons.append("trend has cooled enough that directional mode no longer justifies staying active")
|
|
|
elif 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 because no rebalancer is available")
|
|
|
+ reasons.append("trend has cooled and the tape looks suitable for grid again")
|
|
|
else:
|
|
|
mode = "warn"
|
|
|
- blocks.append("edge cooling is visible but the wallet is not yet ready for grid")
|
|
|
+ blocks.append("trend is easing, but neither grid nor rebalancer is ready for a clean handoff")
|
|
|
+ elif not trend_pressure:
|
|
|
+ if grid and wallet_state.get("grid_ready") and grid_harvestable_now:
|
|
|
+ action = "replace_with_grid"
|
|
|
+ target_strategy = grid["strategy_id"]
|
|
|
+ mode = "act"
|
|
|
+ reasons.append(f"trend hold strength {trend_hold_strength:.2f} fell below threshold {trend_hold_threshold:.2f}, so grid can resume")
|
|
|
+ elif wallet_state.get("rebalance_needed") and rebalance:
|
|
|
+ action = "replace_with_exposure_protector"
|
|
|
+ target_strategy = rebalance["strategy_id"]
|
|
|
+ mode = "act"
|
|
|
+ reasons.append(f"trend hold strength {trend_hold_strength:.2f} fell below threshold {trend_hold_threshold:.2f}, so directional mode should yield")
|
|
|
+ else:
|
|
|
+ action = "hold_trend"
|
|
|
+ mode = "warn"
|
|
|
+ blocks.append(f"trend hold strength {trend_hold_strength:.2f} is below threshold {trend_hold_threshold:.2f}, but no clean handoff is available yet")
|
|
|
elif stance == "neutral_rotational":
|
|
|
if wallet_state.get("rebalance_needed") and rebalance:
|
|
|
action = "replace_with_exposure_protector"
|
|
|
@@ -1526,7 +1641,7 @@ def _decide_for_trend(*,
|
|
|
action = "hold_trend"
|
|
|
blocks.append("grid candidate not strong enough yet")
|
|
|
else:
|
|
|
- reasons.append("trend strategy still fits the directional narrative")
|
|
|
+ reasons.append(f"trend hold strength {trend_hold_strength:.2f} still clears threshold {trend_hold_threshold:.2f}")
|
|
|
|
|
|
return action, mode, target_strategy, reasons, blocks
|
|
|
|
|
|
@@ -1679,6 +1794,8 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
|
|
|
wallet_state=wallet_state,
|
|
|
grid=grid,
|
|
|
rebalance=rebalance,
|
|
|
+ profile_config=_decision_profile_config(decision_profile),
|
|
|
+ decision_signals=decision_signals,
|
|
|
)
|
|
|
elif current_primary and current_primary["strategy_type"] == "exposure_protector":
|
|
|
action, mode, target_strategy, reasons, blocks = _decide_for_rebalancer(
|
|
|
@@ -1701,6 +1818,31 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
|
|
|
mode = "observe"
|
|
|
blocks.append("no strategy is yet a strong enough fit")
|
|
|
|
|
|
+ profile_config = _decision_profile_config(decision_profile)
|
|
|
+ switch_cost_penalty = _safe_float(profile_config.get("switch_cost_penalty"))
|
|
|
+ if switch_cost_penalty is None:
|
|
|
+ switch_cost_penalty = 1.0
|
|
|
+ action_cooldown_seconds = int(_safe_float(profile_config.get("action_cooldown_seconds")) or 0)
|
|
|
+ current_score = float(next((r["score"] for r in ranked if current_primary and r["strategy_id"] == current_primary.get("id")), 0.0))
|
|
|
+ target_score = float(next((r["score"] for r in ranked if r["strategy_id"] == target_strategy), current_score))
|
|
|
+ switch_edge = round(target_score - current_score, 4)
|
|
|
+ required_switch_edge = round(max(switch_cost_penalty - 1.0, 0.0) * 0.08, 4)
|
|
|
+ cooldown_active, cooldown_remaining, cooldown_action = _recent_switch_cooldown_active(history_window, str(concern.get("id") or ""), action_cooldown_seconds)
|
|
|
+
|
|
|
+ if mode == "act" and current_primary and target_strategy and target_strategy != current_primary.get("id"):
|
|
|
+ if required_switch_edge > 0 and switch_edge < required_switch_edge:
|
|
|
+ mode = "observe"
|
|
|
+ action = f"keep_{current_primary['strategy_type'].replace('_trader', '').replace('_follower', '').replace('exposure_protector', 'rebalancer')}"
|
|
|
+ target_strategy = current_primary.get("id")
|
|
|
+ reasons = []
|
|
|
+ blocks = [f"switch edge {switch_edge:.2f} is below required friction {required_switch_edge:.2f}"]
|
|
|
+ elif cooldown_active:
|
|
|
+ mode = "observe"
|
|
|
+ action = f"keep_{current_primary['strategy_type'].replace('_trader', '').replace('_follower', '').replace('exposure_protector', 'rebalancer')}"
|
|
|
+ target_strategy = current_primary.get("id")
|
|
|
+ reasons = []
|
|
|
+ blocks = [f"switch cooldown active for {cooldown_remaining:.0f}s after {cooldown_action or 'recent switch'}"]
|
|
|
+
|
|
|
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_"):
|
|
|
@@ -1721,6 +1863,14 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
|
|
|
"grid_fill_context": grid_fill,
|
|
|
"grid_switch_tradeoff": switch_tradeoff if current_primary and current_primary["strategy_type"] == "grid_trader" else {},
|
|
|
"decision_audit": decision_signals,
|
|
|
+ "switch_friction": {
|
|
|
+ "switch_cost_penalty": round(switch_cost_penalty, 4),
|
|
|
+ "switch_edge": switch_edge,
|
|
|
+ "required_switch_edge": required_switch_edge,
|
|
|
+ "action_cooldown_seconds": action_cooldown_seconds,
|
|
|
+ "cooldown_active": cooldown_active,
|
|
|
+ "cooldown_remaining_seconds": cooldown_remaining,
|
|
|
+ },
|
|
|
"decision_profile": {
|
|
|
"id": decision_profile.get("id") if isinstance(decision_profile, dict) else None,
|
|
|
"name": decision_profile.get("name") if isinstance(decision_profile, dict) else None,
|