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 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
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
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
@@ -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)