Files
Bridge-and-Join-s/internal/lkgateway/lkarchive.go
T
zuevav 7ce4c2c79f Защита рецептов: SQL DEPO/SPECTRE/QUIK вынесен в пакет fsql под garble
Реализация варианта B из аудита защиты знаний. Прямой garble всего bj-server
ломает html/template (он обращается к полям структур по имени, garble их
переименовывает). Решение — селективная обфускация: GOGARBLE обфусцирует ТОЛЬКО
пакет с секретами, шаблоны и обработчики не затрагиваются.

95 SQL-запросов к DEPO/SPECTRE/QUIK и трансферным таблицам FTV_M2M вынесены из
lkgateway в отдельный пакет internal/lkgateway/fsql как переменные (именно var,
не const: const компилятор вставил бы обратно в необфусцируемый пакет, и
шифрование бы не сработало — проверено). Сами обработчики, типы и параметры
sql.Named остались на месте; пакет держит только строки запросов.

build-release.sh собирает bj-server с `GOGARBLE=.../fsql garble -literals`
(нужен отдельный SDK go1.26 — модуль-кэшный тулчейн garble не подходит); если
garble/go1.26 не найдены — обычная сборка с предупреждением.

Проверено на реальном garble-бинаре (запускается, /admin и страницы на
структурах работают): рецепт чтения чужих систем из бинаря исчез —
OD_RESTS 13→0, OD_WIRING 5→0, OD_TURNS 1→0, OD_BALANCES 3→0, D_B_CONTRACTS 1→0,
OD_CLIENTS_PORTS 10→0, DepoLimits/MoneyLimits →0. Остаток FTV_M2M — упоминания
имён таблиц в операторских embed-шаблонах и логах, не сам рецепт.

Стенд собран обычной сборкой (без garble) — трассировки остаются читаемыми.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
2026-09-18 20:29:47 +03:00

573 lines
20 KiB
Go

