Jelajahi Sumber

WIP: trend rework and dashboard tuning

Lukas Goldschmidt 2 minggu lalu
induk
melakukan
d5bc8690b8

+ 32 - 35
src/hermes_mcp/dashboard.py

@@ -276,11 +276,10 @@ def concern_detail(concern_id: str):
             breakout_persistence_min: Number(document.getElementById('tune-breakout-persistence-min')?.value),
             short_term_confirmation_min: Number(document.getElementById('tune-short-term-confirmation-min')?.value),
             switch_cost_penalty: Number(document.getElementById('tune-switch-cost-penalty')?.value),
-            rebalance_imbalance_threshold: Number(document.getElementById('tune-rebalance-imbalance-threshold')?.value),
             force_grid_when_balanced: Boolean(document.getElementById('tune-force-grid-when-balanced')?.checked),
             grid_release_threshold: Number(document.getElementById('tune-grid-release-threshold')?.value),
+            trend_hold_threshold: Number(document.getElementById('tune-trend-hold-threshold')?.value),
             trend_cooling_threshold: Number(document.getElementById('tune-trend-cooling-threshold')?.value),
-            trend_inventory_stress_threshold: Number(document.getElementById('tune-trend-inventory-stress-threshold')?.value),
             action_cooldown_seconds: Number(document.getElementById('tune-action-cooldown-seconds')?.value),
             estimated_turn_cost_pct: Number(document.getElementById('tune-estimated-turn-cost-pct')?.value),
             micro_trend_weight: Number(document.getElementById('tune-micro-trend-weight')?.value),
@@ -453,8 +452,8 @@ def concern_detail(concern_id: str):
               </div>
 
               <div class='panel'>
