- 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
431 lines
12 KiB
Go
431 lines
12 KiB
Go
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)
|
||
}
|