Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -169,6 +170,10 @@ cd vectorbt && pip install -r requirements.txt && jupyter notebook fxmacrodata_v
# CCXT macro scanner
cd ccxt && pip install -r requirements.txt && python example.py

# 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

Expand Down
14 changes: 14 additions & 0 deletions julia/Project.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[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]
Fastback = {url = "https://github.com/rbeeli/Fastback.jl", rev = "3981c33610c97c97b61daca2631d73fdc0bc5b75"}
FXMacroData = {url = "https://github.com/fxmacrodata/FXMacroData.jl", rev = "5e1def28fc23546c0a949d54a79ca30ec85ca01d"}

[compat]
Fastback = "0.9"
FXMacroData = "0.1"
julia = "1.11"
55 changes: 55 additions & 0 deletions julia/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# 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.11 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.

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` 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).
174 changes: 174 additions & 0 deletions julia/fastback_release_aware_backtest.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
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 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, ("epoch", "announcement_datetime", "published_at", "release_datetime"))
value === nothing && throw(ArgumentError("Release row does not contain a publication timestamp"))
return publication_time_value(value)
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
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, ("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

"""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(; base_url="https://api.fxmacrodata.com")
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
49 changes: 49 additions & 0 deletions julia/test/runtests.jl
Original file line number Diff line number Diff line change
@@ -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