Files
Bridge-and-Join-s/internal/lkgateway/prices.go
T
zuevav 510a34b7bc Кабинет: курсы ЦБ, история стоимости портфеля, быстрые операции, контакты в профиле
Курсы валют. Оценивать замещающие и еврооблигации по курсу из копии учётной
системы было нельзя: в DEPO он от 31.05.2026, в SPECTRE — от 18.07.2026.
Берём официальный курс Банка России (открытый XML, без ключа — тем же каналом
пользуется Инвест-хаб) и складываем по дням в lk.fx_rates: без интернета
кабинет считает по последнему известному курсу, а не отказывается оценивать.
Источник переключается в настройках («Данные кабинета»), там же кнопка
«Обновить курсы сейчас». Портфель БР-719: 560,3 млн ₽ против 522 млн по
старому курсу.

История стоимости. Ни QUIK, ни SPECTRE её не хранят, поэтому раз в день после
закрытия торгов считаем стоимость каждого портфеля по экспорту QUIK и пишем
строку в lk.portfolio_daily (257 портфелей за 14 секунд). Кабинет рисует по
ним график на странице портфеля — SVG собирается на сервере, скриптов в
кабинете по-прежнему нет. Режим по умолчанию — «автоматически»: ведём, если
настроен экспорт QUIK, потому что задним числом историю не восстановить.

Операции. Связка «JOIN OD_RESTS ON sr.ID = CASE WHEN w.D_REST IN (…)»
заставляла SQL Server разбирать список счетов на каждой строке журнала
проводок: год данных читался 6–7 секунд. Обе стороны проводки выбираются
заранее двумя ветвями UNION ALL, индексы IX_WIRING_D_REST_WIRDATE и
IX_WIRING_K_REST_WIRDATE отрабатывают поиском — 89 мс на тех же 64 строках.
Период по умолчанию вернули к году, ответ кэшируется на пять минут.

Профиль: почта, телефон и прошлый вход из анкеты — клиент видит, куда с ним
свяжется депозитарий, и заметит устаревшие данные. Правка только через
менеджера. Заказ документов из кабинета убран совсем (форма была убрана
раньше, теперь нет и обработчика).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
2026-09-18 19:25:18 +03:00

170 lines
6.9 KiB
Go

