Răsfoiți Sursa

Add concern playbooks and dashboard control polish

Lukas Goldschmidt 3 săptămâni în urmă
părinte
comite
7fb1c0c720

+ 15 - 0
README.md

@@ -16,6 +16,21 @@ Hermes MCP is a FastAPI + MCP supervisor for market interpretation, strategy sel
 - Dashboard: `/dashboard/`
 - Primary tool: `report()`
 
+The dashboard now includes:
+
+- overview
+- playbooks
+- concern detail pages
+- decision changes
+
+The `report()` tool returns the current Hermes state plus a compact per-concern summary including:
+
+- concern id
+- active playbook
+- active assigned strategies
+- balances
+- total value
+
 ## Hermes to Trader control path
 
 Hermes reads Trader state via strategy snapshots and writes only through Trader's canonical action tool:

+ 5 - 0
RELEASE_NOTES.md

@@ -1,5 +1,10 @@
 # Release notes
 
+## 0.1.2
+- Hermes now supports concern-scoped playbooks with family-aware dashboard tuning for `grid-trend-rebalancer` and `trend-only`.
+- Concern detail now shows active-playbook profile data correctly and includes the compact six-card latest regimes strip.
+- Overview concern activation toggles now update in place, survive periodic sync, and Hermes report output now includes per-concern playbook, strategy, and balance summaries.
+
 ## 0.1.1
 - Argus context now flows into Hermes state, narrative, and decision logic.
 - Breakout confirmation is now time-window aware and less conservative once sustained.

+ 1 - 1
pyproject.toml

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
 
 [project]
 name = "hermes-mcp"
