Warrior_EA/research/profiles.py

233 行
11 KiB
Python

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
"""Build true volume-at-price profiles from the raw tick stream.
Everything this project has tested so far reduced the market to a BAR: OHLC plus some
aggregate. A volume profile is the one description that a bar cannot carry, because it is
volume distributed ACROSS PRICE inside the period rather than summed over it. Villahermosa's
"Wyckoff 2.0" is built entirely on that distribution - VPOC, value area, high/low volume
nodes - so testing any of its claims requires computing it properly first.
WHAT IS BEING COUNTED, HONESTLY
-------------------------------
The feed has no trade prints and no trade direction (see ticks_to_bars.py). So this is a
count of QUOTE UPDATES at each price level, not contracts traded. That distinction matters
and is not papered over:
- It is closest in spirit to Steidlmayer's TPO / Market Profile, which is time-at-price
and needs no volume at all. Several of the claims under test (the 80% rule, value-area
acceptance) are ORIGINALLY TPO claims, so this is the right measurement for them, not
a compromise.
- For the volume-specific claims (VPOC as institutional inventory) it is a proxy. Quote
activity and traded volume correlate strongly but are not identical.
- A price level where the market sat quietly for an hour accumulates fewer counts than
one where it thrashed for ten minutes. That is a real difference from a time profile
and it is the reason both weightings are computed here: `ticks` and `dwell`
(milliseconds spent at the level), so any result can be checked against the other.
BINNING
-------
Bins are on a FIXED ABSOLUTE GRID (bin = round(price / size)), never relative to the day's
range. Two profiles can only be compared - and a node can only be "the same level" across
sessions - if their bins line up. A per-day adaptive grid would silently destroy exactly
the cross-session persistence being tested.
DAY BOUNDARY
------------
Broker day, i.e. UTC+2 (measured, see the stop-run notes). The daily bar the5ers shows
rolls at 22:00 UTC and every "previous session value area" claim is about THAT session,
not about a calendar day in UTC.
"""
import numpy as np, sys, os, time, datetime as dt
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
from sqx import decode_iter, split_offsets, calibrate_decimals, decode, find_block
BARS = 'c:/Users/admin/Documents/Workspaces/Market Data/bars/'
OUT = 'c:/Users/admin/Documents/Workspaces/Market Data/profiles/'
BROKER_OFFSET_MS = 2 * 3600 * 1000 # broker = UTC + 2
DAY_MS = 86400 * 1000
#--- Bin size per instrument, chosen so a typical DAY spans O(1000) bins: fine enough that
#--- a VPOC is a price rather than a zone, coarse enough that counts are not shot noise.
#--- Explicit rather than derived, for the same reason the decimals table is explicit.
BIN = {'EURUSD': 0.00001, # 0.1 pip - day range ~0.008 -> ~800 bins
'USDJPY': 0.001, # 0.1 pip - day range ~0.9 -> ~900 bins
'XAUUSD': 0.01, # 1 cent - day range ~20 -> ~2000 bins
'SP500': 0.10} # 0.1 index - day range ~50 -> ~500 bins
def _scan(args):
"""One byte range -> (key, ticks, dwell) where key packs (day, bin).
Returned already reduced by np.unique, so a chunk covering 40M ticks comes back as a
few hundred thousand rows instead of 40M. The reduction is exact - counts are summed,
never sampled.
"""
path, scale, binsize, start, stop = args
keys, tks, dws = [], [], []
for ts, ai, bi, _vi in decode_iter(path, start=start, stop=stop):
mid = (ai + bi) * (0.5 / scale)
day = (ts + BROKER_OFFSET_MS) // DAY_MS
b = np.rint(mid / binsize).astype(np.int64)
#--- pack: bin can be ~300k for gold, day ~20k. 2^32 is ample headroom for both,
#--- and an int64 key keeps the unique() fast. Bins are non-negative for every
#--- instrument here (prices are positive), asserted below.
key = day * (1 << 32) + b
#--- dwell = time this tick's price stood before the next quote arrived. The last
#--- tick of a chunk has no successor inside the chunk; it gets the chunk's median
#--- gap rather than zero, which is worth ~1 tick in 40M and avoids a systematic
#--- (if tiny) bias against the final price of every chunk.
gap = np.empty(len(ts), np.float64)
gap[:-1] = np.diff(ts)
gap[-1] = np.median(gap[:-1]) if len(ts) > 1 else 0.0
np.clip(gap, 0, 60000, out=gap) # a weekend gap is not dwell at a price
if (b < 0).any():
raise ValueError("negative price bin - check the decimals calibration")
k, inv = np.unique(key, return_inverse=True)
keys.append(k)
tks.append(np.bincount(inv, minlength=len(k)).astype(np.float64))
dws.append(np.bincount(inv, weights=gap, minlength=len(k)))
if not keys:
return (np.empty(0, np.int64), np.empty(0), np.empty(0))
return _reduce(np.concatenate(keys), np.concatenate(tks), np.concatenate(dws))
def _reduce(key, tk, dw):
k, inv = np.unique(key, return_inverse=True)
return k, np.bincount(inv, weights=tk, minlength=len(k)), \
np.bincount(inv, weights=dw, minlength=len(k))
def build(path, sym, ref, decimals=None, workers=None):
binsize = BIN[sym]
if decimals is None:
sts, sai, _, _ = decode(path, max_records=200000)
try:
decimals, err = calibrate_decimals(sts, sai, ref[0], ref[1], verbose=False)
except ValueError:
size = os.path.getsize(path)
decimals = None
for frac in (0.5, 0.75, 0.9):
with open(path, 'rb') as fh:
off = find_block(fh, int(size * frac), size)
if off is None:
continue
it = decode_iter(path, batch=2000000, start=off)
sts, sai, _, _ = next(it); it.close()
try:
decimals, err = calibrate_decimals(sts, sai, ref[0], ref[1],
verbose=False)
break
except ValueError:
continue
if decimals is None:
raise
print(f" scale: decimals={decimals} (median rel.err {err:.4%})")
scale = 10.0 ** decimals
workers = workers or max(1, (os.cpu_count() or 4) - 1)
ranges = split_offsets(path, workers)
t0 = time.time()
print(f" {len(ranges)} chunks across {workers} workers, bin={binsize}", flush=True)
import multiprocessing as mp
args = [(path, scale, binsize, s, e) for s, e in ranges]
parts = []
with mp.Pool(len(ranges)) as pool:
for i, p in enumerate(pool.imap(_scan, args)):
parts.append(p)
print(f" chunk {i+1}/{len(ranges)} {len(p[0]):,} cells "
f"{(time.time()-t0)/60:.1f} min", flush=True)
key, tk, dw = _reduce(np.concatenate([p[0] for p in parts]),
np.concatenate([p[1] for p in parts]),
np.concatenate([p[2] for p in parts]))
day = (key >> 32).astype(np.int64)
b = (key & ((1 << 32) - 1)).astype(np.int64)
print(f" {int(tk.sum()):,} ticks -> {len(key):,} (day,price) cells over "
f"{len(np.unique(day)):,} days in {(time.time()-t0)/60:.1f} min")
return day, b, tk, dw, binsize
#----------------------------------------------------------------------------------------
# Profile statistics
#----------------------------------------------------------------------------------------
def value_area(bins, w, frac=0.70):
"""Classic Market Profile value area: start at the POC and repeatedly annex whichever
NEIGHBOURING PAIR of levels holds more weight, until `frac` of the total is enclosed.
Not a percentile and not mean +/- sigma. Those give a different (usually wider) region
on any skewed profile, and skew is the whole point of the P/b shapes. Villahermosa
quotes 68.2% by analogy with one standard deviation; the Market Profile convention that
every platform implements is 70%. The difference is immaterial to the tests here and
70% is used so the numbers are comparable with anything else the user reads.
"""
n = len(bins)
if n == 0:
return None
poc = int(np.argmax(w))
#--- prefix sums: the walk asks for a running total O(n) times, and recomputing the
#--- enclosed sum each step turns a 350-bin day into 60k adds for nothing.
cs = np.concatenate(([0.0], np.cumsum(w)))
seg = lambda a, b: cs[b + 1] - cs[a] # inclusive [a, b]
tot = cs[-1]
need = frac * tot
lo = hi = poc
got = w[poc]
while got < need and (lo > 0 or hi < n - 1):
#--- pairs, per the standard algorithm, so the area does not creep one side at a time
dn = seg(max(lo - 2, 0), lo - 1) if lo > 0 else -1.0
up = seg(hi + 1, min(hi + 2, n - 1)) if hi < n - 1 else -1.0
if up >= dn:
if hi >= n - 1:
break
hi = min(hi + 2, n - 1)
else:
if lo <= 0:
break
lo = max(lo - 2, 0)
got = seg(lo, hi)
return bins[poc], bins[lo], bins[hi]
def per_day(day, b, w):
"""-> (days, vpoc, val, vah, lo, hi, total) with bins as INTEGER bin indices.
Gaps matter: a price level with zero activity inside the day's range must be present as
a zero, otherwise the value-area walk annexes across a hole as if it were adjacent, and
low-volume nodes - the entire basis of the stop-placement claim - become invisible.
"""
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)]))
D, P, L, H, LO, HI, T = [], [], [], [], [], [], []
for s, e in zip(starts, ends):
bb, ww = b[s:e], w[s:e]
full = np.arange(bb[0], bb[-1] + 1)
dense = np.zeros(len(full))
dense[bb - bb[0]] = ww
va = value_area(full, dense)
D.append(day[s]); P.append(va[0]); L.append(va[1]); H.append(va[2])
LO.append(bb[0]); HI.append(bb[-1]); T.append(ww.sum())
return (np.array(D), np.array(P), np.array(L), np.array(H),
np.array(LO), np.array(HI), np.array(T))
if __name__ == '__main__':
from kit import load_rates
D = 'c:/Users/admin/Documents/Workspaces/Market Data/'
os.makedirs(OUT, exist_ok=True)
JOBS = [('EURUSD_tick_the5ers_TICK.dat', 'EURUSD'),
('USDJPY_tick_the5ers_TICK.dat', 'USDJPY'),
('XAUUSD_tick_the5ers_TICK.dat', 'XAUUSD'),
('USA500IDXUSD_tick_the5ers_TICK.dat', 'SP500')]
only = sys.argv[1] if len(sys.argv) > 1 else None
for fn, sym in JOBS:
if only and sym != only:
continue
out = f"{OUT}{sym}_vp.npz"
if os.path.exists(out):
print(f"{sym}: exists, skipping"); continue
print(f"\n=== {sym} volume profile ({os.path.getsize(D+fn)/1e9:.1f} GB) ===",
flush=True)
rt, ro, rh, rl, rc, rv, rs = load_rates(sym, 16385)
day, b, tk, dw, binsize = build(D + fn, sym, (rt, rc))
np.savez_compressed(out, day=day, bin=b, ticks=tk, dwell=dw,
binsize=np.array([binsize]))
print(f" saved {out}")