-                <h2 style='margin-top:0'>Initial tuning</h2>
-                <div class='small' style='margin-bottom:12px'>This is the first practical tuning pass. Finer micro-trend and momentum weighting can come later.</div>
+                <h2 style='margin-top:0'>Decision tuning</h2>
+                <div class='small' style='margin-bottom:12px'>Tune when Hermes changes posture. Higher values usually make a threshold stricter or a signal more influential; each note below states the exact effect.</div>
                 ${activePlaybookFamily === 'trend-only' ? `
                 <div class='stack'>
                   <div class='panel' style='background:#fafafa'>
@@ -462,19 +461,19 @@ def concern_detail(concern_id: str):
                     <div class='grid'>
                       <div>
                         <label title='Higher means Hermes needs more directional edge before activating a side at all. Lower means it engages sooner.'>Activation edge threshold<br><input id='tune-activation-edge-threshold' type='number' step='0.01' value='${Number(tuning.activation_edge_threshold ?? 1.15)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
-                        <div class='small'>Higher = more selective side activation.</div>
+                        <div class='small'>Higher = harder to activate a side.</div>
                       </div>
                       <div>
                         <label title='Higher means Hermes needs stronger opposite evidence before flipping from buy to sell or sell to buy. Lower means it flips more readily.'>Flip edge threshold<br><input id='tune-flip-edge-threshold' type='number' step='0.01' value='${Number(tuning.flip_edge_threshold ?? 1.35)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
-                        <div class='small'>Higher = fewer flips. Lower = more reactive.</div>
+                        <div class='small'>Higher = fewer flips.</div>
                       </div>
                       <div>
                         <label title='Higher means the new side must beat the current side by a larger margin before Hermes flips. Lower means smaller gaps can trigger a turn.'>Flip confirmation gap<br><input id='tune-flip-confirmation-gap' type='number' step='0.01' value='${Number(tuning.flip_confirmation_gap ?? 0.25)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
-                        <div class='small'>Higher = more reluctance to reverse.</div>
+                        <div class='small'>Higher = larger lead required before a flip.</div>
                       </div>
                       <div>
                         <label title='Higher means Hermes assumes a more expensive side flip and therefore avoids switching more often. Lower means cheaper flips and more responsiveness.'>Estimated turn cost %<br><input id='tune-estimated-turn-cost-pct' type='number' step='0.01' value='${Number(tuning.estimated_turn_cost_pct ?? 0.7)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
-                        <div class='small'>Higher = stronger fee-aware reluctance.</div>
+                        <div class='small'>Higher = more reluctance to switch sides.</div>
                       </div>
                     </div>
                   </div>
@@ -484,19 +483,19 @@ def concern_detail(concern_id: str):
                     <div class='grid'>
                       <div>
                         <label title='Higher means 1m and 5m trend manifestation has more influence on the side choice. Lower means micro tape matters less.'>Micro trend weight<br><input id='tune-micro-trend-weight' type='number' step='0.01' value='${Number(tuning.micro_trend_weight ?? 0.8)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
-                        <div class='small'>Higher = more sensitive to fast trend/momentum.</div>
+                        <div class='small'>Higher = fast tape matters more.</div>
                       </div>
                       <div>
                         <label title='Higher means meso structure and 5m directional bias dominate more strongly. Lower means meso trend matters less.'>Meso trend weight<br><input id='tune-meso-trend-weight' type='number' step='0.01' value='${Number(tuning.meso_trend_weight ?? 1.0)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
-                        <div class='small'>Higher = more respect for persistent structure.</div>
+                        <div class='small'>Higher = meso structure matters more.</div>
                       </div>
                       <div>
                         <label title='Higher means macro directional context has more say in keeping or flipping the side. Lower means macro backdrop matters less.'>Macro trend weight<br><input id='tune-macro-trend-weight' type='number' step='0.01' value='${Number(tuning.macro_trend_weight ?? 0.7)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
-                        <div class='small'>Higher = more patience with the larger trend.</div>
+                        <div class='small'>Higher = macro backdrop matters more.</div>
                       </div>
                       <div>
                         <label title='Higher means repeated same-direction evidence builds confidence faster. Lower means persistence contributes less.'>Persistence bonus weight<br><input id='tune-persistence-bonus-weight' type='number' step='0.01' value='${Number(tuning.persistence_bonus_weight ?? 0.45)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
-                        <div class='small'>Higher = more reward for steady directional persistence.</div>
+                        <div class='small'>Higher = persistence builds confidence faster.</div>
                       </div>
                     </div>
                   </div>
@@ -506,7 +505,7 @@ def concern_detail(concern_id: str):
                     <div class='grid'>
                       <div>
                         <label title='Higher means Argus compression penalizes directional conviction more strongly. Lower means Hermes is less discouraged by compression.'>Argus compression penalty<br><input id='tune-argus-compression-penalty' type='number' step='0.01' value='${Number(tuning.argus_compression_penalty ?? 0.18)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
-                        <div class='small'>Higher = more cautious in compressed/range-like conditions.</div>
+                        <div class='small'>Higher = compression suppresses directional conviction more.</div>
                       </div>
                     </div>
                   </div>
@@ -517,25 +516,15 @@ def concern_detail(concern_id: str):
                     <div class='grid'>
                       <div>
                         <label title='Higher means Hermes waits for more persistent breakout evidence before leaving grid. Lower means it flips to trend sooner.'>Breakout persistence<br><input id='tune-breakout-persistence-min' type='number' step='0.01' value='${Number(tuning.breakout_persistence_min ?? 0.65)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
-                        <div class='small'>Higher = harder to leave grid. Lower = easier to hand off.</div>
+                        <div class='small'>Higher = harder to leave grid.</div>
                       </div>
                       <div>
                         <label title='Higher means 1m and 5m must agree more strongly before Hermes leaves grid. Lower makes short-term confirmation easier.'>Short-term confirmation<br><input id='tune-short-term-confirmation-min' type='number' step='0.01' value='${Number(tuning.short_term_confirmation_min ?? 0.32)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
-                        <div class='small'>Higher = stronger micro confirmation required.</div>
+                        <div class='small'>Higher = stronger short-term confirmation required.</div>
                       </div>
                       <div>
-                        <label title='Higher means switching out of grid should be treated as more expensive. Lower means Hermes is less reluctant to hand off.'>Switch cost penalty<br><input id='tune-switch-cost-penalty' type='number' step='0.01' value='${Number(tuning.switch_cost_penalty ?? 1.0)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
-                        <div class='small'>Higher = stickier grid. Lower = faster handoff.</div>
-                      </div>
-                    </div>
-                  </div>
-
-                  <div class='panel' style='background:#fafafa'>
-                    <h3 style='margin-top:0'>Grid → Rebalancer</h3>
-                    <div class='grid'>
-                      <div>
-                        <label title='Higher means Hermes tolerates more inventory skew before rebalancing. Lower means it repairs earlier.'>Rebalance imbalance threshold<br><input id='tune-rebalance-imbalance-threshold' type='number' step='0.01' value='${Number(tuning.rebalance_imbalance_threshold ?? 0.30)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
-                        <div class='small'>Higher = later repair. Lower = earlier repair.</div>
+                        <label title='Higher means a new posture must beat the current one by a larger margin before Hermes switches.'>Switch cost penalty<br><input id='tune-switch-cost-penalty' type='number' step='0.01' value='${Number(tuning.switch_cost_penalty ?? 1.0)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
+                        <div class='small'>Higher = more reluctance to switch posture.</div>
                       </div>
                     </div>
                   </div>
@@ -545,7 +534,7 @@ def concern_detail(concern_id: str):
                     <div class='grid'>
                       <div>
                         <label title='Higher means Hermes wants a cleaner, more harvestable tape before giving control back to grid. Lower means grid resumes earlier.'>Grid release threshold<br><input id='tune-grid-release-threshold' type='number' step='0.01' value='${Number(tuning.grid_release_threshold ?? 0.35)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
-                        <div class='small'>Higher = more cautious handback to grid.</div>
+                        <div class='small'>Higher = stricter handback to grid.</div>
                       </div>
                     </div>
                     <div style='margin-top:12px'>
@@ -557,22 +546,30 @@ def concern_detail(concern_id: str):
                     <h3 style='margin-top:0'>Trend → Rebalancer</h3>
                     <div class='grid'>
                       <div>
-                        <label title='Higher means trend must cool more clearly before Hermes gives up directional mode. Lower means it exits trend sooner.'>Trend cooling threshold<br><input id='tune-trend-cooling-threshold' type='number' step='0.01' value='${Number(tuning.trend_cooling_threshold ?? 0.45)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
-                        <div class='small'>Higher = stay in trend longer.</div>
+                        <label title='Higher means Hermes requires a stronger live trend before it keeps grid suspended. Lower means it gives trend up sooner.'>Trend hold threshold<br><input id='tune-trend-hold-threshold' type='number' step='0.01' value='${Number(tuning.trend_hold_threshold ?? 0.56)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
+                        <div class='small'>Higher = trend must stay stronger to keep control.</div>
+                      </div>
+                      <div>
+                        <label title='Higher means micro tape has more influence on leaving trend. Lower means micro tape matters less.'>Micro trend weight<br><input id='tune-micro-trend-weight' type='number' step='0.01' value='${Number(tuning.micro_trend_weight ?? 0.8)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
+                        <div class='small'>Higher = micro tape has more say in leaving trend.</div>
                       </div>
                       <div>
-                        <label title='Higher means Hermes tolerates more wallet stress before leaving trend to repair inventory. Lower means it protects the wallet sooner.'>Trend inventory stress threshold<br><input id='tune-trend-inventory-stress-threshold' type='number' step='0.01' value='${Number(tuning.trend_inventory_stress_threshold ?? 0.55)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
-                        <div class='small'>Higher = protect later. Lower = protect sooner.</div>
+                        <label title='Higher means meso structure has more influence on leaving trend. Lower means meso matters less.'>Meso trend weight<br><input id='tune-meso-trend-weight' type='number' step='0.01' value='${Number(tuning.meso_trend_weight ?? 1.0)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
+                        <div class='small'>Higher = meso structure has more say in leaving trend.</div>
+                      </div>
+                      <div>
+                        <label title='Higher means trend must cool more clearly before Hermes gives up directional mode. Lower means it exits trend sooner.'>Trend cooling threshold<br><input id='tune-trend-cooling-threshold' type='number' step='0.01' value='${Number(tuning.trend_cooling_threshold ?? 0.45)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
+                        <div class='small'>Higher = clearer cooling required before exit.</div>
                       </div>
                     </div>
                   </div>
 
                   <div class='panel' style='background:#fafafa'>
-                    <h3 style='margin-top:0'>Global</h3>
+                    <h3 style='margin-top:0'>Switch pacing</h3>
                     <div class='grid'>
                       <div>
-                        <label title='Higher means Hermes should wait longer between action changes. Lower means it can react again sooner.'>Action cooldown seconds<br><input id='tune-action-cooldown-seconds' type='number' step='1' value='${Number(tuning.action_cooldown_seconds ?? 600)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
-                        <div class='small'>Higher = slower re-action. Lower = faster re-action.</div>
+                        <label title='After a real posture change, Hermes will wait at least this many seconds before switching again.'>Action cooldown seconds<br><input id='tune-action-cooldown-seconds' type='number' step='1' value='${Number(tuning.action_cooldown_seconds ?? 600)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
+                        <div class='small'>Higher = less churn after a switch.</div>
                       </div>
                     </div>
                   </div>

+ 168 - 18
src/hermes_mcp/decision_engine.py

@@ -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,

+ 17 - 5
src/hermes_mcp/server.py

@@ -311,6 +311,15 @@ async def lifespan(_: FastAPI):
                     payload_json=json.dumps({"snapshot": argus_snapshot, "regime": argus_regime}, ensure_ascii=False),
                     observed_at=datetime.now(timezone.utc).isoformat(),
                 )
+            recent_decisions_by_concern = {}
+            try:
+                for d in latest_decisions(200):
+                    cid = str(d.get("concern_id") or "")
+                    if not cid:
+                        continue
+                    recent_decisions_by_concern.setdefault(cid, []).append(d)
+            except Exception:
+                recent_decisions_by_concern = {}
             for concern in concerns:
                 symbol = _resolve_regime_symbol(concern)
                 if not symbol:
@@ -403,6 +412,7 @@ async def lifespan(_: FastAPI):
                     ]
                     breakout_window_seconds = max(300, int(getattr(cfg, "breakout_memory_window_seconds", 900) or 900))
                     recent_state_rows = recent_states_for_concern(concern_id=str(concern["id"]), since_seconds=breakout_window_seconds, limit=12)
+                    recent_decision_rows = recent_decisions_by_concern.get(str(concern["id"]), [])[:40]
                     decision = make_family_decision(
                         family=str(active_playbook.get("strategy_family") or "grid-trend-rebalancer") if active_playbook else "grid-trend-rebalancer",
                         concern=concern,
@@ -416,6 +426,7 @@ async def lifespan(_: FastAPI):
                         history_window={
                             "window_seconds": breakout_window_seconds,
                             "recent_states": recent_state_rows,
+                            "recent_decisions": recent_decision_rows,
                         },
                         decision_profile=decision_profiles.get(str(concern.get("decision_profile_id") or "").strip()),
                     )
@@ -442,6 +453,7 @@ async def lifespan(_: FastAPI):
                         history_window={
                             "window_seconds": breakout_window_seconds,
                             "recent_states": recent_state_rows,
+                            "recent_decisions": recent_decision_rows,
                         },
                         ),
                         "dispatch": dispatch_record,
@@ -660,11 +672,12 @@ def _default_profile_config(family: str | None = None) -> dict[str, object]:
         "breakout_persistence_min": 0.65,
         "short_term_confirmation_min": 0.32,
         "switch_cost_penalty": 1.0,
-        "rebalance_imbalance_threshold": 0.30,
         "force_grid_when_balanced": True,
         "grid_release_threshold": 0.35,
+        "trend_hold_threshold": 0.56,
         "trend_cooling_threshold": 0.45,
-        "trend_inventory_stress_threshold": 0.55,
+        "micro_trend_weight": 0.8,
+        "meso_trend_weight": 1.0,
         "action_cooldown_seconds": 600,
     }
 
@@ -1143,15 +1156,14 @@ async def dashboard_update_playbook_tuning(concern_id: str, playbook_id: str, re
         "breakout_persistence_min",
         "short_term_confirmation_min",
         "switch_cost_penalty",
-        "rebalance_imbalance_threshold",
         "force_grid_when_balanced",
         "grid_release_threshold",
+        "trend_hold_threshold",
         "trend_cooling_threshold",
-        "trend_inventory_stress_threshold",
-        "action_cooldown_seconds",
         "estimated_turn_cost_pct",
         "micro_trend_weight",
         "meso_trend_weight",
+        "action_cooldown_seconds",
         "macro_trend_weight",
         "persistence_bonus_weight",
         "argus_compression_penalty",

+ 31 - 15
src/hermes_mcp/store.py

@@ -203,9 +203,12 @@ def _now() -> str:
 
 def _connect() -> sqlite3.Connection:
     DATA_DIR.mkdir(parents=True, exist_ok=True)
-    conn = sqlite3.connect(DB_PATH)
+    conn = sqlite3.connect(DB_PATH, timeout=30)
     conn.row_factory = sqlite3.Row
     conn.execute("pragma foreign_keys = on")
+    conn.execute("pragma journal_mode = wal")
+    conn.execute("pragma synchronous = normal")
+    conn.execute("pragma busy_timeout = 30000")
     return conn
 
 
@@ -511,20 +514,33 @@ def upsert_cycle(*, id: str, started_at: str, finished_at: str | None, status: s
 
 def upsert_regime_sample(*, id: str, cycle_id: str, concern_id: str, timeframe: str, regime_json: str, captured_at: str) -> None:
     init_db()
-    with _connect() as conn:
-        conn.execute(
-            """
-            insert into regime_samples(id, cycle_id, concern_id, timeframe, regime_json, captured_at)
-            values(?, ?, ?, ?, ?, ?)
-            on conflict(id) do update set
-              cycle_id=excluded.cycle_id,
-              concern_id=excluded.concern_id,
-              timeframe=excluded.timeframe,
-              regime_json=excluded.regime_json,
-              captured_at=excluded.captured_at
-            """,
-            (id, cycle_id, concern_id, timeframe, regime_json, captured_at),
-        )
+    last_error: Exception | None = None
+    for delay_ms in (0, 75, 150, 300):
+        try:
+            with _connect() as conn:
+                conn.execute(
+                    """
+                    insert into regime_samples(id, cycle_id, concern_id, timeframe, regime_json, captured_at)
+                    values(?, ?, ?, ?, ?, ?)
+                    on conflict(id) do update set
+                      cycle_id=excluded.cycle_id,
+                      concern_id=excluded.concern_id,
+                      timeframe=excluded.timeframe,
+                      regime_json=excluded.regime_json,
+                      captured_at=excluded.captured_at
+                    """,
+                    (id, cycle_id, concern_id, timeframe, regime_json, captured_at),
+                )
+            return
+        except sqlite3.OperationalError as exc:
+            last_error = exc
+            if "database is locked" not in str(exc).lower():
+                raise
+            if delay_ms:
+                import time
+                time.sleep(delay_ms / 1000.0)
+    if last_error:
+        raise last_error
 
 
 def upsert_observation(*, id: str, cycle_id: str, concern_id: str | None, source: str, kind: str, payload_json: str, observed_at: str | None = None) -> None:

+ 6 - 6
tests/test_decision_engine.py

@@ -932,7 +932,7 @@ def test_normalize_strategy_snapshot_uses_live_report_contract_and_supervision()
     assert normalized["open_order_count"] == 12
 
 
-def test_make_decision_keeps_trend_during_directional_regime_even_if_wallet_is_skewed():
+def test_make_decision_keeps_trend_during_strong_directional_regime():
     concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
     narrative = {
         "stance": "constructive_bullish",
@@ -940,11 +940,11 @@ def test_make_decision_keeps_trend_during_directional_regime_even_if_wallet_is_s
         "opportunity_map": {"continuation": 0.8, "mean_reversion": 0.1, "reversal": 0.05, "wait": 0.05},
     }
     wallet_state = {
-        "inventory_state": "critically_unbalanced",
-        "rebalance_needed": True,
-        "grid_ready": False,
-        "base_ratio": 0.88,
-        "quote_ratio": 0.12,
+        "inventory_state": "balanced",
+        "rebalance_needed": False,
+        "grid_ready": True,
+        "base_ratio": 0.52,
+        "quote_ratio": 0.48,
     }
     strategies = [
         {"id": "trend-1", "strategy_type": "trend_follower", "mode": "active", "account_id": "a1", "state": {}, "config": {}},