package lkgateway // lksnapshot.go — ежедневный срез стоимости портфелей для графика в кабинете. // // Ни QUIK, ни SPECTRE не хранят историю стоимости: экспорт QUIK показывает позиции // «сейчас», SPECTRE — остатки на дату, но переоценивать каждый день задним числом // дорого и всё равно неточно (котировки прошлых дней в копии не полны). Поэтому раз // в день, после закрытия торгов, считаем стоимость каждого портфеля по экспорту QUIK // и складываем одну строку на договор в lk.portfolio_daily. Из этих строк кабинет // рисует график: чем дольше работает система, тем длиннее история. import ( "context" "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/lkgateway/fsql" "log" "strings" "time" ) var lkSnapshotSchema = fsql.Sn01 // 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, fsql.Sn02, 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, fsql.Sn03, 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, fsql.Sn04) 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, fsql.Sn05, day.Format("2006-01-02")).Scan(&n) != nil { return true } return n > 0 }