mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
- Market plugin (#49): daily index snapshots (Yahoo Finance + Finnhub fallback), Ollama-generated summaries, !howsthemarket, !marketstatus, !marketreport commands, exchange hours with DST support, 30-min room cooldown - Adventure arena: 5-tier combat gauntlet with 20 monsters, risk-reward cashout, death lockout changed from 24h to midnight UTC across all code and flavor text - Adventure holidays: double daily actions on holidays - Adventure DM fix: 15-minute response window prevents bare numbers from triggering adventure during UNO games - Adventure scheduler: jitter between morning DMs to avoid Matrix rate limits - Adventure revive: wire up adv_revived achievement on admin revive - Column migration system for existing databases (holiday_action_taken) - Fix italic markdown rendering after newlines - Fix holdem DMs broken by space groups including DM rooms - UNO No Mercy: remove stacking escalation rule (any draw card stacks on any other) - UNO multiplayer: add turn announcements after stack absorption and turn passes, jitter between rapid-fire messages with safe mutex handling - Birthday: add €1,000 gift and 10 XP + €100 community celebration bonus - Market: guard against division by zero, nil pointer, and timezone errors - README updates: plugin count 48→49, all new commands, feature flags, architecture Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1109 lines
34 KiB
Go
1109 lines
34 KiB
Go
package plugin
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"math"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"gogobee/internal/db"
|
|
|
|
"maunium.net/go/mautrix"
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// ── Plugin ───────────────────────────────────────────────────────────────────
|
|
|
|
// MarketPlugin provides daily market index snapshots with Ollama-generated
|
|
// summaries, market status by exchange hours, and historical trend reports.
|
|
type MarketPlugin struct {
|
|
Base
|
|
httpClient *http.Client
|
|
finnhubKey string
|
|
mu sync.Mutex
|
|
cooldowns map[id.RoomID]time.Time
|
|
enabled bool
|
|
broadcastOn bool
|
|
}
|
|
|
|
func NewMarketPlugin(client *mautrix.Client) *MarketPlugin {
|
|
enabled := os.Getenv("FEATURE_MARKET") == "true"
|
|
broadcast := os.Getenv("MARKET_BROADCAST_SUMMARY") == "true"
|
|
return &MarketPlugin{
|
|
Base: NewBase(client),
|
|
httpClient: &http.Client{Timeout: 15 * time.Second},
|
|
finnhubKey: os.Getenv("FINNHUB_API_KEY"),
|
|
cooldowns: make(map[id.RoomID]time.Time),
|
|
enabled: enabled,
|
|
broadcastOn: broadcast,
|
|
}
|
|
}
|
|
|
|
func (p *MarketPlugin) Name() string { return "market" }
|
|
|
|
func (p *MarketPlugin) Commands() []CommandDef {
|
|
return []CommandDef{
|
|
{Name: "howsthemarket", Description: "Daily market snapshot with commentary", Usage: "!howsthemarket", Category: "Finance"},
|
|
{Name: "marketstatus", Description: "Which exchanges are open right now", Usage: "!marketstatus", Category: "Finance"},
|
|
{Name: "marketreport", Description: "Historical market trend reports", Usage: "!marketreport week|month|year|vix|compare <index> <days>", Category: "Finance"},
|
|
}
|
|
}
|
|
|
|
func (p *MarketPlugin) Init() error { return nil }
|
|
|
|
func (p *MarketPlugin) OnReaction(_ ReactionContext) error { return nil }
|
|
|
|
func (p *MarketPlugin) OnMessage(ctx MessageContext) error {
|
|
if !p.enabled {
|
|
return nil
|
|
}
|
|
if p.IsCommand(ctx.Body, "howsthemarket") {
|
|
return p.handleSnapshot(ctx)
|
|
}
|
|
if p.IsCommand(ctx.Body, "marketstatus") {
|
|
return p.handleStatus(ctx)
|
|
}
|
|
if p.IsCommand(ctx.Body, "marketreport") {
|
|
return p.handleReport(ctx)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ── Index Configuration ──────────────────────────────────────────────────────
|
|
|
|
type marketIndex struct {
|
|
Symbol string
|
|
DisplayName string
|
|
FallbackETF string
|
|
ShortName string
|
|
}
|
|
|
|
var marketIndices = []marketIndex{
|
|
{Symbol: "^GSPC", DisplayName: "S&P 500", FallbackETF: "SPY", ShortName: "SP500"},
|
|
{Symbol: "^IXIC", DisplayName: "NASDAQ", FallbackETF: "QQQ", ShortName: "NASDAQ"},
|
|
{Symbol: "^DJI", DisplayName: "Dow Jones", FallbackETF: "DIA", ShortName: "DOW"},
|
|
{Symbol: "^FTSE", DisplayName: "FTSE 100", FallbackETF: "EWU", ShortName: "FTSE"},
|
|
{Symbol: "^N225", DisplayName: "Nikkei 225", FallbackETF: "EWJ", ShortName: "NIKKEI"},
|
|
{Symbol: "PSI20.NX", DisplayName: "PSI 20", FallbackETF: "PGAL", ShortName: "PSI20"},
|
|
{Symbol: "^VIX", DisplayName: "VIX", FallbackETF: "VIXY", ShortName: "VIX"},
|
|
}
|
|
|
|
// marketShortNames maps case-insensitive short names to indices for the compare command.
|
|
var marketShortNames map[string]*marketIndex
|
|
|
|
func init() {
|
|
marketShortNames = make(map[string]*marketIndex, len(marketIndices))
|
|
for i := range marketIndices {
|
|
marketShortNames[strings.ToUpper(marketIndices[i].ShortName)] = &marketIndices[i]
|
|
}
|
|
}
|
|
|
|
// ── Data Types ───────────────────────────────────────────────────────────────
|
|
|
|
type marketSnapshot struct {
|
|
Symbol string
|
|
DisplayName string
|
|
Price *float64 // nil if unavailable
|
|
PrevClose *float64
|
|
ChangePct *float64
|
|
Source string
|
|
}
|
|
|
|
// ── Cooldown ─────────────────────────────────────────────────────────────────
|
|
|
|
const marketCooldown = 30 * time.Minute
|
|
|
|
func (p *MarketPlugin) checkCooldown(roomID id.RoomID) bool {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
last, ok := p.cooldowns[roomID]
|
|
if ok && time.Since(last) < marketCooldown {
|
|
return false
|
|
}
|
|
p.cooldowns[roomID] = time.Now()
|
|
return true
|
|
}
|
|
|
|
// ── Yahoo Finance Fetcher ────────────────────────────────────────────────────
|
|
//
|
|
// Yahoo Finance's unofficial API has historically been stable but is not
|
|
// guaranteed. If the endpoint format changes, the Finnhub ETF fallback
|
|
// handles continuity until the primary is updated.
|
|
|
|
func (p *MarketPlugin) fetchYahoo(symbol string) (*marketSnapshot, error) {
|
|
url := fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s", symbol)
|
|
req, err := http.NewRequest("GET", url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("User-Agent", "Mozilla/5.0")
|
|
|
|
resp, err := p.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("yahoo request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("yahoo HTTP %d", resp.StatusCode)
|
|
}
|
|
|
|
var result struct {
|
|
Chart struct {
|
|
Result []struct {
|
|
Meta struct {
|
|
RegularMarketPrice float64 `json:"regularMarketPrice"`
|
|
PreviousClose float64 `json:"previousClose"`
|
|
RegularMarketChangePct float64 `json:"regularMarketChangePercent"`
|
|
} `json:"meta"`
|
|
} `json:"result"`
|
|
Error *struct {
|
|
Code string `json:"code"`
|
|
} `json:"error"`
|
|
} `json:"chart"`
|
|
}
|
|
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("yahoo decode: %w", err)
|
|
}
|
|
if result.Chart.Error != nil {
|
|
return nil, fmt.Errorf("yahoo error: %s", result.Chart.Error.Code)
|
|
}
|
|
if len(result.Chart.Result) == 0 {
|
|
return nil, fmt.Errorf("yahoo: no results")
|
|
}
|
|
|
|
meta := result.Chart.Result[0].Meta
|
|
price := meta.RegularMarketPrice
|
|
prevClose := meta.PreviousClose
|
|
changePct := meta.RegularMarketChangePct
|
|
|
|
return &marketSnapshot{
|
|
Price: &price,
|
|
PrevClose: &prevClose,
|
|
ChangePct: &changePct,
|
|
Source: "yahoo",
|
|
}, nil
|
|
}
|
|
|
|
// ── Finnhub ETF Fallback ─────────────────────────────────────────────────────
|
|
|
|
func (p *MarketPlugin) fetchFinnhub(etfSymbol string) (*marketSnapshot, error) {
|
|
if p.finnhubKey == "" {
|
|
return nil, fmt.Errorf("no FINNHUB_API_KEY")
|
|
}
|
|
|
|
url := fmt.Sprintf("https://finnhub.io/api/v1/quote?symbol=%s&token=%s", etfSymbol, p.finnhubKey)
|
|
resp, err := p.httpClient.Get(url)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("finnhub request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("finnhub HTTP %d", resp.StatusCode)
|
|
}
|
|
|
|
var q struct {
|
|
Current float64 `json:"c"`
|
|
PrevClose float64 `json:"pc"`
|
|
ChangePct float64 `json:"dp"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&q); err != nil {
|
|
return nil, fmt.Errorf("finnhub decode: %w", err)
|
|
}
|
|
if q.Current == 0 {
|
|
return nil, fmt.Errorf("finnhub: zero price for %s", etfSymbol)
|
|
}
|
|
|
|
return &marketSnapshot{
|
|
Price: &q.Current,
|
|
PrevClose: &q.PrevClose,
|
|
ChangePct: &q.ChangePct,
|
|
Source: "finnhub_etf",
|
|
}, nil
|
|
}
|
|
|
|
// ── Daily Pull ───────────────────────────────────────────────────────────────
|
|
|
|
// DailyPull fetches end-of-day data for all indices. Called from cron at 23:00 UTC.
|
|
func (p *MarketPlugin) DailyPull() {
|
|
if !p.enabled {
|
|
return
|
|
}
|
|
|
|
today := time.Now().UTC().Format("2006-01-02")
|
|
if db.JobCompleted("market_pull", today) {
|
|
return
|
|
}
|
|
|
|
start := time.Now()
|
|
d := db.Get()
|
|
var fetched, failed int
|
|
|
|
for _, idx := range marketIndices {
|
|
snap, err := p.fetchYahoo(idx.Symbol)
|
|
if err != nil {
|
|
slog.Warn("market: yahoo failed, trying finnhub", "symbol", idx.Symbol, "err", err)
|
|
snap, err = p.fetchFinnhub(idx.FallbackETF)
|
|
if err != nil {
|
|
slog.Error("market: both sources failed", "symbol", idx.Symbol, "etf", idx.FallbackETF, "err", err)
|
|
// Store NULL row
|
|
_, _ = d.Exec(`INSERT INTO market_snapshots (snapshot_date, symbol, display_name, price, prev_close, change_pct, source, pulled_at)
|
|
VALUES (?, ?, ?, NULL, NULL, NULL, 'unavailable', ?)
|
|
ON CONFLICT(snapshot_date, symbol) DO UPDATE SET price=NULL, prev_close=NULL, change_pct=NULL, source='unavailable', pulled_at=?`,
|
|
today, idx.Symbol, idx.DisplayName, time.Now().Unix(), time.Now().Unix())
|
|
failed++
|
|
continue
|
|
}
|
|
}
|
|
|
|
_, err = d.Exec(`INSERT INTO market_snapshots (snapshot_date, symbol, display_name, price, prev_close, change_pct, source, pulled_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(snapshot_date, symbol) DO UPDATE SET price=?, prev_close=?, change_pct=?, source=?, pulled_at=?`,
|
|
today, idx.Symbol, idx.DisplayName, snap.Price, snap.PrevClose, snap.ChangePct, snap.Source, time.Now().Unix(),
|
|
snap.Price, snap.PrevClose, snap.ChangePct, snap.Source, time.Now().Unix())
|
|
if err != nil {
|
|
slog.Error("market: db insert failed", "symbol", idx.Symbol, "err", err)
|
|
failed++
|
|
continue
|
|
}
|
|
fetched++
|
|
}
|
|
|
|
// Generate Ollama summary
|
|
summary := p.generateDailySummary(today)
|
|
if summary != "" {
|
|
_, _ = d.Exec(`INSERT INTO market_daily_summary (snapshot_date, summary, generated_at) VALUES (?, ?, ?)
|
|
ON CONFLICT(snapshot_date) DO UPDATE SET summary=?, generated_at=?`,
|
|
today, summary, time.Now().Unix(), summary, time.Now().Unix())
|
|
}
|
|
|
|
db.MarkJobCompleted("market_pull", today)
|
|
slog.Info("market: daily pull complete", "fetched", fetched, "failed", failed, "duration", time.Since(start))
|
|
|
|
// Optional broadcast
|
|
if p.broadcastOn {
|
|
gr := gamesRoom()
|
|
if gr != "" {
|
|
text := p.formatSnapshot(today)
|
|
if text != "" {
|
|
_ = p.SendMessage(id.RoomID(gr), text)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Ollama Summary Generation ────────────────────────────────────────────────
|
|
|
|
const marketSystemPrompt = `You are GogoBee, a sardonic but genuinely helpful financial commentator for a retro gaming and anime community chat room.
|
|
You summarize global market conditions in 2-3 sentences.
|
|
Be dry, witty, and informative. Reference the VIX when describing overall sentiment.
|
|
Do not use em dashes. Do not use exclamation marks. Do not offer financial advice.
|
|
If markets are closed or data is stale, note it briefly and move on.`
|
|
|
|
func (p *MarketPlugin) generateDailySummary(date string) string {
|
|
host := os.Getenv("OLLAMA_HOST")
|
|
model := os.Getenv("OLLAMA_MODEL")
|
|
if host == "" || model == "" {
|
|
return ""
|
|
}
|
|
|
|
snapshots := p.loadSnapshots(date)
|
|
if len(snapshots) == 0 {
|
|
return ""
|
|
}
|
|
|
|
var prompt strings.Builder
|
|
var notableMovers []string
|
|
var unavailable []string
|
|
|
|
for _, s := range snapshots {
|
|
if s.ChangePct != nil && math.Abs(*s.ChangePct) > 2.0 {
|
|
notableMovers = append(notableMovers, fmt.Sprintf("%s %+.2f%%", s.DisplayName, *s.ChangePct))
|
|
}
|
|
}
|
|
|
|
if len(notableMovers) > 0 {
|
|
prompt.WriteString(fmt.Sprintf("Notable moves today: %s\n\n", strings.Join(notableMovers, ", ")))
|
|
}
|
|
|
|
prompt.WriteString(fmt.Sprintf("Here is today's end-of-day market data (%s, all prices in local currency):\n\n", date))
|
|
for _, s := range snapshots {
|
|
if s.Price != nil && s.ChangePct != nil {
|
|
prompt.WriteString(fmt.Sprintf("%s: %.2f (%+.2f%%)\n", s.DisplayName, *s.Price, *s.ChangePct))
|
|
} else {
|
|
unavailable = append(unavailable, s.DisplayName)
|
|
}
|
|
}
|
|
if len(unavailable) > 0 {
|
|
prompt.WriteString(fmt.Sprintf("\nUnavailable: %s\n", strings.Join(unavailable, ", ")))
|
|
}
|
|
prompt.WriteString("\nWrite a 2-3 sentence summary.")
|
|
|
|
result, err := p.callOllamaChat(host, model, marketSystemPrompt, prompt.String())
|
|
if err != nil {
|
|
slog.Error("market: ollama summary failed", "err", err)
|
|
return ""
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (p *MarketPlugin) generateReportSummary(snapsByDate map[string][]marketSnapshot, dateRange string) string {
|
|
host := os.Getenv("OLLAMA_HOST")
|
|
model := os.Getenv("OLLAMA_MODEL")
|
|
if host == "" || model == "" {
|
|
return ""
|
|
}
|
|
|
|
// Build per-index price series
|
|
// Collect sorted dates
|
|
var dates []string
|
|
for d := range snapsByDate {
|
|
dates = append(dates, d)
|
|
}
|
|
// Sort dates
|
|
for i := 0; i < len(dates); i++ {
|
|
for j := i + 1; j < len(dates); j++ {
|
|
if dates[j] < dates[i] {
|
|
dates[i], dates[j] = dates[j], dates[i]
|
|
}
|
|
}
|
|
}
|
|
|
|
indexPrices := make(map[string][]string)
|
|
for _, date := range dates {
|
|
for _, s := range snapsByDate[date] {
|
|
if s.Price != nil {
|
|
indexPrices[s.DisplayName] = append(indexPrices[s.DisplayName], fmt.Sprintf("%.2f", *s.Price))
|
|
}
|
|
}
|
|
}
|
|
|
|
var prompt strings.Builder
|
|
prompt.WriteString(fmt.Sprintf("Here is historical data for the following indices (%s):\n\n", dateRange))
|
|
for name, prices := range indexPrices {
|
|
prompt.WriteString(fmt.Sprintf("%s: %s\n", name, strings.Join(prices, ", ")))
|
|
}
|
|
prompt.WriteString("\nDescribe the trend in 2-3 sentences. Note any significant moves or divergences between indices.\nBe sardonic but accurate. Reference the VIX trajectory when relevant.")
|
|
|
|
result, err := p.callOllamaChat(host, model, marketSystemPrompt, prompt.String())
|
|
if err != nil {
|
|
slog.Warn("market: ollama report summary failed", "err", err)
|
|
return ""
|
|
}
|
|
return result
|
|
}
|
|
|
|
// callOllamaChat calls the Ollama /api/chat endpoint with a system and user message.
|
|
// Uses the types already defined in holdem_tips.go (same package).
|
|
func (p *MarketPlugin) callOllamaChat(host, model, systemPrompt, userPrompt string) (string, error) {
|
|
req := ollamaChatRequest{
|
|
Model: model,
|
|
Messages: []chatMessage{
|
|
{Role: "system", Content: systemPrompt},
|
|
{Role: "user", Content: userPrompt},
|
|
},
|
|
Stream: false,
|
|
}
|
|
|
|
body, err := json.Marshal(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("marshal: %w", err)
|
|
}
|
|
|
|
url := strings.TrimRight(host, "/") + "/api/chat"
|
|
resp, err := p.httpClient.Post(url, "application/json", bytes.NewReader(body))
|
|
if err != nil {
|
|
return "", fmt.Errorf("request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
return "", fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
var ollamaResp ollamaChatResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&ollamaResp); err != nil {
|
|
return "", fmt.Errorf("decode: %w", err)
|
|
}
|
|
|
|
text := ollamaResp.Message.Content
|
|
// Strip <think>...</think> blocks (reasoning models)
|
|
if i := strings.Index(text, "<think>"); i != -1 {
|
|
if j := strings.Index(text, "</think>"); j != -1 {
|
|
text = text[:i] + text[j+len("</think>"):]
|
|
}
|
|
}
|
|
|
|
return strings.TrimSpace(text), nil
|
|
}
|
|
|
|
// ── DB Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
func (p *MarketPlugin) loadSnapshots(date string) []marketSnapshot {
|
|
d := db.Get()
|
|
rows, err := d.Query(
|
|
`SELECT symbol, display_name, price, prev_close, change_pct, source
|
|
FROM market_snapshots WHERE snapshot_date = ? ORDER BY ROWID`, date)
|
|
if err != nil {
|
|
slog.Error("market: load snapshots", "err", err)
|
|
return nil
|
|
}
|
|
defer rows.Close()
|
|
|
|
var results []marketSnapshot
|
|
for rows.Next() {
|
|
var s marketSnapshot
|
|
var price, prevClose, changePct *float64
|
|
if err := rows.Scan(&s.Symbol, &s.DisplayName, &price, &prevClose, &changePct, &s.Source); err != nil {
|
|
continue
|
|
}
|
|
s.Price = price
|
|
s.PrevClose = prevClose
|
|
s.ChangePct = changePct
|
|
results = append(results, s)
|
|
}
|
|
return results
|
|
}
|
|
|
|
func (p *MarketPlugin) loadSummary(date string) string {
|
|
d := db.Get()
|
|
var summary *string
|
|
err := d.QueryRow(`SELECT summary FROM market_daily_summary WHERE snapshot_date = ?`, date).Scan(&summary)
|
|
if err != nil || summary == nil {
|
|
return ""
|
|
}
|
|
return *summary
|
|
}
|
|
|
|
// loadSnapshotRange returns snapshots grouped by date for a date range.
|
|
func (p *MarketPlugin) loadSnapshotRange(startDate, endDate string) map[string][]marketSnapshot {
|
|
d := db.Get()
|
|
rows, err := d.Query(
|
|
`SELECT snapshot_date, symbol, display_name, price, prev_close, change_pct, source
|
|
FROM market_snapshots WHERE snapshot_date >= ? AND snapshot_date <= ?
|
|
ORDER BY snapshot_date, ROWID`, startDate, endDate)
|
|
if err != nil {
|
|
slog.Error("market: load range", "err", err)
|
|
return nil
|
|
}
|
|
defer rows.Close()
|
|
|
|
result := make(map[string][]marketSnapshot)
|
|
for rows.Next() {
|
|
var date string
|
|
var s marketSnapshot
|
|
var price, prevClose, changePct *float64
|
|
if err := rows.Scan(&date, &s.Symbol, &s.DisplayName, &price, &prevClose, &changePct, &s.Source); err != nil {
|
|
continue
|
|
}
|
|
s.Price = price
|
|
s.PrevClose = prevClose
|
|
s.ChangePct = changePct
|
|
result[date] = append(result[date], s)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// mostRecentSnapshotDate returns the most recent date with data, or empty string.
|
|
func (p *MarketPlugin) mostRecentSnapshotDate() string {
|
|
d := db.Get()
|
|
var date string
|
|
err := d.QueryRow(`SELECT snapshot_date FROM market_snapshots ORDER BY snapshot_date DESC LIMIT 1`).Scan(&date)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return date
|
|
}
|
|
|
|
// ── Command: !howsthemarket ──────────────────────────────────────────────────
|
|
|
|
func (p *MarketPlugin) handleSnapshot(ctx MessageContext) error {
|
|
if !p.IsAdmin(ctx.Sender) && !p.checkCooldown(ctx.RoomID) {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Market data was just posted. Try again in a few minutes.")
|
|
}
|
|
|
|
text := p.formatSnapshot("")
|
|
if text == "" {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "No market data available yet. Data pulls at 23:00 UTC.")
|
|
}
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, text)
|
|
}
|
|
|
|
// formatSnapshot builds the snapshot text. If date is empty, uses the most recent available.
|
|
func (p *MarketPlugin) formatSnapshot(date string) string {
|
|
today := time.Now().UTC().Format("2006-01-02")
|
|
showingStale := false
|
|
|
|
if date == "" {
|
|
date = today
|
|
}
|
|
|
|
snapshots := p.loadSnapshots(date)
|
|
if len(snapshots) == 0 {
|
|
// Try most recent
|
|
date = p.mostRecentSnapshotDate()
|
|
if date == "" {
|
|
return ""
|
|
}
|
|
snapshots = p.loadSnapshots(date)
|
|
if len(snapshots) == 0 {
|
|
return ""
|
|
}
|
|
showingStale = true
|
|
}
|
|
|
|
// Check if today's data exists but we were given today
|
|
if date == today && len(snapshots) == 0 {
|
|
date = p.mostRecentSnapshotDate()
|
|
snapshots = p.loadSnapshots(date)
|
|
showingStale = true
|
|
}
|
|
|
|
if date != today {
|
|
showingStale = true
|
|
}
|
|
|
|
summary := p.loadSummary(date)
|
|
|
|
var sb strings.Builder
|
|
|
|
if showingStale {
|
|
sb.WriteString(fmt.Sprintf("_Showing end-of-day data from %s. Today's data pulls at 23:00 UTC._\n\n", date))
|
|
}
|
|
|
|
if summary != "" {
|
|
sb.WriteString(fmt.Sprintf("**Market Snapshot** — %s (end of day)\n\n", date))
|
|
sb.WriteString(summary)
|
|
sb.WriteString("\n\n")
|
|
// Also show the numbers
|
|
for _, s := range snapshots {
|
|
if s.Price != nil && s.ChangePct != nil {
|
|
emoji := marketChangeEmoji(*s.ChangePct)
|
|
sb.WriteString(fmt.Sprintf("%s %-10s %s %s\n",
|
|
emoji, s.DisplayName, formatPrice(*s.Price), formatChangePct(*s.ChangePct)))
|
|
} else {
|
|
sb.WriteString(fmt.Sprintf(" %-10s — unavailable\n", s.DisplayName))
|
|
}
|
|
}
|
|
} else {
|
|
// Numbers-only fallback
|
|
sb.WriteString(fmt.Sprintf("**Market Snapshot** — %s (end of day)\n\n", date))
|
|
for _, s := range snapshots {
|
|
if s.Price != nil && s.ChangePct != nil {
|
|
emoji := marketChangeEmoji(*s.ChangePct)
|
|
sb.WriteString(fmt.Sprintf("%s %-10s %s %s\n",
|
|
emoji, s.DisplayName, formatPrice(*s.Price), formatChangePct(*s.ChangePct)))
|
|
} else {
|
|
sb.WriteString(fmt.Sprintf(" %-10s — unavailable\n", s.DisplayName))
|
|
}
|
|
}
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
func marketChangeEmoji(pct float64) string {
|
|
if math.Abs(pct) < 0.10 {
|
|
return "➡️"
|
|
}
|
|
if pct > 0 {
|
|
return "📈"
|
|
}
|
|
return "📉"
|
|
}
|
|
|
|
func formatPrice(price float64) string {
|
|
s := fmt.Sprintf("%.2f", price)
|
|
parts := strings.SplitN(s, ".", 2)
|
|
intPart := parts[0]
|
|
neg := ""
|
|
if strings.HasPrefix(intPart, "-") {
|
|
neg = "-"
|
|
intPart = intPart[1:]
|
|
}
|
|
if len(intPart) > 3 {
|
|
var buf strings.Builder
|
|
for i, c := range intPart {
|
|
if i > 0 && (len(intPart)-i)%3 == 0 {
|
|
buf.WriteByte(',')
|
|
}
|
|
buf.WriteRune(c)
|
|
}
|
|
intPart = buf.String()
|
|
}
|
|
return neg + intPart + "." + parts[1]
|
|
}
|
|
|
|
func formatChangePct(pct float64) string {
|
|
return fmt.Sprintf("%+.2f%%", pct)
|
|
}
|
|
|
|
// ── Command: !marketstatus ───────────────────────────────────────────────────
|
|
|
|
type exchangeSchedule struct {
|
|
Name string
|
|
Covers string
|
|
Timezone string // IANA timezone
|
|
OpenHour int
|
|
OpenMin int
|
|
CloseHour int
|
|
CloseMin int
|
|
// Optional lunch break (TSE)
|
|
HasLunch bool
|
|
LunchStartHour int
|
|
LunchStartMin int
|
|
LunchEndHour int
|
|
LunchEndMin int
|
|
}
|
|
|
|
var exchanges = []exchangeSchedule{
|
|
{
|
|
Name: "NYSE / NASDAQ", Covers: "S&P 500, NASDAQ, DOW",
|
|
Timezone: "America/New_York",
|
|
OpenHour: 9, OpenMin: 30, CloseHour: 16, CloseMin: 0,
|
|
},
|
|
{
|
|
Name: "LSE", Covers: "FTSE 100",
|
|
Timezone: "Europe/London",
|
|
OpenHour: 8, OpenMin: 0, CloseHour: 16, CloseMin: 30,
|
|
},
|
|
{
|
|
Name: "Euronext", Covers: "PSI 20",
|
|
Timezone: "Europe/Lisbon",
|
|
OpenHour: 8, OpenMin: 0, CloseHour: 16, CloseMin: 30,
|
|
},
|
|
{
|
|
Name: "TSE", Covers: "Nikkei 225",
|
|
Timezone: "Asia/Tokyo",
|
|
OpenHour: 9, OpenMin: 0, CloseHour: 15, CloseMin: 0,
|
|
HasLunch: true, LunchStartHour: 11, LunchStartMin: 30, LunchEndHour: 12, LunchEndMin: 30,
|
|
},
|
|
}
|
|
|
|
func (p *MarketPlugin) handleStatus(ctx MessageContext) error {
|
|
if !p.IsAdmin(ctx.Sender) && !p.checkCooldown(ctx.RoomID) {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Market status was just posted. Try again in a few minutes.")
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
var sb strings.Builder
|
|
sb.WriteString(fmt.Sprintf("**Market Status** — %s UTC\n\n", now.Format("15:04")))
|
|
|
|
for _, ex := range exchanges {
|
|
loc, err := time.LoadLocation(ex.Timezone)
|
|
if err != nil {
|
|
sb.WriteString(fmt.Sprintf("%-15s — timezone error\n", ex.Name))
|
|
continue
|
|
}
|
|
|
|
localNow := now.In(loc)
|
|
weekday := localNow.Weekday()
|
|
|
|
// Weekend check
|
|
if weekday == time.Saturday || weekday == time.Sunday {
|
|
nextOpen := nextWeekdayOpen(localNow, ex)
|
|
sb.WriteString(fmt.Sprintf("%-15s 🔴 Closed (weekend, opens %s)\n", ex.Name, marketFormatDuration(nextOpen.Sub(now))))
|
|
continue
|
|
}
|
|
|
|
openTime := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), ex.OpenHour, ex.OpenMin, 0, 0, loc)
|
|
closeTime := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), ex.CloseHour, ex.CloseMin, 0, 0, loc)
|
|
|
|
if localNow.Before(openTime) {
|
|
dur := openTime.Sub(now)
|
|
sb.WriteString(fmt.Sprintf("%-15s 🔴 Closed (opens in %s)\n", ex.Name, marketFormatDuration(dur)))
|
|
} else if localNow.After(closeTime) {
|
|
nextOpen := nextWeekdayOpen(localNow.AddDate(0, 0, 1), ex)
|
|
sb.WriteString(fmt.Sprintf("%-15s 🔴 Closed (opens in %s)\n", ex.Name, marketFormatDuration(nextOpen.Sub(now))))
|
|
} else if ex.HasLunch {
|
|
lunchStart := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), ex.LunchStartHour, ex.LunchStartMin, 0, 0, loc)
|
|
lunchEnd := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), ex.LunchEndHour, ex.LunchEndMin, 0, 0, loc)
|
|
if localNow.After(lunchStart) && localNow.Before(lunchEnd) {
|
|
sb.WriteString(fmt.Sprintf("%-15s 🟡 Lunch break (resumes in %s)\n", ex.Name, marketFormatDuration(lunchEnd.Sub(now))))
|
|
} else {
|
|
sb.WriteString(fmt.Sprintf("%-15s 🟢 Open\n", ex.Name))
|
|
}
|
|
} else {
|
|
sb.WriteString(fmt.Sprintf("%-15s 🟢 Open\n", ex.Name))
|
|
}
|
|
}
|
|
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
|
}
|
|
|
|
func nextWeekdayOpen(from time.Time, ex exchangeSchedule) time.Time {
|
|
loc, err := time.LoadLocation(ex.Timezone)
|
|
if err != nil {
|
|
loc = time.UTC
|
|
}
|
|
t := time.Date(from.Year(), from.Month(), from.Day(), ex.OpenHour, ex.OpenMin, 0, 0, loc)
|
|
for t.Weekday() == time.Saturday || t.Weekday() == time.Sunday {
|
|
t = t.AddDate(0, 0, 1)
|
|
}
|
|
return t
|
|
}
|
|
|
|
func marketFormatDuration(d time.Duration) string {
|
|
h := int(d.Hours())
|
|
m := int(d.Minutes()) % 60
|
|
if h > 0 {
|
|
return fmt.Sprintf("%dh %dm", h, m)
|
|
}
|
|
return fmt.Sprintf("%dm", m)
|
|
}
|
|
|
|
// ── Command: !marketreport ───────────────────────────────────────────────────
|
|
|
|
func (p *MarketPlugin) handleReport(ctx MessageContext) error {
|
|
args := strings.TrimSpace(p.GetArgs(ctx.Body, "marketreport"))
|
|
if args == "" {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
|
"Usage: `!marketreport week|month|year|vix|compare <index> <days>`")
|
|
}
|
|
|
|
if !p.IsAdmin(ctx.Sender) && !p.checkCooldown(ctx.RoomID) {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Market data was just posted. Try again in a few minutes.")
|
|
}
|
|
|
|
parts := strings.Fields(args)
|
|
sub := strings.ToLower(parts[0])
|
|
|
|
switch sub {
|
|
case "week":
|
|
return p.handleTrendReport(ctx, 7)
|
|
case "month":
|
|
return p.handleTrendReport(ctx, 30)
|
|
case "year":
|
|
return p.handleYearReport(ctx)
|
|
case "vix":
|
|
return p.handleVixReport(ctx)
|
|
case "compare":
|
|
return p.handleCompare(ctx, parts[1:])
|
|
default:
|
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
|
"Unknown report type. Try: `week`, `month`, `year`, `vix`, or `compare <index> <days>`.")
|
|
}
|
|
}
|
|
|
|
func (p *MarketPlugin) handleTrendReport(ctx MessageContext, days int) error {
|
|
now := time.Now().UTC()
|
|
startDate := now.AddDate(0, 0, -days).Format("2006-01-02")
|
|
endDate := now.Format("2006-01-02")
|
|
|
|
snapsByDate := p.loadSnapshotRange(startDate, endDate)
|
|
if len(snapsByDate) == 0 {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "No historical data available for that range.")
|
|
}
|
|
|
|
dateRange := fmt.Sprintf("%s to %s", startDate, endDate)
|
|
summary := p.generateReportSummary(snapsByDate, dateRange)
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString(fmt.Sprintf("**Market Report** — Last %d days\n\n", days))
|
|
|
|
if summary != "" {
|
|
sb.WriteString(summary)
|
|
sb.WriteString("\n\n")
|
|
}
|
|
|
|
// Show first and last data points for each index
|
|
sb.WriteString(p.formatTrendTable(snapsByDate))
|
|
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
|
}
|
|
|
|
func (p *MarketPlugin) handleYearReport(ctx MessageContext) error {
|
|
now := time.Now().UTC()
|
|
startDate := now.AddDate(-1, 0, 0).Format("2006-01-02")
|
|
endDate := now.Format("2006-01-02")
|
|
|
|
snapsByDate := p.loadSnapshotRange(startDate, endDate)
|
|
if len(snapsByDate) == 0 {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "No historical data available.")
|
|
}
|
|
|
|
dateRange := fmt.Sprintf("%s to %s", startDate, endDate)
|
|
summary := p.generateReportSummary(snapsByDate, dateRange)
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString("**Market Report** — 365 days\n\n")
|
|
|
|
if summary != "" {
|
|
sb.WriteString(summary)
|
|
sb.WriteString("\n\n")
|
|
}
|
|
|
|
// High, low, current per index
|
|
indexStats := make(map[string]struct{ High, Low, Current float64 })
|
|
for _, snaps := range snapsByDate {
|
|
for _, s := range snaps {
|
|
if s.Price == nil {
|
|
continue
|
|
}
|
|
stats, ok := indexStats[s.DisplayName]
|
|
if !ok {
|
|
stats = struct{ High, Low, Current float64 }{*s.Price, *s.Price, *s.Price}
|
|
}
|
|
if *s.Price > stats.High {
|
|
stats.High = *s.Price
|
|
}
|
|
if *s.Price < stats.Low {
|
|
stats.Low = *s.Price
|
|
}
|
|
stats.Current = *s.Price // last one wins (data is ordered by date)
|
|
indexStats[s.DisplayName] = stats
|
|
}
|
|
}
|
|
|
|
for _, idx := range marketIndices {
|
|
if stats, ok := indexStats[idx.DisplayName]; ok {
|
|
sb.WriteString(fmt.Sprintf("%-10s High: %s Low: %s Current: %s\n",
|
|
idx.DisplayName, formatPrice(stats.High), formatPrice(stats.Low), formatPrice(stats.Current)))
|
|
}
|
|
}
|
|
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
|
}
|
|
|
|
func (p *MarketPlugin) handleVixReport(ctx MessageContext) error {
|
|
now := time.Now().UTC()
|
|
startDate := now.AddDate(-1, 0, 0).Format("2006-01-02")
|
|
endDate := now.Format("2006-01-02")
|
|
|
|
d := db.Get()
|
|
rows, err := d.Query(
|
|
`SELECT snapshot_date, price, change_pct FROM market_snapshots
|
|
WHERE symbol = '^VIX' AND snapshot_date >= ? AND snapshot_date <= ? AND price IS NOT NULL
|
|
ORDER BY snapshot_date`, startDate, endDate)
|
|
if err != nil {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load VIX data.")
|
|
}
|
|
defer rows.Close()
|
|
|
|
type vixEntry struct {
|
|
Date string
|
|
Price float64
|
|
ChangePct float64
|
|
}
|
|
var entries []vixEntry
|
|
for rows.Next() {
|
|
var e vixEntry
|
|
if err := rows.Scan(&e.Date, &e.Price, &e.ChangePct); err != nil {
|
|
continue
|
|
}
|
|
entries = append(entries, e)
|
|
}
|
|
|
|
if len(entries) == 0 {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "No VIX data available.")
|
|
}
|
|
|
|
// Generate VIX-specific summary
|
|
var prices []string
|
|
for _, e := range entries {
|
|
prices = append(prices, fmt.Sprintf("%.2f", e.Price))
|
|
}
|
|
|
|
var summary string
|
|
host := os.Getenv("OLLAMA_HOST")
|
|
model := os.Getenv("OLLAMA_MODEL")
|
|
if host != "" && model != "" {
|
|
prompt := fmt.Sprintf("VIX (fear index) data over %d days (%s to %s):\n%s\n\nDescribe the fear/greed trajectory in 2-3 sentences. Be sardonic but accurate.",
|
|
len(entries), entries[0].Date, entries[len(entries)-1].Date, strings.Join(prices, ", "))
|
|
summary, _ = p.callOllamaChat(host, model, marketSystemPrompt, prompt)
|
|
}
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString("**VIX Report** — Fear & Greed\n\n")
|
|
|
|
if summary != "" {
|
|
sb.WriteString(summary)
|
|
sb.WriteString("\n\n")
|
|
}
|
|
|
|
current := entries[len(entries)-1]
|
|
high, low := entries[0].Price, entries[0].Price
|
|
for _, e := range entries {
|
|
if e.Price > high {
|
|
high = e.Price
|
|
}
|
|
if e.Price < low {
|
|
low = e.Price
|
|
}
|
|
}
|
|
|
|
sb.WriteString(fmt.Sprintf("Current: %.2f | 52w High: %.2f | 52w Low: %.2f\n", current.Price, high, low))
|
|
|
|
// Sentiment label
|
|
label := "Moderate"
|
|
if current.Price < 15 {
|
|
label = "Complacent"
|
|
} else if current.Price < 20 {
|
|
label = "Calm"
|
|
} else if current.Price < 25 {
|
|
label = "Elevated"
|
|
} else if current.Price < 30 {
|
|
label = "High Fear"
|
|
} else {
|
|
label = "Extreme Fear"
|
|
}
|
|
sb.WriteString(fmt.Sprintf("Sentiment: %s", label))
|
|
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
|
}
|
|
|
|
func (p *MarketPlugin) handleCompare(ctx MessageContext, args []string) error {
|
|
if len(args) < 2 {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
|
"Usage: `!marketreport compare <index> <days>`\nIndices: SP500, NASDAQ, DOW, FTSE, NIKKEI, PSI20, VIX")
|
|
}
|
|
|
|
idx, ok := marketShortNames[strings.ToUpper(args[0])]
|
|
if !ok {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
|
fmt.Sprintf("Unknown index '%s'. Valid: SP500, NASDAQ, DOW, FTSE, NIKKEI, PSI20, VIX", args[0]))
|
|
}
|
|
|
|
days, err := strconv.Atoi(args[1])
|
|
if err != nil || days < 1 || days > 365 {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Days must be between 1 and 365.")
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
startDate := now.AddDate(0, 0, -days).Format("2006-01-02")
|
|
endDate := now.Format("2006-01-02")
|
|
|
|
d := db.Get()
|
|
rows, err := d.Query(
|
|
`SELECT snapshot_date, price, change_pct FROM market_snapshots
|
|
WHERE symbol = ? AND snapshot_date >= ? AND snapshot_date <= ? AND price IS NOT NULL
|
|
ORDER BY snapshot_date`, idx.Symbol, startDate, endDate)
|
|
if err != nil {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load data.")
|
|
}
|
|
defer rows.Close()
|
|
|
|
type entry struct {
|
|
Date string
|
|
Price float64
|
|
ChangePct float64
|
|
}
|
|
var entries []entry
|
|
for rows.Next() {
|
|
var e entry
|
|
if err := rows.Scan(&e.Date, &e.Price, &e.ChangePct); err != nil {
|
|
continue
|
|
}
|
|
entries = append(entries, e)
|
|
}
|
|
|
|
if len(entries) == 0 {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No data for %s in that range.", idx.DisplayName))
|
|
}
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString(fmt.Sprintf("**%s** — %d day report\n\n", idx.DisplayName, days))
|
|
|
|
// Generate LLM summary
|
|
var prices []string
|
|
for _, e := range entries {
|
|
prices = append(prices, fmt.Sprintf("%.2f", e.Price))
|
|
}
|
|
host := os.Getenv("OLLAMA_HOST")
|
|
model := os.Getenv("OLLAMA_MODEL")
|
|
if host != "" && model != "" {
|
|
prompt := fmt.Sprintf("%s over %d days (%s to %s):\n%s\n\nDescribe the trend in 2-3 sentences. Be sardonic but accurate.",
|
|
idx.DisplayName, len(entries), entries[0].Date, entries[len(entries)-1].Date, strings.Join(prices, ", "))
|
|
if summary, err := p.callOllamaChat(host, model, marketSystemPrompt, prompt); err == nil && summary != "" {
|
|
sb.WriteString(summary)
|
|
sb.WriteString("\n\n")
|
|
}
|
|
}
|
|
|
|
first := entries[0]
|
|
last := entries[len(entries)-1]
|
|
var totalChange float64
|
|
if first.Price != 0 {
|
|
totalChange = ((last.Price - first.Price) / first.Price) * 100
|
|
}
|
|
|
|
high, low := first.Price, first.Price
|
|
for _, e := range entries {
|
|
if e.Price > high {
|
|
high = e.Price
|
|
}
|
|
if e.Price < low {
|
|
low = e.Price
|
|
}
|
|
}
|
|
|
|
sb.WriteString(fmt.Sprintf("Start: %s (%s) → Current: %s (%s)\n",
|
|
formatPrice(first.Price), first.Date, formatPrice(last.Price), last.Date))
|
|
sb.WriteString(fmt.Sprintf("Change: %+.2f%% | High: %s | Low: %s\n",
|
|
totalChange, formatPrice(high), formatPrice(low)))
|
|
sb.WriteString(fmt.Sprintf("Data points: %d", len(entries)))
|
|
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
|
}
|
|
|
|
// ── Trend Table Helper ───────────────────────────────────────────────────────
|
|
|
|
func (p *MarketPlugin) formatTrendTable(snapsByDate map[string][]marketSnapshot) string {
|
|
// Get first and last dates
|
|
var dates []string
|
|
for d := range snapsByDate {
|
|
dates = append(dates, d)
|
|
}
|
|
for i := 0; i < len(dates); i++ {
|
|
for j := i + 1; j < len(dates); j++ {
|
|
if dates[j] < dates[i] {
|
|
dates[i], dates[j] = dates[j], dates[i]
|
|
}
|
|
}
|
|
}
|
|
if len(dates) < 2 {
|
|
return ""
|
|
}
|
|
|
|
firstDate := dates[0]
|
|
lastDate := dates[len(dates)-1]
|
|
first := snapsByDate[firstDate]
|
|
last := snapsByDate[lastDate]
|
|
|
|
// Map by symbol
|
|
firstMap := make(map[string]*marketSnapshot)
|
|
lastMap := make(map[string]*marketSnapshot)
|
|
for i := range first {
|
|
firstMap[first[i].Symbol] = &first[i]
|
|
}
|
|
for i := range last {
|
|
lastMap[last[i].Symbol] = &last[i]
|
|
}
|
|
|
|
var sb strings.Builder
|
|
for _, idx := range marketIndices {
|
|
f, okF := firstMap[idx.Symbol]
|
|
l, okL := lastMap[idx.Symbol]
|
|
if !okF || !okL || f.Price == nil || l.Price == nil || *f.Price == 0 {
|
|
continue
|
|
}
|
|
change := ((*l.Price - *f.Price) / *f.Price) * 100
|
|
emoji := marketChangeEmoji(change)
|
|
sb.WriteString(fmt.Sprintf("%s %-10s %s → %s (%+.2f%%)\n",
|
|
emoji, idx.DisplayName, formatPrice(*f.Price), formatPrice(*l.Price), change))
|
|
}
|
|
|
|
return sb.String()
|
|
}
|