mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 08:52:42 +00:00
Add CAD currency, forex cross-pair support, and adventure timer fixes
Forex: add CAD to tracked currencies, add cross-pair syntax (EUR/USD, EUR|JPY, etc.) for both !fx rate and !fx report with full historical analysis computed from stored USD-base rates. Adventure: fix midnight reset silently failing due to SQLite busy contention with the reminder fire loop — propagate errors so the job isn't marked complete on failure, add retry with backoff, and add startup catch-up for missed resets and expired death timers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -531,8 +531,10 @@ Three activity types across 5 tiers of locations (15 total). Higher tiers requir
|
|||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `!stock <ticker>` | Stock quote (Finnhub) |
|
| `!stock <ticker>` | Stock quote (Finnhub) |
|
||||||
| `!stockwatch add\|list\|remove` | Stock watchlist |
|
| `!stockwatch add\|list\|remove` | Stock watchlist |
|
||||||
| `!fx rate [EUR\|JPY]` | Live USD exchange rate + quick signal |
|
| `!fx rate [EUR\|JPY\|CAD]` | Live USD exchange rate + quick signal |
|
||||||
| `!fx report [EUR\|JPY]` | Full analysis (averages, 52w range, buy score) |
|
| `!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 <currency> <rate>` | DM alert when rate hits threshold |
|
| `!fx setalert <currency> <rate>` | DM alert when rate hits threshold |
|
||||||
| `!fx alerts` | List your active alerts |
|
| `!fx alerts` | List your active alerts |
|
||||||
| `!fx delalert <currency> <rate>` | Remove an alert |
|
| `!fx delalert <currency> <rate>` | Remove an alert |
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
"maunium.net/go/mautrix"
|
"maunium.net/go/mautrix"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
)
|
)
|
||||||
@@ -74,6 +76,17 @@ func (p *AdventurePlugin) Init() error {
|
|||||||
slog.Info("adventure: rehydrated DM rooms", "count", len(chars))
|
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
|
// Start schedulers
|
||||||
go p.morningTicker()
|
go p.morningTicker()
|
||||||
go p.summaryTicker()
|
go p.summaryTicker()
|
||||||
@@ -83,6 +96,25 @@ func (p *AdventurePlugin) Init() error {
|
|||||||
return nil
|
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 }
|
func (p *AdventurePlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||||
|
|
||||||
// ── Message Dispatch ─────────────────────────────────────────────────────────
|
// ── Message Dispatch ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -233,17 +233,19 @@ func (p *AdventurePlugin) midnightTicker() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("adventure: midnight reset")
|
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)
|
db.MarkJobCompleted(jobName, dateKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *AdventurePlugin) midnightReset() {
|
func (p *AdventurePlugin) midnightReset() error {
|
||||||
// Send idle shame DMs to players who didn't act
|
// Send idle shame DMs to players who didn't act
|
||||||
chars, err := loadAllAdvCharacters()
|
chars, err := loadAllAdvCharacters()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("adventure: midnight reset failed to load chars", "err", err)
|
return fmt.Errorf("load chars: %w", err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
today := time.Now().UTC().Format("2006-01-02")
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
@@ -281,9 +283,18 @@ func (p *AdventurePlugin) midnightReset() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset all daily actions
|
// Reset all daily actions — retry up to 3 times to handle SQLite busy errors
|
||||||
if err := resetAllAdvDailyActions(); err != nil {
|
// from concurrent writers (e.g. reminder fire loop).
|
||||||
slog.Error("adventure: failed to reset daily actions", "err", err)
|
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
|
// Prune expired buffs
|
||||||
@@ -300,6 +311,8 @@ func (p *AdventurePlugin) midnightReset() {
|
|||||||
p.dmRemindedDate.Delete(key)
|
p.dmRemindedDate.Delete(key)
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helper ───────────────────────────────────────────────────────────────────
|
// ── Helper ───────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -91,8 +91,9 @@ func (p *ForexPlugin) OnMessage(ctx MessageContext) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fxHelpText = "**Forex Commands**\n\n" +
|
const fxHelpText = "**Forex Commands**\n\n" +
|
||||||
"`!fx rate [EUR|JPY]` — current rate + quick signal\n" +
|
"`!fx rate [EUR|JPY|CAD]` — current rate + quick signal\n" +
|
||||||
"`!fx report [EUR|JPY]` — full analysis (averages, 52w range, buy score)\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 <currency> <rate>` — alert when USD/currency reaches threshold\n" +
|
"`!fx setalert <currency> <rate>` — alert when USD/currency reaches threshold\n" +
|
||||||
"`!fx alerts` — list active alerts in this room\n" +
|
"`!fx alerts` — list active alerts in this room\n" +
|
||||||
"`!fx delalert <currency> <rate>` — remove an alert\n" +
|
"`!fx delalert <currency> <rate>` — remove an alert\n" +
|
||||||
@@ -101,6 +102,11 @@ const fxHelpText = "**Forex Commands**\n\n" +
|
|||||||
// ── Command Handlers ────────────────────────────────────────────────────────
|
// ── Command Handlers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (p *ForexPlugin) cmdRate(ctx MessageContext, args []string) error {
|
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)
|
currencies := fxParseCurrencies(args)
|
||||||
rates, err := p.fxLiveRates(currencies)
|
rates, err := p.fxLiveRates(currencies)
|
||||||
if err != nil {
|
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"))
|
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 {
|
func (p *ForexPlugin) cmdReport(ctx MessageContext, args []string) error {
|
||||||
|
if pair := fxParsePair(args); pair != nil {
|
||||||
|
return p.cmdPairRateOrReport(ctx, pair, true)
|
||||||
|
}
|
||||||
currencies := fxParseCurrencies(args)
|
currencies := fxParseCurrencies(args)
|
||||||
rates, err := p.fxLiveRates(currencies)
|
rates, err := p.fxLiveRates(currencies)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import (
|
|||||||
|
|
||||||
// ForexSignal holds the computed analysis for a currency pair.
|
// ForexSignal holds the computed analysis for a currency pair.
|
||||||
type ForexSignal struct {
|
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
|
Rate float64
|
||||||
Avg30 float64
|
Avg30 float64
|
||||||
Avg90 float64
|
Avg90 float64
|
||||||
@@ -35,8 +36,14 @@ func (p *ForexPlugin) fxComputeSignal(currency string, currentRate float64) (*Fo
|
|||||||
}
|
}
|
||||||
rates[len(records)] = currentRate
|
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)
|
// 30-day and 90-day moving averages (trading days, not calendar)
|
||||||
avg30 := fxAvg(rates, 30)
|
avg30 := fxAvg(rates, 30)
|
||||||
avg90 := fxAvg(rates, 90)
|
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
|
// 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)
|
devScore := fxDeviationScore(currentRate, avg30, avg90)
|
||||||
rawScore := percentile/100*10*0.6 + devScore*0.4
|
rawScore := percentile/100*10*0.6 + devScore*0.4
|
||||||
score := int(math.Round(rawScore))
|
score := int(math.Round(rawScore))
|
||||||
@@ -74,10 +81,7 @@ func (p *ForexPlugin) fxComputeSignal(currency string, currentRate float64) (*Fo
|
|||||||
|
|
||||||
label, emoji := fxScoreLabel(score)
|
label, emoji := fxScoreLabel(score)
|
||||||
|
|
||||||
_ = n // rates slice used above
|
|
||||||
|
|
||||||
return &ForexSignal{
|
return &ForexSignal{
|
||||||
Currency: currency,
|
|
||||||
Rate: currentRate,
|
Rate: currentRate,
|
||||||
Avg30: avg30,
|
Avg30: avg30,
|
||||||
Avg90: avg90,
|
Avg90: avg90,
|
||||||
@@ -87,7 +91,84 @@ func (p *ForexPlugin) fxComputeSignal(currency string, currentRate float64) (*Fo
|
|||||||
Score: score,
|
Score: score,
|
||||||
Label: label,
|
Label: label,
|
||||||
Emoji: emoji,
|
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.
|
// 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.
|
// FormatQuick returns a one-line summary.
|
||||||
func (s *ForexSignal) FormatQuick() string {
|
func (s *ForexSignal) FormatQuick() string {
|
||||||
|
if s.Pair != nil {
|
||||||
|
return s.formatQuickPair()
|
||||||
|
}
|
||||||
meta := fxMeta[s.Currency]
|
meta := fxMeta[s.Currency]
|
||||||
return fmt.Sprintf("%s **%s** 1 USD = **%s** %s · 30d avg: %s · USD strength: %d/10 %s %s",
|
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,
|
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)
|
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.
|
// FormatReport returns a detailed analysis block.
|
||||||
func (s *ForexSignal) FormatReport() string {
|
func (s *ForexSignal) FormatReport() string {
|
||||||
|
if s.Pair != nil {
|
||||||
|
return s.formatReportPair()
|
||||||
|
}
|
||||||
meta := fxMeta[s.Currency]
|
meta := fxMeta[s.Currency]
|
||||||
var sb strings.Builder
|
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))
|
sb.WriteString(fmt.Sprintf("**Current:** 1 USD = **%s %s**\n\n", fxFormatRate(s.Currency, s.Rate), s.Currency))
|
||||||
|
|
||||||
// Moving averages
|
// Moving averages
|
||||||
sb.WriteString(fmt.Sprintf(" 30-day avg: %s", fxFormatRate(s.Currency, s.Avg30)))
|
fxWriteAvgLine(&sb, "30-day", s.Currency, s.Rate, s.Avg30)
|
||||||
if s.Rate > s.Avg30 {
|
fxWriteAvgLine(&sb, "90-day", s.Currency, s.Rate, s.Avg90)
|
||||||
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))
|
|
||||||
}
|
|
||||||
sb.WriteString("\n")
|
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
|
// 52-week range bar
|
||||||
sb.WriteString(fmt.Sprintf(" 52-week range: %s — %s\n",
|
sb.WriteString(fmt.Sprintf(" 52-week range: %s — %s\n",
|
||||||
@@ -186,6 +275,52 @@ func (s *ForexSignal) FormatReport() string {
|
|||||||
return sb.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.
|
// fxRangeBar renders a text-based position indicator.
|
||||||
func fxRangeBar(percentile float64) string {
|
func fxRangeBar(percentile float64) string {
|
||||||
width := 20
|
width := 20
|
||||||
|
|||||||
43
internal/plugin/forex_analysis_test.go
Normal file
43
internal/plugin/forex_analysis_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
// ── Currency Configuration ──────────────────────────────────────────────────
|
// ── Currency Configuration ──────────────────────────────────────────────────
|
||||||
|
|
||||||
var fxTrackedCurrencies = []string{"EUR", "JPY"}
|
var fxTrackedCurrencies = []string{"EUR", "JPY", "CAD"}
|
||||||
|
|
||||||
type fxCurrencyMeta struct {
|
type fxCurrencyMeta struct {
|
||||||
Emoji string
|
Emoji string
|
||||||
@@ -22,6 +22,7 @@ type fxCurrencyMeta struct {
|
|||||||
var fxMeta = map[string]fxCurrencyMeta{
|
var fxMeta = map[string]fxCurrencyMeta{
|
||||||
"EUR": {Emoji: "🇪🇺", Label: "Euro"},
|
"EUR": {Emoji: "🇪🇺", Label: "Euro"},
|
||||||
"JPY": {Emoji: "🇯🇵", Label: "Japanese Yen"},
|
"JPY": {Emoji: "🇯🇵", Label: "Japanese Yen"},
|
||||||
|
"CAD": {Emoji: "🇨🇦", Label: "Canadian Dollar"},
|
||||||
}
|
}
|
||||||
|
|
||||||
func fxIsTracked(cur string) bool {
|
func fxIsTracked(cur string) bool {
|
||||||
|
|||||||
Reference in New Issue
Block a user