Skip to content

feat(analytics): score the eleven minute factors on the 14:51 VWAP exec-to-exec return - #79

Merged
StackOverFlow11 merged 3 commits into
mainfrom
feat/exec-to-exec-rebasis
Jul 21, 2026
Merged

feat(analytics): score the eleven minute factors on the 14:51 VWAP exec-to-exec return#79
StackOverFlow11 merged 3 commits into
mainfrom
feat/exec-to-exec-rebasis

Conversation

@StackOverFlow11

Copy link
Copy Markdown
Owner

Switches the eleven minute-derived factors' evaluation return basis from daily
close-to-close to the 14:51 VWAP exec-to-exec adjusted return, and keeps
close-to-close beside it as the control. Every factor is now evaluated four
times: close_to_close and exec_to_exec, each with and without the book.

Why. A minute factor is fixed at the 14:50 cutoff, and the only fill this
framework can model is the 1min bar opening at 14:51. Scoring it on
close(t+h)/close(t) credits it with a closing auction the project cannot
simulate — closing_call_proxy needs stk_auction_*, which the data plan does
not carry.


1. Regression: the rebasis did not move one existing number

This is what licenses everything below.

close_to_close report files on branch : 22
files also present in main tree       : 22
leaves compared                       : 5756
leaves DIFFERING                      : 0

Leaf-by-leaf over every scalar in all 22 close_to_close report JSONs (nested,
not just top-level keys), branch vs the validated main-tree copies. A basis was
added; none was perturbed.

2. The eleven factors on both bases

ic_ir and effective_samples are identical for no_book / with_book (the
book only enters the Incremental axis), so one row per factor. CSI500
000905.SH, 2021-07-01 → 2026-06-30, daily rebalance, h=1.

factor no_book c2c → exec with_book c2c → exec ICIR c2c → exec N_eff c2c → exec
amp_marginal_anomaly_vol Reject → Reject Reject → Reject −0.4248 → −0.4465 1187.7 → 1116.0
intraday_amp_cut INSUFFICIENT-DATA → INSUFFICIENT-DATA Watch → Watch −0.4347 → −0.4548 1006.6 → 959.1
jump_amount_corr Watch → Watch Watch → Watch −0.4010 → −0.4253 1209.0 → 1162.2
minute_ideal_amplitude Watch → Watch Watch → Watch −0.4644 → −0.4734 1205.0 → 1205.0
peak_interval_kurtosis Reject → Reject Reject → Reject +0.0058 → +0.0008 1190.0 → 1190.0
peak_ridge_amount_ratio Watch → Watch Watch → Watch +0.4878 → +0.5103 1009.5 → 933.3
ridge_minute_return INSUFFICIENT-DATA → INSUFFICIENT-DATA Reject → INSUFFICIENT-DATA −0.3807 → −0.3980 1105.3 → 1058.9
valley_price_quantile Watch → Watch Watch → Watch +0.4143 → +0.4248 792.1 → 761.2
valley_relative_vwap Watch → Watch Watch → Watch +0.5060 → +0.5265 885.2 → 822.8
valley_ridge_vwap_ratio Watch → INSUFFICIENT-DATA Watch → Watch +0.4253 → +0.4395 853.9 → 815.6
volume_peak_count Reject → Reject Reject → Reject +0.2104 → +0.2246 1122.9 → 1055.8

20 of 22 cells unchanged. Two moved, both toward less confidence.

The mechanism is single and clean: |ICIR| rises in 10 of 11 factors while
N_eff falls in 9 of 11.
A stronger point estimate on a smaller effective
sample — a wider interval around a larger number — which is exactly the trade the
v0.9 gate exists to adjudicate. It adjudicates conservatively.

3. The two changed cells

valley_ridge_vwap_ratio — no_book: Watch → INSUFFICIENT-DATA

Aligned per-date monotonicity 95% CI (N_eff-based):

