НПР / УДС — отдельная служба bj-npr

Модуль вынесен в modules/npr + cmd/bj-npr (127.0.0.1:8083), обновляется
независимо от ядра. Служба читает подключение из setup.json и принимает
запросы только со служебным ключом модулей (/var/lib/bj/.bj/module.key,
создаёт bj-server). Полные страницы отдаются фрагментом (X-BJ-Layout: page).

bj-server хранит настройки и проверку подключения, проксирует /admin/npr
после входа, прав и лицензии и встраивает страницы модуля в общий макет
(admin.modulePage); если служба не отвечает — понятная страница.
Unit bj-npr.service встроен в установщик, установщик включает службы
установленных модулей; build-release.sh собирает bj-npr.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zuevav
2026-09-16 01:00:31 +03:00
parent d8dee07213
commit 5527a464ca
15 changed files with 872 additions and 467 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,32 +0,0 @@
{{define "content"}}
{{template "npr_css"}}
<script src="/admin/npr/static/htmx.min.js"></script>
<div class="doc-header" style="margin-bottom:12px"><h1>📊 Риск-монитор: НПР и УДС клиентов</h1><p class="lede">НПР1 / НПР2 / УДС клиентов маржинальной торговли по данным экспорта QUIK. Модуль перенесён как есть, доработки — по мере необходимости.</p></div>
<nav class="ntabs">
<a href="?tab=margin" class="{{if eq .Tab "margin"}}active{{end}}">Маржинальный риск <span class="count" id="tab_margin_count">—</span></a>
<a href="?tab=debt" class="{{if eq .Tab "debt"}}active{{end}}">Дебиторская задолженность <span class="count" id="tab_debt_count">—</span></a>
</nav>
{{if eq .Tab "margin"}}<p class="tab-desc">Клиенты с открытыми маржинальными позициями (есть начальная или минимальная маржа). У кого УДС близок к 1 — под маржин-колл, у кого &lt;0 — под принудительное закрытие.</p>
{{else}}<p class="tab-desc">Клиенты без открытых позиций, но с отрицательной стоимостью портфеля (должны брокеру). УДС у таких счетов в QUIK уходит в −9,99 — это задолженность, а не маржин-колл.</p>{{end}}
<div class="nhelp"><strong>УДС</strong> — уровень достаточности средств (чем меньше, тем опаснее). <strong>НПР1</strong> — запас относительно начальной маржи (отрицательный — запрещено наращивать позиции). <strong>НПР2</strong> — запас относительно минимальной маржи (отрицательный — начинается принудительное закрытие).
<div class="legend"><span class="legend-item"><span class="legend-box" style="background:#fee2e2"></span> маржин-колл (УДС&lt;1)</span><span class="legend-item"><span class="legend-box" style="background:#fef3c7"></span> внимание (УДС&lt;1,5)</span><span class="legend-item"><span class="legend-box" style="background:#d1fae5"></span> «∞» избыточно (УДС≥9,99)</span></div></div>
<section class="nsummary" hx-get="/admin/npr/api/summary?tab={{.Tab}}" hx-trigger="load, every 60s" hx-swap="none" hx-on::after-request="try{const d=JSON.parse(event.detail.xhr.responseText);for(const [k,v] of Object.entries({sum_total:d.total,sum_calls:d.calls,sum_warn:d.warn,sum_ok:d.ok,sum_inf:d.inf,tab_margin_count:d.tab_margin,tab_debt_count:d.tab_debt})){const e=document.getElementById(k);if(e)e.textContent=v;}}catch(e){}">
<article><header>всего клиентов</header><strong id="sum_total">—</strong></article>
<article class="tile-call"><header>маржин-колл (УДС&lt;1)</header><strong id="sum_calls">—</strong></article>
<article class="tile-warn"><header>внимание (УДС&lt;1,5)</header><strong id="sum_warn">—</strong></article>
<article class="tile-ok"><header>норма</header><strong id="sum_ok">—</strong></article>
<article class="tile-ok"><header>избыточно (∞)</header><strong id="sum_inf">—</strong></article>
</section>
<div class="nfresh" hx-get="/admin/npr/api/freshness" hx-trigger="load, every 15s" hx-swap="none" hx-on::after-request="try{const d=JSON.parse(event.detail.xhr.responseText);const now=d.server_now?new Date(d.server_now):new Date();const tick=d.last_tick?new Date(d.last_tick):null;const ago=tick?Math.round((now-tick.getTime())/1000):null;const ok=ago!==null&&ago<90;document.getElementById('fresh_dot').style.background=ok?'#10b981':'#dc2626';document.getElementById('fresh_text').textContent=tick?('Последнее обновление из QUIK: '+tick.toLocaleString('ru-RU')+' ('+ago+' сек. назад)'):'Ждём первый тик от экспортёра…';const ev=d.last_event_ts?new Date(d.last_event_ts):null;document.getElementById('fresh_detail').textContent='События в истории: всего '+d.total_rows.toLocaleString('ru-RU')+' · за час: '+d.events_last_hour+' · за сутки: '+d.events_last_24h+(ev?' · последнее: '+ev.toLocaleString('ru-RU'):'');}catch(e){}">
<span id="fresh_dot" style="width:10px;height:10px;border-radius:50%;background:#9ca3af;display:inline-block"></span><span id="fresh_text">Проверяю актуальность данных…</span><span class="muted" id="fresh_detail" style="margin-left:auto"></span></div>
<form id="filters" class="nfilters" hx-get="/admin/npr/rows" hx-trigger="input changed delay:300ms, change" hx-target="#rows" hx-swap="innerHTML" onsubmit="return false">
<label>Поиск по коду клиента<input type="search" name="q" placeholder="например, BR22…" value="{{.Q}}"></label>
<label>Тип клиента<select name="mt" multiple size="3">{{range $v := list "МД" "МД+" "С"}}<option value="{{$v}}" {{if has $.MT $v}}selected{{end}}>{{$v}}</option>{{end}}</select></label>
<label>УДС не менее<input type="number" step="0.01" name="umn" value="{{.UMN}}"></label>
<label>УДС не более<input type="number" step="0.01" name="umx" value="{{.UMX}}"></label>
<label style="align-self:center;white-space:nowrap;display:flex;gap:6px;align-items:center"><input type="checkbox" name="only_calls" value="true" {{if .OnlyCalls}}checked{{end}} style="width:auto"> только маржин-колл</label>
<label>Сортировка<select name="sort">{{range $k, $t := dict "uds_asc" "УДС ↑ (опаснее сверху)" "uds_desc" "УДС ↓" "client_asc" "Код клиента ↑" "client_desc" "Код клиента ↓" "ts_desc" "Время обновления ↓"}}<option value="{{$k}}" {{if eq $.Sort $k}}selected{{end}}>{{$t}}</option>{{end}}</select></label>
<input type="hidden" name="page" value="{{.Page}}"><input type="hidden" name="tab" value="{{.Tab}}">
</form>
<div id="rows" hx-get="/admin/npr/rows" hx-trigger="load, every 60s" hx-include="#filters" hx-swap="innerHTML"><p class="muted"><em>Загружаю клиентов…</em></p></div>
{{end}}
@@ -1,47 +0,0 @@
{{define "content"}}
{{template "npr_css"}}
<p style="margin:0 0 12px;display:flex;align-items:center;gap:12px"><a href="/admin/npr" class="btn">← К списку клиентов</a>{{if not .Custom}}<span class="muted" id="autoupd" style="margin-left:auto">⟳ Страница обновится автоматически через 60 сек</span>{{end}}</p>
{{if not .Custom}}<script>(function(){let left=60;const el=document.getElementById('autoupd');const t=setInterval(function(){left-=1;if(el)el.textContent='⟳ Обновление через '+left+' сек';if(left<=0){clearInterval(t);location.reload();}},1000);})();</script>{{end}}
<div class="card">
<div style="display:flex;align-items:baseline;gap:12px;flex-wrap:wrap"><h2 style="margin:0">Клиент <code style="font-size:1.1em">{{.C.ClientCode}}</code></h2><span class="muted">Тип: <strong>{{if .C.MarginType}}{{.C.MarginType}}{{else}}—{{end}}</strong> · Данные на {{.C.TS.Local.Format "02.01.2006 15:04:05"}}</span></div>
<section class="nmetrics">
<article class="{{if or (eq .C.UDSFlag "CALL") (and .C.UDS (lt (deref .C.UDS) 1.0))}}m-call{{else if eq .C.UDSFlag "INF"}}m-ok{{end}}"><header>Уровень достаточности средств (УДС)</header><strong>{{if .C.UDS}}{{nprF .C.UDS}}{{else if eq .C.UDSFlag "INF"}}∞{{else if eq .C.UDSFlag "CALL"}}маржин-колл{{else}}—{{end}}</strong>{{if .C.BadgeClass}}<div style="margin-top:4px"><span class="badge {{.C.BadgeClass}}">{{.C.BadgeText}}</span></div>{{end}}</article>
<article><header>НПР1 (запас до начальной маржи)</header><strong>{{nprF .C.RCV1}}</strong></article>
<article><header>НПР2 (запас до минимальной маржи)</header><strong>{{nprF .C.RCV2}}</strong></article>
<article><header>Стоимость портфеля</header><strong>{{nprF .C.PortfolioValue}}</strong></article>
<article><header>Маржинальное требование</header><strong>{{nprF .C.MarginReq}}</strong></article>
</section>
<section style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:12px 0">
<div class="nmini"><header>Начальная маржа</header><strong>{{nprF .C.InitMargin}}</strong></div>
<div class="nmini"><header>Минимальная маржа</header><strong>{{nprF .C.MinMargin}}</strong></div>
<div class="nmini"><header>Скорректированная маржа</header><strong>{{nprF .C.CorrMargin}}</strong></div>
</section>
</div>
<div class="card">
<h3 style="margin:0 0 8px">История изменений</h3>
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:8px">{{range .Periods}}<a class="btn{{if and (eq $.Hours .H) (not $.Custom)}} btn-ok{{end}}" href="?hours={{.H}}">{{.T}}</a>{{end}}<span class="muted" style="margin-left:auto">или свой диапазон (МСК):</span></div>
<form method="get" action="" style="display:flex;gap:8px;align-items:end;flex-wrap:wrap;margin-bottom:8px">
<label style="margin:0;font-size:12px">С<br><input type="datetime-local" name="dt_from" value="{{.DtFrom}}"></label>
<label style="margin:0;font-size:12px">По<br><input type="datetime-local" name="dt_to" value="{{.DtTo}}"></label>
<button type="submit" class="btn">Показать</button><a class="btn" href="?hours=24">Сбросить</a>
<a class="btn" style="margin-left:auto" href="/admin/npr/c/{{.C.ClientCode}}/export.csv?{{if .Custom}}dt_from={{.DtFrom}}&dt_to={{.DtTo}}{{else}}hours={{.Hours}}{{end}}">⤓ Скачать CSV (для регулятора)</a>
</form>
<p class="muted">Показано: с <strong>{{.TF.Local.Format "02.01.2006 15:04"}}</strong> по <strong>{{.TT.Local.Format "02.01.2006 15:04"}}</strong> (МСК).</p>
<canvas id="chart" style="max-height:340px;margin-bottom:14px"></canvas>
<h4 style="margin:0 0 6px">Последние {{len .History}} записей</h4>
<table class="ntable"><thead><tr><th>Время</th><th class="num">УДС</th><th>Статус</th><th class="num">НПР1</th><th class="num">НПР2</th><th class="num">Марж. требование</th></tr></thead>
<tbody>{{range .History}}<tr class="{{.Class}}"><td><small>{{.TS.Local.Format "02.01.2006 15:04:05"}}</small></td><td class="num">{{nprF .UDS}}</td><td>{{if .BadgeClass}}<span class="badge {{.BadgeClass}}">{{.BadgeText}}</span>{{else}}—{{end}}</td><td class="num">{{nprF .RCV1}}</td><td class="num">{{nprF .RCV2}}</td><td class="num">{{nprF .MarginReq}}</td></tr>{{end}}</tbody></table>
</div>
<script src="/admin/npr/static/chart.umd.min.js"></script>
<script>
fetch("/admin/npr/c/{{.C.ClientCode}}/series?{{if .Custom}}dt_from={{.DtFrom}}&dt_to={{.DtTo}}{{else}}hours={{.Hours}}{{end}}").then(r=>r.json()).then(d=>{
const labels=d.labels.map(s=>new Date(s).toLocaleString('ru-RU',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}));
new Chart(document.getElementById('chart'),{type:'line',data:{labels:labels,datasets:[
{label:'НПР1',data:d.rcv1,borderColor:'#059669',backgroundColor:'#05966920',tension:.2,spanGaps:true,yAxisID:'y',pointRadius:1.5},
{label:'НПР2',data:d.rcv2,borderColor:'#2563eb',backgroundColor:'#2563eb20',tension:.2,spanGaps:true,yAxisID:'y',pointRadius:1.5},
{label:'УДС',data:d.uds,borderColor:'#dc2626',backgroundColor:'#dc262620',tension:.2,spanGaps:true,yAxisID:'y2',pointRadius:1.5,borderDash:[5,3]}]},
options:{animation:false,responsive:true,maintainAspectRatio:false,interaction:{mode:'index',intersect:false},plugins:{legend:{position:'top'},tooltip:{callbacks:{label:c=>c.dataset.label+': '+(c.parsed.y===null?'—':c.parsed.y.toLocaleString('ru-RU',{maximumFractionDigits:2}))}}},
scales:{y:{position:'left',title:{display:true,text:'НПР'}},y2:{position:'right',title:{display:true,text:'УДС'},grid:{drawOnChartArea:false},suggestedMin:-1,suggestedMax:3}}}});
});
</script>
{{end}}
@@ -1,7 +0,0 @@
{{define "npr_rows"}}
<p class="muted">Найдено клиентов: <strong>{{.Total}}</strong>.{{if .Pages}} Страница <strong>{{.Page}}</strong> из <strong>{{.Pages}}</strong>.{{end}}</p>
<table class="ntable"><thead><tr><th>Код клиента</th><th>Тип</th><th class="num">УДС</th><th>Статус</th><th class="num">НПР1</th><th class="num">НПР2</th><th class="num">Марж. требование</th><th>Обновлено</th></tr></thead>
<tbody>{{range .Rows}}<tr class="{{.Class}}"><td><a href="/admin/npr/c/{{.ClientCode}}">{{.ClientCode}}</a></td><td>{{if .MarginType}}{{.MarginType}}{{else}}—{{end}}</td><td class="num"><strong>{{nprF .UDS}}</strong></td><td>{{if .BadgeClass}}<span class="badge {{.BadgeClass}}">{{.BadgeText}}</span>{{else}}—{{end}}</td><td class="num">{{nprF .RCV1}}</td><td class="num">{{nprF .RCV2}}</td><td class="num">{{nprF .MarginReq}}</td><td><small>{{.TS.Local.Format "15:04:05"}}</small></td></tr>{{end}}
{{if not .Rows}}<tr><td colspan="8" class="muted" style="text-align:center;padding:2rem"><em>Ни одного клиента под этот фильтр не найдено</em></td></tr>{{end}}</tbody></table>
<nav style="display:flex;gap:8px;justify-content:center;margin-top:10px">{{if gt .Page 1}}<button class="btn" hx-get="/admin/npr/rows" hx-include="#filters" hx-vals='{"page": {{add .Page -1}} }' hx-target="#rows">← Предыдущая</button>{{end}}{{if .HasNext}}<button class="btn" hx-get="/admin/npr/rows" hx-include="#filters" hx-vals='{"page": {{add .Page 1}} }' hx-target="#rows">Следующая →</button>{{end}}</nav>
{{end}}
@@ -1,14 +0,0 @@
{{define "npr_css"}}
<style>
.ntabs{display:flex;gap:4px;margin:0 0 10px;border-bottom:2px solid var(--border)}.ntabs a{padding:8px 14px;text-decoration:none;color:var(--text-2);border-bottom:2px solid transparent;margin-bottom:-2px;display:inline-flex;align-items:center;gap:6px;font-size:14px}.ntabs a.active{border-bottom-color:var(--accent);color:var(--accent-strong);font-weight:600}.ntabs .count{background:var(--card-2);color:var(--text-2);font-size:11px;padding:1px 7px;border-radius:10px;font-weight:600}
.tab-desc{font-size:13px;color:var(--muted);margin:0 0 10px}.nhelp{background:var(--card-2);padding:10px 14px;border-radius:6px;margin-bottom:12px;font-size:13px}.nhelp .legend{display:inline-flex;gap:12px;flex-wrap:wrap;margin-top:6px}.legend-item{display:inline-flex;align-items:center;gap:5px}.legend-box{display:inline-block;width:14px;height:14px;border-radius:3px;border:1px solid #999}
.nsummary{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin-bottom:10px}.nsummary article{margin:0;padding:8px 10px;text-align:center;border:1px solid var(--border);border-radius:8px;background:var(--card)}.nsummary header{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.03em}.nsummary strong{font-size:24px;display:block;line-height:1.1}.tile-call strong{color:#b91c1c}.tile-warn strong{color:#b45309}.tile-ok strong{color:#047857}.tile-call{border-color:#fecaca!important;background:color-mix(in srgb,#fee2e2 50%,var(--card))!important}.tile-warn{border-color:#fde68a!important;background:color-mix(in srgb,#fef3c7 50%,var(--card))!important}.tile-ok{border-color:#d1fae5!important;background:color-mix(in srgb,#d1fae5 50%,var(--card))!important}
.nfresh{display:flex;align-items:center;gap:8px;margin-bottom:12px;padding:7px 12px;background:var(--card-2);border-radius:6px;font-size:13px;flex-wrap:wrap}
.nfilters{display:grid;grid-template-columns:2fr 1fr 1fr 1fr auto 1fr;gap:8px;align-items:end;padding:10px;background:var(--card-2);border-radius:8px;margin-bottom:12px}.nfilters label{font-size:12px;color:var(--muted);margin:0}.nfilters input,.nfilters select{margin:0;padding:5px 8px;font-size:13px;width:100%}
.ntable{width:100%;border-collapse:collapse;font-size:13px}.ntable th,.ntable td{padding:5px 8px;border-bottom:1px solid var(--border);vertical-align:middle}.ntable thead th{background:var(--card-2);font-size:11px;text-transform:uppercase;letter-spacing:.03em;text-align:left}.ntable tbody tr:hover{background:var(--card-2)}.ntable td a{text-decoration:none;font-weight:600;color:var(--accent-strong)}.num{text-align:right;font-variant-numeric:tabular-nums}
.row-call{background:color-mix(in srgb,#fee2e2 70%,var(--card))!important}.row-call td{color:#7f1d1d}.row-warn{background:color-mix(in srgb,#fef3c7 70%,var(--card))!important}.row-inf{background:color-mix(in srgb,#d1fae5 60%,var(--card))!important}
.badge{display:inline-block;padding:1px 8px;border-radius:3px;font-size:11px;font-weight:600}.badge-call{background:#dc2626;color:#fff}.badge-warn{background:#f59e0b;color:#fff}.badge-inf{background:#10b981;color:#fff}.badge-ok{background:var(--card-2);color:var(--text-2)}
.nmetrics{display:grid;grid-template-columns:repeat(5,1fr);gap:10px;margin:12px 0 0}.nmetrics article{margin:0;padding:12px;text-align:center;border:1px solid var(--border);border-radius:8px}.nmetrics header{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.03em;margin-bottom:5px}.nmetrics strong{font-size:26px;display:block;font-variant-numeric:tabular-nums}.m-call strong{color:#b91c1c}.m-ok strong{color:#047857}.nmini{padding:10px;border:1px solid var(--border);border-radius:8px}.nmini header{font-size:11px;color:var(--muted);text-transform:uppercase}
@media (max-width:900px){.nsummary,.nmetrics{grid-template-columns:repeat(2,1fr)}.nfilters{grid-template-columns:1fr 1fr}}
</style>
{{end}}