diff --git a/README.md b/README.md index 202f8c0..87e109c 100644 --- a/README.md +++ b/README.md @@ -531,8 +531,10 @@ Three activity types across 5 tiers of locations (15 total). Higher tiers requir |---------|-------------| | `!stock ` | Stock quote (Finnhub) | | `!stockwatch add\|list\|remove` | Stock watchlist | -| `!fx rate [EUR\|JPY]` | Live USD exchange rate + quick signal | -| `!fx report [EUR\|JPY]` | Full analysis (averages, 52w range, buy score) | +| `!fx rate [EUR\|JPY\|CAD]` | Live USD exchange rate + quick signal | +| `!fx rate EUR/USD` | Cross-pair rate from first currency's perspective | +| `!fx report [EUR\|JPY\|CAD]` | Full analysis (averages, 52w range, buy score) | +| `!fx report EUR/JPY` | Cross-pair full analysis with computed history | | `!fx setalert ` | DM alert when rate hits threshold | | `!fx alerts` | List your active alerts | | `!fx delalert ` | Remove an alert | diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 11dfbde..1474ede 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -9,6 +9,8 @@ import ( "sync" "time" + "gogobee/internal/db" + "maunium.net/go/mautrix" "maunium.net/go/mautrix/id" ) @@ -74,6 +76,17 @@ func (p *AdventurePlugin) Init() error { slog.Info("adventure: rehydrated DM rooms", "count", len(chars)) } + // Catch up on missed jobs (e.g. after a redeploy or SQLite busy failure) + dateKey := time.Now().UTC().Format("2006-01-02") + if !db.JobCompleted("adventure_midnight", dateKey) { + slog.Info("adventure: missed midnight reset detected, running catch-up") + if err := resetAllAdvDailyActions(); err != nil { + slog.Error("adventure: catch-up daily reset failed", "err", err) + } + } + // Revive any characters whose DeadUntil has expired + p.catchUpRespawns(chars) + // Start schedulers go p.morningTicker() go p.summaryTicker() @@ -83,6 +96,25 @@ func (p *AdventurePlugin) Init() error { return nil } +func (p *AdventurePlugin) catchUpRespawns(chars []AdventureCharacter) { + now := time.Now().UTC() + for _, char := range chars { + if !char.Alive && char.DeadUntil != nil && now.After(*char.DeadUntil) { + char.Alive = true + char.DeadUntil = nil + if err := saveAdvCharacter(&char); err != nil { + slog.Error("adventure: catch-up revive failed", "user", char.UserID, "err", err) + continue + } + slog.Info("adventure: catch-up revived player", "user", char.UserID) + text := renderAdvRespawnDM(&char) + if err := p.SendDM(char.UserID, text); err != nil { + slog.Error("adventure: catch-up respawn DM failed", "user", char.UserID, "err", err) + } + } + } +} + func (p *AdventurePlugin) OnReaction(_ ReactionContext) error { return nil } // ── Message Dispatch ───────────────────────────────────────────────────────── diff --git a/internal/plugin/adventure_scheduler.go b/internal/plugin/adventure_scheduler.go index b664e78..a2f3784 100644 --- a/internal/plugin/adventure_scheduler.go +++ b/internal/plugin/adventure_scheduler.go @@ -233,17 +233,19 @@ func (p *AdventurePlugin) midnightTicker() { } slog.Info("adventure: midnight reset") - p.midnightReset() + if err := p.midnightReset(); err != nil { + slog.Error("adventure: midnight reset failed, will retry next tick", "err", err) + continue + } db.MarkJobCompleted(jobName, dateKey) } } -func (p *AdventurePlugin) midnightReset() { +func (p *AdventurePlugin) midnightReset() error { // Send idle shame DMs to players who didn't act chars, err := loadAllAdvCharacters() if err != nil { - slog.Error("adventure: midnight reset failed to load chars", "err", err) - return + return fmt.Errorf("load chars: %w", err) } today := time.Now().UTC().Format("2006-01-02") @@ -281,9 +283,18 @@ func (p *AdventurePlugin) midnightReset() { } } - // Reset all daily actions - if err := resetAllAdvDailyActions(); err != nil { - slog.Error("adventure: failed to reset daily actions", "err", err) + // Reset all daily actions — retry up to 3 times to handle SQLite busy errors + // from concurrent writers (e.g. reminder fire loop). + var resetErr error + for attempt := 0; attempt < 3; attempt++ { + if resetErr = resetAllAdvDailyActions(); resetErr == nil { + break + } + slog.Warn("adventure: daily action reset failed, retrying", "attempt", attempt+1, "err", resetErr) + time.Sleep(time.Duration(attempt+1) * 2 * time.Second) + } + if resetErr != nil { + return fmt.Errorf("reset daily actions after 3 attempts: %w", resetErr) } // Prune expired buffs @@ -300,6 +311,8 @@ func (p *AdventurePlugin) midnightReset() { p.dmRemindedDate.Delete(key) return true }) + + return nil } // ── Helper ─────────────────────────────────────────────────────────────────── diff --git a/internal/plugin/forex.go b/internal/plugin/forex.go index 4dd85f6..ab3c19d 100644 --- a/internal/plugin/forex.go +++ b/internal/plugin/forex.go @@ -91,8 +91,9 @@ func (p *ForexPlugin) OnMessage(ctx MessageContext) error { } const fxHelpText = "**Forex Commands**\n\n" + - "`!fx rate [EUR|JPY]` — current rate + quick signal\n" + - "`!fx report [EUR|JPY]` — full analysis (averages, 52w range, buy score)\n" + + "`!fx rate [EUR|JPY|CAD]` — current rate + quick signal\n" + + "`!fx rate EUR/USD` — cross-pair rate from first currency's perspective\n" + + "`!fx report [EUR|JPY|CAD]` — full analysis (averages, 52w range, buy score)\n" + "`!fx setalert ` — alert when USD/currency reaches threshold\n" + "`!fx alerts` — list active alerts in this room\n" + "`!fx delalert ` — remove an alert\n" + @@ -101,6 +102,11 @@ const fxHelpText = "**Forex Commands**\n\n" + // ── Command Handlers ──────────────────────────────────────────────────────── func (p *ForexPlugin) cmdRate(ctx MessageContext, args []string) error { + // Check for cross-pair syntax like EUR/USD or USD/JPY + if pair := fxParsePair(args); pair != nil { + return p.cmdPairRateOrReport(ctx, pair, false) + } + currencies := fxParseCurrencies(args) rates, err := p.fxLiveRates(currencies) if err != nil { @@ -123,7 +129,113 @@ func (p *ForexPlugin) cmdRate(ctx MessageContext, args []string) error { return p.SendReply(ctx.RoomID, ctx.EventID, strings.Join(lines, "\n")) } +// fxPair represents a currency cross-pair like EUR/USD. +type fxPair struct { + Base string // first currency (perspective) + Quote string // second currency +} + +// fxParsePair detects "XXX/YYY" syntax in args. Both sides must be USD or a +// tracked currency. Returns nil if no pair is found. +func fxParsePair(args []string) *fxPair { + if len(args) == 0 { + return nil + } + raw := strings.ToUpper(args[0]) + // Accept common pair separators: EUR/USD, EUR|USD, EUR-USD + var parts []string + for _, sep := range []string{"/", "|", "-"} { + if strings.Contains(raw, sep) { + parts = strings.SplitN(raw, sep, 2) + break + } + } + if len(parts) != 2 { + return nil + } + base, quote := parts[0], parts[1] + if !fxIsSupported(base) || !fxIsSupported(quote) { + return nil + } + if base == quote { + return nil + } + return &fxPair{Base: base, Quote: quote} +} + +// fxIsSupported returns true if the currency is USD or a tracked currency. +func fxIsSupported(cur string) bool { + return cur == "USD" || fxIsTracked(cur) +} + +// cmdPairRateOrReport handles cross-pair display for both rate and report. +func (p *ForexPlugin) cmdPairRateOrReport(ctx MessageContext, pair *fxPair, full bool) error { + currentRate, err := p.fxLivePairRate(pair) + if err != nil { + return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Could not fetch rates: %v", err)) + } + + sig, err := fxComputePairSignal(pair, currentRate) + if err != nil { + // Fall back to just the rate if insufficient history + meta := fxMeta[pair.Base] + emoji := meta.Emoji + if emoji == "" { + emoji = "🇺🇸" + } + return p.SendReply(ctx.RoomID, ctx.EventID, + fmt.Sprintf("%s **%s/%s** 1 %s = **%s** %s (insufficient data for analysis)", + emoji, pair.Base, pair.Quote, pair.Base, fxFormatRate(pair.Quote, currentRate), pair.Quote)) + } + + if full { + return p.SendReply(ctx.RoomID, ctx.EventID, sig.FormatReport()) + } + return p.SendReply(ctx.RoomID, ctx.EventID, sig.FormatQuick()) +} + +// fxLivePairRate computes the live cross-pair rate from USD-base rates. +func (p *ForexPlugin) fxLivePairRate(pair *fxPair) (float64, error) { + var currencies []string + if pair.Base != "USD" { + currencies = append(currencies, pair.Base) + } + if pair.Quote != "USD" { + currencies = append(currencies, pair.Quote) + } + + rates, err := p.fxLiveRates(currencies) + if err != nil { + return 0, err + } + + switch { + case pair.Base == "USD": + r, ok := rates[pair.Quote] + if !ok { + return 0, fmt.Errorf("no rate data for %s", pair.Quote) + } + return r, nil + case pair.Quote == "USD": + r, ok := rates[pair.Base] + if !ok || r == 0 { + return 0, fmt.Errorf("no rate data for %s", pair.Base) + } + return 1.0 / r, nil + default: + br, ok1 := rates[pair.Base] + qr, ok2 := rates[pair.Quote] + if !ok1 || !ok2 || br == 0 { + return 0, fmt.Errorf("missing rate data for cross-pair") + } + return qr / br, nil + } +} + func (p *ForexPlugin) cmdReport(ctx MessageContext, args []string) error { + if pair := fxParsePair(args); pair != nil { + return p.cmdPairRateOrReport(ctx, pair, true) + } currencies := fxParseCurrencies(args) rates, err := p.fxLiveRates(currencies) if err != nil { diff --git a/internal/plugin/forex_analysis.go b/internal/plugin/forex_analysis.go index cdfd349..8b9f893 100644 --- a/internal/plugin/forex_analysis.go +++ b/internal/plugin/forex_analysis.go @@ -8,7 +8,8 @@ import ( // ForexSignal holds the computed analysis for a currency pair. type ForexSignal struct { - Currency string + Currency string // single currency (e.g. "EUR") for USD-base signals + Pair *fxPair // non-nil for cross-pair signals Rate float64 Avg30 float64 Avg90 float64 @@ -35,8 +36,14 @@ func (p *ForexPlugin) fxComputeSignal(currency string, currentRate float64) (*Fo } rates[len(records)] = currentRate - n := len(rates) + sig := fxComputeSignalFromRates(rates, currentRate) + sig.Currency = currency + return sig, nil +} +// fxComputeSignalFromRates computes the signal from a raw rate series. +// The rates slice should be in chronological order with currentRate as the last element. +func fxComputeSignalFromRates(rates []float64, currentRate float64) *ForexSignal { // 30-day and 90-day moving averages (trading days, not calendar) avg30 := fxAvg(rates, 30) avg90 := fxAvg(rates, 90) @@ -61,7 +68,7 @@ func (p *ForexPlugin) fxComputeSignal(currency string, currentRate float64) (*Fo } // Score: weighted combination of percentile position and deviation from averages - // Higher = USD is stronger = better time to convert + // Higher = base currency is stronger devScore := fxDeviationScore(currentRate, avg30, avg90) rawScore := percentile/100*10*0.6 + devScore*0.4 score := int(math.Round(rawScore)) @@ -74,10 +81,7 @@ func (p *ForexPlugin) fxComputeSignal(currency string, currentRate float64) (*Fo label, emoji := fxScoreLabel(score) - _ = n // rates slice used above - return &ForexSignal{ - Currency: currency, Rate: currentRate, Avg30: avg30, Avg90: avg90, @@ -87,7 +91,84 @@ func (p *ForexPlugin) fxComputeSignal(currency string, currentRate float64) (*Fo Score: score, Label: label, Emoji: emoji, - }, nil + } +} + +// fxComputePairSignal computes a signal for a cross-pair by combining stored +// USD-base histories. For pairs involving USD, it inverts or uses the rate +// directly. For non-USD crosses (e.g. EUR/JPY), it joins by date and divides. +func fxComputePairSignal(pair *fxPair, currentRate float64) (*ForexSignal, error) { + var sig *ForexSignal + var err error + + if pair.Base == "USD" { + sig, err = fxComputePairSignalSingleCurrency(pair.Quote, currentRate, false) + } else if pair.Quote == "USD" { + sig, err = fxComputePairSignalSingleCurrency(pair.Base, currentRate, true) + } else { + sig, err = fxComputePairSignalCross(pair, currentRate) + } + if err != nil { + return nil, err + } + sig.Pair = pair + return sig, nil +} + +func fxComputePairSignalCross(pair *fxPair, currentRate float64) (*ForexSignal, error) { + // Non-USD cross: load both, join by date, compute cross-rates + baseRecords, err := fxGetRatesByLimit(pair.Base, 260) + if err != nil || len(baseRecords) < 10 { + return nil, fmt.Errorf("insufficient data for %s", pair.Base) + } + quoteRecords, err := fxGetRatesByLimit(pair.Quote, 260) + if err != nil || len(quoteRecords) < 10 { + return nil, fmt.Errorf("insufficient data for %s", pair.Quote) + } + + // Index quote rates by date for join + quoteByDate := make(map[string]float64, len(quoteRecords)) + for _, r := range quoteRecords { + quoteByDate[r.Date] = r.Rate + } + + // Compute cross-rates for matching dates + var crossRates []float64 + for _, br := range baseRecords { + if qr, ok := quoteByDate[br.Date]; ok && br.Rate != 0 { + crossRates = append(crossRates, qr/br.Rate) + } + } + if len(crossRates) < 10 { + return nil, fmt.Errorf("insufficient overlapping data for %s/%s (%d records)", pair.Base, pair.Quote, len(crossRates)) + } + + crossRates = append(crossRates, currentRate) + sig := fxComputeSignalFromRates(crossRates, currentRate) + sig.Currency = pair.Base + "/" + pair.Quote + return sig, nil +} + +// fxComputePairSignalSingleCurrency computes a signal for a pair where one side +// is USD. If invert is true, all stored rates are inverted (1/rate). +func fxComputePairSignalSingleCurrency(currency string, currentRate float64, invert bool) (*ForexSignal, error) { + records, err := fxGetRatesByLimit(currency, 260) + if err != nil || len(records) < 10 { + return nil, fmt.Errorf("insufficient data for %s (%d records)", currency, len(records)) + } + + rates := make([]float64, len(records)+1) + for i, r := range records { + if invert { + rates[i] = 1.0 / r.Rate + } else { + rates[i] = r.Rate + } + } + rates[len(records)] = currentRate + + sig := fxComputeSignalFromRates(rates, currentRate) + return sig, nil } // fxAvg computes the average of the last n values in a slice. @@ -142,6 +223,9 @@ func fxScoreLabel(score int) (string, string) { // FormatQuick returns a one-line summary. func (s *ForexSignal) FormatQuick() string { + if s.Pair != nil { + return s.formatQuickPair() + } meta := fxMeta[s.Currency] return fmt.Sprintf("%s **%s** 1 USD = **%s** %s · 30d avg: %s · USD strength: %d/10 %s %s", meta.Emoji, s.Currency, fxFormatRate(s.Currency, s.Rate), s.Currency, @@ -149,8 +233,24 @@ func (s *ForexSignal) FormatQuick() string { s.Score, s.Emoji, s.Label) } +func (s *ForexSignal) formatQuickPair() string { + meta := fxMeta[s.Pair.Base] + emoji := meta.Emoji + if emoji == "" { + emoji = "🇺🇸" + } + return fmt.Sprintf("%s **%s/%s** 1 %s = **%s** %s · 30d avg: %s · %s strength: %d/10 %s %s", + emoji, s.Pair.Base, s.Pair.Quote, + s.Pair.Base, fxFormatRate(s.Pair.Quote, s.Rate), s.Pair.Quote, + fxFormatRate(s.Pair.Quote, s.Avg30), + s.Pair.Base, s.Score, s.Emoji, s.Label) +} + // FormatReport returns a detailed analysis block. func (s *ForexSignal) FormatReport() string { + if s.Pair != nil { + return s.formatReportPair() + } meta := fxMeta[s.Currency] var sb strings.Builder @@ -158,20 +258,9 @@ func (s *ForexSignal) FormatReport() string { sb.WriteString(fmt.Sprintf("**Current:** 1 USD = **%s %s**\n\n", fxFormatRate(s.Currency, s.Rate), s.Currency)) // Moving averages - sb.WriteString(fmt.Sprintf(" 30-day avg: %s", fxFormatRate(s.Currency, s.Avg30))) - if s.Rate > s.Avg30 { - sb.WriteString(fmt.Sprintf(" _(+%.1f%% above)_", (s.Rate-s.Avg30)/s.Avg30*100)) - } else if s.Rate < s.Avg30 { - sb.WriteString(fmt.Sprintf(" _(%.1f%% below)_", (s.Rate-s.Avg30)/s.Avg30*100)) - } + fxWriteAvgLine(&sb, "30-day", s.Currency, s.Rate, s.Avg30) + fxWriteAvgLine(&sb, "90-day", s.Currency, s.Rate, s.Avg90) sb.WriteString("\n") - sb.WriteString(fmt.Sprintf(" 90-day avg: %s", fxFormatRate(s.Currency, s.Avg90))) - if s.Rate > s.Avg90 { - sb.WriteString(fmt.Sprintf(" _(+%.1f%% above)_", (s.Rate-s.Avg90)/s.Avg90*100)) - } else if s.Rate < s.Avg90 { - sb.WriteString(fmt.Sprintf(" _(%.1f%% below)_", (s.Rate-s.Avg90)/s.Avg90*100)) - } - sb.WriteString("\n\n") // 52-week range bar sb.WriteString(fmt.Sprintf(" 52-week range: %s — %s\n", @@ -186,6 +275,52 @@ func (s *ForexSignal) FormatReport() string { return sb.String() } +func (s *ForexSignal) formatReportPair() string { + meta := fxMeta[s.Pair.Base] + emoji := meta.Emoji + label := meta.Label + if emoji == "" { + emoji = "🇺🇸" + label = "US Dollar" + } + + quoteCur := s.Pair.Quote + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("%s **%s/%s — %s**\n", emoji, s.Pair.Base, s.Pair.Quote, label)) + sb.WriteString(fmt.Sprintf("**Current:** 1 %s = **%s %s**\n\n", s.Pair.Base, fxFormatRate(quoteCur, s.Rate), s.Pair.Quote)) + + // Moving averages + fxWriteAvgLine(&sb, "30-day", quoteCur, s.Rate, s.Avg30) + fxWriteAvgLine(&sb, "90-day", quoteCur, s.Rate, s.Avg90) + sb.WriteString("\n") + + // 52-week range bar + sb.WriteString(fmt.Sprintf(" 52-week range: %s — %s\n", + fxFormatRate(quoteCur, s.Low52w), fxFormatRate(quoteCur, s.High52w))) + sb.WriteString(fmt.Sprintf(" %s\n", fxRangeBar(s.Percentile))) + sb.WriteString(fmt.Sprintf(" Position: %.0f%%\n\n", s.Percentile)) + + // Score + sb.WriteString(fmt.Sprintf(" **%s Strength: %d/10** %s %s\n", s.Pair.Base, s.Score, s.Emoji, s.Label)) + sb.WriteString(fmt.Sprintf(" _Higher = %s buys more %s._", s.Pair.Base, s.Pair.Quote)) + + return sb.String() +} + +func fxWriteAvgLine(sb *strings.Builder, label, currency string, rate, avg float64) { + sb.WriteString(fmt.Sprintf(" %s avg: %s", label, fxFormatRate(currency, avg))) + if avg != 0 { + pct := (rate - avg) / avg * 100 + if pct > 0 { + sb.WriteString(fmt.Sprintf(" _(+%.1f%% above)_", pct)) + } else if pct < 0 { + sb.WriteString(fmt.Sprintf(" _(%.1f%% below)_", pct)) + } + } + sb.WriteString("\n") +} + // fxRangeBar renders a text-based position indicator. func fxRangeBar(percentile float64) string { width := 20 diff --git a/internal/plugin/forex_analysis_test.go b/internal/plugin/forex_analysis_test.go new file mode 100644 index 0000000..80374c2 --- /dev/null +++ b/internal/plugin/forex_analysis_test.go @@ -0,0 +1,43 @@ +package plugin + +import ( + "math/rand/v2" + "testing" +) + +func BenchmarkComputeSignalFromRates(b *testing.B) { + // Simulate 260 trading days of rates around 150 (JPY-like) + rates := make([]float64, 261) + for i := range rates { + rates[i] = 145 + rand.Float64()*10 + } + currentRate := rates[len(rates)-1] + + b.ResetTimer() + for b.Loop() { + fxComputeSignalFromRates(rates, currentRate) + } +} + +func BenchmarkComputeSignalFromRates_CrossPairSize(b *testing.B) { + // Simulate cross-pair: 260 days of EUR/JPY-like rates + baseRates := make([]float64, 260) + quoteRates := make([]float64, 260) + for i := range baseRates { + baseRates[i] = 0.90 + rand.Float64()*0.10 // EUR/USD ~0.90-1.00 + quoteRates[i] = 145 + rand.Float64()*10 // JPY/USD ~145-155 + } + + // Compute cross-rates (simulating the join) + crossRates := make([]float64, len(baseRates)+1) + for i := range baseRates { + crossRates[i] = quoteRates[i] / baseRates[i] + } + crossRates[len(baseRates)] = quoteRates[len(quoteRates)-1] / baseRates[len(baseRates)-1] + currentRate := crossRates[len(crossRates)-1] + + b.ResetTimer() + for b.Loop() { + fxComputeSignalFromRates(crossRates, currentRate) + } +} diff --git a/internal/plugin/forex_api.go b/internal/plugin/forex_api.go index cf2cc4d..2b20df1 100644 --- a/internal/plugin/forex_api.go +++ b/internal/plugin/forex_api.go @@ -12,7 +12,7 @@ import ( // ── Currency Configuration ────────────────────────────────────────────────── -var fxTrackedCurrencies = []string{"EUR", "JPY"} +var fxTrackedCurrencies = []string{"EUR", "JPY", "CAD"} type fxCurrencyMeta struct { Emoji string @@ -22,6 +22,7 @@ type fxCurrencyMeta struct { var fxMeta = map[string]fxCurrencyMeta{ "EUR": {Emoji: "🇪🇺", Label: "Euro"}, "JPY": {Emoji: "🇯🇵", Label: "Japanese Yen"}, + "CAD": {Emoji: "🇨🇦", Label: "Canadian Dollar"}, } func fxIsTracked(cur string) bool {