"""SimMarket: the one shared simulated market of the course (B1 onwards and every E lesson). Simulated, no market data. Paste into a lesson script in course/sim to use it: import sys; from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) # python3 -I leaves the script's folder off the path from simmarket import market, universe, export Every lesson imports this module; no lesson makes its own market. Run `python3 -I course/sim/simmarket.py` for the self-test. Design fixed before the first run (9 Nov 2026); the seed is never re-rolled: Seed and markets. SEED = 20261109. Five markets, ids 1-5 ("Market 1" ... "Market 5"; not lesson 0's markets A-E, which are a different, simpler simulation). Each market draws from its own stream, numpy default_rng([seed, id]), all draws made up front in a fixed order, so the same (seed, id) always gives the same data and switching any flag below never changes a random draw. Calendar and session. Trading days are Monday-Friday from 2015-01-05 to 2024-12-31 (2,607 days; weekends skipped, no holidays). Each day is one session, 09:30-16:00 exchange time (America/New_York wall clock, stored as minutes after midnight, so daylight saving never moves the session), cut into 78 five-minute bars labelled by their start time (09:30 ... 15:55). Between one day's 16:00 close and the next day's 09:30 open lies the overnight gap. Prices are mid prices (halfway between bid and ask). One day, step by step (log returns throughout): 1. Regime. A two-state Markov chain, calm (0) or wild (1). Calm stays calm with probability 0.99 a day, wild stays wild with 0.96: calm spells last 100 days on average, wild ones 25, and about 20% of days are wild. Day 0 is calm. 2. Volatility. sigma_t = scale x regime_vol x sqrt(h_t), regime_vol = 0.8% (calm) or 2.0% (wild), and h_t a GARCH(1,1) multiplier with long-run mean 1: h_t = 0.05 + 0.05 z_{t-1}^2 + 0.90 h_{t-1}, h_0 = 1. With scale 1 the long-run daily volatility is about 1.15% (about 18% a year). 3. The day's move. r_t = mu + effect_t + sigma_t z_t, mu = +0.03% a day (about 7.5% a year), z_t a Student-t with 6 degrees of freedom scaled to variance 1 (fat tails: kurtosis 6 before the clustering adds more). 4. THE PLANTED EFFECT (the one real edge, switch: effect=False). Each market publishes an invented daily reading, `signal`, at each day's close: a standard normal draw rounded to 2 decimals, independent from day to day and of everything else. It is made up and stands for nothing in the real world (no indicator, no data source, no public trading rule); it exists only so lessons have an edge to find. The next day's move gets an extra 0.10% x signal (effect_t = 0.0010 x signal_{t-1}: a reading of +1.5 adds 0.15%, a reading of -1 takes away 0.10%). Its correlation with the next day's move is about 0.08 (0.10% against a ~1.2% day), so over ten years (about 2,600 days) one market's slope is about 4 standard errors from zero at scale 1 (more in calmer markets, fewer in wilder ones), over a month (21 days) about 0.4: findable with ten years and an honest test, invisible in a month. With effect=False every draw is the same and the signal is still published; only the extra is removed, so the same market shows "no edge". The signal is drawn last, after every other draw. 5. Inside the day. The move is split into 79 pieces: the overnight gap (20% of the day's variance) and the 78 bars, whose variance follows an intraday U-shape u_i = 1 + 3 exp(-i/4) + 1.5 exp(-(77 - i)/4) (the first bar about twice the midday volatility, the last about 1.6 times). Each piece is Student-t(6) noise; a bridge then shifts the pieces so they add up exactly to r_t, which keeps each piece's variance (x_i = e_i + v_i (z - sum e)). The drift and effect are spread in proportion to the variances. A bar's high and low reach past max(open, close) and min(open, close) by half a standard normal draw's size times the bar's volatility. Daily OHLCV is the aggregate of the day's bars: open of the first, highest high, lowest low, close of the last, summed volume. This rule is asserted. Within the day (the bridge setting). With Student-t pieces (bridge="t", the default) the bridge leaves a small pull-back within the day: the day's total is one heavy-tailed draw while the pieces add up to something closer to a bell curve, so a moderate move earlier in the day is more often partly given back later. Measured on 100 markets (Market 1's generator, seeds 556000-556099, effect off; 260,700 days): the straight correlation of the morning's move (09:30 open to the 12:30 close) with the afternoon's is about zero (0.004), but on the 257,796 days with a move in both halves the afternoon went against the morning's direction on 52.47% (25 standard errors from 50%). A rule that is long while the close is above the running average of the day's closes made 2.01 basis points a day less than its opposite, long while below (t = -10.2). Daily lessons are not affected: a day's move, and every daily close, is the same under both settings. bridge="normal" removes the pull-back: it keeps each day's total exactly, draws that day's variance from its distribution given the total (a Student-t(6) scaled to variance 1 is a normal draw whose variance is InvGamma(3, 2); given the total z it is InvGamma(3.5, 2 + z^2 / 2)), then builds the bars from normal pieces with that variance (own stream, default_rng([seed, id, 2]), so the default's draws are untouched). The bars are then a random walk whose step size is set for the whole day, with no pull-back (the same 100 markets: against the morning on 49.95% of 257,482 days, 0.5 standard errors from 50%; the first rule made 0.07 basis points a day more than its opposite, t = 0.4). Under bridge="normal" the daily closes, volumes and the planted effect are identical (asserted); the intraday prices, and so each day's open, high, low and the spreads, differ. Lessons from E6 on that trade within the day use bridge="normal". 6. Volume and spread. Bar volume = the market's daily volume / 78 x u_i / mean(u) x sigma_t / typical sigma x a lognormal(0, 0.25) draw, rounded to whole shares. Bid-ask spread = price x the market's base spread x sqrt(u_i / mean(u)) x sigma_t / typical sigma, rounded to cents, at least $0.01: wider at the open and in the wild regime. A daily bar's spread is the mean of its bars' spreads. Prices are rounded to cents after everything else. Markets (scale, first price, base spread in basis points, shares a day): 1: 1.0, 100, 2, 2,000,000; 2: 0.8, 50, 3, 1,000,000; 3: 1.3, 250, 4, 500,000; 4: 1.6, 20, 8, 300,000; 5: 0.6, 1000, 1, 100,000. Data-quality hooks for B4 and D6, all off by default (off = clean, split- and dividend-adjusted data). Day numbers count the full 2,607-day calendar from 0. Each hook only changes the data as described, never a draw. - split=True: a 2-for-1 split before day 1500. Raw prices before it are doubled and volumes halved (the raw series shows a one-day 50% "crash"); the clean series is the split-adjusted one. - dividends=True: a cash dividend every 63 trading days from day 40, 0.5% of the previous raw close, rounded to cents. Raw prices before each ex-day are scaled up so the raw price drops by the dividend at that open; the clean series is the total-return (dividend-adjusted) one. Raw price-only returns therefore understate the total return by about 2% a year. - missing_day=True: day 900 is removed from the daily and intraday data (a weekday with no bars). - bad_print=True: the last bar of day 1200 closes 20% too high (its high too), so the daily close is wrong for one day. Survivorship hook for B3: universe(n) simulates n stocks, each moving with Market 1 (beta drawn from 0.5-1.5) plus its own Student-t(6) noise (daily volatility drawn from 1.5%-4%, no expected return of its own), first prices $10-$100. A stock is delisted on the first day its close falls below a quarter of its first price; it has no prices after that. include_delisted=False returns only the survivors (the biased list a backtest on "today's stocks" would use). Its stream is default_rng([seed, 1000]). summary() measures, for every market, the daily volatility, excess kurtosis, autocorrelation of squared returns, the wild share, the U-shape, the overnight share, total and yearly return, the planted effect (with and without it: the slope of the next day's move on the signal, in % per unit, and its t-statistic), and the universe's survivor flattery, and asserts each is in its intended range. Changed after the first run (9 Nov, same seed, same draws): a stock's own noise had a log drift of 0, which quietly gave every stock an expected return of its own (about sd^2 / 2 a day, up to 20% a year), so "all 500 stocks" earned 23% a year against Market 1's 12%. Its log drift is now -sd^2 / 2, so its own noise averages zero and a stock's expected return is beta x Market 1's, as intended. The day count was also corrected from 2,608 to 2,607 (the calendar was always the same). Changed 9 Nov after lead review, before any lesson used it: the planted effect was "+0.20% the day after three down closes in a row". That is a widely shared real-world trading idea, so a course that "finds" it in SimMarket would read like a tip that it works. It is replaced by the invented `signal` of step 4 (drawn last, so every other draw is unchanged; prices differ only through the effect). The signal is exported with the daily bars. Which market to pick (measured after the change, effect on; luck of the draw, never re-rolled; see summary()), over the ten years: Market 1 +89% (6.6% a year) and Market 5 +106% (7.5% a year) look ordinary, close to the design drift (about 8% a year). Market 4 +47% (3.9% a year) is a little weak and Market 2 +7% (0.7% a year) is a flat decade, both plausible. Market 3 is a luck outlier: +1,167% (29% a year). A lesson that shows Market 3 should say it is a lucky draw, or pick another. The planted effect's t-statistic is 2.8 (Market 4) to 7.5 (Market 5); with effect=False, -1.2 to 0.5. """ import json from types import SimpleNamespace as NS import numpy as np SEED = 20261109 DAYS = np.array([d for d in np.arange("2015-01-05", "2025-01-01", dtype="datetime64[D]") if np.is_busday(d)]) BARS, BAR_MINUTES, OPEN_MINUTE = 78, 5, 9 * 60 + 30 # 09:30-16:00, America/New_York wall clock MINUTES = OPEN_MINUTE + BAR_MINUTES * np.arange(BARS) # each bar's start time, minutes after midnight TZ = "America/New_York" MU, EFFECT, DF = 0.0003, 0.0010, 6 # daily drift, extra per unit of signal, Student-t df REGIME_VOL, STAY = (0.008, 0.020), (0.99, 0.96) # calm / wild daily volatility, chance to stay a day GARCH_A, GARCH_B = 0.05, 0.90 # h_t = (1 - A - B) + A z^2 + B h OVERNIGHT = 0.20 # overnight share of a day's variance U = 1 + 3 * np.exp(-np.arange(BARS) / 4) + 1.5 * np.exp(-(BARS - 1 - np.arange(BARS)) / 4) # intraday U-shape V = np.concatenate([[OVERNIGHT], (1 - OVERNIGHT) * U / U.sum()]) # variance share of 79 pieces TYPICAL_VOL = np.sqrt(0.8 * REGIME_VOL[0] ** 2 + 0.2 * REGIME_VOL[1] ** 2) # about 1.15% at scale 1 MARKETS = { # id: (scale, first price, base spread in basis points, shares a day) 1: (1.0, 100.0, 2, 2_000_000), 2: (0.8, 50.0, 3, 1_000_000), 3: (1.3, 250.0, 4, 500_000), 4: (1.6, 20.0, 8, 300_000), 5: (0.6, 1000.0, 1, 100_000)} SPLIT_DAY, SPLIT_RATIO = 1500, 2 DIV_FIRST, DIV_EVERY, DIV_YIELD = 40, 63, 0.005 MISSING_DAY, BAD_DAY, BAD_FACTOR = 900, 1200, 1.2 def _t(rng, size): """Student-t draws scaled to variance 1.""" return rng.standard_t(DF, size) * np.sqrt((DF - 2) / DF) def market(mid=1, seed=SEED, effect=True, split=False, dividends=False, missing_day=False, bad_print=False, bridge="t"): """One simulated market: daily and 5-minute bars, consistent with each other. See the module docstring. bridge="t" (the default) builds each day's bars from Student-t pieces; bridge="normal" from normal pieces whose variance is drawn for each day given its total (see "Within the day" in the module docstring).""" assert bridge in ("t", "normal"), bridge scale, p0, spread_bp, shares = MARKETS[mid] T = len(DAYS) rng = np.random.default_rng([seed, mid]) # All draws first, in a fixed order: flags never change them. u_regime = rng.random(T) z = _t(rng, T) e = _t(rng, (T, BARS + 1)) * np.sqrt(V) hl = np.abs(rng.standard_normal((T, BARS, 2))) vol_noise = rng.lognormal(0.0, 0.25, (T, BARS)) signal = np.round(rng.standard_normal(T), 2) # the invented daily reading (step 4), drawn last # Day by day: regime, GARCH volatility, the move (with the planted effect from yesterday's signal). regime, sigma, r = np.zeros(T, int), np.zeros(T), np.zeros(T) extra = np.zeros(T) h = 1.0 for t in range(T): if t > 0: regime[t] = regime[t - 1] if u_regime[t] < STAY[regime[t - 1]] else 1 - regime[t - 1] h = (1 - GARCH_A - GARCH_B) + GARCH_A * z[t - 1] ** 2 + GARCH_B * h sigma[t] = scale * REGIME_VOL[regime[t]] * np.sqrt(h) if effect and t > 0: extra[t] = EFFECT * signal[t - 1] # yesterday's reading nudges today's move r[t] = MU + extra[t] + sigma[t] * z[t] # Inside each day: a bridge makes the 79 pieces add up to r_t exactly. x = e + V * (z - e.sum(1))[:, None] pieces = sigma[:, None] * x + (MU + extra)[:, None] * V prev_close = np.log(p0) + np.concatenate([[0.0], np.cumsum(r)[:-1]]) path = prev_close[:, None] + np.cumsum(pieces, axis=1) # column 0 = the open, columns 1..78 = bar closes if bridge == "normal": # Own stream, so the default path's draws are untouched. Student-t(6) scaled to variance 1 is a normal draw whose # variance is InvGamma(3, 2); given the day's total z that variance is InvGamma(3.5, 2 + z^2 / 2). rng2 = np.random.default_rng([seed, mid, 2]) lam = 1.0 / rng2.gamma(3.5, 1.0 / (2.0 + z ** 2 / 2.0)) g = rng2.standard_normal((T, BARS + 1)) * np.sqrt(V) * np.sqrt(lam)[:, None] xn = g + V * (z - g.sum(1))[:, None] last = path[:, -1].copy() # the day's close, exactly as with the default bridge path = prev_close[:, None] + np.cumsum(sigma[:, None] * xn + (MU + extra)[:, None] * V, axis=1) path[:, -1] = last o, c = path[:, :-1], path[:, 1:] bar_sd = sigma[:, None] * np.sqrt(V[1:]) hi = np.maximum(o, c) + 0.5 * hl[..., 0] * bar_sd lo = np.minimum(o, c) - 0.5 * hl[..., 1] * bar_sd o, hi, lo, c = np.exp(o), np.exp(hi), np.exp(lo), np.exp(c) rel = sigma[:, None] / (scale * TYPICAL_VOL) * np.ones((1, BARS)) volume = shares / BARS * U / U.mean() * rel * vol_noise # Raw (unadjusted) data: scale history before each event; the clean series is the adjusted one. factor, vol_factor, events = np.ones(T), np.ones(T), [] hooks = [(SPLIT_DAY, "split")] * split + [(d, "dividend") for d in range(DIV_FIRST, T, DIV_EVERY)] * dividends for day, kind in sorted(hooks, reverse=True): # latest first, so each uses the raw close before it if kind == "split": factor[:day] *= SPLIT_RATIO vol_factor[:day] /= SPLIT_RATIO events.append(["split", str(DAYS[day]), SPLIT_RATIO]) else: prev = c[day - 1, -1] * factor[day - 1] # the raw close before the ex-day, before this dividend div = round(DIV_YIELD * prev, 2) factor[:day] *= (prev + div) / prev events.append(["dividend", str(DAYS[day]), div]) o, hi, lo, c = (a * factor[:, None] for a in (o, hi, lo, c)) volume = np.round(volume * vol_factor[:, None]) if bad_print: c[BAD_DAY, -1] *= BAD_FACTOR hi[BAD_DAY, -1] = max(hi[BAD_DAY, -1], c[BAD_DAY, -1]) events.append(["bad_print", str(DAYS[BAD_DAY]), BAD_FACTOR]) spread = np.maximum(0.01, np.round(c * spread_bp / 1e4 * np.sqrt(U / U.mean()) * rel, 2)) o, hi, lo, c = (np.round(a, 2) for a in (o, hi, lo, c)) keep = np.ones(T, bool) if missing_day: keep[MISSING_DAY] = False events.append(["missing_day", str(DAYS[MISSING_DAY]), 0]) intraday = NS(minutes=MINUTES, open=o[keep], high=hi[keep], low=lo[keep], close=c[keep], volume=volume[keep].astype(np.int64), spread=spread[keep]) flags = dict(effect=effect, split=split, dividends=dividends, missing_day=missing_day, bad_print=bad_print) if bridge != "t": flags["bridge"] = bridge # absent for the default, so default exports are unchanged return NS(id=mid, name=f"Market {mid}", seed=seed, flags=flags, dates=DAYS[keep], daily=aggregate(intraday), intraday=intraday, regime=regime[keep], sigma=sigma[keep], signal=signal[keep], events=sorted(events, key=lambda ev: ev[1])) def aggregate(bars): """Daily bars from 5-minute bars: first open, highest high, lowest low, last close, summed volume, mean spread.""" return NS(open=bars.open[:, 0], high=bars.high.max(1), low=bars.low.min(1), close=bars.close[:, -1], volume=bars.volume.sum(1), spread=np.round(bars.spread.mean(1), 3)) def universe(n=500, seed=SEED, include_delisted=True): """n simulated stocks moving with Market 1; a stock is delisted the first day it closes below 1/4 of its first price.""" m1 = market(1, seed) rm = np.diff(np.log(m1.daily.close), prepend=np.log(MARKETS[1][1])) rng = np.random.default_rng([seed, 1000]) beta, sd, p0 = rng.uniform(0.5, 1.5, n), rng.uniform(0.015, 0.04, n), np.round(rng.uniform(10, 100, n), 2) own = sd[:, None] * _t(rng, (n, len(rm))) - sd[:, None] ** 2 / 2 # the stock's own noise, average return 0 logp = np.log(p0)[:, None] + np.cumsum(beta[:, None] * rm + own, axis=1) close = np.round(np.exp(logp), 2) below = close < p0[:, None] / 4 delisted_at = np.where(below.any(1), below.argmax(1), -1) for i, d in enumerate(delisted_at): if d >= 0: close[i, d + 1:] = np.nan # no prices after the delisting day rows = np.arange(n) if include_delisted else np.flatnonzero(delisted_at < 0) return NS(dates=m1.dates, ids=rows, close=close[rows], beta=beta[rows], first_price=p0[rows], delisted_at=delisted_at[rows]) def _stats(close): r = np.diff(np.log(close)) d = r - r.mean() sq = r ** 2 - (r ** 2).mean() return r, dict(daily_vol_pct=100 * r.std(), excess_kurtosis=(d ** 4).mean() / (d ** 2).mean() ** 2 - 3, acf_sq_lag1=(sq[1:] * sq[:-1]).mean() / (sq ** 2).mean(), acf_lag1=(d[1:] * d[:-1]).mean() / (d ** 2).mean()) def _effect(r, signal): """Least-squares slope of the next day's move on today's signal, and its t-statistic. r[i] is close i to close i + 1.""" x, y = signal[:-1] - signal[:-1].mean(), r - r.mean() slope = (x * y).sum() / (x * x).sum() resid = y - slope * x return slope, slope / np.sqrt((resid ** 2).sum() / (len(x) - 2) / (x * x).sum()) def summary(seed=SEED, check=True): """Key statistics of every market and of the universe; with check=True, assert each is in its intended range.""" out, pooled = {}, {True: [], False: []} years = (DAYS[-1] - DAYS[0]).astype(int) / 365.25 for mid, (scale, *_rest) in MARKETS.items(): m = market(mid, seed) r, s = _stats(m.daily.close) ib = m.intraday first = np.log(ib.close[:, 0] / ib.open[:, 0]) mid_bars = np.log(ib.close[:, 30:48] / ib.open[:, 30:48]) gap = np.log(m.daily.open[1:] / m.daily.close[:-1]) s.update(design_vol_pct=100 * scale * TYPICAL_VOL, wild_share=m.regime.mean(), u_shape_first_vs_midday=first.std() / mid_bars.std(), overnight_var_share=gap.var() / r.var(), mean_spread_bp=1e4 * (m.daily.spread / m.daily.close).mean(), total_return_pct=100 * (m.daily.close[-1] / MARKETS[mid][1] - 1), pct_a_year=100 * ((m.daily.close[-1] / MARKETS[mid][1]) ** (1 / years) - 1)) for on in (True, False): rr = r if on else _stats(market(mid, seed, effect=False).daily.close)[0] slope, tstat = _effect(rr, m.signal) pooled[on].append(slope) s["effect_pct_per_unit" if on else "effect_off_pct_per_unit"] = 100 * slope s["effect_t" if on else "effect_off_t"] = tstat out[m.name] = {k: round(float(v), 4) for k, v in s.items()} for on in (True, False): out["effect_pooled_pct_per_unit" if on else "effect_off_pooled_pct_per_unit"] = round(100 * float(np.mean(pooled[on])), 4) u = universe(500, seed) alive = u.delisted_at < 0 daily = np.diff(u.close, axis=1) / u.close[:, :-1] # NaN once a stock has gone ew = lambda rows: 100 * ((1 + np.nanmean(daily[rows], axis=0)).prod() ** (1 / years) - 1) out["universe"] = dict(stocks=500, delisted=int((~alive).sum()), delisted_share=round(float((~alive).mean()), 4), all_stocks_pct_a_year=round(float(ew(np.arange(500))), 2), survivors_only_pct_a_year=round(float(ew(alive)), 2)) if check: for name, s in out.items(): if not name.startswith("Market"): continue assert 0.7 < s["daily_vol_pct"] / s["design_vol_pct"] < 1.3, (name, s) assert 2 < s["excess_kurtosis"] < 30, (name, s) # fat tails assert 0.05 < s["acf_sq_lag1"] < 0.6, (name, s) # volatility clustering assert abs(s["acf_lag1"]) < 0.08, (name, s) # no strong day-to-day pattern in the moves assert 0.08 < s["wild_share"] < 0.40, (name, s) assert 1.6 < s["u_shape_first_vs_midday"] < 2.6, (name, s) assert 0.12 < s["overnight_var_share"] < 0.30, (name, s) assert 0.06 < out["effect_pooled_pct_per_unit"] < 0.14, out["effect_pooled_pct_per_unit"] assert abs(out["effect_off_pooled_pct_per_unit"]) < 0.04, out["effect_off_pooled_pct_per_unit"] un = out["universe"] assert 0.10 < un["delisted_share"] < 0.45, un assert un["survivors_only_pct_a_year"] > un["all_stocks_pct_a_year"] + 1, un return out def to_json(m, intraday_days=None): """Compact JSON-ready dict: columns as arrays, prices in cents. intraday_days=(first, stop) adds those days' 5-minute bars.""" d = m.daily j = dict(name=m.name, seed=m.seed, simulated=True, flags=m.flags, tz=TZ, session="09:30-16:00", bar_minutes=BAR_MINUTES, date=[str(x) for x in m.dates], o=d.open.tolist(), h=d.high.tolist(), l=d.low.tolist(), c=d.close.tolist(), v=d.volume.tolist(), s=d.spread.tolist(), regime=m.regime.tolist(), signal=m.signal.tolist(), events=m.events) if intraday_days is not None: a, b = intraday_days ib = m.intraday j["intraday"] = dict(first=a, minutes=MINUTES.tolist(), o=ib.open[a:b].tolist(), h=ib.high[a:b].tolist(), l=ib.low[a:b].tolist(), c=ib.close[a:b].tolist(), v=ib.volume[a:b].tolist(), s=ib.spread[a:b].tolist()) return j def export(m, path, intraday_days=None): """Write to_json(m) to path (no spaces) for a browser lesson; read it with course/assets/simmarket.js.""" with open(path, "w") as f: json.dump(to_json(m, intraday_days), f, separators=(",", ":")) def _same(a, b): return all(np.array_equal(getattr(a, k), getattr(b, k)) for k in vars(a) if k != "minutes") if __name__ == "__main__": import tempfile from pathlib import Path # Determinism: the same call gives the same data; different markets differ. a, b = market(1), market(1) assert _same(a.daily, b.daily) and _same(a.intraday, b.intraday) and a.events == b.events assert not np.array_equal(market(2).daily.close / 50, a.daily.close / 100) assert len(DAYS) == 2607 and len(a.dates) == 2607 and a.intraday.close.shape == (2607, BARS) # The aggregation rule and bar sanity, on clean data and with every hook on. every = market(1, split=True, dividends=True, missing_day=True, bad_print=True) for m in (a, every): assert _same(m.daily, aggregate(m.intraday)) ib = m.intraday assert (ib.open[:, 1:] == ib.close[:, :-1]).all() # each bar opens where the last one closed assert (ib.high >= np.maximum(ib.open, ib.close)).all() and (ib.low <= np.minimum(ib.open, ib.close)).all() assert (ib.low > 0).all() and (ib.spread >= 0.01).all() # Hooks change only what they say. off = market(1, effect=False) assert all(np.array_equal(getattr(off, k), getattr(a, k)) for k in ("sigma", "regime", "signal")) # same draws assert len(every.dates) == 2606 and DAYS[MISSING_DAY] not in every.dates sp = market(1, split=True) assert np.allclose(sp.daily.close[:SPLIT_DAY], 2 * a.daily.close[:SPLIT_DAY], atol=0.011) assert np.array_equal(sp.daily.close[SPLIT_DAY:], a.daily.close[SPLIT_DAY:]) bp = market(1, bad_print=True) assert abs(bp.daily.close[BAD_DAY] / a.daily.close[BAD_DAY] - BAD_FACTOR) < 1e-3 dv = market(1, dividends=True) assert len(dv.events) == len(range(DIV_FIRST, len(DAYS), DIV_EVERY)) and dv.daily.close[0] > a.daily.close[0] # bridge="normal": the same daily closes and volumes, other intraday prices. nb = market(1, bridge="normal") assert np.array_equal(nb.daily.close, a.daily.close) and np.array_equal(nb.intraday.volume, a.intraday.volume) assert not np.array_equal(nb.intraday.close, a.intraday.close) assert _same(nb.daily, aggregate(nb.intraday)) and (nb.intraday.open[:, 1:] == nb.intraday.close[:, :-1]).all() # Universe: deterministic, and survivors_only drops exactly the delisted. u, u2, s = universe(), universe(), universe(include_delisted=False) assert np.array_equal(u.close, u2.close, equal_nan=True) assert len(s.ids) == (u.delisted_at < 0).sum() and not np.isnan(s.close).any() # Export round trip. with tempfile.TemporaryDirectory() as tmp: p = Path(tmp) / "m1.json" export(every, p, intraday_days=(0, 5)) j = json.loads(p.read_text()) assert len(j["date"]) == len(j["c"]) == 2606 and len(j["intraday"]["c"]) == 5 and len(j["intraday"]["c"][0]) == BARS assert j["h"][3] == max(j["intraday"]["h"][3]) and j["c"][4] == j["intraday"]["c"][4][-1] print(f"export: {p.stat().st_size // 1024} KB for 2,606 daily bars + 5 days of 5-minute bars") print(json.dumps(summary(), indent=1)) print("SimMarket self-test passed")