package lkgateway
// lkarchive.go — портфель клиента на дату и операции по брокерскому учёту (SPECTRE).
//
// SPECTRE — бухгалтерское ядро Fancy. Позиции клиента лежат в остатках OD_RESTS по регистру
// REG_3 = договор (D_B_CONTRACTS.DOC), в портфельном разделе плана счетов (OD_BALANCES.PART=39):
// деньги — счета 50/51/52/55 (остаток D в валюте VALUE_ID), бумаги — 58.xx (количество A,
// бумага REG_2), НКД — отдельный счёт 58.05 (не показываем: он в цене). OD_RESTS — текущий
// остаток; позиция на прошлую дату = остаток минус обороты OD_TURNS после этой даты
// (TYPE_ = +1 дебет / −1 кредит, что подтверждено сверкой: сумма оборотов = остаток).
// Оценка — по котировке OD_VALUES_RATES не старше 90 дней на дату (правило признаваемой
// котировки), иначе — балансовая стоимость EQ_D. Рецепт взят из InvestControl Hub
// (service.build_portfolio_base, deals_report) и проверен там на договорах ДУ-489 и БР-704.
//
// Операции — из проводок OD_WIRING по портфельным счетам договора: покупка, продажа, ввод и
// вывод бумаг, погашение, конвертация; выручка при продаже берётся со счёта ВЫРУЧКА.
import (
"context"
"database/sql"
"fmt"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/lkgateway/fsql"
"sort"
"strings"
"sync"
"time"
)
// spectreRecognizeDays — котировка старше этого срока не считается рыночной.
const spectreRecognizeDays = 90
type spectreRest struct {
id int64
numS, sysName string
num1 int
secID, curID int64
a, d, eqD float64
isin, secName, cur string
class int // OD_SHARES.CLASS: 1 акция, 2 облигация, 7 депозитарная расписка …
nominal float64
}
// lkArchivePortfolio — портфель на дату по SPECTRE. date — в местном времени, конец дня включительно.
func lkArchivePortfolio(ctx context.Context, s FancyMSSQLSettings, contract string, date time.Time) (*LKPortfolio, error) {
c, ok := spectreContractFor(ctx, s, contract)
if !ok {
return nil, fmt.Errorf("договор %s в брокерском учёте не найден", lkContractNumber(contract))
}
conn, err := spectreOpen(s)
if err != nil {
return nil, err
}
defer conn.Close()
cctx, cancel := context.WithTimeout(ctx, 40*time.Second)
defer cancel()
day := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, time.UTC)
// Мы читаем копию учёта; она может отставать. Позже её последнего дня данных нет —
// показываем портфель на последний день копии и говорим об этом.
note := ""
if last := spectreAsOf(cctx, conn); !last.IsZero() && day.After(last) {
day = last
note = "данные брокерского учёта доступны по " + last.Format("02.01.2006")
}
today := time.Now()
isPast := day.Before(time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, time.UTC))
rows, err := conn.QueryContext(cctx, fsql.Arch01, sql.Named("c", c.DOC))
if err != nil {
return nil, fmt.Errorf("остатки: %v", err)
}
var rests []spectreRest
for rows.Next() {
var r spectreRest
if rows.Scan(&r.id, &r.numS, &r.sysName, &r.num1, &r.secID, &r.curID, &r.a, &r.d, &r.eqD,
&r.isin, &r.secName, &r.cur, &r.class, &r.nominal) == nil {
rests = append(rests, r)
}
}
rows.Close()
// Прошлая дата: откатываем остатки на обороты после конца этого дня.
if isPast && len(rests) > 0 {
ids := make([]string, 0, len(rests))
for _, r := range rests {
ids = append(ids, fmt.Sprint(r.id))
}
trows, err := conn.QueryContext(cctx, fmt.Sprintf(fsql.Arch02, strings.Join(ids, ",")), sql.Named("d", day.AddDate(0, 0, 1)))
if err != nil {
return nil, fmt.Errorf("обороты: %v", err)
}
adj := map[int64][3]float64{}
for trows.Next() {
var id int64
var a, v, e float64
if trows.Scan(&id, &a, &v, &e) == nil {
adj[id] = [3]float64{a, v, e}
}
}
trows.Close()
for i := range rests {
if x, ok := adj[rests[i].id]; ok {
rests[i].a -= x[0]
rests[i].d -= x[1]
rests[i].eqD -= x[2]
}
}
}
out := &LKPortfolio{Source: "spectre", AsOf: day.Format("02.01.2006"), Note: note}
fx := spectreFx(cctx, conn, day)
byISIN := map[string]*LKPosition{}
cash := map[string]float64{}
for _, r := range rests {
isCash := r.num1 != 58
if isCash {
if abs(r.d) < 0.005 {
continue
}
cur := spectreCurCode(r.cur)
cash[cur] += r.d
if cur == "SUR" {
out.CashRUB += r.d
} else {
out.CashRUB += r.d * fx(r.curID)
}
continue
}
if strings.Contains(strings.ToUpper(r.sysName), "НКД") || strings.Contains(strings.ToUpper(r.sysName), "СКД") {
continue // купонный доход сидит в цене, отдельной строкой клиенту не нужен
}
if abs(r.a) < 1e-9 {
continue
}
key := r.isin
if key == "" {
key = fmt.Sprintf("id:%d", r.secID)
}
pos := byISIN[key]
if pos == nil {
pos = &LKPosition{ISIN: r.isin, Name: r.secName, Kind: spectreKind(r.class), Currency: spectreCurCode(r.cur)}
byISIN[key] = pos
}
pos.Quantity += r.a
pos.Cost += r.eqD
// Оценка: рыночная котировка на дату, если не старше 90 дней, иначе балансовая.
// Котировки OD_VALUES_RATES уже в рублях за одну бумагу (и для валютных выпусков —
// проверено: НОВАТЭК 001P-03 с номиналом 100 USD котируется как 7 967 ₽).
price, age := spectreQuote(cctx, conn, r.secID, day)
if price > 0 && age <= spectreRecognizeDays {
pos.Value += r.a * price
pos.Price = price
} else {
pos.Value += r.eqD
pos.Note = "оценка по балансовой стоимости: нет свежей котировки"
}
}
for _, p := range byISIN {
p.PnL = p.Value - p.Cost
if p.Cost != 0 {
p.PnLPct = p.PnL / p.Cost * 100
}
if p.Quantity != 0 && p.Cost != 0 {
p.AvgPrice = p.Cost / p.Quantity
}
out.Positions = append(out.Positions, *p)
}
for cur, amt := range cash {
if abs(amt) >= 0.005 {
out.Cash = append(out.Cash, LKCash{Currency: cur, Amount: amt})
}
}
sort.Slice(out.Cash, func(i, j int) bool { return out.Cash[i].Currency < out.Cash[j].Currency })
out.Priced = 0
for _, p := range out.Positions {
if p.Price > 0 {
out.Priced++
}
}
lkPortfolioTotals(out)
if c.Closed {
out.Note = strings.TrimSpace(strings.TrimPrefix(out.Note+"; договор закрыт", "; "))
}
return out, nil
}
// spectreAsOf — последний день, за который в копии учёта есть документы (кэш 10 минут).
var spectreAsOfCache struct {
mu sync.Mutex
at time.Time
v time.Time
}
func spectreAsOf(ctx context.Context, conn *sql.DB) time.Time {
spectreAsOfCache.mu.Lock()
defer spectreAsOfCache.mu.Unlock()
if time.Since(spectreAsOfCache.at) < 10*time.Minute {
return spectreAsOfCache.v
}
var d sql.NullTime
_ = conn.QueryRowContext(ctx, fsql.Arch03).Scan(&d)
v := time.Time{}
if d.Valid {
v = time.Date(d.Time.Year(), d.Time.Month(), d.Time.Day(), 0, 0, 0, 0, time.UTC)
}
spectreAsOfCache.at, spectreAsOfCache.v = time.Now(), v
return v
}
func abs(v float64) float64 {
if v < 0 {
return -v
}
return v
}
// spectreCurCode — код валюты в терминах кабинета (рубли — SUR, как в QUIK).
func spectreCurCode(sys string) string {
switch strings.ToUpper(strings.TrimSpace(sys)) {
case "RUB", "RUR", "SUR", "":
return "SUR"
}
return strings.ToUpper(strings.TrimSpace(sys))
}
// spectreKind — вид бумаги по классу OD_SHARES.CLASS.
func spectreKind(class int) string {
switch class {
case 1:
return "акция"
case 2:
return "облигация"
case 3, 4:
return "пай"
case 7:
return "депозитарная расписка"
}
return "бумага"
}
// spectreQuote — котировка бумаги на дату (не позже) и её возраст в днях.
func spectreQuote(ctx context.Context, conn *sql.DB, secID int64, day time.Time) (float64, int) {
var ofic sql.NullTime
var rate3, pfr, closeP sql.NullFloat64
err := conn.QueryRowContext(ctx, fsql.Arch04, sql.Named("v", secID), sql.Named("d", day.AddDate(0, 0, 1))).Scan(&ofic, &rate3, &pfr, &closeP)
if err != nil {
return 0, 1 << 30
}
price := pfr.Float64
if price <= 0 {
price = rate3.Float64
}
if price <= 0 {
price = closeP.Float64
}
age := 1 << 30
if ofic.Valid {
age = int(day.Sub(ofic.Time).Hours() / 24)
if age < 0 {
age = 0
}
}
return price, age
}
// spectreFx — курс валюты к рублю на дату (MARKET=0), с кэшем на запрос.
func spectreFx(ctx context.Context, conn *sql.DB, day time.Time) func(curID int64) float64 {
cache := map[int64]float64{}
return func(curID int64) float64 {
if curID == 0 {
return 1
}
if v, ok := cache[curID]; ok {
return v
}
var rate sql.NullFloat64
_ = conn.QueryRowContext(ctx, fsql.Arch05,
sql.Named("v", curID), sql.Named("d", day.AddDate(0, 0, 1))).Scan(&rate)
v := rate.Float64
if v <= 0 {
v = 1
}
cache[curID] = v
return v
}
}
// LKOperationRow — операция по брокерскому счёту для ленты кабинета.
type LKOperationRow struct {
Date string `json:"date"`
Kind string `json:"kind"` // покупка | продажа | ввод бумаг | вывод бумаг | погашение | конвертация | замещение | пополнение | вывод денег
Side string `json:"side"` // in | out
Name string `json:"name"` // бумага или «Денежные средства»
ISIN string `json:"isin,omitempty"`
Quantity float64 `json:"quantity,omitempty"`
Price float64 `json:"price,omitempty"` // цена за бумагу в валюте
Amount float64 `json:"amount,omitempty"` // сумма в валюте операции
Currency string `json:"currency,omitempty"`
AmountRUB float64 `json:"amount_rub"` // сумма в рублях
}
// lkArchiveOperations — сделки и движения по портфельным счетам договора за период.
// Лента операций за период не меняется в течение дня, а год данных из журнала
// проводок читается секундами: держим готовый ответ пять минут.
var opsCache = struct {
mu sync.Mutex
m map[string]opsCached
}{m: map[string]opsCached{}}
type opsCached struct {
at time.Time
rows []LKOperationRow
}
func lkArchiveOperations(ctx context.Context, s FancyMSSQLSettings, contract string, from, to time.Time) ([]LKOperationRow, error) {
key := lkContractNumber(contract) + "|" + from.Format("2006-01-02") + "|" + to.Format("2006-01-02")
opsCache.mu.Lock()
c, ok := opsCache.m[key]
opsCache.mu.Unlock()
if ok && time.Since(c.at) < 5*time.Minute {
return c.rows, nil
}
rows, err := lkArchiveOperationsLoad(ctx, s, contract, from, to)
if err == nil {
opsCache.mu.Lock()
opsCache.m[key] = opsCached{at: time.Now(), rows: rows}
opsCache.mu.Unlock()
}
return rows, err
}
func lkArchiveOperationsLoad(ctx context.Context, s FancyMSSQLSettings, contract string, from, to time.Time) ([]LKOperationRow, error) {
c, ok := spectreContractFor(ctx, s, contract)
if !ok {
return nil, fmt.Errorf("договор %s в брокерском учёте не найден", lkContractNumber(contract))
}
conn, err := spectreOpen(s)
if err != nil {
return nil, err
}
defer conn.Close()
cctx, cancel := context.WithTimeout(ctx, 40*time.Second)
defer cancel()
// Счета бумаг (ПОРТФЕЛЬ) и денег (касса) договора.
rows, err := conn.QueryContext(cctx, fsql.Arch06, sql.Named("c", c.DOC))
if err != nil {
return nil, err
}
var secIDs, cashIDs []string
secSet := map[int64]bool{}
for rows.Next() {
var id int64
var num1 int
var sys string
if rows.Scan(&id, &num1, &sys) == nil {
if num1 == 58 {
secIDs = append(secIDs, fmt.Sprint(id))
secSet[id] = true
} else {
cashIDs = append(cashIDs, fmt.Sprint(id))
}
}
}
rows.Close()
var out []LKOperationRow
toEnd := time.Date(to.Year(), to.Month(), to.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, 1)
fromDay := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, time.UTC)
// Три обхода журнала проводок независимы, а каждый по году данных идёт секунды:
// выполняем их разом, иначе клиент ждёт их сумму.
var wg sync.WaitGroup
var mu sync.Mutex
var cashOut []LKOperationRow
var proceeds map[string][2]float64
wg.Add(1)
go func() {
defer wg.Done()
proceeds = spectreProceeds(cctx, conn, c.DOC, fromDay, toEnd)
}()
wg.Add(1)
go func() {
defer wg.Done()
cashOut = spectreCashOps(cctx, conn, cashIDs, fromDay, toEnd)
}()
if len(secIDs) > 0 {
in := strings.Join(secIDs, ",")
// Сторона проводки выбирается заранее двумя ветвями UNION ALL: связка
// «JOIN … ON sr.ID = CASE WHEN w.D_REST IN (…)» заставляла SQL Server
// разбирать список счетов на каждой строке журнала — год данных читался
// 6–7 секунд, сейчас — десятки миллисекунд (оба индекса по D_REST/K_REST
// с WIRDATE отрабатывают поиском).
wrows, err := conn.QueryContext(cctx, fmt.Sprintf(fsql.Arch07, in), sql.Named("f", fromDay), sql.Named("t", toEnd))
if err != nil {
return nil, fmt.Errorf("проводки: %v", err)
}
type key struct{ day, kind, isin, cur string }
agg := map[key]*LKOperationRow{}
var order []key
for wrows.Next() {
var wd time.Time
var eq, dSum, kSum, dAmt, kAmt float64
var dRest, kRest int64
var wname, secName, isin, cur string
if wrows.Scan(&wd, &eq, &dRest, &kRest, &dSum, &kSum, &dAmt, &kAmt, &wname, &secName, &isin, &cur) != nil {
continue
}
onDebit := secSet[dRest]
kind := spectreDealKind(wname, onDebit)
if kind == "" {
continue
}
qty, sum := kAmt, dSum
if onDebit {
qty, sum = dAmt, kSum
}
k := key{wd.Format("2006-01-02"), kind, isin, spectreCurCode(cur)}
a := agg[k]
if a == nil {
side := "out"
if onDebit {
side = "in"
}
a = &LKOperationRow{Date: wd.Format("02.01.2006"), Kind: kind, Side: side, Name: secName, ISIN: isin, Currency: k.cur}
agg[k] = a
order = append(order, k)
}
a.Quantity += qty
a.Amount += sum
a.AmountRUB += eq
}
wrows.Close()
// При продаже счёт ПОРТФЕЛЬ списывает бумагу по себестоимости; фактическая выручка —
// на счёте ВЫРУЧКА. Клиенту показываем именно её.
wg.Wait()
for _, k := range order {
a := agg[k]
if a.Kind == "продажа" || a.Kind == "погашение" {
if p, ok := proceeds[k.isin+"|"+k.day]; ok && p[1] != 0 {
a.Amount, a.AmountRUB = p[0], p[1]
}
}
if a.Quantity != 0 && a.Amount != 0 {
a.Price = a.Amount / a.Quantity
}
out = append(out, *a)
}
}
wg.Wait()
mu.Lock()
out = append(out, cashOut...)
mu.Unlock()
sort.SliceStable(out, func(i, j int) bool {
di, _ := time.Parse("02.01.2006", out[i].Date)
dj, _ := time.Parse("02.01.2006", out[j].Date)
return di.After(dj)
})
return out, nil
}
// spectreCashOps — пополнения и выводы денег по кассовым счетам договора.
func spectreCashOps(ctx context.Context, conn *sql.DB, cashIDs []string, fromDay, toEnd time.Time) []LKOperationRow {
if len(cashIDs) == 0 {
return nil
}
var out []LKOperationRow
in := strings.Join(cashIDs, ",")
crows, err := conn.QueryContext(ctx, fmt.Sprintf(fsql.Arch08, in), sql.Named("f", fromDay), sql.Named("t", toEnd))
if err != nil {
return nil
}
defer crows.Close()
cashSet := map[string]bool{}
for _, id := range cashIDs {
cashSet[id] = true
}
for crows.Next() {
var wd time.Time
var eq, dSum, kSum float64
var dRest, kRest int64
var wname, cur string
if crows.Scan(&wd, &eq, &dRest, &kRest, &dSum, &kSum, &wname, &cur) != nil {
continue
}
low := strings.ToLower(wname)
var kind, side string
switch {
case strings.Contains(low, "ввод") && strings.Contains(low, "ден"), strings.Contains(low, "пополнен"), strings.Contains(low, "зачисление денеж"):
kind, side = "пополнение", "in"
case strings.Contains(low, "вывод") && strings.Contains(low, "ден"), strings.Contains(low, "списание денеж") && !strings.Contains(low, "комисс"):
kind, side = "вывод денег", "out"
default:
continue
}
sum := kSum
if cashSet[fmt.Sprint(dRest)] {
sum = dSum
}
out = append(out, LKOperationRow{Date: wd.Format("02.01.2006"), Kind: kind, Side: side, Name: "Денежные средства",
Amount: sum, Currency: spectreCurCode(cur), AmountRUB: eq})
}
return out
}
// spectreDealKind — вид операции по названию проводки (рецепт хаба). Пусто — не показываем.
func spectreDealKind(name string, onDebit bool) string {
n := strings.ToLower(name)
switch {
case strings.Contains(n, "замещени") || strings.Contains(n, "обмен"):
return "замещение"
case strings.Contains(n, "конвертац") || strings.Contains(n, "слияни"):
return "конвертация"
case strings.Contains(n, "погашен"):
return "погашение"
case strings.Contains(n, "ввод") && strings.Contains(n, "цб"):
return "ввод бумаг"
case strings.Contains(n, "вывод") && strings.Contains(n, "цб"):
return "вывод бумаг"
case strings.Contains(n, "заём") || strings.Contains(n, "займ") || strings.Contains(n, "репо"):
return "" // РЕПО и займы клиенту в ленте не показываем
}
if onDebit {
return "покупка"
}
return "продажа"
}
// spectreProceeds — выручка от продаж по (ISIN|день): [сумма в валюте, сумма в рублях].
func spectreProceeds(ctx context.Context, conn *sql.DB, doc int64, from, to time.Time) map[string][2]float64 {
out := map[string][2]float64{}
rows, err := conn.QueryContext(ctx, fsql.Arch09, sql.Named("c", doc))
if err != nil {
return out
}
var ids []string
idSet := map[int64]bool{}
for rows.Next() {
var id int64
if rows.Scan(&id) == nil {
ids = append(ids, fmt.Sprint(id))
idSet[id] = true
}
}
rows.Close()
if len(ids) == 0 {
return out
}
in := strings.Join(ids, ",")
wrows, err := conn.QueryContext(ctx, fmt.Sprintf(fsql.Arch10, in), sql.Named("f", from), sql.Named("t", to))
if err != nil {
return out
}
defer wrows.Close()
for wrows.Next() {
var wd time.Time
var eq, dSum, kSum float64
var dRest, kRest int64
var isin string
if wrows.Scan(&wd, &eq, &dRest, &kRest, &dSum, &kSum, &isin) != nil {
continue
}
sum := dSum
if idSet[kRest] {
sum = kSum
}
k := isin + "|" + wd.Format("2006-01-02")
v := out[k]
v[0] += sum
v[1] += eq
out[k] = v
}
return out
}