Экспорт QUIK: настройка пакетами данных вместо правки export.ini

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zuevav
2026-09-17 21:43:41 +03:00
parent 1fbc5d2375
commit 893e1222ab
3 changed files with 318 additions and 16 deletions
+181 -8
View File
@@ -70,7 +70,8 @@ type QuikExportSettings struct {
ROPassword string `json:"ro_password"` ROPassword string `json:"ro_password"`
// Объём данных. // Объём данных.
Preset string `json:"preset"` // npr | depo | trading | full | custom Preset string `json:"preset"` // packages — набор пакетов; custom — таблицы вручную (npr|depo|trading|full — старые наборы)
Packages []string `json:"packages"` // выбранные пакеты данных
Tables []string `json:"tables"` Tables []string `json:"tables"`
PortfolioPeriod int `json:"portfolio_period"` PortfolioPeriod int `json:"portfolio_period"`
ParamPeriod int `json:"param_period"` ParamPeriod int `json:"param_period"`
@@ -112,8 +113,19 @@ func (s QuikExportSettings) withDefaults() QuikExportSettings {
if s.Preset == "" { if s.Preset == "" {
s.Preset = "npr" s.Preset = "npr"
} }
if s.Preset != "custom" { // старые наборы (npr/depo/trading/full) переводим в пакеты
if len(s.Packages) == 0 {
if len(s.Tables) > 0 {
s.Packages = quikExportPackagesOf(s.Tables)
} else {
s.Packages = quikExportPackagesOf(quikExportPresetTables(s.Preset))
}
}
s.Preset = "packages"
s.Tables = quikExportPackageTables(s.Packages)
}
if len(s.Tables) == 0 { if len(s.Tables) == 0 {
s.Tables = quikExportPresetTables(s.Preset) s.Tables = []string{"Portfolio"}
} }
if s.PortfolioPeriod == 0 { if s.PortfolioPeriod == 0 {
s.PortfolioPeriod = 60 s.PortfolioPeriod = 60
@@ -210,6 +222,114 @@ var quikExportTables = []quikExportTable{
{"SecAccIntEx", "Внутренние переводы бумаг", "Прочее", "низкий", ""}, {"SecAccIntEx", "Внутренние переводы бумаг", "Прочее", "низкий", ""},
} }
// quikExportPackage — «пакет данных»: набор таблиц экспортёра под конкретную задачу. Оператор
// отмечает пакеты галочками, bj сам собирает из них секцию [Output tables] в export.ini.
type quikExportPackage struct {
Key string
Title string
What string // какие данные приходят
Gives []string // что это даёт: кому и для чего
Load string // объём
Warn string // предупреждение (пусто — нет)
Tables []string
}
var quikExportPackages = []quikExportPackage{
{"risk", "Маржа и риск клиента", "Стоимость портфеля, начальная и минимальная маржа, скорректированная маржа, НПР1/НПР2, уровень достаточности средств (УДС), статус расчёта.",
[]string{
"Модуль «НПР / УДС»: списки клиентов, зоны риска, история УДС — без пакета модуль пуст",
"Личный кабинет: показатель достаточности средств и предупреждение о марджин-колле",
"Оповещения: выход клиента в зону риска",
}, "≈20 МБ/сутки при 5 тыс. клиентов и периоде 60 с", "", []string{"Portfolio"}},
{"positions", "Позиции и деньги клиента", "Остатки бумаг по торговым счетам (лимиты ДЕПО), денежные лимиты и позиции по счетам.",
[]string{
"Личный кабинет: портфель клиента по торговым счетам — что есть и сколько заблокировано",
"M2M: проверка позиции в торговой системе перед списанием бумаг и сверка с остатком депозитария",
"Бэк-офис: расхождения между учётом депозитария и торговой системой",
}, "средний — десятки МБ/сутки", "", []string{"DepoLimits", "MoneyLimits", "AccountPositions"}},
{"refs", "Справочники бумаг и счетов", "Инструменты и их коды, классы, фирмы, торговые счета, счета депо, валюты, коды расчётов, календари торгов.",
[]string{
"Сопоставление ISIN с кодом бумаги в QUIK — без этого позиции не связать с депозитарными",
"Личный кабинет: нормальные наименования бумаг вместо кодов",
"Привязка клиента к его торговому счёту и счёту депо",
}, "низкий — единицы МБ", "", []string{"Classes", "Securities", "SecuritiesDictionary", "Firms", "Trdaccs", "FirmDepoAccount", "SecDepoAccount", "CurrencyInfo", "SettleCodes", "Calendars"}},
{"prices", "Цены для оценки портфеля", "Текущие торги: последние цены, изменение к закрытию, параметры инструментов.",
[]string{
"Личный кабинет: стоимость портфеля и позиций в рублях, изменение за день",
"Отчёты и выписки с оценкой по рыночной цене",
}, "высокий — зависит от числа инструментов и периода «Текущих торгов»", "", []string{"Params"}},
{"deals", "Сделки и заявки клиента", "Сделки, заявки, стоп-заявки, внебиржевые сделки и отчёты по ним, результаты транзакций.",
[]string{
"Личный кабинет: история сделок и заявок клиента",
"Отчёты брокера и сверка операций",
}, "высокий — сотни МБ в активные дни", "", []string{"Trades", "Orders", "StopOrders", "NegDeals", "NegReports", "UsTrades", "TransResult"}},
{"futures", "Срочный рынок", "Позиции и лимиты на срочном рынке (FORTS).",
[]string{
"Личный кабинет и НПР: показатели клиентов, торгующих фьючерсами",
}, "средний", "", []string{"FuturesHolding", "FuturesLimits"}},
{"journals", "Журналы изменений лимитов", "История изменения денежных лимитов, лимитов по бумагам и позиций срочного рынка.",
[]string{
"Разбор спорных ситуаций: когда и на сколько изменилась позиция клиента",
"Аудит и внутренний контроль",
}, "высокий — растёт постоянно", "Журналы не очищаются экспортёром: следите за местом на диске.", []string{"MoneyLimitsJournal", "DepoLimitsJournal", "FuturesHoldingJournal"}},
{"ncc", "Риск-параметры НКЦ", "Обеспечение, ценовые коридоры, индикативные курсы, спреды, статистика маркет-мейкера.",
[]string{
"Риск-менеджмент и отчётность по требованиям НКЦ",
"Проверка ценовых коридоров при оценке обеспечения",
}, "низкий — средний", "", []string{"CCPHold", "CCPIndicative", "CCPPriceRange", "RmHold", "RmIndicative", "RmPriceRange", "RmPriceRangeFirm", "RmProductSpreadCurr", "MarketMakerStats", "MarketMakerForts", "MarketMakerExtStats"}},
{"market", "Весь рынок: обезличенные сделки и стаканы", "Все сделки рынка и котировки (стаканы) по инструментам.",
[]string{
"Аналитика и собственные расчёты по рынку",
}, "очень высокий — гигабайты в сутки", "Включайте только при явной необходимости и запасе места на диске: нагрузка на сервер QUIK и базу возрастает в разы.", []string{"AllTrades", "Quotes"}},
{"misc", "Прочее", "Внутренние переводы бумаг, переводы НКЦ, запреты, алгоритмические заявки.",
[]string{
"Редкие задачи бэк-офиса и расследования",
}, "низкий", "", []string{"SecAccIntEx", "NCCTransfers", "Prohibitions", "AlgoOrders", "AlgoLinkedOrders", "AlgoLinkedTrades"}},
}
// quikExportPackageTables — таблицы выбранных пакетов (Portfolio входит всегда: на нём держится НПР).
func quikExportPackageTables(keys []string) []string {
on := map[string]bool{"Portfolio": true}
for _, k := range keys {
for _, p := range quikExportPackages {
if p.Key == k {
for _, t := range p.Tables {
on[t] = true
}
}
}
}
out := make([]string, 0, len(on))
for _, t := range quikExportTables { // порядок каталога
if on[t.Name] {
out = append(out, t.Name)
}
}
return out
}
// quikExportPackagesOf — какие пакеты покрыты набором таблиц (все таблицы пакета включены).
func quikExportPackagesOf(tables []string) []string {
on := map[string]bool{}
for _, t := range tables {
on[t] = true
}
var out []string
for _, p := range quikExportPackages {
all := true
for _, t := range p.Tables {
if !on[t] {
all = false
break
}
}
if all {
out = append(out, p.Key)
}
}
return out
}
var quikExportPresets = []struct{ Key, Title, Desc string }{ var quikExportPresets = []struct{ Key, Title, Desc string }{
{"npr", "Минимум — только НПР/УДС", "Portfolio. Достаточно для модуля «НПР / УДС». ≈20 МБ/сутки."}, {"npr", "Минимум — только НПР/УДС", "Portfolio. Достаточно для модуля «НПР / УДС». ≈20 МБ/сутки."},
{"depo", "Депозитарий — портфели, позиции, справочники", "Portfolio + DepoLimits, MoneyLimits, AccountPositions + справочники бумаг и счетов. Даёт bj дополнительные проверки при M2M (позиция клиента в QUIK). ≈50–100 МБ/сутки."}, {"depo", "Депозитарий — портфели, позиции, справочники", "Portfolio + DepoLimits, MoneyLimits, AccountPositions + справочники бумаг и счетов. Даёт bj дополнительные проверки при M2M (позиция клиента в QUIK). ≈50–100 МБ/сутки."},
@@ -553,7 +673,11 @@ func quikExportImport(ctx context.Context, st *Store) (string, error) {
} }
if tabs := iniEnabledOutputTables(exp); len(tabs) > 0 { if tabs := iniEnabledOutputTables(exp); len(tabs) > 0 {
s.Tables = tabs s.Tables = tabs
s.Preset = quikExportDetectPreset(tabs) s.Packages = quikExportPackagesOf(tabs)
s.Preset = "custom" // набор из файла: пакетами описывается, только если совпадает точно
if same(tabs, quikExportPackageTables(s.Packages)) {
s.Preset = "packages"
}
} }
if inf != "" { if inf != "" {
set(&s.QuikHost, iniGet(inf, "connect", "trade-server-ip-address")) set(&s.QuikHost, iniGet(inf, "connect", "trade-server-ip-address"))
@@ -739,6 +863,7 @@ type quikExportDBStats struct {
Name, Size string Name, Size string
Rows int64 Rows int64
} }
NonEmpty map[string]bool // таблицы экспортёра, в которых есть данные
HistoryRows int64 HistoryRows int64
HistoryFrom time.Time HistoryFrom time.Time
Rows24h int64 Rows24h int64
@@ -828,6 +953,16 @@ func quikExportStats(s QuikExportSettings) quikExportDBStats {
st.LastTick = *tick st.LastTick = *tick
st.TickAgeSec = int(time.Since(*tick).Seconds()) st.TickAgeSec = int(time.Since(*tick).Seconds())
} }
st.NonEmpty = map[string]bool{} // по каким таблицам экспортёр реально приносит данные
if rows, err := p.Query(ctx, `SELECT relname FROM pg_stat_user_tables WHERE schemaname='public' AND (n_live_tup > 0 OR reltuples > 0)`); err == nil {
for rows.Next() {
var n string
if rows.Scan(&n) == nil {
st.NonEmpty[n] = true
}
}
rows.Close()
}
_ = p.QueryRow(ctx, `SELECT count(DISTINCT "ClientCode") FROM "Portfolio"`).Scan(&st.Clients) _ = p.QueryRow(ctx, `SELECT count(DISTINCT "ClientCode") FROM "Portfolio"`).Scan(&st.Clients)
st.OK = true st.OK = true
return st return st
@@ -850,6 +985,9 @@ type QuikExportView struct {
Tables []quikExportTable Tables []quikExportTable
Groups []string Groups []string
Presets []struct{ Key, Title, Desc string } Presets []struct{ Key, Title, Desc string }
Packages []quikExportPackage
PkgOn map[string]bool // пакет выбран в настройках
PkgLive map[string]bool // пакет уже выгружается экспортёром (по export.ini)
Enabled map[string]bool Enabled map[string]bool
RODSN string RODSN string
RODSNFull string RODSNFull string
@@ -881,6 +1019,12 @@ func quikExportView(st *Store, licensed, showRO bool) QuikExportView {
for _, t := range s.Tables { for _, t := range s.Tables {
v.Enabled[t] = true v.Enabled[t] = true
} }
v.Packages = quikExportPackages
v.PkgOn = map[string]bool{}
for _, k := range s.Packages {
v.PkgOn[k] = true
}
v.PkgLive = map[string]bool{} // по каким пакетам данные уже есть в базе
seen := map[string]bool{} seen := map[string]bool{}
for _, t := range quikExportTables { for _, t := range quikExportTables {
if !seen[t.Group] { if !seen[t.Group] {
@@ -903,6 +1047,15 @@ func quikExportView(st *Store, licensed, showRO bool) QuikExportView {
v.DiskDB = diskUsage("/var/lib/postgresql") v.DiskDB = diskUsage("/var/lib/postgresql")
} }
v.DB = quikExportStats(s) v.DB = quikExportStats(s)
if len(v.DB.NonEmpty) > 0 { // пакет отмечаем «выгружается», когда его таблицы в базе не пусты
var have []string
for t := range v.DB.NonEmpty {
have = append(have, t)
}
for _, k := range quikExportPackagesOf(have) {
v.PkgLive[k] = true
}
}
v.LogTail = quikExportTail(filepath.Join(s.Dir, "logs", "export.log"), 12) v.LogTail = quikExportTail(filepath.Join(s.Dir, "logs", "export.log"), 12)
v.ErrTail = quikExportTail(filepath.Join(s.Dir, "logs", "export.err"), 6) v.ErrTail = quikExportTail(filepath.Join(s.Dir, "logs", "export.err"), 6)
v.RODSN = s.RODSN(false) v.RODSN = s.RODSN(false)
@@ -1023,8 +1176,8 @@ func (srv *Server) saveQuikExport(w http.ResponseWriter, r *http.Request) {
s.ROPassword = p s.ROPassword = p
} }
case "volume": case "volume":
s.Preset = f("preset") if f("mode") == "custom" {
if s.Preset == "custom" { s.Preset = "custom"
s.Tables = nil s.Tables = nil
for _, t := range r.Form["tables"] { for _, t := range r.Form["tables"] {
s.Tables = append(s.Tables, t) s.Tables = append(s.Tables, t)
@@ -1032,10 +1185,13 @@ func (srv *Server) saveQuikExport(w http.ResponseWriter, r *http.Request) {
if len(s.Tables) == 0 { if len(s.Tables) == 0 {
s.Tables = []string{"Portfolio"} s.Tables = []string{"Portfolio"}
} }
} else {
s.Tables = quikExportPresetTables(s.Preset)
}
sort.Strings(s.Tables) sort.Strings(s.Tables)
s.Packages = quikExportPackagesOf(s.Tables)
} else {
s.Preset = "packages"
s.Packages = append([]string(nil), r.Form["packages"]...)
s.Tables = quikExportPackageTables(s.Packages)
}
s.PortfolioPeriod = atoi(f("portfolio_period"), 60) s.PortfolioPeriod = atoi(f("portfolio_period"), 60)
if s.PortfolioPeriod < 10 { if s.PortfolioPeriod < 10 {
s.PortfolioPeriod = 10 s.PortfolioPeriod = 10
@@ -1231,3 +1387,20 @@ func (srv *Server) previewQuikExport(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprintf(w, "===== info.ini =====\n%s\n\n===== /etc/odbc.ini (секция) =====\n%s\n\n===== export.ini =====\n%s", mask(inf), quikExportRenderODBC(s), mask(exp)) fmt.Fprintf(w, "===== info.ini =====\n%s\n\n===== /etc/odbc.ini (секция) =====\n%s\n\n===== export.ini =====\n%s", mask(inf), quikExportRenderODBC(s), mask(exp))
} }
// same — одинаковые ли наборы имён таблиц.
func same(a, b []string) bool {
if len(a) != len(b) {
return false
}
m := map[string]bool{}
for _, v := range a {
m[v] = true
}
for _, v := range b {
if !m[v] {
return false
}
}
return true
}
@@ -0,0 +1,102 @@
package quikexport
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
// Пакеты: набор таблиц, обратное определение пакетов, обязательный Portfolio.
func TestQuikExportPackages(t *testing.T) {
tabs := quikExportPackageTables([]string{"positions", "refs"})
for _, want := range []string{"Portfolio", "DepoLimits", "MoneyLimits", "Securities", "SecDepoAccount"} {
if !slices.Contains(tabs, want) {
t.Fatalf("нет %s в %v", want, tabs)
}
}
if slices.Contains(tabs, "AllTrades") {
t.Fatalf("лишняя таблица рынка: %v", tabs)
}
if got := quikExportPackageTables(nil); len(got) != 1 || got[0] != "Portfolio" {
t.Fatalf("без пакетов остаётся только Portfolio: %v", got)
}
pk := quikExportPackagesOf(tabs)
if !slices.Contains(pk, "positions") || !slices.Contains(pk, "refs") || !slices.Contains(pk, "risk") {
t.Fatalf("обратное определение: %v", pk)
}
if slices.Contains(pk, "deals") {
t.Fatalf("пакет сделок не выбран: %v", pk)
}
// Каждая таблица пакета есть в каталоге экспортёра.
known := map[string]bool{}
for _, tb := range quikExportTables {
known[tb.Name] = true
}
for _, p := range quikExportPackages {
for _, tb := range p.Tables {
if !known[tb] {
t.Errorf("пакет %s: таблицы %s нет в каталоге", p.Key, tb)
}
}
}
}
// Старая настройка (preset=depo) переносится в пакеты.
func TestQuikExportPresetMigration(t *testing.T) {
s := QuikExportSettings{Preset: "depo"}.withDefaults()
if s.Preset != "packages" || len(s.Packages) == 0 {
t.Fatalf("миграция набора: %+v", s.Packages)
}
if !slices.Contains(s.Tables, "DepoLimits") || !slices.Contains(s.Tables, "Portfolio") {
t.Fatalf("таблицы после миграции: %v", s.Tables)
}
}
// Раздел настроек рисует пакеты и сохраняет выбор.
func TestQuikExportPackagesSection(t *testing.T) {
dir := t.TempDir()
keyPath := filepath.Join(dir, "module.key")
if err := os.WriteFile(keyPath, []byte(testKey+"\n"), 0o600); err != nil {
t.Fatal(err)
}
srv, err := New(Config{StorePath: filepath.Join(dir, "settings.json"), SetupPath: filepath.Join(dir, "setup.json"), KeyPath: keyPath})
if err != nil {
t.Fatal(err)
}
h := srv.Handler()
req := httptest.NewRequest(http.MethodGet, "/section", nil)
req.Header.Set("X-BJ-Module-Key", testKey)
req.Header.Set("X-BJ-Licensed", "1")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("раздел: %d %s", rec.Code, rec.Body.String())
}
for _, want := range []string{"Какие данные выгружаем", "Позиции и деньги клиента", "Цены для оценки портфеля", "Личный кабинет"} {
if !strings.Contains(rec.Body.String(), want) {
t.Errorf("в разделе нет %q", want)
}
}
form := url.Values{"part": {"volume"}, "mode": {"packages"}, "packages": {"risk", "positions", "prices"},
"portfolio_period": {"60"}, "param_period": {"30"}, "limit_kinds": {"0, 1, 365"}, "debug_level": {"1"}}
req = httptest.NewRequest(http.MethodPost, "/admin/setup/quikexport", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("X-BJ-Module-Key", testKey)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("сохранение: %d", rec.Code)
}
s := srv.store.Get()
if s.Preset != "packages" || !slices.Contains(s.Tables, "DepoLimits") || !slices.Contains(s.Tables, "Params") {
t.Fatalf("после сохранения: %v %v", s.Preset, s.Tables)
}
if slices.Contains(s.Tables, "Trades") {
t.Fatalf("пакет сделок не выбирали: %v", s.Tables)
}
}
+36 -9
View File
@@ -96,27 +96,54 @@
</div> </div>
<div class="card"> <div class="card">
<h2><span class="dot ok"></span>3. Объём данных</h2> <h2><span class="dot ok"></span>3. Какие данные выгружаем</h2>
<p class="muted">Какие таблицы QUIK выгружаем. Чем больше набор, тем больше нагрузка на сервер QUIK, база и диск. Портфели пересчитываются каждые «период портфелей» секунд (ARQA рекомендует не менее 60); история изменений НПР/УДС хранится в схеме <code>npr</code>.</p> <p class="muted">Выгрузка настраивается пакетами: отметьте, что нужно, — bj сам соберёт секцию <code>[Output tables]</code> в <code>export.ini</code>, править файл руками не нужно. Чем больше пакетов, тем выше нагрузка на сервер QUIK, базу и диск. Пакет «Маржа и риск» включён всегда: на нём держится модуль «НПР / УДС».</p>
<form method="post" action="/admin/setup/quikexport" style="display:grid;gap:10px;margin-top:10px"> <form method="post" action="/admin/setup/quikexport" style="display:grid;gap:12px;margin-top:10px">
<input type="hidden" name="section" value="sec-quikexport"><input type="hidden" name="part" value="volume"> <input type="hidden" name="section" value="sec-quikexport"><input type="hidden" name="part" value="volume">
<div style="display:grid;gap:6px">{{range $X.Presets}} <input type="hidden" name="mode" value="{{if eq $S.Preset "custom"}}custom{{else}}packages{{end}}" id="qx-mode">
<label style="display:flex;gap:8px;align-items:flex-start;font-weight:400;margin:0"><input type="radio" name="preset" value="{{.Key}}" {{if eq $S.Preset .Key}}checked{{end}} onchange="document.getElementById('qx-tables').style.display=this.value==='custom'?'block':'none'"><span><b>{{.Title}}</b><br><span class="muted" style="font-size:12px">{{.Desc}}</span></span></label>{{end}} <div id="qx-packages" style="display:{{if eq $S.Preset "custom"}}none{{else}}grid{{end}};grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:10px">
{{range $X.Packages}}
<label style="display:block;border:1px solid {{if index $X.PkgOn .Key}}var(--accent){{else}}var(--border){{end}};border-radius:10px;padding:12px 14px;margin:0;font-weight:400;cursor:pointer;background:var(--card)">
<span style="display:flex;gap:8px;align-items:flex-start">
<input type="checkbox" name="packages" value="{{.Key}}" {{if index $X.PkgOn .Key}}checked{{end}} {{if eq .Key "risk"}}checked disabled{{end}} style="width:auto;margin-top:3px">
<span style="flex:1">
<b style="font-size:14px">{{.Title}}</b>
{{if index $X.PkgLive .Key}}<span class="badge ok" style="margin-left:6px" title="данные этого пакета уже есть в базе">данные поступают</span>{{else if index $X.PkgOn .Key}}<span class="badge warn" style="margin-left:6px" title="выбрано, но экспортёр ещё не перенастроен">после применения</span>{{end}}
<span class="muted" style="display:block;font-size:12.5px;margin-top:4px">{{.What}}</span>
<span style="display:block;margin-top:6px;font-size:12.5px">Что это даёт:</span>
<ul style="margin:2px 0 0;padding-left:18px;font-size:12.5px">{{range .Gives}}<li>{{.}}</li>{{end}}</ul>
<span class="muted" style="display:block;font-size:12px;margin-top:6px">Объём: {{.Load}} · таблиц: {{len .Tables}}</span>
{{if .Warn}}<span style="display:block;font-size:12px;margin-top:4px;color:var(--warn)">{{.Warn}}</span>{{end}}
<span class="muted" style="display:block;font-size:11.5px;margin-top:4px">{{range .Tables}}<code>{{.}}</code> {{end}}</span>
</span>
</span>
</label>{{end}}
<input type="hidden" name="packages" value="risk">
</div> </div>
<div id="qx-tables" style="display:{{if eq $S.Preset "custom"}}block{{else}}none{{end}};border:1px solid var(--border);border-radius:8px;padding:10px"> <div id="qx-tables" style="display:{{if eq $S.Preset "custom"}}block{{else}}none{{end}};border:1px solid var(--border);border-radius:8px;padding:10px">
<p class="muted" style="margin:0 0 8px;font-size:12.5px">Ручной набор таблиц — когда пакетов недостаточно. Список таблиц экспортёра ARQA целиком.</p>
{{range $g := $X.Groups}}<div style="font-weight:600;margin:6px 0 4px">{{$g}}</div> {{range $g := $X.Groups}}<div style="font-weight:600;margin:6px 0 4px">{{$g}}</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:4px 12px">{{range $X.Tables}}{{if eq .Group $g}} <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:4px 12px">{{range $X.Tables}}{{if eq .Group $g}}
<label style="display:flex;gap:6px;align-items:flex-start;font-weight:400;margin:0;font-size:12.5px"><input type="checkbox" name="tables" value="{{.Name}}" {{if index $X.Enabled .Name}}checked{{end}}><span><code>{{.Name}}</code> — {{.Title}} <span class="muted">· объём: {{.Load}}{{if .Note}} · {{.Note}}{{end}}</span></span></label>{{end}}{{end}}</div>{{end}} <label style="display:flex;gap:6px;align-items:flex-start;font-weight:400;margin:0;font-size:12.5px"><input type="checkbox" name="tables" value="{{.Name}}" {{if index $X.Enabled .Name}}checked{{end}} style="width:auto"><span><code>{{.Name}}</code> — {{.Title}} <span class="muted">· объём: {{.Load}}{{if .Note}} · {{.Note}}{{end}}</span></span></label>{{end}}{{end}}</div>{{end}}
</div> </div>
<details><summary class="muted" style="cursor:pointer">Сейчас включено: {{len $S.Tables}} таблиц — {{range $S.Tables}}<code>{{.}}</code> {{end}}</summary></details> <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center">
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:10px;max-width:820px"> <button type="submit" class="btn btn-ok">Сохранить</button>
<button type="button" class="btn btn-secondary" onclick="qxMode()">{{if eq $S.Preset "custom"}}← Вернуться к пакетам{{else}}Выбрать таблицы вручную →{{end}}</button>
<span class="muted" style="font-size:12.5px">Сейчас выбрано таблиц: {{len $S.Tables}}{{if eq $S.Preset "custom"}} (ручной набор){{end}}. В экспортёр попадут после «Применить настройки».</span>
</div>
<details><summary class="muted" style="cursor:pointer;font-size:12.5px">Список выбранных таблиц</summary><p class="muted" style="font-size:12px">{{range $S.Tables}}<code>{{.}}</code> {{end}}</p></details>
<div style="display:grid;grid-template-columns:repeat(4,minmax(140px,1fr));gap:10px;max-width:820px">
<label>Период портфелей, с <input type="number" name="portfolio_period" min="10" value="{{$S.PortfolioPeriod}}" style="width:100%"></label> <label>Период портфелей, с <input type="number" name="portfolio_period" min="10" value="{{$S.PortfolioPeriod}}" style="width:100%"></label>
<label>Период «Текущих торгов», с <input type="number" name="param_period" min="0" value="{{$S.ParamPeriod}}" style="width:100%"></label> <label>Период «Текущих торгов», с <input type="number" name="param_period" min="0" value="{{$S.ParamPeriod}}" style="width:100%"></label>
<label>Виды лимитов (LimitKinds) <input type="text" name="limit_kinds" value="{{$S.LimitKinds}}" style="width:100%"></label> <label>Виды лимитов (LimitKinds) <input type="text" name="limit_kinds" value="{{$S.LimitKinds}}" style="width:100%"></label>
<label>Уровень лога (DebugLevel) <input type="number" name="debug_level" min="0" max="255" value="{{$S.DebugLevel}}" style="width:100%"></label> <label>Уровень лога (DebugLevel) <input type="number" name="debug_level" min="0" max="255" value="{{$S.DebugLevel}}" style="width:100%"></label>
</div> </div>
<div><button type="submit" class="btn btn-ok">Сохранить</button></div>
</form> </form>
<script>
function qxMode(){var m=document.getElementById('qx-mode'),p=document.getElementById('qx-packages'),t=document.getElementById('qx-tables');
var custom=m.value!=='custom';m.value=custom?'custom':'packages';p.style.display=custom?'none':'grid';t.style.display=custom?'block':'none';
event.target.textContent=custom?'← Вернуться к пакетам':'Выбрать таблицы вручную →'}
</script>
</div> </div>
<div class="card"> <div class="card">