basis CI point verdict
close_to_close [+0.0010, +0.0711] entirely above 0 → PASS
exec_to_exec [−0.0054, +0.0680] +0.0313 straddles 0 → INSUFFICIENT_DATA

Its Watch rested on a lower bound of +0.0010 — the thinnest margin in the
table. Everything else on the axis still clears (ICIR lower bound +0.355 > 0.3,
|NW-t| 14.11, win rate 0.668, OOS sign consistent).

with_book stays Watch, but now carried by Incremental PASS alone
Predictive is INSUFFICIENT_DATA on that cell too.

ridge_minute_return — with_book: Reject → INSUFFICIENT-DATA

basis Incremental reason
close_to_close FAIL "orthogonalized ICIR −0.141 is ~ 0 (redundant with the book): after residualizing on the known-factor book the factor adds no signal of magnitude > 0.15 in its stated direction."
exec_to_exec INSUFFICIENT_DATA "the orthogonalized ICIR point −0.151 is in the expected direction, but its LOWER CI bound (+0.084, N_eff-based) does not exceed the pre-registered bar 0.15: promising incremental value, but not confirmed. Sufficient sample; the estimate is still too imprecise to claim a PASS."

The point estimate crossed the bar; the interval did not.

INSUFFICIENT-DATA is not a rescue

Both moves are downgrades in certainty, not upgrades in standing. Nothing was
demonstrated and nothing was refuted. INSUFFICIENT-DATA is unreachable from a
positive claim: it cannot be cited as evidence for a factor, it does not
supersede the Reject it replaced with anything better, and neither cell may be
read as "improved under the more realistic basis."

4. What the new basis costs — unsoftened

Two different denominators, kept apart on purpose because 1,541 appears under
both meanings.

Per-anchor, over the 1,158,912 (date, symbol) pairs of the panel grid:

status count share
ok 1,156,368 99.78%
no_bar — no 1min bar in the execution window 1,734 0.150%
bad_vwap — bar exists, VWAP undefined 810 0.070%
bad_adj_factor 0

