7ce4c2c79f
Реализация варианта 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
241 lines
8.8 KiB
Go
241 lines
8.8 KiB
Go
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 })
|
||
}
|