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

227 lines
8.3 KiB
Go

package lkgateway
// lksnapshot.go — ежедневный срез стоимости портфелей для графика в кабинете.
//
// Ни QUIK, ни SPECTRE не хранят историю стоимости: экспорт QUIK показывает позиции
// «сейчас», SPECTRE — остатки на дату, но переоценивать каждый день задним числом
// дорого и всё равно неточно (котировки прошлых дней в копии не полны). Поэтому раз
// в день, после закрытия торгов, считаем стоимость каждого портфеля по экспорту QUIK
// и складываем одну строку на договор в lk.portfolio_daily. Из этих строк кабинет
// рисует график: чем дольше работает система, тем длиннее история.
import (
"context"
"log"
"strings"
"time"
)
const lkSnapshotSchema = `
CREATE TABLE IF NOT EXISTS lk.portfolio_daily (
day DATE NOT NULL, contract VARCHAR(64) NOT NULL,
value DOUBLE PRECISION NOT NULL DEFAULT 0, cash DOUBLE PRECISION NOT NULL DEFAULT 0,
positions INT NOT NULL DEFAULT 0, priced INT NOT NULL DEFAULT 0,
source VARCHAR(16) NOT NULL DEFAULT 'quik', created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (day, contract))`
// lkSnapshotEnabled — ведём ли историю стоимости. По умолчанию (режим не выбран)
// ведём, если настроен экспорт QUIK: история накапливается только вперёд, и
// упущенные дни потом не восстановить.
func lkSnapshotEnabled(rc *RuntimeConfig) bool {
switch strings.ToLower(strings.TrimSpace(rc.Snapshot().LK.SnapshotMode)) {
case "on":
return true
case "off":
return false
}
_, err := nprDB(rc)
return err == nil
}
// lkSnapshotTime — когда снимаем срез (по умолчанию 20:30, после закрытия основной сессии).
func lkSnapshotTime(s LKSettings) (hour, min int) {
hour, min = 20, 30
v := strings.TrimSpace(s.SnapshotAt)
if len(v) == 5 && v[2] == ':' {
h := int(v[0]-'0')*10 + int(v[1]-'0')
m := int(v[3]-'0')*10 + int(v[4]-'0')
if h >= 0 && h < 24 && m >= 0 && m < 60 {
hour, min = h, m
}
}
return hour, min
}
// LKDailyPoint — точка графика стоимости портфеля.
type LKDailyPoint struct {
Day string `json:"day"` // 2026-09-18
Value float64 `json:"value"` // бумаги, ₽
Cash float64 `json:"cash"` // деньги в рублях
Total float64 `json:"total"` // бумаги + деньги
Positions int `json:"positions"`
}
// lkSnapshotHistory — история стоимости портфеля за последние days дней.
func lkSnapshotHistory(ctx context.Context, rc *RuntimeConfig, contract string, days int) ([]LKDailyPoint, error) {
db, err := lkDB(rc)
if err != nil {
return nil, err
}
num := lkContractNumber(contract)
if num == "" {
return nil, nil
}
if days <= 0 || days > 3650 {
days = 365
}
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
rows, err := db.QueryContext(cctx, `SELECT day, value, cash, positions FROM lk.portfolio_daily
WHERE contract = $1 AND day >= current_date - $2::int ORDER BY day`, num, days)
if err != nil {
return nil, err
}
defer rows.Close()
var out []LKDailyPoint
for rows.Next() {
var p LKDailyPoint
var day time.Time
if rows.Scan(&day, &p.Value, &p.Cash, &p.Positions) != nil {
continue
}
p.Day = day.Format("2006-01-02")
p.Total = p.Value + p.Cash
out = append(out, p)
}
return out, rows.Err()
}
// lkSnapshotSave — записать срез одного портфеля за день (повторный запуск перезаписывает).
func lkSnapshotSave(ctx context.Context, rc *RuntimeConfig, day time.Time, contract string, p *LKPortfolio) error {
db, err := lkDB(rc)
if err != nil {
return err
}
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
_, err = db.ExecContext(cctx, `INSERT INTO lk.portfolio_daily(day, contract, value, cash, positions, priced, source)
VALUES ($1,$2,$3,$4,$5,$6,$7)
ON CONFLICT (day, contract) DO UPDATE SET value = EXCLUDED.value, cash = EXCLUDED.cash,
positions = EXCLUDED.positions, priced = EXCLUDED.priced, source = EXCLUDED.source, created_at = now()`,
day.Format("2006-01-02"), lkContractNumber(contract), p.Securities, p.CashRUB,
len(p.Positions), p.Priced, p.Source)
return err
}
// lkSnapshotContracts — чьи портфели снимаем: договоры, по которым в экспорте QUIK
// есть ненулевые позиции. Берём коды клиентов из экспорта и переводим их в номера
// договоров депозитария (BR746 → БР-746), иначе пришлось бы перебирать 1300 договоров.
func lkSnapshotContracts(ctx context.Context, rc *RuntimeConfig) []string {
p, err := nprDB(rc)
if err != nil {
return nil
}
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
rows, err := p.Query(cctx, `SELECT DISTINCT "ClientCode" FROM "DepoLimits"
WHERE "LimitKind" = 'T2' AND "CurrentBal" <> 0 AND "ClientCode" <> ''`)
if err != nil {
return nil
}
defer rows.Close()
live := map[string]bool{}
for rows.Next() {
var code string
if rows.Scan(&code) == nil {
live[strings.ToUpper(strings.TrimSpace(code))] = true
}
}
// номер договора знает только депозитарий: сопоставляем его коды с живыми в QUIK
var out []string
seen := map[string]bool{}
for _, full := range depoSuggestContracts(ctx, rc.Snapshot().Fancy) {
num := lkContractNumber(full)
if num == "" || seen[num] {
continue
}
for _, code := range lkQuikCodes(num) {
if live[strings.ToUpper(code)] {
seen[num] = true
out = append(out, num)
break
}
}
}
return out
}
// lkSnapshotRun — один проход: считает и сохраняет срез за день по всем портфелям.
func lkSnapshotRun(ctx context.Context, rc *RuntimeConfig, day time.Time) (int, error) {
contracts := lkSnapshotContracts(ctx, rc)
saved := 0
for _, num := range contracts {
select {
case <-ctx.Done():
return saved, ctx.Err()
default:
}
pf, err := lkOnlinePortfolio(ctx, rc, num)
if err != nil || pf == nil {
continue
}
if err := lkSnapshotSave(ctx, rc, day, num, pf); err == nil {
saved++
}
}
return saved, nil
}
// lkSnapshotLoop — раз в день в назначенное время снимает срез. Если сервер был
// выключен и срез за сегодня не снят, а время уже прошло — снимаем при старте.
func (s *Server) lkSnapshotLoop(ctx context.Context) {
t := time.NewTicker(5 * time.Minute)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
}
if !lkSnapshotEnabled(s.rc) {
continue
}
set := s.rc.Snapshot()
hour, min := lkSnapshotTime(set.LK)
now := time.Now()
if now.Hour() < hour || (now.Hour() == hour && now.Minute() < min) {
continue
}
day := now.Truncate(24 * time.Hour)
if lkSnapshotDone(ctx, s.rc, now) {
continue
}
n, err := lkSnapshotRun(ctx, s.rc, day)
if err != nil {
log.Printf("кабинет: срез стоимости портфелей прерван: %v", err)
continue
}
log.Printf("кабинет: срез стоимости портфелей за %s — %d договоров", now.Format("02.01.2006"), n)
}
}
// lkSnapshotDone — срез за этот день уже снят.
func lkSnapshotDone(ctx context.Context, rc *RuntimeConfig, day time.Time) bool {
db, err := lkDB(rc)
if err != nil {
return true // без базы срез всё равно не сохранить, и жаловаться каждые 5 минут незачем
}
cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
var n int
if db.QueryRowContext(cctx, `SELECT count(*) FROM lk.portfolio_daily WHERE day = $1`,
day.Format("2006-01-02")).Scan(&n) != nil {
return true
}
return n > 0
}