Files
gooseek/backend/internal/finance/heatmap.go
home 06fe57c765 feat: Go backend, enhanced search, new widgets, Docker deploy
Major changes:
- Add Go backend (backend/) with microservices architecture
- Enhanced master-agents-svc: reranker, content-classifier, stealth-crawler,
  proxy-manager, media-search, fastClassifier, language detection
- New web-svc widgets: KnowledgeCard, ProductCard, ProfileCard, VideoCard,
  UnifiedCard, CardGallery, InlineImageGallery, SourcesPanel, RelatedQuestions
- Improved discover-svc with discover-db integration
- Docker deployment improvements (Caddyfile, vendor.sh, BUILD.md)
- Library-svc: project_id schema migration
- Remove deprecated finance-svc and travel-svc
- Localization improvements across services

Made-with: Cursor
2026-02-27 04:15:32 +03:00

538 lines
14 KiB
Go

package finance
import (
"context"
"encoding/json"
"fmt"
"math"
"net/http"
"sort"
"strings"
"sync"
"time"
)
type HeatmapService struct {
cache map[string]*CachedHeatmap
mu sync.RWMutex
httpClient *http.Client
config HeatmapConfig
}
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"`
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) {
heatmap := s.generateMockMarketData(market)
heatmap.TimeRange = timeRange
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"},
}},
}
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,
}
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,
}
}
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 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)
}