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:
home
2026-03-01 21:58:32 +03:00
parent e6b9cfc60a
commit 08bd41e75c
71 changed files with 12364 additions and 945 deletions

View File

@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"fmt"
"math"
"net/http"
"sort"
"strings"
@@ -19,6 +18,9 @@ type HeatmapService struct {
config HeatmapConfig
}
// HeatmapConfig configures the heatmap service.
// Встроенные провайдеры (без ключа): moex → MOEX ISS, crypto → CoinGecko, forex → ЦБ РФ.
// DataProviderURL — опционально для своих рынков; GET с параметрами market, range.
type HeatmapConfig struct {
DataProviderURL string
CacheTTL time.Duration
@@ -59,6 +61,7 @@ type Sector struct {
MarketCap float64 `json:"marketCap"`
Volume float64 `json:"volume"`
TickerCount int `json:"tickerCount"`
Tickers []TickerData `json:"tickers,omitempty"`
TopGainers []TickerData `json:"topGainers,omitempty"`
TopLosers []TickerData `json:"topLosers,omitempty"`
Color string `json:"color"`
@@ -199,137 +202,72 @@ func (s *HeatmapService) GetSectorHeatmap(ctx context.Context, market, sector, t
}
func (s *HeatmapService) fetchMarketData(ctx context.Context, market, timeRange string) (*MarketHeatmap, error) {
heatmap := s.generateMockMarketData(market)
market = strings.ToLower(strings.TrimSpace(market))
var heatmap *MarketHeatmap
var err error
switch market {
case "moex":
heatmap, err = s.fetchMOEX(ctx, timeRange)
case "crypto":
heatmap, err = s.fetchCoinGecko(ctx, timeRange)
case "forex":
heatmap, err = s.fetchForexCBR(ctx, timeRange)
default:
if s.config.DataProviderURL != "" {
heatmap, err = s.fetchFromProvider(ctx, market, timeRange)
} else {
return nil, fmt.Errorf("неизвестный рынок %q и FINANCE_DATA_PROVIDER_URL не задан", market)
}
}
if err != nil {
return nil, err
}
heatmap.TimeRange = timeRange
heatmap.UpdatedAt = time.Now()
if len(heatmap.Colorscale.Colors) == 0 {
heatmap.Colorscale = DefaultColorscale
}
s.fillSectorTickers(heatmap)
return heatmap, nil
}
func (s *HeatmapService) generateMockMarketData(market string) *MarketHeatmap {
sectors := []struct {
name string
tickers []struct{ symbol, name string }
}{
{"Technology", []struct{ symbol, name string }{
{"AAPL", "Apple Inc."},
{"MSFT", "Microsoft Corp."},
{"GOOGL", "Alphabet Inc."},
{"AMZN", "Amazon.com Inc."},
{"META", "Meta Platforms"},
{"NVDA", "NVIDIA Corp."},
{"TSLA", "Tesla Inc."},
}},
{"Healthcare", []struct{ symbol, name string }{
{"JNJ", "Johnson & Johnson"},
{"UNH", "UnitedHealth Group"},
{"PFE", "Pfizer Inc."},
{"MRK", "Merck & Co."},
{"ABBV", "AbbVie Inc."},
}},
{"Finance", []struct{ symbol, name string }{
{"JPM", "JPMorgan Chase"},
{"BAC", "Bank of America"},
{"WFC", "Wells Fargo"},
{"GS", "Goldman Sachs"},
{"MS", "Morgan Stanley"},
}},
{"Energy", []struct{ symbol, name string }{
{"XOM", "Exxon Mobil"},
{"CVX", "Chevron Corp."},
{"COP", "ConocoPhillips"},
{"SLB", "Schlumberger"},
}},
{"Consumer", []struct{ symbol, name string }{
{"WMT", "Walmart Inc."},
{"PG", "Procter & Gamble"},
{"KO", "Coca-Cola Co."},
{"PEP", "PepsiCo Inc."},
{"COST", "Costco Wholesale"},
}},
func (s *HeatmapService) fetchFromProvider(ctx context.Context, market, timeRange string) (*MarketHeatmap, error) {
u := strings.TrimSuffix(s.config.DataProviderURL, "/")
reqURL := fmt.Sprintf("%s?market=%s&range=%s", u, market, timeRange)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, err
}
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("provider returned %d", resp.StatusCode)
}
var heatmap MarketHeatmap
if err := json.NewDecoder(resp.Body).Decode(&heatmap); err != nil {
return nil, err
}
if len(heatmap.Tickers) == 0 && len(heatmap.Sectors) == 0 {
return nil, fmt.Errorf("provider returned empty data")
}
return &heatmap, nil
}
allTickers := make([]TickerData, 0)
allSectors := make([]Sector, 0)
for _, sec := range sectors {
sectorTickers := make([]TickerData, 0)
sectorChange := 0.0
for _, t := range sec.tickers {
change := (randomFloat(-5, 5))
price := randomFloat(50, 500)
marketCap := randomFloat(50e9, 3000e9)
volume := randomFloat(1e6, 100e6)
ticker := TickerData{
Symbol: t.symbol,
Name: t.name,
Price: price,
Change: price * change / 100,
ChangePercent: change,
Volume: volume,
MarketCap: marketCap,
Sector: sec.name,
Color: getColorForChange(change),
Size: math.Log10(marketCap) * 10,
func (s *HeatmapService) fillSectorTickers(heatmap *MarketHeatmap) {
for i := range heatmap.Sectors {
sec := &heatmap.Sectors[i]
if len(sec.Tickers) > 0 {
continue
}
for _, t := range heatmap.Tickers {
if strings.EqualFold(t.Sector, sec.Name) {
sec.Tickers = append(sec.Tickers, t)
}
sectorTickers = append(sectorTickers, ticker)
sectorChange += change
}
if len(sectorTickers) > 0 {
sectorChange /= float64(len(sectorTickers))
}
sort.Slice(sectorTickers, func(i, j int) bool {
return sectorTickers[i].ChangePercent > sectorTickers[j].ChangePercent
})
var topGainers, topLosers []TickerData
if len(sectorTickers) >= 2 {
topGainers = sectorTickers[:2]
topLosers = sectorTickers[len(sectorTickers)-2:]
}
sectorMarketCap := 0.0
sectorVolume := 0.0
for _, t := range sectorTickers {
sectorMarketCap += t.MarketCap
sectorVolume += t.Volume
}
sector := Sector{
ID: strings.ToLower(strings.ReplaceAll(sec.name, " ", "_")),
Name: sec.name,
Change: sectorChange,
MarketCap: sectorMarketCap,
Volume: sectorVolume,
TickerCount: len(sectorTickers),
TopGainers: topGainers,
TopLosers: topLosers,
Color: getColorForChange(sectorChange),
Weight: sectorMarketCap,
}
allSectors = append(allSectors, sector)
allTickers = append(allTickers, sectorTickers...)
}
sort.Slice(allTickers, func(i, j int) bool {
return allTickers[i].MarketCap > allTickers[j].MarketCap
})
return &MarketHeatmap{
ID: market,
Title: getMarketTitle(market),
Type: HeatmapTreemap,
Market: market,
Sectors: allSectors,
Tickers: allTickers,
Summary: *s.calculateSummaryPtr(allTickers),
UpdatedAt: time.Now(),
Colorscale: DefaultColorscale,
sec.TickerCount = len(sec.Tickers)
}
}
@@ -487,51 +425,6 @@ type TopMovers struct {
UpdatedAt time.Time `json:"updatedAt"`
}
func getColorForChange(change float64) string {
if change >= 5 {
return "#22c55e"
} else if change >= 3 {
return "#4ade80"
} else if change >= 1 {
return "#86efac"
} else if change >= 0 {
return "#bbf7d0"
} else if change >= -1 {
return "#fecaca"
} else if change >= -3 {
return "#fca5a5"
} else if change >= -5 {
return "#f87171"
}
return "#ef4444"
}
func getMarketTitle(market string) string {
titles := map[string]string{
"sp500": "S&P 500",
"nasdaq": "NASDAQ",
"dow": "Dow Jones",
"moex": "MOEX",
"crypto": "Cryptocurrency",
"forex": "Forex",
"commodities": "Commodities",
}
if title, ok := titles[strings.ToLower(market)]; ok {
return title
}
return market
}
var rng uint64 = uint64(time.Now().UnixNano())
func randomFloat(min, max float64) float64 {
rng ^= rng << 13
rng ^= rng >> 17
rng ^= rng << 5
f := float64(rng) / float64(1<<64)
return min + f*(max-min)
}
func (h *MarketHeatmap) ToJSON() ([]byte, error) {
return json.Marshal(h)
}

View 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 })
}