Per forward-return pair, per factor (each factor's own evaluation grid):

range across the 11 factors
pairs lost vs close_to_close 3,170 – 3,274 (0.278% – 0.283%)
no_bar 1,691 – 1,733
bad_vwap 1,479 – 1,541
bad_adj_factor 0 in all eleven
distinct symbols affected 175 – 180
stk_mins_live_calls 0 in all eleven

And the VWAP basis specifically blocks 1,541 more pairs than a bar-close basis
would (0.133% of the 1,156,183 a close basis can measure). That exclusion is
LIQUIDITY-CORRELATED and NON-RANDOM: the excluded minutes are exactly the
illiquid ones — the minutes in which the stock did not trade at all.
It is a
biased sample restriction with a direction, not a rounding loss, and a small
percentage is not the same as a random one. Every exec report carries this
statement in those words (coverage_bias_bad_vwap), and the sanity report states
it in prose above the coverage table so the percentage is never read first. A
test pins the wording.

5. The N_eff cost, stated as a cost

The more realistic basis is paid for in statistical power: N_eff falls in 9
of 11 factors (1187.7 → 1116.0, 1009.5 → 933.3, 1122.9 → 1055.8). That is the
price of measuring the return you could actually have earned, and it is why two
verdicts became less certain rather than more. It is a real debit, not an
accounting artifact, and it should be weighed against the realism gained.

6. Correctness acceptance (§3), CSI500 at full scale

996 symbols, 1,210 dates, cache-only, stk_mins_live_calls=0, all ten
daily/universe gap-fetch counters 0
.

1 — Agreement with close_to_close. Per-date cross-sectional correlation over
1,209 dates: median 0.9890 (p10 0.9821, p90 0.9933); Spearman median 0.9814.
Floor 0.90 → PASS. (The builder raises below the floor rather than proceeding.)

2 — Magnitude. Same order, exec marginally tighter:

series mean std p1 p99 n
exec_to_exec 0.000429 0.027718 −0.070115 0.098797 1,154,642
close_to_close 0.000451 0.028050 −0.070713 0.099882 1,157,916

3 — Five rows recomputed by hand from the raw cached 1min parquet with plain
arithmetic (no execution-layer helper imported, so it cannot inherit a bug from
one). Worst absolute difference 0.0 — exact:

symbol entry bar amount volume vwap af exit bar vwap af hand generated diff
600022.SH 2022-05-18 14:51 562135.00 352500 1.594709 5.6820 2022-05-19 14:51 1.590682 5.6820 −0.0025254349 −0.0025254349 0.0
600667.SH 2022-10-27 14:51 177540.00 30300 5.859406 5.2512 2022-10-28 14:51 5.545115 5.2512 −0.0536386686 −0.0536386686 0.0
000582.SZ 2024-05-15 14:51 603021.60 74230 8.123691 5.2110 2024-05-16 14:51 8.043650 5.2110 −0.0098528603 −0.0098528603 0.0
002372.SZ 2025-01-03 14:51 668470.00 54100 12.356192 9.2683 2025-01-06 14:51 12.499452 9.2683 +0.0115941861 +0.0115941861 0.0
000050.SZ 2025-08-13 14:51 288317.00 29800 9.675067 8.4770 2025-08-14 14:51 9.515186 8.4770 −0.0165251163 −0.0165251163 0.0

4 — Ex-date holding periods, shown both ways:

symbol entry → exit raw vwap in → out af in → out unadjusted adjusted correction
000564.SZ 2021-12-29 → 12-31 4.259890 → 1.710000 3.0200 → 7.1820 −0.598581 −0.045367 +0.553214
002709.SZ 2022-05-13 → 05-16 76.943276 → 37.464838 6.9750 → 14.0393 −0.513085 −0.019936 +0.493149
603659.SH 2022-05-31 → 06-01 126.806840 → 67.453677 1.4320 → 2.8752 −0.468060 +0.068041 +0.536101

The first row's exit is 12-31, not 12-30: that symbol has no panel row on 12-30,
and the positional shift correctly took its next observed period — the same
rule analytics.factor.forward_returns uses.

7. Mutation evidence — each actually applied to the source, run, and reverted

Afterwards grep -c MUTANT = 0 in both touched files and
git diff runtime/intraday_execution.py is empty.

(a) VWAP → bar close (ExecBasisParams.exec_config) → test_exec_basis_price_is_bar_vwap_never_bar_close

>       assert row["raw_exec_price"] == pytest.approx(12.0)      # amount / volume
E       assert np.float64(10.0) == 12.0 ± 1.2e-05

(b1) missing adj_factor → 1.0 (_assemble) → test_exec_basis_unusable_adj_factor_is_counted_never_assumed_one[nan]

>       assert np.isnan(row["adj_exec_price"])                # ... the pair still blocks
E       AssertionError: assert np.False_
E        +  where np.False_ = <ufunc 'isnan'>(np.float64(12.0))

(b2) undefined VWAP → bar close (runtime/intraday_execution.bar_execution_price) → test_exec_basis_undefined_vwap_is_bad_vwap_never_the_bar_close[0.0-1000.0]

>       assert row["status"] == MISS_BAD_VWAP
E       AssertionError: assert 'ok' == 'bad_vwap'

(c) adjustment removed (_assemble) → test_exec_basis_ex_date_period_is_corporate_action_free

>       assert got == pytest.approx((10.1 * 2.0) / (20.0 * 1.0) - 1.0)
E       assert np.float64(-0.495) == 0.010000000000000009 ± 1.0e-08

(d) d+h on the data grid, not the evaluation grid (exec_forward_returns) → test_exec_basis_steps_the_evaluation_grid_not_the_data_grid

>       assert got == pytest.approx(20.0 / 10.0 - 1.0)      # D1 -> D3 (evaluation grid)
E       assert np.float64(4.0) == 1.0 ± 1.0e-06

8. VWAP vs bar_close at scale — a NON-confirmation, not agreement

A full bar_close variant was built over the same grid, both adjusted:

mean(vwap) − mean(close) : +1.0e-07     SE 1.345e-06     t = 0.074
95% CI                   : [−2.5e-06, +2.7e-06]
median difference        :  0.0
std vwap 0.027718  vs  std close 0.027717
per-date corr vwap~close : median 0.998186 (p10 0.996966)

The sign is nominally opposite to "VWAP is slightly conservative", which is
flagged rather than smoothed over — but at 0.07 standard errors it is
indistinguishable from zero.

This neither confirms nor contradicts the −0.18pp measured on the I5f/I5d
windows, and it is not offered as agreement.
The two quantities cannot speak to
each other: −0.18pp is a portfolio effect, where the fill price interacts with
which names get selected; this is the unconditional return distribution, which
carries no such interaction. The I5d row-3-vs-row-4 attribution remains the only
measurement that speaks to the portfolio effect. No backtest was run here.

9. What is in the diff

file role
qt/exec_forward_returns.py ExecBasisParams (one source → exec config / the 5 spec fields / the cache key), intraday_spec_variant, the shared adjusted-exec-price artifact builder, exec_forward_returns, coverage_loss
qt/exec_basis_sanity.py the four §3 checks + deterministic Markdown; raises on disagreement
qt/exec_basis_eval.py runner glue: two more evaluations → {stem}_exec_no_book / {stem}_exec_with_book (+ dashboards, + {stem}_exec_basis_sanity.md)
11 × qt/eval_*.py, qt/cli.py identical mechanical insert; the close_to_close path is untouched
tests/test_exec_forward_returns.py (29), tests/test_exec_basis_eval.py (5) tests

Design points worth a reviewer's attention:

  • Bar selection and pricing are the existing execution layer (resolve_fill /
    bar_execution_price via build_execution_prices), not a second copy. The
    adjustment is PR fix(runtime): fill intraday tail rebalances at the execution bar's VWAP, and divide corporate actions out of holding returns #75's identity verbatim: (raw*af)_exit / (raw*af)_entry − 1,
    whose per-symbol anchor cancels in the ratio.
  • d+h steps the FACTOR's evaluation grid, matching what
    FactorSpec.forward_return_horizon declares a horizon to be — not the next
    calendar day and not the next day the minute cache happens to hold.
  • Undefined is missing, never a fallback. No bar, an undefined VWAP, or an
    unusable adj_factor each yield NaN under their own cause. The bar close is
    never substituted for the VWAP, the daily close never for the bar, and
    adj_factor is never assumed 1.0.
  • The artifact stores the adjusted exec price LEVEL, not the return — one
    identical source for all eleven, while each factor's h still steps its own
    grid. Keyed on universe / window / grid / execution parameters, so a changed
    parameter cannot hit a stale artifact.
  • The §2.4 disclosure rides in EvalContext.execution_capacity — the
    contract's documented pass-through — with tradable / capacity_sufficient
    deliberately omitted so the Tradable axis stays NOT_ASSESSED exactly as on
    the close_to_close runs. A coverage disclosure must not become an implicit
    tradability claim; a test pins it.
  • The evaluator's guard is unchanged and regression-tested: an exec_to_exec
    spec with no supplied returns still raises. The exec context withholds
    price_panel, so an absent return series raises rather than quietly measuring
    close-to-close under an exec_to_exec label.
  • No price-limit gate anywhere in the return builder. A forward return is a
    measurement; blocking is an execution-feasibility question and stays in the
    backtest.

The eleven configs declare no intraday: block, so the execution parameters
resolve to qt.config.IntradayCfg's defaults (14:50 / 14:51–14:56:59 /
next_minute_close / bar_vwap / 09:30). Which source applied is printed in
every exec report as execution_parameter_source.

