package lkgateway // cbrfx.go — официальные курсы валют Банка России. // // Оценивать валютные выпуски по курсу из учётной системы нельзя: в копии DEPO он // от 31.05.2026, в SPECTRE — от 18.07.2026, то есть отстаёт на месяцы. Банк России // публикует курсы открыто и без ключа (XML, windows-1251), тем же каналом ими // пользуется Инвест-хаб. Забранные курсы складываем в lk.fx_rates: в закрытом // контуре без интернета кабинет продолжит считать по последнему известному курсу, // а для архива на дату курс нужен именно на тот день. import ( "context" "encoding/xml" "fmt" "io" "net/http" "strconv" "strings" "sync" "time" "golang.org/x/text/encoding/charmap" ) // cbrDailyURL — «Курсы валют на дату» (без date_req — на сегодня). const cbrDailyURL = "https://www.cbr.ru/scripts/XML_daily.asp" // lkFxSchema — курсы валют по дням: source = cbr | accounting. const lkFxSchema = ` CREATE TABLE IF NOT EXISTS lk.fx_rates ( day DATE NOT NULL, code VARCHAR(8) NOT NULL, rate DOUBLE PRECISION NOT NULL, source VARCHAR(16) NOT NULL DEFAULT 'cbr', fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (day, code))` type cbrDoc struct { Date string `xml:"Date,attr"` Valute []struct { CharCode string `xml:"CharCode"` Nominal string `xml:"Nominal"` Value string `xml:"Value"` } `xml:"Valute"` } // cbrFetch — курсы ЦБ на дату (нулевая дата = на сегодня). Возвращает дату, на // которую курс установлен: в выходные ЦБ отдаёт курс ближайшего рабочего дня. func cbrFetch(ctx context.Context, day time.Time) (time.Time, map[string]float64, error) { url := cbrDailyURL if !day.IsZero() { url += "?date_req=" + day.Format("02/01/2006") } cctx, cancel := context.WithTimeout(ctx, 20*time.Second) defer cancel() req, err := http.NewRequestWithContext(cctx, http.MethodGet, url, nil) if err != nil { return time.Time{}, nil, err } req.Header.Set("User-Agent", "bj/1.0 (+depozitarij)") resp, err := http.DefaultClient.Do(req) if err != nil { return time.Time{}, nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return time.Time{}, nil, fmt.Errorf("Банк России ответил %s", resp.Status) } var doc cbrDoc dec := xml.NewDecoder(io.LimitReader(resp.Body, 1<<20)) // Ответ в windows-1251; коды валют латиницей, но без раскодировки Go // откажется разбирать документ целиком. dec.CharsetReader = func(enc string, in io.Reader) (io.Reader, error) { switch strings.ToLower(strings.TrimSpace(enc)) { case "windows-1251", "cp1251": return charmap.Windows1251.NewDecoder().Reader(in), nil } return in, nil } if err := dec.Decode(&doc); err != nil { return time.Time{}, nil, err } at, _ := time.ParseInLocation("02.01.2006", doc.Date, time.Local) out := make(map[string]float64, len(doc.Valute)) for _, v := range doc.Valute { code := strings.ToUpper(strings.TrimSpace(v.CharCode)) val, err1 := strconv.ParseFloat(strings.ReplaceAll(strings.TrimSpace(v.Value), ",", "."), 64) nom, err2 := strconv.ParseFloat(strings.TrimSpace(v.Nominal), 64) if code == "" || err1 != nil || val <= 0 { continue } if err2 != nil || nom <= 0 { nom = 1 } out[code] = val / nom // курс за одну единицу валюты } if len(out) == 0 { return at, nil, fmt.Errorf("Банк России не вернул ни одного курса") } return at, out, nil } // ---- хранение ---- // lkFxSave — сложить курсы дня в базу (перезаписывая курс того же дня). func lkFxSave(ctx context.Context, rc *RuntimeConfig, day time.Time, source string, rates map[string]float64) { db, err := lkDB(rc) if err != nil || day.IsZero() || len(rates) == 0 { return } cctx, cancel := context.WithTimeout(ctx, 20*time.Second) defer cancel() for code, rate := range rates { _, _ = db.ExecContext(cctx, `INSERT INTO lk.fx_rates(day, code, rate, source, fetched_at) VALUES ($1,$2,$3,$4, now()) ON CONFLICT (day, code) DO UPDATE SET rate = EXCLUDED.rate, source = EXCLUDED.source, fetched_at = now()`, day.Format("2006-01-02"), code, rate, source) } } // lkFxLoad — последний известный курс каждой валюты не позже дня day // (нулевая дата = последний известный вообще). func lkFxLoad(ctx context.Context, rc *RuntimeConfig, day time.Time) map[string]LKFxRate { out := map[string]LKFxRate{} db, err := lkDB(rc) if err != nil { return out } cctx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() upto := "9999-12-31" if !day.IsZero() { upto = day.Format("2006-01-02") } rows, err := db.QueryContext(cctx, `SELECT DISTINCT ON (code) code, day, rate FROM lk.fx_rates WHERE day <= $1 ORDER BY code, day DESC`, upto) if err != nil { return out } defer rows.Close() for rows.Next() { var code string var at time.Time var rate float64 if rows.Scan(&code, &at, &rate) == nil && rate > 0 { out[strings.ToUpper(code)] = LKFxRate{Rate: rate, At: at} } } return out } // ---- курс на сегодня ---- var cbrCache = struct { mu sync.Mutex at time.Time // когда ходили в ЦБ day time.Time // на какую дату курс m map[string]float64 err string }{} // cbrToday — курсы ЦБ на сегодня с кэшем на час. Ошибку запоминаем, чтобы // в закрытом контуре не ходить в недоступный интернет на каждой странице. func cbrToday(ctx context.Context, rc *RuntimeConfig) (map[string]LKFxRate, string) { cbrCache.mu.Lock() fresh := time.Since(cbrCache.at) < time.Hour m, day, cerr := cbrCache.m, cbrCache.day, cbrCache.err cbrCache.mu.Unlock() if !fresh { at, rates, err := cbrFetch(ctx, time.Time{}) cbrCache.mu.Lock() cbrCache.at = time.Now() if err != nil { cbrCache.err = err.Error() } else { cbrCache.m, cbrCache.day, cbrCache.err = rates, at, "" } m, day, cerr = cbrCache.m, cbrCache.day, cbrCache.err cbrCache.mu.Unlock() if err == nil { lkFxSave(ctx, rc, at, "cbr", rates) } } out := make(map[string]LKFxRate, len(m)) for code, rate := range m { out[code] = LKFxRate{Rate: rate, At: day} } return out, cerr }