forexgen 2.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- forexgen/__init__.py +27 -0
- forexgen/__main__.py +5 -0
- forexgen/api.py +94 -0
- forexgen/calibration.py +357 -0
- forexgen/cli.py +108 -0
- forexgen/costs.py +66 -0
- forexgen/diagnostics.py +210 -0
- forexgen/engine.py +313 -0
- forexgen/events.py +269 -0
- forexgen/export.py +81 -0
- forexgen/factors.py +114 -0
- forexgen/fixtures.py +174 -0
- forexgen/marketcal.py +112 -0
- forexgen/markets.py +76 -0
- forexgen/pipeline.py +289 -0
- forexgen/profile_data/EURUSD.json +135 -0
- forexgen/profiles.py +286 -0
- forexgen/regimes.py +115 -0
- forexgen/report.py +135 -0
- forexgen/scenarios.py +152 -0
- forexgen/ticks.py +81 -0
- forexgen/validation.py +100 -0
- forexgen-2.1.0.dist-info/METADATA +303 -0
- forexgen-2.1.0.dist-info/RECORD +28 -0
- forexgen-2.1.0.dist-info/WHEEL +5 -0
- forexgen-2.1.0.dist-info/entry_points.txt +2 -0
- forexgen-2.1.0.dist-info/licenses/LICENSE +17 -0
- forexgen-2.1.0.dist-info/top_level.txt +1 -0
forexgen/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""forexgen 2.0 - offline synthetic forex / gold data generator.
|
|
2
|
+
|
|
3
|
+
Quick start:
|
|
4
|
+
import forexgen
|
|
5
|
+
df = forexgen.get_data("EURUSD", "M15", "3m") # different every call; seed in df.attrs
|
|
6
|
+
df = forexgen.get_data("GBPUSD", "H1", "1y", seed=7) # reproducible
|
|
7
|
+
forexgen.describe("XAUUSD") # calibrated vs assumed parameters
|
|
8
|
+
"""
|
|
9
|
+
from .api import (generate_csv, get_data, get_multi, list_markets, parse_duration, # noqa: F401
|
|
10
|
+
parse_timeframe, register_market)
|
|
11
|
+
from .profiles import (Profile, describe, get_profile, load_profile, save_profile, # noqa: F401
|
|
12
|
+
use_profile, reset_profiles)
|
|
13
|
+
from .events import get_events, register_event, set_events, clear_user_events # noqa: F401
|
|
14
|
+
from .scenarios import SCENARIOS # noqa: F401
|
|
15
|
+
from .ticks import get_ticks # noqa: F401
|
|
16
|
+
from .report import scorecard, stats_m1 # noqa: F401
|
|
17
|
+
from .calibration import calibrate, calibrate_many, CalibrationSet, read_histdata # noqa: F401
|
|
18
|
+
from .validation import get_null_paths, monte_carlo, percentile_in_null, backtest # noqa: F401
|
|
19
|
+
from .costs import slippage, stop_fill, swap_pips # noqa: F401
|
|
20
|
+
|
|
21
|
+
from .factors import set_correlation, reset_correlation # noqa: F401
|
|
22
|
+
from .export import export_histdata, generate_histdata, to_histdata_frame # noqa: F401
|
|
23
|
+
from .diagnostics import selftest, robustness_report, trend_strategy, mean_reversion_strategy # noqa: F401
|
|
24
|
+
from . import fixtures # noqa: F401
|
|
25
|
+
from .fixtures import indicator_stress_test # noqa: F401
|
|
26
|
+
|
|
27
|
+
__version__ = "2.1.0"
|
forexgen/__main__.py
ADDED
forexgen/api.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Public API: get_data / generate_csv / get_multi (v1-compatible signatures)."""
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
from . import pipeline as pl
|
|
8
|
+
from .marketcal import parse_duration, parse_timeframe # noqa: F401 (re-exported)
|
|
9
|
+
from .markets import get_market, list_markets, register_market, normalize_symbol # noqa: F401
|
|
10
|
+
from .pipeline import DEFAULT_START
|
|
11
|
+
|
|
12
|
+
CSV_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_data(market, timeframe="M15", duration="30d", start=DEFAULT_START, end=None,
|
|
16
|
+
seed=None, start_price=None, profile="typical", scenario=None, scenario_at=0.5,
|
|
17
|
+
scenario_strength=1.0, scenario_direction="auto", scenario_markets=None,
|
|
18
|
+
events="default", sparse=False, return_labels=False, edge=None, daily_anchor="utc", **model):
|
|
19
|
+
"""Generate synthetic OHLCV bars for a market.
|
|
20
|
+
|
|
21
|
+
market : e.g. 'EURUSD', 'GBPUSD', 'XAUUSD' (see list_markets())
|
|
22
|
+
timeframe : M1 M5 M15 M30 H1 H4 D1 W1 ... (also '15m', '1h', 'daily')
|
|
23
|
+
duration : '30d', '2w', '3m', '1y', '12h', '90min' (market time) - or use end=
|
|
24
|
+
start, end : UTC timestamps (naive UTC). Output index is naive UTC.
|
|
25
|
+
seed : None (default) -> a different dataset every call; the seed used is in
|
|
26
|
+
df.attrs['seed']. An int reproduces the data exactly.
|
|
27
|
+
profile : 'typical' (default) | 'calm_aug2026' | 'stressed', or a forexgen.Profile
|
|
28
|
+
scenario : None or one of forexgen.SCENARIOS, placed at scenario_at (timestamp or
|
|
29
|
+
fraction 0..1 of the data) with scenario_strength
|
|
30
|
+
events : 'default' (rule calendar) | 'none' | DataFrame/CSV of your own events
|
|
31
|
+
sparse : True -> broker-like flat bars and missing minutes
|
|
32
|
+
return_labels : add columns regime, event_flag, true_vol
|
|
33
|
+
edge : dict(kind='momentum'|'mean_reversion', strength=0.02, horizon='1h') plants a
|
|
34
|
+
known edge (for testing whether a strategy/backtester can find it)
|
|
35
|
+
daily_anchor : 'utc' (default, as v1: D1/W1 cut at UTC midnight) or 'ny' (cut at the 17:00 New York close,
|
|
36
|
+
5 bars per week, the usual FX convention)
|
|
37
|
+
**model : v1 options still accepted: garch=(a,b), t_df, jump_rate_per_day, jump_sigma,
|
|
38
|
+
weekend_gap, kappa, drift, usd_factor (+ vol_scale)
|
|
39
|
+
|
|
40
|
+
Returns a DataFrame indexed by UTC time with open, high, low, close, volume, spread_pips.
|
|
41
|
+
"""
|
|
42
|
+
return pl.generate(market, timeframe, duration, start, end, seed, start_price, profile,
|
|
43
|
+
scenario, scenario_at, scenario_strength, scenario_direction,
|
|
44
|
+
scenario_markets, events, sparse, return_labels, edge, False,
|
|
45
|
+
model=model, daily_anchor=daily_anchor)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def default_csv_path(market, timeframe, duration, seed, folder="forex_data", end=None):
|
|
49
|
+
"""<folder>/<SYMBOL>_<TF>_<duration>_seed<seed>.csv - the seed actually used is in the name."""
|
|
50
|
+
mk, tf = get_market(market), parse_timeframe(timeframe)
|
|
51
|
+
tag = re.sub(r"\W", "", str(duration))
|
|
52
|
+
if end is not None:
|
|
53
|
+
tag = "to-" + re.sub(r"\D", "", str(end))
|
|
54
|
+
return os.path.join(folder, f"{mk.symbol}_{tf}_{tag}_seed{seed}.csv")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def generate_csv(market, timeframe="M15", duration="30d", path=None, folder="forex_data", **kwargs):
|
|
58
|
+
"""Generate data and write a CSV. Returns the file path.
|
|
59
|
+
|
|
60
|
+
Default name: <folder>/<SYMBOL>_<TF>_<duration>_seed<seed>.csv - the seed actually used
|
|
61
|
+
is in the name, so any run can be reproduced with seed=<that number>."""
|
|
62
|
+
df = get_data(market, timeframe, duration, **kwargs)
|
|
63
|
+
if path is None:
|
|
64
|
+
path = default_csv_path(market, timeframe, duration, df.attrs["seed"], folder, kwargs.get("end"))
|
|
65
|
+
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
|
66
|
+
df.to_csv(path, date_format=CSV_DATE_FORMAT)
|
|
67
|
+
return path
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
_GEN_KW = {"scenario", "scenario_at", "scenario_strength", "scenario_direction", "scenario_markets",
|
|
71
|
+
"events", "sparse", "return_labels", "edge", "daily_anchor"}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_multi(markets, timeframe="M15", duration="30d", start=DEFAULT_START, end=None, seed=None,
|
|
75
|
+
profile="typical", as_frame=False, **kwargs):
|
|
76
|
+
"""Jointly simulate several markets: shared factors, correlations that rise in crises, and
|
|
77
|
+
crosses (EURGBP, EURJPY, GBPJPY) derived exactly from their legs.
|
|
78
|
+
|
|
79
|
+
Returns {symbol: DataFrame}, or one DataFrame with (market, field) columns if as_frame=True.
|
|
80
|
+
A market inside get_multi equals get_data(market, seed=seed) for the same arguments.
|
|
81
|
+
Accepts the same options as get_data (scenario=..., events=..., sparse=..., v1 model kwargs)."""
|
|
82
|
+
if isinstance(markets, str):
|
|
83
|
+
markets = [markets]
|
|
84
|
+
seed = pl.resolve_seed(seed)
|
|
85
|
+
gen = {k: v for k, v in kwargs.items() if k in _GEN_KW}
|
|
86
|
+
model = {k: v for k, v in kwargs.items() if k not in _GEN_KW}
|
|
87
|
+
cache, out = {}, {}
|
|
88
|
+
for m in markets:
|
|
89
|
+
mk = get_market(m)
|
|
90
|
+
out[mk.symbol] = pl.generate(mk.symbol, timeframe, duration, start, end, seed, None, profile,
|
|
91
|
+
cache=cache, model=model, **gen)
|
|
92
|
+
if as_frame:
|
|
93
|
+
return pd.concat(out, axis=1)
|
|
94
|
+
return out
|
forexgen/calibration.py
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""calibrate(): fit a market Profile from a real M1 file (HistData / MetaTrader ASCII format).
|
|
2
|
+
|
|
3
|
+
Fitted: volatility level, event-free seasonality (NY hour), microstructure (lag-1 bounce and
|
|
4
|
+
variance-ratio profile, solved analytically), GARCH persistence (simulated method of moments on
|
|
5
|
+
the |r| autocorrelations), bar-range scale, weekend-gap size, flat-bar / missing-minute rates and,
|
|
6
|
+
where >= 8 events of an importance class exist, the event shock size.
|
|
7
|
+
NOT fittable from price-only files: spreads and volume (flagged "assumed").
|
|
8
|
+
"""
|
|
9
|
+
import math
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import pandas as pd
|
|
13
|
+
|
|
14
|
+
from . import events as ev
|
|
15
|
+
from . import report
|
|
16
|
+
from .marketcal import ny_clock
|
|
17
|
+
from .markets import get_market
|
|
18
|
+
from .profiles import Profile, bootstrap_eurusd, builtin_profile, get_profile
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------- reading
|
|
22
|
+
def read_histdata(src, tz="America/New_York", fmt="histdata_mt"):
|
|
23
|
+
"""Read a HistData/MT file (no header: date YYYY.MM.DD, time HH:MM, o, h, l, c, v) or a DataFrame
|
|
24
|
+
(index or 'time' column + open/high/low/close). Returns an M1 DataFrame, naive UTC index."""
|
|
25
|
+
if isinstance(src, pd.DataFrame):
|
|
26
|
+
df = src.copy()
|
|
27
|
+
if "time" in df.columns:
|
|
28
|
+
df = df.set_index("time")
|
|
29
|
+
df.index = pd.to_datetime(df.index)
|
|
30
|
+
else:
|
|
31
|
+
with open(src) as fh:
|
|
32
|
+
first = fh.readline()
|
|
33
|
+
if ";" in first: # HistData ASCII M1: "YYYYMMDD HHMMSS;open;high;low;close;volume" (EST, no DST)
|
|
34
|
+
raw = pd.read_csv(src, sep=";", header=None, names=["dt", "open", "high", "low", "close", "volume"])
|
|
35
|
+
df = raw[["open", "high", "low", "close", "volume"]].copy()
|
|
36
|
+
df.index = pd.to_datetime(raw["dt"], format="%Y%m%d %H%M%S")
|
|
37
|
+
if tz == "America/New_York" and fmt == "histdata_mt":
|
|
38
|
+
tz = "EST" # ASCII files are fixed UTC-5 unless the caller says otherwise
|
|
39
|
+
else:
|
|
40
|
+
raw = pd.read_csv(src, header=None, names=["d", "t", "open", "high", "low", "close", "volume"])
|
|
41
|
+
df = raw[["open", "high", "low", "close", "volume"]].copy()
|
|
42
|
+
df.index = pd.to_datetime(raw["d"] + " " + raw["t"], format="%Y.%m.%d %H:%M")
|
|
43
|
+
df = df[["open", "high", "low", "close"] + (["volume"] if "volume" in df.columns else [])]
|
|
44
|
+
df = df[~df.index.duplicated(keep="first")].sort_index()
|
|
45
|
+
if str(tz).upper() == "EST": # fixed UTC-5, no daylight saving
|
|
46
|
+
df.index = df.index + pd.Timedelta(hours=5)
|
|
47
|
+
elif tz and str(tz).upper() != "UTC":
|
|
48
|
+
try:
|
|
49
|
+
idx = df.index.tz_localize(tz, ambiguous="infer", nonexistent="NaT")
|
|
50
|
+
except Exception:
|
|
51
|
+
idx = df.index.tz_localize(tz, ambiguous="NaT", nonexistent="NaT")
|
|
52
|
+
keep = ~pd.isna(idx)
|
|
53
|
+
df, idx = df[keep], idx[keep]
|
|
54
|
+
df.index = idx.tz_convert("UTC").tz_localize(None)
|
|
55
|
+
df = df.sort_index()
|
|
56
|
+
df.index.name = "time"
|
|
57
|
+
return df
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------- microstructure fit
|
|
61
|
+
def fit_micro(ac1, vr, pfs=(0.2, 0.35, 0.5), pss=(0.75, 0.80, 0.85, 0.90, 0.93, 0.95, 0.97, 0.98, 0.99, 0.995)):
|
|
62
|
+
"""Solve for (fx, vf, vs, phi_f, phi_s): efficient-price share, fast-bounce variance and slow
|
|
63
|
+
mean-reverting variance, matching lag-1 autocorrelation and variance ratios (linear in the
|
|
64
|
+
three variances once the two AR coefficients are fixed; grid over the coefficients)."""
|
|
65
|
+
best = None
|
|
66
|
+
ks = [k for k in (5, 15, 60, 240) if k in vr and vr[k] == vr[k]]
|
|
67
|
+
if ac1 != ac1 or not ks:
|
|
68
|
+
return None
|
|
69
|
+
for pf in pfs:
|
|
70
|
+
for ps in pss:
|
|
71
|
+
A, y, w = [[1.0, 2 * (1 - pf), 2 * (1 - ps)]], [1.0], [10.0]
|
|
72
|
+
A.append([0.0, (1 - pf) ** 2, (1 - ps) ** 2])
|
|
73
|
+
y.append(-ac1)
|
|
74
|
+
w.append(25.0)
|
|
75
|
+
for k in ks:
|
|
76
|
+
A.append([1.0, 2 * (1 - pf ** k) / k, 2 * (1 - ps ** k) / k])
|
|
77
|
+
y.append(vr[k])
|
|
78
|
+
w.append(1.0)
|
|
79
|
+
A, y, w = np.array(A), np.array(y), np.array(w)
|
|
80
|
+
active = [0, 1, 2]
|
|
81
|
+
for _ in range(3):
|
|
82
|
+
sol, *_ = np.linalg.lstsq(A[:, active] * w[:, None], y * w, rcond=None)
|
|
83
|
+
full = np.zeros(3)
|
|
84
|
+
full[active] = sol
|
|
85
|
+
neg = [a for a, v in zip(active, sol) if v < 0]
|
|
86
|
+
if not neg:
|
|
87
|
+
break
|
|
88
|
+
active = [a for a in active if a not in neg]
|
|
89
|
+
res = float(np.sum((w * (A @ full - y)) ** 2))
|
|
90
|
+
if best is None or res < best[0]:
|
|
91
|
+
best = (res, full, pf, ps)
|
|
92
|
+
_, (X, F, S), pf, ps = best
|
|
93
|
+
X = max(X, 0.05)
|
|
94
|
+
return dict(fx=float(X), vf=float(F), vs=float(S), phi_f=pf, phi_s=ps)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------- helpers
|
|
98
|
+
def _event_mask(index, start, end, minutes_before=5):
|
|
99
|
+
cal = ev.rule_events(start - pd.Timedelta(days=1), end + pd.Timedelta(days=1))
|
|
100
|
+
cal = cal[cal.importance >= 2]
|
|
101
|
+
mask = np.zeros(len(index), dtype=bool)
|
|
102
|
+
t = index.values.astype("datetime64[m]").astype(np.int64)
|
|
103
|
+
for t0, imp in zip(cal.time_utc.values.astype("datetime64[m]").astype(np.int64), cal.importance.values):
|
|
104
|
+
after = 150 if imp >= 4 else 60
|
|
105
|
+
lo, hi = np.searchsorted(t, t0 - minutes_before), np.searchsorted(t, t0 + after)
|
|
106
|
+
mask[lo:hi] = True
|
|
107
|
+
return mask
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _run(profile, variant, days, seeds, sym, pip):
|
|
111
|
+
"""Stats of simulated data for a profile in a given variant (profile object + variant name)."""
|
|
112
|
+
from . import pipeline as pl
|
|
113
|
+
res = []
|
|
114
|
+
for sd in seeds:
|
|
115
|
+
ts = pl.timeline("2024-03-04 00:00", f"{days}d", None, get_market(sym).daily_break)
|
|
116
|
+
cal = ev.calendar_between(ts[0] - pd.Timedelta(days=1), ts[-1] + pd.Timedelta(days=1), "default")
|
|
117
|
+
m1 = pl.build_m1(sym, ts, sd, variant, profile, None, None, cal, None, {}, None, {})
|
|
118
|
+
res.append(report.stats_m1(pl.finalize(m1, get_market(sym)), pip))
|
|
119
|
+
return res
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _mean(stats, key, sub=None):
|
|
123
|
+
vals = [(s[key][sub] if sub is not None else s[key]) for s in stats]
|
|
124
|
+
return float(np.nanmean(vals))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------- main
|
|
128
|
+
def calibrate(src, market="EURUSD", tz="America/New_York", fmt="histdata_mt", variant="calm_aug2026",
|
|
129
|
+
sim_days=60, seeds=(11, 12, 13, 14), verbose=True):
|
|
130
|
+
"""Fit and return a Profile from real M1 data of `market`. See module docstring.
|
|
131
|
+
|
|
132
|
+
variant: which volatility variant the file represents ('calm_aug2026' = a calm month; its
|
|
133
|
+
measured level becomes ann_vol_calm). Use 'typical' if the file is a normal year."""
|
|
134
|
+
mk = get_market(market)
|
|
135
|
+
sym = mk.symbol
|
|
136
|
+
log = (lambda *a: print(*a)) if verbose else (lambda *a: None)
|
|
137
|
+
df = read_histdata(src, tz, fmt)
|
|
138
|
+
pip = mk.pip
|
|
139
|
+
real = report.stats_m1(df, pip)
|
|
140
|
+
start_prof = bootstrap_eurusd() if sym == "EURUSD" else get_profile(sym)
|
|
141
|
+
p = start_prof.copy(symbol=sym)
|
|
142
|
+
prov = dict(p.provenance) if sym != "EURUSD" else {}
|
|
143
|
+
log(f"[calibrate] {sym}: {real['n_bars']} bars {df.index[0]} .. {df.index[-1]} (UTC)")
|
|
144
|
+
|
|
145
|
+
# ---- event-free seasonality (robust)
|
|
146
|
+
r = report.returns_m1(df)
|
|
147
|
+
mask_all = _event_mask(r.index, df.index[0], df.index[-1])
|
|
148
|
+
r_ex = r[~mask_all]
|
|
149
|
+
scale = 1.4826 * np.median(np.abs(r_ex - r_ex.median())) + 1e-12
|
|
150
|
+
rw = r_ex.clip(-8 * scale, 8 * scale)
|
|
151
|
+
hrs, _ = ny_clock(rw.index)
|
|
152
|
+
prof_h = rw.abs().groupby(np.floor(hrs).astype(int)).mean().reindex(range(24))
|
|
153
|
+
prof_h = prof_h.interpolate(limit_direction="both").values
|
|
154
|
+
sm = np.convolve(np.r_[prof_h[-1], prof_h, prof_h[0]], [0.25, 0.5, 0.25], mode="valid")
|
|
155
|
+
p.seasonality = (sm / math.sqrt(np.mean(sm ** 2))).tolist()
|
|
156
|
+
prov["seasonality"] = "calibrated (event windows excluded, robust, NY hour)"
|
|
157
|
+
|
|
158
|
+
# ---- microstructure
|
|
159
|
+
mic = fit_micro(real["ac1"], real["vr"])
|
|
160
|
+
if mic:
|
|
161
|
+
for k, v in mic.items():
|
|
162
|
+
setattr(p, k, v)
|
|
163
|
+
prov["microstructure"] = (f"calibrated (lag-1 ACF {real['ac1']:.3f}, variance ratios "
|
|
164
|
+
f"{', '.join(f'{k}m={v:.2f}' for k, v in real['vr'].items())})")
|
|
165
|
+
# ---- level, gaps, rates
|
|
166
|
+
p.ann_vol_calm = real["ann_vol_m1"] if variant == "calm_aug2026" else p.ann_vol_calm
|
|
167
|
+
if variant == "typical":
|
|
168
|
+
p.ann_vol_long = real["ann_vol_m1"]
|
|
169
|
+
prov["volatility level"] = f"calibrated ({variant} M1-implied {real['ann_vol_m1']:.2%}); the other level is assumed"
|
|
170
|
+
g = df.index.to_series().diff()
|
|
171
|
+
big = g[(g > pd.Timedelta("40h")) & (g < pd.Timedelta("60h"))].index # weekend-sized gaps only (not month joins)
|
|
172
|
+
gaps_bp = [abs(df["open"].loc[i] / df["close"].shift(1).loc[i] - 1) * 1e4 for i in big]
|
|
173
|
+
if len(gaps_bp) >= 1:
|
|
174
|
+
p.weekend_gap_bp = float(np.mean(gaps_bp)) # measured in the file's own (calm) month
|
|
175
|
+
prov["weekend gap"] = f"calibrated (n={len(gaps_bp)} gaps)"
|
|
176
|
+
p.flat_share = real["flat_share"]
|
|
177
|
+
p.missing_share = real["missing_share"]
|
|
178
|
+
prov["flat bars / missing minutes"] = "calibrated"
|
|
179
|
+
|
|
180
|
+
# ---- event sizes (only with >= 8 events in a class)
|
|
181
|
+
cal = ev.rule_events(df.index[0], df.index[-1] + pd.Timedelta(days=1))
|
|
182
|
+
cal = cal[cal.importance >= 3]
|
|
183
|
+
tt = r.index.values.astype("datetime64[m]").astype(np.int64)
|
|
184
|
+
obs = {}
|
|
185
|
+
for t0, imp, name in zip(cal.time_utc.values.astype("datetime64[m]").astype(np.int64),
|
|
186
|
+
cal.importance.values, cal.name.values):
|
|
187
|
+
lo, hi = np.searchsorted(tt, t0), np.searchsorted(tt, t0 + 5)
|
|
188
|
+
if hi > lo:
|
|
189
|
+
obs.setdefault(int(imp), []).append((name, float(r.iloc[lo:hi].abs().max() * 1e4)))
|
|
190
|
+
p.meta["observed_events_bp"] = {str(k): [[str(n), round(v, 1)] for n, v in vs] for k, vs in obs.items()} # JSON-stable
|
|
191
|
+
done = []
|
|
192
|
+
for imp, vs in obs.items():
|
|
193
|
+
if len(vs) >= 8:
|
|
194
|
+
p.event_base_bp[str(imp)] = float(np.median([v for _, v in vs]) / 0.75)
|
|
195
|
+
done.append(imp)
|
|
196
|
+
anchors = sum(len(v) for v in obs.values())
|
|
197
|
+
prov["events"] = (f"calibrated for importance {done}" if done else
|
|
198
|
+
f"assumed shape/size, sanity-anchored to {anchors} observed events "
|
|
199
|
+
f"({'; '.join(f'{n} {v:.0f}bp' for vs in obs.values() for n, v in vs)})")
|
|
200
|
+
prov["spread"] = "assumed (price-only file has no bid/ask)"
|
|
201
|
+
prov["volume"] = "assumed (file volume is not usable)"
|
|
202
|
+
prov["regimes & scenarios"] = "assumed"
|
|
203
|
+
|
|
204
|
+
# ---- simulated-method-of-moments: GARCH persistence, then range scale and level
|
|
205
|
+
sim_variant = variant
|
|
206
|
+
target_acf = real["acf_abs"]
|
|
207
|
+
best = None
|
|
208
|
+
for alpha in (0.03, 0.05, 0.08, 0.12, 0.16):
|
|
209
|
+
for phi in (0.93, 0.95, 0.97, 0.985, 0.995):
|
|
210
|
+
q = p.copy(garch_alpha=alpha, garch_beta=phi - alpha)
|
|
211
|
+
st = _run(q, sim_variant, sim_days, seeds[:2], sym, pip)
|
|
212
|
+
loss = sum((_mean(st, "acf_abs", k) - target_acf[k]) ** 2 for k in (1, 10, 60) if k in target_acf)
|
|
213
|
+
if best is None or loss < best[0]:
|
|
214
|
+
best = (loss, alpha, phi)
|
|
215
|
+
p.garch_alpha, p.garch_beta = best[1], best[2] - best[1]
|
|
216
|
+
prov["garch"] = f"calibrated by simulated method of moments (alpha={best[1]:.2f}, persistence={best[2]:.3f})"
|
|
217
|
+
cur_ac, cur_vr = real["ac1"], dict(real["vr"])
|
|
218
|
+
for it in range(5):
|
|
219
|
+
st = _run(p, sim_variant, sim_days, seeds, sym, pip)
|
|
220
|
+
p.vol_base_adj *= (real["ann_vol_m1"] / _mean(st, "ann_vol_m1")) ** 1.0
|
|
221
|
+
# simulation feedback: scheduled-event spikes add permanent variance, so correct the
|
|
222
|
+
# analytic microstructure targets by the simulated-vs-real gap and refit
|
|
223
|
+
if mic:
|
|
224
|
+
cur_ac += 0.8 * (real["ac1"] - _mean(st, "ac1"))
|
|
225
|
+
for k in cur_vr:
|
|
226
|
+
cur_vr[k] += 0.8 * (real["vr"][k] - _mean(st, "vr", k))
|
|
227
|
+
mic2 = fit_micro(cur_ac, cur_vr)
|
|
228
|
+
for k, v in mic2.items():
|
|
229
|
+
setattr(p, k, v)
|
|
230
|
+
lo_, hi_ = 0.15, 1.2
|
|
231
|
+
for _ in range(7):
|
|
232
|
+
mid = 0.5 * (lo_ + hi_)
|
|
233
|
+
stt = _run(p.copy(range_scale=mid), sim_variant, 15, seeds[:2], sym, pip)
|
|
234
|
+
if _mean(stt, "range_mean_pips") > real["range_mean_pips"]:
|
|
235
|
+
hi_ = mid
|
|
236
|
+
else:
|
|
237
|
+
lo_ = mid
|
|
238
|
+
p.range_scale = 0.5 * (lo_ + hi_)
|
|
239
|
+
# ---- final polish on longer simulations (range scale and level)
|
|
240
|
+
for _ in range(3):
|
|
241
|
+
st = _run(p, sim_variant, 120, seeds, sym, pip)
|
|
242
|
+
p.range_scale *= (real["range_mean_pips"] / _mean(st, "range_mean_pips")) ** 1.2
|
|
243
|
+
p.vol_base_adj *= (real["ann_vol_m1"] / _mean(st, "ann_vol_m1")) ** 0.8
|
|
244
|
+
prov["bar range"] = f"calibrated (mean {real['range_mean_pips']:.2f} pips)"
|
|
245
|
+
p.provenance = prov
|
|
246
|
+
p.meta.update({"calibrated_from": str(src) if not isinstance(src, pd.DataFrame) else "DataFrame",
|
|
247
|
+
"n_bars": real["n_bars"], "period_utc": [str(df.index[0]), str(df.index[-1])],
|
|
248
|
+
"tz": tz, "variant": variant})
|
|
249
|
+
log("[calibrate] done: vol_base_adj=%.3f range_scale=%.3f garch=(%.3f, %.3f) micro=%s" % (
|
|
250
|
+
p.vol_base_adj, p.range_scale, p.garch_alpha, p.garch_beta,
|
|
251
|
+
{k: round(getattr(p, k), 3) for k in ("fx", "vf", "vs", "phi_f", "phi_s")}))
|
|
252
|
+
return p
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ---------------------------------------------------------------- several files / markets / months
|
|
256
|
+
import json as _json
|
|
257
|
+
import os as _os
|
|
258
|
+
import re as _re
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class CalibrationSet:
|
|
262
|
+
"""Result of calibrate_many: per-market profiles, the realised correlation matrix of the data
|
|
263
|
+
(base markets, M15 returns) and metadata. apply() installs everything; save()/load() persist it."""
|
|
264
|
+
|
|
265
|
+
def __init__(self, profiles, corr=None, meta=None):
|
|
266
|
+
self.profiles, self.corr, self.meta = profiles, corr, meta or {}
|
|
267
|
+
|
|
268
|
+
def apply(self, correlation=True):
|
|
269
|
+
from .profiles import use_profile
|
|
270
|
+
from . import factors
|
|
271
|
+
for sym, p in self.profiles.items():
|
|
272
|
+
use_profile(p, sym)
|
|
273
|
+
if correlation and self.corr is not None and self.corr.shape[0] >= 2:
|
|
274
|
+
factors.set_correlation(self.corr)
|
|
275
|
+
return self
|
|
276
|
+
|
|
277
|
+
def save(self, folder):
|
|
278
|
+
_os.makedirs(folder, exist_ok=True)
|
|
279
|
+
for sym, p in self.profiles.items():
|
|
280
|
+
p.save(_os.path.join(folder, f"{sym}.json"))
|
|
281
|
+
if self.corr is not None:
|
|
282
|
+
self.corr.to_csv(_os.path.join(folder, "correlation.csv"))
|
|
283
|
+
with open(_os.path.join(folder, "meta.json"), "w") as f:
|
|
284
|
+
_json.dump(self.meta, f, indent=2, default=str)
|
|
285
|
+
return folder
|
|
286
|
+
|
|
287
|
+
@classmethod
|
|
288
|
+
def load(cls, folder):
|
|
289
|
+
profs = {fn[:-5]: Profile.load(_os.path.join(folder, fn)) for fn in sorted(_os.listdir(folder))
|
|
290
|
+
if fn.endswith(".json") and fn != "meta.json"}
|
|
291
|
+
cp = _os.path.join(folder, "correlation.csv")
|
|
292
|
+
corr = pd.read_csv(cp, index_col=0) if _os.path.exists(cp) else None
|
|
293
|
+
mp = _os.path.join(folder, "meta.json")
|
|
294
|
+
meta = _json.load(open(mp)) if _os.path.exists(mp) else {}
|
|
295
|
+
return cls(profs, corr, meta)
|
|
296
|
+
|
|
297
|
+
def __repr__(self):
|
|
298
|
+
return f"CalibrationSet(markets={sorted(self.profiles)}, correlation={'yes' if self.corr is not None else 'no'})"
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _group_files(files):
|
|
302
|
+
if isinstance(files, dict):
|
|
303
|
+
return {get_market(k).symbol: (v if isinstance(v, (list, tuple)) else [v]) for k, v in files.items()}
|
|
304
|
+
out = {}
|
|
305
|
+
for f in files:
|
|
306
|
+
m = _re.search(r"DAT_(?:MT|ASCII)_([A-Za-z]{6})_M1_", _os.path.basename(str(f)))
|
|
307
|
+
if not m:
|
|
308
|
+
raise ValueError(f"cannot read the market from file name '{f}'; pass a dict {{symbol: paths}} instead")
|
|
309
|
+
out.setdefault(m.group(1).upper(), []).append(f)
|
|
310
|
+
return out
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def calibrate_many(files, tz="America/New_York", variant="auto", fit_correlation=True,
|
|
314
|
+
sim_days=30, seeds=(11, 12), verbose=True):
|
|
315
|
+
"""Calibrate several markets, each from one or many monthly files, and measure their correlations.
|
|
316
|
+
|
|
317
|
+
files : {symbol: path | [paths] | DataFrame} or a list of HistData files named
|
|
318
|
+
DAT_MT_<SYMBOL>_M1_<YYYYMM>.csv (the symbol is read from the name).
|
|
319
|
+
variant : 'auto' (default) -> 'calm_aug2026' if the pooled data's volatility is below 75% of the
|
|
320
|
+
market's long-run level, else 'typical'; or force one of them.
|
|
321
|
+
The months of one market are pooled into ONE profile (no regime fitting: pooled level, pooled
|
|
322
|
+
seasonality, pooled microstructure). Derived crosses (EURGBP, EURJPY, GBPJPY) are skipped: they
|
|
323
|
+
follow their legs. The correlation matrix is measured from M15 returns of the base markets over
|
|
324
|
+
their common period, and installed by CalibrationSet.apply()."""
|
|
325
|
+
log = (lambda *a: print(*a)) if verbose else (lambda *a: None)
|
|
326
|
+
groups = _group_files(files)
|
|
327
|
+
profiles, data, meta = {}, {}, {}
|
|
328
|
+
from .profiles import get_profile
|
|
329
|
+
for sym, srcs in groups.items():
|
|
330
|
+
mk = get_market(sym)
|
|
331
|
+
if mk.legs:
|
|
332
|
+
log(f"[calibrate_many] {sym}: derived cross, skipped (follows its legs)")
|
|
333
|
+
continue
|
|
334
|
+
frames = [read_histdata(s, tz) if not isinstance(s, pd.DataFrame) else read_histdata(s, "UTC") for s in srcs]
|
|
335
|
+
df = pd.concat(frames).sort_index()
|
|
336
|
+
df = df[~df.index.duplicated(keep="first")]
|
|
337
|
+
data[sym] = df
|
|
338
|
+
r = report.returns_m1(df)
|
|
339
|
+
vol = float(r.std() * math.sqrt(260 * 1440))
|
|
340
|
+
v = variant
|
|
341
|
+
if variant == "auto":
|
|
342
|
+
v = "calm_aug2026" if vol < 0.75 * get_profile(sym).ann_vol_long else "typical"
|
|
343
|
+
log(f"[calibrate_many] {sym}: {len(srcs)} file(s), {len(df)} bars, M1-implied vol {vol:.2%} -> variant '{v}'")
|
|
344
|
+
p = calibrate(df, market=sym, tz="UTC", variant=v, sim_days=sim_days, seeds=seeds, verbose=False)
|
|
345
|
+
p.meta["n_files"] = len(srcs)
|
|
346
|
+
profiles[sym] = p
|
|
347
|
+
meta[sym] = dict(variant=v, bars=len(df), vol_m1=vol, period_utc=[str(df.index[0]), str(df.index[-1])])
|
|
348
|
+
corr = None
|
|
349
|
+
if fit_correlation:
|
|
350
|
+
from .factors import BASE
|
|
351
|
+
closes = {s: np.log(d["close"].resample("15min").last()) for s, d in data.items() if s in BASE}
|
|
352
|
+
if len(closes) >= 2:
|
|
353
|
+
R = pd.DataFrame({s: c.diff() for s, c in closes.items()}).dropna()
|
|
354
|
+
if len(R) >= 500:
|
|
355
|
+
corr = R.corr()
|
|
356
|
+
log(f"[calibrate_many] correlation from {len(R)} common M15 returns of {sorted(closes)}")
|
|
357
|
+
return CalibrationSet(profiles, corr, meta)
|
forexgen/cli.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Command line: python -m forexgen EURUSD M15 3m --seed 7 --out data"""
|
|
2
|
+
import argparse
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from . import api
|
|
7
|
+
from .markets import list_markets
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main(argv=None):
|
|
11
|
+
ap = argparse.ArgumentParser(prog="forexgen", description="Offline synthetic FX / gold data generator.")
|
|
12
|
+
ap.add_argument("market", nargs="?", help="e.g. EURUSD GBPUSD XAUUSD (see --list)")
|
|
13
|
+
ap.add_argument("timeframe", nargs="?", default="M15", help="M1 M5 M15 M30 H1 H4 D1 W1 (default M15)")
|
|
14
|
+
ap.add_argument("duration", nargs="?", default="30d", help="30d, 2w, 3m, 1y, 12h ... (default 30d)")
|
|
15
|
+
ap.add_argument("--start", default=api.DEFAULT_START, help="UTC start (default %(default)s)")
|
|
16
|
+
ap.add_argument("--end", default=None, help="UTC end (overrides duration)")
|
|
17
|
+
ap.add_argument("--seed", type=int, default=None, help="omit for different data every run; the seed used is printed")
|
|
18
|
+
ap.add_argument("--start-price", type=float, default=None)
|
|
19
|
+
ap.add_argument("--out", default="forex_data", help="output folder")
|
|
20
|
+
ap.add_argument("--file", default=None, help="exact output file path")
|
|
21
|
+
ap.add_argument("--list", action="store_true", help="list markets")
|
|
22
|
+
ap.add_argument("--profile", default="typical", help="typical | calm_aug2026 | stressed")
|
|
23
|
+
ap.add_argument("--scenario", default=None, help="flash_crash peg_break rate_shock trend_up trend_down range "
|
|
24
|
+
"high_vol low_liquidity gap intervention crisis")
|
|
25
|
+
ap.add_argument("--scenario-at", default="0.5", help="timestamp or fraction 0..1 (default 0.5)")
|
|
26
|
+
ap.add_argument("--scenario-strength", type=float, default=1.0)
|
|
27
|
+
ap.add_argument("--sparse", action="store_true", help="broker-like flat bars and missing minutes")
|
|
28
|
+
ap.add_argument("--labels", action="store_true", help="add regime, event_flag, true_vol columns")
|
|
29
|
+
ap.add_argument("--ticks", action="store_true", help="write bid/ask ticks instead of bars")
|
|
30
|
+
ap.add_argument("--describe", action="store_true", help="show calibrated vs assumed parameters of the market")
|
|
31
|
+
ap.add_argument("--multi", default=None, help="comma list of markets simulated jointly, e.g. EURUSD,GBPUSD,XAUUSD")
|
|
32
|
+
ap.add_argument("--calibrate", default=None, metavar="CSV", help="fit a profile from a real HistData/MT M1 file")
|
|
33
|
+
ap.add_argument("--save-profile", default=None, help="where to save the calibrated profile (JSON)")
|
|
34
|
+
ap.add_argument("--tz", default="America/New_York", help="time zone of the calibration file")
|
|
35
|
+
ap.add_argument("--daily-anchor", default="utc", choices=["utc", "ny"], help="cut D1/W1 bars at UTC midnight or the 17:00 New York close")
|
|
36
|
+
ap.add_argument("--histdata", action="store_true", help="write M1 data in HistData layout (one file per New York month) instead of a bar CSV")
|
|
37
|
+
ap.add_argument("--histdata-fmt", default="mt", choices=["mt", "ascii"], help="mt: New York time, ascii: fixed EST")
|
|
38
|
+
ap.add_argument("--selftest", action="store_true", help="run the generator self-test (trend/mean-reversion bias, market-like statistics)")
|
|
39
|
+
a = ap.parse_args(argv)
|
|
40
|
+
|
|
41
|
+
if a.list:
|
|
42
|
+
print("\n".join(list_markets()))
|
|
43
|
+
return 0
|
|
44
|
+
if a.calibrate:
|
|
45
|
+
from .calibration import calibrate
|
|
46
|
+
p = calibrate(a.calibrate, market=a.market or "EURUSD", tz=a.tz)
|
|
47
|
+
if a.save_profile:
|
|
48
|
+
print("saved profile ->", p.save(a.save_profile))
|
|
49
|
+
p.describe()
|
|
50
|
+
return 0
|
|
51
|
+
if a.describe and a.market:
|
|
52
|
+
from .profiles import describe
|
|
53
|
+
describe(a.market)
|
|
54
|
+
return 0
|
|
55
|
+
if a.selftest:
|
|
56
|
+
from .diagnostics import selftest
|
|
57
|
+
rep = selftest(a.market or "EURUSD", profile=a.profile, seed=a.seed)
|
|
58
|
+
return 0 if rep.passed else 1
|
|
59
|
+
if not a.market and not a.multi:
|
|
60
|
+
ap.error("give a market (or --list)")
|
|
61
|
+
try:
|
|
62
|
+
at = float(a.scenario_at) if a.scenario_at.replace(".", "", 1).isdigit() else a.scenario_at
|
|
63
|
+
kw = dict(profile=a.profile, scenario=a.scenario, scenario_at=at,
|
|
64
|
+
scenario_strength=a.scenario_strength)
|
|
65
|
+
if a.multi:
|
|
66
|
+
data = api.get_multi([m.strip() for m in a.multi.split(",")], a.timeframe, a.duration,
|
|
67
|
+
start=a.start, end=a.end, seed=a.seed, sparse=a.sparse,
|
|
68
|
+
return_labels=a.labels, **kw)
|
|
69
|
+
os.makedirs(a.out, exist_ok=True)
|
|
70
|
+
for sym, df in data.items():
|
|
71
|
+
path = os.path.join(a.out, f"{sym}_{a.timeframe}_{a.duration}_seed{df.attrs['seed']}.csv")
|
|
72
|
+
df.to_csv(path, date_format=api.CSV_DATE_FORMAT)
|
|
73
|
+
print("saved ->", path)
|
|
74
|
+
print("seed ->", next(iter(data.values())).attrs["seed"])
|
|
75
|
+
return 0
|
|
76
|
+
if a.histdata:
|
|
77
|
+
from .export import generate_histdata
|
|
78
|
+
df0 = api.get_data(a.market, "M1", a.duration, start=a.start, end=a.end, seed=a.seed,
|
|
79
|
+
start_price=a.start_price, **kw)
|
|
80
|
+
from .export import export_histdata
|
|
81
|
+
paths = export_histdata(df0, a.out, a.market, a.histdata_fmt, None, True)
|
|
82
|
+
for p in paths:
|
|
83
|
+
print("saved ->", p)
|
|
84
|
+
print("seed ->", df0.attrs["seed"])
|
|
85
|
+
return 0
|
|
86
|
+
if a.ticks:
|
|
87
|
+
from .ticks import get_ticks
|
|
88
|
+
df = get_ticks(a.market, a.duration, start=a.start, end=a.end, seed=a.seed, **kw)
|
|
89
|
+
os.makedirs(a.out, exist_ok=True)
|
|
90
|
+
path = a.file or os.path.join(a.out, f"{a.market.upper()}_ticks_{a.duration}_seed{df.attrs['seed']}.csv")
|
|
91
|
+
df.to_csv(path, date_format="%Y-%m-%d %H:%M:%S.%f")
|
|
92
|
+
else:
|
|
93
|
+
df = api.get_data(a.market, a.timeframe, a.duration, start=a.start, end=a.end, seed=a.seed,
|
|
94
|
+
start_price=a.start_price, sparse=a.sparse, return_labels=a.labels,
|
|
95
|
+
daily_anchor=a.daily_anchor, **kw)
|
|
96
|
+
path = a.file or api.default_csv_path(a.market, a.timeframe, a.duration, df.attrs["seed"], a.out, a.end)
|
|
97
|
+
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
|
98
|
+
df.to_csv(path, date_format=api.CSV_DATE_FORMAT)
|
|
99
|
+
print("saved ->", path)
|
|
100
|
+
print("seed ->", df.attrs["seed"])
|
|
101
|
+
return 0
|
|
102
|
+
except ValueError as e:
|
|
103
|
+
print(f"error: {e}", file=sys.stderr)
|
|
104
|
+
return 2
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
sys.exit(main())
|
forexgen/costs.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Trading-cost helpers for backtests: slippage, gap-aware stop fills, swap/rollover.
|
|
2
|
+
|
|
3
|
+
All sizes are in pips of the market. These are MODELS (assumptions), not broker data.
|
|
4
|
+
"""
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
from .marketcal import to_ny
|
|
9
|
+
from .markets import get_market
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def slippage(df, market="EURUSD", order_type="market", model="vol", fixed_pips=0.2):
|
|
13
|
+
"""Slippage in pips per bar.
|
|
14
|
+
|
|
15
|
+
model 'vol' : 0.25 * spread + 0.10 * bar range (grows with volatility; stops pay double)
|
|
16
|
+
model 'fixed': fixed_pips
|
|
17
|
+
model 'none' : 0
|
|
18
|
+
order_type : 'market' | 'stop' | 'limit' (limit orders: 0 slippage, but may not fill)."""
|
|
19
|
+
mk = get_market(market)
|
|
20
|
+
if model == "none" or order_type == "limit":
|
|
21
|
+
return pd.Series(0.0, index=df.index)
|
|
22
|
+
if model == "fixed":
|
|
23
|
+
base = pd.Series(float(fixed_pips), index=df.index)
|
|
24
|
+
elif model == "vol":
|
|
25
|
+
rng_pips = (df["high"] - df["low"]) / mk.pip
|
|
26
|
+
base = 0.25 * df["spread_pips"] + 0.10 * rng_pips
|
|
27
|
+
else:
|
|
28
|
+
raise ValueError("model must be 'vol', 'fixed' or 'none'")
|
|
29
|
+
return base * (2.0 if order_type == "stop" else 1.0)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def stop_fill(stop_price, side, bar_open, bar_high, bar_low, slip_pips=0.0, pip=1e-4):
|
|
33
|
+
"""Fill of a stop order inside one bar. side: 'sell' (stop below the market, e.g. closing a
|
|
34
|
+
long) or 'buy' (stop above). Returns (filled: bool, price). If the bar OPENS beyond the stop
|
|
35
|
+
(a gap) the order fills at the OPEN, never at the stop price."""
|
|
36
|
+
if side == "sell":
|
|
37
|
+
if bar_low > stop_price:
|
|
38
|
+
return False, None
|
|
39
|
+
if bar_open <= stop_price:
|
|
40
|
+
return True, bar_open - slip_pips * pip
|
|
41
|
+
return True, stop_price - slip_pips * pip
|
|
42
|
+
if side == "buy":
|
|
43
|
+
if bar_high < stop_price:
|
|
44
|
+
return False, None
|
|
45
|
+
if bar_open >= stop_price:
|
|
46
|
+
return True, bar_open + slip_pips * pip
|
|
47
|
+
return True, stop_price + slip_pips * pip
|
|
48
|
+
raise ValueError("side must be 'sell' or 'buy'")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def rollover_bars(index_utc):
|
|
52
|
+
"""Boolean array: bars at the 17:00 New York rollover (Mon-Thu). Wednesday counts triple."""
|
|
53
|
+
ny = to_ny(index_utc)
|
|
54
|
+
at = (ny.hour == 17) & (ny.minute == 0) & (ny.weekday < 4)
|
|
55
|
+
return np.asarray(at), np.where(ny.weekday == 2, 3.0, 1.0)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def swap_pips(index_utc, position, swap_long_pips, swap_short_pips):
|
|
59
|
+
"""Swap earned (+) / paid (-) in pips, charged on the 17:00 NY bar for the position held
|
|
60
|
+
through the rollover; Wednesday is charged triple. `position` is a Series/array in [-1, 1]
|
|
61
|
+
aligned with the index (M1 or M5..H1 data; coarser bars may skip the 17:00 bar)."""
|
|
62
|
+
at, mult = rollover_bars(index_utc)
|
|
63
|
+
pos = np.asarray(position, float)
|
|
64
|
+
rate = np.where(pos > 0, swap_long_pips, swap_short_pips) * np.abs(pos)
|
|
65
|
+
out = np.where(at, rate * mult, 0.0)
|
|
66
|
+
return pd.Series(out, index=index_utc)
|