Files
gooseek/backend/internal/finance/providers.go
home 08bd41e75c feat: travel service with 2GIS routing, POI, hotels + finance providers + UI overhaul
- Add travel-svc microservice (Amadeus, TravelPayouts, 2GIS, OpenRouteService)
- Add travel orchestrator with parallel collectors (events, POI, hotels, flights)
- Add 2GIS road routing with transport cost calculation (car/bus/taxi)
- Add TravelMap (2GIS MapGL) and TravelWidgets components
- Add useTravelChat hook for streaming travel agent responses
- Add finance heatmap providers refactor
- Add SearXNG settings, API proxy routes, Docker compose updates
- Update Dockerfiles, config, types, and all UI pages for consistency

Made-with: Cursor
2026-03-01 21:58:32 +03:00

407 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 })
}