Files
Bridge-and-Join-s/internal/lkgateway/lkonline.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

241 lines
8.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package lkgateway
// lkonline.go — портфель клиента «сейчас» по экспорту биржевой информации из QUIK.
//
// Это то, что клиент видит у брокера: позиции с количеством, средней ценой покупки, текущей
// ценой и результатом, деньги по валютам, стоимость портфеля. Источник — таблицы экспорта
// DepoLimits (позиции), MoneyLimits (деньги), Securities/Params (бумаги и цены). Клиент
// определяется кодом QUIK, полученным из номера договора (lkQuikCodes).
import (
"context"
"fmt"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/lkgateway/fsql"
"sort"
"strconv"
"strings"
"time"
)
// LKPortfolio — портфель для кабинета (онлайн или на дату — одна форма).
type LKPortfolio struct {
Source string `json:"source"` // quik | spectre
AsOf string `json:"as_of"` // на какой момент
Total float64 `json:"total"` // стоимость портфеля, ₽ (бумаги + деньги в рублях)
Securities float64 `json:"securities_value"` // стоимость бумаг, ₽
CashRUB float64 `json:"cash_rub"` // деньги в рублях
Cost float64 `json:"cost"` // вложено (по средней цене / балансовой стоимости), ₽
PnL float64 `json:"pnl"` // результат по позициям, ₽
PnLPct float64 `json:"pnl_pct"` // результат, %
Priced int `json:"priced"` // позиций с ценой
Cash []LKCash `json:"cash"`
Positions []LKPosition `json:"positions"`
Note string `json:"note,omitempty"`
}
// LKCash — деньги в одной валюте.
type LKCash struct {
Currency string `json:"currency"` // SUR, USD, CNY …
Amount float64 `json:"amount"`
}
// LKPosition — позиция по бумаге.
type LKPosition struct {
ISIN string `json:"isin"`
Code string `json:"code,omitempty"` // код в торговой системе
Name string `json:"name"`
Kind string `json:"kind"` // акция | облигация | фонд | …
Quantity float64 `json:"quantity"`
AvgPrice float64 `json:"avg_price,omitempty"` // средняя цена покупки (за бумагу, в тех же единицах, что и Price)
Price float64 `json:"price,omitempty"` // текущая цена
InPct bool `json:"in_pct,omitempty"` // цены в процентах номинала (облигации)
Value float64 `json:"value"` // стоимость, ₽
Cost float64 `json:"cost,omitempty"` // вложено, ₽
PnL float64 `json:"pnl"` // результат, ₽
PnLPct float64 `json:"pnl_pct"` // результат, %
DayPct float64 `json:"day_pct,omitempty"` // изменение за день, %
Share float64 `json:"share"` // доля в портфеле, %
Currency string `json:"currency,omitempty"`
Note string `json:"note,omitempty"`
}
// lkKindByClass — вид бумаги по классу QUIK.
func lkKindByClass(class string) string {
switch {
case class == "TQBR", class == "TQPI", strings.HasPrefix(class, "SPBXM"):
return "акция"
case class == "TQOB", class == "TQCB", class == "TQIR", class == "TQRD", strings.HasSuffix(class, "_BND"):
return "облигация"
case class == "TQTF", class == "TQIF", class == "TQTD":
return "фонд"
case class == "CETS":
return "валюта"
}
return "бумага"
}
// lkOnlinePortfolio — портфель по экспорту QUIK. Ошибка — только если базы экспорта нет.
func lkOnlinePortfolio(ctx context.Context, rc *RuntimeConfig, contract string) (*LKPortfolio, error) {
codes := lkQuikCodes(contract)
if len(codes) == 0 {
return nil, fmt.Errorf("не определён номер договора")
}
p, err := nprDB(rc)
if err != nil {
return nil, fmt.Errorf("база экспорта QUIK недоступна: %v", err)
}
cctx, cancel := context.WithTimeout(ctx, 12*time.Second)
defer cancel()
out := &LKPortfolio{Source: "quik"}
// Позиции: свежая строка по каждому (клиент, бумага, торговый счёт), затем сумма по бумаге.
rows, err := p.Query(cctx, fsql.On01, codes)
if err != nil {
return nil, fmt.Errorf("позиции: %v", err)
}
type raw struct {
code string
qty, avg float64
}
var pos []raw
var asOf time.Time
for rows.Next() {
var r raw
var ld *time.Time
if rows.Scan(&r.code, &r.qty, &r.avg, &ld) == nil {
pos = append(pos, r)
if ld != nil && ld.After(asOf) {
asOf = *ld
}
}
}
rows.Close()
// Бумаги: ISIN, имя, класс (основные режимы торгов в приоритете), номинал.
secByCode := map[string]struct {
isin, name, class string
face float64
}{}
if len(pos) > 0 {
codesList := make([]string, 0, len(pos))
for _, r := range pos {
codesList = append(codesList, r.code)
}
srows, err := p.Query(cctx, fsql.On02, codesList)
if err == nil {
for srows.Next() {
var code, isin, name, class string
var face float64
if srows.Scan(&code, &isin, &name, &class, &face) == nil {
secByCode[code] = struct {
isin, name, class string
face float64
}{isin, name, class, face}
}
}
srows.Close()
}
}
isins := make([]string, 0, len(pos))
for _, r := range pos {
if s, ok := secByCode[r.code]; ok && s.isin != "" {
isins = append(isins, s.isin)
}
}
prices := lkPrices(ctx, rc, isins)
fxUsed := map[string]SecurityPrice{}
for _, r := range pos {
s := secByCode[r.code]
lp := LKPosition{ISIN: s.isin, Code: r.code, Name: s.name, Kind: lkKindByClass(s.class), Quantity: r.qty, AvgPrice: r.avg, Currency: "SUR"}
if lp.Name == "" {
lp.Name = r.code
}
if px, ok := prices[s.isin]; ok && px.Price > 0 {
lp.Price, lp.InPct, lp.DayPct = px.Price, px.InPct, px.ChangePc
lp.Value = px.Value(r.qty)
if lp.Value > 0 {
out.Priced++
}
switch {
case lp.Value <= 0 && px.FaceCur != "":
lp.Note = "номинал в " + px.FaceCur + ": нет курса для оценки"
case px.FX > 0 && px.FX != 1:
lp.Note = "номинал в " + px.FaceCur
fxUsed[px.FaceCur] = px // курс покажем одной строкой под таблицей
}
if r.avg > 0 && lp.Value > 0 {
// средняя цена в тех же единицах, что и котировка: для облигаций — процент номинала
cost := SecurityPrice{Price: r.avg, Face: px.Face, InPct: px.InPct, FaceCur: px.FaceCur, FX: px.FX}
lp.Cost = cost.Value(r.qty)
lp.PnL = lp.Value - lp.Cost
if lp.Cost != 0 {
lp.PnLPct = lp.PnL / lp.Cost * 100
}
}
} else {
lp.Note = "нет биржевой цены"
}
out.Positions = append(out.Positions, lp)
}
// Деньги по валютам.
crows, err := p.Query(cctx, fsql.On03, codes)
if err == nil {
for crows.Next() {
var c LKCash
var ld *time.Time
if crows.Scan(&c.Currency, &c.Amount, &ld) == nil {
out.Cash = append(out.Cash, c)
if c.Currency == "SUR" || c.Currency == "RUB" {
out.CashRUB += c.Amount
}
if ld != nil && ld.After(asOf) {
asOf = *ld
}
}
}
crows.Close()
}
if !asOf.IsZero() {
out.AsOf = asOf.Format("02.01.2006")
}
if len(fxUsed) > 0 {
var parts []string
for cur, px := range fxUsed {
p := cur + " " + fmtRate(px.FX) + " ₽"
if !px.FXAt.IsZero() {
p += " от " + px.FXAt.Format("02.01.2006")
}
parts = append(parts, p)
}
sort.Strings(parts)
out.Note = "выпуски с валютным номиналом пересчитаны по курсу " + strings.Join(parts, ", ")
}
lkPortfolioTotals(out)
return out, nil
}
// fmtRate — курс валюты с двумя знаками.
func fmtRate(v float64) string { return strconv.FormatFloat(v, 'f', 2, 64) }
// lkPortfolioTotals — итоги и доли.
func lkPortfolioTotals(p *LKPortfolio) {
p.Securities, p.Cost, p.PnL = 0, 0, 0
for _, pos := range p.Positions {
p.Securities += pos.Value
p.Cost += pos.Cost
p.PnL += pos.PnL
}
p.Total = p.Securities + p.CashRUB
if p.Cost != 0 {
p.PnLPct = p.PnL / p.Cost * 100
}
for i := range p.Positions {
if p.Total > 0 {
p.Positions[i].Share = p.Positions[i].Value / p.Total * 100
}
}
sort.SliceStable(p.Positions, func(i, j int) bool { return p.Positions[i].Value > p.Positions[j].Value })
}