Parcourir la source

refactored decision engine by codex

Lukas Goldschmidt il y a 2 semaines
Parent
commit
0af4bf3964

+ 1 - 0
.gitignore

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

+ 56 - 167
src/hermes_mcp/dashboard.py

@@ -128,9 +128,11 @@ def playbook_detail_page(playbook_id: str):
             return;
             return;
           }
           }
           const playbook = data.playbook || {};
           const playbook = data.playbook || {};
+          const playbookDefinition = data.playbook_definition || {};
           const concern = data.concern || {};
           const concern = data.concern || {};
           const assignments = data.assignments || [];
           const assignments = data.assignments || [];
           const available = data.available_strategies || [];
           const available = data.available_strategies || [];
+          const roles = playbookDefinition.roles || [];
           document.getElementById('title').textContent = playbook.name || playbook.id || 'Playbook';
           document.getElementById('title').textContent = playbook.name || playbook.id || 'Playbook';
           document.getElementById('root').innerHTML = `
           document.getElementById('root').innerHTML = `
             <div class='grid'>
             <div class='grid'>
@@ -138,13 +140,13 @@ def playbook_detail_page(playbook_id: str):
                 <h2 style='margin-top:0'>Playbook</h2>
                 <h2 style='margin-top:0'>Playbook</h2>
                 <div><strong>Concern</strong>: ${concern.account_id || '-'} / ${concern.market_symbol || '-'}</div>
                 <div><strong>Concern</strong>: ${concern.account_id || '-'} / ${concern.market_symbol || '-'}</div>
                 <div><strong>Status</strong>: ${playbook.status || '-'}</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>
               <div class='panel'>
               <div class='panel'>
                 <h2 style='margin-top:0'>Assign existing strategy</h2>
                 <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>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>
                 <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>
                 <button type='button' onclick='assignStrategy()'>Assign strategy</button>
               </div>
               </div>
