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
This commit is contained in:
406
backend/internal/finance/providers.go
Normal file
406
backend/internal/finance/providers.go
Normal file
@@ -0,0 +1,406 @@
|
||||
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 })
|
||||
}
|
||||
Reference in New Issue
Block a user