From 86fad2b264310f78defdf7ac7e44a8b2a38c5d3e Mon Sep 17 00:00:00 2001 From: Robert Tidball Date: Wed, 15 Jul 2026 01:05:50 +1000 Subject: [PATCH 1/2] Add Julia Fastback backtest example --- README.md | 9 +- julia/Project.toml | 16 +++ julia/README.md | 25 ++++ julia/fastback_release_aware_backtest.jl | 139 +++++++++++++++++++++++ 4 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 julia/Project.toml create mode 100644 julia/README.md create mode 100644 julia/fastback_release_aware_backtest.jl 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 From c362781479a1c4a0c468106a21b0779280d4b02f Mon Sep 17 00:00:00 2001 From: Robert Tidball Date: Sun, 16 Aug 2026 18:03:44 +1000 Subject: [PATCH 2/2] Make the Fastback example release-aware --- README.md | 10 ++--- julia/Project.toml | 12 +++--- julia/README.md | 34 +++++++++++++++- julia/fastback_release_aware_backtest.jl | 43 +++++++++++++++++++-- julia/test/runtests.jl | 49 ++++++++++++++++++++++++ 5 files changed, 130 insertions(+), 18 deletions(-) create mode 100644 julia/test/runtests.jl diff --git a/README.md b/README.md index 9214ca9..f5edc40 100644 --- a/README.md +++ b/README.md @@ -167,12 +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 +# Julia + Fastback.jl release-aware EUR/USD backtest (Julia 1.11+) +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 index d3ca68c..07a9e13 100644 --- a/julia/Project.toml +++ b/julia/Project.toml @@ -1,16 +1,14 @@ -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" +Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [sources] -FXMacroData = {url = "https://github.com/fxmacrodata/FXMacroData.jl"} +Fastback = {url = "https://github.com/rbeeli/Fastback.jl", rev = "3981c33610c97c97b61daca2631d73fdc0bc5b75"} +FXMacroData = {url = "https://github.com/fxmacrodata/FXMacroData.jl", rev = "5e1def28fc23546c0a949d54a79ca30ec85ca01d"} [compat] Fastback = "0.9" -julia = "1.9" +FXMacroData = "0.1" +julia = "1.11" diff --git a/julia/README.md b/julia/README.md index 2c1c00d..17b7094 100644 --- a/julia/README.md +++ b/julia/README.md @@ -6,7 +6,7 @@ The strategy is deliberately simple: it changes a simulated EUR/USD position whe ## Run it -Install Julia 1.9 or later, then run: +Install Julia 1.11 or later, then run: ```bash cd julia @@ -16,10 +16,40 @@ 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. +The project pins both unregistered Julia dependencies through Julia 1.11's +`[sources]` support. The macro release history is part of FXMacroData's public +USD baseline, while the EUR/USD price history used by the backtest requires an +API key. + +Run the deterministic parser and look-ahead tests without making network calls: + +```bash +julia --project test/runtests.jl +``` + ## How it avoids look-ahead bias -- The client requests `revisions=all`, preserving the publication time of each initial result and later revision. +- The client requests `revisions=all` and expands each nested `{epoch, val}` revision into a timestamped event. - 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. +## Capability coverage + +This is a consumer example for a data-independent backtesting library, so data +access remains in the standalone FXMacroData Julia client rather than being +embedded in Fastback. + +| FXMacroData capability | Fastback workflow | Authentication | Status | +| --- | --- | --- | --- | +| Discovery/catalogue | Select indicator names before a run | Public USD | Available through `data_catalogue` in FXMacroData.jl | +| Macro indicator history | Point-in-time policy-rate events | Public USD | Used by this example | +| Release calendar | Event schedule for blackout rules | Public USD | Available through `release_calendar` in FXMacroData.jl | +| Event predictions | Optional strategy feature | Verify current service access | Not used by this focused example | +| Macro news | Optional text/context feature | Verify current service access | Not used by this focused example | +| FX spot history | Daily EUR/USD valuation bars | API key | Used by this example | +| FX market sessions | Intraday session filter | Verify current service access | Not applicable to daily bars | +| COT positioning | Weekly strategy feature | API key where required | Available through `cot` in FXMacroData.jl | +| Commodities | Macro-context time series | API key | Available through `commodity` in FXMacroData.jl | +| Seasonality | Optional research feature | Verify current service access | Not used by this focused example | + 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 index 15f5a7c..1c71340 100644 --- a/julia/fastback_release_aware_backtest.jl +++ b/julia/fastback_release_aware_backtest.jl @@ -24,11 +24,30 @@ function observation_date(row) return Date(first(split(String(value), 'T'))) end +"""Parse a Unix epoch or ISO-8601 timestamp as a UTC `DateTime`.""" +function publication_time_value(value) + value isa DateTime && return value + value isa Date && return DateTime(value) + value isa Number && return unix2datetime(Float64(value)) + + text = String(value) + epoch = tryparse(Float64, text) + epoch !== nothing && return unix2datetime(epoch) + + normalized = replace(text, r"Z$" => "") + offset = match(r"^(.*)([+-])(\d\d):(\d\d)$", normalized) + offset === nothing && return DateTime(normalized) + + local_time = DateTime(offset.captures[1]) + displacement = Hour(parse(Int, offset.captures[3])) + Minute(parse(Int, offset.captures[4])) + return offset.captures[2] == "+" ? local_time - displacement : local_time + displacement +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 = row_value(row, ("epoch", "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$" => "")) + return publication_time_value(value) end """Parse a numeric field while preserving API values represented as JSON strings.""" @@ -41,13 +60,29 @@ end function policy_rate_events(rows) events = NamedTuple{(:released_at, :value),Tuple{DateTime,Float64}}[] for row in rows + revisions = get(row, "revisions", nothing) + if revisions isa AbstractVector && !isempty(revisions) + for revision in revisions + released_at = row_value(revision, ("epoch", "announcement_datetime")) + actual = row_value(revision, ("val", "actual", "value")) + released_at === nothing && continue + actual === nothing && continue + push!( + events, + (released_at=publication_time(revision), value=numeric_value(actual)), + ) + end + continue + end + released_at = row_value(row, ("announcement_datetime", "published_at", "release_datetime")) - actual = row_value(row, ("actual", "value", "val")) + actual = row_value(row, ("val", "actual", "value")) 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) + unique!(events) return events end @@ -65,7 +100,7 @@ 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() + client = Client(; base_url="https://api.fxmacrodata.com") release_rows = announcements( client, "usd", diff --git a/julia/test/runtests.jl b/julia/test/runtests.jl new file mode 100644 index 0000000..d04d458 --- /dev/null +++ b/julia/test/runtests.jl @@ -0,0 +1,49 @@ +using Dates +using Test + +include(joinpath(@__DIR__, "..", "fastback_release_aware_backtest.jl")) + +@testset "FXMacroData release parsing" begin + rows = [ + Dict{String,Any}( + "announcement_datetime" => 1_704_067_200, + "val" => 5.25, + "revisions" => Any[ + Dict{String,Any}("epoch" => 1_704_067_200, "val" => 5.25), + Dict{String,Any}("epoch" => 1_704_153_600, "val" => "5.50"), + ], + ), + Dict{String,Any}( + "announcement_datetime" => "2024-02-01T14:30:00+00:00", + "val" => "5.75", + ), + ] + + events = policy_rate_events(rows) + + @test length(events) == 3 + @test events[1] == (released_at=DateTime(2024, 1, 1), value=5.25) + @test events[2] == (released_at=DateTime(2024, 1, 2), value=5.50) + @test events[3] == (released_at=DateTime(2024, 2, 1, 14, 30), value=5.75) +end + +@testset "FXMacroData FX-bar parsing" begin + rows = [ + Dict{String,Any}("date" => "2024-01-03T00:00:00Z", "val" => "1.0950"), + Dict{String,Any}("date" => "2024-01-02", "value" => 1.0900), + ] + + bars = fx_bars(rows) + + @test bars == [ + (date=Date(2024, 1, 2), price=1.0900), + (date=Date(2024, 1, 3), price=1.0950), + ] +end + +@testset "daily bars cannot see same-day releases" begin + release_time = publication_time_value("2024-03-20T18:00:00Z") + + @test !(release_time < DateTime(Date(2024, 3, 20))) + @test release_time < DateTime(Date(2024, 3, 21)) +end