Files
gogobee/FOREX_PLUGIN.md
prosolis 9e1a1f606c Adv 2.0 D&D layer: Phases 1-8 + Phase 9 SP1+SP2
Combines all uncommitted D&D work into one checkpoint:

Phases 1-8 (per gogobee_dnd_session_summary.md):
- Race/class/stats system, !setup flow, !sheet, !respec
- d20-vs-AC combat with race/class passives, auto-migration
- XP/level-up, skill checks, NPC refund hooks
- Equipment slot mapping, rarity, !rest short/long
- Pre-arm active abilities, resource pools
- Bestiary, !roll/!stats/!level, onboarding DM
- Audit fixes A-I, flavor wiring under internal/flavor/
- Phase 8 weapon dice + armor AC tables (gogobee_equipment_appendix)

Phase 9 SP1 — spell system foundations:
- 79 SpellDefinitions (76 in-scope + 3 reaction-deferred)
- dnd_known_spells, dnd_spell_slots tables
- pending_cast / concentration_* columns on dnd_character
- Slot tables for Mage/Cleric/Ranger, DC + attack-bonus math
- Long-rest refreshes slots; respec wipes spell state
- Auto-grant default known list on !setup confirm and auto-migration

Phase 9 SP2 — !cast / !spells / !prepare commands:
- Out-of-combat HEAL and UTILITY resolve immediately
- Damage/control/buff queue as pending_cast for next combat
- Audit-style save-then-debit, slot refund on save failure
- !cast --drop, concentration supersession, prep-cap enforcement
- Reaction spells refused with Phase 11 note

SP3 (combat-time resolution of pending_cast) and SP4 (full prep/learn
tests) are next. Build clean, full test suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:21 -07:00

7.0 KiB
Raw Blame History

GogoBee — forex Plugin

Design document for Claude Code integration.


Purpose

Tracks USD exchange rates against EUR and JPY using the Frankfurter API (ECB-sourced, no API key required). Stores daily history in bbolt, computes buy signals, and exposes Matrix commands for rate checks, full reports, and threshold alerts.


File Layout

Place all files under plugins/forex/ in the GogoBee module tree.

plugins/forex/
├── forex.go       # Plugin struct, Start/Stop, HandleCommand, all Matrix command handlers
├── store.go       # bbolt persistence (rates + alerts)
├── analyzer.go    # Signal computation (30/90d moving averages, 52w range, buy score)
├── scheduler.go   # Frankfurter HTTP client, backfill, daily poller goroutine

Matrix Commands

All commands use the !fx prefix. Currency arguments are optional where noted and accept any currency in TrackedCurrencies.

Command Args Behavior
!fx rate [currencies...] Current rate + one-line quick signal. No args = all tracked currencies.
!fx report [currencies...] Full analysis block per currency: 30/90d averages, 52w range bar, buy score.
!fx setalert <currency> <rate> Save a threshold alert for this room. Fires when 1 USD >= rate.
!fx alerts List all active alerts in the current room, with last-fired time if applicable.
!fx delalert <currency> <rate> Remove a specific alert.

Example usage:

!fx rate                  → EUR and JPY quick signals
!fx rate JPY              → JPY only
!fx report EUR JPY        → full report for both
!fx setalert JPY 155      → alert when USD/JPY ≥ 155
!fx delalert JPY 155

Data Source

Frankfurter.app — wraps ECB reference rates. No authentication. Two endpoints used:

  • GET /latest?from=USD&to=EUR,JPY — current rates (called on-demand for !fx rate/report and daily by the poller)
  • GET /YYYY-MM-DD..YYYY-MM-DD?from=USD&to=EUR,JPY — date range (used for the one-time backfill)

Both return JSON. All currencies for a given date arrive in a single response, so there is only ever one HTTP call per operation regardless of how many currencies are tracked.


Storage (store.go)

Database: bbolt, single file, path configurable at New() call time (e.g. ./data/forex.db).

Two buckets:

rates

Key: "EUR:20240315" (currency + date in YYYYMMDD, lexicographically sortable per currency)
Value: JSON-encoded RateRecord{Date, Currency, Rate}

Range queries use bbolt cursor Seek + forward scan bounded by the end key. Each currency's history is naturally isolated by key prefix.

alerts

