package finance import ( "context" "encoding/json" "fmt" "net/http" "sort" "strconv" "strings" "time" ) const ( moexISSBase = "https://iss.moex.com/iss" coinGeckoBase = "https://api.coingecko.com/api/v3" ) // fetchMOEX получает данные с Московской биржи (ISS API, бесплатно, без регистрации). // Документация: https://iss.moex.com/iss/reference/ func (s *HeatmapService) fetchMOEX(ctx context.Context, _ string) (*MarketHeatmap, error) { // Основной режим акций Т+2, boardgroup 57 url := moexISSBase + "/engines/stock/markets/shares/boardgroups/57/securities.json?limit=100" req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } resp, err := s.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("moex request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("moex returned %d", resp.StatusCode) } var raw struct { Securities struct { Columns []string `json:"columns"` Data [][]interface{} `json:"data"` } `json:"securities"` Marketdata struct { Columns []string `json:"columns"` Data [][]interface{} `json:"data"` } `json:"marketdata"` } if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { return nil, fmt.Errorf("moex decode: %w", err) } colIdx := func(cols []string, name string) int { for i, c := range cols { if c == name { return i } } return -1 } getStr := func(row []interface{}, idx int) string { if idx < 0 || idx >= len(row) || row[idx] == nil { return "" } switch v := row[idx].(type) { case string: return v case float64: return strconv.FormatFloat(v, 'f', -1, 64) default: return "" } } getFloat := func(row []interface{}, idx int) float64 { if idx < 0 || idx >= len(row) || row[idx] == nil { return 0 } switch v := row[idx].(type) { case float64: return v case string: f, _ := strconv.ParseFloat(v, 64) return f default: return 0 } } secCols := raw.Securities.Columns iSECID := colIdx(secCols, "SECID") iSHORTNAME := colIdx(secCols, "SHORTNAME") iSECNAME := colIdx(secCols, "SECNAME") iPREVPRICE := colIdx(secCols, "PREVPRICE") iPREVWAPRICE := colIdx(secCols, "PREVWAPRICE") iISSUESIZE := colIdx(secCols, "ISSUESIZE") iBOARDID := colIdx(secCols, "BOARDID") // Только акции TQBR (основной режим), без паёв и прочего tickers := make([]TickerData, 0, len(raw.Securities.Data)) marketdataBySec := make(map[string]struct{ Last, LastChangePrc float64 }) mdCols := raw.Marketdata.Columns iMD_SECID := colIdx(mdCols, "SECID") iMD_LAST := colIdx(mdCols, "LAST") iMD_LASTCHANGEPRC := colIdx(mdCols, "LASTCHANGEPRC") for _, row := range raw.Marketdata.Data { sid := getStr(row, iMD_SECID) if sid != "" { marketdataBySec[sid] = struct{ Last, LastChangePrc float64 }{ Last: getFloat(row, iMD_LAST), LastChangePrc: getFloat(row, iMD_LASTCHANGEPRC), } } } for _, row := range raw.Securities.Data { board := getStr(row, iBOARDID) if board != "TQBR" { continue } secID := getStr(row, iSECID) prevPrice := getFloat(row, iPREVPRICE) prevWAPrice := getFloat(row, iPREVWAPRICE) if prevPrice <= 0 { prevPrice = prevWAPrice } if prevPrice <= 0 { continue } issuesize := getFloat(row, iISSUESIZE) marketCap := prevPrice * issuesize price := prevPrice changePct := 0.0 if md, ok := marketdataBySec[secID]; ok && md.Last > 0 { price = md.Last changePct = md.LastChangePrc } else if prevWAPrice > 0 && prevWAPrice != prevPrice { changePct = (prevWAPrice - prevPrice) / prevPrice * 100 price = prevWAPrice } name := getStr(row, iSECNAME) if name == "" { name = getStr(row, iSHORTNAME) } tickers = append(tickers, TickerData{ Symbol: secID, Name: name, Price: price, Change: price - prevPrice, ChangePercent: changePct, MarketCap: marketCap, Volume: 0, Sector: "Акции", Color: colorForChange(changePct), Size: marketCap, PrevClose: prevPrice, }) } if len(tickers) == 0 { return nil, fmt.Errorf("moex: no tickers") } sortTickersByMarketCap(tickers) summary := s.calculateSummaryPtr(tickers) sector := Sector{ ID: "akcii", Name: "Акции", Change: summary.AverageChange, MarketCap: summary.TotalMarketCap, Volume: summary.TotalVolume, TickerCount: len(tickers), Tickers: tickers, Color: colorForChange(summary.AverageChange), Weight: summary.TotalMarketCap, } if len(tickers) >= 5 { gainers := make([]TickerData, len(tickers)) copy(gainers, tickers) sortTickersByChangeDesc(gainers) sector.TopGainers = gainers[:minInt(3, len(gainers))] losers := make([]TickerData, len(tickers)) copy(losers, tickers) sortTickersByChangeAsc(losers) sector.TopLosers = losers[:minInt(3, len(losers))] } return &MarketHeatmap{ ID: "moex", Title: "MOEX", Type: HeatmapTreemap, Market: "moex", Sectors: []Sector{sector}, Tickers: tickers, Summary: *summary, UpdatedAt: now(), Colorscale: DefaultColorscale, }, nil } // fetchCoinGecko получает топ криптовалют с CoinGecko (бесплатно, без API ключа). // Документация: https://www.coingecko.com/en/api func (s *HeatmapService) fetchCoinGecko(ctx context.Context, _ string) (*MarketHeatmap, error) { url := coinGeckoBase + "/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=50&page=1" req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } req.Header.Set("Accept", "application/json") resp, err := s.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("coingecko request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("coingecko returned %d", resp.StatusCode) } var list []struct { ID string `json:"id"` Symbol string `json:"symbol"` Name string `json:"name"` CurrentPrice float64 `json:"current_price"` MarketCap float64 `json:"market_cap"` PriceChange24h *float64 `json:"price_change_percentage_24h"` TotalVolume float64 `json:"total_volume"` } if err := json.NewDecoder(resp.Body).Decode(&list); err != nil { return nil, fmt.Errorf("coingecko decode: %w", err) } tickers := make([]TickerData, 0, len(list)) for _, c := range list { chg := 0.0 if c.PriceChange24h != nil { chg = *c.PriceChange24h } sym := strings.ToUpper(c.Symbol) tickers = append(tickers, TickerData{ Symbol: sym, Name: c.Name, Price: c.CurrentPrice, ChangePercent: chg, Change: c.CurrentPrice * chg / 100, MarketCap: c.MarketCap, Volume: c.TotalVolume, Sector: "Криптовалюты", Color: colorForChange(chg), Size: c.MarketCap, }) } if len(tickers) == 0 { return nil, fmt.Errorf("coingecko: no tickers") } summary := s.calculateSummaryPtr(tickers) sector := Sector{ ID: "crypto", Name: "Криптовалюты", Change: summary.AverageChange, MarketCap: summary.TotalMarketCap, Volume: summary.TotalVolume, TickerCount: len(tickers), Tickers: tickers, Color: colorForChange(summary.AverageChange), Weight: summary.TotalMarketCap, } byGain := make([]TickerData, len(tickers)) copy(byGain, tickers) sortTickersByChangeDesc(byGain) if len(byGain) >= 3 { sector.TopGainers = byGain[:3] } byLoss := make([]TickerData, len(tickers)) copy(byLoss, tickers) sortTickersByChangeAsc(byLoss) if len(byLoss) >= 3 { sector.TopLosers = byLoss[:3] } return &MarketHeatmap{ ID: "crypto", Title: "Криптовалюты", Type: HeatmapTreemap, Market: "crypto", Sectors: []Sector{sector}, Tickers: tickers, Summary: *summary, UpdatedAt: now(), Colorscale: DefaultColorscale, }, nil } // fetchForexCBR получает курсы валют ЦБ РФ (бесплатно, без ключа). func (s *HeatmapService) fetchForexCBR(ctx context.Context, _ string) (*MarketHeatmap, error) { url := "https://www.cbr-xml-daily.ru/daily_json.js" req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } resp, err := s.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("cbr request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("cbr returned %d", resp.StatusCode) } var raw struct { Valute map[string]struct { CharCode string `json:"CharCode"` Name string `json:"Name"` Value float64 `json:"Value"` Previous float64 `json:"Previous"` } `json:"Valute"` } if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { return nil, fmt.Errorf("cbr decode: %w", err) } tickers := make([]TickerData, 0, len(raw.Valute)) for _, v := range raw.Valute { if v.Previous <= 0 { continue } chg := (v.Value - v.Previous) / v.Previous * 100 tickers = append(tickers, TickerData{ Symbol: v.CharCode, Name: v.Name, Price: v.Value, PrevClose: v.Previous, Change: v.Value - v.Previous, ChangePercent: chg, Sector: "Валюты", Color: colorForChange(chg), }) } if len(tickers) == 0 { return nil, fmt.Errorf("cbr: no rates") } summary := s.calculateSummaryPtr(tickers) sector := Sector{ ID: "forex", Name: "Валюты", Change: summary.AverageChange, TickerCount: len(tickers), Tickers: tickers, Color: colorForChange(summary.AverageChange), } return &MarketHeatmap{ ID: "forex", Title: "Валюты (ЦБ РФ)", Type: HeatmapTreemap, Market: "forex", Sectors: []Sector{sector}, Tickers: tickers, Summary: *summary, UpdatedAt: now(), Colorscale: DefaultColorscale, }, nil } func colorForChange(change float64) string { if change >= 5 { return "#22c55e" } if change >= 3 { return "#4ade80" } if change >= 1 { return "#86efac" } if change >= 0 { return "#bbf7d0" } if change >= -1 { return "#fecaca" } if change >= -3 { return "#fca5a5" } if change >= -5 { return "#f87171" } return "#ef4444" } func now() time.Time { return time.Now() } func minInt(a, b int) int { if a < b { return a } return b } func sortTickersByMarketCap(t []TickerData) { sort.Slice(t, func(i, j int) bool { return t[i].MarketCap > t[j].MarketCap }) } func sortTickersByChangeDesc(t []TickerData) { sort.Slice(t, func(i, j int) bool { return t[i].ChangePercent > t[j].ChangePercent }) } func sortTickersByChangeAsc(t []TickerData) { sort.Slice(t, func(i, j int) bool { return t[i].ChangePercent < t[j].ChangePercent }) }