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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user