diff --git a/README.md b/README.md index 74e7d88..9214ca9 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ to a paid plan at: | Macro Data Access | pandas-datareader | Local script | — | | Macro Carry Scanner | CCXT | Local script | — | | Carry Rebalance Bot | Blankly | Local strategy | — | +| Release-Aware EUR/USD Backtest | Julia + Fastback.jl | Local script | — | ## Distribution-first publishing loop @@ -166,8 +167,12 @@ cd pandas-datareader && pip install -r requirements.txt && python example.py # VectorBT Jupyter notebook cd vectorbt && pip install -r requirements.txt && jupyter notebook fxmacrodata_vectorbt.ipynb -# CCXT macro scanner -cd ccxt && pip install -r requirements.txt && python example.py + # CCXT macro scanner + cd ccxt && pip install -r requirements.txt && python example.py + + # Julia + Fastback.jl release-aware EUR/USD backtest + cd julia && julia --project -e 'using Pkg; Pkg.instantiate()' + julia --project fastback_release_aware_backtest.jl # Next.js app cd vercel && npm install && npm run dev diff --git a/julia/Project.toml b/julia/Project.toml new file mode 100644 index 0000000..d3ca68c --- /dev/null +++ b/julia/Project.toml @@ -0,0 +1,16 @@ +name = "FXMacroDataFastbackExample" +uuid = "3cd9b1af-5e7b-428e-b2db-e5f38b2b5d83" +authors = ["FXMacroData "] +version = "0.1.0" + +[deps] +Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" +Fastback = "2b92286b-cbc8-46f1-be48-a629b4baefca" +FXMacroData = "c3744e4c-d024-465f-a33d-892fc1481992" + +[sources] +FXMacroData = {url = "https://github.com/fxmacrodata/FXMacroData.jl"} + +[compat] +Fastback = "0.9" +julia = "1.9" diff --git a/julia/README.md b/julia/README.md new file mode 100644 index 0000000..2c1c00d --- /dev/null +++ b/julia/README.md @@ -0,0 +1,25 @@ +# FXMacroData + Fastback.jl + +This example runs a release-aware EUR/USD backtest with [Fastback.jl](https://github.com/rbeeli/Fastback.jl) and the FXMacroData Julia client. It uses the source timestamp of every economic release and revision, so a value is eligible for a simulated decision only after it was published. + +The strategy is deliberately simple: it changes a simulated EUR/USD position when a newly available USD policy-rate observation differs from the preceding one. It is a research example, not investment advice or a live-trading system. + +## Run it + +Install Julia 1.9 or later, then run: + +```bash +cd julia +julia --project -e 'using Pkg; Pkg.instantiate()' +julia --project fastback_release_aware_backtest.jl +``` + +Set `FXMACRODATA_API_KEY` (or `FXMD_API_KEY`) in your shell before running the example. The client reads the key at runtime and sends it only as an `api_key` query parameter; do not put keys in `Project.toml`, scripts, or notebooks. + +## How it avoids look-ahead bias + +- The client requests `revisions=all`, preserving the publication time of each initial result and later revision. +- The loop applies a release only when its timestamp is strictly earlier than the daily valuation timestamp, so same-day daily bars cannot use a result published later that day. +- It consumes FXMacroData's daily reference rates and processes orders only inside Fastback's simulated account. + +For the API client source and endpoint coverage, see [FXMacroData.jl](https://github.com/fxmacrodata/FXMacroData.jl). Explore the [FXMacroData API documentation](https://fxmacrodata.com/documentation) or [subscribe for protected datasets](https://fxmacrodata.com/subscribe). diff --git a/julia/fastback_release_aware_backtest.jl b/julia/fastback_release_aware_backtest.jl new file mode 100644 index 0000000..15f5a7c --- /dev/null +++ b/julia/fastback_release_aware_backtest.jl @@ -0,0 +1,139 @@ +using Dates +using Fastback +using FXMacroData + +const START_DATE = Date(2024, 1, 1) +const END_DATE = Date(2025, 12, 31) +const POSITION_SIZE_EUR = 10_000.0 + +"""Return the first non-empty value for any of `keys` in an API row.""" +function row_value(row, keys) + for key in keys + value = get(row, key, nothing) + if value !== nothing && value != "" + return value + end + end + return nothing +end + +"""Parse a daily observation date from an FXMacroData row.""" +function observation_date(row) + value = row_value(row, ("date", "observation_date", "timestamp")) + value === nothing && throw(ArgumentError("FX row does not contain a date")) + return Date(first(split(String(value), 'T'))) +end + +"""Parse an announcement or revision timestamp from an FXMacroData row.""" +function publication_time(row) + value = row_value(row, ("announcement_datetime", "published_at", "release_datetime")) + value === nothing && throw(ArgumentError("Release row does not contain a publication timestamp")) + return DateTime(replace(String(value), r"Z$" => "")) +end + +"""Parse a numeric field while preserving API values represented as JSON strings.""" +function numeric_value(value) + value isa Number && return Float64(value) + return parse(Float64, String(value)) +end + +"""Normalise API release rows into timestamp-ordered values, including revisions.""" +function policy_rate_events(rows) + events = NamedTuple{(:released_at, :value),Tuple{DateTime,Float64}}[] + for row in rows + released_at = row_value(row, ("announcement_datetime", "published_at", "release_datetime")) + actual = row_value(row, ("actual", "value", "val")) + released_at === nothing && continue + actual === nothing && continue + push!(events, (released_at=publication_time(row), value=numeric_value(actual))) + end + sort!(events; by=event -> event.released_at) + return events +end + +"""Normalise daily FX rows into date-ordered EUR/USD valuation bars.""" +function fx_bars(rows) + bars = NamedTuple{(:date, :price),Tuple{Date,Float64}}[] + for row in rows + price = row_value(row, ("val", "value", "rate", "close")) + price === nothing && continue + push!(bars, (date=observation_date(row), price=numeric_value(price))) + end + sort!(bars; by=bar -> bar.date) + return bars +end + +"""Run the release-aware EUR/USD policy-rate demonstration in a Fastback account.""" +function run_backtest(; start_date=START_DATE, end_date=END_DATE) + client = Client() + release_rows = announcements( + client, + "usd", + "policy_rate"; + start_date=start_date, + end_date=end_date, + revisions="all", + ) + price_rows = forex(client, "eur", "usd"; start_date=start_date, end_date=end_date) + + events = policy_rate_events(release_rows) + bars = fx_bars(price_rows) + isempty(events) && throw(ArgumentError("No policy-rate releases returned for the selected period")) + isempty(bars) && throw(ArgumentError("No EUR/USD observations returned for the selected period")) + + account = Account( + ; + funding=AccountFunding.Margined, + base_currency=CashSpec(:USD), + broker=FlatFeeBroker(; pct=0.0002), + ) + usd = cash_asset(account, :USD) + deposit!(account, :USD, 100_000.0) + eurusd = register_instrument!(account, spot_instrument(Symbol("EUR/USD"), :EUR, :USD)) + collect_equity, equity_history = periodic_collector(Float64, Day(1)) + + event_index = 1 + active_policy_rate = nothing + preceding_policy_rate = nothing + held_quantity = 0.0 + + for bar in bars + valuation_time = DateTime(bar.date) + while event_index <= length(events) && events[event_index].released_at < valuation_time + preceding_policy_rate = active_policy_rate + active_policy_rate = events[event_index].value + event_index += 1 + end + + if active_policy_rate !== nothing && preceding_policy_rate !== nothing + desired_quantity = active_policy_rate > preceding_policy_rate ? POSITION_SIZE_EUR : -POSITION_SIZE_EUR + if desired_quantity != held_quantity + delta = desired_quantity - held_quantity + order = Order(oid!(account), eurusd, valuation_time, bar.price, delta) + fill_order!( + account, + order; + dt=valuation_time, + fill_price=bar.price, + bid=bar.price, + ask=bar.price, + last=bar.price, + ) + held_quantity = desired_quantity + end + end + + update_marks!(account, eurusd, valuation_time, bar.price, bar.price, bar.price) + if should_collect(equity_history, valuation_time) + collect_equity(valuation_time, equity(account, usd)) + end + end + + return (account=account, equity_history=equity_history, releases_processed=event_index - 1) +end + +if abspath(PROGRAM_FILE) == @__FILE__ + result = run_backtest() + println("Processed $(result.releases_processed) policy-rate releases.") + println("Final simulated equity: $(equity(result.account, cash_asset(result.account, :USD))) USD") +end