package lkgateway
// prices.go — рыночные цены бумаг из базы экспорта QUIK (таблицы Securities и Params,
// пакет данных «Цены для оценки портфеля»). Нужны кабинету, чтобы показать стоимость
// позиции и портфеля. Подключение берём из настроек «НПР / УДС» — это та же база
// экспорта; без неё кабинет просто не показывает оценку.
//
// Цена облигации приходит в процентах от номинала, акции и пая — в рублях за штуку.
// Оценка: облигация = количество × номинал × цена / 100 + НКД × количество.
import (
"context"
"strings"
"sync"
"time"
)
// SecurityPrice — цена одной бумаги на момент среза.
type SecurityPrice struct {
ISIN string `json:"isin"`
Board string `json:"board"` // класс QUIK, откуда взята цена
Name string `json:"name,omitempty"` // краткое наименование в торговой системе
Price float64 `json:"price"` // последняя цена (для облигаций — процент номинала)
Prev float64 `json:"prev,omitempty"` // цена предыдущего закрытия
Face float64 `json:"face,omitempty"` // номинал
Accrued float64 `json:"accrued,omitempty"` // НКД на бумагу
InPct bool `json:"in_pct,omitempty"` // цена в процентах от номинала
ChangePc float64 `json:"change_pc,omitempty"` // изменение к закрытию, %
// FaceCur — валюта номинала (USD, EUR, CNY…); пусто или SUR — рубли. У «замещающих»
// и еврооблигаций номинал валютный, поэтому тело переводится в рубли по курсу FX.
FaceCur string `json:"face_cur,omitempty"`
FX float64 `json:"fx,omitempty"` // курс валюты номинала к рублю (1 для рублей)
FXAt time.Time `json:"fx_at,omitempty"` // дата курса
}
// Value — стоимость количества бумаг по этой цене.
func (p SecurityPrice) Value(qty float64) float64 {
if p.Price <= 0 || qty == 0 {
return 0
}
if p.InPct && p.Face > 0 {
fx := p.FX
if fx <= 0 {
if p.FaceCur == "" || strings.EqualFold(p.FaceCur, "SUR") || strings.EqualFold(p.FaceCur, "RUB") {
fx = 1
} else {
return 0 // валютный номинал без курса: оценку не выдумываем
}
}
// НКД экспорт отдаёт в рублях, тело — в процентах от номинала в валюте выпуска.
return qty*p.Face*p.Price/100*fx + qty*p.Accrued
}
return qty * p.Price
}
// pricesCache — цены живут минуту: страницу кабинета открывают часто, а цикл экспорта — 30 с.
var (
priceMu sync.Mutex
priceAt time.Time
priceLast map[string]SecurityPrice
)
// lkPrices — цены по списку ISIN. Пустая карта, если база экспорта не настроена или
// пакет «Цены» не включён: кабинет в этом случае показывает реестр без оценки.
func lkPrices(ctx context.Context, rc *RuntimeConfig, isins []string) map[string]SecurityPrice {
out := map[string]SecurityPrice{}
if rc == nil || len(isins) == 0 {
return out
}
priceMu.Lock()
if time.Since(priceAt) < time.Minute && priceLast != nil {
for _, isin := range isins {
if p, ok := priceLast[strings.ToUpper(isin)]; ok {
out[p.ISIN] = p
}
}
priceMu.Unlock()
return out
}
priceMu.Unlock()
p, err := nprDB(rc)
if err != nil {
return out
}
cctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
fxRates := lkFxRates(ctx, rc) // для выпусков с валютным номиналом
// Одна цена на бумагу: берём класс основных торгов (акции TQBR, ОФЗ TQOB,
// корпоративные облигации TQCB, фонды TQTF/TQIF), иначе — класс с торгами.
rows, err := p.Query(cctx, `
SELECT DISTINCT ON (s."ISINCode")
upper(s."ISINCode"), s."ClassCode", COALESCE(s."SecShortName", s."SecName", ''),
COALESCE(p.last, 0)::float8, COALESCE(p.prevprice, 0)::float8,
COALESCE(s."FaceValue", 0)::float8, COALESCE(p.accruedint, 0)::float8, COALESCE(s."FaceUnit", '')
FROM "Securities" s JOIN "Params" p ON p."SecCode" = s."SecCode" AND p."ClassCode" = s."ClassCode"
WHERE s."ISINCode" <> '' AND (p.last > 0 OR p.prevprice > 0)
ORDER BY s."ISINCode",
CASE s."ClassCode" WHEN 'TQBR' THEN 1 WHEN 'TQOB' THEN 2 WHEN 'TQCB' THEN 3
WHEN 'TQTF' THEN 4 WHEN 'TQIF' THEN 5 WHEN 'TQRD' THEN 6 ELSE 9 END,
p.last DESC`)
if err != nil {
return out
}
all := map[string]SecurityPrice{}
for rows.Next() {
var sp SecurityPrice
if rows.Scan(&sp.ISIN, &sp.Board, &sp.Name, &sp.Price, &sp.Prev, &sp.Face, &sp.Accrued, &sp.FaceCur) != nil {
continue
}
if sp.Price <= 0 {
sp.Price = sp.Prev
}
sp.InPct = sp.Face > 0 && (sp.Board == "TQOB" || sp.Board == "TQCB" || strings.HasSuffix(sp.Board, "_BND"))
if sp.Prev > 0 && sp.Price > 0 {
sp.ChangePc = (sp.Price - sp.Prev) / sp.Prev * 100
}
if fx := lkFxFor(fxRates, sp.FaceCur); fx.Rate > 0 {
sp.FX, sp.FXAt = fx.Rate, fx.At
}
all[sp.ISIN] = sp
}
rows.Close()
if len(all) == 0 {
return out
}
priceMu.Lock()
priceLast, priceAt = all, time.Now()
priceMu.Unlock()
for _, isin := range isins {
if sp, ok := all[strings.ToUpper(isin)]; ok {
out[sp.ISIN] = sp
}
}
return out
}
// lkPriceReset — сбросить кэш цен (после смены настроек подключения).
func lkPriceReset() {
priceMu.Lock()
priceLast, priceAt = nil, time.Time{}
priceMu.Unlock()
}
// lkApplyPrices — проставляет позициям цену и стоимость, возвращает оценку портфеля
// и число бумаг, по которым цена нашлась.
func lkApplyPrices(ctx context.Context, rc *RuntimeConfig, pos []ClientPosition) (total float64, priced int) {
if len(pos) == 0 {
return 0, 0
}
isins := make([]string, 0, len(pos))
for _, p := range pos {
if p.ISIN != "" {
isins = append(isins, p.ISIN)
}
}
px := lkPrices(ctx, rc, isins)
for i := range pos {
sp, ok := px[strings.ToUpper(pos[i].ISIN)]
if !ok || sp.Price <= 0 {
continue
}
pos[i].Price, pos[i].PriceAt, pos[i].ChangePct = sp.Price, sp.Board, sp.ChangePc
pos[i].Value = sp.Value(pos[i].Quantity)
total += pos[i].Value
priced++
}
return total, priced
}