Files
gooseek/backend/internal/finance/heatmap.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

431 lines
12 KiB
Go
Raw Permalink 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"
"strings"
"sync"
"time"
)
type HeatmapService struct {
cache map[string]*CachedHeatmap
mu sync.RWMutex
httpClient *http.Client
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
RefreshInterval time.Duration
}
type CachedHeatmap struct {
Data *MarketHeatmap
ExpiresAt time.Time
}
type MarketHeatmap struct {
ID string `json:"id"`
Title string `json:"title"`
Type HeatmapType `json:"type"`
Market string `json:"market"`
Sectors []Sector `json:"sectors"`
Tickers []TickerData `json:"tickers"`
Summary MarketSummary `json:"summary"`
UpdatedAt time.Time `json:"updatedAt"`
TimeRange string `json:"timeRange"`
Colorscale Colorscale `json:"colorscale"`
}
type HeatmapType string
const (
HeatmapTreemap HeatmapType = "treemap"
HeatmapGrid HeatmapType = "grid"
HeatmapBubble HeatmapType = "bubble"
HeatmapSectorChart HeatmapType = "sector_chart"
)
type Sector struct {
ID string `json:"id"`
Name string `json:"name"`
Change float64 `json:"change"`
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"`
Weight float64 `json:"weight"`
}
type TickerData struct {
Symbol string `json:"symbol"`
Name string `json:"name"`
Price float64 `json:"price"`
Change float64 `json:"change"`
ChangePercent float64 `json:"changePercent"`
Volume float64 `json:"volume"`
MarketCap float64 `json:"marketCap"`
Sector string `json:"sector"`
Industry string `json:"industry"`
Color string `json:"color"`
Size float64 `json:"size"`
PrevClose float64 `json:"prevClose,omitempty"`
DayHigh float64 `json:"dayHigh,omitempty"`
DayLow float64 `json:"dayLow,omitempty"`
Week52High float64 `json:"week52High,omitempty"`
Week52Low float64 `json:"week52Low,omitempty"`
PE float64 `json:"pe,omitempty"`
EPS float64 `json:"eps,omitempty"`
Dividend float64 `json:"dividend,omitempty"`
DividendYield float64 `json:"dividendYield,omitempty"`
}
type MarketSummary struct {
TotalMarketCap float64 `json:"totalMarketCap"`
TotalVolume float64 `json:"totalVolume"`
AdvancingCount int `json:"advancingCount"`
DecliningCount int `json:"decliningCount"`
UnchangedCount int `json:"unchangedCount"`
AverageChange float64 `json:"averageChange"`
TopGainer *TickerData `json:"topGainer,omitempty"`
TopLoser *TickerData `json:"topLoser,omitempty"`
MostActive *TickerData `json:"mostActive,omitempty"`
MarketSentiment string `json:"marketSentiment"`
VIX float64 `json:"vix,omitempty"`
FearGreedIndex int `json:"fearGreedIndex,omitempty"`
}
type Colorscale struct {
Min float64 `json:"min"`
Max float64 `json:"max"`
MidPoint float64 `json:"midPoint"`
Colors []string `json:"colors"`
Thresholds []float64 `json:"thresholds"`
}
var DefaultColorscale = Colorscale{
Min: -10,
Max: 10,
MidPoint: 0,
Colors: []string{
"#ef4444",
"#f87171",
"#fca5a5",
"#fecaca",
"#e5e7eb",
"#bbf7d0",
"#86efac",
"#4ade80",
"#22c55e",
},
Thresholds: []float64{-5, -3, -2, -1, 1, 2, 3, 5},
}
func NewHeatmapService(cfg HeatmapConfig) *HeatmapService {
if cfg.CacheTTL == 0 {
cfg.CacheTTL = 5 * time.Minute
}
if cfg.RefreshInterval == 0 {
cfg.RefreshInterval = time.Minute
}
return &HeatmapService{
cache: make(map[string]*CachedHeatmap),
httpClient: &http.Client{Timeout: 30 * time.Second},
config: cfg,
}
}
func (s *HeatmapService) GetMarketHeatmap(ctx context.Context, market string, timeRange string) (*MarketHeatmap, error) {
cacheKey := fmt.Sprintf("%s:%s", market, timeRange)
s.mu.RLock()
if cached, ok := s.cache[cacheKey]; ok && time.Now().Before(cached.ExpiresAt) {
s.mu.RUnlock()
return cached.Data, nil
}
s.mu.RUnlock()
heatmap, err := s.fetchMarketData(ctx, market, timeRange)
if err != nil {
return nil, err
}
s.mu.Lock()
s.cache[cacheKey] = &CachedHeatmap{
Data: heatmap,
ExpiresAt: time.Now().Add(s.config.CacheTTL),
}
s.mu.Unlock()
return heatmap, nil
}
func (s *HeatmapService) GetSectorHeatmap(ctx context.Context, market, sector, timeRange string) (*MarketHeatmap, error) {
heatmap, err := s.GetMarketHeatmap(ctx, market, timeRange)
if err != nil {
return nil, err
}
filteredTickers := make([]TickerData, 0)
for _, t := range heatmap.Tickers {
if strings.EqualFold(t.Sector, sector) {
filteredTickers = append(filteredTickers, t)
}
}
sectorHeatmap := &MarketHeatmap{
ID: fmt.Sprintf("%s-%s", market, sector),
Title: fmt.Sprintf("%s - %s", market, sector),
Type: HeatmapTreemap,
Market: market,
Tickers: filteredTickers,
TimeRange: timeRange,
UpdatedAt: time.Now(),
Colorscale: DefaultColorscale,
}
sectorHeatmap.Summary = s.calculateSummary(filteredTickers)
return sectorHeatmap, nil
}
func (s *HeatmapService) fetchMarketData(ctx context.Context, market, timeRange string) (*MarketHeatmap, error) {
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) 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
}
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)
}
}
sec.TickerCount = len(sec.Tickers)
}
}
func (s *HeatmapService) calculateSummary(tickers []TickerData) MarketSummary {
return *s.calculateSummaryPtr(tickers)
}
func (s *HeatmapService) calculateSummaryPtr(tickers []TickerData) *MarketSummary {
summary := &MarketSummary{}
var totalChange float64
var topGainer, topLoser, mostActive *TickerData
for i := range tickers {
t := &tickers[i]
summary.TotalMarketCap += t.MarketCap
summary.TotalVolume += t.Volume
totalChange += t.ChangePercent
if t.ChangePercent > 0 {
summary.AdvancingCount++
} else if t.ChangePercent < 0 {
summary.DecliningCount++
} else {
summary.UnchangedCount++
}
if topGainer == nil || t.ChangePercent > topGainer.ChangePercent {
topGainer = t
}
if topLoser == nil || t.ChangePercent < topLoser.ChangePercent {
topLoser = t
}
if mostActive == nil || t.Volume > mostActive.Volume {
mostActive = t
}
}
if len(tickers) > 0 {
summary.AverageChange = totalChange / float64(len(tickers))
}
summary.TopGainer = topGainer
summary.TopLoser = topLoser
summary.MostActive = mostActive
if summary.AverageChange > 1 {
summary.MarketSentiment = "bullish"
} else if summary.AverageChange < -1 {
summary.MarketSentiment = "bearish"
} else {
summary.MarketSentiment = "neutral"
}
return summary
}
func (s *HeatmapService) GenerateTreemapData(heatmap *MarketHeatmap) interface{} {
children := make([]map[string]interface{}, 0)
for _, sector := range heatmap.Sectors {
sectorChildren := make([]map[string]interface{}, 0)
for _, ticker := range heatmap.Tickers {
if ticker.Sector == sector.Name {
sectorChildren = append(sectorChildren, map[string]interface{}{
"name": ticker.Symbol,
"value": ticker.MarketCap,
"change": ticker.ChangePercent,
"color": ticker.Color,
"data": ticker,
})
}
}
children = append(children, map[string]interface{}{
"name": sector.Name,
"children": sectorChildren,
"change": sector.Change,
"color": sector.Color,
})
}
return map[string]interface{}{
"name": heatmap.Market,
"children": children,
}
}
func (s *HeatmapService) GenerateGridData(heatmap *MarketHeatmap, rows, cols int) [][]TickerData {
grid := make([][]TickerData, rows)
for i := range grid {
grid[i] = make([]TickerData, cols)
}
idx := 0
for i := 0; i < rows && idx < len(heatmap.Tickers); i++ {
for j := 0; j < cols && idx < len(heatmap.Tickers); j++ {
grid[i][j] = heatmap.Tickers[idx]
idx++
}
}
return grid
}
func (s *HeatmapService) GetTopMovers(ctx context.Context, market string, count int) (*TopMovers, error) {
heatmap, err := s.GetMarketHeatmap(ctx, market, "1d")
if err != nil {
return nil, err
}
tickers := make([]TickerData, len(heatmap.Tickers))
copy(tickers, heatmap.Tickers)
sort.Slice(tickers, func(i, j int) bool {
return tickers[i].ChangePercent > tickers[j].ChangePercent
})
gainers := tickers
if len(gainers) > count {
gainers = gainers[:count]
}
sort.Slice(tickers, func(i, j int) bool {
return tickers[i].ChangePercent < tickers[j].ChangePercent
})
losers := tickers
if len(losers) > count {
losers = losers[:count]
}
sort.Slice(tickers, func(i, j int) bool {
return tickers[i].Volume > tickers[j].Volume
})
active := tickers
if len(active) > count {
active = active[:count]
}
return &TopMovers{
Gainers: gainers,
Losers: losers,
MostActive: active,
UpdatedAt: time.Now(),
}, nil
}
type TopMovers struct {
Gainers []TickerData `json:"gainers"`
Losers []TickerData `json:"losers"`
MostActive []TickerData `json:"mostActive"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (h *MarketHeatmap) ToJSON() ([]byte, error) {
return json.Marshal(h)
}