-version = "0.1.1"
+version = "0.1.2"
 description = "Hermes MCP server"
 requires-python = ">=3.11"
 dependencies = [

+ 1 - 1
src/hermes_mcp/__init__.py

@@ -1,4 +1,4 @@
 """Hermes MCP package."""
 
 __all__ = ["__version__"]
-__version__ = "0.1.1"
+__version__ = "0.1.2"

+ 651 - 19
src/hermes_mcp/dashboard.py

@@ -6,6 +6,603 @@ from .store import latest_cycle, latest_regime_samples, latest_states
 router = APIRouter(prefix="/dashboard", tags=["dashboard"])
 
 
+@router.get("/playbooks", response_class=HTMLResponse)
+def playbooks_page():
+    return """
+    <html>
+    <head>
+      <title>Hermes Playbooks</title>
+      <meta name="viewport" content="width=device-width, initial-scale=1" />
+      <style>
+        body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial; margin: 0; color: #111827; background: #fff; }
+        .page { width: 100%; display: flex; justify-content: center; }
+        .card { width: min(1400px, calc(100vw - 2rem)); margin: 1rem auto; padding: 1.25rem; border: 1px solid #e5e7eb; border-radius: 12px; }
+        .nav { display:flex; gap:10px; flex-wrap:wrap; margin: 10px 0 18px; }
+        .nav a { text-decoration:none; border:1px solid #d1d5db; padding:8px 10px; border-radius:8px; color:#111827; background:#fff; }
+        .grid { display:grid; grid-template-columns: 1fr 1fr; gap:16px; }
+        .panel { border:1px solid #e5e7eb; border-radius:12px; padding:14px; }
+        table { width:100%; border-collapse:collapse; margin-top:12px; }
+        th, td { border-bottom:1px solid #e5e7eb; padding:10px 8px; text-align:left; vertical-align:top; }
+        th { background:#f9fafb; }
+        .small { font-size:.92rem; color:#4b5563; }
+        .chip { display:inline-block; padding:2px 8px; border-radius:999px; font-size:12px; font-weight:600; }
+        .good { background:#dcfce7; color:#166534; }
+        .info { background:#dbeafe; color:#1d4ed8; }
+        input, select { margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px; }
+        button { padding:8px 12px; border:1px solid #2563eb; background:#2563eb; color:#fff; border-radius:8px; cursor:pointer; }
+      </style>
+      <script>
+        async function refreshPlaybooks() {
+          const res = await fetch('/dashboard/playbooks/data', { cache: 'no-store' });
+          const data = await res.json();
+          const playbooks = data.playbooks || [];
+          const concernOptions = [...new Map(playbooks.map(p => [String(p.concern?.id || p.concern_id || ''), p.concern])).values()].filter(Boolean);
+          document.getElementById('playbooks-body').innerHTML = playbooks.map(p => `
+            <tr>
+              <td><a href='/dashboard/playbooks/${encodeURIComponent(p.id || '')}'><strong>${p.name || p.id || '-'}</strong></a><div class='small'>${p.id || ''}</div></td>
+              <td>${p.concern?.account_id || '-'} / ${p.concern?.market_symbol || p.concern_id || '-'}</td>
+              <td>${p.strategy_family || '-'}</td>
+              <td><span class='chip ${String(p.status || '').toLowerCase() === 'active' ? 'good' : 'info'}'>${p.status || '-'}</span></td>
+              <td>${p.assignment_count || 0}</td>
+              <td>${p.decision_profile_id || '-'}</td>
+            </tr>`).join('') || `<tr><td colspan='6'>No playbooks yet.</td></tr>`;
+          document.getElementById('concern-id').innerHTML = concernOptions.map(c => `<option value='${c.id || ''}'>${c.account_id || ''} / ${c.market_symbol || ''}</option>`).join('');
+        }
+        async function createPlaybook() {
+          const concernId = document.getElementById('concern-id').value;
+          const name = document.getElementById('playbook-name').value;
+          const strategyFamily = document.getElementById('strategy-family').value;
+          if (!concernId || !name) return;
+          const res = await fetch(`/dashboard/concerns/${encodeURIComponent(concernId)}/playbooks/create`, {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify({ name, strategy_family: strategyFamily }),
+          });
+          const data = await res.json().catch(() => ({}));
+          if (!res.ok || !data.ok) {
+            alert(data.error || 'Failed to create playbook');
+            return;
+          }
+          window.location.href = `/dashboard/playbooks/${encodeURIComponent(data.playbook_id)}`;
+        }
+        window.addEventListener('load', refreshPlaybooks);
+      </script>
+    </head>
+    <body><div class='page'><div class='card'>
+      <h1>Playbooks</h1>
+      <div class='nav'>
+        <a href='/dashboard/'>Overview</a>
+        <a href='/dashboard/playbooks'>Playbooks</a>
+        <a href='/dashboard/changes'>Decision changes</a>
+      </div>
+      <div class='grid'>
+        <div class='panel'>
+          <h2 style='margin-top:0'>Existing playbooks</h2>
+          <table>
+            <tr><th>Name</th><th>Concern</th><th>Family</th><th>Status</th><th>Strategies</th><th>Profile</th></tr>
+            <tbody id='playbooks-body'><tr><td colspan='6'>Loading…</td></tr></tbody>
+          </table>
+        </div>
+        <div class='panel'>
+          <h2 style='margin-top:0'>Create playbook</h2>
+          <label>Concern<select id='concern-id'></select></label>
+          <label>Name<input id='playbook-name' placeholder='trend-only'></label>
+          <label>Family<select id='strategy-family'><option value='mixed'>mixed</option><option value='trend-only'>trend-only</option><option value='grid-trend-rebalancer'>grid-trend-rebalancer</option></select></label>
+          <div class='small' style='margin:10px 0 14px'>This creates an empty playbook that reuses existing strategies. No new trader strategies are spawned here.</div>
+          <button type='button' onclick='createPlaybook()'>Create playbook</button>
+        </div>
+      </div>
+    </div></div></body></html>
+    """
+
+
+@router.get("/playbooks/{playbook_id}", response_class=HTMLResponse)
+def playbook_detail_page(playbook_id: str):
+    playbook_id = str(playbook_id or "").strip()
+    template = """
+    <html>
+    <head>
+      <title>Hermes Playbook Detail</title>
+      <meta name="viewport" content="width=device-width, initial-scale=1" />
+      <style>
+        body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial; margin: 0; color: #111827; background: #fff; }
+        .page { width: 100%; display: flex; justify-content: center; }
+        .card { width: min(1400px, calc(100vw - 2rem)); margin: 1rem auto; padding: 1.25rem; border: 1px solid #e5e7eb; border-radius: 12px; }
+        .nav { display:flex; gap:10px; flex-wrap:wrap; margin: 10px 0 18px; }
+        .nav a { text-decoration:none; border:1px solid #d1d5db; padding:8px 10px; border-radius:8px; color:#111827; background:#fff; }
+        .grid { display:grid; grid-template-columns: 1fr 1fr; gap:16px; }
+        .panel { border:1px solid #e5e7eb; border-radius:12px; padding:14px; }
+        table { width:100%; border-collapse:collapse; margin-top:12px; }
+        th, td { border-bottom:1px solid #e5e7eb; padding:10px 8px; text-align:left; vertical-align:top; }
+        th { background:#f9fafb; }
+        .small { font-size:.92rem; color:#4b5563; }
+        input, select { margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px; }
+        button { padding:8px 12px; border:1px solid #2563eb; background:#2563eb; color:#fff; border-radius:8px; cursor:pointer; }
+      </style>
+      <script>
+        async function refreshPlaybook() {
+          const res = await fetch('/dashboard/playbooks/__PLAYBOOK_ID__/data', { cache: 'no-store' });
+          const data = await res.json();
+          if (!data.ok) {
+            document.getElementById('root').innerHTML = '<div class="panel">Playbook not found.</div>';
+            return;
+          }
+          const playbook = data.playbook || {};
+          const concern = data.concern || {};
+          const assignments = data.assignments || [];
+          const available = data.available_strategies || [];
+          document.getElementById('title').textContent = playbook.name || playbook.id || 'Playbook';
+          document.getElementById('root').innerHTML = `
+            <div class='grid'>
+              <div class='panel'>
+                <h2 style='margin-top:0'>Playbook</h2>
+                <div><strong>Concern</strong>: ${concern.account_id || '-'} / ${concern.market_symbol || '-'}</div>
+                <div><strong>Status</strong>: ${playbook.status || '-'}</div>
+                <div><strong>Family</strong>: ${playbook.strategy_family || '-'}</div>
+                <div><strong>Decision profile</strong>: ${playbook.decision_profile_id || '-'}</div>
+              </div>
+              <div class='panel'>
+                <h2 style='margin-top:0'>Assign existing strategy</h2>
+                <label>Strategy<select id='strategy-id'>${available.map(s => `<option value='${s.id || ''}' data-type='${s.strategy_type || ''}'>${s.display_label || s.id || ''}</option>`).join('')}</select></label>
+                <label>Role<select id='strategy-role'><option value='member'>member</option><option value='trend_buy'>trend_buy</option><option value='trend_sell'>trend_sell</option><option value='grid'>grid</option><option value='rebalancer'>rebalancer</option></select></label>
+                <div class='small' style='margin:10px 0 14px'>This reuses already discovered trader strategies on the same concern.</div>
+                <button type='button' onclick='assignStrategy()'>Assign strategy</button>
+              </div>
+            </div>
+            <div class='panel' style='margin-top:16px'>
+              <h2 style='margin-top:0'>Assigned strategies</h2>
+              <table>
+                <tr><th>Label</th><th>Type</th><th>Role</th><th>Status</th><th>Action</th></tr>
+                ${assignments.map(a => `<tr><td><strong>${a.strategy_label || a.strategy_id || '-'}</strong><div class='small'>${a.strategy_id || ''}</div></td><td>${a.strategy_type || '-'}</td><td>${a.role || '-'}</td><td>${a.status || '-'}</td><td><button type='button' onclick='removeAssignment("${a.id || ''}")'>Remove</button></td></tr>`).join('') || `<tr><td colspan='5'>No strategies assigned yet.</td></tr>`}
+              </table>
+            </div>`;
+        }
+        async function assignStrategy() {
+          const strategySelect = document.getElementById('strategy-id');
+          const role = document.getElementById('strategy-role').value;
+          const strategyId = strategySelect.value;
+          const strategyType = strategySelect.options[strategySelect.selectedIndex]?.dataset?.type || '';
+          if (!strategyId) return;
+          const res = await fetch('/dashboard/playbooks/__PLAYBOOK_ID__/assignments/upsert', {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify({ strategy_id: strategyId, strategy_type: strategyType, role }),
+          });
+          const data = await res.json().catch(() => ({}));
+          if (!res.ok || !data.ok) { alert(data.error || 'Failed to assign strategy'); return; }
+          await refreshPlaybook();
+        }
+        async function removeAssignment(assignmentId) {
+          const res = await fetch(`/dashboard/playbooks/__PLAYBOOK_ID__/assignments/${encodeURIComponent(assignmentId)}/delete`, { method: 'POST' });
+          const data = await res.json().catch(() => ({}));
+          if (!res.ok || !data.ok) { alert(data.error || 'Failed to remove assignment'); return; }
+          await refreshPlaybook();
+        }
+        window.addEventListener('load', refreshPlaybook);
+      </script>
+    </head>
+    <body><div class='page'><div class='card'>
+      <h1 id='title'>Playbook detail</h1>
+      <div class='nav'>
+        <a href='/dashboard/'>Overview</a>
+        <a href='/dashboard/playbooks'>Playbooks</a>
+        <a href='/dashboard/changes'>Decision changes</a>
+      </div>
+      <div id='root'><div class='panel'>Loading…</div></div>
+    </div></div></body></html>
+    """
+    return HTMLResponse(template.replace("__PLAYBOOK_ID__", playbook_id))
+
+
+@router.get("/concerns/{concern_id}", response_class=HTMLResponse)
+def concern_detail(concern_id: str):
+    concern_id = str(concern_id or "").strip()
+    template = """
+    <html>
+    <head>
+      <title>Hermes Concern Detail</title>
+      <meta name="viewport" content="width=device-width, initial-scale=1" />
+      <style>
+        body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial; margin: 0; color: #111827; background: #fff; }
+        .page { width: 100%; display: flex; justify-content: center; }
+        .card { width: min(1400px, calc(100vw - 2rem)); margin: 1rem auto; padding: 1.25rem; border: 1px solid #e5e7eb; border-radius: 12px; }
+        .muted { color: #6b7280; }
+        .nav { display:flex; gap:10px; flex-wrap:wrap; margin: 10px 0 18px; }
+        .nav a { text-decoration:none; border:1px solid #d1d5db; padding:8px 10px; border-radius:8px; color:#111827; background:#fff; }
+        .stack { display:grid; gap:16px; }
+        .grid { display:grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap:16px; }
+        .panel { border:1px solid #e5e7eb; border-radius:12px; padding:14px; background:#fff; }
+        .pill, .chip { display:inline-block; padding:2px 10px; border-radius:999px; background:#f3f4f6; font-size:.9em; }
+        .chip { font-size:12px; font-weight:600; margin-right:6px; }
+        .regime-grid { display:grid; grid-template-columns: repeat(6, minmax(140px, 1fr)); gap: 10px; }
+        .regime-card { border:1px solid #e5e7eb; border-radius: 14px; padding: 10px; background: linear-gradient(180deg, #fff, #fafafa); min-width: 0; }
+        .chips { display:flex; gap:6px; flex-wrap:wrap; margin: 8px 0; }
+        .good { background:#dcfce7; color:#166534; }
+        .info { background:#dbeafe; color:#1d4ed8; }
+        .warn { background:#fef3c7; color:#92400e; }
+        .bad { background:#fee2e2; color:#991b1b; }
+        .neutral { background:#e5e7eb; color:#374151; }
+        table { width:100%; border-collapse: collapse; }
+        th, td { border-bottom: 1px solid #e5e7eb; padding: 10px 8px; text-align:left; vertical-align:top; }
+        th { background:#f9fafb; }
+        .small { font-size:.92rem; color:#4b5563; }
+        code { background:#f3f4f6; padding:2px 6px; border-radius:6px; }
+        .spark { width:100%; height:40px; display:block; margin-top:8px; }
+        .spark-block { margin-top: 8px; }
+        .spark-label { display:flex; justify-content:space-between; gap:8px; font-size:12px; color:#6b7280; }
+        @media (max-width: 900px) { .grid { grid-template-columns: 1fr; } }
+      </style>
+      <script>
+        function parseJson(value, fallback) {
+          try { return JSON.parse(value || 'null') ?? fallback; } catch { return fallback; }
+        }
+        function regimeColor(state) {
+          const s = String(state || '').toLowerCase();
+          if (['bull', 'bullish', 'strong', 'up', 'positive'].includes(s)) return 'good';
+          if (['bear', 'bearish', 'down', 'negative'].includes(s)) return 'bad';
+          if (['neutral', 'range', 'chop', 'sideways'].includes(s)) return 'neutral';
+          return 'warn';
+        }
+        function sparkline(values, stroke='#2563eb') {
+          if (!values.length) return '';
+          const min = Math.min(...values), max = Math.max(...values);
+          const span = (max - min) || 1;
+          const points = values.map((v, i) => `${(i/(values.length-1||1))*100},${40 - ((v-min)/span)*40}`).join(' ');
+          return `<svg class='spark' viewBox='0 0 100 40' preserveAspectRatio='none'><polyline fill='none' stroke='${stroke}' stroke-width='2' points='${points}' /></svg>`;
+        }
+        function modeChip(value) {
+          const v = String(value || '').toLowerCase();
+          if (['act', 'active', 'running', 'keep_grid', 'keep_trend', 'keep_rebalancer'].includes(v)) return 'good';
+          if (['observe', 'wait', 'warn'].includes(v)) return 'info';
+          return 'warn';
+        }
+        async function activateSelectedPlaybook() {
+          const select = document.getElementById('playbook-select');
+          if (!select || !select.value) return;
+          const res = await fetch(`/dashboard/concerns/__CONCERN_ID__/playbooks/${encodeURIComponent(select.value)}/activate`, { method: 'POST' });
+          const data = await res.json().catch(() => ({}));
+          if (!res.ok || !data.ok) {
+            alert(data.error || 'Failed to activate playbook');
+            return;
+          }
+          await refreshDetail();
+        }
+        async function saveTuning() {
+          const select = document.getElementById('playbook-select');
+          if (!select || !select.value) return;
+          const status = document.getElementById('tuning-save-status');
+          if (status) status.textContent = 'Saving…';
+          const payload = {
+            breakout_persistence_min: Number(document.getElementById('tune-breakout-persistence-min')?.value),
+            short_term_confirmation_min: Number(document.getElementById('tune-short-term-confirmation-min')?.value),
+            switch_cost_penalty: Number(document.getElementById('tune-switch-cost-penalty')?.value),
+            rebalance_imbalance_threshold: Number(document.getElementById('tune-rebalance-imbalance-threshold')?.value),
+            force_grid_when_balanced: Boolean(document.getElementById('tune-force-grid-when-balanced')?.checked),
+            grid_release_threshold: Number(document.getElementById('tune-grid-release-threshold')?.value),
+            trend_cooling_threshold: Number(document.getElementById('tune-trend-cooling-threshold')?.value),
+            trend_inventory_stress_threshold: Number(document.getElementById('tune-trend-inventory-stress-threshold')?.value),
+            action_cooldown_seconds: Number(document.getElementById('tune-action-cooldown-seconds')?.value),
+            estimated_turn_cost_pct: Number(document.getElementById('tune-estimated-turn-cost-pct')?.value),
+            micro_trend_weight: Number(document.getElementById('tune-micro-trend-weight')?.value),
+            meso_trend_weight: Number(document.getElementById('tune-meso-trend-weight')?.value),
+            macro_trend_weight: Number(document.getElementById('tune-macro-trend-weight')?.value),
+            persistence_bonus_weight: Number(document.getElementById('tune-persistence-bonus-weight')?.value),
+            argus_compression_penalty: Number(document.getElementById('tune-argus-compression-penalty')?.value),
+            activation_edge_threshold: Number(document.getElementById('tune-activation-edge-threshold')?.value),
+            flip_edge_threshold: Number(document.getElementById('tune-flip-edge-threshold')?.value),
+            flip_confirmation_gap: Number(document.getElementById('tune-flip-confirmation-gap')?.value),
+          };
+          const res = await fetch(`/dashboard/concerns/__CONCERN_ID__/playbooks/${encodeURIComponent(select.value)}/tuning`, {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify(payload),
+          });
+          const data = await res.json().catch(() => ({}));
+          if (!res.ok || !data.ok) {
+            if (status) status.textContent = 'Save failed';
+            alert(data.error || 'Failed to save tuning');
+            return;
+          }
+          if (status) status.textContent = 'Saved';
+          await refreshDetail();
+        }
+        async function refreshDetail() {
+          const res = await fetch('/dashboard/concerns/__CONCERN_ID__/data', { cache: 'no-store' });
+          const data = await res.json();
+          if (!data.ok) {
+            document.getElementById('root').innerHTML = `<div class='panel'><strong>Concern not found.</strong></div>`;
+            return;
+          }
+          const concern = data.concern || {};
+          const profile = data.decision_profile || null;
+          const playbooks = data.playbooks || [];
+          const strategies = data.strategies || [];
+          const decision = data.latest_decision || null;
+          const decisionPayload = parseJson(decision?.target_policy_json, {});
+          const wallet = decisionPayload.wallet_state || {};
+          const latestState = data.latest_state || null;
+          const latestStatePayload = parseJson(latestState?.payload_json, {});
+          const latestNarrative = data.latest_narrative || null;
+          const latestRegimes = data.latest_regimes || [];
+
+          const activePlaybook = playbooks.find(p => String(p.status || '').toLowerCase() === 'active') || playbooks[0] || null;
+          const selectedPlaybookId = activePlaybook?.id || '';
+          const activePlaybookFamily = String(activePlaybook?.strategy_family || 'mixed');
+          const tuning = profile?.config || {};
+          const desiredOrder = ['1d', '4h', '1h', '15m', '5m', '1m'];
+          const latestByTf = new Map();
+          const historiesByTf = new Map();
+          for (const sample of latestRegimes) {
+            const tf = String(sample.timeframe || '').toLowerCase();
+            if (!tf) continue;
+            if (!latestByTf.has(tf)) latestByTf.set(tf, sample);
+            if (!historiesByTf.has(tf)) historiesByTf.set(tf, []);
+            historiesByTf.get(tf).push(sample);
+          }
+          const regimeCards = desiredOrder
+            .map(tf => latestByTf.get(tf))
+            .filter(Boolean)
+            .map(r => {
+              const parsed = parseJson(r.regime_json, {});
+              const rawError = parsed.raw || parsed.error || '';
+              const trend = parsed.trend?.state || 'neutral';
+              const momentum = parsed.momentum?.state || 'neutral';
+              const reversal = parsed.reversal?.direction || 'none';
+              const strength = parsed.reversal?.score ?? 0;
+              const hist = (historiesByTf.get(String(r.timeframe || '').toLowerCase()) || [])
+                .slice()
+                .sort((a, b) => String(a.captured_at || '').localeCompare(String(b.captured_at || '')))
+                .map(x => parseJson(x.regime_json, {}));
+              const prices = hist.map(x => Number(x.price)).filter(Number.isFinite).slice(-24);
+              const atrs = hist.map(x => Number(x.volatility?.atr_percent)).filter(Number.isFinite).slice(-24);
+              const rsis = hist.map(x => Number(x.momentum?.rsi)).filter(Number.isFinite).slice(-24);
+              return `
+                <div class='regime-card'>
+                  <div><strong>${concern.market_display || concern.market_symbol || 'Market'} · ${r.timeframe || ''}</strong></div>
+                  <div class='chips'>
+                    ${rawError ? `<span class='chip bad'>regime unavailable</span><span class='chip warn'>symbol issue</span>` : `
+                    <span class='chip ${regimeColor(trend)}'>trend: ${trend}</span>
+                    <span class='chip ${regimeColor(momentum)}'>momentum: ${momentum}</span>
+                    <span class='chip ${regimeColor(reversal)}'>reversal: ${reversal}</span>
+                    <span class='chip neutral'>strength: ${strength}</span>`}
+                  </div>
+                  ${rawError ? `<div class='small' style='color:#991b1b'><strong>error</strong>: ${rawError}</div>` : ''}
+                  <div class='spark-block'><div class='spark-label'><span>Price: ${parsed.price ?? '-'}</span><span>${prices.length} / 24</span></div>${sparkline(prices, '#2563eb')}</div>
+                  <div class='spark-block'><div class='spark-label'><span>ATR %: ${parsed.volatility?.atr_percent ?? '-'}</span><span>${atrs.length} / 24</span></div>${sparkline(atrs, '#d97706')}</div>
+                  <div class='spark-block'><div class='spark-label'><span>Momentum RSI: ${parsed.momentum?.rsi ?? '-'}</span><span>${rsis.length} / 24</span></div>${sparkline(rsis, '#16a34a')}</div>
+                </div>`;
+            }).join('') || `<div class='muted'>No regime samples yet.</div>`;
+
+          document.getElementById('title').textContent = `${concern.account_display || concern.account_id || ''} · ${concern.market_display || concern.market_symbol || ''}`;
+          document.getElementById('subtitle').textContent = concern.id || '';
+
+          document.getElementById('root').innerHTML = `
+            <div class='stack'>
+              <div class='grid'>
+                <div class='panel'>
+                  <h2 style='margin-top:0'>Concern</h2>
+                  <div><strong>Account</strong>: ${concern.account_display || concern.account_id || '-'}</div>
+                  <div><strong>Market</strong>: ${concern.market_display || concern.market_symbol || '-'}</div>
+                  <div><strong>Status</strong>: <span class='chip ${modeChip(concern.status)}'>${concern.status || '-'}</span></div>
+                  <div><strong>Balances</strong>: ${concern.balance_summary || '-'}</div>
+                  <div class='small' style='margin-top:8px'><strong>Total value</strong>: ${typeof concern.total_value_usd === 'number' ? concern.total_value_usd.toFixed(2) : '-'}</div>
+                </div>
+                <div class='panel'>
+                  <h2 style='margin-top:0'>Active decision state</h2>
+                  <div><strong>Latest action</strong>: <span class='chip ${modeChip(decision?.action)}'>${decision?.action || '-'}</span></div>
+                  <div><strong>Mode</strong>: <span class='chip ${modeChip(decision?.mode)}'>${decision?.mode || '-'}</span></div>
+                  <div><strong>Reason</strong>: ${decision?.reason_summary || '-'}</div>
+                  <div class='small' style='margin-top:8px'><strong>Wallet</strong>: ${wallet.inventory_state || '-'}${wallet.grid_ready ? ' · grid-ready' : ''}${wallet.rebalance_needed ? ' · rebalance-needed' : ''}</div>
+                  <div class='small'><strong>State</strong>: ${latestState ? `${latestState.market_regime || '-'} / ${latestState.volatility_state || '-'} / ${latestState.sentiment_pressure || '-'}` : '-'}</div>
+                </div>
+              </div>
+
+              <div class='panel'>
+                <h2 style='margin-top:0'>Decision profile</h2>
+                ${profile ? `
+                  <div><strong>${profile.name || profile.id || '-'}</strong></div>
+                  <div class='small'>Family: ${activePlaybookFamily || '-'}</div>
+                  <div class='small'>${profile.description || ''}</div>
+                  <div class='small' style='margin-top:8px'>Profile id: <code>${profile.id || '-'}</code></div>
+                  <pre style='white-space:pre-wrap;margin-top:10px'>${JSON.stringify(profile.config || {}, null, 2)}</pre>
+                ` : `<div class='muted'>No decision profile attached yet.</div>`}
+              </div>
+
+              <div class='panel'>
+                <h2 style='margin-top:0'>Latest regimes</h2>
+                <div class='regime-grid'>${regimeCards}</div>
+              </div>
+
+              <div class='panel'>
+                <h2 style='margin-top:0'>Attached playbooks</h2>
+                <div style='margin-bottom:12px'>
+                  <label for='playbook-select'><strong>Selected playbook</strong></label><br>
+                  <select id='playbook-select' style='margin-top:6px; min-width:280px; padding:8px 10px; border:1px solid #d1d5db; border-radius:8px'>
+                    ${playbooks.map(p => `<option value='${p.id || ''}' ${String(p.id || '') === String(selectedPlaybookId) ? 'selected' : ''}>${p.name || p.id || '-'}${String(p.status || '').toLowerCase() === 'active' ? ' (active)' : ''}</option>`).join('') || `<option>no playbooks</option>`}
+                  </select>
+                  <button type='button' onclick='activateSelectedPlaybook()' style='margin-left:8px; padding:8px 12px; border:1px solid #2563eb; background:#2563eb; color:#fff; border-radius:8px; cursor:pointer'>Activate</button>
+                  <div class='small' style='margin-top:6px'>Activate the selected playbook for this concern, then tune the active profile below.</div>
+                </div>
+                ${playbooks.length ? `
+                  <table>
+                    <tr><th>name</th><th>family</th><th>profile</th><th>strategies</th><th>status</th></tr>
+                    ${playbooks.map(p => `
+                      <tr>
+                        <td><strong>${p.name || p.id || '-'}</strong><div class='small'>${p.id || ''}</div></td>
+                        <td>${p.strategy_family || '-'}</td>
+                        <td>${p.decision_profile_id || '-'}</td>
+                        <td>${(p.assignments || []).map(a => `${a.strategy_label || a.strategy_id || '-'}<div class='small'>${a.strategy_type || 'strategy'} · ${a.strategy_id || '-'}</div>`).join('<br>') || '-'}</td>
+                        <td>${p.status || '-'}</td>
+                      </tr>
+                    `).join('')}
+                  </table>
+                ` : `<div class='muted'>No playbooks attached yet.</div>`}
+              </div>
+
+              <div class='grid'>
+                <div class='panel'>
+                  <h2 style='margin-top:0'>Trader strategies on this concern</h2>
+                  ${strategies.length ? `<ul>${strategies.map(s => `<li><strong>${s.display_label || s.strategy_type || s.id || 'strategy'}</strong> <span class='small'>${s.strategy_type || 'strategy'} · ${s.id || ''} · ${s.mode || 'unknown'}</span></li>`).join('')}</ul>` : `<div class='muted'>No trader strategies visible for this concern.</div>`}
+                </div>
+                <div class='panel'>
+                  <h2 style='margin-top:0'>Narrative snapshot</h2>
+                  <div>${latestNarrative?.summary || '-'}</div>
+                  <div class='small' style='margin-top:8px'><strong>Cross-scope</strong>: ${latestStatePayload.cross_scope_summary?.alignment || '-'} / ${latestStatePayload.cross_scope_summary?.opportunity_type || '-'}</div>
+                  <div class='small'><strong>Micro</strong>: ${latestStatePayload.scoped_state?.micro?.impulse || '-'} / ${latestStatePayload.scoped_state?.micro?.location || '-'}</div>
+                </div>
+              </div>
+
+              <div class='panel'>
+                <h2 style='margin-top:0'>Initial tuning</h2>
+                <div class='small' style='margin-bottom:12px'>This is the first practical tuning pass. Finer micro-trend and momentum weighting can come later.</div>
+                ${activePlaybookFamily === 'trend-only' ? `
+                <div class='stack'>
+                  <div class='panel' style='background:#fafafa'>
+                    <h3 style='margin-top:0'>Buy ↔ Sell switching</h3>
+                    <div class='grid'>
+                      <div>
+                        <label title='Higher means Hermes needs more directional edge before activating a side at all. Lower means it engages sooner.'>Activation edge threshold<br><input id='tune-activation-edge-threshold' type='number' step='0.01' value='${Number(tuning.activation_edge_threshold ?? 1.15)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
+                        <div class='small'>Higher = more selective side activation.</div>
+                      </div>
+                      <div>
+                        <label title='Higher means Hermes needs stronger opposite evidence before flipping from buy to sell or sell to buy. Lower means it flips more readily.'>Flip edge threshold<br><input id='tune-flip-edge-threshold' type='number' step='0.01' value='${Number(tuning.flip_edge_threshold ?? 1.35)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
+                        <div class='small'>Higher = fewer flips. Lower = more reactive.</div>
+                      </div>
+                      <div>
+                        <label title='Higher means the new side must beat the current side by a larger margin before Hermes flips. Lower means smaller gaps can trigger a turn.'>Flip confirmation gap<br><input id='tune-flip-confirmation-gap' type='number' step='0.01' value='${Number(tuning.flip_confirmation_gap ?? 0.25)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
+                        <div class='small'>Higher = more reluctance to reverse.</div>
+                      </div>
+                      <div>
+                        <label title='Higher means Hermes assumes a more expensive side flip and therefore avoids switching more often. Lower means cheaper flips and more responsiveness.'>Estimated turn cost %<br><input id='tune-estimated-turn-cost-pct' type='number' step='0.01' value='${Number(tuning.estimated_turn_cost_pct ?? 0.7)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
+                        <div class='small'>Higher = stronger fee-aware reluctance.</div>
+                      </div>
+                    </div>
+                  </div>
+
+                  <div class='panel' style='background:#fafafa'>
+                    <h3 style='margin-top:0'>Signal weighting</h3>
+                    <div class='grid'>
+                      <div>
+                        <label title='Higher means 1m and 5m trend manifestation has more influence on the side choice. Lower means micro tape matters less.'>Micro trend weight<br><input id='tune-micro-trend-weight' type='number' step='0.01' value='${Number(tuning.micro_trend_weight ?? 0.8)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
+                        <div class='small'>Higher = more sensitive to fast trend/momentum.</div>
+                      </div>
+                      <div>
+                        <label title='Higher means meso structure and 5m directional bias dominate more strongly. Lower means meso trend matters less.'>Meso trend weight<br><input id='tune-meso-trend-weight' type='number' step='0.01' value='${Number(tuning.meso_trend_weight ?? 1.0)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
+                        <div class='small'>Higher = more respect for persistent structure.</div>
+                      </div>
+                      <div>
+                        <label title='Higher means macro directional context has more say in keeping or flipping the side. Lower means macro backdrop matters less.'>Macro trend weight<br><input id='tune-macro-trend-weight' type='number' step='0.01' value='${Number(tuning.macro_trend_weight ?? 0.7)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
+                        <div class='small'>Higher = more patience with the larger trend.</div>
+                      </div>
+                      <div>
+                        <label title='Higher means repeated same-direction evidence builds confidence faster. Lower means persistence contributes less.'>Persistence bonus weight<br><input id='tune-persistence-bonus-weight' type='number' step='0.01' value='${Number(tuning.persistence_bonus_weight ?? 0.45)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
+                        <div class='small'>Higher = more reward for steady directional persistence.</div>
+                      </div>
+                    </div>
+                  </div>
+
+                  <div class='panel' style='background:#fafafa'>
+                    <h3 style='margin-top:0'>Argus context</h3>
+                    <div class='grid'>
+                      <div>
+                        <label title='Higher means Argus compression penalizes directional conviction more strongly. Lower means Hermes is less discouraged by compression.'>Argus compression penalty<br><input id='tune-argus-compression-penalty' type='number' step='0.01' value='${Number(tuning.argus_compression_penalty ?? 0.18)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
+                        <div class='small'>Higher = more cautious in compressed/range-like conditions.</div>
+                      </div>
+                    </div>
+                  </div>
+                </div>` : `
+                <div class='stack'>
+                  <div class='panel' style='background:#fafafa'>
+                    <h3 style='margin-top:0'>Grid → Trend</h3>
+                    <div class='grid'>
+                      <div>
+                        <label title='Higher means Hermes waits for more persistent breakout evidence before leaving grid. Lower means it flips to trend sooner.'>Breakout persistence<br><input id='tune-breakout-persistence-min' type='number' step='0.01' value='${Number(tuning.breakout_persistence_min ?? 0.65)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
+                        <div class='small'>Higher = harder to leave grid. Lower = easier to hand off.</div>
+                      </div>
+                      <div>
+                        <label title='Higher means 1m and 5m must agree more strongly before Hermes leaves grid. Lower makes short-term confirmation easier.'>Short-term confirmation<br><input id='tune-short-term-confirmation-min' type='number' step='0.01' value='${Number(tuning.short_term_confirmation_min ?? 0.32)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
+                        <div class='small'>Higher = stronger micro confirmation required.</div>
+                      </div>
+                      <div>
+                        <label title='Higher means switching out of grid should be treated as more expensive. Lower means Hermes is less reluctant to hand off.'>Switch cost penalty<br><input id='tune-switch-cost-penalty' type='number' step='0.01' value='${Number(tuning.switch_cost_penalty ?? 1.0)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
+                        <div class='small'>Higher = stickier grid. Lower = faster handoff.</div>
+                      </div>
+                    </div>
+                  </div>
+
+                  <div class='panel' style='background:#fafafa'>
+                    <h3 style='margin-top:0'>Grid → Rebalancer</h3>
+                    <div class='grid'>
+                      <div>
+                        <label title='Higher means Hermes tolerates more inventory skew before rebalancing. Lower means it repairs earlier.'>Rebalance imbalance threshold<br><input id='tune-rebalance-imbalance-threshold' type='number' step='0.01' value='${Number(tuning.rebalance_imbalance_threshold ?? 0.30)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
+                        <div class='small'>Higher = later repair. Lower = earlier repair.</div>
+                      </div>
+                    </div>
+                  </div>
+
+                  <div class='panel' style='background:#fafafa'>
+                    <h3 style='margin-top:0'>Rebalancer → Grid</h3>
+                    <div class='grid'>
+                      <div>
+                        <label title='Higher means Hermes wants a cleaner, more harvestable tape before giving control back to grid. Lower means grid resumes earlier.'>Grid release threshold<br><input id='tune-grid-release-threshold' type='number' step='0.01' value='${Number(tuning.grid_release_threshold ?? 0.35)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
+                        <div class='small'>Higher = more cautious handback to grid.</div>
+                      </div>
+                    </div>
+                    <div style='margin-top:12px'>
+                      <label title='When enabled, a balanced wallet hands back to grid first. When disabled, Hermes may keep the rebalancer longer.'><input id='tune-force-grid-when-balanced' type='checkbox' ${Boolean(tuning.force_grid_when_balanced ?? true) ? 'checked' : ''}> Force grid when balanced</label>
+                    </div>
+                  </div>
+
+                  <div class='panel' style='background:#fafafa'>
+                    <h3 style='margin-top:0'>Trend → Rebalancer</h3>
+                    <div class='grid'>
+                      <div>
+                        <label title='Higher means trend must cool more clearly before Hermes gives up directional mode. Lower means it exits trend sooner.'>Trend cooling threshold<br><input id='tune-trend-cooling-threshold' type='number' step='0.01' value='${Number(tuning.trend_cooling_threshold ?? 0.45)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
+                        <div class='small'>Higher = stay in trend longer.</div>
+                      </div>
+                      <div>
+                        <label title='Higher means Hermes tolerates more wallet stress before leaving trend to repair inventory. Lower means it protects the wallet sooner.'>Trend inventory stress threshold<br><input id='tune-trend-inventory-stress-threshold' type='number' step='0.01' value='${Number(tuning.trend_inventory_stress_threshold ?? 0.55)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
+                        <div class='small'>Higher = protect later. Lower = protect sooner.</div>
+                      </div>
+                    </div>
+                  </div>
+
+                  <div class='panel' style='background:#fafafa'>
+                    <h3 style='margin-top:0'>Global</h3>
+                    <div class='grid'>
+                      <div>
+                        <label title='Higher means Hermes should wait longer between action changes. Lower means it can react again sooner.'>Action cooldown seconds<br><input id='tune-action-cooldown-seconds' type='number' step='1' value='${Number(tuning.action_cooldown_seconds ?? 600)}' style='margin-top:6px; width:100%; padding:8px; border:1px solid #d1d5db; border-radius:8px'></label>
+                        <div class='small'>Higher = slower re-action. Lower = faster re-action.</div>
+                      </div>
+                    </div>
+                  </div>
+                </div>`}
+                <div style='margin-top:14px'>
+                  <button type='button' onclick='saveTuning()' style='padding:8px 12px; border:1px solid #2563eb; background:#2563eb; color:#fff; border-radius:8px; cursor:pointer'>Save tuning</button>
+                  <span id='tuning-save-status' class='small' style='margin-left:10px'></span>
+                </div>
+              </div>
+            </div>`;
+        }
+        window.addEventListener('load', refreshDetail);
+      </script>
+    </head>
+    <body>
+      <div class="page"><div class="card">
+        <h1 id="title">Concern detail</h1>
+        <p id="subtitle" class="muted"></p>
+      <div class="nav">
+        <a href="/dashboard/">Overview</a>
+        <a href="/dashboard/playbooks">Playbooks</a>
+        <a href="/dashboard/changes">Decision changes</a>
+      </div>
+        <div id="root"><div class='panel muted'>Loading concern detail…</div></div>
+      </div></div>
+    </body></html>
+    """
+    return HTMLResponse(template.replace("__CONCERN_ID__", concern_id))
+
+
 @router.get("/", response_class=HTMLResponse)
 def overview():
     cycle = latest_cycle() or {}
@@ -33,6 +630,10 @@ def overview():
         th, td {{ border-bottom: 1px solid #e5e7eb; padding: 10px 8px; text-align: left; vertical-align: top; }}
         th {{ background: #f9fafb; }}
         .pill {{ display:inline-block; padding:2px 10px; border-radius:999px; background:#f3f4f6; font-size: 0.9em; }}
+        .action-btn {{ border-radius:999px; padding:4px 12px; cursor:pointer; font-size:0.9em; text-decoration:none; display:inline-block; }}
+        .action-details {{ border:1px solid #d1d5db; background:#fff; color:#111827; }}
+        .action-activate {{ border:1px solid #16a34a; background:#dcfce7; color:#166534; }}
+        .action-deactivate {{ border:1px solid #d97706; background:#fef3c7; color:#92400e; }}
         .nav {{ display:flex; gap:10px; flex-wrap:wrap; margin: 10px 0 18px; }}
         .nav a {{ text-decoration:none; border:1px solid #d1d5db; padding:8px 10px; border-radius:8px; color:#111827; background:#fff; }}
         pre {{ white-space: pre-wrap; margin: 0; }}
@@ -172,6 +773,44 @@ def overview():
           }}
           await refreshData();
         }}
+        function renderConcernStatusCell(concern) {{
+          return `${{concern.status || ''}}${{concern.orphaned ? "<div><span class='chip warn'>orphaned</span></div>" : ''}}`;
+        }}
+        function renderConcernActionCell(concern) {{
+          return `
+            <a href="/dashboard/concerns/${{encodeURIComponent(concern.id || '')}}" class='action-btn action-details'>Details</a>
+            ${{String(concern.status || '').toLowerCase() === 'inactive'
+              ? ` <button type='button' class='action-btn action-activate' data-concern-id='${{concern.id || ''}}' onclick="setConcernStatus(this, this.dataset.concernId, 'active')">Activate</button>`
+              : ` <button type='button' class='action-btn action-deactivate' data-concern-id='${{concern.id || ''}}' onclick="setConcernStatus(this, this.dataset.concernId, 'inactive')">Deactivate</button>`}}
+            ${{concern.orphaned ? ` <button type='button' class='danger' data-concern-id='${{concern.id || ''}}' onclick="deleteConcern(this.dataset.concernId)">Delete</button>` : ''}}`;
+        }}
+        async function setConcernStatus(button, concernId, status) {{
+          if (!concernId || !status) return;
+          if (button) button.disabled = true;
+          const res = await fetch(`/dashboard/concerns/${{encodeURIComponent(concernId)}}/status`, {{
+            method: 'POST',
+            headers: {{ 'Content-Type': 'application/json' }},
+            body: JSON.stringify({{ status }}),
+          }});
+          const body = await res.json().catch(() => ({{}}));
+          if (!res.ok || !body.ok) {{
+            if (button) button.disabled = false;
+            alert(body.error || 'Failed to update concern status');
+            return;
+          }}
+          const row = button?.closest('tr');
+          if (row) {{
+            const concern = {{
+              id: concernId,
+              status,
+              orphaned: row.dataset.orphaned === 'true',
+            }};
+            const statusCell = row.querySelector('[data-role="status"]');
+            const actionCell = row.querySelector('[data-role="actions"]');
+            if (statusCell) statusCell.innerHTML = renderConcernStatusCell(concern);
+            if (actionCell) actionCell.innerHTML = renderConcernActionCell(concern);
+          }}
+        }}
         async function refreshData() {{
           const res = await fetch('/dashboard/data', {{ cache: 'no-store' }});
           const data = await res.json();
@@ -181,13 +820,13 @@ def overview():
           document.getElementById('cycle-notes').textContent = data.latest_cycle?.notes || '-';
           document.getElementById('concern-count').textContent = String(data.concerns.length);
         document.getElementById('concerns-body').innerHTML = data.concerns.map(c => `
-            <tr>
+            <tr data-concern-id='${{c.id || ''}}' data-orphaned='${{c.orphaned ? 'true' : 'false'}}'>
               <td><strong>${{c.account_display || ''}}</strong><div class='small'>${{c.id || ''}}</div></td>
               <td>${{c.market_display || c.market_symbol || ''}}<div class='small'>${{c.market_description || ''}}</div></td>
               <td>${{c.balance_summary || '-'}}<div class='small'>Total value: ${{typeof c.total_value_usd === 'number' ? c.total_value_usd.toFixed(2) : '-'}}</div></td>
               <td>${{c.source || ''}}</td>
-              <td>${{c.status || ''}}${{c.orphaned ? "<div><span class='chip warn'>orphaned</span></div>" : ''}}</td>
-              <td>${{c.orphaned ? `<button type='button' class='danger' data-concern-id='${{c.id || ''}}' onclick="deleteConcern(this.dataset.concernId)">Delete</button>` : '<span class="small">linked</span>'}}</td>
+              <td data-role='status'>${{renderConcernStatusCell(c)}}</td>
+              <td data-role='actions'>${{renderConcernActionCell(c)}}</td>
             </tr>`).join('') || "<tr><td colspan='6' class='muted'>No concerns yet.</td></tr>";
           const histories = data.regime_histories || {};
           const desiredOrder = ['1d', '4h', '1h', '15m', '5m', '1m'];
@@ -230,13 +869,15 @@ def overview():
                 <div class='spark-block'><div class='spark-label'><span>Momentum RSI: ${{parsed.momentum?.rsi ?? '-'}}</span><span>${{rsis.length}} / 24</span></div>${{sparkline(rsis, '#16a34a')}}</div>
               </div>`;
           }}).join('') || "<div class='muted'>No regime samples yet.</div>";
-          document.getElementById('regimes-body').innerHTML = `<div class='regime-grid'>${{cards}}</div>`;
+          const regimesBody = document.getElementById('regimes-body');
+          if (regimesBody) regimesBody.innerHTML = `<div class='regime-grid'>${{cards}}</div>`;
           const latestStates = latestByConcern(data.state_samples || []);
           const prevStates = previousByConcern(data.state_samples || []);
           const latestStateByConcern = new Map(latestStates.map(s => [String(s.concern_id || ''), s]));
           const latestArgusObservation = (data.argus_observations || [])[0] || null;
           const latestArgusPayload = parseJson(latestArgusObservation?.payload_json, {{}});
-          document.getElementById('states-body').innerHTML = latestStates.map(s => {
+          const statesBody = document.getElementById('states-body');
+          if (statesBody) statesBody.innerHTML = latestStates.map(s => {
             const hasChanged = changed(s, prevStates.get(String(s.concern_id || '')), ['market_regime','volatility_state','sentiment_pressure','event_risk','execution_quality']);
             const payload = parseJson(s.payload_json, {});
             const micro = payload.scoped_state?.micro || {};
@@ -263,7 +904,8 @@ def overview():
           }).join('') || "<tr><td colspan='9' class='muted'>No state snapshots yet.</td></tr>";
           const latestNarratives = latestByConcern(data.narrative_samples || []);
           const prevNarratives = previousByConcern(data.narrative_samples || []);
-          document.getElementById('narratives-body').innerHTML = latestNarratives.map(n => {
+          const narrativesBody = document.getElementById('narratives-body');
+          if (narrativesBody) narrativesBody.innerHTML = latestNarratives.map(n => {
             const hasChanged = changed(n, prevNarratives.get(String(n.concern_id || '')), ['summary','confidence']);
             const drivers = parseJson(n.key_drivers_json, []);
             const risks = parseJson(n.risk_flags_json, []);
@@ -332,8 +974,8 @@ def overview():
       <p class="muted">Overview, signals, features, narrative, decision, explanation.</p>
       <div class="nav">
         <a href="/dashboard/">Overview</a>
+        <a href="/dashboard/playbooks">Playbooks</a>
         <a href="/dashboard/changes">Decision changes</a>
-        <a href="/dashboard/tech">Tech monitor</a>
       </div>
       <h2>Last poll</h2>
       <p><span class="pill" id="cycle-status">__CYCLE_STATUS__</span></p>
@@ -346,18 +988,6 @@ def overview():
         <tr><th>account</th><th>market</th><th>balances</th><th>source</th><th>status</th><th>action</th></tr>
         <tbody id="concerns-body">__CONCERN_ROWS__</tbody>
       </table>
-      <h2>Latest regime samples</h2>
-      <div id="regimes-body">__REGIME_ROWS__</div>
-      <h2>Latest state snapshots</h2>
-      <table>
-        <tr><th>concern</th><th>market regime</th><th>volatility</th><th>liquidity</th><th>sentiment</th><th>event risk</th><th>execution</th><th>confidence</th><th>scoped detail</th></tr>
-        <tbody id="states-body">__STATE_ROWS__</tbody>
-      </table>
-      <h2>Latest narratives</h2>
-      <table>
-        <tr><th>concern</th><th>summary</th><th>drivers</th><th>risks</th><th>uncertainties</th><th>confidence</th></tr>
-        <tbody id="narratives-body">__NARRATIVE_ROWS__</tbody>
-      </table>
       <h2>Latest decisions</h2>
       <table>
         <tr><th>concern</th><th>mode</th><th>action</th><th>target strategy</th><th>reason</th><th>detail</th><th>confidence</th></tr>
@@ -518,6 +1148,7 @@ def changes():
       <p class="muted">Only rows where the decision changed from the previous state for that concern.</p>
       <div class="nav">
         <a href="/dashboard/">Overview</a>
+        <a href="/dashboard/playbooks">Playbooks</a>
         <a href="/dashboard/changes">Decision changes</a>
         <a href="/dashboard/tech">Tech monitor</a>
       </div>
@@ -551,6 +1182,7 @@ def tech():
       <p class="muted">Signals, features, state, narrative, decision, explanation.</p>
       <div class="nav">
         <a href="/dashboard/">Overview</a>
+        <a href="/dashboard/playbooks">Playbooks</a>
         <a href="/dashboard/changes">Decision changes</a>
         <a href="/dashboard/tech">Tech monitor</a>
       </div>

+ 60 - 6
src/hermes_mcp/decision_engine.py

@@ -42,6 +42,23 @@ def _safe_float(value: Any) -> float | None:
         return None
 
 
+def _decision_profile_config(decision_profile: dict[str, Any] | None) -> dict[str, Any]:
+    if not isinstance(decision_profile, dict):
+        return {}
+    config = decision_profile.get("config")
+    if isinstance(config, dict):
+        return config
+    raw = decision_profile.get("config_json")
+    if isinstance(raw, str) and raw.strip():
+        try:
+            parsed = json.loads(raw)
+            if isinstance(parsed, dict):
+                return parsed
+        except Exception:
+            return {}
+    return {}
+
+
 def _inventory_state_label(value: Any) -> str:
     state = str(value or "unknown").strip().lower()
     aliases = {
@@ -837,6 +854,7 @@ def _extract_decision_signals(*,
     grid_strategy: dict[str, Any] | None = None,
     breakout: 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 {}
@@ -860,6 +878,16 @@ def _extract_decision_signals(*,
     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"}:
@@ -1023,11 +1051,11 @@ def _extract_decision_signals(*,
 
     trend_following_pressure = bool(
         structural_strength >= 0.58
-        and breakout_persistence >= 0.65
+        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 >= 0.32
+        and short_term_trend_score >= short_term_trend_min_score
     )
     grid_harvestable_now = bool(
         harvestability_score >= 0.48
@@ -1041,7 +1069,7 @@ def _extract_decision_signals(*,
                 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 >= 0.42 and breakout_persistence < 0.75)
+            or (tactical_range_quality >= grid_release_threshold and breakout_persistence < 0.75)
         )
     )
 
@@ -1067,6 +1095,9 @@ def _extract_decision_signals(*,
         "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,
@@ -1507,6 +1538,7 @@ def _decide_for_rebalancer(*,
     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"
@@ -1521,8 +1553,11 @@ def _decide_for_rebalancer(*,
     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:
+    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"
@@ -1558,8 +1593,19 @@ def _decide_for_rebalancer(*,
     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) -> DecisionSnapshot:
-    normalized = [normalize_strategy_snapshot(s) for s in strategies if str(s.get("account_id") or "") == str(concern.get("account_id") or "")]
+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]
@@ -1596,6 +1642,7 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
         grid_strategy=grid_strategy,
         breakout=breakout,
         history_window=history_window,
+        decision_profile=decision_profile,
     )
     switch_tradeoff: dict[str, Any] = {}
 
@@ -1641,6 +1688,7 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
             grid=grid,
             decision_signals=decision_signals,
             trend=trend,
+            decision_profile=decision_profile,
         )
     else:
         if best and best["score"] >= 0.55:
@@ -1673,6 +1721,12 @@ def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any],
         "grid_fill_context": grid_fill,
         "grid_switch_tradeoff": switch_tradeoff if current_primary and current_primary["strategy_type"] == "grid_trader" else {},
         "decision_audit": decision_signals,
+        "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,

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

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

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

@@ -0,0 +1,30 @@
+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,
+    )

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

@@ -0,0 +1,19 @@
+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,
+    )

+ 264 - 0
src/hermes_mcp/decision_families/trend_only.py

@@ -0,0 +1,264 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import Any
+
+from ..decision_engine import DecisionSnapshot, _argus_decision_context, _clamp, _decision_profile_config, _parse_timestamp, _safe_float, _short_term_trend_manifest_score, _timeframe_direction
+
+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",
+    ],
+}
+
+
+def _find_role_strategy(strategies: list[dict[str, Any]], role: str, side: str) -> dict[str, Any] | None:
+    for strategy in strategies:
+        if str(strategy.get("playbook_role") or "").strip().lower() == role:
+            return strategy
+    for strategy in strategies:
+        if str(strategy.get("strategy_type") or "") != "trend_follower":
+            continue
+        config = strategy.get("config") if isinstance(strategy.get("config"), dict) else {}
+        state = strategy.get("state") if isinstance(strategy.get("state"), dict) else {}
+        trade_side = str(config.get("trade_side") or state.get("trade_side") or strategy.get("trade_side") or "").strip().lower()
+        if trade_side == side:
+            return strategy
+    return None
+
+
+def _recent_move(history_window: dict[str, Any] | None) -> tuple[float, str, int]:
+    rows = history_window.get("recent_states") if isinstance(history_window, dict) and isinstance(history_window.get("recent_states"), list) else []
+    points: list[tuple[datetime, float]] = []
+    for row in rows:
+        if not isinstance(row, dict):
+            continue
+        payload = row.get("payload_json") or {}
+        if isinstance(payload, str):
+            try:
+                import json
+                payload = json.loads(payload)
+            except Exception:
+                payload = {}
+        features = payload.get("features_by_timeframe") if isinstance(payload, dict) else {}
+        raw = features.get("1m", {}).get("raw", {}) if isinstance(features, dict) else {}
+        price = _safe_float(raw.get("price"))
+        ts = _parse_timestamp(row.get("created_at") or (payload.get("generated_at") if isinstance(payload, dict) else None))
+        if price is None or ts is None:
+            continue
+        points.append((ts, price))
+    if len(points) < 2:
+        return 0.0, "mixed", 0
+    points.sort(key=lambda x: x[0])
+    first, last = points[0][1], points[-1][1]
+    if first <= 0:
+        return 0.0, "mixed", 0
+    move_pct = ((last - first) / first) * 100.0
+    minutes = int((points[-1][0] - points[0][0]).total_seconds() / 60.0)
+    direction = "bullish" if move_pct > 0 else "bearish" if move_pct < 0 else "mixed"
+    return move_pct, direction, minutes
+
+
+def _direction_persistence(narrative_payload: dict[str, Any], history_window: dict[str, Any] | None, direction: str) -> float:
+    features = narrative_payload.get("features_by_timeframe") if isinstance(narrative_payload.get("features_by_timeframe"), dict) else {}
+    current_dir = _timeframe_direction(features.get("5m"))
+    if current_dir != direction:
+        return 0.0
+    rows = history_window.get("recent_states") if isinstance(history_window, dict) and isinstance(history_window.get("recent_states"), list) else []
+    streak = 1
+    for row in reversed(rows):
+        if not isinstance(row, dict):
+            continue
+        payload = row.get("payload_json") or {}
+        if isinstance(payload, str):
+            try:
+                import json
+                payload = json.loads(payload)
+            except Exception:
+                payload = {}
+        sample_features = payload.get("features_by_timeframe") if isinstance(payload, dict) else {}
+        sample_dir = _timeframe_direction(sample_features.get("5m"))
+        if sample_dir != direction:
+            break
+        streak += 1
+    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)
+    micro_weight = _safe_float(config.get("micro_trend_weight")) or 0.8
+    meso_weight = _safe_float(config.get("meso_trend_weight")) or 1.0
+    macro_weight = _safe_float(config.get("macro_trend_weight")) or 0.7
+    persistence_weight = _safe_float(config.get("persistence_bonus_weight")) or 0.45
+    compression_penalty = _safe_float(config.get("argus_compression_penalty")) or 0.18
+
+    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 {}
+    meso = scoped.get("meso") if isinstance(scoped.get("meso"), dict) else {}
+    macro = scoped.get("macro") if isinstance(scoped.get("macro"), dict) else {}
+    move_pct, move_direction, move_minutes = _recent_move(history_window)
+    argus = _argus_decision_context(narrative_payload)
+    compression = float(argus.get("compression") or 0.0)
+
+    buy_score = 0.0
+    sell_score = 0.0
+    buy_score += _short_term_trend_manifest_score(narrative_payload, "bullish") * micro_weight
+    sell_score += _short_term_trend_manifest_score(narrative_payload, "bearish") * micro_weight
+    if str(meso.get("momentum_bias") or "") == "bullish":
+        buy_score += meso_weight
+    elif str(meso.get("momentum_bias") or "") == "bearish":
+        sell_score += meso_weight
+    if str(macro.get("bias") or "") == "bullish":
+        buy_score += macro_weight
+    elif str(macro.get("bias") or "") == "bearish":
+        sell_score += macro_weight
+    if move_direction == "bullish":
+        buy_score += min(abs(move_pct) / 0.6, 1.0) * 0.35
+    elif move_direction == "bearish":
+        sell_score += min(abs(move_pct) / 0.6, 1.0) * 0.35
+    buy_score += _direction_persistence(narrative_payload, history_window, "bullish") * persistence_weight
+    sell_score += _direction_persistence(narrative_payload, history_window, "bearish") * persistence_weight
+    if str(micro.get("reversal_risk") or "") == "high":
+        buy_score -= 0.15
+        sell_score -= 0.15
+    buy_score -= compression * compression_penalty
+    sell_score -= compression * compression_penalty
+
+    return {
+        "buy_score": round(_clamp(buy_score, 0.0, 3.0), 4),
+        "sell_score": round(_clamp(sell_score, 0.0, 3.0), 4),
+        "recent_move_pct": round(move_pct, 4),
+        "recent_move_direction": move_direction,
+        "recent_move_minutes": move_minutes,
+        "argus_compression": round(compression, 4),
+    }
+
+
+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)
+    turn_cost_pct = _safe_float(config.get("estimated_turn_cost_pct")) or 0.7
+    activation_edge_threshold = _safe_float(config.get("activation_edge_threshold")) or 1.15
+    flip_edge_threshold = _safe_float(config.get("flip_edge_threshold")) or 1.35
+    flip_confirmation_gap = _safe_float(config.get("flip_confirmation_gap")) or 0.25
+
+    buy_strategy = _find_role_strategy(strategies, "trend_buy", "buy")
+    sell_strategy = _find_role_strategy(strategies, "trend_sell", "sell")
+    active = next((s for s in strategies if str(s.get("mode") or "").lower() == "active"), None)
+    active_side = str(active.get("playbook_role") or "").strip().lower() if active else ""
+    if active_side not in {"trend_buy", "trend_sell"}:
+        active_config = active.get("config") if active and isinstance(active.get("config"), dict) else {}
+        trade_side = str((active_config or {}).get("trade_side") or (active.get("trade_side") if active else "") or "").strip().lower()
+        active_side = "trend_buy" if trade_side == "buy" else "trend_sell" if trade_side == "sell" else ""
+
+    scores = _trend_side_scores(narrative_payload, history_window, decision_profile or {})
+    buy_score = float(scores["buy_score"])
+    sell_score = float(scores["sell_score"])
+    edge_to_buy = buy_score - sell_score
+    edge_to_sell = sell_score - buy_score
+    fee_gate = turn_cost_pct / 0.7
+
+    mode = "observe"
+    action = "hold"
+    target_strategy = active.get("id") if active else None
+    reasons: list[str] = []
+    blocks: list[str] = []
+
+    if not buy_strategy and not sell_strategy:
+        return DecisionSnapshot(
+            mode="observe",
+            action="wait",
+            target_strategy=None,
+            reason_summary="trend-only playbook has no assigned trend strategies yet",
+            confidence=0.3,
+            requires_action=False,
+            payload={
+                "generated_at": datetime.now(timezone.utc).isoformat(),
+                "wallet_state": wallet_state,
+                "narrative_stance": str(narrative_payload.get("stance") or "neutral"),
+                "decision_family": FAMILY_ID,
+                "decision_audit": scores,
+                "reason_chain": [],
+                "blocks": ["trend-only playbook has no assigned trend strategies yet"],
+                "decision_profile": decision_profile,
+            },
+        )
+
+    if active_side == "trend_buy":
+        if sell_strategy and edge_to_sell >= max(flip_edge_threshold, fee_gate) and edge_to_sell >= flip_confirmation_gap:
+            mode = "act"
+            action = "replace_with_trend_follower"
+            target_strategy = sell_strategy.get("id")
+            reasons.append("bearish evidence now outweighs the active buy side strongly enough to justify paying the turn cost")
+        else:
+            action = "keep_trend"
+            reasons.append("buy side remains active because the opposite edge is not strong enough to pay for a flip")
+    elif active_side == "trend_sell":
+        if buy_strategy and edge_to_buy >= max(flip_edge_threshold, fee_gate) and edge_to_buy >= flip_confirmation_gap:
+            mode = "act"
+            action = "replace_with_trend_follower"
+            target_strategy = buy_strategy.get("id")
+            reasons.append("bullish evidence now outweighs the active sell side strongly enough to justify paying the turn cost")
+        else:
+            action = "keep_trend"
+            reasons.append("sell side remains active because the opposite edge is not strong enough to pay for a flip")
+    else:
+        if buy_strategy and edge_to_buy >= max(activation_edge_threshold, fee_gate):
+            mode = "act"
+            action = "replace_with_trend_follower"
+            target_strategy = buy_strategy.get("id")
+            reasons.append("bullish evidence is strong enough to activate the buy trend follower")
+        elif sell_strategy and edge_to_sell >= max(activation_edge_threshold, fee_gate):
+            mode = "act"
+            action = "replace_with_trend_follower"
+            target_strategy = sell_strategy.get("id")
+            reasons.append("bearish evidence is strong enough to activate the sell trend follower")
+        else:
+            action = "wait"
+            blocks.append("neither side has enough edge yet to overcome turn cost and activation threshold")
+
+    confidence = round(_clamp(float(narrative_payload.get("confidence") or 0.45), 0.2, 0.95), 3)
+    payload = {
+        "generated_at": datetime.now(timezone.utc).isoformat(),
+        "wallet_state": wallet_state,
+        "narrative_stance": str(narrative_payload.get("stance") or "neutral"),
+        "active_playbook_strategy_roles": {
+            "trend_buy": buy_strategy.get("id") if buy_strategy else None,
+            "trend_sell": sell_strategy.get("id") if sell_strategy else None,
+            "active_side": active_side or None,
+        },
+        "decision_family": FAMILY_ID,
+        "decision_audit": {
+            **scores,
+            "edge_to_buy": round(edge_to_buy, 4),
+            "edge_to_sell": round(edge_to_sell, 4),
+            "estimated_turn_cost_pct": round(turn_cost_pct, 4),
+            "activation_edge_threshold": round(activation_edge_threshold, 4),
+            "flip_edge_threshold": round(flip_edge_threshold, 4),
+            "flip_confirmation_gap": round(flip_confirmation_gap, 4),
+        },
+        "reason_chain": reasons,
+        "blocks": blocks,
+        "decision_profile": decision_profile,
+    }
+    return DecisionSnapshot(
+        mode=mode,
+        action=action,
+        target_strategy=target_strategy,
+        reason_summary=reasons[0] if reasons else (blocks[0] if blocks else "trend-only posture unchanged"),
+        confidence=confidence,
+        requires_action=mode == "act",
+        payload=payload,
+    )

+ 778 - 15
src/hermes_mcp/server.py

@@ -8,7 +8,7 @@ from datetime import datetime, timezone
 from uuid import uuid4
 
 import anyio
-from fastapi import FastAPI
+from fastapi import FastAPI, Request
 from fastapi.responses import JSONResponse
 from mcp.server.fastmcp import FastMCP
 from mcp.server.transport_security import TransportSecuritySettings
@@ -18,12 +18,13 @@ from mcp.client.sse import sse_client
 from .config import load_config
 from .argus_client import get_regime as argus_get_regime, get_snapshot as argus_get_snapshot
 from .crypto_client import get_price, get_regime
-from .decision_engine import assess_wallet_state, make_decision
+from .decision_engine import assess_wallet_state
+from .decision_families import make_family_decision
 from .narrative_engine import build_narrative
 from .replay import build_replay_input
 from .state_engine import synthesize_state
-from .store import delete_concern, get_state, init_db, list_concerns, 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_cycle, upsert_decision, upsert_narrative, upsert_observation, upsert_regime_sample, upsert_state, latest_states
-from .trader_client import apply_control_decision as trader_apply_control_decision, get_strategy as trader_get_strategy, list_strategies
+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, get_strategy as trader_get_strategy, list_strategies
 
 mcp = FastMCP(
     "hermes-mcp",
@@ -76,6 +77,11 @@ def _build_trader_control_payload(*, decision_id: str, concern: dict, decision:
 
 
 async def _maybe_dispatch_trader_action(*, cfg: object, decision_id: str, concern: dict, decision: object, trader_available: bool = True, retry_after_seconds: int | None = None) -> dict:
+    if str(concern.get("status") or "active").strip().lower() != "active":
+        return {
+            "dispatch": "blocked",
+            "reason": "concern is inactive",
+        }
     if not bool(getattr(decision, "requires_action", False)):
         return {"dispatch": "not_required"}
 
@@ -119,12 +125,57 @@ async def _maybe_dispatch_trader_action(*, cfg: object, decision_id: str, concer
 @mcp.tool(description="Return Hermes current state, narrative, uncertainty, and a short self-assessment report.")
 def report() -> dict:
     state = get_state()
+    cfg = load_config()
+    concerns = list_concerns()
+    groups_by_concern: dict[str, list[dict[str, Any]]] = {}
+    for group in list_strategy_groups():
+        groups_by_concern.setdefault(str(group.get("concern_id") or ""), []).append(group)
+
+    try:
+        accounts_by_id, markets_by_symbol, total_values = anyio.run(_load_exec_enrichment, cfg.exec_url, cfg.crypto_url, concerns)
+    except Exception:
+        accounts_by_id, markets_by_symbol, total_values = {}, {}, {}
+
+    concern_summaries = []
+    for concern in concerns:
+        concern_id = str(concern.get("id") or "")
+        account_id = str(concern.get("account_id") or "").strip()
+        market_symbol = str(concern.get("market_symbol") or "").strip().lower()
+        account_info = accounts_by_id.get(account_id, {})
+        market_info = markets_by_symbol.get(market_symbol, {})
+        groups = groups_by_concern.get(concern_id, [])
+        active_playbook = next((g for g in groups if str(g.get("status") or "").lower() == "active"), None)
+        assignments = list_strategy_assignments(strategy_group_id=str(active_playbook.get("id") or "")) if active_playbook else []
+        concern_summaries.append({
+            "concern_id": concern_id,
+            "account_id": account_id or None,
+            "account": account_info.get("display_name") or account_id or None,
+            "market_symbol": str(concern.get("market_symbol") or "") or None,
+            "market": market_info.get("name") or str(concern.get("market_symbol") or "") or None,
+            "status": str(concern.get("status") or "active"),
+            "active_playbook": {
+                "id": str(active_playbook.get("id") or "") or None,
+                "name": str(active_playbook.get("name") or "") or None,
+                "family": str(active_playbook.get("strategy_family") or "") or None,
+            } if active_playbook else None,
+            "active_strategies": [
+                {
+                    "strategy_id": str(a.get("strategy_id") or "") or None,
+                    "role": str(a.get("role") or "member") or "member",
+                    "strategy_type": str(a.get("strategy_type") or "") or None,
+                }
+                for a in assignments
+            ],
+            "balances": _compact_balances(account_info.get("balances") or account_info.get("balance") or account_info.get("wallets") or []),
+            "total_value_usd": total_values.get(account_id) if total_values.get(account_id) is not None else account_info.get("total_value_usd"),
+        })
     return {
         "status": state.get("status", "stub"),
         "thinking": state.get("thinking", "Hermes scaffold is ready."),
         "confidence": state.get("confidence", 0.0),
         "uncertainty": state.get("uncertainty", ["no live adapters wired yet"]),
         "layers": state.get("layers", []),
+        "concerns": concern_summaries,
     }
 
 
@@ -165,6 +216,23 @@ async def lifespan(_: FastAPI):
             started = datetime.now(timezone.utc).isoformat()
             cycle_id = str(uuid4())
             concerns = list_concerns()
+            profile_ids = sorted({str(c.get("decision_profile_id") or "").strip() for c in concerns if str(c.get("decision_profile_id") or "").strip()})
+            decision_profiles = {}
+            for profile_id in profile_ids:
+                profile = get_decision_profile(profile_id=profile_id)
+                if not profile:
+                    continue
+                try:
+                    profile_config = json.loads(profile.get("config_json") or "{}")
+                except Exception:
+                    profile_config = {}
+                if isinstance(profile_config, dict):
+                    decision_profiles[profile_id] = {**profile, "config": profile_config}
+            playbook_groups = list_strategy_groups()
+            playbook_assignments = {
+                str(group.get("id") or ""): list_strategy_assignments(strategy_group_id=str(group.get("id") or ""))
+                for group in playbook_groups
+            }
             strategy_inventory = cached_strategy_inventory
             if _trader_available():
                 try:
@@ -186,6 +254,10 @@ async def lifespan(_: FastAPI):
                 except Exception as exc:
                     _mark_trader_failure(exc)
                     strategy_inventory = cached_strategy_inventory
+            try:
+                sync_concerns_from_strategies(strategy_inventory)
+            except Exception:
+                pass
             upsert_cycle(id=cycle_id, started_at=started, finished_at=None, status="running", trigger="interval", notes=f"polling {len(concerns)} concerns")
             argus_snapshot: dict = {}
             argus_regime: dict = {}
@@ -211,6 +283,7 @@ async def lifespan(_: FastAPI):
                 symbol = _resolve_regime_symbol(concern)
                 if not symbol:
                     continue
+                concern_id = str(concern.get("id") or "")
                 account_id = str(concern.get("account_id") or "").strip()
                 account_info = {}
                 if account_id:
@@ -274,9 +347,32 @@ async def lifespan(_: FastAPI):
                         price=float(latest_price) if latest_price is not None else None,
                         strategies=strategy_inventory,
                     )
+                    active_playbook = next((g for g in playbook_groups if str(g.get("concern_id") or "") == concern_id and str(g.get("status") or "").lower() == "active"), None)
+                    assignment_by_strategy_id = {
+                        str(a.get("strategy_id") or "").strip(): a
+                        for a in playbook_assignments.get(str(active_playbook.get("id") or ""), [])
+                        if str(a.get("strategy_id") or "").strip()
+                    } if active_playbook else {}
+                    assigned_strategy_ids = {
+                        str(a.get("strategy_id") or "").strip()
+                        for a in playbook_assignments.get(str(active_playbook.get("id") or ""), [])
+                        if str(a.get("strategy_id") or "").strip()
+                    } if active_playbook else set()
+                    candidate_strategies = [
+                        {
+                            **s,
+                            "playbook_role": str(assignment_by_strategy_id.get(str(s.get("id") or "").strip(), {}).get("role") or "").strip() or None,
+                            "playbook_assignment_id": str(assignment_by_strategy_id.get(str(s.get("id") or "").strip(), {}).get("id") or "").strip() or None,
+                        }
+                        for s in strategy_inventory
+                        if str(s.get("account_id") or "").strip() == account_id
+                        and str(s.get("market_symbol") or "").strip().lower() == str(concern.get("market_symbol") or "").strip().lower()
+                        and (not assigned_strategy_ids or str(s.get("id") or "").strip() in assigned_strategy_ids)
+                    ]
                     breakout_window_seconds = max(300, int(getattr(cfg, "breakout_memory_window_seconds", 900) or 900))
                     recent_state_rows = recent_states_for_concern(concern_id=str(concern["id"]), since_seconds=breakout_window_seconds, limit=12)
-                    decision = make_decision(
+                    decision = make_family_decision(
+                        family=str(active_playbook.get("strategy_family") or "grid-trend-rebalancer") if active_playbook else "grid-trend-rebalancer",
                         concern=concern,
                         narrative_payload={
                             **state.payload,
@@ -284,11 +380,12 @@ async def lifespan(_: FastAPI):
                             "confidence": narrative.confidence,
                         },
                         wallet_state=wallet_state,
-                        strategies=strategy_inventory,
+                        strategies=candidate_strategies,
                         history_window={
                             "window_seconds": breakout_window_seconds,
                             "recent_states": recent_state_rows,
                         },
+                        decision_profile=decision_profiles.get(str(concern.get("decision_profile_id") or "").strip()),
                     )
                     decision_id = f"{cycle_id}:{concern['id']}"
                     dispatch_record = await _maybe_dispatch_trader_action(
@@ -305,17 +402,20 @@ async def lifespan(_: FastAPI):
                             concern=concern,
                             narrative_payload={
                                 **state.payload,
-                                **narrative.payload,
-                                "confidence": narrative.confidence,
-                            },
-                            wallet_state=wallet_state,
-                            strategies=strategy_inventory,
-                            history_window={
-                                "window_seconds": breakout_window_seconds,
-                                "recent_states": recent_state_rows,
-                            },
+                            **narrative.payload,
+                            "confidence": narrative.confidence,
+                        },
+                        wallet_state=wallet_state,
+                        strategies=candidate_strategies,
+                        history_window={
+                            "window_seconds": breakout_window_seconds,
+                            "recent_states": recent_state_rows,
+                        },
                         ),
                         "dispatch": dispatch_record,
+                        "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("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 "")],
                     }
                     upsert_decision(
                         id=decision_id,
@@ -467,6 +567,107 @@ def _resolve_regime_symbol(concern: dict) -> str | None:
     return market or None
 
 
+def _default_playbook_name(strategies: list[dict]) -> str:
+    types = {str(s.get("strategy_type") or "").strip() for s in strategies}
+    if {"grid_trader", "trend_follower", "exposure_protector"}.issubset(types):
+        return "grid-trend-rebalancer"
+    if types == {"trend_follower"} or ("trend_follower" in types and "grid_trader" not in types and "exposure_protector" not in types):
+        return "trend-only"
+    labels = sorted(t.replace("_", "-") for t in types if t)
+    return "+".join(labels) if labels else "playbook"
+
+
+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"
+
+
+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,
+    }
+
+
+def _profile_allowed_keys(family: str | None = None) -> set[str]:
+    return set(_default_profile_config(family).keys())
+
+
+def _normalize_profile_config(config: dict[str, object] | None, family: str | None = None) -> dict[str, object]:
+    defaults = _default_profile_config(family)
+    allowed = _profile_allowed_keys(family)
+    current = config if isinstance(config, dict) else {}
+    return {**defaults, **{k: v for k, v in current.items() if k in allowed}}
+
+
+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"
+    profile = get_decision_profile(profile_id=profile_id)
+    config: dict[str, object] = {}
+    if profile:
+        try:
+            raw = json.loads(profile.get("config_json") or "{}")
+        except Exception:
+            raw = {}
+        config = _normalize_profile_config(raw if isinstance(raw, dict) else {}, family)
+        current_name = str(profile.get("name") or "").strip()
+        generic_names = {"grid-trend-rebalancer profile", "trend-only profile", "playbook profile"}
+        profile_name = name if not current_name or current_name in generic_names else current_name
+        upsert_decision_profile(
+            id=profile_id,
+            name=profile_name,
+            description=str(profile.get("description") or description or "").strip() or None,
+            config=config,
+            status=str(profile.get("status") or status or "active"),
+        )
+        return {**profile, "name": profile_name, "config": config}
+
+    config = _default_profile_config(family)
+    upsert_decision_profile(
+        id=profile_id,
+        name=name or f"{family_label} profile",
+        description=description,
+        config=config,
+        status=status,
+    )
+    created = get_decision_profile(profile_id=profile_id) or {"id": profile_id, "name": name, "description": description, "status": status}
+    return {**created, "config": config}
+
+
+def _strategy_display_label(strategy: dict) -> str:
+    for key in ("label", "display_name", "name", "title"):
+        value = str(strategy.get(key) or "").strip()
+        if value:
+            return value
+    strategy_type = str(strategy.get("strategy_type") or "strategy").strip().replace("_", " ")
+    instance_id = str(strategy.get("id") or "").strip()
+    return f"{strategy_type} ({instance_id[:8]})" if instance_id else strategy_type
+
+
 @app.get("/dashboard/data")
 def dashboard_data() -> JSONResponse:
     cfg = load_config()
@@ -537,3 +738,565 @@ def dashboard_data() -> JSONResponse:
         "decision_samples": latest_decisions(20),
         "decision_history": latest_decisions(100),
     })
+
+
+@app.get("/dashboard/concerns/{concern_id}/data")
+def dashboard_concern_detail_data(concern_id: str) -> JSONResponse:
+    cfg = load_config()
+    concern_id = str(concern_id or "").strip()
+    concern = next((c for c in list_concerns() if str(c.get("id") or "") == concern_id), None)
+    if not concern:
+        return JSONResponse({"ok": False, "error": "concern not found"}, status_code=404)
+
+    account_id = str(concern.get("account_id") or "").strip()
+    market_symbol = str(concern.get("market_symbol") or "").strip().lower()
+    concerns = [concern]
+
+    try:
+        accounts_by_id, markets_by_symbol, total_values = anyio.run(_load_exec_enrichment, cfg.exec_url, cfg.crypto_url, concerns)
+    except Exception:
+        accounts_by_id, markets_by_symbol, total_values = {}, {}, {}
+
+    account_info = accounts_by_id.get(account_id, {})
+    market_info = markets_by_symbol.get(market_symbol, {})
+    enriched_concern = {
+        **concern,
+        "account_display": account_info.get("display_name") or account_id,
+        "balances": account_info.get("balances") or account_info.get("balance") or account_info.get("wallets") or [],
+        "balance_summary": _compact_balances(account_info.get("balances") or account_info.get("balance") or account_info.get("wallets") or []),
+        "total_value_usd": total_values.get(account_id) if total_values.get(account_id) is not None else account_info.get("total_value_usd"),
+        "market_display": market_info.get("name") or concern.get("market_symbol") or "",
+        "market_description": market_info.get("description") or "",
+    }
+
+    try:
+        strategy_inventory = anyio.run(list_strategies, cfg.trader_url)
+    except Exception:
+        strategy_inventory = []
+    concern_strategies = [
+        s for s in strategy_inventory
+        if str(s.get("account_id") or "").strip() == account_id
+        and str(s.get("market_symbol") or "").strip().lower() == market_symbol
+    ]
+    strategies_by_id = {str(s.get("id") or "").strip(): s for s in concern_strategies if str(s.get("id") or "").strip()}
+    profile_id = str(concern.get("decision_profile_id") or "").strip()
+
+    existing_groups = list_strategy_groups(concern_id=concern_id)
+    if not existing_groups and concern_strategies:
+        seeded_group_id = f"playbook:{concern_id}:default"
+        seeded_family = _default_playbook_family(concern_strategies)
+        seeded_profile_id = profile_id or f"profile:{concern_id}:default"
+        if not profile_id:
+            upsert_decision_profile(
+                id=seeded_profile_id,
+                name=f"{_default_playbook_name(concern_strategies)} profile",
+                description="Auto-seeded default profile for this concern.",
+                config=_default_profile_config(seeded_family),
+                status="active",
+            )
+            upsert_concern(
+                id=str(concern.get("id") or ""),
+                account_id=account_id or None,
+                market_symbol=market_symbol or None,
+                base_currency=str(concern.get("base_currency") or "").strip() or None,
+                quote_currency=str(concern.get("quote_currency") or "").strip() or None,
+                strategy_id=str(concern.get("strategy_id") or "").strip() or None,
+                decision_profile_id=seeded_profile_id,
+                source=str(concern.get("source") or "dashboard"),
+                status=str(concern.get("status") or "active"),
+                notes=str(concern.get("notes") or "").strip() or None,
+            )
+            concern = {**concern, "decision_profile_id": seeded_profile_id}
+            profile_id = seeded_profile_id
+        upsert_strategy_group(
+            id=seeded_group_id,
+            concern_id=concern_id,
+            name=_default_playbook_name(concern_strategies),
+            strategy_family=seeded_family,
+            decision_profile_id=profile_id or None,
+            notes="auto-seeded from trader strategies",
+            status="active",
+        )
+        for strategy in concern_strategies:
+            strategy_id = str(strategy.get("id") or "").strip()
+            if not strategy_id:
+                continue
+            upsert_strategy_assignment(
+                id=f"assign:{seeded_group_id}:{strategy_id}",
+                strategy_group_id=seeded_group_id,
+                strategy_id=strategy_id,
+                strategy_type=str(strategy.get("strategy_type") or "").strip() or None,
+                role="member",
+                status="active",
+                notes="auto-seeded from trader inventory",
+            )
+        existing_groups = list_strategy_groups(concern_id=concern_id)
+
+    playbooks = []
+    active_playbook_profile_id = None
+    for group in existing_groups:
+        assignments = list_strategy_assignments(strategy_group_id=str(group.get("id") or ""))
+        if str(group.get("strategy_family") or "").strip().lower() == "mixed" and assignments:
+            assigned_strategies = [
+                strategies_by_id.get(str(a.get("strategy_id") or "").strip(), {"strategy_type": a.get("strategy_type")})
+                for a in assignments
+            ]
+            inferred_family = _default_playbook_family(assigned_strategies)
+            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 "").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}
+        group_profile_id = str(group.get("decision_profile_id") or "").strip()
+        if not group_profile_id:
+            group_profile_id = f"profile:{concern_id}:{str(group.get('id') or '').strip() or 'default'}"
+            _ensure_profile_for_family(
+                profile_id=group_profile_id,
+                family=str(group.get("strategy_family") or ""),
+                name=f"{str(group.get('name') or group.get('id') or 'playbook')} profile",
+                description="Auto-created for this playbook.",
+                status="active",
+            )
+            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=str(group.get("strategy_family") or "").strip() or None,
+                decision_profile_id=group_profile_id,
+                notes=str(group.get("notes") or "").strip() or None,
+                status=str(group.get("status") or "active"),
+            )
+            group = {**group, "decision_profile_id": group_profile_id}
+        else:
+            _ensure_profile_for_family(
+                profile_id=group_profile_id,
+                family=str(group.get("strategy_family") or ""),
+                name=f"{str(group.get('name') or group.get('id') or 'playbook')} profile",
+                description="Auto-created for this playbook.",
+                status="active",
+            )
+        if str(group.get("status") or "").lower() == "active" and str(group.get("decision_profile_id") or "").strip():
+            active_playbook_profile_id = str(group.get("decision_profile_id") or "").strip()
+        enriched_assignments = []
+        for assignment in assignments:
+            strategy = strategies_by_id.get(str(assignment.get("strategy_id") or "").strip(), {})
+            enriched_assignments.append({
+                **assignment,
+                "strategy_label": _strategy_display_label(strategy) if strategy else str(assignment.get("strategy_id") or "").strip(),
+            })
+        playbooks.append({**group, "assignments": enriched_assignments})
+
+    concern_strategies = [{**s, "display_label": _strategy_display_label(s)} for s in concern_strategies]
+
+    if active_playbook_profile_id and profile_id != active_playbook_profile_id:
+        upsert_concern(
+            id=str(concern.get("id") or ""),
+            account_id=account_id or None,
+            market_symbol=market_symbol or None,
+            base_currency=str(concern.get("base_currency") or "").strip() or None,
+            quote_currency=str(concern.get("quote_currency") or "").strip() or None,
+            strategy_id=str(concern.get("strategy_id") or "").strip() or None,
+            decision_profile_id=active_playbook_profile_id,
+            source=str(concern.get("source") or "dashboard"),
+            status=str(concern.get("status") or "active"),
+            notes=str(concern.get("notes") or "").strip() or None,
+        )
+        concern = {**concern, "decision_profile_id": active_playbook_profile_id}
+        profile_id = active_playbook_profile_id
+
+    active_family = next((str(p.get("strategy_family") or "") for p in playbooks if str(p.get("status") or "").lower() == "active"), "")
+    decision_profile = (
+        _ensure_profile_for_family(
+            profile_id=profile_id,
+            family=active_family,
+            name=f"{str((next((p for p in playbooks if str(p.get('status') or '').lower() == 'active'), {}) or {}).get('name') or 'playbook')} profile",
+            description="Auto-created for this playbook.",
+            status="active",
+        )
+        if profile_id else None
+    )
+
+    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_decision = next((d for d in latest_decisions(200) if str(d.get("concern_id") or "") == concern_id), None)
+    latest_regimes = [s for s in recent_regime_samples(500) if str(s.get("concern_id") or "") == concern_id][:24]
+
+    return JSONResponse({
+        "ok": True,
+        "concern": enriched_concern,
+        "decision_profile": decision_profile,
+        "playbooks": playbooks,
+        "strategies": concern_strategies,
+        "latest_state": latest_state,
+        "latest_narrative": latest_narrative,
+        "latest_decision": latest_decision,
+        "latest_regimes": latest_regimes,
+    })
+
+
+@app.post("/dashboard/concerns/{concern_id}/playbooks/{playbook_id}/activate")
+def dashboard_activate_playbook(concern_id: str, playbook_id: str) -> JSONResponse:
+    concern_id = str(concern_id or "").strip()
+    playbook_id = str(playbook_id or "").strip()
+    concern = next((c for c in list_concerns() if str(c.get("id") or "") == concern_id), None)
+    if not concern:
+        return JSONResponse({"ok": False, "error": "concern not found"}, status_code=404)
+
+    groups = list_strategy_groups(concern_id=concern_id)
+    target = next((g for g in groups if str(g.get("id") or "") == playbook_id), None)
+    if not target:
+        return JSONResponse({"ok": False, "error": "playbook not found"}, status_code=404)
+
+    target_profile_id = str(target.get("decision_profile_id") or "").strip() or f"profile:{concern_id}:{playbook_id}"
+    _ensure_profile_for_family(
+        profile_id=target_profile_id,
+        family=str(target.get("strategy_family") or ""),
+        name=f"{str(target.get('name') or playbook_id)} profile",
+        description="Auto-created for this playbook.",
+        status="active",
+    )
+    if str(target.get("decision_profile_id") or "").strip() != target_profile_id:
+        upsert_strategy_group(
+            id=str(target.get("id") or ""),
+            concern_id=concern_id,
+            name=str(target.get("name") or target.get("id") or "playbook"),
+            strategy_family=str(target.get("strategy_family") or "").strip() or None,
+            decision_profile_id=target_profile_id,
+            notes=str(target.get("notes") or "").strip() or None,
+            status=str(target.get("status") or "active"),
+        )
+        target = {**target, "decision_profile_id": target_profile_id}
+
+    for group in groups:
+        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=str(group.get("strategy_family") or "").strip() or None,
+            decision_profile_id=(target_profile_id if str(group.get("id") or "") == playbook_id else str(group.get("decision_profile_id") or "").strip() or None),
+            notes=str(group.get("notes") or "").strip() or None,
+            status="active" if str(group.get("id") or "") == playbook_id else "standby",
+        )
+
+    upsert_concern(
+        id=str(concern.get("id") or ""),
+        account_id=str(concern.get("account_id") or "").strip() or None,
+        market_symbol=str(concern.get("market_symbol") or "").strip() or None,
+        base_currency=str(concern.get("base_currency") or "").strip() or None,
+        quote_currency=str(concern.get("quote_currency") or "").strip() or None,
+        strategy_id=str(concern.get("strategy_id") or "").strip() or None,
+        decision_profile_id=target_profile_id,
+        source=str(concern.get("source") or "dashboard"),
+        status=str(concern.get("status") or "active"),
+        notes=str(concern.get("notes") or "").strip() or None,
+    )
+
+    return JSONResponse({"ok": True, "activated_playbook_id": playbook_id})
+
+
+@app.post("/dashboard/concerns/{concern_id}/playbooks/{playbook_id}/tuning")
+async def dashboard_update_playbook_tuning(concern_id: str, playbook_id: str, request: Request) -> JSONResponse:
+    concern_id = str(concern_id or "").strip()
+    playbook_id = str(playbook_id or "").strip()
+    concern = next((c for c in list_concerns() if str(c.get("id") or "") == concern_id), None)
+    if not concern:
+        return JSONResponse({"ok": False, "error": "concern not found"}, status_code=404)
+    groups = list_strategy_groups(concern_id=concern_id)
+    target = next((g for g in groups if str(g.get("id") or "") == playbook_id), None)
+    if not target:
+        return JSONResponse({"ok": False, "error": "playbook not found"}, status_code=404)
+
+    profile_id = str(target.get("decision_profile_id") or "").strip() or f"profile:{concern_id}:{playbook_id}"
+    if not str(target.get("decision_profile_id") or "").strip():
+        _ensure_profile_for_family(
+            profile_id=profile_id,
+            family=str(target.get("strategy_family") or ""),
+            name=f"{str(target.get('name') or playbook_id)} profile",
+            description="Auto-created while saving tuning from the dashboard.",
+            status="active",
+        )
+        upsert_strategy_group(
+            id=str(target.get("id") or ""),
+            concern_id=concern_id,
+            name=str(target.get("name") or playbook_id),
+            strategy_family=str(target.get("strategy_family") or "").strip() or None,
+            decision_profile_id=profile_id,
+            notes=str(target.get("notes") or "").strip() or None,
+            status=str(target.get("status") or "active"),
+        )
+    _ensure_profile_for_family(
+        profile_id=profile_id,
+        family=str(target.get("strategy_family") or ""),
+        name=f"{str(target.get('name') or playbook_id)} profile",
+        description="Auto-created while saving tuning from the dashboard.",
+        status="active",
+    )
+
+    if str(target.get("status") or "").lower() == "active" and str(concern.get("decision_profile_id") or "").strip() != profile_id:
+        upsert_concern(
+            id=str(concern.get("id") or ""),
+            account_id=str(concern.get("account_id") or "").strip() or None,
+            market_symbol=str(concern.get("market_symbol") or "").strip() or None,
+            base_currency=str(concern.get("base_currency") or "").strip() or None,
+            quote_currency=str(concern.get("quote_currency") or "").strip() or None,
+            strategy_id=str(concern.get("strategy_id") or "").strip() or None,
+            decision_profile_id=profile_id,
+            source=str(concern.get("source") or "dashboard"),
+            status=str(concern.get("status") or "active"),
+            notes=str(concern.get("notes") or "").strip() or None,
+        )
+
+    payload = await request.json()
+    updates = payload if isinstance(payload, dict) else {}
+    profile = get_decision_profile(profile_id=profile_id)
+    if not profile:
+        return JSONResponse({"ok": False, "error": "decision profile not found"}, status_code=404)
+
+    try:
+        current_config = json.loads(profile.get("config_json") or "{}")
+    except Exception:
+        current_config = {}
+    if not isinstance(current_config, dict):
+        current_config = {}
+
+    allowed_keys = {
+        "breakout_persistence_min",
+        "short_term_confirmation_min",
+        "switch_cost_penalty",
+        "rebalance_imbalance_threshold",
+        "force_grid_when_balanced",
+        "grid_release_threshold",
+        "trend_cooling_threshold",
+        "trend_inventory_stress_threshold",
+        "action_cooldown_seconds",
+        "estimated_turn_cost_pct",
+        "micro_trend_weight",
+        "meso_trend_weight",
+        "macro_trend_weight",
+        "persistence_bonus_weight",
+        "argus_compression_penalty",
+        "activation_edge_threshold",
+        "flip_edge_threshold",
+        "flip_confirmation_gap",
+    }
+    merged = _normalize_profile_config(current_config, str(target.get("strategy_family") or ""))
+    for key, value in updates.items():
+        if key not in allowed_keys:
+            continue
+        if key == "force_grid_when_balanced":
+            merged[key] = bool(value)
+            continue
+        try:
+            merged[key] = float(value) if key != "action_cooldown_seconds" else int(float(value))
+        except Exception:
+            continue
+
+    upsert_decision_profile(
+        id=profile_id,
+        name=str(profile.get("name") or profile_id),
+        description=str(profile.get("description") or "").strip() or None,
+        config=merged,
+        status=str(profile.get("status") or "active"),
+    )
+    return JSONResponse({"ok": True, "profile_id": profile_id, "config": merged})
+
+
+@app.get("/dashboard/playbooks/data")
+def dashboard_playbooks_data() -> JSONResponse:
+    concerns = {str(c.get("id") or ""): c for c in list_concerns()}
+    groups = list_strategy_groups()
+    out = []
+    for group in groups:
+        concern = concerns.get(str(group.get("concern_id") or ""), {})
+        assignments = list_strategy_assignments(strategy_group_id=str(group.get("id") or ""))
+        out.append({
+            **group,
+            "concern": concern,
+            "assignment_count": len(assignments),
+        })
+    return JSONResponse({"ok": True, "playbooks": out})
+
+
+@app.get("/dashboard/playbooks/{playbook_id}/data")
+def dashboard_playbook_detail_data(playbook_id: str) -> JSONResponse:
+    playbook_id = str(playbook_id or "").strip()
+    group = next((g for g in list_strategy_groups() if str(g.get("id") or "") == playbook_id), None)
+    if not group:
+        return JSONResponse({"ok": False, "error": "playbook not found"}, status_code=404)
+
+    concern_id = str(group.get("concern_id") or "").strip()
+    concern = next((c for c in list_concerns() if str(c.get("id") or "") == concern_id), None)
+    if not concern:
+        return JSONResponse({"ok": False, "error": "concern not found"}, status_code=404)
+
+    cfg = load_config()
+    account_id = str(concern.get("account_id") or "").strip()
+    market_symbol = str(concern.get("market_symbol") or "").strip().lower()
+    try:
+        strategy_inventory = anyio.run(list_strategies, cfg.trader_url)
+    except Exception:
+        strategy_inventory = []
+    concern_strategies = [
+        {**s, "display_label": _strategy_display_label(s)}
+        for s in strategy_inventory
+        if str(s.get("account_id") or "").strip() == account_id
+        and str(s.get("market_symbol") or "").strip().lower() == market_symbol
+    ]
+    strategies_by_id = {str(s.get("id") or "").strip(): s for s in concern_strategies if str(s.get("id") or "").strip()}
+
+    assignments = []
+    raw_assignments = list_strategy_assignments(strategy_group_id=playbook_id)
+    if str(group.get("strategy_family") or "").strip().lower() == "mixed" and raw_assignments:
+        inferred_family = _default_playbook_family([
+            strategies_by_id.get(str(a.get("strategy_id") or "").strip(), {"strategy_type": a.get("strategy_type")})
+            for a in raw_assignments
+        ])
+        if inferred_family != "mixed":
+            upsert_strategy_group(
+                id=str(group.get("id") or ""),
+                concern_id=concern_id,
+                name=str(group.get("name") or group.get("id") or "playbook"),
+                strategy_family=inferred_family,
+                decision_profile_id=str(group.get("decision_profile_id") or concern.get("decision_profile_id") or "").strip() or None,
+                notes=str(group.get("notes") or "").strip() or None,
+                status=str(group.get("status") or "active"),
+            )
+            group = {**group, "strategy_family": inferred_family}
+    for assignment in raw_assignments:
+        strategy = strategies_by_id.get(str(assignment.get("strategy_id") or "").strip(), {})
+        assignments.append({
+            **assignment,
+            "strategy_label": _strategy_display_label(strategy) if strategy else str(assignment.get("strategy_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
+    if profile:
+        try:
+            profile = {**profile, "config": json.loads(profile.get("config_json") or "{}")}
+        except Exception:
+            profile = {**profile, "config": {}}
+
+    return JSONResponse({
+        "ok": True,
+        "playbook": group,
+        "concern": concern,
+        "decision_profile": profile,
+        "assignments": assignments,
+        "available_strategies": concern_strategies,
+    })
+
+
+@app.post("/dashboard/concerns/{concern_id}/playbooks/create")
+async def dashboard_create_playbook(concern_id: str, request: Request) -> JSONResponse:
+    concern_id = str(concern_id or "").strip()
+    concern = next((c for c in list_concerns() if str(c.get("id") or "") == concern_id), None)
+    if not concern:
+        return JSONResponse({"ok": False, "error": "concern not found"}, status_code=404)
+
+    payload = await request.json()
+    name = str((payload or {}).get("name") or "").strip()
+    strategy_family = str((payload or {}).get("strategy_family") or "manual").strip() or "manual"
+    if not name:
+        return JSONResponse({"ok": False, "error": "name is required"}, status_code=400)
+
+    playbook_id = f"playbook:{concern_id}:{uuid4().hex[:8]}"
+    profile_id = str(concern.get("decision_profile_id") or "").strip() or f"profile:{playbook_id}"
+    if not get_decision_profile(profile_id=profile_id):
+        upsert_decision_profile(
+            id=profile_id,
+            name=f"{name} profile",
+            description="Auto-created profile for a new playbook.",
+            config=_default_profile_config(strategy_family),
+            status="active",
+        )
+
+    upsert_strategy_group(
+        id=playbook_id,
+        concern_id=concern_id,
+        name=name,
+        strategy_family=strategy_family,
+        decision_profile_id=profile_id,
+        notes="created from dashboard playbooks page",
+        status="standby",
+    )
+    return JSONResponse({"ok": True, "playbook_id": playbook_id})
+
+
+@app.post("/dashboard/playbooks/{playbook_id}/assignments/upsert")
+async def dashboard_playbook_assignment_upsert(playbook_id: str, request: Request) -> JSONResponse:
+    playbook_id = str(playbook_id or "").strip()
+    group = next((g for g in list_strategy_groups() if str(g.get("id") or "") == playbook_id), None)
+    if not group:
+        return JSONResponse({"ok": False, "error": "playbook not found"}, status_code=404)
+
+    payload = await request.json()
+    strategy_id = str((payload or {}).get("strategy_id") or "").strip()
+    strategy_type = str((payload or {}).get("strategy_type") or "").strip() or None
+    role = str((payload or {}).get("role") or "member").strip() or "member"
+    if not strategy_id:
+        return JSONResponse({"ok": False, "error": "strategy_id is required"}, status_code=400)
+
+    assignment_id = f"assign:{playbook_id}:{strategy_id}"
+    upsert_strategy_assignment(
+        id=assignment_id,
+        strategy_group_id=playbook_id,
+        strategy_id=strategy_id,
+        strategy_type=strategy_type,
+        role=role,
+        status="active",
+        notes="managed from dashboard playbook editor",
+    )
+    return JSONResponse({"ok": True, "assignment_id": assignment_id})
+
+
+@app.post("/dashboard/concerns/{concern_id}/status")
+async def dashboard_set_concern_status(concern_id: str, request: Request) -> JSONResponse:
+    concern_id = str(concern_id or "").strip()
+    concern = next((c for c in list_concerns() if str(c.get("id") or "") == concern_id), None)
+    if not concern:
+        return JSONResponse({"ok": False, "error": "concern not found"}, status_code=404)
+
+    payload = await request.json()
+    status = str((payload or {}).get("status") or "").strip().lower()
+    if status not in {"active", "inactive"}:
+        return JSONResponse({"ok": False, "error": "status must be active or inactive"}, status_code=400)
+
+    account_id = str(concern.get("account_id") or "").strip()
+    if status == "inactive" and account_id:
+        try:
+            await trader_cancel_all_orders(cfg.trader_url if (cfg := load_config()) else "", account_id)
+        except Exception:
+            pass
+
+    upsert_concern(
+        id=str(concern.get("id") or ""),
+        account_id=account_id or None,
+        market_symbol=str(concern.get("market_symbol") or "").strip() or None,
+        base_currency=str(concern.get("base_currency") or "").strip() or None,
+        quote_currency=str(concern.get("quote_currency") or "").strip() or None,
+        strategy_id=str(concern.get("strategy_id") or "").strip() or None,
+        decision_profile_id=str(concern.get("decision_profile_id") or "").strip() or None,
+        source=str(concern.get("source") or "dashboard"),
+        status=status,
+        notes=str(concern.get("notes") or "").strip() or None,
+    )
+    return JSONResponse({"ok": True, "status": status})
+
+
+@app.post("/dashboard/playbooks/{playbook_id}/assignments/{assignment_id}/delete")
+def dashboard_playbook_assignment_delete(playbook_id: str, assignment_id: str) -> JSONResponse:
+    assignment_id = str(assignment_id or "").strip()
+    init_db()
+    from .store import _connect  # local import to avoid widening the public store API for one dashboard mutation
+    with _connect() as conn:
+        deleted = conn.execute("delete from strategy_assignments where id = ? and strategy_group_id = ?", (assignment_id, str(playbook_id or "").strip())).rowcount or 0
+    if not deleted:
+        return JSONResponse({"ok": False, "error": "assignment not found"}, status_code=404)
+    return JSONResponse({"ok": True, "deleted": deleted})

+ 174 - 12
src/hermes_mcp/store.py

@@ -19,6 +19,7 @@ SCHEMA_STATEMENTS = [
       base_currency text,
       quote_currency text,
       strategy_id text,
+      decision_profile_id text,
       source text not null,
       status text not null default 'active',
       notes text,
@@ -27,6 +28,46 @@ SCHEMA_STATEMENTS = [
     )
     """,
     """
+    create table if not exists decision_profiles (
+      id text primary key,
+      name text not null,
+      description text,
+      config_json text not null,
+      status text not null default 'active',
+      created_at text not null,
+      updated_at text not null
+    )
+    """,
+    """
+    create table if not exists strategy_groups (
+      id text primary key,
+      concern_id text not null,
+      name text not null,
+      strategy_family text,
+      decision_profile_id text,
+      notes text,
+      status text not null default 'active',
+      created_at text not null,
+      updated_at text not null,
+      foreign key(concern_id) references concerns(id) on delete cascade,
+      foreign key(decision_profile_id) references decision_profiles(id)
+    )
+    """,
+    """
+    create table if not exists strategy_assignments (
+      id text primary key,
+      strategy_group_id text not null,
+      strategy_id text not null,
+      strategy_type text,
+      role text,
+      status text not null default 'active',
+      notes text,
+      created_at text not null,
+      updated_at text not null,
+      foreign key(strategy_group_id) references strategy_groups(id) on delete cascade
+    )
+    """,
+    """
     create table if not exists cycles (
       id text primary key,
       started_at text not null,
@@ -140,6 +181,12 @@ SCHEMA_STATEMENTS = [
     """,
     "create index if not exists idx_observations_cycle on observations(cycle_id)",
     "create index if not exists idx_observations_concern on observations(concern_id)",
+    "create index if not exists idx_concerns_profile on concerns(decision_profile_id)",
+    "create index if not exists idx_decision_profiles_status on decision_profiles(status)",
+    "create index if not exists idx_strategy_groups_concern on strategy_groups(concern_id)",
+    "create index if not exists idx_strategy_groups_profile on strategy_groups(decision_profile_id)",
+    "create index if not exists idx_strategy_assignments_group on strategy_assignments(strategy_group_id)",
+    "create index if not exists idx_strategy_assignments_strategy on strategy_assignments(strategy_id)",
     "create index if not exists idx_states_cycle on states(cycle_id)",
     "create index if not exists idx_states_concern on states(concern_id)",
     "create index if not exists idx_narratives_cycle on narratives(cycle_id)",
@@ -165,6 +212,8 @@ def _connect() -> sqlite3.Connection:
 def init_db() -> None:
     with _connect() as conn:
         for stmt in SCHEMA_STATEMENTS:
+            if stmt.lstrip().lower().startswith("create index"):
+                continue
             conn.execute(stmt)
         conn.execute(
             """
@@ -177,10 +226,15 @@ def init_db() -> None:
             )
 
         concern_columns = {row[1] for row in conn.execute("pragma table_info(concerns)").fetchall()}
-        for column in ("base_currency", "quote_currency"):
+        for column in ("base_currency", "quote_currency", "decision_profile_id"):
             if column not in concern_columns:
                 conn.execute(f"alter table concerns add column {column} text")
 
+        for stmt in SCHEMA_STATEMENTS:
+            if not stmt.lstrip().lower().startswith("create index"):
+                continue
+            conn.execute(stmt)
+
 
 def get_state() -> dict[str, Any]:
     init_db()
@@ -190,7 +244,7 @@ def get_state() -> dict[str, Any]:
             return {
                 "status": "stub",
                 "thinking": "Hermes is scaffolded and waiting for integrations.",
-                "layers": ["concerns", "cycles", "observations", "states", "narratives", "decisions", "actions", "coverage_gaps"],
+                "layers": ["concerns", "decision_profiles", "strategy_groups", "strategy_assignments", "cycles", "observations", "states", "narratives", "decisions", "actions", "coverage_gaps"],
             }
         return json.loads(row["value"])
 
@@ -204,26 +258,27 @@ def put_state(payload: dict[str, Any]) -> None:
         )
 
 
-def upsert_concern(*, id: str, account_id: str | None, market_symbol: str | None, base_currency: str | None = None, quote_currency: str | None = None, strategy_id: str | None, source: str, status: str = "active", notes: str | None = None) -> None:
+def upsert_concern(*, id: str, account_id: str | None, market_symbol: str | None, base_currency: str | None = None, quote_currency: str | None = None, strategy_id: str | None, decision_profile_id: str | None = None, source: str, status: str = "active", notes: str | None = None) -> None:
     init_db()
     now = _now()
     with _connect() as conn:
         conn.execute(
             """
-            insert into concerns(id, account_id, market_symbol, base_currency, quote_currency, strategy_id, source, status, notes, created_at, updated_at)
-            values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+            insert into concerns(id, account_id, market_symbol, base_currency, quote_currency, strategy_id, decision_profile_id, source, status, notes, created_at, updated_at)
+            values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
             on conflict(id) do update set
               account_id=excluded.account_id,
               market_symbol=excluded.market_symbol,
               base_currency=excluded.base_currency,
               quote_currency=excluded.quote_currency,
               strategy_id=excluded.strategy_id,
+              decision_profile_id=excluded.decision_profile_id,
               source=excluded.source,
               status=excluded.status,
               notes=excluded.notes,
               updated_at=excluded.updated_at
             """,
-            (id, account_id, market_symbol, base_currency, quote_currency, strategy_id, source, status, notes, now, now),
+            (id, account_id, market_symbol, base_currency, quote_currency, strategy_id, decision_profile_id, source, status, notes, now, now),
         )
 
 
@@ -234,19 +289,123 @@ def list_concerns() -> list[dict[str, Any]]:
     return [dict(r) for r in rows]
 
 
+def upsert_decision_profile(*, id: str, name: str, config: dict[str, Any], description: str | None = None, status: str = "active") -> None:
+    init_db()
+    now = _now()
+    with _connect() as conn:
+        conn.execute(
+            """
+            insert into decision_profiles(id, name, description, config_json, status, created_at, updated_at)
+            values(?, ?, ?, ?, ?, ?, ?)
+            on conflict(id) do update set
+              name=excluded.name,
+              description=excluded.description,
+              config_json=excluded.config_json,
+              status=excluded.status,
+              updated_at=excluded.updated_at
+            """,
+            (id, name, description, json.dumps(config, ensure_ascii=False), status, now, now),
+        )
+
+
+def list_decision_profiles() -> list[dict[str, Any]]:
+    init_db()
+    with _connect() as conn:
+        rows = conn.execute("select * from decision_profiles order by updated_at desc").fetchall()
+    return [dict(r) for r in rows]
+
+
+def get_decision_profile(*, profile_id: str) -> dict[str, Any] | None:
+    init_db()
+    profile_id = str(profile_id or "").strip()
+    if not profile_id:
+        return None
+    with _connect() as conn:
+        row = conn.execute("select * from decision_profiles where id = ?", (profile_id,)).fetchone()
+    return dict(row) if row else None
+
+
+def upsert_strategy_group(*, id: str, concern_id: str, name: str, strategy_family: str | None = None, decision_profile_id: str | None = None, notes: str | None = None, status: str = "active") -> None:
+    init_db()
+    now = _now()
+    with _connect() as conn:
+        conn.execute(
+            """
+            insert into strategy_groups(id, concern_id, name, strategy_family, decision_profile_id, notes, status, created_at, updated_at)
+            values(?, ?, ?, ?, ?, ?, ?, ?, ?)
+            on conflict(id) do update set
+              concern_id=excluded.concern_id,
+              name=excluded.name,
+              strategy_family=excluded.strategy_family,
+              decision_profile_id=excluded.decision_profile_id,
+              notes=excluded.notes,
+              status=excluded.status,
+              updated_at=excluded.updated_at
+            """,
+            (id, concern_id, name, strategy_family, decision_profile_id, notes, status, now, now),
+        )
+
+
+def list_strategy_groups(*, concern_id: str | None = None) -> list[dict[str, Any]]:
+    init_db()
+    with _connect() as conn:
+        if concern_id:
+            rows = conn.execute("select * from strategy_groups where concern_id = ? order by updated_at desc", (concern_id,)).fetchall()
+        else:
+            rows = conn.execute("select * from strategy_groups order by updated_at desc").fetchall()
+    return [dict(r) for r in rows]
+
+
+def upsert_strategy_assignment(*, id: str, strategy_group_id: str, strategy_id: str, strategy_type: str | None = None, role: str | None = None, status: str = "active", notes: str | None = None) -> None:
+    init_db()
+    now = _now()
+    with _connect() as conn:
+        conn.execute(
+            """
+            insert into strategy_assignments(id, strategy_group_id, strategy_id, strategy_type, role, status, notes, created_at, updated_at)
+            values(?, ?, ?, ?, ?, ?, ?, ?, ?)
+            on conflict(id) do update set
+              strategy_group_id=excluded.strategy_group_id,
+              strategy_id=excluded.strategy_id,
+              strategy_type=excluded.strategy_type,
+              role=excluded.role,
+              status=excluded.status,
+              notes=excluded.notes,
+              updated_at=excluded.updated_at
+            """,
+            (id, strategy_group_id, strategy_id, strategy_type, role, status, notes, now, now),
+        )
+
+
+def list_strategy_assignments(*, strategy_group_id: str | None = None) -> list[dict[str, Any]]:
+    init_db()
+    with _connect() as conn:
+        if strategy_group_id:
+            rows = conn.execute("select * from strategy_assignments where strategy_group_id = ? order by updated_at desc", (strategy_group_id,)).fetchall()
+        else:
+            rows = conn.execute("select * from strategy_assignments order by updated_at desc").fetchall()
+    return [dict(r) for r in rows]
+
+
 def delete_concern(*, concern_id: str) -> dict[str, int]:
     init_db()
     concern_id = str(concern_id or "").strip()
     if not concern_id:
-        return {"concerns": 0, "observations": 0, "states": 0, "narratives": 0, "decisions": 0, "actions": 0, "coverage_gaps": 0, "regime_samples": 0}
+        return {"concerns": 0, "strategy_groups": 0, "strategy_assignments": 0, "observations": 0, "states": 0, "narratives": 0, "decisions": 0, "actions": 0, "coverage_gaps": 0, "regime_samples": 0}
 
-    deleted = {"concerns": 0, "observations": 0, "states": 0, "narratives": 0, "decisions": 0, "actions": 0, "coverage_gaps": 0, "regime_samples": 0}
+    deleted = {"concerns": 0, "strategy_groups": 0, "strategy_assignments": 0, "observations": 0, "states": 0, "narratives": 0, "decisions": 0, "actions": 0, "coverage_gaps": 0, "regime_samples": 0}
     with _connect() as conn:
         decision_ids = [row[0] for row in conn.execute("select id from decisions where concern_id = ?", (concern_id,)).fetchall()]
+        group_ids = [row[0] for row in conn.execute("select id from strategy_groups where concern_id = ?", (concern_id,)).fetchall()]
         deleted["actions"] = conn.execute(
             f"delete from actions where decision_id in ({','.join('?' for _ in decision_ids)})",
             decision_ids,
         ).rowcount if decision_ids else 0
+        deleted["strategy_assignments"] = conn.execute(
+            f"delete from strategy_assignments where strategy_group_id in ({','.join('?' for _ in group_ids)})",
+            group_ids,
+        ).rowcount if group_ids else 0
+        deleted["strategy_groups"] = conn.execute("delete from strategy_groups where concern_id = ?", (concern_id,)).rowcount or 0
         deleted["observations"] = conn.execute("delete from observations where concern_id = ?", (concern_id,)).rowcount or 0
         deleted["states"] = conn.execute("delete from states where concern_id = ?", (concern_id,)).rowcount or 0
         deleted["narratives"] = conn.execute("delete from narratives where concern_id = ?", (concern_id,)).rowcount or 0
@@ -278,6 +437,7 @@ def prune_older_than(days: int) -> dict[str, int]:
 def sync_concerns_from_strategies(strategies: list[dict[str, Any]]) -> list[dict[str, Any]]:
     seen: set[str] = set()
     synced: list[dict[str, Any]] = []
+    existing = {str(c.get("id") or ""): c for c in list_concerns()}
     for s in strategies:
         account_id = str(s.get("account_id") or "").strip() or None
         market_symbol = str(s.get("market_symbol") or "").strip() or None
@@ -290,6 +450,7 @@ def sync_concerns_from_strategies(strategies: list[dict[str, Any]]) -> list[dict
         if concern_id in seen:
             continue
         seen.add(concern_id)
+        current = existing.get(concern_id, {})
         upsert_concern(
             id=concern_id,
             account_id=account_id,
@@ -297,9 +458,10 @@ def sync_concerns_from_strategies(strategies: list[dict[str, Any]]) -> list[dict
             base_currency=base_currency,
             quote_currency=quote_currency,
             strategy_id=strategy_id,
-            source="trader_inventory",
-            status="active",
-            notes="mirrored from trader strategy inventory",
+            decision_profile_id=str(current.get("decision_profile_id") or "").strip() or None,
+            source=str(current.get("source") or "trader_inventory"),
+            status=str(current.get("status") or "active"),
+            notes=str(current.get("notes") or "mirrored from trader strategy inventory").strip() or None,
         )
         synced.append({"id": concern_id, "account_id": account_id, "market_symbol": market_symbol, "base_currency": base_currency, "quote_currency": quote_currency, "strategy_id": strategy_id})
     return synced
@@ -307,7 +469,7 @@ def sync_concerns_from_strategies(strategies: list[dict[str, Any]]) -> list[dict
 
 def table_counts() -> dict[str, int]:
     init_db()
-    tables = ["concerns", "cycles", "observations", "states", "narratives", "decisions", "actions", "coverage_gaps", "regime_samples"]
+    tables = ["concerns", "decision_profiles", "strategy_groups", "strategy_assignments", "cycles", "observations", "states", "narratives", "decisions", "actions", "coverage_gaps", "regime_samples"]
     out: dict[str, int] = {}
     with _connect() as conn:
         for table in tables:

+ 5 - 0
src/hermes_mcp/trader_client.py

@@ -66,6 +66,11 @@ async def list_accounts(base_url: str) -> list[dict[str, Any]]:
     return [a for a in accounts if isinstance(a, dict)]
 
 
+async def cancel_all_orders(base_url: str, account_id: str, client_id: str | None = None) -> dict[str, Any]:
+    payload = await _call_tool(base_url, "cancel_all_orders", {"account_id": account_id, "client_id": client_id})
+    return payload if isinstance(payload, dict) else {}
+
+
 async def apply_control_decision(base_url: str, payload: dict[str, Any]) -> dict[str, Any]:
     response = await _call_tool(base_url, "apply_control_decision", {"payload": payload})
     return response if isinstance(response, dict) else {}

+ 2 - 0
tests/test_report.py

@@ -5,3 +5,5 @@ def test_report_stub():
     payload = report()
     assert payload["status"] == "stub"
     assert payload["layers"]
+    assert "concerns" in payload
+    assert isinstance(payload["concerns"], list)

+ 27 - 1
tests/test_schema.py

@@ -1,4 +1,4 @@
-from hermes_mcp.store import init_db, table_counts
+from hermes_mcp.store import get_decision_profile, init_db, list_decision_profiles, list_strategy_assignments, list_strategy_groups, table_counts, upsert_concern, upsert_decision_profile, upsert_strategy_assignment, upsert_strategy_group
 
 
 def test_schema_tables_exist():
@@ -6,6 +6,9 @@ def test_schema_tables_exist():
     counts = table_counts()
     assert set(counts) == {
         "concerns",
+        "decision_profiles",
+        "strategy_groups",
+        "strategy_assignments",
         "cycles",
         "observations",
         "states",
@@ -15,3 +18,26 @@ def test_schema_tables_exist():
         "coverage_gaps",
         "regime_samples",
     }
+
+
+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_concern(
+        id="a1:xrpusd",
+        account_id="a1",
+        market_symbol="xrpusd",
+        base_currency="XRP",
+        quote_currency="USD",
+        strategy_id="grid-1",
+        decision_profile_id="profile-grid",
+        source="test",
+    )
+    upsert_strategy_group(id="group-1", concern_id="a1:xrpusd", name="XRP spot cluster", strategy_family="mixed", decision_profile_id="profile-grid")
+    upsert_strategy_assignment(id="assign-1", strategy_group_id="group-1", strategy_id="grid-1", strategy_type="grid_trader", role="primary")
+
+    profile = get_decision_profile(profile_id="profile-grid")
+    assert profile is not None
+    assert profile["id"] == "profile-grid"
+    assert list_decision_profiles()[0]["id"] == "profile-grid"
+    assert list_strategy_groups(concern_id="a1:xrpusd")[0]["id"] == "group-1"
+    assert list_strategy_assignments(strategy_group_id="group-1")[0]["id"] == "assign-1"