@@ -252,10 +254,33 @@ def concern_detail(concern_id: str):
         }
         }
         function modeChip(value) {
         function modeChip(value) {
           const v = String(value || '').toLowerCase();
           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';
           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() {
         async function activateSelectedPlaybook() {
           const select = document.getElementById('playbook-select');
           const select = document.getElementById('playbook-select');
           if (!select || !select.value) return;
           if (!select || !select.value) return;
@@ -272,26 +297,13 @@ def concern_detail(concern_id: str):
           if (!select || !select.value) return;
           if (!select || !select.value) return;
           const status = document.getElementById('tuning-save-status');
           const status = document.getElementById('tuning-save-status');
           if (status) status.textContent = 'Saving…';
           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`, {
           const res = await fetch(`/dashboard/concerns/__CONCERN_ID__/playbooks/${encodeURIComponent(select.value)}/tuning`, {
             method: 'POST',
             method: 'POST',
             headers: { 'Content-Type': 'application/json' },
             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 activePlaybook = playbooks.find(p => String(p.status || '').toLowerCase() === 'active') || playbooks[0] || null;
           const selectedPlaybookId = activePlaybook?.id || '';
           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 desiredOrder = ['1d', '4h', '1h', '15m', '5m', '1m'];
           const latestByTf = new Map();
           const latestByTf = new Map();
           const historiesByTf = new Map();
           const historiesByTf = new Map();
@@ -390,7 +402,7 @@ def concern_detail(concern_id: str):
                 <div class='panel'>
                 <div class='panel'>
                   <h2 style='margin-top:0'>Active decision state</h2>
                   <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>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><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' 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>
                   <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>
 
 
               <div class='panel'>
               <div class='panel'>
-                <h2 style='margin-top:0'>Decision profile</h2>
+                <h2 style='margin-top:0'>Playbook Parameters</h2>
                 ${profile ? `
                 ${profile ? `
                   <div><strong>${profile.name || profile.id || '-'}</strong></div>
                   <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'>${profile.description || ''}</div>
                   <div class='small' style='margin-top:8px'>Profile id: <code>${profile.id || '-'}</code></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>
 
 
               <div class='panel'>
               <div class='panel'>
@@ -453,130 +465,9 @@ def concern_detail(concern_id: str):
               </div>
               </div>
 
 
               <div class='panel'>
               <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'>
                 <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>
                   <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>
                   <span id='tuning-save-status' class='small' style='margin-left:10px'></span>
@@ -701,9 +592,8 @@ def overview():
         }}
         }}
         function modeChip(value) {{
         function modeChip(value) {{
           const v = String(value || '').toLowerCase();
           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';
           return 'neutral';
         }}
         }}
         function formatLocalTime(value) {{
         function formatLocalTime(value) {{
@@ -753,7 +643,7 @@ def overview():
             for (let i = 1; i < sorted.length; i++) {{
             for (let i = 1; i < sorted.length; i++) {{
               const prev = sorted[i - 1];
               const prev = sorted[i - 1];
               const cur = sorted[i];
               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] ?? ''));
               const diffs = fields.filter(f => String(cur?.[f] ?? '') !== String(prev?.[f] ?? ''));
               if (!diffs.length) continue;
               if (!diffs.length) continue;
               out.push({{ cur, prev, diffs }});
               out.push({{ cur, prev, diffs }});
@@ -939,7 +829,7 @@ def overview():
           const latestDecisions = latestByConcern(data.decision_samples || []);
           const latestDecisions = latestByConcern(data.decision_samples || []);
           const prevDecisions = previousByConcern(data.decision_samples || []);
           const prevDecisions = previousByConcern(data.decision_samples || []);
           document.getElementById('decisions-body').innerHTML = latestDecisions.map(d => {
           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 payload = (() => { try { return JSON.parse(d.target_policy_json || '{}'); } catch { return {}; } })();
             const wallet = payload.wallet_state || {};
             const wallet = payload.wallet_state || {};
             const ranking = payload.strategy_fit_ranking || [];
             const ranking = payload.strategy_fit_ranking || [];
@@ -949,7 +839,7 @@ def overview():
             return `
             return `
             <tr class='${hasChanged ? 'recent-change' : ''}'>
             <tr class='${hasChanged ? 'recent-change' : ''}'>
               <td class='focus-cell'>${d.concern_id || ''}</td>
               <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><span class='chip ${modeChip(d.action)}'>${d.action || ''}</span></td>
               <td>${d.target_strategy_label || d.target_strategy || '-'}</td>
               <td>${d.target_strategy_label || d.target_strategy || '-'}</td>
               <td>${d.reason_summary || ''}</td>
               <td>${d.reason_summary || ''}</td>
@@ -990,7 +880,7 @@ def overview():
       </table>
       </table>
       <h2>Latest decisions</h2>
       <h2>Latest decisions</h2>
       <table>
       <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>
         <tbody id="decisions-body">__DECISION_ROWS__</tbody>
       </table>
       </table>
       </div></div>
       </div></div>
@@ -1042,9 +932,8 @@ def changes():
       <script>
       <script>
         function modeChip(value) {
         function modeChip(value) {
           const v = String(value || '').toLowerCase();
           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';
           return 'neutral';
         }
         }
         function formatLocalTime(value) {
         function formatLocalTime(value) {
@@ -1072,7 +961,7 @@ def changes():
             for (let i = 1; i < sorted.length; i++) {
             for (let i = 1; i < sorted.length; i++) {
               const prev = sorted[i - 1];
               const prev = sorted[i - 1];
               const cur = sorted[i];
               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] ?? ''));
               const diffs = fields.filter(f => String(cur?.[f] ?? '') !== String(prev?.[f] ?? ''));
               if (!diffs.length) continue;
               if (!diffs.length) continue;
               out.push({ cur, prev, diffs });
               out.push({ cur, prev, diffs });

+ 36 - 1324
src/hermes_mcp/decision_engine.py

@@ -20,7 +20,6 @@ from typing import Any
 
 
 @dataclass(frozen=True)
 @dataclass(frozen=True)
 class DecisionSnapshot:
 class DecisionSnapshot:
-    mode: str
     action: str
     action: str
     target_strategy: str | None
     target_strategy: str | None
     reason_summary: str
     reason_summary: str
@@ -42,13 +41,13 @@ def _safe_float(value: Any) -> float | None:
         return 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 {}
         return {}
-    config = decision_profile.get("config")
+    config = playbook_parameters.get("config")
     if isinstance(config, dict):
     if isinstance(config, dict):
         return config
         return config
-    raw = decision_profile.get("config_json")
+    raw = playbook_parameters.get("config_json")
     if isinstance(raw, str) and raw.strip():
     if isinstance(raw, str) and raw.strip():
         try:
         try:
             parsed = json.loads(raw)
             parsed = json.loads(raw)
@@ -56,7 +55,7 @@ def _decision_profile_config(decision_profile: dict[str, Any] | None) -> dict[st
                 return parsed
                 return parsed
         except Exception:
         except Exception:
             return {}
             return {}
-    return {}
+    return dict(playbook_parameters)
 
 
 
 
 def _inventory_state_label(value: Any) -> str:
 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]:
 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],
     narrative_payload: dict[str, Any],
     wallet_state: 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,
     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,
         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],
     narrative_payload: dict[str, Any],
     wallet_state: 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,
         narrative_payload=narrative_payload,
         wallet_state=wallet_state,
         wallet_state=wallet_state,
-        grid_strategy=grid_strategy,
-        breakout=breakout,
+        strategies=strategies,
         history_window=history_window,
         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 datetime import datetime, timezone
 from typing import Any
 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:
 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))
         points.append((ts, price))
     if len(points) < 2:
     if len(points) < 2:
         return 0.0, "mixed", 0
         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]
     first, last = points[0][1], points[-1][1]
     if first <= 0:
     if first <= 0:
         return 0.0, "mixed", 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)
     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
     micro_weight = _safe_float(config.get("micro_trend_weight")) or 0.8
     meso_weight = _safe_float(config.get("meso_trend_weight")) or 1.0
     meso_weight = _safe_float(config.get("meso_trend_weight")) or 1.0
     macro_weight = _safe_float(config.get("macro_trend_weight")) or 0.7
     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
     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
     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
     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()
         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 ""
         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"])
     buy_score = float(scores["buy_score"])
     sell_score = float(scores["sell_score"])
     sell_score = float(scores["sell_score"])
     edge_to_buy = buy_score - sell_score
     edge_to_buy = buy_score - sell_score
     edge_to_sell = sell_score - buy_score
     edge_to_sell = sell_score - buy_score
     fee_gate = turn_cost_pct / 0.7
     fee_gate = turn_cost_pct / 0.7
 
 
-    mode = "observe"
     action = "hold"
     action = "hold"
     target_strategy = active.get("id") if active else None
     target_strategy = active.get("id") if active else None
     reasons: list[str] = []
     reasons: list[str] = []
     blocks: list[str] = []
     blocks: list[str] = []
+    requires_action = False
 
 
     if not buy_strategy and not sell_strategy:
     if not buy_strategy and not sell_strategy:
         return DecisionSnapshot(
         return DecisionSnapshot(
-            mode="observe",
             action="wait",
             action="wait",
             target_strategy=None,
             target_strategy=None,
             reason_summary="trend-only playbook has no assigned trend strategies yet",
             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(),
                 "generated_at": datetime.now(timezone.utc).isoformat(),
                 "wallet_state": wallet_state,
                 "wallet_state": wallet_state,
                 "narrative_stance": str(narrative_payload.get("stance") or "neutral"),
                 "narrative_stance": str(narrative_payload.get("stance") or "neutral"),
-                "decision_family": FAMILY_ID,
+                "playbook_id": PLAYBOOK_ID,
                 "decision_audit": scores,
                 "decision_audit": scores,
                 "reason_chain": [],
                 "reason_chain": [],
                 "blocks": ["trend-only playbook has no assigned trend strategies yet"],
                 "blocks": ["trend-only playbook has no assigned trend strategies yet"],
-                "decision_profile": decision_profile,
+                "playbook_parameters": config,
             },
             },
         )
         )
 
 
     if active_side == "trend_buy":
     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:
         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"
             action = "replace_with_trend_follower"
             target_strategy = sell_strategy.get("id")
             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")
             reasons.append("bearish evidence now outweighs the active buy side strongly enough to justify paying the turn cost")
         else:
         else:
             action = "keep_trend"
             action = "keep_trend"
             reasons.append("buy side remains active because the opposite edge is not strong enough to pay for a flip")
             reasons.append("buy side remains active because the opposite edge is not strong enough to pay for a flip")
     elif active_side == "trend_sell":
     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:
         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"
             action = "replace_with_trend_follower"
             target_strategy = buy_strategy.get("id")
             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")
             reasons.append("bullish evidence now outweighs the active sell side strongly enough to justify paying the turn cost")
         else:
         else:
             action = "keep_trend"
             action = "keep_trend"
             reasons.append("sell side remains active because the opposite edge is not strong enough to pay for a flip")
             reasons.append("sell side remains active because the opposite edge is not strong enough to pay for a flip")
     else:
     else:
         if buy_strategy and edge_to_buy >= max(activation_edge_threshold, fee_gate):
         if buy_strategy and edge_to_buy >= max(activation_edge_threshold, fee_gate):
-            mode = "act"
             action = "replace_with_trend_follower"
             action = "replace_with_trend_follower"
             target_strategy = buy_strategy.get("id")
             target_strategy = buy_strategy.get("id")
+            requires_action = True
             reasons.append("bullish evidence is strong enough to activate the buy trend follower")
             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):
         elif sell_strategy and edge_to_sell >= max(activation_edge_threshold, fee_gate):
-            mode = "act"
             action = "replace_with_trend_follower"
             action = "replace_with_trend_follower"
             target_strategy = sell_strategy.get("id")
             target_strategy = sell_strategy.get("id")
+            requires_action = True
             reasons.append("bearish evidence is strong enough to activate the sell trend follower")
             reasons.append("bearish evidence is strong enough to activate the sell trend follower")
         else:
         else:
             action = "wait"
             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,
             "trend_sell": sell_strategy.get("id") if sell_strategy else None,
             "active_side": active_side or None,
             "active_side": active_side or None,
         },
         },
-        "decision_family": FAMILY_ID,
+        "playbook_id": PLAYBOOK_ID,
         "decision_audit": {
         "decision_audit": {
             **scores,
             **scores,
             "edge_to_buy": round(edge_to_buy, 4),
             "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,
         "reason_chain": reasons,
         "blocks": blocks,
         "blocks": blocks,
-        "decision_profile": decision_profile,
+        "playbook_parameters": config,
     }
     }
     return DecisionSnapshot(
     return DecisionSnapshot(
-        mode=mode,
         action=action,
         action=action,
         target_strategy=target_strategy,
         target_strategy=target_strategy,
         reason_summary=reasons[0] if reasons else (blocks[0] if blocks else "trend-only posture unchanged"),
         reason_summary=reasons[0] if reasons else (blocks[0] if blocks else "trend-only posture unchanged"),
         confidence=confidence,
         confidence=confidence,
-        requires_action=mode == "act",
+        requires_action=requires_action,
         payload=payload,
         payload=payload,
     )
     )

+ 9 - 3
src/hermes_mcp/replay.py

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

+ 79 - 99
src/hermes_mcp/server.py

@@ -1,8 +1,10 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
 from contextlib import asynccontextmanager
 from contextlib import asynccontextmanager
+from contextlib import suppress
 import asyncio
 import asyncio
 import json
 import json
+import sys
 import time
 import time
 from datetime import datetime, timezone
 from datetime import datetime, timezone
 from uuid import uuid4
 from uuid import uuid4
@@ -18,14 +20,16 @@ from mcp.client.sse import sse_client
 from .config import load_config
 from .config import load_config
 from .argus_client import get_regime as argus_get_regime, get_snapshot as argus_get_snapshot
 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 .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 .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 .replay import build_replay_input
 from .state_engine import synthesize_state
 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 .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
 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(
 mcp = FastMCP(
     "hermes-mcp",
     "hermes-mcp",
     transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False),
     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
     current_primary = str(decision_payload.get("current_primary_strategy") or "").strip() or None
 
 
     trader_action: str | None = None
     trader_action: str | None = None
-    risk_mode: str | None = None
     if action.startswith("replace_with_") or action.startswith("enable_"):
     if action.startswith("replace_with_") or action.startswith("enable_"):
         trader_action = "switch"
         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:
     else:
         return None
         return None
 
 
@@ -65,7 +65,6 @@ def _build_trader_control_payload(*, decision_id: str, concern: dict, decision:
         "action": trader_action,
         "action": trader_action,
         "target_strategy_id": target_strategy,
         "target_strategy_id": target_strategy,
         "expected_active_strategy_id": current_primary,
         "expected_active_strategy_id": current_primary,
-        "risk_mode": risk_mode,
         "reason": reason,
         "reason": reason,
         "confidence": confidence,
         "confidence": confidence,
         "dry_run": False,
         "dry_run": False,
@@ -249,7 +248,7 @@ async def lifespan(_: FastAPI):
             cycle_id = str(uuid4())
             cycle_id = str(uuid4())
             concerns = list_concerns()
             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()})
             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:
             for profile_id in profile_ids:
                 profile = get_decision_profile(profile_id=profile_id)
                 profile = get_decision_profile(profile_id=profile_id)
                 if not profile:
                 if not profile:
@@ -259,7 +258,7 @@ async def lifespan(_: FastAPI):
                 except Exception:
                 except Exception:
                     profile_config = {}
                     profile_config = {}
                 if isinstance(profile_config, dict):
                 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_groups = list_strategy_groups()
             playbook_assignments = {
             playbook_assignments = {
                 str(group.get("id") or ""): list_strategy_assignments(strategy_group_id=str(group.get("id") or ""))
                 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))
                     breakout_window_seconds = max(300, int(getattr(cfg, "breakout_memory_window_seconds", 900) or 900))
                     recent_state_rows = recent_states_for_concern(concern_id=str(concern["id"]), since_seconds=breakout_window_seconds, limit=12)
                     recent_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,
                         concern=concern,
                         narrative_payload={
                         narrative_payload={
                             **state.payload,
                             **state.payload,
@@ -417,7 +421,7 @@ async def lifespan(_: FastAPI):
                             "window_seconds": breakout_window_seconds,
                             "window_seconds": breakout_window_seconds,
                             "recent_states": recent_state_rows,
                             "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']}"
                     decision_id = f"{cycle_id}:{concern['id']}"
                     dispatch_record = await _maybe_dispatch_trader_action(
                     dispatch_record = await _maybe_dispatch_trader_action(
@@ -443,9 +447,12 @@ async def lifespan(_: FastAPI):
                             "window_seconds": breakout_window_seconds,
                             "window_seconds": breakout_window_seconds,
                             "recent_states": recent_state_rows,
                             "recent_states": recent_state_rows,
                         },
                         },
+                        playbook_id=active_playbook_id,
+                        playbook_parameters=resolved_parameters,
                         ),
                         ),
                         "dispatch": dispatch_record,
                         "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,
                         "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 "")],
                         "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,
                         id=decision_id,
                         cycle_id=cycle_id,
                         cycle_id=cycle_id,
                         concern_id=str(concern["id"]),
                         concern_id=str(concern["id"]),
-                        mode=decision.mode,
                         action=decision.action,
                         action=decision.action,
                         target_strategy=decision.target_strategy,
                         target_strategy=decision.target_strategy,
                         target_policy_json=json.dumps(decision_payload, ensure_ascii=False),
                         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)}")
             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))
             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("/")
 @app.get("/")
@@ -624,64 +634,30 @@ def _resolve_regime_symbol(concern: dict) -> str | None:
 
 
 
 
 def _default_playbook_name(strategies: list[dict]) -> str:
 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:
 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]:
 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]:
 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]:
 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 {}
     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]:
 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)
     profile = get_decision_profile(profile_id=profile_id)
     config: dict[str, object] = {}
     config: dict[str, object] = {}
     if profile:
     if profile:
@@ -967,17 +943,23 @@ def dashboard_concern_detail_data(concern_id: str) -> JSONResponse:
         concern = {**concern, "decision_profile_id": active_playbook_profile_id}
         concern = {**concern, "decision_profile_id": active_playbook_profile_id}
         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 = (
     decision_profile = (
         _ensure_profile_for_family(
         _ensure_profile_for_family(
             profile_id=profile_id,
             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",
             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.",
             description="Auto-created for this playbook.",
             status="active",
             status="active",
         )
         )
         if profile_id else None
         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_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)
     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,
         "ok": True,
         "concern": enriched_concern,
         "concern": enriched_concern,
         "decision_profile": decision_profile,
         "decision_profile": decision_profile,
+        "active_playbook_definition": get_playbook_definition(active_playbook_id or "grid-trend-rebalancer"),
+        "playbook_parameters": resolved_parameters,
         "playbooks": playbooks,
         "playbooks": playbooks,
         "strategies": concern_strategies,
         "strategies": concern_strategies,
         "latest_state": latest_state,
         "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):
     if not isinstance(current_config, dict):
         current_config = {}
         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 ""))
     merged = _normalize_profile_config(current_config, str(target.get("strategy_family") or ""))
     for key, value in updates.items():
     for key, value in updates.items():
         if key not in allowed_keys:
         if key not in allowed_keys:
             continue
             continue
-        if key == "force_grid_when_balanced":
+        if parameter_types.get(key) == "boolean":
             merged[key] = bool(value)
             merged[key] = bool(value)
             continue
             continue
         try:
         try:
-            merged[key] = float(value) if key != "action_cooldown_seconds" else int(float(value))
+            merged[key] = float(value)
         except Exception:
         except Exception:
             continue
             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")})
             strategies_by_id.get(str(a.get("strategy_id") or "").strip(), {"strategy_type": a.get("strategy_type")})
             for a in raw_assignments
             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:
     for assignment in raw_assignments:
         strategy = strategies_by_id.get(str(assignment.get("strategy_id") or "").strip(), {})
         strategy = strategies_by_id.get(str(assignment.get("strategy_id") or "").strip(), {})
         assignments.append({
         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_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
     profile = get_decision_profile(profile_id=profile_id) if profile_id else None
+    resolved_parameters = {}
     if profile:
     if profile:
         try:
         try:
             profile = {**profile, "config": json.loads(profile.get("config_json") or "{}")}
             profile = {**profile, "config": json.loads(profile.get("config_json") or "{}")}
         except Exception:
         except Exception:
             profile = {**profile, "config": {}}
             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({
     return JSONResponse({
         "ok": True,
         "ok": True,
         "playbook": group,
         "playbook": group,
+        "playbook_definition": playbook_definition,
         "concern": concern,
         "concern": concern,
         "decision_profile": profile,
         "decision_profile": profile,
+        "resolved_parameters": resolved_parameters,
         "assignments": assignments,
         "assignments": assignments,
         "available_strategies": concern_strategies,
         "available_strategies": concern_strategies,
     })
     })
@@ -1276,7 +1256,7 @@ async def dashboard_create_playbook(concern_id: str, request: Request) -> JSONRe
 
 
     payload = await request.json()
     payload = await request.json()
     name = str((payload or {}).get("name") or "").strip()
     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:
     if not name:
         return JSONResponse({"ok": False, "error": "name is required"}, status_code=400)
         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,
       id text primary key,
       cycle_id text not null,
       cycle_id text not null,
       concern_id text not null,
       concern_id text not null,
-      mode text not null,
       action text not null,
       action text not null,
       target_strategy text,
       target_strategy text,
       target_policy_json text,
       target_policy_json text,
@@ -209,6 +208,66 @@ def _connect() -> sqlite3.Connection:
     return conn
     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:
 def init_db() -> None:
     with _connect() as conn:
     with _connect() as conn:
         for stmt in SCHEMA_STATEMENTS:
         for stmt in SCHEMA_STATEMENTS:
@@ -230,6 +289,8 @@ def init_db() -> None:
             if column not in concern_columns:
             if column not in concern_columns:
                 conn.execute(f"alter table concerns add column {column} text")
                 conn.execute(f"alter table concerns add column {column} text")
 
 
+        _migrate_decisions_schema(conn)
+
         for stmt in SCHEMA_STATEMENTS:
         for stmt in SCHEMA_STATEMENTS:
             if not stmt.lstrip().lower().startswith("create index"):
             if not stmt.lstrip().lower().startswith("create index"):
                 continue
                 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()
     init_db()
     created_at = created_at or _now()
     created_at = created_at or _now()
     with _connect() as conn:
     with _connect() as conn:
         conn.execute(
         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
             on conflict(id) do update set
               cycle_id=excluded.cycle_id,
               cycle_id=excluded.cycle_id,
               concern_id=excluded.concern_id,
               concern_id=excluded.concern_id,
-              mode=excluded.mode,
               action=excluded.action,
               action=excluded.action,
               target_strategy=excluded.target_strategy,
               target_strategy=excluded.target_strategy,
               target_policy_json=excluded.target_policy_json,
               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,
               requires_action=excluded.requires_action,
               created_at=excluded.created_at
               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():
 def test_build_trader_control_payload_maps_replace_to_switch():
     concern = {"id": "c1", "account_id": "acct-1", "market_symbol": "xrpusd"}
     concern = {"id": "c1", "account_id": "acct-1", "market_symbol": "xrpusd"}
     decision = DecisionSnapshot(
     decision = DecisionSnapshot(
-        mode="act",
         action="replace_with_exposure_protector",
         action="replace_with_exposure_protector",
         target_strategy="protect-1",
         target_strategy="protect-1",
         reason_summary="inventory repair should start",
         reason_summary="inventory repair should start",
@@ -28,7 +27,6 @@ def test_build_trader_control_payload_maps_replace_to_switch():
         "action": "switch",
         "action": "switch",
         "target_strategy_id": "protect-1",
         "target_strategy_id": "protect-1",
         "expected_active_strategy_id": "grid-1",
         "expected_active_strategy_id": "grid-1",
-        "risk_mode": None,
         "reason": "inventory repair should start",
         "reason": "inventory repair should start",
         "confidence": 0.81,
         "confidence": 0.81,
         "dry_run": False,
         "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():
 async def test_dispatch_is_blocked_when_hermes_allow_actions_is_false():
     concern = {"id": "c1", "account_id": "acct-1", "market_symbol": "xrpusd"}
     concern = {"id": "c1", "account_id": "acct-1", "market_symbol": "xrpusd"}
     decision = DecisionSnapshot(
     decision = DecisionSnapshot(
-        mode="act",
         action="replace_with_grid",
         action="replace_with_grid",
         target_strategy="grid-1",
         target_strategy="grid-1",
         reason_summary="range conditions support grid again",
         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):
 async def test_dispatch_calls_trader_when_gate_is_open(monkeypatch):
     concern = {"id": "c1", "account_id": "acct-1", "market_symbol": "xrpusd"}
     concern = {"id": "c1", "account_id": "acct-1", "market_symbol": "xrpusd"}
     decision = DecisionSnapshot(
     decision = DecisionSnapshot(
-        mode="act",
         action="replace_with_trend_follower",
         action="replace_with_trend_follower",
         target_strategy="trend-1",
         target_strategy="trend-1",
         reason_summary="persistent breakout pressure favors trend capture",
         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,
         id=decision_id,
         cycle_id=cycle_id,
         cycle_id=cycle_id,
         concern_id=concern_id,
         concern_id=concern_id,
-        mode="act",
         action="replace_with_grid",
         action="replace_with_grid",
         target_strategy="grid-1",
         target_strategy="grid-1",
         target_policy_json="{}",
         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():
 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": {}},
         {"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)
     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.action == "keep_grid"
     assert decision.target_strategy == "grid-1"
     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": {}},
         {"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)
     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.action == "keep_grid"
     assert decision.target_strategy == "grid-1"
     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": {}},
         {"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)
     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.action == "keep_grid"
     assert decision.target_strategy == "grid-1"
     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": {}},
         {"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)
     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.action == "replace_with_trend_follower"
     assert decision.target_strategy == "trend-1"
     assert decision.target_strategy == "trend-1"
     assert decision.payload["grid_breakout_pressure"]["phase"] == "confirmed"
     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,
         history_window=history_window,
     )
     )
 
 
-    assert decision.mode == "act"
+    assert decision.requires_action is True
     assert decision.action == "replace_with_trend_follower"
     assert decision.action == "replace_with_trend_follower"
     assert decision.target_strategy == "trend-1"
     assert decision.target_strategy == "trend-1"
     assert decision.payload["decision_audit"]["rapid_downside_pressure"] is True
     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,
         history_window=history_window,
     )
     )
 
 
-    assert decision.mode == "act"
+    assert decision.requires_action is True
     assert decision.action == "replace_with_exposure_protector"
     assert decision.action == "replace_with_exposure_protector"
     assert decision.target_strategy == "protect-1"
     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,
         history_window=history_window,
     )
     )
 
 
-    assert decision.mode == "act"
+    assert decision.requires_action is True
     assert decision.action == "replace_with_exposure_protector"
     assert decision.action == "replace_with_exposure_protector"
     assert decision.target_strategy == "protect-1"
     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"}},
         {"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)
     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.action == "replace_with_trend_follower"
     assert decision.target_strategy == "trend-long"
     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": {}},
         {"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)
     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.action == "keep_trend"
     assert decision.target_strategy == "trend-1"
     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": {}},
         {"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)
     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.action == "replace_with_exposure_protector"
     assert decision.target_strategy == "protect-1"
     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": {}},
         {"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)
     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.action == "replace_with_exposure_protector"
     assert decision.target_strategy == "protect-1"
     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": {}},
         {"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)
     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.action == "replace_with_exposure_protector"
     assert decision.target_strategy == "protect-1"
     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": {}},
         {"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)
     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.action == "replace_with_exposure_protector"
     assert decision.target_strategy == "protect-1"
     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": {}},
         {"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)
     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.action == "replace_with_exposure_protector"
     assert decision.target_strategy == "protect-1"
     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": {}},
         {"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)
     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.action == "replace_with_grid"
     assert decision.target_strategy == "grid-1"
     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)
     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.action == "replace_with_grid"
     assert decision.target_strategy == "grid-1"
     assert decision.target_strategy == "grid-1"
     assert "rebalanced" in decision.reason_summary
     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": {}},
         {"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)
     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.action == "replace_with_grid"
     assert decision.target_strategy == "grid-1"
     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}}},
         {"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)
     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.action == "replace_with_grid"
     assert decision.target_strategy == "grid-1"
     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": {}},
         {"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)
     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.action == "replace_with_grid"
     assert decision.target_strategy == "grid-1"
     assert decision.target_strategy == "grid-1"
     assert decision.payload["decision_audit"]["tactical_easing"] is True
     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}},
         {"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)
     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.action == "replace_with_grid"
     assert decision.target_strategy == "grid-1"
     assert decision.target_strategy == "grid-1"
     assert decision.payload["decision_audit"]["pullback_to_grid_ratio"] is not None
     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,
         strategies=strategies,
         history_window=history_window,
         history_window=history_window,
     )
     )
-    assert decision.mode == "act"
+    assert decision.requires_action is True
     assert decision.action == "replace_with_trend_follower"
     assert decision.action == "replace_with_trend_follower"
     assert decision.target_strategy == "trend-1"
     assert decision.target_strategy == "trend-1"
     assert decision.payload["decision_audit"]["pullback_to_grid_ratio"] < 1.0
     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}}},
         {"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)
     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.action == "keep_rebalancer"
     assert decision.target_strategy == "protect-1"
     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(
     result = compare_to_baseline(
         replay_input=replay_input,
         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
     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():
 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(
     upsert_concern(
         id="a1:xrpusd",
         id="a1:xrpusd",
         account_id="a1",
         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():
 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():
 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.
+