Key: "<roomID>:<userID>:<currency>:<threshold>" (float formatted to 6 decimal places)
Value: JSON-encoded AlertRecord{RoomID, UserID, Currency, Threshold, FiredAt}

FiredAt is a Unix timestamp (int64). Zero means never fired. Alerts are reset (set back to zero) if FiredAt is older than 24 hours — called automatically after each daily poll.


Signal Computation (analyzer.go)

Compute(store, currency, currentRate) returns a Signal struct.

Inputs gathered from store:

  • 30-day rate slice (stored records + today's live rate appended)
  • 90-day rate slice (same)
  • 52-week rate slice (same)

Derived values:

Field Calculation
Avg30 / Avg90 Arithmetic mean of respective slice
High52w / Low52w Min/max of 52-week slice
Percentile (current - low) / (high - low) * 100 — where current sits in the 52w range
Score (110) percentile/100 * 10 * 0.6 + devScore * 0.4

devScore is derived from the average deviation above the 30d and 90d moving averages, mapped to a 010 scale where +5% above average = 10, 5% = 0.

Score labels:

Score Label Emoji
≥ 8 Excellent 🟢
≥ 6 Good 🟡
≥ 4 Fair 🟠
< 4 Poor 🔴

Note on score direction: Higher score = dollar is stronger relative to history = good time to convert USD to a foreign currency. This is correct for both EUR (Portugal move) and JPY (Japan purchases).

Rate formatting: JPY prints to 2 decimal places (154.32); all other currencies to 4 (1.0842). Controlled by formatRate(currency, rate) in analyzer.go.


Daily Poller (scheduler.go)

StartDailyPoller(ctx, store, pollHourUTC, alertCallback) launches a goroutine that:

  1. Sleeps until pollHourUTC:01 UTC (default: 17 — ECB publishes ~16:00 CET)
  2. Calls FetchCurrentRates for all TrackedCurrencies
  3. Saves each rate to the store
  4. Calls store.ResetFiredAlerts() (clears alerts fired >24h ago)
  5. Calls alertCallback(rates) so forex.go can check thresholds and send Matrix notifications
  6. Sleeps until next day

Backfill (Backfill(ctx, store)) is called once at Start(). It fetches the full trailing year in one request and saves all records. Safe to call repeatedly — bbolt upserts are idempotent.


Plugin Lifecycle

// Initialization (in GogoBee main/registry)
p, err := forex.New(matrixClient, "./data/forex.db")
p.Start()   // triggers backfill + launches poller goroutine
defer p.Stop()

// Command dispatch (in message event handler)
// parts = strings.Fields(messageBody), e.g. ["!fx", "rate", "JPY"]
if parts[0] == "!fx" {
    p.HandleCommand(evt, parts[1:])
}

New takes the mautrix *mautrix.Client and the bbolt file path.
HandleCommand takes the *event.Event and the argument slice (everything after !fx).


Adding Currencies

One change required: add the ISO 4217 code to TrackedCurrencies and a CurrencyMeta entry in store.go.

var TrackedCurrencies = []string{"EUR", "JPY", "GBP"}

var CurrencyMeta = map[string]struct {
    Emoji string
    Label string
}{
    "EUR": {Emoji: "🇪🇺", Label: "Euro"},
    "JPY": {Emoji: "🇯🇵", Label: "Japanese Yen"},
    "GBP": {Emoji: "🇬🇧", Label: "British Pound"},
}

Frankfurter supports all major ISO currencies. The backfill and daily poll will automatically include any new entry on next run.


Dependencies

go.etcd.io/bbolt v1.3.9
maunium.net/go/mautrix v0.18.1   // already in GogoBee
gopkg.in/yaml.v3                 // only if config file support is added later

No CGO. Builds cleanly on Linux/ARM (Hetzner CAX11 compatible).


Notes for Integration

  • The mdToHTML function in forex.go is a minimal stub that handles **bold** and `code` only. Replace with GogoBee's existing markdown renderer if one is available — the function signature is mdToHTML(s string) string.
  • Alert FiredAt uses a 24-hour cooldown. If the daily poll fires while an alert's threshold is still met the next day, it will re-notify. This is intentional — persistent high rates should keep notifying.
  • Live rate fetch has a 10-second timeout. On failure it falls back to the most recent stored rate per currency and logs a warning. Commands will still work, but the response will note staleness via the log (not surfaced to Matrix — add if desired).