From 893e1222aba5456ee84b44c5eea067a057f2a559 Mon Sep 17 00:00:00 2001 From: zuevav Date: Thu, 17 Sep 2026 21:43:41 +0300 Subject: [PATCH] =?UTF-8?q?=D0=AD=D0=BA=D1=81=D0=BF=D0=BE=D1=80=D1=82=20QU?= =?UTF-8?q?IK:=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=B0=D0=BA=D0=B5=D1=82=D0=B0=D0=BC=D0=B8=20=D0=B4=D0=B0?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D1=85=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20export.ini?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- modules/quikexport/quikexport.go | 187 +++++++++++++++++- .../quikexport/quikexport_packages_test.go | 102 ++++++++++ modules/quikexport/web/section.html | 45 ++++- 3 files changed, 318 insertions(+), 16 deletions(-) create mode 100644 modules/quikexport/quikexport_packages_test.go diff --git a/modules/quikexport/quikexport.go b/modules/quikexport/quikexport.go index f5b892f..6b2332d 100644 --- a/modules/quikexport/quikexport.go +++ b/modules/quikexport/quikexport.go @@ -70,7 +70,8 @@ type QuikExportSettings struct { 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"` PortfolioPeriod int `json:"portfolio_period"` ParamPeriod int `json:"param_period"` @@ -112,8 +113,19 @@ func (s QuikExportSettings) withDefaults() QuikExportSettings { if s.Preset == "" { 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 { - s.Tables = quikExportPresetTables(s.Preset) + s.Tables = []string{"Portfolio"} } if s.PortfolioPeriod == 0 { s.PortfolioPeriod = 60 @@ -210,6 +222,114 @@ var quikExportTables = []quikExportTable{ {"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 }{ {"npr", "Минимум — только НПР/УДС", "Portfolio. Достаточно для модуля «НПР / УДС». ≈20 МБ/сутки."}, {"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 { 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 != "" { set(&s.QuikHost, iniGet(inf, "connect", "trade-server-ip-address")) @@ -739,6 +863,7 @@ type quikExportDBStats struct { Name, Size string Rows int64 } + NonEmpty map[string]bool // таблицы экспортёра, в которых есть данные HistoryRows int64 HistoryFrom time.Time Rows24h int64 @@ -828,6 +953,16 @@ func quikExportStats(s QuikExportSettings) quikExportDBStats { st.LastTick = *tick 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) st.OK = true return st @@ -850,6 +985,9 @@ type QuikExportView struct { Tables []quikExportTable Groups []string Presets []struct{ Key, Title, Desc string } + Packages []quikExportPackage + PkgOn map[string]bool // пакет выбран в настройках + PkgLive map[string]bool // пакет уже выгружается экспортёром (по export.ini) Enabled map[string]bool RODSN string RODSNFull string @@ -881,6 +1019,12 @@ func quikExportView(st *Store, licensed, showRO bool) QuikExportView { for _, t := range s.Tables { 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{} for _, t := range quikExportTables { if !seen[t.Group] { @@ -903,6 +1047,15 @@ func quikExportView(st *Store, licensed, showRO bool) QuikExportView { v.DiskDB = diskUsage("/var/lib/postgresql") } 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.ErrTail = quikExportTail(filepath.Join(s.Dir, "logs", "export.err"), 6) v.RODSN = s.RODSN(false) @@ -1023,8 +1176,8 @@ func (srv *Server) saveQuikExport(w http.ResponseWriter, r *http.Request) { s.ROPassword = p } case "volume": - s.Preset = f("preset") - if s.Preset == "custom" { + if f("mode") == "custom" { + s.Preset = "custom" s.Tables = nil for _, t := range r.Form["tables"] { 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 { s.Tables = []string{"Portfolio"} } + sort.Strings(s.Tables) + s.Packages = quikExportPackagesOf(s.Tables) } else { - s.Tables = quikExportPresetTables(s.Preset) + s.Preset = "packages" + s.Packages = append([]string(nil), r.Form["packages"]...) + s.Tables = quikExportPackageTables(s.Packages) } - sort.Strings(s.Tables) s.PortfolioPeriod = atoi(f("portfolio_period"), 60) if 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") 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 +} diff --git a/modules/quikexport/quikexport_packages_test.go b/modules/quikexport/quikexport_packages_test.go new file mode 100644 index 0000000..3e1619c --- /dev/null +++ b/modules/quikexport/quikexport_packages_test.go @@ -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) + } +} diff --git a/modules/quikexport/web/section.html b/modules/quikexport/web/section.html index e02e30f..2e49b3d 100644 --- a/modules/quikexport/web/section.html +++ b/modules/quikexport/web/section.html @@ -96,27 +96,54 @@
-

3. Объём данных

-

Какие таблицы QUIK выгружаем. Чем больше набор, тем больше нагрузка на сервер QUIK, база и диск. Портфели пересчитываются каждые «период портфелей» секунд (ARQA рекомендует не менее 60); история изменений НПР/УДС хранится в схеме npr.

-
+

3. Какие данные выгружаем

+

Выгрузка настраивается пакетами: отметьте, что нужно, — bj сам соберёт секцию [Output tables] в export.ini, править файл руками не нужно. Чем больше пакетов, тем выше нагрузка на сервер QUIK, базу и диск. Пакет «Маржа и риск» включён всегда: на нём держится модуль «НПР / УДС».

+ -
{{range $X.Presets}} - {{end}} + +
+ {{range $X.Packages}} + {{end}} +
+

Ручной набор таблиц — когда пакетов недостаточно. Список таблиц экспортёра ARQA целиком.

{{range $g := $X.Groups}}
{{$g}}
{{range $X.Tables}}{{if eq .Group $g}} - {{end}}{{end}}
{{end}} + {{end}}{{end}}
{{end}}
-
Сейчас включено: {{len $S.Tables}} таблиц — {{range $S.Tables}}{{.}} {{end}}
-
+
+ + + Сейчас выбрано таблиц: {{len $S.Tables}}{{if eq $S.Preset "custom"}} (ручной набор){{end}}. В экспортёр попадут после «Применить настройки». +
+
Список выбранных таблиц

{{range $S.Tables}}{{.}} {{end}}

+
-
+