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 }