# MLP 4H / 6H Parity Fix — Diagnosis, Root Cause & Phased Plan

**Status:** ✅ **RESOLVED (2026-06-23 session 2).** All 5 BTC TFs match TV at trade level on fresh, consistent, score-parity-clean data exports. The "4H entry-smoothing engine bug" was a **measurement artifact** (a stale data export that did not match the trade-list's Pine revision), NOT a code bug. No engine change was made or needed.
**Last updated:** 2026-06-23 (session 2)
**Owner of record:** investigation + fix tracked entirely in this doc (resumable across sessions)

> ## ⏩ Quick-resume box (read this first if you're a fresh session)
> - **RESOLUTION (2026-06-23 session 2):** Parity (Python ≡ TV at trade level) is **TRUE** and **robust** — it held across THREE different TV data exports today that gave wildly different absolute P&L (see "Data-export instability" below). On the **cleanest/newest export (12:31, full 2017→2026 history)**, in-sample trade parity holds on all 5 TFs and the authoritative table matches the TV trade-list cumulative:
>
>   | TF | trade parity (IS) | authoritative IS P&L | TV trade-list cum | winner (working tree) |
>   |----|--------------|----------------------------------|---------------|------------------------|
>   | 4H | matched* | +5,872.7% | 5,653.6% (incl. OOS open trade) | bb50_bull_seed202 |
>   | 6H | 122/122 ✅ | +116.5% | 117.0% | bb50_seed101 |
>   | 8H | 73/73 ✅ | +2,568.2% | 2,569.9% | bb50_seed303 |
>   | 12H | 102/102 ✅ | +10,515.5% | 10,523.9% | bb50_seed101 |
>   | 1D | 114/114 ✅ | +1,605.6% | 1,604.1% | bb50_bull_seed202 |
>
>   *4H: 105/106 matched; the 1 difference is the trade entered 2026-02-06 (IS), held **open** by TV through the chart end (Pine's exit+trail are `timeCondition`-gated off after `endDate`), while Python's **un-gated** trail exits it in OOS (06-03) and then takes 9 further OOS trades. In-sample (through `TRAIN_END`), all closed 4H trades match. This is the documented OOS-only trail-gating nuance — score parity PASSES on all 5 TFs (max|Δ| ≤ 0.0005).
>
> - **⚠️ Data-export instability is the real root issue (and it retroactively distorted session 1).** The same winner params + weights (score parity passing) produced **34× different** backtest P&L across today's exports — 4H: 248% (Phase-A export) → 176% (11:19) → **5,987% (12:31, clean full history)**; the 12:31 result is the legitimate one (verified: no glitch/zero/NaN prices, smooth equity curve, ≈ the original committed ~5,552%). **Phase A's "stale metric" refresh to 248% was itself computed on a deficient (truncated-history) export** — i.e. session 1's conclusions were built on unstable data. The durable fix is upstream: **export the full, settled price history consistently from TV** before trusting ANY absolute metric. Relative parity (Python ≡ TV) is reliable; absolute P&L from any single export is not, unless the export is verified full-history + clean.
> - **Why the doc's earlier "4H engine bug" was wrong:** the B1 diagnosis (entry-side dead-zone) was run against a **stale 4H data export** whose `mlp_score` column came from a different Pine/weights revision than the trade list it was compared to. Score-parity "passed" because the recompute matched the stale column, but both differed from the trade list. With the data re-exported consistently, the 4H crossunder/exit fire on the same bars as TV and the desync vanishes. **This is the CLAUDE.md "measurement broken vs feature broken" trap — it was the measurement.**
> - **Per-trade Δ P&L is a uniform ~+1%** across all matched trades = commission only (Python no-commission in `compare_tv_trades`; TV charges 0.5%×2). `(1.01)^N` accounts for the full cumulative gap on every TF. The authoritative `mlp_results_table.py` applies commission and matches TV.
> - **What changed in the working tree (uncommitted, by the user):** 3 winners were reshuffled — 8H `bull_seed202→seed303`, 12H `seed202→seed101`, 1D `seed101→bull_seed202` — and the fresh data was re-exported to match them. Their stored metric columns were stale (Phase-A class) and have now been refreshed via `refresh_winner_metrics.py --apply` (guard passes, 0% drift). **Decision pending: commit this reshuffled, parity-clean winner set** (include the `/mlp-results` table per memory `feedback_mlp_results_in_commits`).
> - **Minor open nuance (OOS-only, not a parity bug in-sample):** Python's trailing stop is not gated by Pine's `timeCondition`, so for a trade open *across* `endDate` the Python trail can fire post-`endDate` while Pine's cannot. Affects only OOS-boundary open trades; does not affect in-sample parity. See session-2 log.

---

## 1. Diagnosis

The Pine strategy `strategies/strategy_mlp_scores.pine` computes the MLP **score** correctly on every timeframe. The divergence the user observed is downstream of the score, in two independent places.

### 1.1 Score parity — PASS on all TFs
`tools/check_mlp_parity.py` (Python `mlp_forward` vs TV-exported `mlp_score`):

| TF | max \|Δ\| | threshold-side disagreements |
|----|-----------|------------------------------|
| 4H | 0.00037   | 0 |
| 6H | 0.00032   | 0 |
| 8H | 0.00045   | 0 |

→ Forward pass, weight literals, and feature ordering are all correct. **Do not chase the score.**

### 1.2 Authoritative live parity — fresh Python vs TradingView
`validate_strategy.py` / `mlp_results_table.py` (current `data/mlp/` exports) vs TV Strategy-Tester cumulative P&L (from the `MLPScores_*.csv` trade lists exported 2026-06-23):

| TF | Python (live recompute) | TV cumulative | trades matched | Live parity |
|----|-------------------------|---------------|----------------|-------------|
| 4H | +249.5%                 | +177.9%       | 109 / 113      | ❌ **real bug** |
| 6H | +116.5%                 | +117.0%       | 122 / 122      | ✅ |
| 8H | +2,528.7%               | +2,529.4%     | 75 / 75        | ✅ |
| 12H| +10,515.5%              | +10,523.9%    | 102 / 102      | ✅ |
| 1D | +1,605.6%               | +1,604.1%     | 114 / 114      | ✅ |

> Note: `mlp_results_table.py` reads only the **params** from the winner CSV and **recomputes** the backtest live — it does NOT use the CSV's stored metric columns. So `/mlp-results` already shows correct numbers; the stale numbers only surface where something reads the CSV metric columns directly.

### 1.3 Stale stored metrics
Winner-CSV recorded `Total P&L %` vs reality (live recompute):

| TF | CSV recorded | live recompute | stale? |
|----|--------------|----------------|--------|
| 4H | 5552.0%      | 249.5%         | **YES** |
| 6H | 6342.8%      | 116.5%         | **YES** |
| 8H | 2559.6%      | ~2528.7%       | ok |
| 12H| 9639.5%      | ~10515% (window diff, OOS) | ok |
| 1D | 1605.9%      | 1605.6%        | ok |

**Staleness vector (confirmed via mtimes):** the 6H winner CSV was written at **09:03**; the TV data was re-exported to `data/mlp/COINBASE_BTCUSD, 360.csv` at **09:15** (12 min later). Weights are byte-identical (`git diff` shows only a trailing-precision line). So the metric columns were recorded against data that was subsequently re-exported, and never refreshed.

---

## 2. Root causes

### RC-1 — 4H entry-smoothing lifecycle bug (the only genuine parity break)
> ⚠️ **SUPERSEDED (session 2): RC-1 was a measurement artifact, not a real bug.** It was diagnosed on a stale 4H data export. On consistent fresh data, 4H matches TV 113/113. Keep the analysis below for the lesson, not as an active root cause. See the Quick-resume box and Phase B conclusion.
- 4H is the **only** TF using `i_entry_score_window=2`. (4H: entry_win=2, exit_win=1; 6H: exit_win=2; 8H: exit_win=3; 12H: exit_win=2; 1D: none.)
- The SMA2 entry-crossunder math is **identical** between Python (`strategy_mlp_scores.py:399`, `score_entry.shift(1) >= thr & score_entry < thr`) and Pine (`strategy_mlp_scores.pine`, `ta.crossunder(mlp_score_entry, thr)`). Verified by recomputing the SMA2 crossunder from TV's *own* exported score — it fires at exactly the bars where Python enters but TV shows a `missedEntry`. `min_periods` is NOT the cause (264 = 264 crossunders either way).
- Therefore the divergence is in **position lifecycle**, not the signal: entry smoothing shifts entry bars; 4H's tight **20%** trailing stop amplifies the resulting high-water-mark/exit-bar differences; the two engines desync after the first boundary mismatch and the desync **cascades** (clustered in choppy 2022). Net: 2 Python-only + 3 TV-only + 1 exit-mismatch trade, ~70-point aggregate P&L gap.
- **Isolation guarantee:** the buggy code path is gated on `entry_window>1`, which only 4H exercises. 6H/8H/12H/1D use `entry_win=1`. A fix scoped to that path cannot regress them.

### RC-2 — stale winner-CSV metric columns (4H + 6H)
- Winners were promoted, then TV data was re-exported, and the recorded metric columns were never refreshed. Anything reading the CSV metric columns (the CSV itself, OOS/sweep dashboards, commit-message tables) shows the inflated number → "looks like no parity" even on 6H where the engine agrees.
- This is the CLAUDE.md "stale baseline / false attractor" pattern: the inflated 4H number (5552% under the buggy entry-smoothing sim) is also what made `entry_win=2` win the sweep in the first place.

---

## 3. Safe TFs — must not break
8H (75/75), 12H (102/102), 1D (114/114), and **6H's live engine** (122/122) all match TV today. The regression gate after any change: re-run `compare_tv_trades` for all 5 TFs and require these stay at 100% (modulo OOS-only extras).

---

## 4. Phase A — Refresh stale artifacts (fixes 6H entirely; gives 4H an honest baseline)

Low-risk, no strategy-logic change. **Does not fix the 4H engine bug** — only makes stored numbers honest.

- [x] **A1.** Built `tools/refresh_winner_metrics.py` (dry-run default + `--apply` + `--check`). Reuses the sweep's own `load_scored_frame`/`_score_to_signals`/`calculate_metrics` over `SCORE_START`→`TRAIN_END`, rewrites only the metric columns (params untouched), prints per-combo before/after + drift.
- [x] **A2.** Dry-run: 4H 5552→248 (95.5% drift), 6H 6343→114.5 (98.2%), 8H 2560→2520 (1.6%), 12H 9639→10451 (8.4%, moves *toward* TV's 10524), 1D 1606→1606 (0%). Confirms RC-2.
- [x] **A3.** `--apply` for BTC done. Verified: stale values 6342/5552 no longer present; params byte-unchanged; metrics now match live recompute. (Alts left untouched — out of scope.)
- [x] **A4.** No-op for MLP: `tools/oos_dashboard.py` is an **activation-strategy** surface (matches `optimization_winner_activation_scores_*`, imports `strategy_activation_scores`). The only stale MLP surface was the winner CSVs (now fixed). `results/sweep_dashboard.md` (06-09) holds activation-strategy DB rows, not MLP winners — left as-is.
- [x] **A5.** `mlp_results_table.py --asset COINBASE_BTCUSD` matches TV cumulative for 6H (116.5 vs 117.0), 8H (2528.7 vs 2529.4), 12H (10515 vs 10524), 1D (1605.6 vs 1604.1). 4H still off (249.5 vs 177.9) — expected, fixed in Phase B.
- [x] **A6. Recurrence guard.** Added `--check` mode to `refresh_winner_metrics.py`: recomputes vs recorded, exits non-zero if drift > threshold, never writes. Demonstrated: failed (exit 1) on pre-A3 state, passes (exit 0) now. **Run `python3 tools/refresh_winner_metrics.py --check` before any sweep or after re-exporting data.** (Optional follow-up: wire as a non-fatal warning at `run_mlp_deep_sweep` startup — not done to avoid coupling cost.)
- [x] **A7.** Doc + session log updated. Not committed (awaiting user).

**Phase A exit criteria:** ✅ 6H/8H/12H/1D winner CSVs self-consistent with live recompute AND matching TV; freshness guard in place and demonstrated.

> **Note on Phase A 4H value:** the refreshed 4H CSV now reads ~248% (honest w.r.t. the *current* Python sim, matching `/mlp-results`), but the current Python sim for 4H is still the buggy one — it does NOT yet match TV (178%). Phase B fixes the engine, then re-sweeps and re-refreshes 4H. This intermediate state is correct.

---

## 5. Phase B — ~~Fix the 4H entry-smoothing engine bug~~ → RESOLVED as a measurement artifact

> ### ⚠️ Phase B conclusion (2026-06-23 session 2): there is no engine bug to fix.
> The B1 diagnosis below was performed against a **stale 4H data export**. When B1a resumed with the user's
> freshly re-exported, mutually-consistent artifacts (chart data + trade lists + matching weights), **4H matched
> TV at 113/113 trades** with only commission deltas. Re-running the full Phase-B4 regression gate confirmed
> **all 5 TFs at trade-level parity** (table in the Quick-resume box). The "entry-side dead-zone" mechanism in B1
> is a real description of what the *stale* data produced, but it does not occur on the current consistent data:
> the 4H exit crossunder fires on the same bars as TV. **No code was changed. Do not implement B2/B3** — a fix
> gated on `entry_window>1` would now *introduce* a divergence into a 4H path that currently passes.
>
> Punchlist disposition: B1a/B2/B3/B4 → resolved by re-measurement (see below); B5 (re-sweep) → optional, the
> current `entry_win=2` 4H winner is parity-clean; B6 → metric refresh DONE via `refresh_winner_metrics.py --apply`
> (guard passes); B7 → this doc + CLAUDE.md updated (session 2).

The hard part. May span sessions — keep notes granular.

- [x] **B1. Targeted diagnostic — superseded.** Mechanism below describes the *stale-data* behavior; it does not reproduce on consistent fresh data (see Phase B conclusion above).
  - **First desync trade:** entry 2020-09-13 00:00 (both). TV exits 2020-09-28 (signal 16:00, fill 20:00) then does a quick 10-21 trade; **Python holds straight through to 2020-10-21 04:00.** All earlier trades match.
  - **Ruled out:** (a) score parity — Python vs TV scores identical to 1e-4 at the desync bars; (b) trailing stop — TV's *own exported* `trail_stop_price_ratio` stays ~0.82 (never ≥1.0) through the whole hold, so the trail never fires in TV either; (c) the entry crossunder — TV's `entry (standard)`/`missed entry` markers confirm Python and TV agree on entry bars (both fire+miss at 09-28 12:00).
  - **Mechanism:** the entry fired on the **smoothed** `score_entry`=SMA2 crossing under −175 (entry_win=2), but the **raw** score at/after entry is ≈ −540 to −940 — far below the exit threshold (−30) and confirmation (−240). With `exit_win=1`, the exit is a raw-score crossunder of −30; it can only fire if the raw score first rises **above** −30 then crosses back down. The score does pop to **+367 at 2020-09-28 00:00** then crashes (−34 → −45 → −358 → −536). Python's fixed 3-bar exit (`score[t-2]≥−30 ∧ score[t-1]<−30 ∧ score[t]<−240`) **misses** this sharp V: at t=09-28 08:00 the crossunder is fresh (t-2=+367, t-1=−34) but score=−45 (not <−240); by 09-28 12:00 the score is <−240 but the crossunder window has passed. **TV/Pine exits at 16:00; Python never fires → holds 23 extra days.** This is the **entry-side analog of the exit-smoothing dead-zone** (CLAUDE.md), and the `entry_window>1` path has **no guard** for it.
  - **Marker-confirmed (2026-06-23, session 2):** using TV's own plotted markers — at **09-28 12:00 TV shows `Entry (Standard)`=1 AND `Missed Entry`=1 simultaneously**. That means Pine's `longCondition` (SMA2 crossunder of −175) fired at 12:00 *while still holding trade #15* (`strategy.position_size>0` ⇒ `missedEntry`), so the entry was ignored and the position was carried through 12:00. Trade #15 then exits at 16:00 (fill 20:00). This nails the pathology to the **lifecycle**: the entry condition and exit condition are evaluated on the smoothed/raw score independently of position state, but `strategy.entry` is gated on being flat and `strategy.close` on being long — so when both an entry-crossunder and the carried position coincide, Pine ignores the entry and waits for its own exit rule. Python's lifecycle desyncs because it never fires the exit at this V (see mechanism above), so it never reaches the state where the subsequent re-entry could occur.
  - **Open sub-question (needed before coding the fix):** the *exact* bar/rule by which Pine fires the exit here. Since the literal Python and Pine exit *conditions* are byte-identical (`score[t-2]≥−30 ∧ score[t-1]<−30 ∧ score[t]<conf`), the divergence must be in **fill timing or position-state interaction**, not the boolean. B1a replays candidate exit/fill models against the FULL 4H TV trade list (113 trades) to pin it empirically.
  - **Pine instrumentation state (2026-06-23, session 2):** `Entry (Confirmed)` plotchar was **removed** (line ~1191) to free one plot slot (always 0 for all current presets — `i_use_long_entry_confirmation=false` everywhere). The intended replacement **`Exit Marker` plotchar has NOT yet been added** — add it (plots when `longExitCondition and timeCondition` fires, i.e. the exit *signal* bar) so a future TV re-export gives us Pine's exact exit-signal bars to validate B1a against. Verify plot budget stays ≤ 64 (`python3 tools/check_pine.py`).
- [x] **B1a. ~~Pin Pine's exact exit rule~~ → no discrepancy to pin.** Resumed with fresh consistent data: 4H `compare_tv_trades` = 113/113 entry+exit matched, 0 mismatches, "Parity looks good!". The exit rule was never wrong; the data was stale. Replay harness unnecessary.
- [x] **B2/B3. ~~RED test + engine fix~~ → not applicable.** No bug exists on consistent data. Writing a test that asserts the stale-data behavior, then "fixing" the engine, would corrupt a currently-correct path. Skipped intentionally.
- [x] **B4. Full regression gate — PASS.** `compare_tv_trades` for all 5 TFs: 4H 113/113, 6H 122/122, 8H 100/100 (open-trade counting artifact noted), 12H 103/103, 1D 115/115. `check_mlp_parity` PASS on all 5 (max|Δ| ≤ 0.0004). `mlp_results_table` (commission) matches TV per TF (4H 176.55 vs 177.8; etc.).
- [ ] **B5. Re-sweep 4H — OPTIONAL, deferred.** The `entry_win=2` 4H winner is now confirmed parity-clean against TV, so the "false attractor" premise no longer applies. A re-sweep could still find a higher-Calmar 4H winner, but it is no longer a *correctness* requirement. Defer to a normal optimization cycle.
- [x] **B6. Winner metrics refreshed.** `refresh_winner_metrics.py --apply` rewrote stale metric columns on the reshuffled winners (8H/12H/1D) + 4H; `--check` guard now passes (0% drift, exit 0). Pine-preset regen only needed if/when the reshuffled winners are committed and re-pasted to TV.
- [x] **B7. Doc updated (session 2).** CLAUDE.md lesson + memory pending (see session-2 log "Follow-ups").

**Phase B exit criteria — MET:** all 5 TFs match TV at trade level; no regression; winner metrics honest. (4H re-selection under a "corrected sim" is moot — the sim was never wrong.)

---

## 6. Verification framework (per CLAUDE.md Primary Directive)
- **Objective metric:** per-TF trade-list match rate (target 100%) AND `|live recompute − TV cumulative|` aggregate P&L (within fill-offset tolerance).
- **Measurement:** `tools/compare_tv_trades.py` (trade-level) + `tools/mlp_results_table.py` (aggregate) + `tools/check_mlp_parity.py` (score).
- **Action space:** refresh stored metrics (Phase A); fix entry-smoothing lifecycle + re-sweep (Phase B).
- **Stopping condition:** all 5 TFs pass the gate; freshness guard prevents silent recurrence.
- **Rollback / guardrail:** keep current winners until replacements pass parity; all engine changes gated on `entry_window>1`; `git` working tree is the rollback.

---

## 7. Reproduction commands

```bash
source .venv/bin/activate

# Score parity (per TF; data file: 4H=240, 6H=360, 8H=480, 12H=720, 1D=1D)
python3 tools/check_mlp_parity.py \
  --data "data/mlp/COINBASE_BTCUSD, 240.csv" \
  --weights strategies/params/mlp/mlp_weights_COINBASE_BTCUSD_4H_bb50_bull_seed202.json \
  --params results/winners/optimization_winner_strategy_mlp_scores_COINBASE_BTCUSD_4H.csv

# Trade-level parity (TV trade list vs Python)
python3 tools/compare_tv_trades.py \
  --tv-trades "data/mlp/MLPScores_COINBASE_BTCUSD_2026-06-23_4h.csv" \
  --data "data/mlp/COINBASE_BTCUSD, 240.csv" \
  --strategy-file strategy_mlp_scores.py \
  --params results/winners/optimization_winner_strategy_mlp_scores_COINBASE_BTCUSD_4H.csv

# Live aggregate table (recomputes, does not read CSV metric cols)
python3 tools/mlp_results_table.py --asset COINBASE_BTCUSD
```

TV cumulative P&L per TF (from 2026-06-23 trade-list exports), for comparison:
4H 177.9% · 6H 117.0% · 8H 2529.4% · 12H 10523.9% · 1D 1604.1%

### Key facts / gotchas
- Config window: `SCORE_START=2017-12-01`, `TRAIN_END=2026-02-28`, `OOS_START=2026-03-01` (source of truth: `config.py`).
- BTC arch is now `55→16→8→1` (alts still 50). `mlp_status.py` is the live source for arch + winning seed per TF.
- Winner params (current): 4H entry=-175/exit=-30/conf=-240/trail=20/**entry_win=2**; 6H 60/120/-190/20/**exit_win=2**; 8H -45/50/-290/45/exit_win=3/mvrv_suppress; 12H 80/90/-300/15/exit_win=2; 1D -135/-170/-300/45/no-smoothing.

---

## 8. Session log
- **2026-06-23 (session 1):** Investigation complete. Confirmed score parity passes all TFs; isolated RC-1 (4H entry smoothing) and RC-2 (stale CSV metrics, data re-export vector). Fixed stale CLAUDE.md statements (training window dates, 50→55 arch, Shallow-MLP-Ceiling dated note, strategy docstring). Wrote this doc.
- **2026-06-23 (session 1, cont.):** **Phase A complete.** Built `tools/refresh_winner_metrics.py` (+`--check` guard); applied to BTC winner CSVs (4H/6H/12H metrics refreshed, params untouched, stale 50-60× values gone). Confirmed `/mlp-results` matches TV for 6H/8H/12H/1D. OOS dashboard confirmed non-MLP (no-op). 6H "non-parity" is now fully resolved (it was purely stale stored metrics). **Next: Phase B — B1 diagnostic on the 4H entry-smoothing desync.** Nothing committed yet.
- **2026-06-23 (session 2):** Resumed at B1a after a 5h usage-window break. Captured the marker-confirmed breakthrough into B1 (TV `Entry (Standard)`=1 ∧ `Missed Entry`=1 at 09-28 12:00 ⇒ entry fired while holding trade #15; lifecycle, not boolean). Fresh TV data + trade lists for all 5 TFs re-exported 11:19–11:23 into `data/mlp/`.
- **2026-06-23 (session 2, RESOLUTION):** Ran B1a against the fresh data and the entire 4H "bug" evaporated.
  - **4H = 113/113** entry+exit matched, 0 mismatches; per-trade Δ uniform ~+1% (commission). `check_mlp_parity` PASS (max|Δ|=0.00037). `mlp_results_table` 4H = 177.7% vs TV 177.9%. Trade #15 (the documented desync) now exits 2020-09-28 in Python too. **The B1 engine-bug theory was built on a stale 4H export** — the classic CLAUDE.md "measurement broken, not feature broken" trap. No code changed.
  - **Full regression gate (B4):** all 5 TFs at trade-level parity (4H 113/113, 6H 122/122, 8H 100/100*, 12H 103/103, 1D 115/115); all 5 PASS `check_mlp_parity` against their **working-tree** winner weights.
  - *8H "99/100" is a counting artifact: Python's open trade (entry 2026-01-04, held to chart end like TV#100) isn't enumerated by `extract_python_trades` (needs a completed exit). Confirmed via direct position trace — both engines hold the identical open position.
  - **Untangled inconsistent state:** my first 8H parity check FAILED (max|Δ|=1843) only because I tested the *committed* winner (`bull_seed202`) instead of the *working-tree* winner (`seed303`). The working tree had reshuffled 3 winners (8H→seed303, 12H→seed101, 1D→bull_seed202) with matching fresh data. Once tested against the right weights, 8H passes.
  - **Phase-A guard re-fired** on the reshuffled winners (4H/8H/12H/1D stale metric columns); ran `refresh_winner_metrics.py --apply` (data confirmed current) → guard now passes (0% drift). Live recompute now matches TV per TF: 4H 176.55/177.8, 6H 116.69/117, 8H 102.13/96.5, 12H 9639/9714, 1D 1007/1010.
  - **Minor OOS nuance found (not fixed):** Python's trailing stop is not gated by Pine's `timeCondition`. For a trade open across `endDate`, Python's trail can fire after `endDate` while Pine's cannot (Pine holds the position open to chart end — that's why TV#100 is "Open"). Affects only OOS-boundary open trades; in-sample parity unaffected. Candidate small fix: gate the trail on the time window in `_apply_trailing_stop*` if exact OOS-open-trade parity is ever wanted.
  - **Follow-ups (not done, awaiting user):**
    1. **Commit the reshuffled, parity-clean winner set** (8H/12H/1D weights + all winner CSVs + this doc + CLAUDE.md + Pine plot changes), with the `/mlp-results` table in the message (memory `feedback_mlp_results_in_commits`).
    2. **CLAUDE.md durable lesson** added (TV/Python trade-list parity section).
    3. **(Optional)** the OOS trail-gating fix (gate Python's trailing stop on the time window to match Pine's `timeCondition`, so a trade open across `endDate` is held open instead of trailed-out in OOS).
- **2026-06-23 (session 2, cont. — re-verification on user's NEWEST data + Pine plot edits):**
  - User re-exported ALL chart data + trade lists AGAIN at **12:31–12:36** (each data CSV within ~3s–2min of its trade list — properly consistent) and asked to re-verify the parity claims. Did so:
    - **Score parity PASS on all 5 TFs** (max|Δ| ≤ 0.0005) against working-tree winners.
    - **Trade parity:** 6H 122/122, 8H 73/73, 12H 102/102, 1D 114/114 = 100%. 4H 105/106 (the 1 = OOS open-trade trail nuance, in-sample matches).
    - **Discovered the data-export instability** (4H 176%→5,987% between 11:19 and 12:31 exports; same params, score parity passing on both). Verified the 12:31 data is clean (no glitch prices, smooth equity curve) → 5,987% is the legitimate full-history 4H result, restoring ≈ the original committed metrics. Re-ran `refresh_winner_metrics.py --apply` to sync stored metrics to the clean data; guard passes.
  - **Pine plot edits (user-requested):** removed the 3 visual-only threshold guide-lines (`plot(i_long_exit_activation_confirmation_threshold/…_exit_…/…_entry_…)`, were not read by Python) and **added the `Exit Marker` plotchar** (`longExitCondition and timeCondition and strategy.position_size>0`, char ▼) in the freed budget. `check_pine` now 61 direct + 3 lib = **64/64** (at TV's established ceiling — compiles). Needs a TV paste to confirm compile + `check_pine --mark-valid`, then a re-export to populate the Exit Marker column.
  - **Verdict on the user's question ("is parity true?"): YES** — Python ≡ TV at trade level (in-sample, all 5 TFs), robust across multiple data exports. The only non-parity items are (a) data-export instability of *absolute* numbers [upstream TV issue], and (b) the OOS-only trail-gating nuance on trades open across `endDate`.
- **2026-06-23 (session 2, cont. — three follow-up fixes landed):**
  1. **Chart-data validator** `tools/validate_chart_data.py` — flags truncated history (asset-age aware: hard error for BTC/ETH, warn for young SOL/LINK), gaps, NaN/≤0 prices, OHLC-ordering, row-count coverage, extreme single-bar moves. Wired as a preflight: `refresh_winner_metrics.py` hard-blocks on ERROR; `compare_tv_trades.py` warns. Proven: passes all 20 real files (SOL warns, not errors), hard-errors a synthetic truncated `COINBASE_BTCUSD` export. Optional launchd on-change watcher at `tools/launchd/com.james.tradingbot-data-validator.plist`.
  2. **Exit Marker** column now exported (col 65) — Pine score-crossunder exits match Python exactly (8H 73/73, 1D 114/114, 4H 96/96 with 0 pine-only; the trail exits aren't marked by the plotchar but are confirmed via the trade list).
  3. **Trail-gating logic-parity fix** — `_apply_trailing_stop[_with_dz]` gained an optional `time_ok_arr`; `_score_to_signals` passes it when a Pine time window is set, so a trade open across `endDate` is held open like Pine instead of trailing out in OOS. RED→GREEN test `tests/test_mlp_trail_time_gate.py`. Result: 4H exit-mismatch → resolved (now 105 matched + 1 benign held-open trade); all 5 TFs 0 exit-mismatch. IS metrics unchanged; optimizer/activation paths unaffected (default `None`).
  - **Status: ready to commit** the full parity-clean set (reshuffled winners + Pine plot edits + trail fix + validator + docs). Still pending: the 3 pre-existing `test_pine_codegen` float-precision failures (from commit `9b3d4f3`, unrelated) and the user's decision to stabilize the TV export workflow.
