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