Explorar el Código

refactored decision engine by codex

Lukas Goldschmidt hace 2 semanas
padre
commit
0af4bf3964

+ 1 - 0
.gitignore

@@ -7,3 +7,4 @@ logs/
 data/*.sqlite3
 .env
 .DS_Store
+.codex 

+ 56 - 167
src/hermes_mcp/dashboard.py

@@ -128,9 +128,11 @@ def playbook_detail_page(playbook_id: str):
             return;
           }
           const playbook = data.playbook || {};
+          const playbookDefinition = data.playbook_definition || {};
           const concern = data.concern || {};
           const assignments = data.assignments || [];
           const available = data.available_strategies || [];
+          const roles = playbookDefinition.roles || [];
           document.getElementById('title').textContent = playbook.name || playbook.id || 'Playbook';
           document.getElementById('root').innerHTML = `
             <div class='grid'>
@@ -138,13 +140,13 @@ def playbook_detail_page(playbook_id: str):
                 <h2 style='margin-top:0'>Playbook</h2>
                 <div><strong>Concern</strong>: ${concern.account_id || '-'} / ${concern.market_symbol || '-'}</div>
                 <div><strong>Status</strong>: ${playbook.status || '-'}</div>
-                <div><strong>Family</strong>: ${playbook.strategy_family || '-'}</div>
-                <div><strong>Decision profile</strong>: ${playbook.decision_profile_id || '-'}</div>
+                <div><strong>Playbook id</strong>: ${playbook.strategy_family || '-'}</div>
+                <div><strong>Profile</strong>: ${playbook.decision_profile_id || '-'}</div>
               </div>
               <div class='panel'>
                 <h2 style='margin-top:0'>Assign existing strategy</h2>
                 <label>Strategy<select id='strategy-id'>${available.map(s => `<option value='${s.id || ''}' data-type='${s.strategy_type || ''}'>${s.display_label || s.id || ''}</option>`).join('')}</select></label>
-                <label>Role<select id='strategy-role'><option value='member'>member</option><option value='trend_buy'>trend_buy</option><option value='trend_sell'>trend_sell</option><option value='grid'>grid</option><option value='rebalancer'>rebalancer</option></select></label>
+                <label>Role<select id='strategy-role'>${roles.map(r => `<option value='${r.id || ''}'>${r.label || r.id || ''}</option>`).join('') || `<option value='member'>member</option>`}</select></label>
                 <div class='small' style='margin:10px 0 14px'>This reuses already discovered trader strategies on the same concern.</div>
                 <button type='button' onclick='assignStrategy()'>Assign strategy</button>
               </div>
@@ -252,10 +254,33 @@ def concern_detail(concern_id: str):
         }
         function modeChip(value) {
           const v = String(value || '').toLowerCase();
-          if (['act', 'active', 'running', 'keep_grid', 'keep_trend', 'keep_rebalancer'].includes(v)) return 'good';
-          if (['observe', 'wait', 'warn'].includes(v)) return 'info';
+          if (['active', 'running', 'keep_grid', 'keep_trend', 'keep_rebalancer', 'hold_trend'].includes(v)) return 'good';
+          if (['wait', 'hold'].includes(v)) return 'info';
           return 'warn';
         }
+        function renderTuningFields(parameters, tuning) {
+          const rows = (parameters || []).map(parameter => {
+            const id = String(parameter.id || '');
+            const type = String(parameter.type || 'number');
+            const label = parameter.label || id;
+            const help = parameter.help || '';
+            const value = tuning?.[id] ?? parameter.default;
+            if (!id) return '';
+            if (type === 'boolean') {
+              return `
+                <div class='panel' style='background:#fafafa'>
+                  <label title='${help}'><input data-playbook-parameter='${id}' data-parameter-type='boolean' type='checkbox' ${Boolean(value) ? 'checked' : ''}> ${label}</label>
+                  <div class='small' style='margin-top:6px'>${help}</div>
+                </div>`;
+            }
+            return `
+              <div class='panel' style='background:#fafafa'>
+                <label title='${help}'>${label}<br><input data-playbook-parameter='${id}' data-parameter-type='number' type='number' step='${parameter.step ?? 0.01}' min='${parameter.min ?? ''}' max='${parameter.max ?? ''}' value='${Number(value ?? 0)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
+                <div class='small' style='margin-top:6px'>${help}</div>
+              </div>`;
+          }).join('');
+          return rows || `<div class='muted'>This playbook has no tunable parameters.</div>`;
+        }
         async function activateSelectedPlaybook() {
           const select = document.getElementById('playbook-select');
           if (!select || !select.value) return;
@@ -272,26 +297,13 @@ def concern_detail(concern_id: str):
           if (!select || !select.value) return;
           const status = document.getElementById('tuning-save-status');
           if (status) status.textContent = 'Saving…';
-          const payload = {
-            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_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),
-            meso_trend_weight: Number(document.getElementById('tune-meso-trend-weight')?.value),
-            macro_trend_weight: Number(document.getElementById('tune-macro-trend-weight')?.value),
-            persistence_bonus_weight: Number(document.getElementById('tune-persistence-bonus-weight')?.value),
-            argus_compression_penalty: Number(document.getElementById('tune-argus-compression-penalty')?.value),
-            activation_edge_threshold: Number(document.getElementById('tune-activation-edge-threshold')?.value),
-            flip_edge_threshold: Number(document.getElementById('tune-flip-edge-threshold')?.value),
-            flip_confirmation_gap: Number(document.getElementById('tune-flip-confirmation-gap')?.value),
-          };
+          const payload = {};
+          for (const input of document.querySelectorAll('[data-playbook-parameter]')) {
+            const id = String(input.dataset.playbookParameter || '');
+            const type = String(input.dataset.parameterType || 'number');
+            if (!id) continue;
+            payload[id] = type === 'boolean' ? Boolean(input.checked) : Number(input.value);
+          }
           const res = await fetch(`/dashboard/concerns/__CONCERN_ID__/playbooks/${encodeURIComponent(select.value)}/tuning`, {
             method: 'POST',
             headers: { 'Content-Type': 'application/json' },
@@ -327,8 +339,8 @@ def concern_detail(concern_id: str):
 
           const activePlaybook = playbooks.find(p => String(p.status || '').toLowerCase() === 'active') || playbooks[0] || null;
           const selectedPlaybookId = activePlaybook?.id || '';
-          const activePlaybookFamily = String(activePlaybook?.strategy_family || 'mixed');
-          const tuning = profile?.config || {};
+          const activePlaybookDefinition = data.active_playbook_definition || {};
+          const tuning = data.playbook_parameters || profile?.config || {};
           const desiredOrder = ['1d', '4h', '1h', '15m', '5m', '1m'];
           const latestByTf = new Map();
           const historiesByTf = new Map();
@@ -390,7 +402,7 @@ def concern_detail(concern_id: str):
                 <div class='panel'>
                   <h2 style='margin-top:0'>Active decision state</h2>
                   <div><strong>Latest action</strong>: <span class='chip ${modeChip(decision?.action)}'>${decision?.action || '-'}</span></div>
-                  <div><strong>Mode</strong>: <span class='chip ${modeChip(decision?.mode)}'>${decision?.mode || '-'}</span></div>
+                  <div><strong>Requires action</strong>: <span class='chip ${decision?.requires_action ? 'warn' : 'info'}'>${decision?.requires_action ? 'yes' : 'no'}</span></div>
                   <div><strong>Reason</strong>: ${decision?.reason_summary || '-'}</div>
                   <div class='small' style='margin-top:8px'><strong>Wallet</strong>: ${wallet.inventory_state || '-'}${wallet.grid_ready ? ' · grid-ready' : ''}${wallet.rebalance_needed ? ' · rebalance-needed' : ''}</div>
                   <div class='small'><strong>State</strong>: ${latestState ? `${latestState.market_regime || '-'} / ${latestState.volatility_state || '-'} / ${latestState.sentiment_pressure || '-'}` : '-'}</div>
@@ -398,14 +410,14 @@ def concern_detail(concern_id: str):
               </div>
 
               <div class='panel'>
-                <h2 style='margin-top:0'>Decision profile</h2>
+                <h2 style='margin-top:0'>Playbook Parameters</h2>
                 ${profile ? `
                   <div><strong>${profile.name || profile.id || '-'}</strong></div>
-                  <div class='small'>Family: ${activePlaybookFamily || '-'}</div>
+                  <div class='small'>Playbook: ${activePlaybookDefinition.name || activePlaybook?.strategy_family || '-'}</div>
                   <div class='small'>${profile.description || ''}</div>
                   <div class='small' style='margin-top:8px'>Profile id: <code>${profile.id || '-'}</code></div>
-                  <pre style='white-space:pre-wrap;margin-top:10px'>${JSON.stringify(profile.config || {}, null, 2)}</pre>
-                ` : `<div class='muted'>No decision profile attached yet.</div>`}
+                  <pre style='white-space:pre-wrap;margin-top:10px'>${JSON.stringify(tuning || {}, null, 2)}</pre>
+                ` : `<div class='muted'>No playbook profile attached yet.</div>`}
               </div>
 
               <div class='panel'>
@@ -453,130 +465,9 @@ 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>
-                ${activePlaybookFamily === 'trend-only' ? `
-                <div class='stack'>
-                  <div class='panel' style='background:#fafafa'>
-                    <h3 style='margin-top:0'>Buy ↔ Sell switching</h3>
-                    <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>
-                      <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>
-                      <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>
-                      <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>
-                    </div>
-                  </div>
-
-                  <div class='panel' style='background:#fafafa'>
-                    <h3 style='margin-top:0'>Signal weighting</h3>
-                    <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>
-                      <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>
-                      <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>
-                      <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>
-                    </div>
-                  </div>
-
-                  <div class='panel' style='background:#fafafa'>
-                    <h3 style='margin-top:0'>Argus context</h3>
-                    <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>
-                    </div>
-                  </div>
-                </div>` : `
-                <div class='stack'>
-                  <div class='panel' style='background:#fafafa'>
-                    <h3 style='margin-top:0'>Grid → Trend</h3>
-                    <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>
-                      <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>
-                      <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>
-                      </div>
-                    </div>
-                  </div>
-
-                  <div class='panel' style='background:#fafafa'>
-                    <h3 style='margin-top:0'>Rebalancer → Grid</h3>
-                    <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>
-                    </div>
-                    <div style='margin-top:12px'>
-                      <label title='When enabled, a balanced wallet hands back to grid first. When disabled, Hermes may keep the rebalancer longer.'><input id='tune-force-grid-when-balanced' type='checkbox' ${Boolean(tuning.force_grid_when_balanced ?? true) ? 'checked' : ''}> Force grid when balanced</label>
-                    </div>
-                  </div>
-
-                  <div class='panel' style='background:#fafafa'>
-                    <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>
-                      </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>
-                      </div>
-                    </div>
-                  </div>
-
-                  <div class='panel' style='background:#fafafa'>
-                    <h3 style='margin-top:0'>Global</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>
-                      </div>
-                    </div>
-                  </div>
-                </div>`}
+                <h2 style='margin-top:0'>Playbook tuning</h2>
+                <div class='small' style='margin-bottom:12px'>Controls are rendered from the active playbook definition and only include parameters that Hermes actually uses.</div>
+                <div class='stack'>${renderTuningFields(activePlaybookDefinition.parameters || [], tuning)}</div>
                 <div style='margin-top:14px'>
                   <button type='button' onclick='saveTuning()' style='padding:8px 12px; border:1px solid #2563eb; background:#2563eb; color:#fff; border-radius:8px; cursor:pointer'>Save tuning</button>
                   <span id='tuning-save-status' class='small' style='margin-left:10px'></span>
@@ -701,9 +592,8 @@ def overview():
         }}
         function modeChip(value) {{
           const v = String(value || '').toLowerCase();
-          if (['act', 'active', 'running', 'keep_grid', 'keep_trend', 'keep_rebalancer'].includes(v)) return 'good';
-          if (['observe', 'wait', 'warn'].includes(v)) return 'info';
-          if (['suspend_grid', 'replace_with_exposure_protector', 'replace_with_trend_follower'].includes(v)) return 'warn';
+          if (['active', 'running', 'keep_grid', 'keep_trend', 'keep_rebalancer', 'hold_trend'].includes(v)) return 'good';
+          if (['wait', 'hold'].includes(v)) return 'info';
           return 'neutral';
         }}
         function formatLocalTime(value) {{
@@ -753,7 +643,7 @@ def overview():
             for (let i = 1; i < sorted.length; i++) {{
               const prev = sorted[i - 1];
               const cur = sorted[i];
-              const fields = ['mode', 'action', 'target_strategy', 'reason_summary'];
+              const fields = ['requires_action', 'action', 'target_strategy', 'reason_summary'];
               const diffs = fields.filter(f => String(cur?.[f] ?? '') !== String(prev?.[f] ?? ''));
               if (!diffs.length) continue;
               out.push({{ cur, prev, diffs }});
@@ -939,7 +829,7 @@ def overview():
           const latestDecisions = latestByConcern(data.decision_samples || []);
           const prevDecisions = previousByConcern(data.decision_samples || []);
           document.getElementById('decisions-body').innerHTML = latestDecisions.map(d => {
-            const hasChanged = changed(d, prevDecisions.get(String(d.concern_id || '')), ['mode','action','target_strategy','reason_summary']);
+            const hasChanged = changed(d, prevDecisions.get(String(d.concern_id || '')), ['requires_action','action','target_strategy','reason_summary']);
             const payload = (() => { try { return JSON.parse(d.target_policy_json || '{}'); } catch { return {}; } })();
             const wallet = payload.wallet_state || {};
             const ranking = payload.strategy_fit_ranking || [];
@@ -949,7 +839,7 @@ def overview():
             return `
             <tr class='${hasChanged ? 'recent-change' : ''}'>
               <td class='focus-cell'>${d.concern_id || ''}</td>
-              <td><span class='chip ${modeChip(d.mode)}'>${d.mode || ''}</span></td>
+              <td><span class='chip ${d.requires_action ? 'warn' : 'info'}'>${d.requires_action ? 'yes' : 'no'}</span></td>
               <td><span class='chip ${modeChip(d.action)}'>${d.action || ''}</span></td>
               <td>${d.target_strategy_label || d.target_strategy || '-'}</td>
               <td>${d.reason_summary || ''}</td>
@@ -990,7 +880,7 @@ def overview():
       </table>
       <h2>Latest decisions</h2>
       <table>
-        <tr><th>concern</th><th>mode</th><th>action</th><th>target strategy</th><th>reason</th><th>detail</th><th>confidence</th></tr>
+        <tr><th>concern</th><th>requires action</th><th>action</th><th>target strategy</th><th>reason</th><th>detail</th><th>confidence</th></tr>
         <tbody id="decisions-body">__DECISION_ROWS__</tbody>
       </table>
       </div></div>
@@ -1042,9 +932,8 @@ def changes():
       <script>
         function modeChip(value) {
           const v = String(value || '').toLowerCase();
-          if (['act', 'active', 'running', 'keep_grid', 'keep_trend', 'keep_rebalancer'].includes(v)) return 'good';
-          if (['observe', 'wait', 'warn'].includes(v)) return 'info';
-          if (['suspend_grid', 'replace_with_exposure_protector', 'replace_with_trend_follower'].includes(v)) return 'warn';
+          if (['active', 'running', 'keep_grid', 'keep_trend', 'keep_rebalancer', 'hold_trend'].includes(v)) return 'good';
+          if (['wait', 'hold'].includes(v)) return 'info';
           return 'neutral';
         }
         function formatLocalTime(value) {
@@ -1072,7 +961,7 @@ def changes():
             for (let i = 1; i < sorted.length; i++) {
               const prev = sorted[i - 1];
               const cur = sorted[i];
-              const fields = ['mode', 'action', 'target_strategy', 'reason_summary'];
+              const fields = ['requires_action', 'action', 'target_strategy', 'reason_summary'];
               const diffs = fields.filter(f => String(cur?.[f] ?? '') !== String(prev?.[f] ?? ''));
               if (!diffs.length) continue;
               out.push({ cur, prev, diffs });

+ 36 - 1324
src/hermes_mcp/decision_engine.py

@@ -20,7 +20,6 @@ from typing import Any
 
 @dataclass(frozen=True)
 class DecisionSnapshot:
-    mode: str
     action: str
     target_strategy: str | None
     reason_summary: str
@@ -42,13 +41,13 @@ def _safe_float(value: Any) -> float | None:
         return None
 
 
-def _decision_profile_config(decision_profile: dict[str, Any] | None) -> dict[str, Any]:
-    if not isinstance(decision_profile, dict):
+def _playbook_parameters_config(playbook_parameters: dict[str, Any] | None) -> dict[str, Any]:
+    if not isinstance(playbook_parameters, dict):
         return {}
-    config = decision_profile.get("config")
+    config = playbook_parameters.get("config")
     if isinstance(config, dict):
         return config
-    raw = decision_profile.get("config_json")
+    raw = playbook_parameters.get("config_json")
     if isinstance(raw, str) and raw.strip():
         try:
             parsed = json.loads(raw)
@@ -56,7 +55,7 @@ def _decision_profile_config(decision_profile: dict[str, Any] | None) -> dict[st
                 return parsed
         except Exception:
             return {}
-    return {}
+    return dict(playbook_parameters)
 
 
 def _inventory_state_label(value: Any) -> str:
@@ -407,1337 +406,50 @@ def _parse_timestamp(value: Any) -> datetime | 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 {}
-    breakout_pressure = narrative.get("grid_breakout_pressure") if isinstance(narrative.get("grid_breakout_pressure"), dict) else {}
-    breakout_phase = str(breakout_pressure.get("phase") or "none")
-    continuation = float(opportunity_map.get("continuation") or 0.0)
-    mean_reversion = float(opportunity_map.get("mean_reversion") or 0.0)
-    reversal = float(opportunity_map.get("reversal") or 0.0)
-    wait = float(opportunity_map.get("wait") or 0.0)
-    inventory_state = _inventory_state_label(wallet_state.get("inventory_state"))
-    argus_context = _argus_decision_context(narrative)
-
-    strategy_type = strategy["strategy_type"]
-    supervision = strategy.get("supervision") if isinstance(strategy.get("supervision"), dict) else {}
-    inventory_pressure = str(supervision.get("inventory_pressure") or "")
-    capacity_available = bool(supervision.get("capacity_available"))
-    side_capacity = supervision.get("side_capacity") if isinstance(supervision.get("side_capacity"), dict) else {}
-    score = 0.0
-    reasons: list[str] = []
-    blocks: list[str] = []
-
-    if strategy_type == "grid_trader":
-        score += mean_reversion * 1.8
-        if stance in {"neutral_rotational", "breakout_watch"}:
-            score += 0.45
-            reasons.append("narrative still supports rotational structure")
-        if continuation >= 0.45:
-            score -= 0.8
-            blocks.append("continuation pressure is too strong for safe grid harvesting")
-        if inventory_state != "balanced":
-            score -= 1.0
-            blocks.append(f"wallet is not grid-ready: {inventory_state}")
-        else:
-            reasons.append("wallet is balanced enough for two-sided harvesting")
-        if not capacity_available:
-            score -= 0.25
-            blocks.append("grid report shows one-sided capacity")
-        if side_capacity and not (bool(side_capacity.get("buy", True)) and bool(side_capacity.get("sell", True))):
-            score -= 0.25
-            blocks.append("grid side capacity is asymmetric")
-        if argus_context["compression_active"]:
-            score += 0.2
-            reasons.append("Argus compression supports staying selective with grid")
-    elif strategy_type == "trend_follower":
-        score += continuation * 1.9
-        trade_side = _strategy_trade_side(strategy)
-        narrative_direction = _narrative_direction(narrative)
-        if stance in {"constructive_bullish", "cautious_bullish", "constructive_bearish", "cautious_bearish"}:
-            score += 0.5
-            reasons.append("narrative supports directional continuation")
-        if trade_side == "buy":
-            if narrative_direction == "bullish":
-                score += 0.6
-                reasons.append("buy-side trend instance matches bullish direction")
-            elif narrative_direction == "bearish":
-                score -= 0.9
-                blocks.append("buy-side trend instance conflicts with bearish direction")
-        elif trade_side == "sell":
-            if narrative_direction == "bearish":
-                score += 0.6
-                reasons.append("sell-side trend instance matches bearish direction")
-            elif narrative_direction == "bullish":
-                score -= 0.9
-                blocks.append("sell-side trend instance conflicts with bullish direction")
-        if breakout_phase == "confirmed":
-            score += 0.45
-            reasons.append("confirmed breakout pressure supports directional continuation")
-        elif breakout_phase == "developing":
-            score += 0.2
-            reasons.append("breakout pressure is developing in trend's favor")
-        if wait >= 0.45 and breakout_phase != "confirmed":
-            score -= 0.35
-            blocks.append("market still has too much wait/uncertainty for trend commitment")
-        if inventory_state in SEVERE_INVENTORY_STATES:
-            score -= 0.25
-            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")
-        if not capacity_available:
-            score -= 0.1
-            blocks.append("trend strength is below its own capacity threshold")
-        if trade_side == "both" and narrative_direction in {"bullish", "bearish"}:
-            score += 0.15
-            reasons.append("generic trend instance can follow either side")
-        if argus_context["compression_active"] and breakout_phase != "confirmed":
-            score -= 0.15
-            blocks.append("Argus compression says the broader tape is still range-like")
-    elif strategy_type == "exposure_protector":
-        score += reversal * 0.4 + wait * 0.5
-        if wallet_state.get("rebalance_needed"):
-            score += 1.1
-            reasons.append("wallet imbalance calls for rebalancing protection")
-        if inventory_state in SEVERE_INVENTORY_STATES:
-            score += 0.45
-            reasons.append("inventory drift is high enough to justify defensive action")
-        if stance in {"constructive_bullish", "constructive_bearish"} and continuation > 0.65:
-            score -= 0.2
-        if inventory_pressure in {"critical", "elevated"}:
-            score += 0.25
-            reasons.append("protector reports active inventory pressure")
-
-    if strategy.get("last_error"):
-        score -= 0.25
-        blocks.append("strategy recently reported an error")
-    if bool(supervision.get("degraded")):
-        score -= 0.15
-        blocks.append("strategy self-reports degraded supervision state")
-
-    return {
-        "strategy_id": strategy.get("id"),
-        "strategy_type": strategy_type,
-        "score": round(score, 4),
-        "reasons": reasons,
-        "blocks": blocks,
-        "enabled": strategy.get("enabled", False),
-    }
-
-
-def _breakout_phase_from_score(score: float) -> str:
-    if score >= 3.45:
-        return "confirmed"
-    if score >= 2.45:
-        return "developing"
-    if score >= 1.4:
-        return "probing"
-    return "none"
-
-
-def _local_breakout_snapshot(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"}
-
-    score = 0.0
-    if micro_directional:
-        score += 1.0
-    if meso_directional:
-        score += 1.1
-    if macro_supportive:
-        score += 0.55
-    if alignment == "micro_meso_macro_aligned":
-        score += 0.8
-    elif alignment == "partial_alignment":
-        score += 0.35
-    if friction == "low":
-        score += 0.45
-    elif friction == "medium":
-        score += 0.15
-
-    return {
-        "score": round(score, 4),
-        "phase": _breakout_phase_from_score(score),
-        "micro_impulse": micro_impulse,
-        "micro_bias": micro_bias,
-        "meso_structure": meso_structure,
-        "meso_bias": meso_bias,
-        "macro_bias": macro_bias,
-        "alignment": alignment,
-        "friction": friction,
-    }
+    from .playbooks.grid_trend_rebalancer import score_strategy_fit as grid_score_strategy_fit
 
+    return grid_score_strategy_fit(strategy=strategy, narrative=narrative, wallet_state=wallet_state)
 
-def _breakout_memory(narrative_payload: dict[str, Any], history_window: dict[str, Any] | None, current_breakout: dict[str, Any]) -> dict[str, Any]:
-    recent_states = history_window.get("recent_states") if isinstance(history_window, dict) and isinstance(history_window.get("recent_states"), list) else []
-    window_seconds = int(history_window.get("window_seconds") or 0) if isinstance(history_window, dict) else 0
-    current_ts = _parse_timestamp(narrative_payload.get("generated_at")) or datetime.now(timezone.utc)
-    current_direction = str(current_breakout.get("meso_bias") or "neutral")
-    directional = current_direction in {"bullish", "bearish"} and current_breakout.get("meso_structure") == "trend_continuation"
-    if not directional:
-        return {"window_seconds": window_seconds, "samples_considered": 0, "qualifying_samples": 0, "same_direction_seconds": 0, "promoted_to_confirmed": False}
 
-    qualifying_samples = 0
-    oldest_match: datetime | None = None
-    for row in recent_states:
-        if not isinstance(row, dict):
-            continue
-        try:
-            payload = json.loads(row.get("payload_json") or "{}")
-        except Exception:
-            continue
-        snapshot = _local_breakout_snapshot(payload)
-        sample_ts = _parse_timestamp(row.get("created_at") or payload.get("generated_at"))
-        if sample_ts is None:
-            continue
-        if snapshot.get("phase") not in {"developing", "confirmed"}:
-            continue
-        if str(snapshot.get("meso_bias") or "neutral") != current_direction:
-            continue
-        if str(snapshot.get("macro_bias") or "mixed") != str(current_breakout.get("macro_bias") or "mixed"):
-            continue
-        qualifying_samples += 1
-        if oldest_match is None:
-            oldest_match = sample_ts
-
-    same_direction_seconds = int((current_ts - oldest_match).total_seconds()) if oldest_match else 0
-    promoted = current_breakout.get("phase") == "developing" and qualifying_samples >= 2 and same_direction_seconds >= min(window_seconds, 8 * 60)
-    return {
-        "window_seconds": window_seconds,
-        "samples_considered": len(recent_states),
-        "qualifying_samples": qualifying_samples,
-        "same_direction_seconds": max(0, same_direction_seconds),
-        "promoted_to_confirmed": promoted,
-    }
-
-
-def _grid_breakout_pressure(narrative_payload: dict[str, Any], history_window: dict[str, Any] | None = None) -> dict[str, Any]:
-    argus_context = _argus_decision_context(narrative_payload)
-    breakout = _local_breakout_snapshot(narrative_payload)
-    memory = _breakout_memory(narrative_payload, history_window, breakout)
-    phase = str(breakout.get("phase") or "none")
-    if memory["promoted_to_confirmed"]:
-        phase = "confirmed"
-    persistent = phase == "confirmed"
-
-    return {
-        "persistent": persistent,
-        "phase": phase,
-        "score": breakout["score"],
-        "micro_impulse": breakout["micro_impulse"],
-        "micro_bias": breakout["micro_bias"],
-        "meso_structure": breakout["meso_structure"],
-        "meso_bias": breakout["meso_bias"],
-        "macro_bias": breakout["macro_bias"],
-        "alignment": breakout["alignment"],
-        "friction": breakout["friction"],
-        "time_window_memory": memory,
-        "argus_regime": argus_context["regime"],
-        "argus_confidence": argus_context["confidence"],
-        "argus_compression_active": argus_context["compression_active"],
-    }
-
-
-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:
-    inventory_state = _inventory_state_label(inventory_state)
-    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 {}
-    short_term_dislocated = _short_term_trend_dislocated(narrative_payload)
-    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")
-    micro_reversal_risk = str(micro.get("reversal_risk") or "low")
-    meso_bias = str(meso.get("momentum_bias") or "neutral")
-    meso_structure = str(meso.get("structure") or "rotation")
-    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"
-    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 = (
-        bullish_inventory_pressure
-        and 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"})
-    )
-    bearish_cooling = (
-        bearish_inventory_pressure
-        and 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"})
-    )
-    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_sell_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
-    )
-    near_buy_fill = bool(
-        next_buy_distance_pct is not None
-        and next_buy_distance_pct >= 0
-        and next_buy_distance_pct <= threshold_pct
-        and next_sell is not None
-    )
-    near_fill_side: str | None = None
-    if near_sell_fill and near_buy_fill:
-        near_fill_side = "sell" if (next_sell_distance_pct or 0.0) <= (next_buy_distance_pct or 0.0) else "buy"
-    elif near_sell_fill:
-        near_fill_side = "sell"
-    elif near_buy_fill:
-        near_fill_side = "buy"
-    return {
-        "near_fill": bool(near_sell_fill or near_buy_fill),
-        "near_fill_side": near_fill_side,
-        "near_sell_fill": near_sell_fill,
-        "near_buy_fill": near_buy_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 _grid_fill_fights_breakout(grid_fill: dict[str, Any], breakout: dict[str, Any]) -> bool:
-    """Whether a nearby grid fill is trading against the breakout move.
-
-    Current product requirement: grid proximity-to-fills should not block or trigger a handoff.
-    We only care about overall regime/tradeoff (fees vs staying), not which side happens to fill.
-    """
-    return False
-
-
-def _recent_1m_price_trace(history_window: dict[str, Any] | None) -> list[tuple[datetime, float]]:
-    recent_states = history_window.get("recent_states") if isinstance(history_window, dict) and isinstance(history_window.get("recent_states"), list) else []
-    trace: list[tuple[datetime, float]] = []
-    for row in recent_states:
-        if not isinstance(row, dict):
-            continue
-        try:
-            payload = json.loads(row.get("payload_json") or "{}")
-        except Exception:
-            continue
-        features = payload.get("features_by_timeframe") if isinstance(payload.get("features_by_timeframe"), dict) else {}
-        micro = features.get("1m") if isinstance(features.get("1m"), dict) else {}
-        raw = micro.get("raw") if isinstance(micro.get("raw"), dict) else {}
-        price = _safe_float(raw.get("price"))
-        if price is None:
-            continue
-        timestamp = _parse_timestamp(row.get("created_at") or payload.get("generated_at"))
-        if timestamp is None:
-            continue
-        trace.append((timestamp, price))
-    trace.sort(key=lambda item: item[0])
-    return trace
-
-
-def _breakout_direction(breakout: dict[str, Any], stance: str | None = None) -> str | None:
-    meso_bias = str(breakout.get("meso_bias") or "")
-    micro_bias = str(breakout.get("micro_bias") or "")
-    if meso_bias in {"bullish", "bearish"}:
-        return meso_bias
-    if micro_bias in {"bullish", "bearish"}:
-        return micro_bias
-    stance_text = str(stance or "")
-    if "bullish" in stance_text:
-        return "bullish"
-    if "bearish" in stance_text:
-        return "bearish"
-    return None
-
-
-def _narrative_direction(narrative: dict[str, Any]) -> str | None:
-    stance = str(narrative.get("stance") or "")
-    breakout = narrative.get("grid_breakout_pressure") if isinstance(narrative.get("grid_breakout_pressure"), dict) else {}
-    direction = _breakout_direction(breakout, stance)
-    if direction:
-        return direction
-    if stance in {"constructive_bullish", "cautious_bullish", "fragile_bullish"}:
-        return "bullish"
-    if stance in {"constructive_bearish", "cautious_bearish", "fragile_bearish"}:
-        return "bearish"
-    return None
-
-
-def _direction_label_from_score(score: float, bullish_threshold: float = 0.18) -> str:
-    if score >= bullish_threshold:
-        return "bullish"
-    if score <= -bullish_threshold:
-        return "bearish"
-    return "mixed"
-
-
-def _extract_decision_signals(*,
+def make_decision(
+    *,
+    concern: dict[str, Any],
     narrative_payload: dict[str, Any],
     wallet_state: dict[str, Any],
-    grid_strategy: dict[str, Any] | None = None,
-    breakout: dict[str, Any] | None = None,
+    strategies: list[dict[str, Any]],
     history_window: dict[str, Any] | None = None,
-    decision_profile: dict[str, Any] | None = None,
-) -> 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 {}
-    features = narrative_payload.get("features_by_timeframe") if isinstance(narrative_payload.get("features_by_timeframe"), 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 {}
-    meso = scoped.get("meso") if isinstance(scoped.get("meso"), dict) else {}
-    macro = scoped.get("macro") if isinstance(scoped.get("macro"), dict) else {}
-    micro_features = features.get("1m") if isinstance(features.get("1m"), dict) else {}
-    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)
-
-    alignment = str(cross.get("alignment") or "partial_alignment")
-    friction = str(cross.get("friction") or "medium")
-    micro_impulse = str(micro.get("impulse") or "mixed")
-    micro_bias = str(micro.get("trend_bias") or "mixed")
-    micro_location = str(micro.get("location") or embedded.get("micro_location") or "unknown")
-    micro_reversal_risk = str(micro.get("reversal_risk") or "low")
-    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")
-    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 = 0.32
-    breakout_persistence_min = _safe_float(profile_config.get("breakout_persistence_min"))
-    if breakout_persistence_min is None:
-        breakout_persistence_min = 0.65
-    grid_release_threshold = _safe_float(profile_config.get("grid_release_threshold"))
-    if grid_release_threshold is None:
-        grid_release_threshold = 0.35
-
-    structural_direction = str(embedded.get("structural_direction") or "")
-    if structural_direction not in {"bullish", "bearish"}:
-        structural_direction = meso_bias if meso_bias in {"bullish", "bearish"} else macro_bias if macro_bias in {"bullish", "bearish"} else "mixed"
-
-    structural_strength = _safe_float(embedded.get("structural_trend_strength"))
-    if structural_strength is None:
-        structural_strength = 0.0
-        if meso_structure == "trend_continuation" and meso_bias in {"bullish", "bearish"}:
-            structural_strength += 0.45
-        elif meso_structure in {"bullish_pullback", "bearish_pullback"} and meso_bias in {"bullish", "bearish"}:
-            structural_strength += 0.25
-        if macro_bias in {"bullish", "bearish"} and macro_bias == structural_direction:
-            structural_strength += 0.25
-        if alignment == "micro_meso_macro_aligned":
-            structural_strength += 0.2
-        elif alignment == "partial_alignment":
-            structural_strength += 0.1
-        if friction == "high":
-            structural_strength -= 0.18
-    structural_strength = round(_clamp(structural_strength, 0.0, 1.0), 4)
-
-    tactical_direction = str(embedded.get("tactical_direction") or "")
-    if tactical_direction not in {"bullish", "bearish", "mixed"}:
-        micro_score = 0.0
-        if micro_impulse == "up":
-            micro_score += 0.35
-        elif micro_impulse == "down":
-            micro_score -= 0.35
-        if micro_bias == "bullish":
-            micro_score += 0.45
-        elif micro_bias == "bearish":
-            micro_score -= 0.45
-        tactical_direction = _direction_label_from_score(micro_score)
-
-    tactical_strength = _safe_float(embedded.get("tactical_trend_strength"))
-    if tactical_strength is None:
-        tactical_strength = 0.0
-        if micro_impulse in {"up", "down"} and micro_bias in {"bullish", "bearish"}:
-            tactical_strength += 0.45
-        elif micro_impulse in {"up", "down"}:
-            tactical_strength += 0.2
-        if micro_location in {"near_upper_band", "near_lower_band"}:
-            tactical_strength += 0.1
-        if micro_reversal_risk == "medium":
-            tactical_strength -= 0.12
-        elif micro_reversal_risk == "high":
-            tactical_strength -= 0.25
-    tactical_strength = round(_clamp(tactical_strength, 0.0, 1.0), 4)
-
-    tactical_range_quality = _safe_float(embedded.get("tactical_range_quality"))
-    if tactical_range_quality is None:
-        tactical_range_quality = 0.0
-        if micro_impulse == "mixed":
-            tactical_range_quality += 0.35
-        if micro_bias == "mixed":
-            tactical_range_quality += 0.2
-        if micro_location in {"centered", "lower_half", "upper_half"}:
-            tactical_range_quality += 0.18
-        if friction == "high":
-            tactical_range_quality += 0.08
-        if micro_reversal_risk == "high":
-            tactical_range_quality -= 0.08
-    tactical_range_quality = round(_clamp(tactical_range_quality, 0.0, 1.0), 4)
-
-    tactical_easing = bool(embedded.get("tactical_easing"))
-    if not tactical_easing:
-        tactical_easing = bool(
-            meso_structure == "trend_continuation"
-            and meso_bias in {"bullish", "bearish"}
-            and (
-                micro_impulse == "mixed"
-                or micro_bias == "mixed"
-                or micro_reversal_risk in {"medium", "high"}
-                or micro_location == "centered"
-            )
-        )
-
-    breakout = breakout or {}
-    breakout_phase = str(breakout.get("phase") or "none")
-    breakout_persistence = 1.0 if bool(breakout.get("persistent")) else 0.65 if breakout_phase == "developing" else 0.35 if breakout_phase == "probing" else 0.0
-
-    grid_step_pct = None
-    if grid_strategy:
-        state = grid_strategy.get("state") if isinstance(grid_strategy.get("state"), dict) else {}
-        config = grid_strategy.get("config") if isinstance(grid_strategy.get("config"), dict) else {}
-        grid_step_pct = _safe_float(config.get("grid_step_pct") or state.get("grid_step_pct") or state.get("recenter_pct_live"))
-
-    atr_percent = _safe_float(embedded.get("micro_atr_percent"))
-    if atr_percent is None:
-        atr_percent = _safe_float(micro_raw.get("atr_percent"))
-    band_width_pct = _safe_float(embedded.get("micro_bollinger_width_pct"))
-    if band_width_pct is None:
-        band_width_pct = _safe_float(micro_vol.get("bollinger_width_pct"))
-    noise_pct = max(band_width_pct or 0.0, (atr_percent or 0.0) * 2.0)
-    pullback_to_grid_ratio = None
-    if grid_step_pct and grid_step_pct > 0:
-        pullback_to_grid_ratio = noise_pct / max(grid_step_pct * 100.0, 0.0001)
-
-    recent_move_pct = 0.0
-    recent_move_window_minutes = 0
-    recent_move_direction = "mixed"
-    if recent_prices:
-        current_price = _safe_float(micro_raw.get("price")) or recent_prices[-1][1]
-        first_price = recent_prices[0][1]
-        if first_price > 0:
-            recent_move_pct = ((current_price - first_price) / first_price) * 100.0
-        recent_move_window_minutes = max(0, int((recent_prices[-1][0] - recent_prices[0][0]).total_seconds() / 60.0))
-        if recent_move_pct > 0:
-            recent_move_direction = "bullish"
-        elif recent_move_pct < 0:
-            recent_move_direction = "bearish"
-    rapid_directional_pressure = bool(
-        recent_move_direction in {"bullish", "bearish"}
-        and abs(recent_move_pct) >= max(0.8, (atr_percent or 0.0) * 2.5)
-        and recent_move_window_minutes >= 10
-        and structural_direction == recent_move_direction
-        and tactical_direction == recent_move_direction
-        and macro_bias == recent_move_direction
-    )
-    if breakout and isinstance(breakout, dict):
-        rapid_directional_pressure = bool(
-            rapid_directional_pressure
-            or (
-                breakout.get("persistent")
-                and str(breakout.get("macro_bias") or "") == recent_move_direction
-                and str(breakout.get("meso_bias") or "") == recent_move_direction
-                and str(breakout.get("micro_bias") or "") == recent_move_direction
-                and abs(recent_move_pct) >= max(0.6, (atr_percent or 0.0) * 1.8)
-            )
-        )
-    rapid_downside_pressure = bool(rapid_directional_pressure and recent_move_direction == "bearish")
-
-    short_term_trend_score = _short_term_trend_manifest_score(narrative_payload, structural_direction)
-
-    harvestability_score = tactical_range_quality * 0.45
-    if pullback_to_grid_ratio is not None:
-        harvestability_score += min(pullback_to_grid_ratio, 2.0) * 0.22
-    elif atr_percent is not None:
-        harvestability_score += min((atr_percent or 0.0) / 0.5, 1.0) * 0.18
-    if tactical_easing:
-        harvestability_score += 0.18
-    if micro_location in {"centered", "lower_half", "upper_half"}:
-        harvestability_score += 0.08
-    if breakout_persistence >= 1.0 and not tactical_easing and tactical_strength >= 0.5:
-        harvestability_score -= 0.3
-    harvestability_score = round(_clamp(harvestability_score, 0.0, 1.0), 4)
-
-    inventory_state = _inventory_state_label(wallet_state.get("inventory_state"))
-    within_rebalance_tolerance = _wallet_within_rebalance_tolerance(wallet_state, 0.3)
-    if wallet_state.get("grid_ready"):
-        wallet_grid_usability = 1.0
-    elif within_rebalance_tolerance:
-        wallet_grid_usability = 0.78
-    elif inventory_state in {"base_heavy", "quote_heavy"}:
-        wallet_grid_usability = 0.42
-    elif inventory_state in SEVERE_INVENTORY_STATES:
-        wallet_grid_usability = 0.12
-    else:
-        wallet_grid_usability = 0.3
-
-    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
-    )
-    grid_harvestable_now = bool(
-        harvestability_score >= 0.48
-        and wallet_grid_usability >= 0.35
-    )
-    rebalancer_release_ready = bool(
-        within_rebalance_tolerance
-        and (
-            (
-                harvestability_score >= 0.35
-                and (tactical_easing or breakout_persistence < 1.0 or tactical_range_quality >= 0.35)
-            )
-            or (wallet_state.get("grid_ready") and breakout_persistence < 1.0)
-            or (tactical_range_quality >= grid_release_threshold and breakout_persistence < 0.75)
-        )
-    )
-
-    return {
-        "structural_direction": structural_direction,
-        "structural_trend_strength": structural_strength,
-        "tactical_direction": tactical_direction,
-        "tactical_trend_strength": tactical_strength,
-        "tactical_range_quality": tactical_range_quality,
-        "tactical_easing": tactical_easing,
-        "breakout_persistence_score": round(breakout_persistence, 4),
-        "micro_location": micro_location,
-        "micro_atr_percent": atr_percent,
-        "micro_bollinger_width_pct": band_width_pct,
-        "grid_step_pct": round(grid_step_pct, 6) if grid_step_pct is not None else None,
-        "pullback_to_grid_ratio": round(pullback_to_grid_ratio, 4) if pullback_to_grid_ratio is not None else None,
-        "grid_harvestability_score": harvestability_score,
-        "wallet_grid_usability": round(wallet_grid_usability, 4),
-        "within_rebalance_tolerance": within_rebalance_tolerance,
-        "rebalance_tolerance": 0.3,
-        "trend_following_pressure": trend_following_pressure,
-        "rapid_directional_pressure": rapid_directional_pressure,
-        "rapid_downside_pressure": rapid_downside_pressure,
-        "recent_move_pct": round(recent_move_pct, 4),
-        "recent_move_window_minutes": recent_move_window_minutes,
-        "short_term_trend_min_score": round(short_term_trend_min_score, 4),
-        "breakout_persistence_min": round(breakout_persistence_min, 4),
-        "grid_release_threshold": round(grid_release_threshold, 4),
-        "short_term_trend_score": short_term_trend_score,
-        "grid_harvestable_now": grid_harvestable_now,
-        "rebalancer_release_ready": rebalancer_release_ready,
-    }
-
-
-def _strategy_trade_side(strategy: dict[str, Any]) -> str:
-    config = strategy.get("config") if isinstance(strategy.get("config"), dict) else {}
-    state = strategy.get("state") if isinstance(strategy.get("state"), dict) else {}
-    side = str(config.get("trade_side") or state.get("trade_side") or strategy.get("trade_side") or "both").strip().lower()
-    return side if side in {"buy", "sell", "both"} else "both"
-
-
-def _trend_handoff_level_threshold(breakout: dict[str, Any]) -> float:
-    memory = breakout.get("time_window_memory") if isinstance(breakout.get("time_window_memory"), dict) else {}
-    if bool(memory.get("promoted_to_confirmed")):
-        return 2.0
-    return 2.75
-
-
-def _grid_switch_tradeoff(*,
-    current_primary: dict[str, Any],
-    wallet_state: dict[str, Any],
-    breakout: dict[str, Any],
-    grid_fill: dict[str, Any],
-    grid_pressure: dict[str, Any],
-    directional_micro_clear: bool,
-    decision_signals: dict[str, Any],
-    trend: dict[str, Any] | None,
-) -> dict[str, Any]:
-    inventory_state = _inventory_state_label(wallet_state.get("inventory_state"))
-    supervision = current_primary.get("supervision") if isinstance(current_primary.get("supervision"), dict) else {}
-    open_order_count = int(current_primary.get("open_order_count") or 0)
-    if not open_order_count:
-        state = current_primary.get("state") if isinstance(current_primary.get("state"), dict) else {}
-        open_order_count = int(state.get("open_order_count") or len(state.get("orders") or []) or 0)
-
-    adverse_side = str(supervision.get("adverse_side") or "unknown")
-    adverse_count = int(supervision.get("adverse_side_open_order_count") or 0)
-    adverse_notional = float(supervision.get("adverse_side_open_order_notional_quote") or 0.0)
-    adverse_distance = _safe_float(supervision.get("adverse_side_nearest_distance_pct"))
-    base_order_notional = 1.0
-    config = current_primary.get("config") if isinstance(current_primary.get("config"), dict) else {}
-    for candidate in (config.get("order_notional_quote"), config.get("max_order_notional_quote")):
-        candidate_value = _safe_float(candidate)
-        if candidate_value and candidate_value > base_order_notional:
-            base_order_notional = candidate_value
-
-    trend_score = float(trend.get("score") or 0.0) if trend else 0.0
-    structural_strength = float(decision_signals.get("structural_trend_strength") or 0.0)
-    tactical_strength = float(decision_signals.get("tactical_trend_strength") or 0.0)
-    harvestability_score = float(decision_signals.get("grid_harvestability_score") or 0.0)
-    breakout_score = float(breakout.get("score") or 0.0)
-    short_term_trend_score = float(decision_signals.get("short_term_trend_score") or 0.0)
-    levels = float(grid_pressure.get("levels") or 0.0)
-    near_fill = bool(grid_fill.get("near_fill"))
-    fill_fights = _grid_fill_fights_breakout(grid_fill, breakout)
-    persistent = bool(breakout.get("persistent"))
-    trend_ready = bool(decision_signals.get("trend_following_pressure")) and directional_micro_clear
-
-    stay_cost = 0.0
-    switch_benefit = 0.0
-    if persistent:
-        switch_benefit += 0.28
-    if trend_ready:
-        switch_benefit += 0.34
-    # Requirement: ignore nearby fill timing/side when estimating the stay-vs-switch tradeoff.
-    if levels >= _trend_handoff_level_threshold(breakout):
-        switch_benefit += 0.18
-    switch_benefit += structural_strength * 0.26
-    switch_benefit += tactical_strength * 0.16
-    switch_benefit += min(trend_score, 2.0) * 0.04
-    switch_benefit += min(breakout_score, 5.0) * 0.04
-    if short_term_trend_score < 0.68:
-        short_term_gap = 0.68 - short_term_trend_score
-        switch_benefit -= short_term_gap * 1.15
-        stay_cost += short_term_gap * 0.42
-
-    if adverse_side in {"buy", "sell"} and adverse_count > 0:
-        adverse_notional_ratio = adverse_notional / max(base_order_notional, 1.0)
-        switch_benefit += min(adverse_count, 8) * 0.02
-        if adverse_distance is not None and adverse_distance <= 1.25:
-            switch_benefit += 0.08
-        stay_cost += min(adverse_notional_ratio, 4.0) * 0.07
-    else:
-        adverse_notional_ratio = 0.0
-
-    if inventory_state == "balanced":
-        stay_cost += 0.06
-    elif inventory_state in {"base_heavy", "quote_heavy"}:
-        stay_cost += 0.16
-    elif inventory_state in SEVERE_INVENTORY_STATES:
-        stay_cost += 0.28
-    else:
-        stay_cost += 0.1
-    stay_cost += min(levels, 6.0) * 0.06
-    stay_cost += min(open_order_count, 8) * 0.025
-    # Requirement: ignore nearby fill timing/side when estimating the stay-vs-switch tradeoff.
-    if not persistent:
-        stay_cost += 0.12
-    if adverse_notional_ratio >= 1.0:
-        stay_cost += 0.08
-    stay_cost += harvestability_score * 0.18
-
-    margin = round(switch_benefit - stay_cost, 4)
-    should_switch = persistent and trend_ready and margin > 0.0
-    return {
-        "trend_score": round(trend_score, 4),
-        "structural_trend_strength": round(structural_strength, 4),
-        "tactical_trend_strength": round(tactical_strength, 4),
-        "grid_harvestability_score": round(harvestability_score, 4),
-        "short_term_trend_score": round(short_term_trend_score, 4),
-        "breakout_score": round(breakout_score, 4),
-        "switch_benefit": round(switch_benefit, 4),
-        "stay_cost": round(stay_cost, 4),
-        "margin": margin,
-        "should_switch": should_switch,
-        "trend_ready": trend_ready,
-        "persistent": persistent,
-        "levels": round(levels, 4),
-        "open_order_count": open_order_count,
-        "near_fill": near_fill,
-        "fill_fights": fill_fights,
-        "adverse_side": adverse_side,
-        "adverse_side_open_order_count": adverse_count,
-        "adverse_side_open_order_notional_quote": round(adverse_notional, 4),
-        "adverse_side_nearest_distance_pct": round(adverse_distance, 4) if adverse_distance is not None else None,
-        "inventory_state": inventory_state,
-    }
-
-
-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 = _inventory_state_label(wallet_state.get("inventory_state"))
-
-    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 SEVERE_INVENTORY_STATES
-
-
-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 = _inventory_state_label(wallet_state.get("inventory_state"))
-    return wallet_state.get("rebalance_needed") and inventory_state in SEVERE_INVENTORY_STATES
-
-
-def _wallet_within_rebalance_tolerance(wallet_state: dict[str, Any], tolerance: float = 0.3) -> bool:
-    imbalance = _safe_float(wallet_state.get("imbalance_score"))
-    if imbalance is None:
-        base_ratio = _safe_float(wallet_state.get("base_ratio"))
-        if base_ratio is not None:
-            imbalance = abs(base_ratio - 0.5)
-    if imbalance is None:
-        return str(wallet_state.get("inventory_state") or "").lower() == "balanced"
-    return imbalance <= tolerance
-
-
-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,
-    decision_signals: dict[str, Any],
-    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] = []
-    inventory_state = _inventory_state_label(inventory_state)
-
-    # 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"])
-    breakout_phase = str(breakout.get("phase") or "none")
-    breakout_direction = _breakout_direction(breakout, stance)
-    trend_handoff_ready = bool(
-        trend
-        and bool(decision_signals.get("trend_following_pressure"))
-        and grid_pressure.get("levels", 0.0) >= _trend_handoff_level_threshold(breakout)
-    )
-    fill_fights_breakout = _grid_fill_fights_breakout(grid_fill, breakout)
-    switch_tradeoff = _grid_switch_tradeoff(
-        current_primary=current_primary,
+    playbook_parameters: dict[str, Any] | None = None,
+    playbook_id: str | None = None,
+) -> DecisionSnapshot:
+    from .playbooks import DEFAULT_PLAYBOOK_ID, get_playbook_module
+
+    module = get_playbook_module(playbook_id or DEFAULT_PLAYBOOK_ID)
+    return module.make_decision(
+        concern=concern,
+        narrative_payload=narrative_payload,
         wallet_state=wallet_state,
-        breakout=breakout,
-        grid_fill=grid_fill,
-        grid_pressure=grid_pressure,
-        directional_micro_clear=directional_micro_clear,
-        decision_signals=decision_signals,
-        trend=trend,
-    )
-
-    rapid_directional = bool(decision_signals.get("rapid_directional_pressure"))
-    directional_pressure = breakout_direction if breakout_direction in {"bullish", "bearish"} else "mixed"
-    all_scopes_aligned = (
-        directional_pressure in {"bullish", "bearish"}
-        and str(decision_signals.get("structural_direction") or "") == directional_pressure
-        and str(decision_signals.get("tactical_direction") or "") == directional_pressure
-        and str(grid_pressure.get("direction") or "") == directional_pressure
-    )
-    repair_inventory_match = bool(
-        (directional_pressure == "bullish" and inventory_state in {"quote_heavy", "critically_unbalanced"})
-        or (directional_pressure == "bearish" and inventory_state in {"base_heavy", "critically_unbalanced"})
-    )
-    urgent_rebalance_exit = bool(
-        rebalance
-        and wallet_state.get("rebalance_needed")
-        and rapid_directional
-        and all_scopes_aligned
-        and repair_inventory_match
-    )
-
-    if urgent_rebalance_exit:
-        action = "replace_with_exposure_protector"
-        target_strategy = rebalance["strategy_id"]
-        mode = "act"
-        reasons.append("wallet is skewed and the directional move is accelerating, so exposure repair should happen before the trend handoff")
-        reasons.append(
-            f"recent 1m history moved {decision_signals.get('recent_move_pct', 0.0):.2f}% over about {decision_signals.get('recent_move_window_minutes', 0)} minutes"
-        )
-        return action, mode, target_strategy, reasons, blocks
-
-    urgent_trend_exit = bool(
-        trend
-        and persistent_breakout
-        and bool(decision_signals.get("trend_following_pressure"))
-        and all_scopes_aligned
-        and (
-            rapid_directional
-            or grid_fill.get("near_fill")
-            or inventory_state in SEVERE_INVENTORY_STATES
-        )
+        strategies=strategies,
+        history_window=history_window,
+        playbook_parameters=playbook_parameters,
     )
 
-    if urgent_trend_exit:
-        action = "replace_with_trend_follower"
-        target_strategy = trend["strategy_id"] if trend else target_strategy
-        mode = "act"
-        reasons.append("all scopes line up and the tape is moving fast, so grid should yield early")
-        if rapid_directional:
-            reasons.append(
-                f"recent 1m history moved {decision_signals.get('recent_move_pct', 0.0):.2f}% over about {decision_signals.get('recent_move_window_minutes', 0)} minutes"
-            )
-        if grid_pressure.get("levels", 0.0) < _trend_handoff_level_threshold(breakout):
-            reasons.append("handoff is happening early, before the normal level threshold, because directional acceleration is sharp")
-        if grid_fill.get("near_fill"):
-            reasons.append("grid fill pressure is already near the market")
-        return action, mode, target_strategy, reasons, blocks
-
-    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 switch_tradeoff["should_switch"] and trend_handoff_ready 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 switch_tradeoff.get("adverse_side_open_order_count", 0) > 0:
-                reasons.append(
-                    f"{switch_tradeoff.get('adverse_side')} ladder is exposed near market"
-                )
-            if directional_inventory:
-                reasons.append("inventory posture can be absorbed by the directional handoff")
-            reasons.append(
-                f"switch benefit ({switch_tradeoff['switch_benefit']:.2f}) exceeds stay cost ({switch_tradeoff['stay_cost']:.2f})"
-            )
-        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 persistent_breakout and trend_handoff_ready and inventory_state in {"balanced", "base_heavy", "quote_heavy"}:
-        if not switch_tradeoff["should_switch"]:
-            reasons.append(
-                f"breakout is persistent, but staying in grid still looks cheaper than switching (benefit {switch_tradeoff['switch_benefit']:.2f} vs cost {switch_tradeoff['stay_cost']:.2f})"
-            )
-            if switch_tradeoff.get("adverse_side_open_order_count", 0) > 0:
-                reasons.append(
-                    f"{switch_tradeoff.get('adverse_side')} ladder exposure is not yet costly enough to justify the handoff"
-                )
-            if grid_fill.get("near_fill") and fill_fights_breakout:
-                reasons.append("nearby opposing fill is only a warning here, not enough on its own to justify the handoff")
-        else:
-            action = "replace_with_trend_follower"
-            target_strategy = trend["strategy_id"] if trend else target_strategy
-            mode = "act"
-            if grid_fill.get("near_fill") and fill_fights_breakout:
-                reasons.append("confirmed trend should not be delayed by a nearby grid fill that trades against the move")
-            elif grid_fill.get("near_fill"):
-                reasons.append("confirmed directional pressure is strong enough that nearby grid fills should not delay the trend handoff")
-            else:
-                reasons.append("grid should yield because directional pressure is confirmed and the trend handoff is ready")
-    elif not persistent_breakout and grid_can_work:
-        if breakout_phase == "developing":
-            reasons.append("breakout pressure is developing, but grid can still work and should not be abandoned yet")
-        else:
-            reasons.append("grid can still operate and self-heal, so inventory skew alone should not force a rebalance handoff")
-        if decision_signals.get("grid_harvestable_now"):
-            reasons.append("tactical range quality still looks harvestable for the grid")
-    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, but trend handoff is not ready enough yet")
-    elif not grid_friendly_stance and persistent_breakout:
-        reasons.append("grid should yield because directional pressure is persistent across scopes")
-        if trend_handoff_ready:
-            action = "replace_with_trend_follower"
-            target_strategy = trend["strategy_id"]
-            mode = "act"
-        else:
-            mode = "warn"
-            if grid_pressure.get("levels", 0.0) < _trend_handoff_level_threshold(breakout):
-                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,
+def make_playbook_decision(
+    *,
+    playbook_id: str | None,
+    concern: dict[str, Any],
     narrative_payload: dict[str, Any],
     wallet_state: dict[str, Any],
-    grid: dict[str, Any] | None,
-    rebalance: 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] = []
-
-    # 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)
-    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")
-        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")
-        else:
-            mode = "warn"
-            blocks.append("edge cooling is visible but the wallet is not yet ready for grid")
-    elif stance == "neutral_rotational":
-        if wallet_state.get("rebalance_needed") and rebalance:
-            action = "replace_with_exposure_protector"
-            target_strategy = rebalance["strategy_id"]
-            mode = "act"
-            reasons.append("trend conditions have cooled and rebalancing should repair the wallet before grid resumes")
-        elif grid and wallet_state.get("grid_ready"):
-            action = "replace_with_grid"
-            target_strategy = grid["strategy_id"]
-            mode = "act"
-            reasons.append("trend conditions have cooled and wallet is grid-ready again")
-        elif wallet_state.get("rebalance_needed"):
-            mode = "warn"
-            blocks.append("trend has cooled but rebalancing should be the next hop")
-        else:
-            action = "hold_trend"
-            blocks.append("grid candidate not strong enough yet")
-    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,
-    decision_signals: dict[str, Any],
-    trend: dict[str, Any] | None = None,
-    decision_profile: dict[str, Any] | None = 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.
-    trend_strength = float(trend["score"]) if trend and isinstance(trend.get("score"), (int, float)) else 0.0
-    within_tolerance = bool(decision_signals.get("within_rebalance_tolerance"))
-    release_ready = bool(decision_signals.get("rebalancer_release_ready"))
-    trend_pressure = bool(decision_signals.get("trend_following_pressure"))
-    grid_harvestable_now = bool(decision_signals.get("grid_harvestable_now"))
-    profile_config = _decision_profile_config(decision_profile)
-    force_grid_when_balanced = bool(profile_config.get("force_grid_when_balanced", True))
-    hold_rebalancer_until_cooldown = bool(profile_config.get("hold_rebalancer_until_cooldown", False))
-
-    if wallet_state.get("grid_ready") and grid and force_grid_when_balanced and not hold_rebalancer_until_cooldown:
-        action = "replace_with_grid"
-        target_strategy = grid["strategy_id"]
-        mode = "act"
-        reasons.append("wallet is rebalanced, so grid should resume first and let the tape prove itself again")
-    elif trend_pressure and not release_ready:
-        blocks.append("trend is still strong enough that rebalancer should keep repairing instead of resetting to grid")
-    elif release_ready:
-        if grid:
-            action = "replace_with_grid"
-            target_strategy = grid["strategy_id"]
-            mode = "act"
-            reasons.append("wallet is usable enough and micro conditions are easing, so grid can resume harvesting")
-        else:
-            blocks.append("wallet is within the rebalance tolerance but no grid candidate is available")
-    elif within_tolerance and not grid_harvestable_now:
-        blocks.append("wallet is close enough, but the local tape is still not harvestable enough for grid release")
-    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_harvestable_now:
-        action = "replace_with_grid"
-        target_strategy = grid["strategy_id"]
-        mode = "act"
-        reasons.append("local price action looks harvestable enough that grid can resume before perfect balance")
-    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]], history_window: dict[str, Any] | None = None, decision_profile: dict[str, Any] | None = None) -> DecisionSnapshot:
-    concern_account_id = str(concern.get("account_id") or "")
-    concern_market_symbol = str(concern.get("market_symbol") or "").strip().lower()
-    normalized = [
-        normalize_strategy_snapshot(s)
-        for s in strategies
-        if str(s.get("account_id") or "") == concern_account_id
-        and (
-            not concern_market_symbol
-            or not str(s.get("market_symbol") or "").strip()
-            or str(s.get("market_symbol") or "").strip().lower() == concern_market_symbol
-        )
-    ]
-    breakout = _grid_breakout_pressure(narrative_payload, history_window=history_window)
-    narrative_for_scoring = {**narrative_payload, "grid_breakout_pressure": breakout}
-    fit_reports = [score_strategy_fit(strategy=s, narrative=narrative_for_scoring, wallet_state=wallet_state) for s in normalized]
-    ranked = sorted(fit_reports, key=lambda item: item["score"], reverse=True)
-    current_primary = _select_current_primary(normalized)
-    best = ranked[0] if ranked else None
-    stance = str(narrative_payload.get("stance") or "neutral_rotational")
-    inventory_state = _inventory_state_label(wallet_state.get("inventory_state"))
-    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 {}
-    micro_impulse = str(micro.get("impulse") or "mixed")
-    micro_bias = str(micro.get("trend_bias") or "mixed")
-    micro_reversal_risk = str(micro.get("reversal_risk") or "low")
-    bullish_micro_clear = micro_impulse == "up" and micro_bias == "bullish" and micro_reversal_risk != "high"
-    bearish_micro_clear = micro_impulse == "down" and micro_bias == "bearish" and micro_reversal_risk != "high"
-    breakout_direction = _breakout_direction(breakout, stance)
-    directional_micro_clear = bullish_micro_clear if breakout_direction == "bullish" else bearish_micro_clear if breakout_direction == "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 SEVERE_INVENTORY_STATES
-
-    action = "hold"
-    mode = "observe"
-    target_strategy = current_primary.get("id") if current_primary else (best.get("strategy_id") if best else None)
-    reasons: list[str] = []
-    blocks: list[str] = []
-    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)
-    grid_strategy = next((s for s in normalized if s["strategy_type"] == "grid_trader"), None)
-    decision_signals = _extract_decision_signals(
+    strategies: list[dict[str, Any]],
+    history_window: dict[str, Any] | None = None,
+    playbook_parameters: dict[str, Any] | None = None,
+) -> DecisionSnapshot:
+    return make_decision(
+        playbook_id=playbook_id,
+        concern=concern,
         narrative_payload=narrative_payload,
         wallet_state=wallet_state,
-        grid_strategy=grid_strategy,
-        breakout=breakout,
+        strategies=strategies,
         history_window=history_window,
-        decision_profile=decision_profile,
-    )
-    switch_tradeoff: dict[str, Any] = {}
-
-    if current_primary and current_primary["strategy_type"] == "grid_trader":
-        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,
-            decision_signals=decision_signals,
-            trend=trend,
-            rebalance=rebalance,
-        )
-        switch_tradeoff = _grid_switch_tradeoff(
-            current_primary=current_primary,
-            wallet_state=wallet_state,
-            breakout=breakout,
-            grid_fill=grid_fill,
-            grid_pressure=grid_pressure,
-            directional_micro_clear=directional_micro_clear,
-            decision_signals=decision_signals,
-            trend=trend,
-        )
-    elif current_primary and current_primary["strategy_type"] == "trend_follower":
-        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,
-            rebalance=rebalance,
-        )
-    elif current_primary and current_primary["strategy_type"] == "exposure_protector":
-        action, mode, target_strategy, reasons, blocks = _decide_for_rebalancer(
-            current_primary=current_primary,
-            stance=stance,
-            wallet_state=wallet_state,
-            grid=grid,
-            decision_signals=decision_signals,
-            trend=trend,
-            decision_profile=decision_profile,
-        )
-    else:
-        if best and best["score"] >= 0.55:
-            action = f"enable_{best['strategy_type']}"
-            target_strategy = best["strategy_id"]
-            mode = "act"
-            reasons.extend(best["reasons"])
-        else:
-            action = "wait"
-            mode = "observe"
-            blocks.append("no strategy is yet a strong enough fit")
-
-    reason_summary = reasons[0] if reasons else (blocks[0] if blocks else "strategy posture unchanged")
-    confidence = float(narrative_payload.get("confidence") or 0.4)
-    if action.startswith("replace_with") or action.startswith("enable_"):
-        confidence += 0.08
-    if wallet_state.get("rebalance_needed") and "grid" in action:
-        confidence -= 0.08
-    confidence = round(_clamp(confidence, 0.2, 0.95), 3)
-
-    payload = {
-        "generated_at": datetime.now(timezone.utc).isoformat(),
-        "wallet_state": wallet_state,
-        "narrative_stance": stance,
-        "strategy_fit_ranking": ranked,
-        "current_primary_strategy": current_primary.get("id") if current_primary else None,
-        "argus_decision_context": _argus_decision_context(narrative_payload),
-        "history_window": history_window or {},
-        "grid_breakout_pressure": breakout,
-        "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,
-        "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,
-            "status": decision_profile.get("status") if isinstance(decision_profile, dict) else None,
-            "config": _decision_profile_config(decision_profile),
-        } if decision_profile else None,
-        "reason_chain": reasons,
-        "blocks": blocks,
-        "decision_version": 3,
-    }
-
-    return DecisionSnapshot(
-        mode=mode,
-        action=action,
-        target_strategy=target_strategy,
-        reason_summary=reason_summary,
-        confidence=confidence,
-        requires_action=mode == "act",
-        payload=payload,
+        playbook_parameters=playbook_parameters,
     )

+ 0 - 3
src/hermes_mcp/decision_families/__init__.py

@@ -1,3 +0,0 @@
-from .dispatch import make_family_decision
-
-__all__ = ["make_family_decision"]

+ 0 - 30
src/hermes_mcp/decision_families/dispatch.py

@@ -1,30 +0,0 @@
-from __future__ import annotations
-
-from typing import Any
-
-from . import grid_trend_rebalancer, trend_only
-
-
-def _normalize_family(value: str | None) -> str:
-    return str(value or "").strip().lower()
-
-
-def make_family_decision(*, family: str | None, concern: dict[str, Any], narrative_payload: dict[str, Any], wallet_state: dict[str, Any], strategies: list[dict[str, Any]], history_window: dict[str, Any] | None = None, decision_profile: dict[str, Any] | None = None):
-    normalized = _normalize_family(family)
-    if normalized in trend_only.FAMILY_ALIASES:
-        return trend_only.make_decision(
-            concern=concern,
-            narrative_payload=narrative_payload,
-            wallet_state=wallet_state,
-            strategies=strategies,
-            history_window=history_window,
-            decision_profile=decision_profile,
-        )
-    return grid_trend_rebalancer.make_decision(
-        concern=concern,
-        narrative_payload=narrative_payload,
-        wallet_state=wallet_state,
-        strategies=strategies,
-        history_window=history_window,
-        decision_profile=decision_profile,
-    )

+ 0 - 19
src/hermes_mcp/decision_families/grid_trend_rebalancer.py

@@ -1,19 +0,0 @@
-from __future__ import annotations
-
-from typing import Any
-
-from ..decision_engine import make_decision as make_core_decision
-
-FAMILY_ID = "grid-trend-rebalancer"
-FAMILY_ALIASES = {"mixed", "grid-trend-rebalancer", "grid_trend_rebalancer", "default", ""}
-
-
-def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any], wallet_state: dict[str, Any], strategies: list[dict[str, Any]], history_window: dict[str, Any] | None = None, decision_profile: dict[str, Any] | None = None):
-    return make_core_decision(
-        concern=concern,
-        narrative_payload=narrative_payload,
-        wallet_state=wallet_state,
-        strategies=strategies,
-        history_window=history_window,
-        decision_profile=decision_profile,
-    )

+ 101 - 0
src/hermes_mcp/playbooks/__init__.py

@@ -0,0 +1,101 @@
+from __future__ import annotations
+
+import json
+from functools import lru_cache
+from importlib import import_module
+from pathlib import Path
+from typing import Any
+
+DEFINITIONS_DIR = Path(__file__).resolve().parent / "definitions"
+DEFAULT_PLAYBOOK_ID = "grid-trend-rebalancer"
+
+
+def normalize_playbook_id(value: str | None) -> str:
+    return str(value or "").strip().lower()
+
+
+@lru_cache(maxsize=1)
+def _definitions() -> dict[str, dict[str, Any]]:
+    definitions: dict[str, dict[str, Any]] = {}
+    for path in sorted(DEFINITIONS_DIR.glob("*.json")):
+        try:
+            loaded = json.loads(path.read_text())
+        except Exception:
+            continue
+        if not isinstance(loaded, dict):
+            continue
+        playbook_id = normalize_playbook_id(loaded.get("id"))
+        if not playbook_id:
+            continue
+        definitions[playbook_id] = loaded
+    return definitions
+
+
+def list_playbook_definitions() -> list[dict[str, Any]]:
+    return [definition.copy() for definition in _definitions().values()]
+
+
+def get_playbook_definition(playbook_id: str | None) -> dict[str, Any]:
+    normalized = normalize_playbook_id(playbook_id)
+    definitions = _definitions()
+    if normalized in definitions:
+        return definitions[normalized].copy()
+    for definition in definitions.values():
+        aliases = {
+            normalize_playbook_id(alias)
+            for alias in definition.get("aliases", [])
+            if isinstance(alias, str)
+        }
+        if normalized in aliases:
+            return definition.copy()
+    if DEFAULT_PLAYBOOK_ID in definitions:
+        return definitions[DEFAULT_PLAYBOOK_ID].copy()
+    raise KeyError(f"unknown playbook: {playbook_id}")
+
+
+def get_playbook_module(playbook_id: str | None):
+    definition = get_playbook_definition(playbook_id)
+    module_name = str(definition.get("python_module") or "").strip()
+    if not module_name:
+        raise KeyError(f"playbook {definition.get('id')} does not declare python_module")
+    return import_module(f".{module_name}", __name__)
+
+
+def playbook_parameter_defaults(playbook_id: str | None) -> dict[str, Any]:
+    definition = get_playbook_definition(playbook_id)
+    parameters = definition.get("parameters") if isinstance(definition.get("parameters"), list) else []
+    defaults: dict[str, Any] = {}
+    for parameter in parameters:
+        if not isinstance(parameter, dict):
+            continue
+        parameter_id = normalize_playbook_id(parameter.get("id"))
+        if not parameter_id:
+            continue
+        defaults[parameter_id] = parameter.get("default")
+    return defaults
+
+
+def playbook_parameter_definitions(playbook_id: str | None) -> list[dict[str, Any]]:
+    definition = get_playbook_definition(playbook_id)
+    parameters = definition.get("parameters") if isinstance(definition.get("parameters"), list) else []
+    return [parameter.copy() for parameter in parameters if isinstance(parameter, dict)]
+
+
+def resolve_playbook_parameters(*, playbook_id: str | None, overrides: dict[str, Any] | None = None) -> dict[str, Any]:
+    defaults = playbook_parameter_defaults(playbook_id)
+    current = overrides if isinstance(overrides, dict) else {}
+    return {**defaults, **{key: value for key, value in current.items() if key in defaults}}
+
+
+def supported_playbook_parameter_ids(playbook_id: str | None) -> set[str]:
+    return set(playbook_parameter_defaults(playbook_id).keys())
+
+
+def default_playbook_id_for_strategies(strategies: list[dict[str, Any]]) -> str:
+    types = {str(strategy.get("strategy_type") or "").strip() for strategy in strategies}
+    if {"grid_trader", "trend_follower", "exposure_protector"}.issubset(types):
+        return "grid-trend-rebalancer"
+    if "trend_follower" in types and "grid_trader" not in types and "exposure_protector" not in types:
+        return "trend-only"
+    return DEFAULT_PLAYBOOK_ID
+

+ 85 - 0
src/hermes_mcp/playbooks/definitions/grid-trend-rebalancer.json

@@ -0,0 +1,85 @@
+{
+  "id": "grid-trend-rebalancer",
+  "name": "Grid Trend Rebalancer",
+  "description": "Supervises grid, directional trend, and rebalancing handoffs for one concern.",
+  "python_module": "grid_trend_rebalancer",
+  "aliases": ["mixed", "default", "grid_trend_rebalancer", ""],
+  "roles": [
+    {
+      "id": "grid",
+      "label": "Grid",
+      "strategy_types": ["grid_trader"]
+    },
+    {
+      "id": "buyer",
+      "label": "Trend Buyer",
+      "strategy_types": ["trend_follower"]
+    },
+    {
+      "id": "seller",
+      "label": "Trend Seller",
+      "strategy_types": ["trend_follower"]
+    },
+    {
+      "id": "rebalancer",
+      "label": "Rebalancer",
+      "strategy_types": ["exposure_protector"]
+    }
+  ],
+  "parameters": [
+    {
+      "id": "breakout_persistence_min",
+      "label": "Breakout persistence",
+      "type": "number",
+      "default": 0.65,
+      "min": 0.0,
+      "max": 1.0,
+      "step": 0.01,
+      "help": "Minimum persistence score before grid-to-trend handoff is allowed."
+    },
+    {
+      "id": "short_term_trend_min_score",
+      "label": "Short-term trend confirmation",
+      "type": "number",
+      "default": 0.32,
+      "min": 0.0,
+      "max": 1.0,
+      "step": 0.01,
+      "help": "Minimum 1m/5m trend manifestation score required for directional pressure."
+    },
+    {
+      "id": "rebalance_tolerance",
+      "label": "Rebalance tolerance",
+      "type": "number",
+      "default": 0.3,
+      "min": 0.05,
+      "max": 0.45,
+      "step": 0.01,
+      "help": "Maximum wallet imbalance still considered close enough for release from rebalancing."
+    },
+    {
+      "id": "grid_release_threshold",
+      "label": "Grid release threshold",
+      "type": "number",
+      "default": 0.35,
+      "min": 0.0,
+      "max": 1.0,
+      "step": 0.01,
+      "help": "Minimum harvestability needed before rebalancer hands back to grid."
+    },
+    {
+      "id": "force_grid_when_balanced",
+      "label": "Force grid when balanced",
+      "type": "boolean",
+      "default": true,
+      "help": "When true, a balanced wallet returns to grid first."
+    },
+    {
+      "id": "require_release_signal_before_grid",
+      "label": "Require release signal before grid",
+      "type": "boolean",
+      "default": false,
+      "help": "When true, balance alone is not enough for a rebalancer-to-grid handback."
+    }
+  ]
+}

+ 111 - 0
src/hermes_mcp/playbooks/definitions/trend-only.json

@@ -0,0 +1,111 @@
+{
+  "id": "trend-only",
+  "name": "Trend Only",
+  "description": "Chooses between buy-side and sell-side trend followers without grid or rebalancer roles.",
+  "python_module": "trend_only",
+  "aliases": ["trend_only", "trend"],
+  "roles": [
+    {
+      "id": "trend_buy",
+      "label": "Trend Buy",
+      "strategy_types": ["trend_follower"]
+    },
+    {
+      "id": "trend_sell",
+      "label": "Trend Sell",
+      "strategy_types": ["trend_follower"]
+    }
+  ],
+  "parameters": [
+    {
+      "id": "estimated_turn_cost_pct",
+      "label": "Estimated turn cost %",
+      "type": "number",
+      "default": 0.7,
+      "min": 0.0,
+      "max": 3.0,
+      "step": 0.01,
+      "help": "Estimated switching cost used as a fee gate."
+    },
+    {
+      "id": "micro_trend_weight",
+      "label": "Micro trend weight",
+      "type": "number",
+      "default": 0.8,
+      "min": 0.0,
+      "max": 2.0,
+      "step": 0.01,
+      "help": "Weight of short-term trend manifestation."
+    },
+    {
+      "id": "meso_trend_weight",
+      "label": "Meso trend weight",
+      "type": "number",
+      "default": 1.0,
+      "min": 0.0,
+      "max": 2.5,
+      "step": 0.01,
+      "help": "Weight of meso directional structure."
+    },
+    {
+      "id": "macro_trend_weight",
+      "label": "Macro trend weight",
+      "type": "number",
+      "default": 0.7,
+      "min": 0.0,
+      "max": 2.0,
+      "step": 0.01,
+      "help": "Weight of macro directional backdrop."
+    },
+    {
+      "id": "persistence_bonus_weight",
+      "label": "Persistence bonus weight",
+      "type": "number",
+      "default": 0.45,
+      "min": 0.0,
+      "max": 1.5,
+      "step": 0.01,
+      "help": "Weight for same-direction persistence over recent history."
+    },
+    {
+      "id": "argus_compression_penalty",
+      "label": "Argus compression penalty",
+      "type": "number",
+      "default": 0.18,
+      "min": 0.0,
+      "max": 1.0,
+      "step": 0.01,
+      "help": "Penalty applied when Argus indicates compression."
+    },
+    {
+      "id": "activation_edge_threshold",
+      "label": "Activation edge threshold",
+      "type": "number",
+      "default": 1.15,
+      "min": 0.0,
+      "max": 3.0,
+      "step": 0.01,
+      "help": "Minimum edge needed to activate a trend follower from neutral."
+    },
+    {
+      "id": "flip_edge_threshold",
+      "label": "Flip edge threshold",
+      "type": "number",
+      "default": 1.35,
+      "min": 0.0,
+      "max": 3.0,
+      "step": 0.01,
+      "help": "Minimum opposite edge needed to flip the active side."
+    },
+    {
+      "id": "flip_confirmation_gap",
+      "label": "Flip confirmation gap",
+      "type": "number",
+      "default": 0.25,
+      "min": 0.0,
+      "max": 1.5,
+      "step": 0.01,
+      "help": "Minimum score gap between the new side and current side before flipping."
+    }
+  ]
+}

+ 1274 - 0
src/hermes_mcp/playbooks/grid_trend_rebalancer.py

@@ -0,0 +1,1274 @@
+from __future__ import annotations
+
+import json
+from datetime import datetime, timezone
+from typing import Any
+
+from ..decision_engine import (
+    DecisionSnapshot,
+    REBALANCE_INVENTORY_STATES,
+    SEVERE_INVENTORY_STATES,
+    _argus_decision_context,
+    _clamp,
+    _inventory_state_label,
+    _parse_timestamp,
+    _playbook_parameters_config,
+    _safe_float,
+    _short_term_trend_dislocated,
+    _short_term_trend_manifest_score,
+    normalize_strategy_snapshot,
+)
+
+PLAYBOOK_ID = "grid-trend-rebalancer"
+
+
+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 {}
+    breakout_pressure = narrative.get("grid_breakout_pressure") if isinstance(narrative.get("grid_breakout_pressure"), dict) else {}
+    breakout_phase = str(breakout_pressure.get("phase") or "none")
+    continuation = float(opportunity_map.get("continuation") or 0.0)
+    mean_reversion = float(opportunity_map.get("mean_reversion") or 0.0)
+    reversal = float(opportunity_map.get("reversal") or 0.0)
+    wait = float(opportunity_map.get("wait") or 0.0)
+    inventory_state = _inventory_state_label(wallet_state.get("inventory_state"))
+    argus_context = _argus_decision_context(narrative)
+
+    strategy_type = strategy["strategy_type"]
+    supervision = strategy.get("supervision") if isinstance(strategy.get("supervision"), dict) else {}
+    inventory_pressure = str(supervision.get("inventory_pressure") or "")
+    capacity_available = bool(supervision.get("capacity_available"))
+    side_capacity = supervision.get("side_capacity") if isinstance(supervision.get("side_capacity"), dict) else {}
+    score = 0.0
+    reasons: list[str] = []
+    blocks: list[str] = []
+
+    if strategy_type == "grid_trader":
+        score += mean_reversion * 1.8
+        if stance in {"neutral_rotational", "breakout_watch"}:
+            score += 0.45
+            reasons.append("narrative still supports rotational structure")
+        if continuation >= 0.45:
+            score -= 0.8
+            blocks.append("continuation pressure is too strong for safe grid harvesting")
+        if inventory_state != "balanced":
+            score -= 1.0
+            blocks.append(f"wallet is not grid-ready: {inventory_state}")
+        else:
+            reasons.append("wallet is balanced enough for two-sided harvesting")
+        if not capacity_available:
+            score -= 0.25
+            blocks.append("grid report shows one-sided capacity")
+        if side_capacity and not (bool(side_capacity.get("buy", True)) and bool(side_capacity.get("sell", True))):
+            score -= 0.25
+            blocks.append("grid side capacity is asymmetric")
+        if argus_context["compression_active"]:
+            score += 0.2
+            reasons.append("Argus compression supports staying selective with grid")
+    elif strategy_type == "trend_follower":
+        score += continuation * 1.9
+        trade_side = _strategy_trade_side(strategy)
+        narrative_direction = _narrative_direction(narrative)
+        if stance in {"constructive_bullish", "cautious_bullish", "constructive_bearish", "cautious_bearish"}:
+            score += 0.5
+            reasons.append("narrative supports directional continuation")
+        if trade_side == "buy":
+            if narrative_direction == "bullish":
+                score += 0.6
+                reasons.append("buy-side trend instance matches bullish direction")
+            elif narrative_direction == "bearish":
+                score -= 0.9
+                blocks.append("buy-side trend instance conflicts with bearish direction")
+        elif trade_side == "sell":
+            if narrative_direction == "bearish":
+                score += 0.6
+                reasons.append("sell-side trend instance matches bearish direction")
+            elif narrative_direction == "bullish":
+                score -= 0.9
+                blocks.append("sell-side trend instance conflicts with bullish direction")
+        if breakout_phase == "confirmed":
+            score += 0.45
+            reasons.append("confirmed breakout pressure supports directional continuation")
+        elif breakout_phase == "developing":
+            score += 0.2
+            reasons.append("breakout pressure is developing in trend's favor")
+        if wait >= 0.45 and breakout_phase != "confirmed":
+            score -= 0.35
+            blocks.append("market still has too much wait/uncertainty for trend commitment")
+        if inventory_state in SEVERE_INVENTORY_STATES:
+            score -= 0.25
+            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")
+        if not capacity_available:
+            score -= 0.1
+            blocks.append("trend strength is below its own capacity threshold")
+        if trade_side == "both" and narrative_direction in {"bullish", "bearish"}:
+            score += 0.15
+            reasons.append("generic trend instance can follow either side")
+        if argus_context["compression_active"] and breakout_phase != "confirmed":
+            score -= 0.15
+            blocks.append("Argus compression says the broader tape is still range-like")
+    elif strategy_type == "exposure_protector":
+        score += reversal * 0.4 + wait * 0.5
+        if wallet_state.get("rebalance_needed"):
+            score += 1.1
+            reasons.append("wallet imbalance calls for rebalancing protection")
+        if inventory_state in SEVERE_INVENTORY_STATES:
+            score += 0.45
+            reasons.append("inventory drift is high enough to justify defensive action")
+        if stance in {"constructive_bullish", "constructive_bearish"} and continuation > 0.65:
+            score -= 0.2
+        if inventory_pressure in {"critical", "elevated"}:
+            score += 0.25
+            reasons.append("protector reports active inventory pressure")
+
+    if strategy.get("last_error"):
+        score -= 0.25
+        blocks.append("strategy recently reported an error")
+    if bool(supervision.get("degraded")):
+        score -= 0.15
+        blocks.append("strategy self-reports degraded supervision state")
+
+    return {
+        "strategy_id": strategy.get("id"),
+        "strategy_type": strategy_type,
+        "score": round(score, 4),
+        "reasons": reasons,
+        "blocks": blocks,
+        "enabled": strategy.get("enabled", False),
+    }
+
+
+def _breakout_phase_from_score(score: float) -> str:
+    if score >= 3.45:
+        return "confirmed"
+    if score >= 2.45:
+        return "developing"
+    if score >= 1.4:
+        return "probing"
+    return "none"
+
+
+def _local_breakout_snapshot(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"}
+
+    score = 0.0
+    if micro_directional:
+        score += 1.0
+    if meso_directional:
+        score += 1.1
+    if macro_supportive:
+        score += 0.55
+    if alignment == "micro_meso_macro_aligned":
+        score += 0.8
+    elif alignment == "partial_alignment":
+        score += 0.35
+    if friction == "low":
+        score += 0.45
+    elif friction == "medium":
+        score += 0.15
+
+    return {
+        "score": round(score, 4),
+        "phase": _breakout_phase_from_score(score),
+        "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 _breakout_memory(narrative_payload: dict[str, Any], history_window: dict[str, Any] | None, current_breakout: dict[str, Any]) -> dict[str, Any]:
+    recent_states = history_window.get("recent_states") if isinstance(history_window, dict) and isinstance(history_window.get("recent_states"), list) else []
+    window_seconds = int(history_window.get("window_seconds") or 0) if isinstance(history_window, dict) else 0
+    current_ts = _parse_timestamp(narrative_payload.get("generated_at")) or datetime.now(timezone.utc)
+    current_direction = str(current_breakout.get("meso_bias") or "neutral")
+    directional = current_direction in {"bullish", "bearish"} and current_breakout.get("meso_structure") == "trend_continuation"
+    if not directional:
+        return {"window_seconds": window_seconds, "samples_considered": 0, "qualifying_samples": 0, "same_direction_seconds": 0, "promoted_to_confirmed": False}
+
+    qualifying_samples = 0
+    oldest_match: datetime | None = None
+    for row in recent_states:
+        if not isinstance(row, dict):
+            continue
+        try:
+            payload = json.loads(row.get("payload_json") or "{}")
+        except Exception:
+            continue
+        snapshot = _local_breakout_snapshot(payload)
+        sample_ts = _parse_timestamp(row.get("created_at") or payload.get("generated_at"))
+        if sample_ts is None:
+            continue
+        if snapshot.get("phase") not in {"developing", "confirmed"}:
+            continue
+        if str(snapshot.get("meso_bias") or "neutral") != current_direction:
+            continue
+        if str(snapshot.get("macro_bias") or "mixed") != str(current_breakout.get("macro_bias") or "mixed"):
+            continue
+        qualifying_samples += 1
+        if oldest_match is None:
+            oldest_match = sample_ts
+
+    same_direction_seconds = int((current_ts - oldest_match).total_seconds()) if oldest_match else 0
+    promoted = current_breakout.get("phase") == "developing" and qualifying_samples >= 2 and same_direction_seconds >= min(window_seconds, 8 * 60)
+    return {
+        "window_seconds": window_seconds,
+        "samples_considered": len(recent_states),
+        "qualifying_samples": qualifying_samples,
+        "same_direction_seconds": max(0, same_direction_seconds),
+        "promoted_to_confirmed": promoted,
+    }
+
+
+def _grid_breakout_pressure(narrative_payload: dict[str, Any], history_window: dict[str, Any] | None = None) -> dict[str, Any]:
+    argus_context = _argus_decision_context(narrative_payload)
+    breakout = _local_breakout_snapshot(narrative_payload)
+    memory = _breakout_memory(narrative_payload, history_window, breakout)
+    phase = str(breakout.get("phase") or "none")
+    if memory["promoted_to_confirmed"]:
+        phase = "confirmed"
+    persistent = phase == "confirmed"
+
+    return {
+        "persistent": persistent,
+        "phase": phase,
+        "score": breakout["score"],
+        "micro_impulse": breakout["micro_impulse"],
+        "micro_bias": breakout["micro_bias"],
+        "meso_structure": breakout["meso_structure"],
+        "meso_bias": breakout["meso_bias"],
+        "macro_bias": breakout["macro_bias"],
+        "alignment": breakout["alignment"],
+        "friction": breakout["friction"],
+        "time_window_memory": memory,
+        "argus_regime": argus_context["regime"],
+        "argus_confidence": argus_context["confidence"],
+        "argus_compression_active": argus_context["compression_active"],
+    }
+
+
+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:
+    inventory_state = _inventory_state_label(inventory_state)
+    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 {}
+    short_term_dislocated = _short_term_trend_dislocated(narrative_payload)
+    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")
+    micro_reversal_risk = str(micro.get("reversal_risk") or "low")
+    meso_bias = str(meso.get("momentum_bias") or "neutral")
+    meso_structure = str(meso.get("structure") or "rotation")
+    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"
+    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 = (
+        bullish_inventory_pressure
+        and 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"})
+    )
+    bearish_cooling = (
+        bearish_inventory_pressure
+        and 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"})
+    )
+    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_sell_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
+    )
+    near_buy_fill = bool(
+        next_buy_distance_pct is not None
+        and next_buy_distance_pct >= 0
+        and next_buy_distance_pct <= threshold_pct
+        and next_sell is not None
+    )
+    near_fill_side: str | None = None
+    if near_sell_fill and near_buy_fill:
+        near_fill_side = "sell" if (next_sell_distance_pct or 0.0) <= (next_buy_distance_pct or 0.0) else "buy"
+    elif near_sell_fill:
+        near_fill_side = "sell"
+    elif near_buy_fill:
+        near_fill_side = "buy"
+    return {
+        "near_fill": bool(near_sell_fill or near_buy_fill),
+        "near_fill_side": near_fill_side,
+        "near_sell_fill": near_sell_fill,
+        "near_buy_fill": near_buy_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 _grid_fill_fights_breakout(grid_fill: dict[str, Any], breakout: dict[str, Any]) -> bool:
+    return False
+
+
+def _recent_1m_price_trace(history_window: dict[str, Any] | None) -> list[tuple[datetime, float]]:
+    recent_states = history_window.get("recent_states") if isinstance(history_window, dict) and isinstance(history_window.get("recent_states"), list) else []
+    trace: list[tuple[datetime, float]] = []
+    for row in recent_states:
+        if not isinstance(row, dict):
+            continue
+        try:
+            payload = json.loads(row.get("payload_json") or "{}")
+        except Exception:
+            continue
+        features = payload.get("features_by_timeframe") if isinstance(payload.get("features_by_timeframe"), dict) else {}
+        micro = features.get("1m") if isinstance(features.get("1m"), dict) else {}
+        raw = micro.get("raw") if isinstance(micro.get("raw"), dict) else {}
+        price = _safe_float(raw.get("price"))
+        if price is None:
+            continue
+        timestamp = _parse_timestamp(row.get("created_at") or payload.get("generated_at"))
+        if timestamp is None:
+            continue
+        trace.append((timestamp, price))
+    trace.sort(key=lambda item: item[0])
+    return trace
+
+
+def _breakout_direction(breakout: dict[str, Any], stance: str | None = None) -> str | None:
+    meso_bias = str(breakout.get("meso_bias") or "")
+    micro_bias = str(breakout.get("micro_bias") or "")
+    if meso_bias in {"bullish", "bearish"}:
+        return meso_bias
+    if micro_bias in {"bullish", "bearish"}:
+        return micro_bias
+    stance_text = str(stance or "")
+    if "bullish" in stance_text:
+        return "bullish"
+    if "bearish" in stance_text:
+        return "bearish"
+    return None
+
+
+def _narrative_direction(narrative: dict[str, Any]) -> str | None:
+    stance = str(narrative.get("stance") or "")
+    breakout = narrative.get("grid_breakout_pressure") if isinstance(narrative.get("grid_breakout_pressure"), dict) else {}
+    direction = _breakout_direction(breakout, stance)
+    if direction:
+        return direction
+    if stance in {"constructive_bullish", "cautious_bullish", "fragile_bullish"}:
+        return "bullish"
+    if stance in {"constructive_bearish", "cautious_bearish", "fragile_bearish"}:
+        return "bearish"
+    return None
+
+
+def _direction_label_from_score(score: float, bullish_threshold: float = 0.18) -> str:
+    if score >= bullish_threshold:
+        return "bullish"
+    if score <= -bullish_threshold:
+        return "bearish"
+    return "mixed"
+
+
+def _extract_decision_signals(*, narrative_payload: dict[str, Any], wallet_state: dict[str, Any], grid_strategy: dict[str, Any] | None = None, breakout: dict[str, Any] | None = None, history_window: dict[str, Any] | None = None, playbook_parameters: dict[str, Any] | None = None) -> 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 {}
+    features = narrative_payload.get("features_by_timeframe") if isinstance(narrative_payload.get("features_by_timeframe"), 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 {}
+    meso = scoped.get("meso") if isinstance(scoped.get("meso"), dict) else {}
+    macro = scoped.get("macro") if isinstance(scoped.get("macro"), dict) else {}
+    micro_features = features.get("1m") if isinstance(features.get("1m"), dict) else {}
+    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)
+
+    alignment = str(cross.get("alignment") or "partial_alignment")
+    friction = str(cross.get("friction") or "medium")
+    micro_impulse = str(micro.get("impulse") or "mixed")
+    micro_bias = str(micro.get("trend_bias") or "mixed")
+    micro_location = str(micro.get("location") or embedded.get("micro_location") or "unknown")
+    micro_reversal_risk = str(micro.get("reversal_risk") or "low")
+    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")
+    profile_config = _playbook_parameters_config(playbook_parameters)
+    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 = 0.32
+    breakout_persistence_min = _safe_float(profile_config.get("breakout_persistence_min"))
+    if breakout_persistence_min is None:
+        breakout_persistence_min = 0.65
+    grid_release_threshold = _safe_float(profile_config.get("grid_release_threshold"))
+    if grid_release_threshold is None:
+        grid_release_threshold = 0.35
+
+    structural_direction = str(embedded.get("structural_direction") or "")
+    if structural_direction not in {"bullish", "bearish"}:
+        structural_direction = meso_bias if meso_bias in {"bullish", "bearish"} else macro_bias if macro_bias in {"bullish", "bearish"} else "mixed"
+
+    structural_strength = _safe_float(embedded.get("structural_trend_strength"))
+    if structural_strength is None:
+        structural_strength = 0.0
+        if meso_structure == "trend_continuation" and meso_bias in {"bullish", "bearish"}:
+            structural_strength += 0.45
+        elif meso_structure in {"bullish_pullback", "bearish_pullback"} and meso_bias in {"bullish", "bearish"}:
+            structural_strength += 0.25
+        if macro_bias in {"bullish", "bearish"} and macro_bias == structural_direction:
+            structural_strength += 0.25
+        if alignment == "micro_meso_macro_aligned":
+            structural_strength += 0.2
+        elif alignment == "partial_alignment":
+            structural_strength += 0.1
+        if friction == "high":
+            structural_strength -= 0.18
+    structural_strength = round(_clamp(structural_strength, 0.0, 1.0), 4)
+
+    tactical_direction = str(embedded.get("tactical_direction") or "")
+    if tactical_direction not in {"bullish", "bearish", "mixed"}:
+        micro_score = 0.0
+        if micro_impulse == "up":
+            micro_score += 0.35
+        elif micro_impulse == "down":
+            micro_score -= 0.35
+        if micro_bias == "bullish":
+            micro_score += 0.45
+        elif micro_bias == "bearish":
+            micro_score -= 0.45
+        tactical_direction = _direction_label_from_score(micro_score)
+
+    tactical_strength = _safe_float(embedded.get("tactical_trend_strength"))
+    if tactical_strength is None:
+        tactical_strength = 0.0
+        if micro_impulse in {"up", "down"} and micro_bias in {"bullish", "bearish"}:
+            tactical_strength += 0.45
+        elif micro_impulse in {"up", "down"}:
+            tactical_strength += 0.2
+        if micro_location in {"near_upper_band", "near_lower_band"}:
+            tactical_strength += 0.1
+        if micro_reversal_risk == "medium":
+            tactical_strength -= 0.12
+        elif micro_reversal_risk == "high":
+            tactical_strength -= 0.25
+    tactical_strength = round(_clamp(tactical_strength, 0.0, 1.0), 4)
+
+    tactical_range_quality = _safe_float(embedded.get("tactical_range_quality"))
+    if tactical_range_quality is None:
+        tactical_range_quality = 0.0
+        if micro_impulse == "mixed":
+            tactical_range_quality += 0.35
+        if micro_bias == "mixed":
+            tactical_range_quality += 0.2
+        if micro_location in {"centered", "lower_half", "upper_half"}:
+            tactical_range_quality += 0.18
+        if friction == "high":
+            tactical_range_quality += 0.08
+        if micro_reversal_risk == "high":
+            tactical_range_quality -= 0.08
+    tactical_range_quality = round(_clamp(tactical_range_quality, 0.0, 1.0), 4)
+
+    tactical_easing = bool(embedded.get("tactical_easing"))
+    if not tactical_easing:
+        tactical_easing = bool(
+            meso_structure == "trend_continuation"
+            and meso_bias in {"bullish", "bearish"}
+            and (
+                micro_impulse == "mixed"
+                or micro_bias == "mixed"
+                or micro_reversal_risk in {"medium", "high"}
+                or micro_location == "centered"
+            )
+        )
+
+    breakout = breakout or {}
+    breakout_phase = str(breakout.get("phase") or "none")
+    breakout_persistence = 1.0 if bool(breakout.get("persistent")) else 0.65 if breakout_phase == "developing" else 0.35 if breakout_phase == "probing" else 0.0
+
+    grid_step_pct = None
+    if grid_strategy:
+        state = grid_strategy.get("state") if isinstance(grid_strategy.get("state"), dict) else {}
+        config = grid_strategy.get("config") if isinstance(grid_strategy.get("config"), dict) else {}
+        grid_step_pct = _safe_float(config.get("grid_step_pct") or state.get("grid_step_pct") or state.get("recenter_pct_live"))
+
+    atr_percent = _safe_float(embedded.get("micro_atr_percent"))
+    if atr_percent is None:
+        atr_percent = _safe_float(micro_raw.get("atr_percent"))
+    band_width_pct = _safe_float(embedded.get("micro_bollinger_width_pct"))
+    if band_width_pct is None:
+        band_width_pct = _safe_float(micro_vol.get("bollinger_width_pct"))
+    noise_pct = max(band_width_pct or 0.0, (atr_percent or 0.0) * 2.0)
+    pullback_to_grid_ratio = None
+    if grid_step_pct and grid_step_pct > 0:
+        pullback_to_grid_ratio = noise_pct / max(grid_step_pct * 100.0, 0.0001)
+
+    recent_move_pct = 0.0
+    recent_move_window_minutes = 0
+    recent_move_direction = "mixed"
+    if recent_prices:
+        current_price = _safe_float(micro_raw.get("price")) or recent_prices[-1][1]
+        first_price = recent_prices[0][1]
+        if first_price > 0:
+            recent_move_pct = ((current_price - first_price) / first_price) * 100.0
+        recent_move_window_minutes = max(0, int((recent_prices[-1][0] - recent_prices[0][0]).total_seconds() / 60.0))
+        if recent_move_pct > 0:
+            recent_move_direction = "bullish"
+        elif recent_move_pct < 0:
+            recent_move_direction = "bearish"
+    rapid_directional_pressure = bool(
+        recent_move_direction in {"bullish", "bearish"}
+        and abs(recent_move_pct) >= max(0.8, (atr_percent or 0.0) * 2.5)
+        and recent_move_window_minutes >= 10
+        and structural_direction == recent_move_direction
+        and tactical_direction == recent_move_direction
+        and macro_bias == recent_move_direction
+    )
+    if breakout and isinstance(breakout, dict):
+        rapid_directional_pressure = bool(
+            rapid_directional_pressure
+            or (
+                breakout.get("persistent")
+                and str(breakout.get("macro_bias") or "") == recent_move_direction
+                and str(breakout.get("meso_bias") or "") == recent_move_direction
+                and str(breakout.get("micro_bias") or "") == recent_move_direction
+                and abs(recent_move_pct) >= max(0.6, (atr_percent or 0.0) * 1.8)
+            )
+        )
+    rapid_downside_pressure = bool(rapid_directional_pressure and recent_move_direction == "bearish")
+
+    short_term_trend_score = _short_term_trend_manifest_score(narrative_payload, structural_direction)
+
+    harvestability_score = tactical_range_quality * 0.45
+    if pullback_to_grid_ratio is not None:
+        harvestability_score += min(pullback_to_grid_ratio, 2.0) * 0.22
+    elif atr_percent is not None:
+        harvestability_score += min((atr_percent or 0.0) / 0.5, 1.0) * 0.18
+    if tactical_easing:
+        harvestability_score += 0.18
+    if micro_location in {"centered", "lower_half", "upper_half"}:
+        harvestability_score += 0.08
+    if breakout_persistence >= 1.0 and not tactical_easing and tactical_strength >= 0.5:
+        harvestability_score -= 0.3
+    harvestability_score = round(_clamp(harvestability_score, 0.0, 1.0), 4)
+
+    inventory_state = _inventory_state_label(wallet_state.get("inventory_state"))
+    rebalance_tolerance = _safe_float(profile_config.get("rebalance_tolerance"))
+    if rebalance_tolerance is None:
+        rebalance_tolerance = 0.3
+    within_rebalance_tolerance = _wallet_within_rebalance_tolerance(wallet_state, rebalance_tolerance)
+    if wallet_state.get("grid_ready"):
+        wallet_grid_usability = 1.0
+    elif within_rebalance_tolerance:
+        wallet_grid_usability = 0.78
+    elif inventory_state in {"base_heavy", "quote_heavy"}:
+        wallet_grid_usability = 0.42
+    elif inventory_state in SEVERE_INVENTORY_STATES:
+        wallet_grid_usability = 0.12
+    else:
+        wallet_grid_usability = 0.3
+
+    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
+    )
+    grid_harvestable_now = bool(harvestability_score >= 0.48 and wallet_grid_usability >= 0.35)
+    rebalancer_release_ready = bool(
+        within_rebalance_tolerance
+        and (
+            (
+                harvestability_score >= 0.35
+                and (tactical_easing or breakout_persistence < 1.0 or tactical_range_quality >= 0.35)
+            )
+            or (wallet_state.get("grid_ready") and breakout_persistence < 1.0)
+            or (tactical_range_quality >= grid_release_threshold and breakout_persistence < 0.75)
+        )
+    )
+
+    return {
+        "structural_direction": structural_direction,
+        "structural_trend_strength": structural_strength,
+        "tactical_direction": tactical_direction,
+        "tactical_trend_strength": tactical_strength,
+        "tactical_range_quality": tactical_range_quality,
+        "tactical_easing": tactical_easing,
+        "breakout_persistence_score": round(breakout_persistence, 4),
+        "micro_location": micro_location,
+        "micro_atr_percent": atr_percent,
+        "micro_bollinger_width_pct": band_width_pct,
+        "grid_step_pct": round(grid_step_pct, 6) if grid_step_pct is not None else None,
+        "pullback_to_grid_ratio": round(pullback_to_grid_ratio, 4) if pullback_to_grid_ratio is not None else None,
+        "grid_harvestability_score": harvestability_score,
+        "wallet_grid_usability": round(wallet_grid_usability, 4),
+        "within_rebalance_tolerance": within_rebalance_tolerance,
+        "rebalance_tolerance": round(rebalance_tolerance, 4),
+        "trend_following_pressure": trend_following_pressure,
+        "rapid_directional_pressure": rapid_directional_pressure,
+        "rapid_downside_pressure": rapid_downside_pressure,
+        "recent_move_pct": round(recent_move_pct, 4),
+        "recent_move_window_minutes": recent_move_window_minutes,
+        "short_term_trend_min_score": round(short_term_trend_min_score, 4),
+        "breakout_persistence_min": round(breakout_persistence_min, 4),
+        "grid_release_threshold": round(grid_release_threshold, 4),
+        "short_term_trend_score": short_term_trend_score,
+        "grid_harvestable_now": grid_harvestable_now,
+        "rebalancer_release_ready": rebalancer_release_ready,
+    }
+
+
+def _strategy_trade_side(strategy: dict[str, Any]) -> str:
+    config = strategy.get("config") if isinstance(strategy.get("config"), dict) else {}
+    state = strategy.get("state") if isinstance(strategy.get("state"), dict) else {}
+    side = str(config.get("trade_side") or state.get("trade_side") or strategy.get("trade_side") or "both").strip().lower()
+    return side if side in {"buy", "sell", "both"} else "both"
+
+
+def _trend_handoff_level_threshold(breakout: dict[str, Any]) -> float:
+    memory = breakout.get("time_window_memory") if isinstance(breakout.get("time_window_memory"), dict) else {}
+    if bool(memory.get("promoted_to_confirmed")):
+        return 2.0
+    return 2.75
+
+
+def _grid_switch_tradeoff(*, current_primary: dict[str, Any], wallet_state: dict[str, Any], breakout: dict[str, Any], grid_fill: dict[str, Any], grid_pressure: dict[str, Any], directional_micro_clear: bool, decision_signals: dict[str, Any], trend: dict[str, Any] | None) -> dict[str, Any]:
+    inventory_state = _inventory_state_label(wallet_state.get("inventory_state"))
+    supervision = current_primary.get("supervision") if isinstance(current_primary.get("supervision"), dict) else {}
+    open_order_count = int(current_primary.get("open_order_count") or 0)
+    if not open_order_count:
+        state = current_primary.get("state") if isinstance(current_primary.get("state"), dict) else {}
+        open_order_count = int(state.get("open_order_count") or len(state.get("orders") or []) or 0)
+
+    adverse_side = str(supervision.get("adverse_side") or "unknown")
+    adverse_count = int(supervision.get("adverse_side_open_order_count") or 0)
+    adverse_notional = float(supervision.get("adverse_side_open_order_notional_quote") or 0.0)
+    adverse_distance = _safe_float(supervision.get("adverse_side_nearest_distance_pct"))
+    base_order_notional = 1.0
+    config = current_primary.get("config") if isinstance(current_primary.get("config"), dict) else {}
+    for candidate in (config.get("order_notional_quote"), config.get("max_order_notional_quote")):
+        candidate_value = _safe_float(candidate)
+        if candidate_value and candidate_value > base_order_notional:
+            base_order_notional = candidate_value
+
+    trend_score = float(trend.get("score") or 0.0) if trend else 0.0
+    structural_strength = float(decision_signals.get("structural_trend_strength") or 0.0)
+    tactical_strength = float(decision_signals.get("tactical_trend_strength") or 0.0)
+    harvestability_score = float(decision_signals.get("grid_harvestability_score") or 0.0)
+    breakout_score = float(breakout.get("score") or 0.0)
+    short_term_trend_score = float(decision_signals.get("short_term_trend_score") or 0.0)
+    levels = float(grid_pressure.get("levels") or 0.0)
+    near_fill = bool(grid_fill.get("near_fill"))
+    fill_fights = _grid_fill_fights_breakout(grid_fill, breakout)
+    persistent = bool(breakout.get("persistent"))
+    trend_ready = bool(decision_signals.get("trend_following_pressure")) and directional_micro_clear
+
+    stay_cost = 0.0
+    switch_benefit = 0.0
+    if persistent:
+        switch_benefit += 0.28
+    if trend_ready:
+        switch_benefit += 0.34
+    if levels >= _trend_handoff_level_threshold(breakout):
+        switch_benefit += 0.18
+    switch_benefit += structural_strength * 0.26
+    switch_benefit += tactical_strength * 0.16
+    switch_benefit += min(trend_score, 2.0) * 0.04
+    switch_benefit += min(breakout_score, 5.0) * 0.04
+    if short_term_trend_score < 0.68:
+        short_term_gap = 0.68 - short_term_trend_score
+        switch_benefit -= short_term_gap * 1.15
+        stay_cost += short_term_gap * 0.42
+
+    if adverse_side in {"buy", "sell"} and adverse_count > 0:
+        adverse_notional_ratio = adverse_notional / max(base_order_notional, 1.0)
+        switch_benefit += min(adverse_count, 8) * 0.02
+        if adverse_distance is not None and adverse_distance <= 1.25:
+            switch_benefit += 0.08
+        stay_cost += min(adverse_notional_ratio, 4.0) * 0.07
+    else:
+        adverse_notional_ratio = 0.0
+
+    if inventory_state == "balanced":
+        stay_cost += 0.06
+    elif inventory_state in {"base_heavy", "quote_heavy"}:
+        stay_cost += 0.16
+    elif inventory_state in SEVERE_INVENTORY_STATES:
+        stay_cost += 0.28
+    else:
+        stay_cost += 0.1
+    stay_cost += min(levels, 6.0) * 0.06
+    stay_cost += min(open_order_count, 8) * 0.025
+    if not persistent:
+        stay_cost += 0.12
+    if adverse_notional_ratio >= 1.0:
+        stay_cost += 0.08
+    stay_cost += harvestability_score * 0.18
+
+    margin = round(switch_benefit - stay_cost, 4)
+    should_switch = persistent and trend_ready and margin > 0.0
+    return {
+        "trend_score": round(trend_score, 4),
+        "structural_trend_strength": round(structural_strength, 4),
+        "tactical_trend_strength": round(tactical_strength, 4),
+        "grid_harvestability_score": round(harvestability_score, 4),
+        "short_term_trend_score": round(short_term_trend_score, 4),
+        "breakout_score": round(breakout_score, 4),
+        "switch_benefit": round(switch_benefit, 4),
+        "stay_cost": round(stay_cost, 4),
+        "margin": margin,
+        "should_switch": should_switch,
+        "trend_ready": trend_ready,
+        "persistent": persistent,
+        "levels": round(levels, 4),
+        "open_order_count": open_order_count,
+        "near_fill": near_fill,
+        "fill_fights": fill_fights,
+        "adverse_side": adverse_side,
+        "adverse_side_open_order_count": adverse_count,
+        "adverse_side_open_order_notional_quote": round(adverse_notional, 4),
+        "adverse_side_nearest_distance_pct": round(adverse_distance, 4) if adverse_distance is not None else None,
+        "inventory_state": inventory_state,
+    }
+
+
+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 = _inventory_state_label(wallet_state.get("inventory_state"))
+
+    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 SEVERE_INVENTORY_STATES
+
+
+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 = _inventory_state_label(wallet_state.get("inventory_state"))
+    return wallet_state.get("rebalance_needed") and inventory_state in SEVERE_INVENTORY_STATES
+
+
+def _wallet_within_rebalance_tolerance(wallet_state: dict[str, Any], tolerance: float = 0.3) -> bool:
+    imbalance = _safe_float(wallet_state.get("imbalance_score"))
+    if imbalance is None:
+        base_ratio = _safe_float(wallet_state.get("base_ratio"))
+        if base_ratio is not None:
+            imbalance = abs(base_ratio - 0.5)
+    if imbalance is None:
+        return str(wallet_state.get("inventory_state") or "").lower() == "balanced"
+    return imbalance <= tolerance
+
+
+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, decision_signals: dict[str, Any], trend: dict[str, Any] | None, rebalance: dict[str, Any] | None) -> tuple[str, bool, str | None, list[str], list[str]]:
+    action = "keep_grid"
+    requires_action = False
+    target_strategy = current_primary["id"]
+    reasons: list[str] = []
+    blocks: list[str] = []
+    inventory_state = _inventory_state_label(inventory_state)
+
+    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"])
+    breakout_phase = str(breakout.get("phase") or "none")
+    breakout_direction = _breakout_direction(breakout, stance)
+    trend_handoff_ready = bool(
+        trend
+        and bool(decision_signals.get("trend_following_pressure"))
+        and grid_pressure.get("levels", 0.0) >= _trend_handoff_level_threshold(breakout)
+    )
+    fill_fights_breakout = _grid_fill_fights_breakout(grid_fill, breakout)
+    switch_tradeoff = _grid_switch_tradeoff(
+        current_primary=current_primary,
+        wallet_state=wallet_state,
+        breakout=breakout,
+        grid_fill=grid_fill,
+        grid_pressure=grid_pressure,
+        directional_micro_clear=directional_micro_clear,
+        decision_signals=decision_signals,
+        trend=trend,
+    )
+
+    rapid_directional = bool(decision_signals.get("rapid_directional_pressure"))
+    directional_pressure = breakout_direction if breakout_direction in {"bullish", "bearish"} else "mixed"
+    all_scopes_aligned = (
+        directional_pressure in {"bullish", "bearish"}
+        and str(decision_signals.get("structural_direction") or "") == directional_pressure
+        and str(decision_signals.get("tactical_direction") or "") == directional_pressure
+        and str(grid_pressure.get("direction") or "") == directional_pressure
+    )
+    repair_inventory_match = bool(
+        (directional_pressure == "bullish" and inventory_state in {"quote_heavy", "critically_unbalanced"})
+        or (directional_pressure == "bearish" and inventory_state in {"base_heavy", "critically_unbalanced"})
+    )
+    urgent_rebalance_exit = bool(
+        rebalance
+        and wallet_state.get("rebalance_needed")
+        and rapid_directional
+        and all_scopes_aligned
+        and repair_inventory_match
+    )
+
+    if urgent_rebalance_exit:
+        action = "replace_with_exposure_protector"
+        target_strategy = rebalance["strategy_id"]
+        requires_action = True
+        reasons.append("wallet is skewed and the directional move is accelerating, so exposure repair should happen before the trend handoff")
+        reasons.append(f"recent 1m history moved {decision_signals.get('recent_move_pct', 0.0):.2f}% over about {decision_signals.get('recent_move_window_minutes', 0)} minutes")
+        return action, requires_action, target_strategy, reasons, blocks
+
+    urgent_trend_exit = bool(
+        trend
+        and persistent_breakout
+        and bool(decision_signals.get("trend_following_pressure"))
+        and all_scopes_aligned
+        and (rapid_directional or grid_fill.get("near_fill") or inventory_state in SEVERE_INVENTORY_STATES)
+    )
+
+    if urgent_trend_exit:
+        action = "replace_with_trend_follower"
+        target_strategy = trend["strategy_id"] if trend else target_strategy
+        requires_action = True
+        reasons.append("all scopes line up and the tape is moving fast, so grid should yield early")
+        if rapid_directional:
+            reasons.append(f"recent 1m history moved {decision_signals.get('recent_move_pct', 0.0):.2f}% over about {decision_signals.get('recent_move_window_minutes', 0)} minutes")
+        if grid_pressure.get("levels", 0.0) < _trend_handoff_level_threshold(breakout):
+            reasons.append("handoff is happening early, before the normal level threshold, because directional acceleration is sharp")
+        if grid_fill.get("near_fill"):
+            reasons.append("grid fill pressure is already near the market")
+        return action, requires_action, target_strategy, reasons, blocks
+
+    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 switch_tradeoff["should_switch"] and trend_handoff_ready 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"]
+            requires_action = True
+            if switch_tradeoff.get("adverse_side_open_order_count", 0) > 0:
+                reasons.append(f"{switch_tradeoff.get('adverse_side')} ladder is exposed near market")
+            if directional_inventory:
+                reasons.append("inventory posture can be absorbed by the directional handoff")
+            reasons.append(f"switch benefit ({switch_tradeoff['switch_benefit']:.2f}) exceeds stay cost ({switch_tradeoff['stay_cost']:.2f})")
+        elif wallet_state.get("rebalance_needed") and rebalance and rebalance["score"] > 0.35:
+            action = "replace_with_exposure_protector"
+            target_strategy = rebalance["strategy_id"]
+            requires_action = True
+        else:
+            action = "deactivate_current_strategy"
+            requires_action = True
+            target_strategy = current_primary["id"]
+    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"]
+        requires_action = True
+        reasons.append("grid has lost practical recovery capacity, so inventory repair should take over")
+    elif persistent_breakout and trend_handoff_ready and inventory_state in {"balanced", "base_heavy", "quote_heavy"}:
+        if not switch_tradeoff["should_switch"]:
+            reasons.append(f"breakout is persistent, but staying in grid still looks cheaper than switching (benefit {switch_tradeoff['switch_benefit']:.2f} vs cost {switch_tradeoff['stay_cost']:.2f})")
+            if switch_tradeoff.get("adverse_side_open_order_count", 0) > 0:
+                reasons.append(f"{switch_tradeoff.get('adverse_side')} ladder exposure is not yet costly enough to justify the handoff")
+            if grid_fill.get("near_fill") and fill_fights_breakout:
+                reasons.append("nearby opposing fill is only a warning here, not enough on its own to justify the handoff")
+        else:
+            action = "replace_with_trend_follower"
+            target_strategy = trend["strategy_id"] if trend else target_strategy
+            requires_action = True
+            if grid_fill.get("near_fill") and fill_fights_breakout:
+                reasons.append("confirmed trend should not be delayed by a nearby grid fill that trades against the move")
+            elif grid_fill.get("near_fill"):
+                reasons.append("confirmed directional pressure is strong enough that nearby grid fills should not delay the trend handoff")
+            else:
+                reasons.append("grid should yield because directional pressure is confirmed and the trend handoff is ready")
+    elif not persistent_breakout and grid_can_work:
+        if breakout_phase == "developing":
+            reasons.append("breakout pressure is developing, but grid can still work and should not be abandoned yet")
+        else:
+            reasons.append("grid can still operate and self-heal, so inventory skew alone should not force a rebalance handoff")
+        if decision_signals.get("grid_harvestable_now"):
+            reasons.append("tactical range quality still looks harvestable for the grid")
+    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, but trend handoff is not ready enough yet")
+    elif not grid_friendly_stance and persistent_breakout:
+        reasons.append("grid should yield because directional pressure is persistent across scopes")
+        if trend_handoff_ready:
+            action = "replace_with_trend_follower"
+            target_strategy = trend["strategy_id"]
+            requires_action = True
+        else:
+            if grid_pressure.get("levels", 0.0) < _trend_handoff_level_threshold(breakout):
+                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, requires_action, 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, rebalance: dict[str, Any] | None = None) -> tuple[str, bool, str | None, list[str], list[str]]:
+    action = "keep_trend"
+    requires_action = False
+    target_strategy = current_primary["id"]
+    reasons: list[str] = []
+    blocks: list[str] = []
+
+    cooling = _trend_cooling_edge(narrative_payload, wallet_state)
+    if cooling:
+        if wallet_state.get("rebalance_needed") and rebalance:
+            action = "replace_with_exposure_protector"
+            target_strategy = rebalance["strategy_id"]
+            requires_action = True
+            reasons.append("trend has cooled and rebalancing should repair the wallet before grid resumes")
+        elif grid and wallet_state.get("grid_ready"):
+            action = "replace_with_grid"
+            target_strategy = grid["strategy_id"]
+            requires_action = True
+            reasons.append("trend has cooled and grid can resume because no rebalancer is available")
+        else:
+            blocks.append("edge cooling is visible but the wallet is not yet ready for grid")
+    elif stance == "neutral_rotational":
+        if wallet_state.get("rebalance_needed") and rebalance:
+            action = "replace_with_exposure_protector"
+            target_strategy = rebalance["strategy_id"]
+            requires_action = True
+            reasons.append("trend conditions have cooled and rebalancing should repair the wallet before grid resumes")
+        elif grid and wallet_state.get("grid_ready"):
+            action = "replace_with_grid"
+            target_strategy = grid["strategy_id"]
+            requires_action = True
+            reasons.append("trend conditions have cooled and wallet is grid-ready again")
+        elif wallet_state.get("rebalance_needed"):
+            blocks.append("trend has cooled but rebalancing should be the next hop")
+        else:
+            action = "hold_trend"
+            blocks.append("grid candidate not strong enough yet")
+    else:
+        reasons.append("trend strategy still fits the directional narrative")
+
+    return action, requires_action, 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, decision_signals: dict[str, Any], trend: dict[str, Any] | None = None, playbook_parameters: dict[str, Any] | None = None) -> tuple[str, bool, str | None, list[str], list[str]]:
+    action = "keep_rebalancer"
+    requires_action = False
+    target_strategy = current_primary["id"]
+    reasons: list[str] = []
+    blocks: list[str] = []
+
+    within_tolerance = bool(decision_signals.get("within_rebalance_tolerance"))
+    release_ready = bool(decision_signals.get("rebalancer_release_ready"))
+    trend_pressure = bool(decision_signals.get("trend_following_pressure"))
+    grid_harvestable_now = bool(decision_signals.get("grid_harvestable_now"))
+    profile_config = _playbook_parameters_config(playbook_parameters)
+    force_grid_when_balanced = bool(profile_config.get("force_grid_when_balanced", True))
+    require_release_signal_before_grid = bool(profile_config.get("require_release_signal_before_grid", False))
+
+    if wallet_state.get("grid_ready") and grid and force_grid_when_balanced and not require_release_signal_before_grid:
+        action = "replace_with_grid"
+        target_strategy = grid["strategy_id"]
+        requires_action = True
+        reasons.append("wallet is rebalanced, so grid should resume first and let the tape prove itself again")
+    elif trend_pressure and not release_ready:
+        blocks.append("trend is still strong enough that rebalancer should keep repairing instead of resetting to grid")
+    elif release_ready:
+        if grid:
+            action = "replace_with_grid"
+            target_strategy = grid["strategy_id"]
+            requires_action = True
+            reasons.append("wallet is usable enough and micro conditions are easing, so grid can resume harvesting")
+        else:
+            blocks.append("wallet is within the rebalance tolerance but no grid candidate is available")
+    elif within_tolerance and not grid_harvestable_now:
+        blocks.append("wallet is close enough, but the local tape is still not harvestable enough for grid release")
+    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"]
+            requires_action = True
+            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_harvestable_now:
+        action = "replace_with_grid"
+        target_strategy = grid["strategy_id"]
+        requires_action = True
+        reasons.append("local price action looks harvestable enough that grid can resume before perfect balance")
+    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, requires_action, 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]], history_window: dict[str, Any] | None = None, playbook_parameters: dict[str, Any] | None = None) -> DecisionSnapshot:
+    concern_account_id = str(concern.get("account_id") or "")
+    concern_market_symbol = str(concern.get("market_symbol") or "").strip().lower()
+    normalized = [
+        normalize_strategy_snapshot(strategy)
+        for strategy in strategies
+        if str(strategy.get("account_id") or "") == concern_account_id
+        and (
+            not concern_market_symbol
+            or not str(strategy.get("market_symbol") or "").strip()
+            or str(strategy.get("market_symbol") or "").strip().lower() == concern_market_symbol
+        )
+    ]
+    breakout = _grid_breakout_pressure(narrative_payload, history_window=history_window)
+    narrative_for_scoring = {**narrative_payload, "grid_breakout_pressure": breakout}
+    fit_reports = [score_strategy_fit(strategy=strategy, narrative=narrative_for_scoring, wallet_state=wallet_state) for strategy in normalized]
+    ranked = sorted(fit_reports, key=lambda item: item["score"], reverse=True)
+    current_primary = _select_current_primary(normalized)
+    best = ranked[0] if ranked else None
+    stance = str(narrative_payload.get("stance") or "neutral_rotational")
+    inventory_state = _inventory_state_label(wallet_state.get("inventory_state"))
+    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 {}
+    micro_impulse = str(micro.get("impulse") or "mixed")
+    micro_bias = str(micro.get("trend_bias") or "mixed")
+    micro_reversal_risk = str(micro.get("reversal_risk") or "low")
+    bullish_micro_clear = micro_impulse == "up" and micro_bias == "bullish" and micro_reversal_risk != "high"
+    bearish_micro_clear = micro_impulse == "down" and micro_bias == "bearish" and micro_reversal_risk != "high"
+    breakout_direction = _breakout_direction(breakout, stance)
+    directional_micro_clear = bullish_micro_clear if breakout_direction == "bullish" else bearish_micro_clear if breakout_direction == "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 SEVERE_INVENTORY_STATES
+
+    action = "hold"
+    requires_action = False
+    target_strategy = current_primary.get("id") if current_primary else (best.get("strategy_id") if best else None)
+    reasons: list[str] = []
+    blocks: list[str] = []
+    trend = next((row for row in ranked if row["strategy_type"] == "trend_follower"), None)
+    rebalance = next((row for row in ranked if row["strategy_type"] == "exposure_protector"), None)
+    grid = next((row for row in ranked if row["strategy_type"] == "grid_trader"), None)
+    grid_strategy = next((strategy for strategy in normalized if strategy["strategy_type"] == "grid_trader"), None)
+    decision_signals = _extract_decision_signals(
+        narrative_payload=narrative_payload,
+        wallet_state=wallet_state,
+        grid_strategy=grid_strategy,
+        breakout=breakout,
+        history_window=history_window,
+        playbook_parameters=playbook_parameters,
+    )
+    switch_tradeoff: dict[str, Any] = {}
+
+    if current_primary and current_primary["strategy_type"] == "grid_trader":
+        action, requires_action, 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,
+            decision_signals=decision_signals,
+            trend=trend,
+            rebalance=rebalance,
+        )
+        switch_tradeoff = _grid_switch_tradeoff(
+            current_primary=current_primary,
+            wallet_state=wallet_state,
+            breakout=breakout,
+            grid_fill=grid_fill,
+            grid_pressure=grid_pressure,
+            directional_micro_clear=directional_micro_clear,
+            decision_signals=decision_signals,
+            trend=trend,
+        )
+    elif current_primary and current_primary["strategy_type"] == "trend_follower":
+        action, requires_action, target_strategy, reasons, blocks = _decide_for_trend(
+            current_primary=current_primary,
+            stance=stance,
+            narrative_payload=narrative_payload,
+            wallet_state=wallet_state,
+            grid=grid,
+            rebalance=rebalance,
+        )
+    elif current_primary and current_primary["strategy_type"] == "exposure_protector":
+        action, requires_action, target_strategy, reasons, blocks = _decide_for_rebalancer(
+            current_primary=current_primary,
+            stance=stance,
+            wallet_state=wallet_state,
+            grid=grid,
+            decision_signals=decision_signals,
+            trend=trend,
+            playbook_parameters=playbook_parameters,
+        )
+    else:
+        if best and best["score"] >= 0.55:
+            action = f"enable_{best['strategy_type']}"
+            target_strategy = best["strategy_id"]
+            requires_action = True
+            reasons.extend(best["reasons"])
+        else:
+            action = "wait"
+            blocks.append("no strategy is yet a strong enough fit")
+
+    reason_summary = reasons[0] if reasons else (blocks[0] if blocks else "strategy posture unchanged")
+    confidence = float(narrative_payload.get("confidence") or 0.4)
+    if action.startswith("replace_with") or action.startswith("enable_"):
+        confidence += 0.08
+    if wallet_state.get("rebalance_needed") and "grid" in action:
+        confidence -= 0.08
+    confidence = round(_clamp(confidence, 0.2, 0.95), 3)
+
+    payload = {
+        "generated_at": datetime.now(timezone.utc).isoformat(),
+        "wallet_state": wallet_state,
+        "narrative_stance": stance,
+        "strategy_fit_ranking": ranked,
+        "current_primary_strategy": current_primary.get("id") if current_primary else None,
+        "argus_decision_context": _argus_decision_context(narrative_payload),
+        "history_window": history_window or {},
+        "grid_breakout_pressure": breakout,
+        "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,
+        "playbook_id": PLAYBOOK_ID,
+        "playbook_parameters": _playbook_parameters_config(playbook_parameters),
+        "reason_chain": reasons,
+        "blocks": blocks,
+        "decision_version": 3,
+    }
+
+    return DecisionSnapshot(
+        action=action,
+        target_strategy=target_strategy,
+        reason_summary=reason_summary,
+        confidence=confidence,
+        requires_action=requires_action,
+        payload=payload,
+    )

+ 18 - 37
src/hermes_mcp/decision_families/trend_only.py → src/hermes_mcp/playbooks/trend_only.py

@@ -3,26 +3,9 @@ from __future__ import annotations
 from datetime import datetime, timezone
 from typing import Any
 
-from ..decision_engine import DecisionSnapshot, _argus_decision_context, _clamp, _decision_profile_config, _parse_timestamp, _safe_float, _short_term_trend_manifest_score, _timeframe_direction
+from ..decision_engine import DecisionSnapshot, _argus_decision_context, _clamp, _parse_timestamp, _playbook_parameters_config, _safe_float, _short_term_trend_manifest_score, _timeframe_direction
 
-FAMILY_ID = "trend-only"
-FAMILY_ALIASES = {"trend-only", "trend_only", "trend"}
-
-TREND_ONLY_SCHEMA = {
-    "roles": ["trend_buy", "trend_sell"],
-    "transitions": ["buy_to_sell", "sell_to_buy"],
-    "parameters": [
-        "estimated_turn_cost_pct",
-        "micro_trend_weight",
-        "meso_trend_weight",
-        "macro_trend_weight",
-        "persistence_bonus_weight",
-        "argus_compression_penalty",
-        "activation_edge_threshold",
-        "flip_edge_threshold",
-        "flip_confirmation_gap",
-    ],
-}
+PLAYBOOK_ID = "trend-only"
 
 
 def _find_role_strategy(strategies: list[dict[str, Any]], role: str, side: str) -> dict[str, Any] | None:
@@ -62,7 +45,7 @@ def _recent_move(history_window: dict[str, Any] | None) -> tuple[float, str, int
         points.append((ts, price))
     if len(points) < 2:
         return 0.0, "mixed", 0
-    points.sort(key=lambda x: x[0])
+    points.sort(key=lambda item: item[0])
     first, last = points[0][1], points[-1][1]
     if first <= 0:
         return 0.0, "mixed", 0
@@ -97,8 +80,8 @@ def _direction_persistence(narrative_payload: dict[str, Any], history_window: di
     return min(streak / 4.0, 1.0)
 
 
-def _trend_side_scores(narrative_payload: dict[str, Any], history_window: dict[str, Any] | None, profile: dict[str, Any]) -> dict[str, Any]:
-    config = _decision_profile_config(profile)
+def _trend_side_scores(narrative_payload: dict[str, Any], history_window: dict[str, Any] | None, playbook_parameters: dict[str, Any]) -> dict[str, Any]:
+    config = _playbook_parameters_config(playbook_parameters)
     micro_weight = _safe_float(config.get("micro_trend_weight")) or 0.8
     meso_weight = _safe_float(config.get("meso_trend_weight")) or 1.0
     macro_weight = _safe_float(config.get("macro_trend_weight")) or 0.7
@@ -147,8 +130,8 @@ def _trend_side_scores(narrative_payload: dict[str, Any], history_window: dict[s
     }
 
 
-def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any], wallet_state: dict[str, Any], strategies: list[dict[str, Any]], history_window: dict[str, Any] | None = None, decision_profile: dict[str, Any] | None = None):
-    config = _decision_profile_config(decision_profile)
+def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any], wallet_state: dict[str, Any], strategies: list[dict[str, Any]], history_window: dict[str, Any] | None = None, playbook_parameters: dict[str, Any] | None = None):
+    config = _playbook_parameters_config(playbook_parameters)
     turn_cost_pct = _safe_float(config.get("estimated_turn_cost_pct")) or 0.7
     activation_edge_threshold = _safe_float(config.get("activation_edge_threshold")) or 1.15
     flip_edge_threshold = _safe_float(config.get("flip_edge_threshold")) or 1.35
@@ -163,22 +146,21 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
         trade_side = str((active_config or {}).get("trade_side") or (active.get("trade_side") if active else "") or "").strip().lower()
         active_side = "trend_buy" if trade_side == "buy" else "trend_sell" if trade_side == "sell" else ""
 
-    scores = _trend_side_scores(narrative_payload, history_window, decision_profile or {})
+    scores = _trend_side_scores(narrative_payload, history_window, config)
     buy_score = float(scores["buy_score"])
     sell_score = float(scores["sell_score"])
     edge_to_buy = buy_score - sell_score
     edge_to_sell = sell_score - buy_score
     fee_gate = turn_cost_pct / 0.7
 
-    mode = "observe"
     action = "hold"
     target_strategy = active.get("id") if active else None
     reasons: list[str] = []
     blocks: list[str] = []
+    requires_action = False
 
     if not buy_strategy and not sell_strategy:
         return DecisionSnapshot(
-            mode="observe",
             action="wait",
             target_strategy=None,
             reason_summary="trend-only playbook has no assigned trend strategies yet",
@@ -188,42 +170,42 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
                 "generated_at": datetime.now(timezone.utc).isoformat(),
                 "wallet_state": wallet_state,
                 "narrative_stance": str(narrative_payload.get("stance") or "neutral"),
-                "decision_family": FAMILY_ID,
+                "playbook_id": PLAYBOOK_ID,
                 "decision_audit": scores,
                 "reason_chain": [],
                 "blocks": ["trend-only playbook has no assigned trend strategies yet"],
-                "decision_profile": decision_profile,
+                "playbook_parameters": config,
             },
         )
 
     if active_side == "trend_buy":
         if sell_strategy and edge_to_sell >= max(flip_edge_threshold, fee_gate) and edge_to_sell >= flip_confirmation_gap:
-            mode = "act"
             action = "replace_with_trend_follower"
             target_strategy = sell_strategy.get("id")
+            requires_action = True
             reasons.append("bearish evidence now outweighs the active buy side strongly enough to justify paying the turn cost")
         else:
             action = "keep_trend"
             reasons.append("buy side remains active because the opposite edge is not strong enough to pay for a flip")
     elif active_side == "trend_sell":
         if buy_strategy and edge_to_buy >= max(flip_edge_threshold, fee_gate) and edge_to_buy >= flip_confirmation_gap:
-            mode = "act"
             action = "replace_with_trend_follower"
             target_strategy = buy_strategy.get("id")
+            requires_action = True
             reasons.append("bullish evidence now outweighs the active sell side strongly enough to justify paying the turn cost")
         else:
             action = "keep_trend"
             reasons.append("sell side remains active because the opposite edge is not strong enough to pay for a flip")
     else:
         if buy_strategy and edge_to_buy >= max(activation_edge_threshold, fee_gate):
-            mode = "act"
             action = "replace_with_trend_follower"
             target_strategy = buy_strategy.get("id")
+            requires_action = True
             reasons.append("bullish evidence is strong enough to activate the buy trend follower")
         elif sell_strategy and edge_to_sell >= max(activation_edge_threshold, fee_gate):
-            mode = "act"
             action = "replace_with_trend_follower"
             target_strategy = sell_strategy.get("id")
+            requires_action = True
             reasons.append("bearish evidence is strong enough to activate the sell trend follower")
         else:
             action = "wait"
@@ -239,7 +221,7 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
             "trend_sell": sell_strategy.get("id") if sell_strategy else None,
             "active_side": active_side or None,
         },
-        "decision_family": FAMILY_ID,
+        "playbook_id": PLAYBOOK_ID,
         "decision_audit": {
             **scores,
             "edge_to_buy": round(edge_to_buy, 4),
@@ -251,14 +233,13 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
         },
         "reason_chain": reasons,
         "blocks": blocks,
-        "decision_profile": decision_profile,
+        "playbook_parameters": config,
     }
     return DecisionSnapshot(
-        mode=mode,
         action=action,
         target_strategy=target_strategy,
         reason_summary=reasons[0] if reasons else (blocks[0] if blocks else "trend-only posture unchanged"),
         confidence=confidence,
-        requires_action=mode == "act",
+        requires_action=requires_action,
         payload=payload,
     )

+ 9 - 3
src/hermes_mcp/replay.py

@@ -12,6 +12,8 @@ def build_replay_input(*,
     wallet_state: dict[str, Any],
     strategies: list[dict[str, Any]],
     history_window: dict[str, Any] | None = None,
+    playbook_id: str | None = None,
+    playbook_parameters: dict[str, Any] | None = None,
 ) -> dict[str, Any]:
     """Capture the minimum full-fidelity input needed to replay a Hermes decision."""
     return {
@@ -20,16 +22,20 @@ def build_replay_input(*,
         "wallet_state": copy.deepcopy(wallet_state),
         "strategies": copy.deepcopy(strategies),
         "history_window": copy.deepcopy(history_window or {}),
+        "playbook_id": copy.deepcopy(playbook_id),
+        "playbook_parameters": copy.deepcopy(playbook_parameters or {}),
     }
 
 
 def replay_decision(replay_input: dict[str, Any]) -> DecisionSnapshot:
     return make_decision(
+        playbook_id=copy.deepcopy(replay_input.get("playbook_id")),
         concern=copy.deepcopy(replay_input.get("concern") or {}),
         narrative_payload=copy.deepcopy(replay_input.get("narrative_payload") or {}),
         wallet_state=copy.deepcopy(replay_input.get("wallet_state") or {}),
         strategies=copy.deepcopy(replay_input.get("strategies") or []),
         history_window=copy.deepcopy(replay_input.get("history_window") or {}),
+        playbook_parameters=copy.deepcopy(replay_input.get("playbook_parameters") or {}),
     )
 
 
@@ -37,7 +43,7 @@ def compare_to_baseline(*, replay_input: dict[str, Any], baseline: dict[str, Any
     replayed = replay_decision(replay_input)
     changed = any(
         [
-            replayed.mode != baseline.get("mode"),
+            replayed.requires_action != bool(baseline.get("requires_action")),
             replayed.action != baseline.get("action"),
             replayed.target_strategy != baseline.get("target_strategy"),
         ]
@@ -45,12 +51,12 @@ def compare_to_baseline(*, replay_input: dict[str, Any], baseline: dict[str, Any
     return {
         "changed": changed,
         "baseline": {
-            "mode": baseline.get("mode"),
+            "requires_action": bool(baseline.get("requires_action")),
             "action": baseline.get("action"),
             "target_strategy": baseline.get("target_strategy"),
         },
         "replayed": {
-            "mode": replayed.mode,
+            "requires_action": replayed.requires_action,
             "action": replayed.action,
             "target_strategy": replayed.target_strategy,
             "reason_summary": replayed.reason_summary,

+ 79 - 99
src/hermes_mcp/server.py

@@ -1,8 +1,10 @@
 from __future__ import annotations
 
 from contextlib import asynccontextmanager
+from contextlib import suppress
 import asyncio
 import json
+import sys
 import time
 from datetime import datetime, timezone
 from uuid import uuid4
@@ -18,14 +20,16 @@ from mcp.client.sse import sse_client
 from .config import load_config
 from .argus_client import get_regime as argus_get_regime, get_snapshot as argus_get_snapshot
 from .crypto_client import get_price, get_regime
-from .decision_engine import assess_wallet_state
-from .decision_families import make_family_decision
+from .decision_engine import assess_wallet_state, make_decision
 from .narrative_engine import build_narrative
+from .playbooks import default_playbook_id_for_strategies, get_playbook_definition, playbook_parameter_definitions, resolve_playbook_parameters, supported_playbook_parameter_ids
 from .replay import build_replay_input
 from .state_engine import synthesize_state
 from .store import delete_concern, get_decision_profile, get_state, init_db, list_concerns, list_strategy_assignments, list_strategy_groups, latest_cycle, latest_cycles, latest_decisions, latest_narratives, latest_observations, latest_regime_samples, prune_older_than, recent_regime_samples, recent_states_for_concern, sync_concerns_from_strategies, upsert_concern, upsert_cycle, upsert_decision, upsert_decision_profile, upsert_narrative, upsert_observation, upsert_regime_sample, upsert_state, latest_states, upsert_strategy_assignment, upsert_strategy_group
 from .trader_client import apply_control_decision as trader_apply_control_decision, cancel_all_orders as trader_cancel_all_orders, control_strategy as trader_control_strategy, get_strategy as trader_get_strategy, list_strategies
 
+TESTING = "pytest" in sys.modules
+
 mcp = FastMCP(
     "hermes-mcp",
     transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False),
@@ -39,15 +43,11 @@ def _build_trader_control_payload(*, decision_id: str, concern: dict, decision:
     current_primary = str(decision_payload.get("current_primary_strategy") or "").strip() or None
 
     trader_action: str | None = None
-    risk_mode: str | None = None
     if action.startswith("replace_with_") or action.startswith("enable_"):
         trader_action = "switch"
-    elif action == "suspend_grid":
-        trader_action = "pause"
-        target_strategy = current_primary
-    elif action == "set_risk_mode":
-        trader_action = "set_risk_mode"
-        risk_mode = str(decision_payload.get("risk_mode") or "").strip() or None
+    elif action == "deactivate_current_strategy":
+        trader_action = "deactivate"
+        target_strategy = target_strategy or current_primary
     else:
         return None
 
@@ -65,7 +65,6 @@ def _build_trader_control_payload(*, decision_id: str, concern: dict, decision:
         "action": trader_action,
         "target_strategy_id": target_strategy,
         "expected_active_strategy_id": current_primary,
-        "risk_mode": risk_mode,
         "reason": reason,
         "confidence": confidence,
         "dry_run": False,
@@ -249,7 +248,7 @@ async def lifespan(_: FastAPI):
             cycle_id = str(uuid4())
             concerns = list_concerns()
             profile_ids = sorted({str(c.get("decision_profile_id") or "").strip() for c in concerns if str(c.get("decision_profile_id") or "").strip()})
-            decision_profiles = {}
+            playbook_profiles = {}
             for profile_id in profile_ids:
                 profile = get_decision_profile(profile_id=profile_id)
                 if not profile:
@@ -259,7 +258,7 @@ async def lifespan(_: FastAPI):
                 except Exception:
                     profile_config = {}
                 if isinstance(profile_config, dict):
-                    decision_profiles[profile_id] = {**profile, "config": profile_config}
+                    playbook_profiles[profile_id] = {**profile, "config": profile_config}
             playbook_groups = list_strategy_groups()
             playbook_assignments = {
                 str(group.get("id") or ""): list_strategy_assignments(strategy_group_id=str(group.get("id") or ""))
@@ -403,8 +402,13 @@ 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)
-                    decision = make_family_decision(
-                        family=str(active_playbook.get("strategy_family") or "grid-trend-rebalancer") if active_playbook else "grid-trend-rebalancer",
+                    active_playbook_id = str(active_playbook.get("strategy_family") or "grid-trend-rebalancer") if active_playbook else "grid-trend-rebalancer"
+                    resolved_parameters = resolve_playbook_parameters(
+                        playbook_id=active_playbook_id,
+                        overrides=(playbook_profiles.get(str(concern.get("decision_profile_id") or "").strip()) or {}).get("config"),
+                    )
+                    decision = make_decision(
+                        playbook_id=active_playbook_id,
                         concern=concern,
                         narrative_payload={
                             **state.payload,
@@ -417,7 +421,7 @@ async def lifespan(_: FastAPI):
                             "window_seconds": breakout_window_seconds,
                             "recent_states": recent_state_rows,
                         },
-                        decision_profile=decision_profiles.get(str(concern.get("decision_profile_id") or "").strip()),
+                        playbook_parameters=resolved_parameters,
                     )
                     decision_id = f"{cycle_id}:{concern['id']}"
                     dispatch_record = await _maybe_dispatch_trader_action(
@@ -443,9 +447,12 @@ async def lifespan(_: FastAPI):
                             "window_seconds": breakout_window_seconds,
                             "recent_states": recent_state_rows,
                         },
+                        playbook_id=active_playbook_id,
+                        playbook_parameters=resolved_parameters,
                         ),
                         "dispatch": dispatch_record,
-                        "decision_family": str(active_playbook.get("strategy_family") or "grid-trend-rebalancer") if active_playbook else "grid-trend-rebalancer",
+                        "playbook_id": active_playbook_id,
+                        "resolved_parameters": resolved_parameters,
                         "active_playbook_id": str(active_playbook.get("id") or "") if active_playbook else None,
                         "candidate_strategy_ids": sorted(assigned_strategy_ids) if assigned_strategy_ids else [str(s.get("id") or "") for s in candidate_strategies if str(s.get("id") or "")],
                     }
@@ -453,7 +460,6 @@ async def lifespan(_: FastAPI):
                         id=decision_id,
                         cycle_id=cycle_id,
                         concern_id=str(concern["id"]),
-                        mode=decision.mode,
                         action=decision.action,
                         target_strategy=decision.target_strategy,
                         target_policy_json=json.dumps(decision_payload, ensure_ascii=False),
@@ -467,12 +473,16 @@ async def lifespan(_: FastAPI):
             upsert_cycle(id=cycle_id, started_at=started, finished_at=datetime.now(timezone.utc).isoformat(), status="ok", trigger="interval", notes=f"polled {len(concerns)} concerns over {','.join(cfg.crypto_timeframes)}")
             await asyncio.sleep(max(10, cfg.cycle_seconds))
 
-    asyncio.create_task(_poll_loop())
-    yield
-
+    poll_task = asyncio.create_task(_poll_loop())
+    try:
+        yield
+    finally:
+        poll_task.cancel()
+        with suppress(asyncio.CancelledError):
+            await poll_task
 
-app = FastAPI(title="Hermes MCP", lifespan=lifespan)
-app.mount("/mcp", mcp.sse_app())
+app = FastAPI(title="Hermes MCP", lifespan=None if TESTING else lifespan)
+app.mount("/mcp", FastAPI() if TESTING else mcp.sse_app())
 
 
 @app.get("/")
@@ -624,64 +634,30 @@ def _resolve_regime_symbol(concern: dict) -> str | None:
 
 
 def _default_playbook_name(strategies: list[dict]) -> str:
-    types = {str(s.get("strategy_type") or "").strip() for s in strategies}
-    if {"grid_trader", "trend_follower", "exposure_protector"}.issubset(types):
-        return "grid-trend-rebalancer"
-    if types == {"trend_follower"} or ("trend_follower" in types and "grid_trader" not in types and "exposure_protector" not in types):
-        return "trend-only"
-    labels = sorted(t.replace("_", "-") for t in types if t)
-    return "+".join(labels) if labels else "playbook"
+    playbook = get_playbook_definition(default_playbook_id_for_strategies(strategies))
+    return str(playbook.get("name") or playbook.get("id") or "playbook")
 
 
 def _default_playbook_family(strategies: list[dict]) -> str:
-    types = {str(s.get("strategy_type") or "").strip() for s in strategies}
-    if {"grid_trader", "trend_follower", "exposure_protector"}.issubset(types):
-        return "grid-trend-rebalancer"
-    if "trend_follower" in types and "grid_trader" not in types and "exposure_protector" not in types:
-        return "trend-only"
-    return "mixed"
+    return default_playbook_id_for_strategies(strategies)
 
 
 def _default_profile_config(family: str | None = None) -> dict[str, object]:
-    normalized = str(family or "").strip().lower()
-    if normalized in {"trend-only", "trend_only", "trend"}:
-        return {
-            "estimated_turn_cost_pct": 0.7,
-            "micro_trend_weight": 0.8,
-            "meso_trend_weight": 1.0,
-            "macro_trend_weight": 0.7,
-            "persistence_bonus_weight": 0.45,
-            "argus_compression_penalty": 0.18,
-            "activation_edge_threshold": 1.15,
-            "flip_edge_threshold": 1.35,
-            "flip_confirmation_gap": 0.25,
-        }
-    return {
-        "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_cooling_threshold": 0.45,
-        "trend_inventory_stress_threshold": 0.55,
-        "action_cooldown_seconds": 600,
-    }
+    return resolve_playbook_parameters(playbook_id=family, overrides={})
 
 
 def _profile_allowed_keys(family: str | None = None) -> set[str]:
-    return set(_default_profile_config(family).keys())
+    return supported_playbook_parameter_ids(family)
 
 
 def _normalize_profile_config(config: dict[str, object] | None, family: str | None = None) -> dict[str, object]:
-    defaults = _default_profile_config(family)
-    allowed = _profile_allowed_keys(family)
     current = config if isinstance(config, dict) else {}
-    return {**defaults, **{k: v for k, v in current.items() if k in allowed}}
+    return resolve_playbook_parameters(playbook_id=family, overrides=current)
 
 
 def _ensure_profile_for_family(*, profile_id: str, family: str | None, name: str, description: str | None = None, status: str = "active") -> dict[str, Any]:
-    family_label = str(family or "").strip() or "playbook"
+    playbook = get_playbook_definition(family)
+    family_label = str(playbook.get("name") or playbook.get("id") or "playbook")
     profile = get_decision_profile(profile_id=profile_id)
     config: dict[str, object] = {}
     if profile:
@@ -967,17 +943,23 @@ def dashboard_concern_detail_data(concern_id: str) -> JSONResponse:
         concern = {**concern, "decision_profile_id": active_playbook_profile_id}
         profile_id = active_playbook_profile_id
 
-    active_family = next((str(p.get("strategy_family") or "") for p in playbooks if str(p.get("status") or "").lower() == "active"), "")
+    active_playbook_id = next((str(p.get("strategy_family") or "") for p in playbooks if str(p.get("status") or "").lower() == "active"), "")
     decision_profile = (
         _ensure_profile_for_family(
             profile_id=profile_id,
-            family=active_family,
+            family=active_playbook_id,
             name=f"{str((next((p for p in playbooks if str(p.get('status') or '').lower() == 'active'), {}) or {}).get('name') or 'playbook')} profile",
             description="Auto-created for this playbook.",
             status="active",
         )
         if profile_id else None
     )
+    resolved_parameters = resolve_playbook_parameters(
+        playbook_id=active_playbook_id,
+        overrides=(decision_profile or {}).get("config"),
+    )
+    if decision_profile:
+        decision_profile = {**decision_profile, "config": resolved_parameters}
 
     latest_state = next((s for s in latest_states(200) if str(s.get("concern_id") or "") == concern_id), None)
     latest_narrative = next((n for n in latest_narratives(200) if str(n.get("concern_id") or "") == concern_id), None)
@@ -1005,6 +987,8 @@ def dashboard_concern_detail_data(concern_id: str) -> JSONResponse:
         "ok": True,
         "concern": enriched_concern,
         "decision_profile": decision_profile,
+        "active_playbook_definition": get_playbook_definition(active_playbook_id or "grid-trend-rebalancer"),
+        "playbook_parameters": resolved_parameters,
         "playbooks": playbooks,
         "strategies": concern_strategies,
         "latest_state": latest_state,
@@ -1139,35 +1123,21 @@ async def dashboard_update_playbook_tuning(concern_id: str, playbook_id: str, re
     if not isinstance(current_config, dict):
         current_config = {}
 
-    allowed_keys = {
-        "breakout_persistence_min",
-        "short_term_confirmation_min",
-        "switch_cost_penalty",
-        "rebalance_imbalance_threshold",
-        "force_grid_when_balanced",
-        "grid_release_threshold",
-        "trend_cooling_threshold",
-        "trend_inventory_stress_threshold",
-        "action_cooldown_seconds",
-        "estimated_turn_cost_pct",
-        "micro_trend_weight",
-        "meso_trend_weight",
-        "macro_trend_weight",
-        "persistence_bonus_weight",
-        "argus_compression_penalty",
-        "activation_edge_threshold",
-        "flip_edge_threshold",
-        "flip_confirmation_gap",
+    playbook_id = str(target.get("strategy_family") or "")
+    allowed_keys = supported_playbook_parameter_ids(playbook_id)
+    parameter_types = {
+        str(parameter.get("id") or ""): str(parameter.get("type") or "number")
+        for parameter in playbook_parameter_definitions(playbook_id)
     }
     merged = _normalize_profile_config(current_config, str(target.get("strategy_family") or ""))
     for key, value in updates.items():
         if key not in allowed_keys:
             continue
-        if key == "force_grid_when_balanced":
+        if parameter_types.get(key) == "boolean":
             merged[key] = bool(value)
             continue
         try:
-            merged[key] = float(value) if key != "action_cooldown_seconds" else int(float(value))
+            merged[key] = float(value)
         except Exception:
             continue
 
@@ -1231,17 +1201,16 @@ def dashboard_playbook_detail_data(playbook_id: str) -> JSONResponse:
             strategies_by_id.get(str(a.get("strategy_id") or "").strip(), {"strategy_type": a.get("strategy_type")})
             for a in raw_assignments
         ])
-        if inferred_family != "mixed":
-            upsert_strategy_group(
-                id=str(group.get("id") or ""),
-                concern_id=concern_id,
-                name=str(group.get("name") or group.get("id") or "playbook"),
-                strategy_family=inferred_family,
-                decision_profile_id=str(group.get("decision_profile_id") or concern.get("decision_profile_id") or "").strip() or None,
-                notes=str(group.get("notes") or "").strip() or None,
-                status=str(group.get("status") or "active"),
-            )
-            group = {**group, "strategy_family": inferred_family}
+        upsert_strategy_group(
+            id=str(group.get("id") or ""),
+            concern_id=concern_id,
+            name=str(group.get("name") or group.get("id") or "playbook"),
+            strategy_family=inferred_family,
+            decision_profile_id=str(group.get("decision_profile_id") or concern.get("decision_profile_id") or "").strip() or None,
+            notes=str(group.get("notes") or "").strip() or None,
+            status=str(group.get("status") or "active"),
+        )
+        group = {**group, "strategy_family": inferred_family}
     for assignment in raw_assignments:
         strategy = strategies_by_id.get(str(assignment.get("strategy_id") or "").strip(), {})
         assignments.append({
@@ -1251,17 +1220,28 @@ def dashboard_playbook_detail_data(playbook_id: str) -> JSONResponse:
 
     profile_id = str(group.get("decision_profile_id") or concern.get("decision_profile_id") or "").strip()
     profile = get_decision_profile(profile_id=profile_id) if profile_id else None
+    resolved_parameters = {}
     if profile:
         try:
             profile = {**profile, "config": json.loads(profile.get("config_json") or "{}")}
         except Exception:
             profile = {**profile, "config": {}}
+        resolved_parameters = resolve_playbook_parameters(
+            playbook_id=str(group.get("strategy_family") or ""),
+            overrides=profile.get("config"),
+        )
+        profile = {**profile, "config": resolved_parameters}
+    else:
+        resolved_parameters = resolve_playbook_parameters(playbook_id=str(group.get("strategy_family") or ""), overrides={})
+    playbook_definition = get_playbook_definition(str(group.get("strategy_family") or ""))
 
     return JSONResponse({
         "ok": True,
         "playbook": group,
+        "playbook_definition": playbook_definition,
         "concern": concern,
         "decision_profile": profile,
+        "resolved_parameters": resolved_parameters,
         "assignments": assignments,
         "available_strategies": concern_strategies,
     })
@@ -1276,7 +1256,7 @@ async def dashboard_create_playbook(concern_id: str, request: Request) -> JSONRe
 
     payload = await request.json()
     name = str((payload or {}).get("name") or "").strip()
-    strategy_family = str((payload or {}).get("strategy_family") or "manual").strip() or "manual"
+    strategy_family = str(get_playbook_definition((payload or {}).get("strategy_family") or "grid-trend-rebalancer").get("id") or "grid-trend-rebalancer")
     if not name:
         return JSONResponse({"ok": False, "error": "name is required"}, status_code=400)
 

+ 66 - 6
src/hermes_mcp/store.py

@@ -128,7 +128,6 @@ SCHEMA_STATEMENTS = [
       id text primary key,
       cycle_id text not null,
       concern_id text not null,
-      mode text not null,
       action text not null,
       target_strategy text,
       target_policy_json text,
@@ -209,6 +208,66 @@ def _connect() -> sqlite3.Connection:
     return conn
 
 
+def _migrate_decisions_schema(conn: sqlite3.Connection) -> None:
+    decision_columns = {row[1] for row in conn.execute("pragma table_info(decisions)").fetchall()}
+    if "mode" not in decision_columns:
+        return
+
+    conn.execute("pragma foreign_keys = off")
+    conn.execute("alter table actions rename to actions_old")
+    conn.execute("alter table decisions rename to decisions_old")
+    conn.execute(
+        """
+        create table decisions (
+          id text primary key,
+          cycle_id text not null,
+          concern_id text not null,
+          action text not null,
+          target_strategy text,
+          target_policy_json text,
+          reason_summary text,
+          confidence real,
+          requires_action integer not null default 0,
+          created_at text not null,
+          foreign key(cycle_id) references cycles(id),
+          foreign key(concern_id) references concerns(id)
+        )
+        """
+    )
+    conn.execute(
+        """
+        create table actions (
+          id text primary key,
+          decision_id text not null,
+          target text not null,
+          command text not null,
+          request_json text not null,
+          response_json text,
+          status text not null default 'pending',
+          executed_at text,
+          foreign key(decision_id) references decisions(id)
+        )
+        """
+    )
+    conn.execute(
+        """
+        insert into decisions(id, cycle_id, concern_id, action, target_strategy, target_policy_json, reason_summary, confidence, requires_action, created_at)
+        select id, cycle_id, concern_id, action, target_strategy, target_policy_json, reason_summary, confidence, requires_action, created_at
+        from decisions_old
+        """
+    )
+    conn.execute(
+        """
+        insert into actions(id, decision_id, target, command, request_json, response_json, status, executed_at)
+        select id, decision_id, target, command, request_json, response_json, status, executed_at
+        from actions_old
+        """
+    )
+    conn.execute("drop table actions_old")
+    conn.execute("drop table decisions_old")
+    conn.execute("pragma foreign_keys = on")
+
+
 def init_db() -> None:
     with _connect() as conn:
         for stmt in SCHEMA_STATEMENTS:
@@ -230,6 +289,8 @@ def init_db() -> None:
             if column not in concern_columns:
                 conn.execute(f"alter table concerns add column {column} text")
 
+        _migrate_decisions_schema(conn)
+
         for stmt in SCHEMA_STATEMENTS:
             if not stmt.lstrip().lower().startswith("create index"):
                 continue
@@ -594,18 +655,17 @@ def upsert_narrative(*, id: str, cycle_id: str, concern_id: str, summary: str, k
         )
 
 
-def upsert_decision(*, id: str, cycle_id: str, concern_id: str, mode: str, action: str, target_strategy: str | None, target_policy_json: str | None, reason_summary: str | None, confidence: float | None, requires_action: bool, created_at: str | None = None) -> None:
+def upsert_decision(*, id: str, cycle_id: str, concern_id: str, action: str, target_strategy: str | None, target_policy_json: str | None, reason_summary: str | None, confidence: float | None, requires_action: bool, created_at: str | None = None) -> None:
     init_db()
     created_at = created_at or _now()
     with _connect() as conn:
         conn.execute(
             """
-            insert into decisions(id, cycle_id, concern_id, mode, action, target_strategy, target_policy_json, reason_summary, confidence, requires_action, created_at)
-            values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+            insert into decisions(id, cycle_id, concern_id, action, target_strategy, target_policy_json, reason_summary, confidence, requires_action, created_at)
+            values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
             on conflict(id) do update set
               cycle_id=excluded.cycle_id,
               concern_id=excluded.concern_id,
-              mode=excluded.mode,
               action=excluded.action,
               target_strategy=excluded.target_strategy,
               target_policy_json=excluded.target_policy_json,
@@ -614,7 +674,7 @@ def upsert_decision(*, id: str, cycle_id: str, concern_id: str, mode: str, actio
               requires_action=excluded.requires_action,
               created_at=excluded.created_at
             """,
-            (id, cycle_id, concern_id, mode, action, target_strategy, target_policy_json, reason_summary, confidence, 1 if requires_action else 0, created_at),
+            (id, cycle_id, concern_id, action, target_strategy, target_policy_json, reason_summary, confidence, 1 if requires_action else 0, created_at),
         )
 
 

+ 0 - 4
tests/test_action_dispatch.py

@@ -9,7 +9,6 @@ from hermes_mcp.server import _build_trader_control_payload, _maybe_dispatch_tra
 def test_build_trader_control_payload_maps_replace_to_switch():
     concern = {"id": "c1", "account_id": "acct-1", "market_symbol": "xrpusd"}
     decision = DecisionSnapshot(
-        mode="act",
         action="replace_with_exposure_protector",
         target_strategy="protect-1",
         reason_summary="inventory repair should start",
@@ -28,7 +27,6 @@ def test_build_trader_control_payload_maps_replace_to_switch():
         "action": "switch",
         "target_strategy_id": "protect-1",
         "expected_active_strategy_id": "grid-1",
-        "risk_mode": None,
         "reason": "inventory repair should start",
         "confidence": 0.81,
         "dry_run": False,
@@ -42,7 +40,6 @@ def test_build_trader_control_payload_maps_replace_to_switch():
 async def test_dispatch_is_blocked_when_hermes_allow_actions_is_false():
     concern = {"id": "c1", "account_id": "acct-1", "market_symbol": "xrpusd"}
     decision = DecisionSnapshot(
-        mode="act",
         action="replace_with_grid",
         target_strategy="grid-1",
         reason_summary="range conditions support grid again",
@@ -64,7 +61,6 @@ async def test_dispatch_is_blocked_when_hermes_allow_actions_is_false():
 async def test_dispatch_calls_trader_when_gate_is_open(monkeypatch):
     concern = {"id": "c1", "account_id": "acct-1", "market_symbol": "xrpusd"}
     decision = DecisionSnapshot(
-        mode="act",
         action="replace_with_trend_follower",
         target_strategy="trend-1",
         reason_summary="persistent breakout pressure favors trend capture",

+ 0 - 1
tests/test_concern_cleanup.py

@@ -71,7 +71,6 @@ def test_delete_concern_purges_related_rows():
         id=decision_id,
         cycle_id=cycle_id,
         concern_id=concern_id,
-        mode="act",
         action="replace_with_grid",
         target_strategy="grid-1",
         target_policy_json="{}",

+ 101 - 23
tests/test_decision_engine.py

@@ -1,4 +1,4 @@
-from hermes_mcp.decision_engine import assess_wallet_state, make_decision, normalize_strategy_snapshot, score_strategy_fit
+from hermes_mcp.decision_engine import assess_wallet_state, make_decision, make_playbook_decision, normalize_strategy_snapshot, score_strategy_fit
 
 
 def test_assess_wallet_state_marks_one_sided_wallet_as_depleted_base_side():
@@ -160,7 +160,7 @@ def test_make_decision_keeps_grid_when_imbalance_is_manageable():
         {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
     ]
     decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
-    assert decision.mode == "observe"
+    assert decision.requires_action is False
     assert decision.action == "keep_grid"
     assert decision.target_strategy == "grid-1"
 
@@ -266,7 +266,7 @@ def test_make_decision_keeps_grid_when_critically_unbalanced_but_grid_still_has_
         {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
     ]
     decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
-    assert decision.mode == "observe"
+    assert decision.requires_action is False
     assert decision.action == "keep_grid"
     assert decision.target_strategy == "grid-1"
 
@@ -298,7 +298,7 @@ def test_make_decision_keeps_grid_when_trend_has_only_eaten_two_levels():
         {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
     ]
     decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
-    assert decision.mode == "warn"
+    assert decision.requires_action is False
     assert decision.action == "keep_grid"
     assert decision.target_strategy == "grid-1"
 
@@ -330,7 +330,7 @@ def test_make_decision_replaces_grid_when_third_level_is_sustained():
         {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
     ]
     decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
-    assert decision.mode == "act"
+    assert decision.requires_action is True
     assert decision.action == "replace_with_trend_follower"
     assert decision.target_strategy == "trend-1"
     assert decision.payload["grid_breakout_pressure"]["phase"] == "confirmed"
@@ -383,7 +383,7 @@ def test_make_decision_exits_grid_early_on_fast_bearish_alignment():
         history_window=history_window,
     )
 
-    assert decision.mode == "act"
+    assert decision.requires_action is True
     assert decision.action == "replace_with_trend_follower"
     assert decision.target_strategy == "trend-1"
     assert decision.payload["decision_audit"]["rapid_downside_pressure"] is True
@@ -436,7 +436,7 @@ def test_make_decision_prefers_exposure_protector_when_downside_hits_skewed_wall
         history_window=history_window,
     )
 
-    assert decision.mode == "act"
+    assert decision.requires_action is True
     assert decision.action == "replace_with_exposure_protector"
     assert decision.target_strategy == "protect-1"
 
@@ -488,7 +488,7 @@ def test_make_decision_prefers_exposure_protector_when_upside_hits_skewed_wallet
         history_window=history_window,
     )
 
-    assert decision.mode == "act"
+    assert decision.requires_action is True
     assert decision.action == "replace_with_exposure_protector"
     assert decision.target_strategy == "protect-1"
 
@@ -514,7 +514,7 @@ def test_make_decision_targets_the_trade_side_that_matches_direction():
         {"id": "trend-short", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {"trade_side": "sell"}},
     ]
     decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
-    assert decision.mode == "act"
+    assert decision.requires_action is True
     assert decision.action == "replace_with_trend_follower"
     assert decision.target_strategy == "trend-long"
 
@@ -951,7 +951,7 @@ def test_make_decision_keeps_trend_during_directional_regime_even_if_wallet_is_s
         {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
     ]
     decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
-    assert decision.mode == "observe"
+    assert decision.requires_action is False
     assert decision.action == "keep_trend"
     assert decision.target_strategy == "trend-1"
 
@@ -975,7 +975,7 @@ def test_make_decision_replaces_trend_with_rebalancer_after_trend_cools_and_wall
         {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
     ]
     decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
-    assert decision.mode == "act"
+    assert decision.requires_action is True
     assert decision.action == "replace_with_exposure_protector"
     assert decision.target_strategy == "protect-1"
 
@@ -1005,7 +1005,7 @@ def test_make_decision_replaces_trend_with_rebalancer_on_edge_cooling_even_befor
         {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
     ]
     decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
-    assert decision.mode == "act"
+    assert decision.requires_action is True
     assert decision.action == "replace_with_exposure_protector"
     assert decision.target_strategy == "protect-1"
 
@@ -1041,7 +1041,7 @@ def test_make_decision_replaces_trend_with_rebalancer_when_1m_and_5m_dislocate_f
         {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
     ]
     decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
-    assert decision.mode == "act"
+    assert decision.requires_action is True
     assert decision.action == "replace_with_exposure_protector"
     assert decision.target_strategy == "protect-1"
 
@@ -1077,7 +1077,7 @@ def test_make_decision_replaces_trend_with_rebalancer_when_short_tape_is_mixed_a
         {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
     ]
     decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
-    assert decision.mode == "act"
+    assert decision.requires_action is True
     assert decision.action == "replace_with_exposure_protector"
     assert decision.target_strategy == "protect-1"
 
@@ -1107,7 +1107,7 @@ def test_make_decision_replaces_trend_with_rebalancer_when_micro_reversal_risk_s
         {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
     ]
     decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
-    assert decision.mode == "act"
+    assert decision.requires_action is True
     assert decision.action == "replace_with_exposure_protector"
     assert decision.target_strategy == "protect-1"
 
@@ -1131,7 +1131,7 @@ def test_make_decision_replaces_rebalancer_with_grid_when_balanced_and_rotationa
         {"id": "grid-1", "strategy_type": "grid_trader", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
     ]
     decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
-    assert decision.mode == "act"
+    assert decision.requires_action is True
     assert decision.action == "replace_with_grid"
     assert decision.target_strategy == "grid-1"
 
@@ -1164,7 +1164,7 @@ def test_make_decision_replaces_rebalancer_with_grid_when_wallet_is_rebalanced_e
 
     decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
 
-    assert decision.mode == "act"
+    assert decision.requires_action is True
     assert decision.action == "replace_with_grid"
     assert decision.target_strategy == "grid-1"
     assert "rebalanced" in decision.reason_summary
@@ -1189,7 +1189,7 @@ def test_make_decision_replaces_rebalancer_with_grid_when_within_tolerance_even_
         {"id": "grid-1", "strategy_type": "grid_trader", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
     ]
     decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
-    assert decision.mode == "act"
+    assert decision.requires_action is True
     assert decision.action == "replace_with_grid"
     assert decision.target_strategy == "grid-1"
 
@@ -1220,7 +1220,7 @@ def test_make_decision_replaces_rebalancer_with_grid_when_trend_is_directional_b
         {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}, "report": {"supervision": {"capacity_available": True, "trend_strength": 0.58, "inventory_pressure": "balanced", "degraded": False}}},
     ]
     decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
-    assert decision.mode == "act"
+    assert decision.requires_action is True
     assert decision.action == "replace_with_grid"
     assert decision.target_strategy == "grid-1"
 
@@ -1257,7 +1257,7 @@ def test_make_decision_replaces_rebalancer_with_grid_when_micro_easing_restores_
         {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
     ]
     decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
-    assert decision.mode == "act"
+    assert decision.requires_action is True
     assert decision.action == "replace_with_grid"
     assert decision.target_strategy == "grid-1"
     assert decision.payload["decision_audit"]["tactical_easing"] is True
@@ -1296,7 +1296,7 @@ def test_make_decision_replaces_rebalancer_with_grid_near_local_bottom_when_nois
         {"id": "grid-1", "strategy_type": "grid_trader", "mode": "off", "account_id": "a1", "state": {}, "config": {"grid_step_pct": 0.005}},
     ]
     decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
-    assert decision.mode == "act"
+    assert decision.requires_action is True
     assert decision.action == "replace_with_grid"
     assert decision.target_strategy == "grid-1"
     assert decision.payload["decision_audit"]["pullback_to_grid_ratio"] is not None
@@ -1370,7 +1370,7 @@ def test_make_decision_replaces_grid_with_trend_when_pullbacks_are_too_shallow_f
         strategies=strategies,
         history_window=history_window,
     )
-    assert decision.mode == "act"
+    assert decision.requires_action is True
     assert decision.action == "replace_with_trend_follower"
     assert decision.target_strategy == "trend-1"
     assert decision.payload["decision_audit"]["pullback_to_grid_ratio"] < 1.0
@@ -1403,6 +1403,84 @@ def test_make_decision_replaces_rebalancer_with_trend_when_breakout_is_still_str
         {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}, "report": {"supervision": {"capacity_available": True, "trend_strength": 0.92, "inventory_pressure": "balanced", "degraded": False}}},
     ]
     decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
-    assert decision.mode == "observe"
+    assert decision.requires_action is False
     assert decision.action == "keep_rebalancer"
     assert decision.target_strategy == "protect-1"
+
+
+def test_make_decision_dispatches_to_trend_only_playbook():
+    concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd"}
+    narrative = {
+        "stance": "constructive_bullish",
+        "confidence": 0.79,
+        "scoped_state": {
+            "micro": {"reversal_risk": "low"},
+            "meso": {"momentum_bias": "bullish"},
+            "macro": {"bias": "bullish"},
+        },
+        "features_by_timeframe": {
+            "1m": {"trend": {"alignment": "fully_bullish", "strength": 0.8, "bias_score": 0.9}},
+            "5m": {"trend": {"alignment": "fully_bullish", "strength": 0.8, "bias_score": 0.9}},
+        },
+    }
+    wallet_state = {"inventory_state": "balanced", "rebalance_needed": False, "grid_ready": True}
+    strategies = [
+        {"id": "trend-buy-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "market_symbol": "xrpusd", "playbook_role": "trend_buy", "config": {"trade_side": "buy"}, "state": {}},
+        {"id": "trend-sell-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "market_symbol": "xrpusd", "playbook_role": "trend_sell", "config": {"trade_side": "sell"}, "state": {}},
+    ]
+
+    decision = make_decision(
+        playbook_id="trend-only",
+        concern=concern,
+        narrative_payload=narrative,
+        wallet_state=wallet_state,
+        strategies=strategies,
+    )
+
+    assert decision.requires_action is True
+    assert decision.action == "replace_with_trend_follower"
+    assert decision.target_strategy == "trend-buy-1"
+    assert decision.payload["playbook_id"] == "trend-only"
+
+
+def test_make_playbook_decision_is_a_compatibility_alias():
+    concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd"}
+    narrative = {
+        "stance": "constructive_bullish",
+        "confidence": 0.79,
+        "scoped_state": {
+            "micro": {"reversal_risk": "low"},
+            "meso": {"momentum_bias": "bullish"},
+            "macro": {"bias": "bullish"},
+        },
+        "features_by_timeframe": {
+            "1m": {"trend": {"alignment": "fully_bullish", "strength": 0.8, "bias_score": 0.9}},
+            "5m": {"trend": {"alignment": "fully_bullish", "strength": 0.8, "bias_score": 0.9}},
+        },
+    }
+    wallet_state = {"inventory_state": "balanced", "rebalance_needed": False, "grid_ready": True}
+    strategies = [
+        {"id": "trend-buy-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "market_symbol": "xrpusd", "playbook_role": "trend_buy", "config": {"trade_side": "buy"}, "state": {}},
+        {"id": "trend-sell-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "market_symbol": "xrpusd", "playbook_role": "trend_sell", "config": {"trade_side": "sell"}, "state": {}},
+    ]
+
+    dispatched = make_decision(
+        playbook_id="trend-only",
+        concern=concern,
+        narrative_payload=narrative,
+        wallet_state=wallet_state,
+        strategies=strategies,
+    )
+    aliased = make_playbook_decision(
+        playbook_id="trend-only",
+        concern=concern,
+        narrative_payload=narrative,
+        wallet_state=wallet_state,
+        strategies=strategies,
+    )
+
+    assert aliased.action == dispatched.action
+    assert aliased.target_strategy == dispatched.target_strategy
+    assert aliased.requires_action == dispatched.requires_action
+    assert aliased.reason_summary == dispatched.reason_summary
+    assert aliased.payload["playbook_id"] == dispatched.payload["playbook_id"]

+ 1 - 1
tests/test_replay.py

@@ -29,7 +29,7 @@ def test_compare_to_baseline_replays_decision_and_exposes_audit():
 
     result = compare_to_baseline(
         replay_input=replay_input,
-        baseline={"mode": "act", "action": "replace_with_grid", "target_strategy": "grid-1"},
+        baseline={"requires_action": True, "action": "replace_with_grid", "target_strategy": "grid-1"},
     )
 
     assert result["changed"] is False

+ 1 - 1
tests/test_schema.py

@@ -21,7 +21,7 @@ def test_schema_tables_exist():
 
 
 def test_profile_group_assignment_helpers_round_trip():
-    upsert_decision_profile(id="profile-grid", name="Grid profile", config={"short_term_trend_min_score": 0.28, "hold_rebalancer_until_cooldown": False})
+    upsert_decision_profile(id="profile-grid", name="Grid profile", config={"short_term_trend_min_score": 0.28, "require_release_signal_before_grid": False})
     upsert_concern(
         id="a1:xrpusd",
         account_id="a1",

+ 5 - 9
tests/test_smoke.py

@@ -1,15 +1,11 @@
-from hermes_mcp.server import app
+from hermes_mcp.server import health, root
 
 
 def test_root():
-    from fastapi.testclient import TestClient
-
-    client = TestClient(app)
-    assert client.get("/").status_code == 200
+    payload = root()
+    assert payload["status"] == "ok"
+    assert payload["mount"] == "/mcp/sse"
 
 
 def test_health():
-    from fastapi.testclient import TestClient
-
-    client = TestClient(app)
-    assert client.get("/health").json()["status"] == "ok"
+    assert health()["status"] == "ok"

+ 59 - 0
upgrade_0.6.0.md

@@ -0,0 +1,59 @@
+# Hermes-mcp upgrade to 0.6.0 :
+
+## hermes-mcp - trader-mcp communication:
+
+desired behaviour: 
+- hermes-mcp only acivates and deactivates strategies on trader-mcp. no observe mode. it only switches to another stratgey. 
+  therefode the mode parameter can be dropped for now from decision logic and dashboard and deision history, if hermes wants to suspend a strategy, it deactivates it.  
+- investigate the back reporting from trader and minimize load or unnecessary communication.
+- hermes does not need to care about passing parameters to strategies on trader or mess with the configuration, except for maybe reading it.
+
+- Hermes supervises the trader by activating the new and deactivating old strategies if appropriate and receives minimal reports like wallet composition and balances from the strategy on trader.
+
+## hermes-mcp decision logic
+
+background:
+we recently introduced playbooks. the current decision logic is mostly based on the grid-trend-rebalancer playbook. the trend-only playbook is here to have another playbbok while bootstrapping the app.
+
+- the repo contains a directoy named decision_families, this is to be renamed to playbooks.
+- and the **code needs to be refactored** so that the actual decision logic used by a playbook does really reside in the corresponding python code in the playbooks folder.
+
+## playbooks
+
+- i am ok with having json files in the repo for definition of each playbook eg grid-trend-rebalancer.json and trend-only.json
+- a playbook defines 
+  - a set of strategies and  
+  - parameters as well as  
+  - the logic  
+ that allow hermes to make decisions to activate or deactivate strategies if decided appropriate on trader.
+
+- a playbook defines roles and which strategy family or class, can be used to fill each role.
+  eg for our grid-trend-rebalancer it would require those four roles :
+  grid, buyer, seller, rebalancer
+  we already have a playbooks endpoint in the dashboard that lets us assign strategies to roles.
+  
+- a playbook defines a set of parameters, that are used to fine tune the decision logic.
+  these parameters are tunable via the dashboard 
+
+- each playbook has a corresponding file in the playbooks folder, where most if not all of the logic used by that playbook is defined.
+- the main decision_engine.py should only contain logic that can be used by all playbooks, eg general input data, utility functions ...  
+- playbook specific functions should be moved to the playbooks specific python file.
+
+- i dont know if we need a dispatcher.py !!?? imho, the main decision_engine.py should play the dispatcher for the playbooks.
+
+- the dashboard and decision logic can use the playbook definitions to choose the right parameter set for processing, persisting and display.
+
+## expectations and constraints
+
+- define more clearly how playbooks are used throughout hermes-mcp, 
+- clearly pack the decision logic and parameters of a playbook into its defilition and code file
+- make the use of playbooks and their different parameters an decision logic code tock solid as a abse for further development
+- clean out leftovers from old architectural mistakes or obsolete code, simplify where possible
+- we need to use the term playbook in the code whenever appropriate and drop the other misleading terms like  decision_family and also maybe decision_configuration for the more appropriate idea of a playbook and its configuration parameters
+
+
+- we do not need to validate if strategies fit the expected family to be used to fill a role. 
+  we do not need to be able to edit playbooks in the dashboard. the current implementation is sufficient.
+  
+- we want to bring hermes-mcp to a point where fine tuning the deciding weights for transitions between strategies as defined by a playbook is solid and reliable.
+