Skip to content

Commit 0a662ed

Browse files
luisleo526claude
andauthored
test(verify): consolidate TradingView trade-list fragments before pairing (#54)
TradingView's "List of Trades" (and the engine mirroring it) splits one entry FILL into multiple "Trade #" rows: a tiny qty_step rounding remainder sharing the SAME entry time+price, or FIFO partial-close lots of a grid bot. The greedy entry-time matcher in verify_corpus then cross-pairs same-entry lots, producing spurious count + exit-price-p90 deltas (the ~90% qty-p90 is the fingerprint) even though the engine's fills are trade-for-trade price-exact. Add consolidate_fragments(): group rows by an EXACT (entry_time, entry_price, direction) key and merge each group into one logical trade (sum qty/pnl, keep the shared entry, represent the exit by the shared price or qty-weighted final close). Applied symmetrically to the TV and engine lists before alignment (mirrored in regen_validation_report.py). Because the key is compared exactly, two rows merge only when they are the same fill event — a distinct trade lands on a different bar or price level and keeps its own key, so real divergences are never masked. Corpus byte-identical: verify_corpus.py --all stays excellent=251 / anomaly=1 / fail=0 with ZERO tier changes (4 corpus strategies that legitimately fragment consolidate symmetrically and stay excellent — the key is correct, not inert). Scraped targets: tomukasss weak->excellent (count 31%->0%, qty-p90 51%->0.2%); xlm/xau grids' fragment artifact cleared (qty-p90 ~90%->~0.5%, count->~0) while their REAL residual exit-p90 ~6% (TV holds lots days longer) correctly remains. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 78b5300 commit 0a662ed

2 files changed

Lines changed: 107 additions & 0 deletions

File tree

scripts/regen_validation_report.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ def _verify_probe(strategy_dir: Path) -> dict:
8686

8787
tv = vc.parse_trades(tv_path, tz=vc.tv_tzinfo(meta))
8888
eng = vc.parse_trades(eng_path, tz=vc.timezone.utc)
89+
# Keep the report in lock-step with verify_one: consolidate fragment rows
90+
# (qty_step rounding / FIFO partial-close lots) symmetrically before pairing.
91+
tv = vc.consolidate_fragments(tv)
92+
eng = vc.consolidate_fragments(eng)
8993
matched = vc.align_by_time(tv, eng)
9094
tv_cmp, eng_cmp = vc.trim_to_common_match_window(tv, eng, matched)
9195
matched = vc.align_by_time(tv_cmp, eng_cmp)

scripts/verify_corpus.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,103 @@ def parse_trades(csv_path: Path, *, tz) -> list[TradePair]:
363363
return pairs
364364

365365

366+
def consolidate_fragments(pairs: list[TradePair]) -> list[TradePair]:
367+
"""Reunite the fragment rows that split a single logical fill into one trade.
368+
369+
TradingView's "List of Trades" (and the engine, mirroring it) splits one
370+
entry FILL across several ``Trade #`` rows whenever that position is closed
371+
in lots — either a tiny ``qty_step`` rounding remainder that shares the
372+
SAME entry time AND price, or FIFO partial-close fragments of a grid bot
373+
where one entry is drained by several exit orders. Every such fragment is a
374+
*different exit lot of the same entry*, so the entry side is identical
375+
across the group: same bar timestamp, same fill price, same direction.
376+
377+
Left raw, these fragments break the entry-time pairing in
378+
:func:`align_by_time`: two fragments share one entry instant, so the greedy
379+
matcher cross-pairs a TV lot with the wrong engine lot and reports spurious
380+
count + exit-price deltas (the tell-tale ~90% qty-p90 is the fingerprint).
381+
This helper merges each fill back into one trade and is applied
382+
SYMMETRICALLY to the TV and engine lists, so a genuinely fragmented
383+
strategy still pairs 1:1.
384+
385+
Merge key = ``(entry_time, entry_price, direction)`` compared EXACTLY: two
386+
rows merge iff they share the same bar, the same fill price (read from the
387+
identical CSV cell, hence bit-identical within one file) and the same side
388+
— i.e. they are the same fill event. Two *distinct* trades can never
389+
collide, because a second independent entry must occur on a different bar
390+
or at a different fill price (a different grid level), either of which
391+
changes the key. For an un-fragmented strategy every group has size 1, so
392+
this is a strict no-op and the reference corpus is left byte-identical.
393+
394+
The merged trade keeps the shared entry (time + price) and direction, sums
395+
the per-lot qty / pnl / excursions, and represents the exit by the lots'
396+
qty-weighted-average price at the final close time — the way TradingView
397+
aggregates a multi-lot deal. When every fragment shares one exit (pure
398+
qty_step rounding) that average IS the shared exit price, kept exactly so
399+
the comparison stays bit-for-bit unchanged.
400+
401+
>>> mk = lambda n, et, ep, xt, xp, q, p: TradePair("long", et, ep, xt, xp, q, p, n)
402+
>>> # two qty_step rounding fragments of one fill: same entry AND same exit
403+
>>> a = mk(1, 100, 10.0, 200, 12.0, 0.01, 0.02)
404+
>>> b = mk(2, 100, 10.0, 200, 12.0, 0.99, 1.98)
405+
>>> # a distinct later trade (different entry bar + price) must NOT merge
406+
>>> c = mk(3, 300, 11.0, 400, 13.0, 1.00, 2.00)
407+
>>> out = consolidate_fragments([a, b, c])
408+
>>> [(round(t.qty, 4), round(t.pnl, 4), t.exit_price) for t in out]
409+
[(1.0, 2.0, 12.0), (1.0, 2.0, 13.0)]
410+
>>> # FIFO grid: ONE entry drained by two DIFFERENT exit lots -> one deal,
411+
>>> # exit = qty-weighted average price at the final close time
412+
>>> d = mk(4, 100, 10.0, 150, 12.0, 0.5, 1.0)
413+
>>> e = mk(5, 100, 10.0, 250, 14.0, 0.5, 2.0)
414+
>>> g = consolidate_fragments([d, e])
415+
>>> len(g), g[0].qty, g[0].exit_price, g[0].exit_time
416+
(1, 1.0, 13.0, 250)
417+
"""
418+
groups: dict[tuple[int, float, str], list[TradePair]] = {}
419+
order: list[tuple[int, float, str]] = []
420+
for t in pairs:
421+
key = (t.entry_time, t.entry_price, t.direction)
422+
if key not in groups:
423+
groups[key] = []
424+
order.append(key)
425+
groups[key].append(t)
426+
427+
out: list[TradePair] = []
428+
for key in order:
429+
members = groups[key]
430+
if len(members) == 1:
431+
out.append(members[0])
432+
continue
433+
qty = sum(m.qty for m in members)
434+
denom = qty if qty else 1.0
435+
rep = members[0]
436+
if len({m.exit_price for m in members}) == 1:
437+
# Shared-exit fragments (pure qty_step rounding): keep the exact
438+
# shared exit so the merge is bit-for-bit identical to a single fill.
439+
exit_price = rep.exit_price
440+
exit_time = rep.exit_time
441+
else:
442+
# FIFO partial-close lots: blend like a TV deal — qty-weighted
443+
# average exit price, settled at the final close time.
444+
exit_price = sum(m.exit_price * m.qty for m in members) / denom
445+
exit_time = max(m.exit_time for m in members)
446+
out.append(TradePair(
447+
direction=rep.direction,
448+
entry_time=rep.entry_time,
449+
entry_price=rep.entry_price,
450+
exit_time=exit_time,
451+
exit_price=exit_price,
452+
qty=qty,
453+
pnl=sum(m.pnl for m in members),
454+
trade_num=min(m.trade_num for m in members),
455+
pnl_pct=sum(m.pnl_pct * m.qty for m in members) / denom,
456+
mfe=sum(m.mfe for m in members),
457+
mae=sum(m.mae for m in members),
458+
))
459+
out.sort(key=lambda t: t.entry_time)
460+
return out
461+
462+
366463
def load_strategy_metadata(strategy_dir: Path) -> dict:
367464
inputs_path = strategy_dir / "inputs.json"
368465
if not inputs_path.exists():
@@ -458,6 +555,12 @@ def verify_one(strategy_dir: Path, *, verbose: bool = True, show_diffs: int = 0)
458555

459556
tv = parse_trades(tv_path, tz=tv_tzinfo(meta))
460557
eng = parse_trades(eng_path, tz=timezone.utc)
558+
# Reunite TradingView/engine fragment rows (qty_step rounding remainders or
559+
# FIFO partial-close lots of one fill) into a single logical trade BEFORE
560+
# pairing, symmetrically on both sides, so the entry-time matcher does not
561+
# cross-pair same-entry lots. No-op for un-fragmented strategies.
562+
tv = consolidate_fragments(tv)
563+
eng = consolidate_fragments(eng)
461564
matched = align_by_time(tv, eng)
462565
tv_cmp, eng_cmp = trim_to_common_match_window(tv, eng, matched)
463566
matched = align_by_time(tv_cmp, eng_cmp)

0 commit comments

Comments
 (0)