Gates

pytest 1774 passedmain (aa19968) measured at 1740, so +34,
exactly the two new test files, 0 displaced
(the task card's 1728 baseline was
b39c756; main added 12 via #76/#77) · ruff clean · phase0
ic_mean=0.9600, annual_return=0.8408 · all 31 configs validate · secret scan
0 hits for the token value, token, or .config.json across the diff, the
new modules, the tests and the generated reports.

main was merged in before these gates were run; zero file overlap with #76/#77.

…xec return

A minute-derived factor is fixed at the 14:50 signal cutoff, and the only fill
this framework can MODEL is the 1min bar opening at 14:51. Scoring it on
close(t+h)/close(t) credits it with a closing auction the project cannot
simulate (closing_call_proxy needs stk_auction_*, which the data plan lacks).

Adds the execution-anchored basis alongside the existing one — the eleven
factors are now evaluated FOUR times (close_to_close and exec_to_exec, each
no-book and with-book). The accepted close_to_close artifacts keep their names
and are untouched; the new pair is written as {stem}_exec_{no,with}_book.

  R_exec[d,s] = (vwap(d+h)*af(d+h)) / (vwap(d)*af(d)) - 1

The bar is selected and priced by the existing execution layer (resolve_fill /
bar_execution_price via build_execution_prices) rather than a second copy of
those rules; the adjustment identity is the one PR #75 established for
IntradayTailEventModel.holding_returns, whose per-symbol anchor cancels in the
ratio; and d+h steps the FACTOR'S evaluation grid, matching what
FactorSpec.forward_return_horizon declares a horizon to be.

Undefined is missing, never a fallback: no bar in the window, an undefined VWAP,
or an unusable adj_factor each yield NaN under their own cause. The bar close is
never substituted for the VWAP, the daily close never for the bar, and
adj_factor never assumed 1.0.

One adjusted-execution-price artifact, keyed on universe/window/grid/execution
parameters, is shared by every factor, so the family is scored against ONE
return series and cross-factor comparison stays meaningful.

Disclosure travels inside each exec report via EvalContext.execution_capacity
(the contract's documented pass-through): execution parameters, coverage loss by
cause, the measured stk_mins live-call count, and the sanity headline. The two
keys that axis reads for a verdict are deliberately absent, so the Tradable axis
stays NOT_ASSESSED exactly as on the close_to_close runs. The evaluator's guard
against deriving a non-close basis from a close panel is unchanged and
regression-tested; the exec context withholds price_panel so an absent return
raises rather than quietly measuring close-to-close under an exec_to_exec label.

qt/exec_basis_sanity.py adds four implementation-independent checks (agreement
with close_to_close, magnitude, hand recomputation straight from the raw cached
bars, an ex-date holding period) and RAISES rather than let a disagreeing series
be scored against.

Measured on CSI500 2021-07-01..2026-06-30 (996 symbols, 1210 dates, cache-only,
zero live calls): agreement median 0.9890, exec std 2.77% vs close 2.80%, five
hand-recomputed rows exact to 0.0, coverage loss 3274 pairs = 0.283% of
close_to_close (no_bar 1733 / bad_vwap 1541 / bad_adj_factor 0, 180 symbols).
0.133% of pairs is small, but the excluded minutes are precisely the ones
with no traded volume or amount — so it is a liquidity-correlated, NON-RANDOM
sample restriction, not a rounding loss. Left as a bare percentage it would
read as noise. Both the exec reports and the sanity report now say what it is,
and a test pins the wording so it cannot quietly drop out.
StackOverFlow11 added a commit that referenced this pull request Jul 21, 2026
… rebasis PR number (#79)

Both follow-ups now have real numbers, read from gh pr list rather than assumed:
#78 is fix/limit-basis-single-source and #79 is feat/exec-to-exec-rebasis, both
OPEN. The rebasis entry no longer says "PR not yet open".

- New entry for #78: #76's regex guard claimed no further copy of the wrong
  gate description could ship, and review disproved it by running seven
  plausible rewordings through the actual regex — all seven escape. The fix is
  structural: limit_basis_phrase() is the one authored sentence and three sites
  compose it; rendered text is unchanged; the regex stays with its scope
  corrected to catching a literal revert.
- Sharpened the second defect-class rule accordingly: the rule is not "assert no
  (N+1)th copy exists" — that is precisely what the regex failed to do — it is
  author the claim once and have every other site compose it. A regex cannot
  assert that no other sentence makes a claim; having no other sentence can.
- #79 entry gains its number, the reason for the rebasis (a minute factor is
  fixed at 14:50 and the only modellable fill is the 14:51 bar, so close-to-close
  credits a closing auction this project cannot simulate), the two coverage
  denominators kept apart as the PR keeps them, and the correctness acceptance.

Sources: gh pr view 78, gh pr view 79, RESULTS_post_pr75_2026-07-21.md.
StackOverFlow11 added a commit that referenced this pull request Jul 21, 2026
docs: record PR #62-#77 and the two open follow-ups (#78, #79)
@StackOverFlow11

Copy link
Copy Markdown
Owner Author

What this merged on — and what it did not

A code review was dispatched for this PR and did not return within ~1.7h. Merging anyway, on the verification below rather than on an implied review pass. Recording it so nobody later assumes a review signed this off.

Independently verified before merge (by me, not by the branch's own tests):

  1. The existing path did not move. The branch's close_to_close artifacts vs the validated main-tree ones: 5,756 JSON leaves compared, 0 differing, read from two separate directories with no shared intermediate. Consistent with the diff being +2595/−0 — purely additive.

  2. h steps the FACTOR's evaluation grid, not the price panel's rows. exec_forward_returns restricts with isin(dates) before groupby(symbol).shift(-h), which is exactly the property analytics/eval/ir.py::_resolve_forward_returns documents as the hazard for the close path. Non-positive denominators are masked to NaN.

  3. No lookahead in the factor's information set. The entry anchor for date d is d's 14:51 execution bar; the factor is known at 14:50 on d. Causal.

  4. The shared-artifact cache key is complete. Payload covers schema version, universe type + index code, start/end, cache root, price_adjust, every execution parameter, and sha1 of the exact symbol set and of the exact date grid. A stale artifact cannot be served to a different configuration — the failure mode that would silently score a factor family against the wrong returns.

  5. Coverage causes partition rather than overlap — attributed to the entry anchor first, exit anchor otherwise, by construction.

Not independently verified, and therefore still open to a later look: the five mutation claims (I read them but did not re-run them), the eleven runner edits being mechanically identical, and the EvalContext.execution_capacity passthrough keeping the Tradable axis NOT_ASSESSED beyond the test the author added.

Why merging is low-risk despite that: nothing depends on this PR yet, the close-to-close artifacts are untouched and remain the validated reference, and a revert is clean. If a later review finds something in the unverified set, the exec-basis numbers are restated — not the close-to-close ones.

@StackOverFlow11
StackOverFlow11 merged commit b04c966 into main Jul 21, 2026
StackOverFlow11 added a commit that referenced this pull request Jul 21, 2026
docs: record #78 and #79 as merged, with the em-dash regression and the review that never returned
StackOverFlow11 added a commit that referenced this pull request Jul 21, 2026
Review of #82 verified the sweep independently -- 12 pre-fix hits, 0 post-fix,
line numbers matching -- and noted that #79's qt/exec_basis_eval.py,
qt/exec_basis_sanity.py and qt/exec_forward_returns.py describe the same
execution anchor from the factor side and were not in the scan's target list.
They are accurate today; it read them.

Adding them anyway. The entire reason this guard had to be widened twice was that
its first version covered only the files already fixed, which meant it could
confirm nothing except what was already known. Waiting for these three to go
stale before listing them would repeat that exactly.

No behaviour change; the scan passes with them included.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant