Predicts Stellar network congestion and expected base fee for the next 5/15/60 minutes, so dApps can time submissions proactively — delay a non-urgent batch operation, or warn a user before a costly action ahead of a predicted congestion spike.
This is complementary to StellarCommons/stellar-fee-tracker, not a replacement for it. stellar-fee-tracker answers "what is the fee doing right now / historically" — a reactive dashboard. This tool answers "what is the fee likely to do in the next few minutes" — a predictive signal a dApp can act on before congestion hits, not after. A production integration would reasonably use both: stellar-fee-tracker for display and historical context, stellar-fee-forecast for the timing decision.
Most Stellar fee tooling is reactive: it tells you the current fee_stats
snapshot. That's useful for setting a transaction's max_fee right now, but
it can't tell a dApp whether it's about to get worse. A batch job, a
scheduled claim, an NFT mint countdown, or any operation that isn't
time-critical to the second can benefit from a short-horizon forecast:
"congestion likelihood in the next 15 minutes is 72% — maybe wait."
Horizon (/fee_stats, /ledgers)
│ poll every 10s (configurable)
▼
backend/app/collector.py ──► rolling-window store (Postgres or SQLite)
│
▼
backend/app/model.py (Holt-Winters exponential smoothing baseline)
│
▼
backend/app/api.py (FastAPI: GET /forecast, /forecast/{5,15,60})
│
▼
hooks/ (TypeScript, useFeeForecast() — wagmi-style React hook)
- Ingestion:
app/collector.pypolls Horizon'sfee_stats(percentiles over the last 5 ledgers) and the matchingledgers/{seq}record (close time, operation/transaction counts) on a schedule, and writes a normalized row into a rolling window. SQLite by default (zero setup); pointFEEFORECAST_DATABASE_URLat Postgres for production. - Model:
app/model.py. Deliberately a solid statistical baseline, not a novel architecture — Holt (damped double exponential smoothing, viastatsmodels) on the p50 fee and capacity-usage series, extrapolated to the requested horizon. Congestion likelihood blends the trend-extrapolated capacity forecast with the recent frequency of congested readings. See Accuracy & limitations below — this is explicitly not a claim of high-precision congestion prediction. - API:
app/api.py, FastAPI.GET /forecastreturns all configured horizons;GET /forecast/{minutes}returns one.GET /healthreports how much history is stored. - Hook package:
hooks/, a small TypeScript package exportinguseFeeForecast(), matching the wagmi-style conventions used elsewhere in the Stellar hooks ecosystem (e.g.dark-princezz/stellar-hooks'susePayment/useClaimableBalanceshape:{ data, isLoading, isError, error, refetch }), plus forecast-specific conveniences (window,isCongestionLikely).
Horizon's /fee_stats endpoint only ever exposes a live snapshot of the
last 5 ledgers — it has no history endpoint. So a genuine training/backtest
dataset for the percentile series has to be built by running the collector
over time (going forward), or by backfilling the /ledgers endpoint (which
does have full history) for base_fee_in_stroops and closed_at, with fee
percentiles back-filled equal to the base fee for uncongested ledgers (see
the caveat in scripts/backfill_ledgers.py — this backfill approach can't
retroactively recover historical percentile spreads during a past
congestion event, only the live collector can capture those going forward).
This sandboxed development environment has no network route to
horizon.stellar.org, so the backtest numbers below were run against a
dataset generated by scripts/generate_synthetic_dataset.py — a script that
is explicitly and permanently labeled as synthetic in its own docstring, its
CLI output, and here. It's built from the well-documented real shape of
Stellar mainnet fee behavior (base fee flat at the 100-stroop network floor
the large majority of the time, ~5s ledger close cadence, occasional
multi-minute surge-pricing episodes with 2-30x fee jumps) — but the specific
values are simulated, not measured. Treat every number in the table below
as "the model works and beats a naive baseline on a plausible fee pattern,"
not as "here is Stellar mainnet's actual forecast accuracy."
To get real numbers: python -m scripts.backfill_ledgers --pages 20 against
a live Horizon instance (5 minutes, ~4000 real ledgers), then
python -m scripts.backtest. Full commands in SETUP.md. This is
listed as issue #1 below — regenerating the backtest against real data is
the single highest-priority follow-up for anyone building on this.
Rolling-origin backtest: at each origin point, forecast using only data
available up to that point, compare against what actually happened at
origin + horizon. Compared against a persistence baseline (predict "no
change from the last known reading") — a forecaster that can't beat
persistence isn't earning its complexity.
Reproduce with: python -m scripts.generate_synthetic_dataset --hours 6 --seed 42 && python -m scripts.backtest --stride 15 --max-origins 200
| Horizon | n | Fee MAE (stroops) | vs. persistence MAE | Fee RMSE | Congestion F1 | vs. persistence F1 |
|---|---|---|---|---|---|---|
| 5 min | 140 | 91.99 | 99.91 (beats it) | 225.26 | 0.250 | 0.294 (loses) |
| 15 min | 136 | 125.93 | 139.69 (beats it) | 289.14 | 0.069 | 0.059 (beats it, both weak) |
| 60 min | 118 | 121.81 | 133.53 (beats it) | 299.22 | 0.000 | 0.071 (loses) |
Honest read of these numbers:
- The fee-magnitude forecast consistently beats naive persistence on MAE across all three horizons, by 6-12%. That's a real, if modest, signal — exponential smoothing's trend component is doing something useful, not just tracking noise.
- The binary congestion-likelihood classifier is weak and, at the 5- and 60-minute horizons, actually worse than just assuming "whatever is happening now keeps happening." At 60 minutes it never fires a true positive on this dataset (F1 = 0). Congestion events in the synthetic data are short (1-10 minutes) and relatively rare (~11-13% of samples), which is a genuinely hard classification setting for any trend-following model — a spike that starts and ends inside the forecast window looks the same, from the model's vantage point at t=0, as no spike at all.
- Practical implication for integrators: treat
expectedBaseFeeStroopsas a soft, directionally-useful estimate, and treatcongestionLikelihoodas a weak prior, not a reliable trigger for automated action, at least until real-data backtests (issue #1) and the planned model upgrade (issue #4) improve on this baseline. Good current use: nudge a UI warning or a "maybe wait" suggestion. Bad current use: silently auto-delaying a user's transaction based on a single threshold crossing.
cd backend
pip install -r requirements-dev.txt
PYTHONPATH=. pytest -q # 9 tests, backend
# zero-setup local run against SQLite:
uvicorn app.api:app --reload &
python -m app.collector # polls real Horizon in the background
curl localhost:8000/forecastcd hooks
npm install
npm test # 4 tests, hook package
npm run buildFull setup, Postgres/Docker instructions, and the real-data backfill workflow: see SETUP.md.
GET /health
GET /forecast -> { generatedAt, lastObservedLedger, observationsUsed, windows: [...] }
GET /forecast/{5,15,60} -> single ForecastWindow
ForecastWindow: horizonMinutes, expectedBaseFeeStroops,
expectedP50FeeStroops, expectedCapacityUsage, congestionLikelihood
(0-1), feeForecastStdStroops.
import { useFeeForecast } from "@stellar-community/use-fee-forecast";
function SubmitButton() {
const { window, isCongestionLikely, isLoading } = useFeeForecast({
apiUrl: "https://your-deployed-api.example.com",
horizon: 15,
refetchInterval: 30_000,
});
return (
<button disabled={isLoading}>
{isCongestionLikely
? `Heads up: congestion likely soon (~${window?.expectedBaseFeeStroops} stroops expected)`
: "Submit"}
</button>
);
}- Statistical baseline, not a sophisticated architecture. Holt's method is a strong, well-understood baseline, chosen deliberately per the brief rather than reaching for something heavier first. It has no way to learn genuinely nonlinear or multi-signal congestion patterns (e.g. "the last three 15-minute windows before a scheduled Soroban event tend to spike"). Issue #4 tracks a follow-up with a more expressive model once there's enough real data to justify it.
- No seasonality yet. With only a few hours/days of rolling history,
there isn't a reliable daily/weekly cycle to fit. The model uses trend
only (
Holt, notHolt-Wintersseasonal). Once weeks of real data accumulate this should add a daily seasonal component — same issue. congestionLikelihoodis not a calibrated probability. It's a blend of recent-frequency and trend-crossing heuristics. Don't feed it directly into an expected-value calculation; use it as a directional signal.- Backtest is on synthetic data, per the section above, until someone runs the real backfill + backtest against live Horizon history.
- Horizon itself is being deprecated in favor of Stellar RPC per Stellar's own docs; this service should be considered a bridge implementation, and a Stellar-RPC-based ingestion path is worth tracking as the ecosystem migrates (not yet filed as an issue below — flag on the tracker if useful).
backend/ FastAPI service, model, ingestion, backtest, tests
hooks/ TypeScript useFeeForecast() package
SETUP.md Local dev, Docker/Postgres, real-data backfill workflow
MIT — see LICENSE.