A small Go daemon that polls exchange rates, evaluates your personal rules, and sends a Telegram alert when it's a good time to convert.
It uses ExchangeRate-API, whose free tier publishes daily rates derived from ECB/central-bank reference data. That makes this a good tool for deciding when to move money on a horizon of days — "tell me if USD/RUB goes above 95" — and a poor tool for intraday trading: there is no tick data, no bid/ask, and updates land roughly once a day.
Each poll fetches every quote for your base currency in one API call (the Standard endpoint), stores the observations in SQLite, then evaluates your rules against the current rate plus history. When a rule fires it sends one Telegram message and starts a cooldown so a still-true rule doesn't alert on every poll.
poll → store → evaluate rules (with cooldown) → notify
Sign up at https://www.exchangerate-api.com/ and copy your API key from the dashboard. The free tier is enough (see Limitations).
- Message @BotFather, send
/newbot, and copy the bot token it gives you. - Send any message to your new bot, then open
https://api.telegram.org/bot<TOKEN>/getUpdatesin a browser and read thechat.idfrom the JSON. For a group, add the bot to the group and use the negative group id (e.g.-1001234567890).
cp config.example.yaml config.yaml
$EDITOR config.yamlconfig.yaml is gitignored — only config.example.yaml (placeholders) is
committed. Keep real secrets out of git.
You can put the API key and bot token in the file, or leave the placeholders and supply them via environment variables (env wins over the file):
export EXCHANGERATE_API_KEY=your-key
export TELEGRAM_BOT_TOKEN=123456:your-bot-tokenapi_base_url: https://v6.exchangerate-api.com/v6
api_key: YOUR_EXCHANGERATE_API_KEY # or env EXCHANGERATE_API_KEY
base_currency: USD # the base polled from the provider
rules:
- pair: USD/RUB
kind: above
params: { threshold: 95 }
cooldown: 24h
telegram:
bot_token: YOUR_TELEGRAM_BOT_TOKEN # or env TELEGRAM_BOT_TOKEN
chat_id: "123456789"
db:
path: currency-notification.db # SQLite file; defaults to this if omitted
poll:
interval: 6h # fallback cadence (see scheduling below)base_currency — the single base fetched each poll. One Standard-endpoint
call returns every quote on that base, and each rule's pair is derived from that
map. Rules do not have to share the base: a pair like EUR/USD (inverse) or
AZN/RUB (cross-rate) is computed from the relevant base quotes — as long as
both currencies appear in the response. See Design notes.
rules[] — each rule is a pair (BASE/QUOTE), a kind, kind-specific
params, and a cooldown:
| kind | fires when… | params |
|---|---|---|
above |
current value > threshold |
threshold |
below |
current value < threshold |
threshold |
cross |
value moves across threshold vs. the last observation (either direction) |
threshold |
change |
value moves by ≥ percent over the trailing window |
percent, window_days |
cross and change need history, which accrues automatically as the daemon
runs. Comparisons are strict, so a value exactly on the threshold does not fire.
cooldown — after a rule fires, re-alerts are suppressed for this long (Go
duration: 24h, 12h, 90m). Last-fired state is persisted in SQLite, so
cooldown survives restarts.
poll.interval — the fallback poll cadence. Normally the scheduler polls
just after the provider's published next-update time; this interval is only used
when that time is unavailable or already past.
Build:
go build -o bin/currency-notification ./cmd/currency-notification--once runs a single poll+evaluate and exits 0 on success, non-zero on error.
Good for cron, since the free tier only updates once a day:
# every day at 08:15, poll and alert
15 8 * * * EXCHANGERATE_API_KEY=… TELEGRAM_BOT_TOKEN=… /opt/currency-notification/bin/currency-notification --once --config /opt/currency-notification/config.yamlWith no --once, it runs a loop: poll, then sleep until the next scheduled poll,
exiting cleanly on SIGINT/SIGTERM.
systemd (/etc/systemd/system/currency-notification.service):
[Unit]
Description=currency-notification FX alerter
After=network-online.target
[Service]
WorkingDirectory=/opt/currency-notification
Environment=EXCHANGERATE_API_KEY=your-key
Environment=TELEGRAM_BOT_TOKEN=123456:your-bot-token
ExecStart=/opt/currency-notification/bin/currency-notification --config /opt/currency-notification/config.yaml
Restart=on-failure
[Install]
WantedBy=multi-user.targetDocker — mount the real config and pass secrets as env; persist the SQLite file on a volume so cooldown state survives restarts:
docker build -t currency-notification .
docker run -d --name currency-notification \
-e EXCHANGERATE_API_KEY=your-key \
-e TELEGRAM_BOT_TOKEN=123456:your-bot-token \
-v "$PWD/config.yaml:/app/config.yaml:ro" \
-v currency-notification-data:/app \
currency-notificationRateProvideris a port. The rule engine depends on an interface, not on ExchangeRate-API. Swapping providers (or using the in-memory mock in tests) needs no change todomain/app.- Quota-aware polling. The response carries
time_next_update_unix; the scheduler waits until then (plus a few-minute buffer, so the new rate is actually published before we fetch) instead of polling blindly. One Standard call covers all pairs on the base — a handful of calls a day, far under quota. - Cross-rate derivation. The provider returns only base→quote rates, but
rules can name any pair:
DeriveRatecomputesFROM/TO = (base→TO) / (base→FROM), which yields direct pairs, inverses (EUR/USDfrom a USD base), and cross-rates (AZN/RUB=USD/RUB ÷ USD/AZN) from that single call. The derived rate for each rule's pair is persisted, so history-based rules (cross/change) work on cross-rates too. - Cooldown / hysteresis. After firing, a rule is suppressed for its cooldown
window;
crossonly alerts on the crossing edge, not while it stays across. Last-fired state lives in SQLite, so restarts don't cause a re-alert storm. - Injectable
Clock.domainandappnever calltime.Now()— time is a port. The rule engine and cooldown logic are fully deterministic under test.
- Daily, not intraday. The free tier updates about once per day from ECB reference rates. Polling more often just re-reads the same numbers. This is a "when to convert over days" tool, not a trading feed — no bid/ask, no ticks.
- USD/AZN barely moves. The Azerbaijani manat is effectively pegged to the US
dollar (~1.70), so
above/below/changerules onUSD/AZNwill rarely, if ever, fire. Put your rules where there's real movement —USD/RUB,EUR/USD, or the derived cross-rateAZN/RUB(which tracks RUB, not the peg).
make test # go test ./... -race
make lint # golangci-lint run
make vet # go vet ./...
make run # go run ./cmd/currency-notification --config config.yamlDefault tests use in-memory mock adapters and an injected clock — no network and no real API key required.