НПР / УДС — отдельная служба 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:
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 — под маржин-колл, у кого <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> маржин-колл (УДС<1)</span><span class="legend-item"><span class="legend-box" style="background:#fef3c7"></span> внимание (УДС<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>маржин-колл (УДС<1)</header><strong id="sum_calls">—</strong></article>
|
||||
<article class="tile-warn"><header>внимание (УДС<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}}
|
||||
Reference in New Issue
Block a user