Warrior_EA/research/vplevels.py

118 lines
5.1 KiB
Python
Raw Permalink Normal View History

research: Wyckoff's law of cause and effect is real but SUBLINEAR The 1:1 range projection is the target rule both books recommend (book 1 ch.8 discards point-and-figure counting as too subjective and keeps the vertical projection). Tested as a complete trade on 4 symbols x M15/H1: enter on the range breakout, stop at the far side of the range, target k x risk. A driftless market gives P(win) = 1/(1+k) and expR = 0 at EVERY k, so the benchmark here is analytic - no permutation null needed. Result: expR sits on that benchmark at every k on every symbol. Target placement does not move expectancy, which is what a martingale already said. But the law itself is measurable, and it is not 1:1. Regressing log(MFE) on log(range height) with log(ATR) as a FREE regressor (a shared ATR denominator correlates the errors and biases the exponent towards the hypothesis, so it cannot be used to argue against it): b = 0.10 .. 0.92, centred ~0.6 b = 1 rejected in 5 of 8 at >2sd, never significantly above 1 b = 0 rejected in 7 of 8 So a bigger cause does produce a bigger effect - sub-proportionally. The 1:1 projection systematically over-reaches after a large consolidation and under-reaches after a small one. Median travel in risk-multiples falls monotonically across height quartiles in 8 of 8 runs. Also adds the volume-profile machinery the second book is built on and which nothing in the EA has: tick-level volume-at-price on a fixed absolute grid, per-session VPOC/value area by the standard Market Profile walk, naked VPOCs, and HVN/LVN from a rolling causal composite. Two biases are left in deliberately, both against the hypothesis: a bar spanning stop and target books the loss, and spread is charged on entry and both barriers. Unresolved trades are marked to market at the horizon rather than discarded - discarding them deletes slow winners and manufactures a false deficit at large k. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 21:36:14 -04:00
"""Turn raw (day, price-bin, weight) profiles into the operational levels the books use.
Every level here is STRICTLY CAUSAL: a level in force on broker-day d is computed only from
days < d. That is not a formality. A session VPOC is the single most tempting lookahead in
this whole subject, because the natural way to draw it on a chart is over the day you are
looking at - and a level fitted to the day it is tested on will "hold" beautifully.
Levels produced
---------------
vpoc / vah / val previous session's point of control and value area edges
naked vpoc a past session VPOC that price has NOT traded through since. Held to
be an unfinished auction and therefore a magnet.
hvn / lvn peaks and valleys of a rolling composite profile over the previous
N sessions. HVN = agreement, claimed to attract and to make good
targets; LVN = rejection, claimed to make good stop locations.
"""
import numpy as np
from scipy.signal import find_peaks
BROKER_OFFSET_MS = 2 * 3600 * 1000
DAY_MS = 86400 * 1000
def broker_day(t_ms):
return ((np.asarray(t_ms, np.int64) + BROKER_OFFSET_MS) // DAY_MS).astype(np.int64)
def load(sym, path='c:/Users/admin/Documents/Workspaces/Market Data/profiles/'):
z = np.load(f"{path}{sym}_vp.npz")
return z['day'], z['bin'], z['ticks'], z['dwell'], float(z['binsize'][0])
def day_index(day, b, w):
"""Group the sparse cells by day. Returns (days, list of (bins, weights)) sorted."""
order = np.lexsort((b, day))
day, b, w = day[order], b[order], w[order]
starts = np.concatenate(([0], np.flatnonzero(np.diff(day)) + 1))
ends = np.concatenate((starts[1:], [len(day)]))
return day[starts], [(b[s:e], w[s:e]) for s, e in zip(starts, ends)]
def densify(bins, w):
"""Sparse bins -> contiguous array with explicit zeros for untraded levels inside the
range. Without the zeros a value-area walk annexes across a hole as if it were adjacent
and low-volume nodes cease to exist - which is the whole point of them."""
full = np.arange(bins[0], bins[-1] + 1)
dense = np.zeros(len(full))
dense[bins - bins[0]] = w
return full, dense
def session_levels(days, cells, frac=0.70):
"""Per-session VPOC / VAL / VAH / high / low, all as integer bin indices."""
from profiles import value_area
out = np.empty((len(days), 6), np.int64)
for i, (bins, w) in enumerate(cells):
full, dense = densify(bins, w)
poc, lo, hi = value_area(full, dense, frac)
out[i] = (days[i], poc, lo, hi, bins[0], bins[-1])
return out # day, vpoc, val, vah, low, high
def composite_nodes(days, cells, lookback=20, smooth=5, prominence=0.35):
"""HVN/LVN from a rolling composite of the PREVIOUS `lookback` sessions.
Smoothing first is not cosmetic: on a raw 0.1-pip grid every second bin is a local
maximum, and "find the peaks" without it returns a few hundred meaningless nodes per
day, which would let any subsequent test claim a hit against whatever price did.
`prominence` is in units of the smoothed profile's own standard deviation, so it means
the same thing on gold and on EURUSD.
Returns {day: (hvn_bins, lvn_bins)} for every day that has a full lookback behind it.
"""
out = {}
for i in range(lookback, len(days)):
bb = np.concatenate([cells[j][0] for j in range(i - lookback, i)])
ww = np.concatenate([cells[j][1] for j in range(i - lookback, i)])
k, inv = np.unique(bb, return_inverse=True)
agg = np.bincount(inv, weights=ww, minlength=len(k))
full, dense = densify(k, agg)
if len(full) < 4 * smooth:
continue
ker = np.ones(smooth) / smooth
sm = np.convolve(dense, ker, mode='same')
p = prominence * sm.std()
hv, _ = find_peaks(sm, prominence=p)
lv, _ = find_peaks(-sm, prominence=p)
out[int(days[i])] = (full[hv], full[lv])
return out
def naked_vpocs(sess, max_age=60):
"""For each day, the past VPOCs price has not traded through yet.
A VPOC is 'naked' until a LATER session's range covers it. Only sessions strictly
before the current one can retire it, so the answer for day d never depends on day d.
Capped at `max_age` sessions back: a naked VPOC from three years ago is not an
unfinished auction, it is a price the market has left behind, and including it would
load the test with levels no trader would draw.
Returns {day: array of still-naked vpoc bins}, nearest-in-time first.
"""
out = {}
vpoc = sess[:, 1]; lo = sess[:, 4]; hi = sess[:, 5]
for i in range(1, len(sess)):
j0 = max(0, i - max_age)
cand = vpoc[j0:i]
#--- covered by any session strictly between its own and today
alive = np.ones(len(cand), bool)
for k in range(len(cand)):
src = j0 + k
if src + 1 >= i:
continue
rl = lo[src + 1:i]; rh = hi[src + 1:i]
if np.any((rl <= cand[k]) & (rh >= cand[k])):
alive[k] = False
out[int(sess[i, 0])] = cand[alive][::-1]
return out