==================== 90 markdown ## 24. The map through the broker (2026-09-28) **The question.** The paper's Section 14 maps the rates and durations of both stages over the reward-to-risk and the risk from exact chains. This section runs the same grid through the deployed strategy on `HistoricalBarBroker`, with the account rules on, and asks two things. Do the broker's rates agree with the chains cell by cell? And how many calendar days does each duration take, which the chains count only in trades? #### Concepts used in this section **Account cycling.** What it is: the strategy scores its own account after every trade and, in a replay, opens the next account when one ends: an assessment after a pass or a failure, a funded account after a death, or, in the lifecycle mode, a funded account after a pass. How it is used: one pass of the broker over the cohort yields many accounts. How it affects the result: accounts are back to back on one price path, so the accounts of one replay are not all independent of the next replay's. Expected against found: below. **Seeds.** What it is: the coin side is replayed under 20 seeds. The prices are shared; the directions are not. How it is used: every statistic is computed per seed, averaged over seeds, and its standard error taken between seeds, so whatever the seeds share through the price path widens the error rather than hiding in it. How it affects the result: about twenty times the accounts of a single replay, which is what makes the low-risk cells, a handful of accounts per replay, readable at all. Expected against found: below. **Agreement in standard errors.** What it is: broker minus chain over the between-seed standard error, per cell. How it is used: a map of it, and the share of cells beyond two. How it affects the result: with some two hundred cells about one in twenty should sit beyond two by chance, so a share near five percent is agreement and a share far above it is a fault. Expected against found: below. #### Briefing, stated before scoring - **Data.** EURUSD vendor hourly bars, `historical_data/EURUSD/ohlcv/3600/*.ohlcv.parquet`, 2023-11-24 to 2026-09-10, UTC. A bar has no path inside it; one reaching both legs books the stop. - **Executor.** `PrimeAcademyStrategy` with `set_account` on: the \$3,000 tier, target and floor 9 percent, the target clipped in the assessment, the stop at the floor in both stages, the 14-trade payout clock, the \$143 minimum request, the 35 percent rule. The deployed stop, entries 07:00 to 10:00 UTC, commission \$2.50 a side from `meta.json`, no spread, no swap, the news rule off. - **Grid.** Assessment: $RR$ 0.5 to 3 against risk 1 to 4.5 percent. Funded: $RR$ 0.5 to 3 against risk 0.5 to 4 percent. Per purchase: the two risks at $RR = 1$, lifecycle mode. 192 cells, 20 coin seeds each plus one short-side replay, `modules/broker_map.py`, cached. - **Reference.** The exact chains of the paper's Section 14 at the broker's own cost: commission only, 0.5 pip over the deployed stop's mean. The paper's deployment figures add the spread; the broker cannot, so the chains carry it once the broker has confirmed them. - **Rule.** A cell agrees when its gap is under two between-seed standard errors. The layer agrees when the share of cells beyond two is under ten percent, twice what chance gives. - **What it cannot see.** The spread and the swap; the equity between closes, since the account is scored at each completed trade; any effect of trading several accounts at once. ==================== 91 code from src.notebooks.reports.topics.prime_academy.modules import broker_map as BM from src.notebooks.reports.topics.prime_academy.modules.mapplot import heat # the chains at the broker's cost: commission only, over the deployed stop's mean on the entry bars bars24 = FX.load(REPO / "historical_data/EURUSD/ohlcv_3600/eurusd_1h.parquet") raw24 = pd.read_parquet(REPO / "historical_data/EURUSD/ohlcv_3600/eurusd_1h.parquet") ts24 = raw24.ts_event.astype("int64").to_numpy() ts24 = ts24 // 10**9 if ts24.max() > 10**12 else ts24 dclose24 = pd.Series(bars24["c"]).groupby(bars24["day"]).last().to_numpy() sig24 = np.r_[np.nan, TF.sigma_kf(dclose24, rho_v=0.0025)[:-1] / FX.PIP][bars24["day"]] entry24 = (bars24["day"] > TF.SIGMA_SEED_N) & (ts24 % 86400 >= 7 * 3600) & (ts24 % 86400 < 10 * 3600) STOP24 = float(np.mean(np.maximum(0.75 * sig24[entry24], 30.0))) G_COMM = PA.cost_share(STOP24, s=0.0) G_FULL = PA.cost_share(STOP24) print(f"deployed stop, mean over entry bars: {STOP24:.1f} pips") print(f"cost share: commission only {G_COMM:.2%} of risk (the broker's); with the 0.5-pip spread {G_FULL:.2%} (the paper's)") CH_E = {(rr, r): PA.eval_chain(r, G_COMM * r, rr=rr, clip_up=True, clip_dn=True) for rr in BM.RR for r in BM.RA} CH_F = {(rr, r): PA.funded_chain(r, G_COMM * r, PA.payout_threshold(r, rr, BM.BANKROLL, BM.SPLIT), rr=rr, clip_dn=True) for rr in BM.RR for r in BM.RF} print(f"chains: {len(CH_E)} assessment cells, {len(CH_F)} funded cells") ==================== 92 code # the broker, from the cache the background sweep wrote; any cell missing is replayed here BR = {} for c in BM.cells(): res = BM.run_cell(*c) BR[c] = {"coin": BM.stats(res), "short": BM.stats(res, "short")} missing = [BM.key(*c) for c in BM.cells() if not (BM.CACHE / f"{BM.key(*c)}.json").is_file()] n_acc = [BR[c]["coin"].get("n_eval", BR[c]["coin"].get("n_funded")) for c in BM.cells() if c[0] != "life"] print(f"broker: {len(BR)} cells, {len(BM.SEEDS)} coin seeds each and one short replay; missing from cache: {len(missing)}") print(f"accounts per replay per cell: median {np.median(n_acc):.1f}, fewest {np.min(n_acc):.1f}, most {np.max(n_acc):.1f}") ==================== 93 code def gap_grid(kind, stat, chain_stat, ys, xs, chains): rows_b, rows_z = [], [] for y in ys: rb, rz = [], [] for x in xs: b = BR[(kind, y, x, x)]["coin"] ref = chains[(y, x)][chain_stat] rb.append(b[stat]); rz.append((b[stat] - ref) / b[stat + "_se"] if b[stat + "_se"] > 0 else np.nan) rows_b.append(rb); rows_z.append(rz) return np.array(rows_b), np.array(rows_z) EB, EZ = gap_grid("eval", "p_pass", "p_pass", BM.RR, BM.RA, CH_E) FB, FZ = gap_grid("funded", "p_any", "p_any", BM.RR, BM.RF, CH_F) LZ = np.array([[(BR[("life", 1.0, ra, rf)]["coin"]["p_cash"] - CH_E[(1.0, ra)]["p_pass"] * CH_F[(1.0, rf)]["p_any"]) / BR[("life", 1.0, ra, rf)]["coin"]["p_cash_se"] for rf in BM.RF] for ra in BM.RA]) allz = np.concatenate([EZ.ravel(), FZ.ravel(), LZ.ravel()]) allz = allz[np.isfinite(allz)] share24 = float(np.mean(np.abs(allz) > 2)) print(f"THE DECLARED TEST: broker coin side, mean over seeds, between-seed SE, against the exact chains, {len(allz)} cells") for name, Z in (("assessment P(pass)", EZ), ("funded P(any payout)", FZ), ("per purchase P(cash)", LZ)): z = Z[np.isfinite(Z)] print(f" {name:22}: mean gap {z.mean():+.2f} SE, beyond 2 SE {np.mean(np.abs(z) > 2):.0%} of {len(z)}, largest {np.abs(z).max():.1f}") print(f" all cells: beyond 2 SE {share24:.1%}, against about 5% by chance") verdict24_declared = ("AGREES" if share24 < 0.10 else "DISAGREES") print(f" declared rule (under 10% beyond 2 SE): {verdict24_declared}") ==================== 94 markdown #### Diagnosis, after the declared test failed The declared test fails: the broker reads below the chains far more often than chance allows. What follows was not declared before scoring. It is a sequence of checks, each run to find out why, and its conclusion is read as a diagnosis, not as a second test the broker passes. 1. **Is each trade fair?** Plain brackets, no account rules, 20 seeds at each ratio: the share of trades reaching the target against $1/(1 + RR)$, and the exits on a bar that reached both legs. 2. **Does the protocol bias the statistic?** The chains' own model run under the broker's protocol: the same trades per replay, accounts back to back, the account open at the end dropped, pooled over seeds. Its mean over 200 repetitions is what the broker should read if the market were the model. 3. **Is the error right?** The seeds share one price path, and one account can span months, so neither the seed nor the month is independent. The error is clustered in blocks of calendar time at least twice the mean account span, and a cell with fewer than ten such blocks is marked unresolvable by this history. 4. **Does the lot grid move it?** Lots floor to 0.01 on the \$3,000 tier, so the broker trades a little under the nominal risk. ==================== 95 code # 1. per-trade fairness, plain brackets through the broker, about two minutes FAIR24 = pd.DataFrame(BM.plain_winrates(BM.RR)) print("plain brackets, coin side, 20 seeds: share of trades reaching the target") print(FAIR24.to_string(index=False, formatters={"win": "{:.4f}".format, "fair": "{:.4f}".format, "gap_se": "{:+.1f}".format})) ==================== 96 code # 2 and 3. the model under the broker's protocol, and the error in blocks as long as the accounts, about three minutes rows = [] for kind, risks, chains, key in (("eval", BM.RA, CH_E, "p_pass"), ("funded", BM.RF, CH_F, "p_any")): for rr in BM.RR: for r in risks: res = BM.run_cell(kind, rr, r, r) n_tr = [res["coin"][k]["entries"] for k in sorted(res["coin"], key=int)] p, se, span, blocks = BM.block_rate(res, kind) proto = BM.protocol_expectation(kind, rr, r, G_COMM, n_tr, reps=200) rows.append(dict(kind=kind, rr=rr, r=r, broker=p, se=se, span_days=span, blocks=blocks, exact=chains[(rr, r)][key], protocol=proto, z=(p - proto) / se if se and se > 0 else np.nan)) AG24 = pd.DataFrame(rows) AG24["bias"] = AG24.protocol - AG24.exact AG24["resolvable"] = AG24.blocks >= 10 for kind in ("eval", "funded"): k = AG24[AG24.kind == kind] kr = k[k.resolvable] print(f"{kind}: protocol bias median {k.bias.median():+.3f}, worst {k.bias.min():+.3f} | resolvable {len(kr)} of {len(k)} cells: " f"beyond 2 SE {np.mean(np.abs(kr.z) > 2):.0%}, mean z {kr.z.mean():+.2f}, largest {np.abs(kr.z).max():.1f}") share24_res = float(np.mean(np.abs(AG24[AG24.resolvable].z) > 2)) print(f"all resolvable cells: {share24_res:.1%} beyond 2 SE of {int(AG24.resolvable.sum())}; unresolvable {int((~AG24.resolvable).sum())} cells, " f"accounts spanning {AG24[~AG24.resolvable].span_days.min():.0f} to {AG24[~AG24.resolvable].span_days.max():.0f} days") ==================== 97 code # 4. the lot grid: the share of the nominal risk the floored lots carry, over the deployed stops stops24 = np.maximum(0.75 * sig24[entry24], 30.0) def lot_share(r): L = r * BM.BANKROLL / (10 * stops24) return float(np.mean(np.floor(L / 0.01 + 1e-9) * 0.01 / L)) LOT24 = {r: lot_share(r) for r in sorted(set(BM.RA) | set(BM.RF))} print("floored lots carry, of the nominal risk: " + ", ".join(f"{100*r:g}% {v:.1%}" for r, v in LOT24.items())) zs = [] for row in AG24[AG24.resolvable].itertuples(): re_ = row.r * LOT24[row.r] ex = (PA.eval_chain(re_, G_COMM * re_, rr=row.rr, clip_up=True, clip_dn=True)["p_pass"] if row.kind == "eval" else PA.funded_chain(re_, G_COMM * re_, PA.payout_threshold(re_, row.rr, BM.BANKROLL, BM.SPLIT), rr=row.rr, clip_dn=True)["p_any"]) zs.append((row.broker - (ex + row.bias)) / row.se) print(f"at the effective risk, resolvable cells beyond 2 SE: {np.mean(np.abs(zs) > 2):.1%} (nominal {share24_res:.1%}), mean z {np.mean(zs):+.2f}") ==================== 98 code fig, ax = plt.subplots(2, 3, figsize=(12, 7.2)) mk_e, mk_f = (1.0, 0.04), (1.0, 0.015) heat(ax[0, 0], EB, BM.RA, BM.RR, "assessment risk, %", "RR", "broker P(pass), 20 seeds", lambda v: f"{100*v:.1f}", mk_e) heat(ax[0, 1], FB, BM.RF, BM.RR, "funded risk, %", "RR", "broker P(any payout), 20 seeds", lambda v: f"{100*v:.1f}", mk_f) heat(ax[0, 2], np.array([[BR[("life", 1.0, ra, rf)]["coin"]["p_cash"] for rf in BM.RF] for ra in BM.RA]), BM.RF, BM.RA, "funded risk, %", "assessment risk, %", "broker P(cash) per purchase, RR 1", lambda v: f"{100*v:.1f}", (0.04, 0.015)) def zgrid(kind, ys, xs): t = AG24[AG24.kind == kind].set_index(["rr", "r"]) return np.array([[t.loc[(y, x), "z"] if t.loc[(y, x), "resolvable"] else np.nan for x in xs] for y in ys]) heat(ax[1, 0], np.clip(zgrid("eval", BM.RR, BM.RA), -3, 3), BM.RA, BM.RR, "assessment risk, %", "RR", "broker - model under protocol, SE", lambda v: "" if not np.isfinite(v) else f"{v:+.1f}", None, cmap="RdBu_r") heat(ax[1, 1], np.clip(zgrid("funded", BM.RR, BM.RF), -3, 3), BM.RF, BM.RR, "funded risk, %", "RR", "broker - model under protocol, SE", lambda v: "" if not np.isfinite(v) else f"{v:+.1f}", None, cmap="RdBu_r") ax[1, 2].axis("off") ax[1, 2].text(0.02, 0.5, "Blank cells: fewer than ten\nblocks of twice the account span\nin 2.8 years, not resolvable\nby this history.", fontsize=9, va="center") fig.suptitle("The map through the broker: rates (top), and broker minus the model under the broker's protocol in block-clustered standard errors (bottom)") fig.tight_layout() fig.savefig("src/notebooks/reports/topics/prime_academy/figures/broker_map_rates.png", dpi=130) plt.show() ==================== 99 markdown **Figure.** Top: the broker's rates, today's cell framed. Bottom: broker minus the chains' model run under the broker's own protocol, in standard errors clustered by blocks of calendar time twice the account span; a blank cell has too few such blocks in 2.8 years to be resolved. Look for a band of one colour along a row or a column: that would say the market and the model disagree about how the parameter acts, which scattered cells of both colours do not. ==================== 100 code # durations in calendar days, which only the broker has, and the pace of one trade against Wald's baseline DE = np.array([[BR[("eval", rr, r, r)]["coin"]["days_pass"] for r in BM.RA] for rr in BM.RR]) DF = np.array([[BR[("funded", rr, r, r)]["coin"]["first_pay_days"] for r in BM.RF] for rr in BM.RR]) DL = np.array([[BR[("funded", rr, r, r)]["coin"]["life_days"] for r in BM.RF] for rr in BM.RR]) pace = {rr: np.nanmean([BR[("eval", rr, r, r)]["coin"]["days_per_trade"] for r in BM.RA]) for rr in BM.RR} sigma_day = float(np.nanmedian(TF.sigma_kf(dclose24, rho_v=0.0025) / FX.PIP)) wald = {rr: rr * STOP24 ** 2 / sigma_day ** 2 * 7 / 5 for rr in BM.RR} # trading days to calendar days print(f"pace, calendar days per trade (broker) against Wald's a b / sigma^2 at a {STOP24:.1f}-pip stop and sigma {sigma_day:.1f} pips:") for rr in BM.RR: print(f" RR {rr:4}: broker {pace[rr]:5.2f} Wald {wald[rr]:5.2f} ratio {pace[rr] / wald[rr]:.2f}") fig, ax = plt.subplots(1, 4, figsize=(15, 3.6)) heat(ax[0], DE, BM.RA, BM.RR, "assessment risk, %", "RR", "days to pass, given a pass", lambda v: f"{v:.0f}", mk_e, cmap="magma_r") heat(ax[1], DF, BM.RF, BM.RR, "funded risk, %", "RR", "median days to first payout", lambda v: f"{v:.0f}", mk_f, cmap="magma_r") heat(ax[2], DL, BM.RF, BM.RR, "funded risk, %", "RR", "mean funded life, days", lambda v: f"{v:.0f}", mk_f, cmap="magma_r") ax[3].plot(BM.RR, [pace[r] for r in BM.RR], "o-", color=PALETTE["BLUE"], label="broker") ax[3].plot(BM.RR, [wald[r] for r in BM.RR], "s--", color=PALETTE["MUT"], label="Wald, a b / sigma^2") ax[3].set_xlabel("RR"); ax[3].set_ylabel("calendar days per trade"); ax[3].legend(frameon=False, fontsize=8) style_axes(ax[3]) fig.tight_layout() fig.savefig("src/notebooks/reports/topics/prime_academy/figures/broker_map_days.png", dpi=130) plt.show() ==================== 101 markdown **Figure.** The durations in calendar days, from the broker, and the pace of one trade against Wald's baseline. The pace should rise about linearly in $RR$ with the stop fixed; a flat line would say the clipped target, not the ratio, sets how long a trade lasts. ==================== 102 code # the summary the paper reads, and the verdict today = {"eval": BR[("eval", 1.0, 0.04, 0.04)]["coin"], "funded": BR[("funded", 1.0, 0.015, 0.015)]["coin"], "life": BR[("life", 1.0, 0.04, 0.015)]["coin"], "life_short": BR[("life", 1.0, 0.04, 0.015)]["short"]} out24 = {"commit": subprocess.run(["git", "rev-parse", "--short", "HEAD"], capture_output=True, text=True).stdout.strip(), "seeds": len(BM.SEEDS), "stop": STOP24, "g_comm": G_COMM, "cells": len(allz), "share_beyond_2": share24, "declared": verdict24_declared, "share_resolvable_beyond_2": share24_res, "n_resolvable": int(AG24.resolvable.sum()), "fair": FAIR24.to_dict("records"), "agreement": AG24.to_dict("records"), "lot_share": {str(k): v for k, v in LOT24.items()}, "gap_mean": {"eval": float(np.nanmean(EZ)), "funded": float(np.nanmean(FZ)), "life": float(np.nanmean(LZ))}, "pace": {str(k): float(v) for k, v in pace.items()}, "wald": {str(k): float(v) for k, v in wald.items()}, "today": {k: {kk: float(vv) for kk, vv in v.items()} for k, v in today.items()}, "grids": {"RR": BM.RR, "RA": BM.RA, "RF": BM.RF, "EB": EB.tolist(), "FB": FB.tolist(), "EZ": EZ.tolist(), "FZ": FZ.tolist(), "LZ": LZ.tolist(), "DE": DE.tolist(), "DF": DF.tolist(), "DL": DL.tolist()}} json.dump(out24, open("src/notebooks/reports/topics/prime_academy/paper/broker_map.json", "w"), indent=1) verdict24 = (f"DECLARED TEST {verdict24_declared} ({share24:.0%} of cells beyond 2 SE); DIAGNOSED: trades fair at every ratio, " f"the model under the broker's protocol within 2 block SE in {1 - share24_res:.0%} of the {int(AG24.resolvable.sum())} resolvable cells, " f"{int((~AG24.resolvable).sum())} cells unresolvable by 2.8 years of history") t = today ag_today = AG24[((AG24.kind == "eval") & (AG24.rr == 1.0) & (AG24.r == 0.04)) | ((AG24.kind == "funded") & (AG24.rr == 1.0) & (AG24.r == 0.015))] print(ag_today[["kind", "rr", "r", "broker", "protocol", "exact", "z", "blocks", "resolvable"]].to_string(index=False, float_format=lambda v: f"{v:.3f}")) print(f"today's cells through the broker, coin side, 20 seeds:") print(f" assessment RR 1 at 4%: P(pass) {t['eval']['p_pass']:.1%} +/- {t['eval']['p_pass_se']:.1%}, {t['eval']['days_pass']:.0f} days to pass") print(f" funded RR 1 at 1.5%: P(any payout) {t['funded']['p_any']:.1%} +/- {t['funded']['p_any_se']:.1%}, " f"first payout {t['funded']['first_pay_days']:.0f} days, life {t['funded']['life_days']:.0f} days") print(f" per purchase: P(cash) {t['life']['p_cash']:.1%} +/- {t['life']['p_cash_se']:.1%}, cash / fee {t['life']['cash_fee']:.2f}") print(f"\n24 verdict: {verdict24}")