CAT researchOverviewEURUSDGBPUSDXAUUSDGBPCADEURCADAUDJPYGBPJPYResearch notes

ZTD Entry Indicator (Pine) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: A Pine v6 indicator with alerts for the 5m/15m chart that reproduces the entry5m harness (H1/H4 ZTD bias, pullback stop orders, HTF-extreme or pullback stop, R:R target, optional scale-out) with signal parity against pyakao.

Architecture: A transform generates the ZTD v17 core as a function f_ztd() inside entry5m/pine/ztd_entry.pine; the chart evaluates it on the bias timeframe through request.security with a one-bar shift (closed HTF bars only). Chart-level Pine ports bias.py, setup.py and execution.py (no slippage) in the harness's per-bar order, draws the levels, keeps a HUD, fires alert() messages, and exposes a data-window surface that two Python scripts compare with ZTD v17 and with run_entry5m on exported bars.

Tech Stack: Pine Script v6 on TradingView Desktop (CDP :9222, tradingview MCP), Python 3.13 (pyakao, pytest), the CDP export tooling in pyakao/validation/ and harmonic/validation/.

Spec: entry5m/docs/2026-09-06-ztd-entry-indicator-design.md (binding).

Global Constraints

  • Pine v6, pure ASCII source (the clipboard pipeline mangles UTF-8). ztd_divergence.pine is NOT modified. The generated core lives between // ENTRY_CORE_BEGIN and // ENTRY_CORE_END in entry5m/pine/ztd_entry.pine and is only ever produced by entry5m/tools/make_entry_core.py (idempotent; never hand-edit inside the markers).
  • ZTD engine constants inside the core are the v17 defaults: intrabar = true, refMode = "Until superseded", maxSigPerRef = 1, timeoutBars = 240, cancelOpp = true, zeroRead = "Close", zeroTol = 0.0, oObTier = "Extreme", both engines "Rolling (24-bar)", "Percentile", elev 2.0 / ext 2.5 (fixed-mode values, unused), calib 2000, percentiles 90 / 95, sigmaLen = 200, useVolume = true, pivLen = 5, flipMin = 5, tfAny = true. tMinTier = the arm-tier input in arm mode, "Elevated" in divergence mode.
  • Non-repainting: the chart only ever sees closed HTF bars (f_ztdPrev() shifts every core output by one bar; request.security(..., lookahead = barmerge.lookahead_on)); every chart-level decision on bar i uses bars <= i. Per-bar order on the chart, exactly the harness (runner.py): (1) open position: stop, partial, target; pending order: fill then the same sequence, else cancel checks; (2) HTF events: ends first (close the position at the bar close, cancel the order, reset the pullback boundary), then a start; (3) divergence mode: bias ends with reason target when the bar trades through the zero-touch level; (4) pivot scan, order pricing, placement only while a bias is active and no position is open.
  • Rule semantics are the pyakao modules pyakao/src/pyakao/entry5m/{bias,setup,execution}.py at HEAD; when this plan's Pine and those files disagree, the Python wins and the Pine is fixed.
  • Output budget: at most 64 plots/alertconditions. Drawing budgets: max_labels_count=500, max_lines_count=500, keep-last-N pruning (default 60).
  • TradingView work follows the recorded pipeline (memory file C:\Users\Danielshobhan\.claude\projects\D--vwap-tpo-pine\memory\tradingview-mcp-quirks.md): never call tv_launch; paste through the clipboard .ps1 + focused Monaco textarea; verify line counts; compile with Ctrl+Enter and read pine_get_errors (severity 8 only are errors); save with Ctrl+S and verify the version through pine-facade; if the bound instance is not updated in place, remove it and re-add via the editor's "Add to chart" button; indicator_set_inputs only through ui_evaluate setInputValues with the changed ids. Do not add or remove studies Daniel placed on the chart beyond what a task names; say what you added.
  • Commit trailer Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>; never stage planning/, pyakao/planning/, *.bak, .superpowers/, or export JSON (entry5m/validation/data_entry/ is git-ignored in Task 5).
  • Python tests run from D:\vwap tpo pine\pyakao with python -m pytest -q (baseline 300 passing).

File map

File Responsibility
entry5m/tools/make_entry_core.py (create) Generates the f_ztd() core from ztd_divergence.pine into the marked region of the indicator
entry5m/tools/tests/test_make_entry_core.py (create) Transform tests (region, constants, hooks, cap, idempotence)
entry5m/pine/ztd_entry.pine (create) The indicator: inputs, core (generated), HTF surface, bias tracker, orders and position, drawings, HUD, alerts, data window
entry5m/validation/compare_core_tv.py (create) HTF gate: the indicator's htf* plots on an H1 chart against ZTD v17's plots shifted one bar, and midLoB/midHiU against the pyakao port
entry5m/validation/compare_entry_tv.py (create) Chart-level parity: indicator data window vs run_entry5m on the exported chart bars
pyakao/src/pyakao/entry5m/runner.py (modify) Entry5mResult.orders log (placed orders) for the parity script
pyakao/tests/test_entry5m_runner.py (modify) test for the orders log
entry5m/validation/.gitignore (create) ignores data_entry/
USER_GUIDE_indicators.md (modify) section 13: ZTD Entry
README.md, entry5m/README.md (modify) script row / sub-project entry
memory project-state.md, indicator-suite.md (modify) outcome paragraph

Task 1: Core transform and indicator skeleton with the HTF surface, compiled and gated on H1

Files:

  • Create: entry5m/tools/make_entry_core.py, entry5m/tools/tests/test_make_entry_core.py, entry5m/pine/ztd_entry.pine, entry5m/validation/compare_core_tv.py, entry5m/validation/.gitignore
  • Read only: ztd_divergence.pine, harmonic/tools/make_hpb_core.py (the model), pyakao/validation/export_tv.py, harmonic/validation/export_tf.py, pyakao/validation/compare_ztd_tv.py

Interfaces:

  • Produces: f_ztd() returning the 12-tuple [sigBear, sigBull, contraB, contraU, midLoB, midHiU, curHi, curLo, fRefB, fRefU, ovl, ext] (all float; curHi/curLo na outside an open range; ovl 1 bear / -1 bull / 0; ext 1 bear / -1 bull / 0); f_ztdPrev() = the same shifted by one bar; chart-level series hSigBear, hSigBull, hContraB, hContraU, hMidLoB, hMidHiU, hCurHi, hCurLo, hRefB, hRefU, hOvl, hExt, hTime and bool htfNew; the input variables named in Step 3; data-window plots htfNew, htfSigBear, htfSigBull, htfOvl, htfCurHi, htfCurLo, htfMidLoB, htfMidHiU, htfContraB, htfContraU, htfExt.

  • [ ] Step 1: Write the failing transform tests (entry5m/tools/tests/test_make_entry_core.py)

"""make_entry_core.py: the ZTD v17 core wrapped as f_ztd() for the entry indicator."""
import re
import subprocess
import sys
from pathlib import Path

import pytest

ROOT = Path(__file__).resolve().parents[3]
TOOL = ROOT / "entry5m" / "tools" / "make_entry_core.py"
DST = ROOT / "entry5m" / "pine" / "ztd_entry.pine"
SRC = ROOT / "ztd_divergence.pine"
sys.path.insert(0, str(TOOL.parent))
import make_entry_core as mec  # noqa: E402


def core_text():
    text = DST.read_text(encoding="utf-8")
    a, b = text.index(mec.CORE_BEGIN), text.index(mec.CORE_END)
    return text[a:b]


def test_transform_runs_and_is_idempotent():
    before = DST.read_text(encoding="utf-8")
    subprocess.run([sys.executable, str(TOOL)], check=True)
    once = DST.read_text(encoding="utf-8")
    subprocess.run([sys.executable, str(TOOL)], check=True)
    twice = DST.read_text(encoding="utf-8")
    assert once == twice
    # everything outside the markers is untouched
    head_b, tail_b = before.split(mec.CORE_BEGIN)[0], before.split(mec.CORE_END)[1]
    head_o, tail_o = once.split(mec.CORE_BEGIN)[0], once.split(mec.CORE_END)[1]
    assert head_b == head_o and tail_b == tail_o


def test_core_is_the_ztd_region_wrapped_in_a_function():
    core = core_text()
    assert "f_ztd() =>" in core
    src = SRC.read_text(encoding="utf-8")
    region = src[src.index(mec.REGION_START): src.index(mec.REGION_END)]
    # a few state-machine lines must be present, indented by four spaces
    for line in ("bool bearIn  = ready and zOzb > zeroTol", "if bearRng and zeroAbove", "bool bullLockedNow = bullState == 2 and prevBullState != 2"):
        assert line in region and ("\n    " + line) in core
    # and nothing from the drawings section
    assert "label.new" not in core and "line.new" not in core and "plot(" not in core and "alertcondition(" not in core


def test_core_constants_are_the_v17_defaults():
    core = core_text()
    for const in ('bool   intrabar = true', 'string refMode = "Until superseded"', 'int    maxSigPerRef = 1',
                  'int    timeoutBars = 240', 'bool   cancelOpp = true', 'string zeroRead = "Close"',
                  'float  zeroTol = 0.0', 'string oObTier = "Extreme"', 'string tAnchor = "Rolling (24-bar)"',
                  'string oAnchor = "Rolling (24-bar)"', 'string tThrMode = "Percentile"', 'string oThrMode = "Percentile"',
                  'int    tCalib = 2000', 'int    oCalib = 2000', 'float  tPElev = 90.0', 'float  tPExt = 95.0',
                  'float  oPElev = 90.0', 'float  oPExt = 95.0', 'int    sigmaLen = 200', 'bool   useVolume = true',
                  'bool   tfAny = true', 'string tMinTier = armMode ? armTier : "Elevated"'):
        assert const in core, const
    assert "input." not in core


def test_core_hooks_percentile_cap_and_return_tuple():
    core = core_text()
    assert core.count("var float midLoB = na") == 1 and core.count("var float midHiU = na") == 1
    assert "midLoB := prevBearState != 2 ? low : math.min(midLoB, low)" in core
    assert "midHiU := prevBullState != 2 ? high : math.max(midHiU, high)" in core
    assert core.count("math.max(1, math.min(tCalib, tZBars))") == 2
    assert core.count("math.max(1, math.min(oCalib, oZBars))") == 2
    assert "bool bearExtNow = false" in core and "bool bullExtNow = false" in core
    ret = re.search(r"\n    \[sigBear \* 1\.0, sigBull \* 1\.0, bearContraNow \? 1\.0 : 0\.0, bullContraNow \? 1\.0 : 0\.0, midLoB, midHiU, "
                    r"bearRng \? curHi : na, bullRng \? curLo : na, fRefB, fRefU, ovlBear \? 1\.0 : ovlBull \? -1\.0 : 0\.0, "
                    r"bearExtNow \? 1\.0 : bullExtNow \? -1\.0 : 0\.0\]\n", core)
    assert ret is not None


def test_generated_file_is_ascii():
    bad = [l for l in DST.read_text(encoding="utf-8").split("\n") if any(ord(c) > 126 for c in l)]
    assert bad == []
  • [ ] Step 2: Run them to verify they fail

Run: cd "D:\vwap tpo pine" && python -m pytest entry5m/tools/tests -q Expected: FAIL (ModuleNotFoundError: No module named 'make_entry_core').

  • [ ] Step 3: Write the indicator skeleton entry5m/pine/ztd_entry.pine (everything outside the markers; the markers start empty)
//@version=6
// ZTD Entry - 5m/15m pullback entries on the H1/H4 ZTD bias.
// Spec: entry5m/docs/2026-09-06-ztd-entry-indicator-design.md. Rules = pyakao/src/pyakao/entry5m/*.py.
// The ZTD core between ENTRY_CORE_BEGIN/END is GENERATED by entry5m/tools/make_entry_core.py from
// ../../ztd_divergence.pine (v17, engines at v17 defaults). Never edit inside the markers.
indicator("ZTD Entry [5m/15m pullback on H1/H4 ZTD bias]",
     shorttitle="ZTD Entry",
     overlay=true,
     max_bars_back=500,
     max_labels_count=500,
     max_lines_count=500)

// ---------------------------------------------------------------- inputs (rule level only)
string GRP_B = "Bias (ZTD on the bias timeframe)"
string biasTf     = input.timeframe("60", "Bias timeframe", options=["60", "240"], group=GRP_B)
string biasSource = input.string("Divergence", "Bias source", options=["Divergence", "Extreme arm"], group=GRP_B,
     tooltip="Divergence: a ZTD RDIV/HDIV fire on the bias timeframe starts the bias; it ends at the zero-touch level, on contradiction, on timeout or when replaced. Extreme arm: an AVWAP-Z triangle at the tier below overlapping the PBK z beyond its extreme level starts the bias; it ends when that oscillator range ends (zero touch), on timeout or when replaced.")
string armTier    = input.string("Extreme", "Arm triangle tier (Extreme arm source)", options=["Extreme", "Elevated"], group=GRP_B)
int    timeoutH   = input.int(96, "Bias timeout (bias-timeframe bars)", minval=1, group=GRP_B)

string GRP_E = "Entry (chart timeframe: use 5m or 15m)"
int    pivotN     = input.int(3, "Pivot strength (bars each side)", minval=1, group=GRP_E)
int    minPbBars  = input.int(3, "Minimum pullback age (bars from origin to pivot)", minval=0, group=GRP_E)
int    pendMax    = input.int(48, "Pending order life (bars)", minval=1, group=GRP_E)
float  minRR      = input.float(1.0, "Minimum reward-to-risk at placement", minval=0.0, step=0.1, group=GRP_E)

string GRP_X = "Stop and target"
string stopMode   = input.string("HTF extreme", "Stop at", options=["HTF extreme", "Pullback swing"], group=GRP_X,
     tooltip="HTF extreme: one tick beyond the bias's divergence extreme (or running range extreme in arm mode); the pending order is cancelled at that level. Pullback swing: one tick beyond the 5m/15m pullback pivot; the order is cancelled when that pivot is taken out.")
string tgtMode    = input.string("Fixed R:R", "Target", options=["Fixed R:R", "Zero-touch level"], group=GRP_X,
     tooltip="Zero-touch level is only available with the Divergence source; the Extreme arm source always uses Fixed R:R.")
float  tgtRR      = input.float(3.0, "Reward-to-risk (Fixed R:R)", minval=0.1, step=0.5, group=GRP_X)

string GRP_P = "Scale-out"
bool   scaleOn    = input.bool(false, "Take a partial", group=GRP_P)
float  scaleFrac  = input.float(0.5, "Partial size (fraction of the position)", minval=0.05, maxval=0.95, step=0.05, group=GRP_P)
float  scaleR     = input.float(1.0, "Partial at R", minval=0.1, step=0.5, group=GRP_P)
bool   beAfter    = input.bool(true, "Move stop to breakeven after the partial", group=GRP_P)

string GRP_D = "Display"
bool   showBias   = input.bool(true, "Show bias levels", group=GRP_D)
bool   showOrders = input.bool(true, "Show pending orders", group=GRP_D)
bool   showPos    = input.bool(true, "Show position levels", group=GRP_D)
bool   showHud    = input.bool(true, "Show status panel", group=GRP_D)
int    keepN      = input.int(60, "Recent events kept (labels/lines)", minval=5, maxval=200, group=GRP_D)

string GRP_AL = "Alerts (alert() messages on bar close)"
bool   alBias     = input.bool(true, "Bias start / end", group=GRP_AL)
bool   alOrder    = input.bool(true, "Order placed / cancelled", group=GRP_AL)
bool   alFill     = input.bool(true, "Fill", group=GRP_AL)
bool   alPartial  = input.bool(true, "Partial", group=GRP_AL)
bool   alBe       = input.bool(true, "Stop to breakeven", group=GRP_AL)
bool   alExit     = input.bool(true, "Exit", group=GRP_AL)

// effective modes
bool armMode  = biasSource == "Extreme arm"
bool fixedTgt = tgtMode == "Fixed R:R" or armMode
bool htfStop  = stopMode == "HTF extreme"
bool scaleOk  = scaleOn and (not fixedTgt or scaleR < tgtRR)
bool chartOk  = timeframe.period == "5" or timeframe.period == "15"
float tick    = syminfo.mintick

// ENTRY_CORE_BEGIN
// ENTRY_CORE_END

// ---------------------------------------------------------------- HTF surface (closed bias-timeframe bars only)
f_ztdPrev() =>
    [a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12] = f_ztd()
    [a1[1], a2[1], a3[1], a4[1], a5[1], a6[1], a7[1], a8[1], a9[1], a10[1], a11[1], a12[1]]

[hSigBear, hSigBull, hContraB, hContraU, hMidLoB, hMidHiU, hCurHi, hCurLo, hRefB, hRefU, hOvl, hExt] = request.security(syminfo.tickerid, biasTf, f_ztdPrev(), lookahead=barmerge.lookahead_on)
int hTime = request.security(syminfo.tickerid, biasTf, time[1], lookahead=barmerge.lookahead_on)
bool htfNew = not na(hTime) and (na(hTime[1]) or hTime != hTime[1])

// ---------------------------------------------------------------- data window: HTF surface
plot(htfNew ? 1 : 0, "htfNew",     color=color.new(color.white, 100), display=display.data_window)
plot(hSigBear,      "htfSigBear",  color=color.new(color.white, 100), display=display.data_window)
plot(hSigBull,      "htfSigBull",  color=color.new(color.white, 100), display=display.data_window)
plot(hOvl,          "htfOvl",      color=color.new(color.white, 100), display=display.data_window)
plot(hCurHi,        "htfCurHi",    color=color.new(color.white, 100), display=display.data_window, precision=6)
plot(hCurLo,        "htfCurLo",    color=color.new(color.white, 100), display=display.data_window, precision=6)
plot(hMidLoB,       "htfMidLoB",   color=color.new(color.white, 100), display=display.data_window, precision=6)
plot(hMidHiU,       "htfMidHiU",   color=color.new(color.white, 100), display=display.data_window, precision=6)
plot(hContraB,      "htfContraB",  color=color.new(color.white, 100), display=display.data_window)
plot(hContraU,      "htfContraU",  color=color.new(color.white, 100), display=display.data_window)
plot(hExt,          "htfExt",      color=color.new(color.white, 100), display=display.data_window)
  • [ ] Step 4: Write the transform entry5m/tools/make_entry_core.py
# entry5m/tools/make_entry_core.py
"""Generate the f_ztd() core of entry5m/pine/ztd_entry.pine from ../ztd_divergence.pine (v17).

The region from the "shared blocks" header up to (not including) the "drawings" header of the ZTD
script is: (1) prefixed with a constants block that pins every ZTD input to its v17 default
(tMinTier follows the entry indicator's arm-tier input in arm mode); (2) given two hooks the
Pine script does not have but the pyakao port does - the zero-touch extremes midLoB/midHiU and
the numeric drawing-follow (ext) events; (3) given a percentile-window cap so the core also
computes on the ~1200-bar "240" leg a 5m chart can see (identical to v17 once 2000 bars exist);
(4) indented under `f_ztd() =>` and closed with the 12-value return tuple. The result replaces
whatever sits between ENTRY_CORE_BEGIN/END in the target; everything else in the target is kept
byte for byte, so re-running on an unchanged source is a no-op.
"""
from __future__ import annotations

import re
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[2]
SRC = ROOT / "ztd_divergence.pine"
DST = ROOT / "entry5m" / "pine" / "ztd_entry.pine"
CORE_BEGIN = "// ENTRY_CORE_BEGIN"
CORE_END = "// ENTRY_CORE_END"
REGION_START = "// ---------------------------------------------------------------- shared blocks"
REGION_END = "// ---------------------------------------------------------------- drawings (price pane + oscillator pane)"

CONSTANTS = """// ZTD v17 inputs pinned to their defaults (the entry indicator exposes rule-level inputs only)
bool   intrabar = true
bool   tfAny = true
bool   tf5 = false
bool   tf15 = false
bool   tf60 = true
bool   tf240 = true
bool   tfD = true
string refMode = "Until superseded"
int    maxSigPerRef = 1
int    timeoutBars = 240
bool   cancelOpp = true
string zeroRead = "Close"
float  zeroTol = 0.0
string tMinTier = armMode ? armTier : "Elevated"
string oObTier = "Extreme"
string tAnchor = "Rolling (24-bar)"
string tThrMode = "Percentile"
float  tZElev = 2.0
float  tZExt = 2.5
int    tCalib = 2000
float  tPElev = 90.0
float  tPExt = 95.0
string oAnchor = "Rolling (24-bar)"
string oThrMode = "Percentile"
float  oZElev = 2.0
float  oZExt = 2.5
int    oCalib = 2000
float  oPElev = 90.0
float  oPExt = 95.0
int    sigmaLen = 200
bool   useVolume = true
int    pivLen = 5
int    flipMin = 5
"""

EXT_BLOCK = """
// drawing-follow, numeric half (pyakao ztd.py): extension events after a fire
var float bearDrawnHi = na
var float bearDrawnPk = na
bool bearExtNow = false
if sigBear != 0
    bearDrawnHi := fCurB
    bearDrawnPk := fCurPkB
if bearState == 3 and bearFired != 0 and not bearContra and sigBear == 0 and not na(bearDrawnHi) and (curHi > bearDrawnHi or curPk > bearDrawnPk)
    bearExtNow := curHi > bearDrawnHi
    bearDrawnHi := curHi
    bearDrawnPk := curPk
var float bullDrawnLo = na
var float bullDrawnTr = na
bool bullExtNow = false
if sigBull != 0
    bullDrawnLo := fCurU
    bullDrawnTr := fCurPkU
if bullState == 3 and bullFired != 0 and not bullContra and sigBull == 0 and not na(bullDrawnLo) and (curLo < bullDrawnLo or curTr < bullDrawnTr)
    bullExtNow := curLo < bullDrawnLo
    bullDrawnLo := curLo
    bullDrawnTr := curTr
"""

RETURN = ("[sigBear * 1.0, sigBull * 1.0, bearContraNow ? 1.0 : 0.0, bullContraNow ? 1.0 : 0.0, midLoB, midHiU, "
          "bearRng ? curHi : na, bullRng ? curLo : na, fRefB, fRefU, ovlBear ? 1.0 : ovlBull ? -1.0 : 0.0, "
          "bearExtNow ? 1.0 : bullExtNow ? -1.0 : 0.0]")


def replace_once(text: str, old: str, new: str) -> str:
    assert text.count(old) == 1, f"anchor not unique/found: {old[:60]!r}"
    return text.replace(old, new, 1)


def build_core(src: str) -> str:
    a, b = src.index(REGION_START), src.index(REGION_END)
    region = src[a:b].rstrip("\n") + "\n"

    # hooks: zero-touch extremes (pyakao ZtdRule.mid_lo_b / mid_hi_u)
    region = replace_once(region, "int prevBearState = bearState\n",
                          "var float midLoB = na\nint prevBearState = bearState\n")
    region = replace_once(region, "bool bearLockedNow = bearState == 2 and prevBearState != 2\n",
                          "bool bearLockedNow = bearState == 2 and prevBearState != 2\n"
                          "if bearState == 2\n"
                          "    midLoB := prevBearState != 2 ? low : math.min(midLoB, low)\n"
                          "else if bearState != 3\n"
                          "    midLoB := na\n")
    region = replace_once(region, "int prevBullState = bullState\n",
                          "var float midHiU = na\nint prevBullState = bullState\n")
    region = replace_once(region, "bool bullLockedNow = bullState == 2 and prevBullState != 2\n",
                          "bool bullLockedNow = bullState == 2 and prevBullState != 2\n"
                          "if bullState == 2\n"
                          "    midHiU := prevBullState != 2 ? high : math.max(midHiU, high)\n"
                          "else if bullState != 3\n"
                          "    midHiU := na\n")

    # percentile-window cap: identical to v17 once 2000 bars exist, computes earlier on short HTF legs
    region = replace_once(region, "float tPctElev = ta.percentile_nearest_rank(tAbsZ, tCalib, tPElev)\n",
                          "var int tZBars = 0\nif not na(tAbsZ)\n    tZBars += 1\n"
                          "float tPctElev = ta.percentile_nearest_rank(tAbsZ, math.max(1, math.min(tCalib, tZBars)), tPElev)\n")
    region = replace_once(region, "float tPctExt  = ta.percentile_nearest_rank(tAbsZ, tCalib, tPExt)\n",
                          "float tPctExt  = ta.percentile_nearest_rank(tAbsZ, math.max(1, math.min(tCalib, tZBars)), tPExt)\n")
    region = replace_once(region, "float oPctElev = ta.percentile_nearest_rank(oAbsZ, oCalib, oPElev)\n",
                          "var int oZBars = 0\nif not na(oAbsZ)\n    oZBars += 1\n"
                          "float oPctElev = ta.percentile_nearest_rank(oAbsZ, math.max(1, math.min(oCalib, oZBars)), oPElev)\n")
    region = replace_once(region, "float oPctExt  = ta.percentile_nearest_rank(oAbsZ, oCalib, oPExt)\n",
                          "float oPctExt  = ta.percentile_nearest_rank(oAbsZ, math.max(1, math.min(oCalib, oZBars)), oPExt)\n")

    # the oscillator colour line is drawing-only and references color.*; drop it
    region = re.sub(r"\ncolor oCol = [^\n]*\n", "\n", region, count=1)
    assert "oCol" not in region, "oCol still referenced inside the core"

    body = region + EXT_BLOCK
    indented = "\n".join(("    " + l) if l.strip() else "" for l in body.split("\n"))
    core = CONSTANTS + "\nf_ztd() =>\n" + indented.rstrip("\n") + "\n    " + RETURN + "\n"
    for forbidden in ("label.", "line.", "plot(", "alertcondition(", "table.", "input."):
        assert forbidden not in core, f"{forbidden} leaked into the core"
    return core


def main() -> int:
    src = SRC.read_text(encoding="utf-8")
    core = build_core(src)
    dst = DST.read_text(encoding="utf-8")
    assert dst.count(CORE_BEGIN) == 1 and dst.count(CORE_END) == 1, "markers missing in the target"
    a = dst.index(CORE_BEGIN) + len(CORE_BEGIN)
    b = dst.index(CORE_END)
    out = dst[:a] + "\n" + core + dst[b:]
    DST.write_text(out, encoding="utf-8", newline="\n")
    bad = [l for l in out.split("\n") if any(ord(c) > 126 for c in l)]
    print(f"wrote {DST.relative_to(ROOT)}: {out.count(chr(10))} lines, core {core.count(chr(10))} lines, non-ascii lines {len(bad)}")
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())

Also create entry5m/tools/tests/__init__.py (empty) and entry5m/validation/.gitignore containing data_entry/.

  • [ ] Step 5: Run the transform and the tests

Run: cd "D:\vwap tpo pine" && python entry5m/tools/make_entry_core.py && python -m pytest entry5m/tools/tests -q Expected: the transform prints the line counts with non-ascii lines 0; 5 tests pass. If replace_once fails on an anchor, the ZTD source line differs from this plan: open ztd_divergence.pine, find the equivalent line, and fix the anchor string in the transform (do not edit the ZTD file).

  • [ ] Step 6: Compile on TradingView and fix Pine errors

Paste entry5m/pine/ztd_entry.pine into a new indicator script named ZTD Entry through the recorded pipeline (clipboard .ps1 from a scratch copy, focused Monaco textarea, Ctrl+A/V, verify the line count matches the file, Ctrl+Enter, pine_get_errors). Expected errors to handle, all inside the generated core and fixed IN THE TRANSFORM (then re-run it and re-paste):

  • a var declared in the region colliding with a name used by the skeleton: rename in the skeleton, never in the region;

  • timeframe.change("D") etc. inside a function called by request.security: allowed in v6; if the compiler objects, replace them in CONSTANTS-style by bool chgD = timeframe.change("D") hoisted to script level in the transform (they only matter for calendar anchors, unused at the defaults);

  • ta.pivothigh(pivLen, pivLen) inside the function: allowed; keep. Save the script (Ctrl+S) and add it to the current chart. Record the pine-facade version and the entity id in the report.

  • [ ] Step 7: HTF gate on an H1 chart (entry5m/validation/compare_core_tv.py)

Set the chart to EURUSD 60 with both ZTD v17 (Daniel's instance, or a fresh instance at defaults if none is present, and say so) and ZTD Entry visible. Export with python harmonic/validation/export_tf.py --tf 60 --out entry5m/validation/data_entry --match "Zero-Touch,ZTD Entry". Then:

"""HTF gate: on an H1 chart the entry indicator's htf* plots must equal ZTD v17's plots shifted by
one bar (the indicator reads the previous, closed HTF bar), and htfMidLoB/htfMidHiU must equal the
pyakao port's midLoB/midHiU on the same H1 bars (v17 does not plot them).

Usage: python compare_core_tv.py --data entry5m/validation/data_entry --symbol EURUSD --tf 60
"""
from __future__ import annotations
import argparse, json, math, sys
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "pyakao" / "validation"))
sys.path.insert(0, str(ROOT / "pyakao" / "src"))
from compare_ztd_tv import find_study, column, _i  # noqa: E402
from pyakao.data import Bars  # noqa: E402
from pyakao.ztd import ZtdConfig, run_ztd  # noqa: E402

PAIRS = [("htfSigBear", "sigBear"), ("htfSigBull", "sigBull"), ("htfOvl", "ovl"), ("htfExt", "ext")]
FLOAT_PAIRS = [("htfCurHi", "curHi"), ("htfCurLo", "curLo")]
FIRST = 2100   # after the 2000-bar percentile window is full on both sides


def isna(x):
    return x is None or (isinstance(x, float) and math.isnan(x))


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--data", default=str(ROOT / "entry5m" / "validation" / "data_entry"))
    ap.add_argument("--symbol", default="EURUSD")
    ap.add_argument("--tf", default="60")
    ap.add_argument("--arm-tier", default="Elevated", help="tMinTier the ZTD instance on the chart uses")
    a = ap.parse_args()
    data = Path(a.data)
    studies = json.loads((data / f"{a.symbol}_{a.tf}_studies.json").read_text())
    bars = json.loads((data / f"{a.symbol}_{a.tf}.json").read_text())
    ztd, ent = find_study(studies, "Zero-Touch"), find_study(studies, "ZTD Entry")
    T = [int(r[0]) for r in bars]
    bad = 0
    for e_name, z_name in PAIRS:
        ec, zc = column(ent, e_name, exact=True), column(ztd, z_name, exact=True)
        for i in range(FIRST, len(T)):
            if _i(ec.get(T[i], 0)) != _i(zc.get(T[i - 1], 0)):
                bad += 1
                if bad <= 20:
                    print(f"MISMATCH {e_name} at {T[i]} ({datetime.fromtimestamp(T[i], tz=timezone.utc)}): entry {ec.get(T[i])} vs ztd[prev] {zc.get(T[i - 1])}")
    for e_name, z_name in FLOAT_PAIRS:
        ec, zc = column(ent, e_name, exact=True), column(ztd, z_name, exact=True)
        for i in range(FIRST, len(T)):
            x, y = ec.get(T[i]), zc.get(T[i - 1])
            if isna(x) != isna(y) or (not isna(x) and abs(x - y) > 1e-6):
                bad += 1
                if bad <= 20:
                    print(f"MISMATCH {e_name} at {T[i]}: entry {x} vs ztd[prev] {y}")
    # midLoB / midHiU against the port, run on the exported bars, same shift
    b = Bars(times=[datetime.fromtimestamp(t, tz=timezone.utc) for t in T], opens=[r[1] for r in bars],
             highs=[r[2] for r in bars], lows=[r[3] for r in bars], closes=[r[4] for r in bars], volumes=[r[5] for r in bars])
    rows = run_ztd(b, ZtdConfig(t_min_tier=a.arm_tier))
    for e_name, attr in (("htfMidLoB", "midLoB"), ("htfMidHiU", "midHiU")):
        ec = column(ent, e_name, exact=True)
        for i in range(FIRST, len(T)):
            x, y = ec.get(T[i]), getattr(rows[i - 1], attr)
            if isna(x) != isna(y) or (not isna(x) and abs(x - y) > 1e-6):
                bad += 1
                if bad <= 20:
                    print(f"MISMATCH {e_name} at {T[i]}: entry {x} vs port[prev] {y}")
    print(f"bars {len(T)} compared from {FIRST}: mismatches {bad}")
    print("RESULT:", "PASS" if bad == 0 else "FAIL")
    return 0 if bad == 0 else 1


if __name__ == "__main__":
    sys.exit(main())

Run it. Expected: RESULT: PASS. The ovl pair only agrees when the ZTD instance's tMinTier equals the indicator's effective tier: in Divergence mode that is Elevated (ZTD default) — leave both at defaults for this gate. A mismatch in curHi/curLo only means the shift or the range-plot form is wrong in the core; a mismatch in sigBear from the first compared bar means the constants differ from the chart instance's inputs — check the instance with data_get_indicator. Note that the port's midLoB may lag the Pine by nothing; if it mismatches only in the first 2000 bars, the FIRST constant is too small for this export (raise it and say so).

  • [ ] Step 8: Commit
cd "D:\vwap tpo pine" && git add entry5m/tools/make_entry_core.py entry5m/tools/tests/__init__.py entry5m/tools/tests/test_make_entry_core.py entry5m/pine/ztd_entry.pine entry5m/validation/compare_core_tv.py entry5m/validation/.gitignore
git commit -m "feat(entry-pine): ZTD core transform, indicator skeleton with the closed-HTF surface, H1 gate" -m "Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"

Task 2: Bias tracker on the chart, target-touch end, bias data window

Files:

  • Modify: entry5m/pine/ztd_entry.pine (append after the HTF-surface plots; keep the generated region untouched)

Interfaces:

  • Consumes: hSigBear, hSigBull, hContraB, hContraU, hMidLoB, hMidHiU, hCurHi, hCurLo, hOvl, hExt, htfNew, armMode, timeoutH (Task 1).

  • Produces (chart-level var state, read by Tasks 3-4): biasSide (0/-1/+1), biasKind (0 none, 1 RDIV, 2 HDIV, 3 ARM), biasStartBar, biasExt, biasTarget, biasDeadline, biasId, htfCount; per-bar events biasStartNow (bool), biasEndNow (bool), biasEndCode (0 none, 1 target, 2 contradiction, 3 timeout, 4 replaced, 5 range_end), endedSide, endedId; and the position/order state declarations Task 3 fills: pendActive, pendSide, pendEntry, pendStop, pendTarget, pendCancel, pendPlacedBar, pendPbBar, pendOriginBar, posActive, posSide, posFill, posFillBar, posStop, posQty, posPartialPx, posPartialBar, posTarget, posRisk, lastPivot. Also the per-bar flags Task 3 sets and Task 4 consumes: orderPlacedNow, orderCancelCode (0 none, 1 extreme_taken, 2 expired, 3 bias_end, 4 replaced), fillNow, partialNow, beNow, exitCode (0 none, 1 stop, 2 target, 3 bias_end, 4 breakeven), exitPx, tradeR.

  • [ ] Step 1: Append the state declarations and the bias tracker

// ---------------------------------------------------------------- chart-level state (declared here; filled by the sections below)
// bias (port of pyakao entry5m/bias.py)
var int   biasSide     = 0
var int   biasKind     = 0        // 1 RDIV, 2 HDIV, 3 ARM
var int   biasStartBar = na
var float biasExt      = na       // HTF extreme: the stop reference in HTF-extreme mode
var float biasTarget   = na       // zero-touch level (divergence mode), na in arm mode
var int   biasDeadline = na       // in HTF bar count
var int   biasId       = 0
var int   htfCount     = -1       // index k of the last consumed HTF bar
// pending order (port of setup.py PendingOrder)
var bool  pendActive   = false
var int   pendSide     = 0
var float pendEntry    = na
var float pendStop     = na
var float pendTarget   = na
var float pendCancel   = na
var int   pendPlacedBar = na
var int   pendPbBar    = na
var int   pendOriginBar = na
var int   lastPivot    = -1       // previous confirmed same-kind pivot bar since the bias started
// simulated position (port of execution.py Position)
var bool  posActive    = false
var int   posSide      = 0
var float posFill      = na
var int   posFillBar   = na
var float posStop      = na       // effective stop (order stop, or the fill after the partial)
var float posQty       = 1.0
var float posPartialPx = na
var int   posPartialBar = na
var float posTarget    = na
var float posRisk      = na
var float posEntryLvl  = na       // the order's entry level (partial level is measured from it)
// per-bar events
bool  biasStartNow   = false
bool  biasEndNow     = false
int   biasEndCode    = 0
int   endedSide      = 0
int   endedId        = 0
bool  orderPlacedNow = false
int   orderCancelCode = 0
bool  fillNow        = false
bool  partialNow     = false
bool  beNow          = false
int   exitCode       = 0
float exitPx         = na
float tradeR         = na
int   tooShortNow    = 0
int   rrRejectedNow  = 0
int   alreadyBrokenNow = 0
int   skippedInPosNow = 0

// ---------------------------------------------------------------- step 2: HTF events (bias.py on_h1_close / _on_h1_arm)
// NOTE: step 1 (position management) is inserted ABOVE this block by Task 3; the file order then matches runner.py.
if htfNew
    htfCount += 1
    int k = htfCount
    if biasSide != 0
        if not armMode
            bool contra = biasSide < 0 ? hContraB > 0.5 : hContraU > 0.5
            if contra
                biasEndCode := 2
            else
                if (biasSide < 0 and hExt > 0.5) or (biasSide > 0 and hExt < -0.5)
                    biasExt := biasSide < 0 ? hCurHi : hCurLo
                    biasDeadline := k + timeoutH
                if k > biasDeadline
                    biasEndCode := 3
        else
            float cur = biasSide < 0 ? hCurHi : hCurLo
            if na(cur)
                biasEndCode := 5
            else
                if (biasSide < 0 and cur > biasExt) or (biasSide > 0 and cur < biasExt)
                    biasExt := cur
                if k > biasDeadline
                    biasEndCode := 3
    int   trigSide = 0
    int   trigKind = 0
    float trigExt  = na
    float trigTgt  = na
    if not armMode
        float sg = hSigBear != 0 ? hSigBear : hSigBull
        if sg != 0
            trigSide := hSigBear != 0 ? -1 : 1
            trigKind := math.abs(sg) > 1.5 ? 1 : 2
            trigExt  := trigSide < 0 ? hCurHi : hCurLo
            trigTgt  := trigSide < 0 ? hMidLoB : hMidHiU
            if na(trigTgt)
                trigSide := 0                       // fire without a zero-touch level: ignored
    else if hOvl != 0
        trigSide := hOvl > 0 ? -1 : 1
        trigKind := 3
        trigExt  := trigSide < 0 ? hCurHi : hCurLo
        if na(trigExt) or (biasSide == trigSide and biasEndCode == 0)
            trigSide := 0                           // range ended on its trigger bar, or same-side re-trigger
    if biasEndCode == 0 and trigSide != 0 and biasSide != 0
        biasEndCode := 4                            // replaced
    if biasEndCode != 0
        biasEndNow := true
        endedSide  := biasSide
        endedId    := biasId
        biasSide   := 0
        biasKind   := 0
    if trigSide != 0
        biasId       += 1
        biasSide     := trigSide
        biasKind     := trigKind
        biasStartBar := bar_index
        biasExt      := trigExt
        biasTarget   := trigTgt
        biasDeadline := k + timeoutH
        biasStartNow := true
        lastPivot    := -1

// ---------------------------------------------------------------- step 3: target touch ends a divergence bias (runner.py)
if biasSide != 0 and not armMode and ((biasSide < 0 and low <= biasTarget) or (biasSide > 0 and high >= biasTarget))
    biasEndNow  := true
    biasEndCode := 1
    endedSide   := biasSide
    endedId     := biasId
    biasSide    := 0
    biasKind    := 0

// ---------------------------------------------------------------- data window: bias
plot(biasSide,                       "biasSide",      color=color.new(color.white, 100), display=display.data_window)
plot(biasKind,                       "biasKind",      color=color.new(color.white, 100), display=display.data_window)
plot(biasSide != 0 ? biasStartBar : na, "biasStartBar", color=color.new(color.white, 100), display=display.data_window)
plot(biasSide != 0 ? biasExt : na,   "biasExt",       color=color.new(color.white, 100), display=display.data_window, precision=6)
plot(biasSide != 0 ? biasTarget : na, "biasTarget",   color=color.new(color.white, 100), display=display.data_window, precision=6)
plot(biasEndNow ? biasEndCode : 0,   "biasEndCode",   color=color.new(color.white, 100), display=display.data_window)
plot(biasStartNow ? 1 : 0,           "biasStartNow",  color=color.new(color.white, 100), display=display.data_window)
plot(bar_index,                      "barIndex",      color=color.new(color.white, 100), display=display.data_window)

Semantics to keep straight (all from bias.py): a same-side arm re-trigger is ignored only while the bias survives this bar's end checks (hence biasEndCode == 0 in the guard); an opposite fire while a bias is active ends it with code 4 before the new start; the tracker's deadline renewal on a same-side ext moves biasExt to hCurHi/hCurLo (equal to fCurB/fCurU on fire bars in the port).

  • [ ] Step 2: Compile, save, sanity-read

Paste the whole file, compile (zero severity-8 errors), save a new version, make sure the chart instance updated (else remove and re-add). On a 5m EURUSD chart read the data window over the last ~2000 bars with data_get_study_values at a few bars and confirm biasSide becomes non-zero after H1 fires (compare a biasStartNow == 1 bar against the ZTD H1 chart: the fire is on the H1 bar that closed just before that 5m bar opens). Record two examples (times, side, kind, ext, target) in the report.

  • [ ] Step 3: Commit
cd "D:\vwap tpo pine" && git add entry5m/pine/ztd_entry.pine
git commit -m "feat(entry-pine): chart-level bias tracker (divergence and arm), target-touch end, bias data window" -m "Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"

Task 3: Pullbacks, orders and the simulated position (steps 1 and 4)

Files:

  • Modify: entry5m/pine/ztd_entry.pine (insert step 1 ABOVE the // step 2 block; append the bias-end cleanup, the scanner and the order/position data window after step 3)

Interfaces:

  • Consumes: Task 2 state and per-bar flags; inputs pivotN, minPbBars, pendMax, minRR, htfStop, fixedTgt, tgtRR, scaleOk, scaleFrac, scaleR, beAfter, tick.

  • Produces: the pending/position state and the per-bar flags listed in Task 2's Interfaces, plus arrays hiArr, loArr (one float per chart bar since bar 0), and data-window plots pendEntry, pendStop, pendTarget, pendCancel, pendPlacedBar, posQty, posFill, posFillBar, posStop, partialPx, exitCode, exitPx, tradeR, orderCancelCode, fillNow, tooShort, rrRejected, alreadyBroken, skippedInPos.

  • [ ] Step 1: Insert step 1 (position management and fills) ABOVE the // step 2 block

// ---------------------------------------------------------------- step 1: open position, then the pending order (execution.py Broker.on_bar; no slippage)
// helper: one bar of an open position: stop -> partial -> target. Returns [exitCode, exitPx, partialNow, beNow]
f_manage(int side, float fill, float stopEff, float qty, float entryLvl, float risk, float target, bool canScale, float frac, float pr, bool be, float hi, float lo) =>
    int   code = 0
    float px = na
    bool  part = false
    bool  beMove = false
    float newStop = stopEff
    float newQty = qty
    float partPx = na
    bool short = side < 0
    if (short ? hi >= stopEff : lo <= stopEff)
        code := qty < 1.0 and stopEff == fill ? 4 : 1
        px := stopEff
    else
        if canScale and qty >= 1.0
            float lvl = short ? entryLvl - pr * risk : entryLvl + pr * risk
            if (short ? lo <= lvl : hi >= lvl)
                newQty := 1.0 - frac
                partPx := lvl
                part := true
                if be
                    newStop := fill
                    beMove := true
        if (short ? lo <= target : hi >= target)
            code := 2
            px := target
    [code, px, part, beMove, newStop, newQty, partPx]

f_tradeR(int side, float fill, float px, float risk, float qty, float partPx) =>
    float r = (px - fill) * side / risk
    qty < 1.0 ? (1.0 - qty) * (partPx - fill) * side / risk + qty * r : r

if posActive
    [c1, p1, part1, be1, ns1, nq1, pp1] = f_manage(posSide, posFill, posStop, posQty, posEntryLvl, posRisk, posTarget, scaleOk, scaleFrac, scaleR, beAfter, high, low)
    if part1
        partialNow := true
        posPartialPx := pp1
        posPartialBar := bar_index
        posQty := nq1
    if be1
        beNow := true
        posStop := ns1
    if c1 != 0
        exitCode := c1
        exitPx := p1
        tradeR := f_tradeR(posSide, posFill, p1, posRisk, posQty, posPartialPx)
        posActive := false
if pendActive and not posActive
    bool touched = pendSide < 0 ? low <= pendEntry : high >= pendEntry
    if touched
        float fillPx = pendSide < 0 ? math.min(open, pendEntry) : math.max(open, pendEntry)
        posActive := true
        posSide := pendSide
        posFill := fillPx
        posFillBar := bar_index
        posStop := pendStop
        posQty := 1.0
        posPartialPx := na
        posPartialBar := na
        posTarget := pendTarget
        posRisk := math.abs(pendStop - pendEntry)
        posEntryLvl := pendEntry
        pendActive := false
        fillNow := true
        [c2, p2, part2, be2, ns2, nq2, pp2] = f_manage(posSide, posFill, posStop, posQty, posEntryLvl, posRisk, posTarget, scaleOk, scaleFrac, scaleR, beAfter, high, low)
        if part2
            partialNow := true
            posPartialPx := pp2
            posPartialBar := bar_index
            posQty := nq2
        if be2
            beNow := true
            posStop := ns2
        if c2 != 0
            exitCode := c2
            exitPx := p2
            tradeR := f_tradeR(posSide, posFill, p2, posRisk, posQty, posPartialPx)
            posActive := false
    else
        bool taken = pendSide < 0 ? high > pendCancel : low < pendCancel
        if taken
            pendActive := false
            orderCancelCode := 1
        else if bar_index - pendPlacedBar >= pendMax
            pendActive := false
            orderCancelCode := 2

f_manage is a pure function (no globals modified) returning what the caller writes back, which is how Pine allows shared logic between the open-position path and the fill path. The breakeven stop set on the partial bar is applied from the next bar (the stop was checked first in this call), exactly as execution.py.

  • [ ] Step 2: Append, after the step-3 block, the bias-end cleanup and step 4 (scanner, order pricing, placement)
// ---------------------------------------------------------------- bias end cleanup (runner.py bias_ended): close at the close, cancel, reset the boundary
if biasEndNow
    if posActive
        exitCode := 3
        exitPx := close
        tradeR := f_tradeR(posSide, posFill, close, posRisk, posQty, posPartialPx)
        posActive := false
    if pendActive
        pendActive := false
        orderCancelCode := 3
    lastPivot := -1

// ---------------------------------------------------------------- step 4: pivots, pullbacks, order pricing (setup.py PullbackScanner)
var float[] hiArr = array.new_float()
var float[] loArr = array.new_float()
array.push(hiArr, high)
array.push(loArr, low)
float ph = ta.pivothigh(high, pivotN, pivotN)
float pl = ta.pivotlow(low, pivotN, pivotN)
if biasSide != 0
    float piv = biasSide < 0 ? ph : pl
    if not na(piv)
        int p = bar_index - pivotN
        if p >= biasStartBar
            int q = lastPivot >= 0 ? lastPivot + 1 : biasStartBar
            lastPivot := p
            int origin = q
            float originPx = biasSide < 0 ? array.get(loArr, q) : array.get(hiArr, q)
            if p > q
                for j = q + 1 to p
                    float v = biasSide < 0 ? array.get(loArr, j) : array.get(hiArr, j)
                    if (biasSide < 0 and v < originPx) or (biasSide > 0 and v > originPx)
                        originPx := v
                        origin := j
            if p - origin < minPbBars
                tooShortNow := 1
            else if posActive
                skippedInPosNow := 1
            else
                float entry = biasSide < 0 ? originPx - tick : originPx + tick
                float stopRef = htfStop ? biasExt : piv
                float stopPx = biasSide < 0 ? stopRef + tick : stopRef - tick
                float risk = math.abs(stopPx - entry)
                float target = fixedTgt ? (biasSide < 0 ? entry - tgtRR * risk : entry + tgtRR * risk) : biasTarget
                float reward = biasSide < 0 ? entry - target : target - entry
                bool broken = false
                for j = p + 1 to bar_index
                    float v = biasSide < 0 ? array.get(loArr, j) : array.get(hiArr, j)
                    if (biasSide < 0 and v <= entry) or (biasSide > 0 and v >= entry)
                        broken := true
                if na(stopRef) or risk <= 0 or reward / risk < minRR
                    rrRejectedNow := 1
                else if broken
                    alreadyBrokenNow := 1
                else
                    if pendActive
                        orderCancelCode := 4                // replaced by the newer pullback
                    pendActive := true
                    pendSide := biasSide
                    pendEntry := entry
                    pendStop := stopPx
                    pendTarget := target
                    pendCancel := stopRef
                    pendPlacedBar := bar_index
                    pendPbBar := p
                    pendOriginBar := origin
                    orderPlacedNow := true

// ---------------------------------------------------------------- data window: orders and position
plot(pendActive ? pendEntry : na,   "pendEntry",     color=color.new(color.white, 100), display=display.data_window, precision=6)
plot(pendActive ? pendStop : na,    "pendStop",      color=color.new(color.white, 100), display=display.data_window, precision=6)
plot(pendActive ? pendTarget : na,  "pendTarget",    color=color.new(color.white, 100), display=display.data_window, precision=6)
plot(pendActive ? pendCancel : na,  "pendCancel",    color=color.new(color.white, 100), display=display.data_window, precision=6)
plot(pendActive ? pendPlacedBar : na, "pendPlacedBar", color=color.new(color.white, 100), display=display.data_window)
plot(orderPlacedNow ? 1 : 0,        "orderPlaced",   color=color.new(color.white, 100), display=display.data_window)
plot(orderCancelCode,               "orderCancelCode", color=color.new(color.white, 100), display=display.data_window)
plot(posActive ? posQty : 0,        "posQty",        color=color.new(color.white, 100), display=display.data_window)
plot(posActive ? posFill : na,      "posFill",       color=color.new(color.white, 100), display=display.data_window, precision=6)
plot(posActive ? posFillBar : na,   "posFillBar",    color=color.new(color.white, 100), display=display.data_window)
plot(posActive ? posStop : na,      "posStop",       color=color.new(color.white, 100), display=display.data_window, precision=6)
plot(posActive ? posPartialPx : na, "partialPx",     color=color.new(color.white, 100), display=display.data_window, precision=6)
plot(fillNow ? 1 : 0,               "fillNow",       color=color.new(color.white, 100), display=display.data_window)
plot(partialNow ? 1 : 0,            "partialNow",    color=color.new(color.white, 100), display=display.data_window)
plot(exitCode,                      "exitCode",      color=color.new(color.white, 100), display=display.data_window)
plot(exitPx,                        "exitPx",        color=color.new(color.white, 100), display=display.data_window, precision=6)
plot(tradeR,                        "tradeR",        color=color.new(color.white, 100), display=display.data_window, precision=4)
plot(tooShortNow,                   "tooShort",      color=color.new(color.white, 100), display=display.data_window)
plot(rrRejectedNow,                 "rrRejected",    color=color.new(color.white, 100), display=display.data_window)
plot(alreadyBrokenNow,              "alreadyBroken", color=color.new(color.white, 100), display=display.data_window)
plot(skippedInPosNow,               "skippedInPos",  color=color.new(color.white, 100), display=display.data_window)

Order-pricing notes from setup.py: the origin search ties go to the earliest bar (strict </> in the loop keeps the first minimum); pendCancel is the pullback extreme in pullback-stop mode and biasExt in HTF-extreme mode; the "already broken" window is (p, c] = p + 1 .. bar_index; a na stopRef counts as rrRejected like the harness. The origin loop is guarded by if p > q because Pine reverses a to loop whose end is below its start; the already-broken loop p + 1 .. bar_index always has pivotN >= 1 elements and needs no guard.

Output count so far: 11 (HTF) + 8 (bias) + 21 (orders/position) = 40 plots. Task 4 adds one alertcondition.

  • [ ] Step 3: Compile, save, sanity-read

Compile clean, save, ensure the chart instance updated. On the 5m EURUSD chart with the defaults (Divergence, HTF extreme, Fixed R:R 3, scale-out off) read the data window around a biasStartNow bar and confirm the sequence: a pivot high confirmed pivotN bars after the pullback top, orderPlaced == 1 on that bar with pendEntry one tick below the origin low and pendStop one tick above biasExt, then fillNow on the first bar whose low reaches pendEntry. Record one full sequence (bar times and levels) in the report. Then flip Take a partial on through setInputValues and confirm a partialNow bar followed by posStop == posFill.

  • [ ] Step 4: Commit
cd "D:\vwap tpo pine" && git add entry5m/pine/ztd_entry.pine
git commit -m "feat(entry-pine): pullback scanner, order pricing, simulated position with scale-out and breakeven" -m "Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"

Task 4: Drawings, HUD, alerts

Files:

  • Modify: entry5m/pine/ztd_entry.pine (append after the orders/position data window)

Interfaces:

  • Consumes: every state variable and per-bar flag from Tasks 2-3; inputs showBias, showOrders, showPos, showHud, keepN, alBias, alOrder, alFill, alPartial, alBe, alExit, chartOk, biasTf, biasSource, stopMode, tgtMode, tgtRR, scaleOk, scaleFrac, scaleR, beAfter.

  • Produces: nothing consumed later; one alertcondition "ZTD Entry: any event".

  • [ ] Step 1: Append drawings

// ---------------------------------------------------------------- drawings (budgeted: keep the last keepN of each kind)
var label[] evLbls = array.new_label()
var line[]  evLns  = array.new_line()
keepLbl(label l) =>
    array.push(evLbls, l)
    while array.size(evLbls) > keepN
        label.delete(array.shift(evLbls))
keepLn(line l) =>
    array.push(evLns, l)
    while array.size(evLns) > math.min(keepN * 3, 480)
        line.delete(array.shift(evLns))

color colBear = color.new(color.red, 0)
color colBull = color.new(color.green, 0)
colSide(int s) => s < 0 ? colBear : colBull
sideTxt(int s) => s < 0 ? "SHORT" : "LONG"
kindTxt(int k) => k == 1 ? "RDIV" : k == 2 ? "HDIV" : k == 3 ? "ARM" : ""
endTxt(int c) => c == 1 ? "target" : c == 2 ? "contradiction" : c == 3 ? "timeout" : c == 4 ? "replaced" : c == 5 ? "range end" : ""
exitTxt(int c) => c == 1 ? "stop" : c == 2 ? "target" : c == 3 ? "bias end" : c == 4 ? "breakeven" : ""
cancelTxt(int c) => c == 1 ? "extreme taken" : c == 2 ? "expired" : c == 3 ? "bias end" : c == 4 ? "replaced" : ""
px(float v) => str.tostring(v, format.mintick)

// bias: one line at the HTF extreme (stop reference) and, in divergence mode, one at the zero-touch target; both extend while the bias lives
var line biasLnExt = na
var line biasLnTgt = na
if showBias
    if biasStartNow
        biasLnExt := line.new(bar_index, biasExt, bar_index + 1, biasExt, color=colSide(biasSide), style=line.style_dashed, width=2)
        keepLn(biasLnExt)
        if not na(biasTarget)
            biasLnTgt := line.new(bar_index, biasTarget, bar_index + 1, biasTarget, color=color.new(colSide(biasSide), 40), style=line.style_dotted, width=1)
            keepLn(biasLnTgt)
        keepLbl(label.new(bar_index, biasExt, "ZTD bias " + sideTxt(biasSide) + " " + kindTxt(biasKind) + "\nstop ref " + px(biasExt) + (na(biasTarget) ? "" : "\nzero-touch " + px(biasTarget)),
             style=biasSide < 0 ? label.style_label_down : label.style_label_up, color=color.new(colSide(biasSide), 20), textcolor=color.white, size=size.small))
    if biasSide != 0 and not na(biasLnExt)
        line.set_xy2(biasLnExt, bar_index, biasExt)
        line.set_y1(biasLnExt, biasExt)
        if not na(biasLnTgt)
            line.set_x2(biasLnTgt, bar_index)
    if biasEndNow and not na(biasLnExt)
        keepLbl(label.new(bar_index, endedSide < 0 ? high : low, "bias end: " + endTxt(biasEndCode), style=endedSide < 0 ? label.style_label_down : label.style_label_up,
             color=color.new(color.gray, 30), textcolor=color.white, size=size.tiny))

// pending order: entry (dotted), stop and target (thin), from the placement bar while pending
var line pendLnE = na
var line pendLnS = na
var line pendLnT = na
if showOrders
    if orderPlacedNow
        pendLnE := line.new(bar_index, pendEntry, bar_index + 1, pendEntry, color=colSide(pendSide), style=line.style_dotted, width=2)
        pendLnS := line.new(bar_index, pendStop, bar_index + 1, pendStop, color=color.new(color.orange, 30), style=line.style_dotted, width=1)
        pendLnT := line.new(bar_index, pendTarget, bar_index + 1, pendTarget, color=color.new(color.teal, 30), style=line.style_dotted, width=1)
        keepLn(pendLnE)
        keepLn(pendLnS)
        keepLn(pendLnT)
        keepLbl(label.new(bar_index, pendEntry, (pendSide < 0 ? "sell stop " : "buy stop ") + px(pendEntry) + "\nstop " + px(pendStop) + "  tgt " + px(pendTarget),
             style=pendSide < 0 ? label.style_label_up : label.style_label_down, color=color.new(colSide(pendSide), 50), textcolor=color.white, size=size.tiny))
    if pendActive and not na(pendLnE)
        line.set_x2(pendLnE, bar_index)
        line.set_x2(pendLnS, bar_index)
        line.set_x2(pendLnT, bar_index)
    if orderCancelCode != 0 and not fillNow
        keepLbl(label.new(bar_index, close, "order cancelled: " + cancelTxt(orderCancelCode), style=label.style_label_left, color=color.new(color.gray, 50), textcolor=color.white, size=size.tiny))

// position: entry (solid), stop (moves to breakeven), partial, target
var line posLnE = na
var line posLnS = na
var line posLnT = na
var line posLnP = na
if showPos
    if fillNow
        posLnE := line.new(bar_index, posFill, bar_index + 1, posFill, color=colSide(posSide), width=2)
        posLnS := line.new(bar_index, posStop, bar_index + 1, posStop, color=color.orange, width=1)
        posLnT := line.new(bar_index, posTarget, bar_index + 1, posTarget, color=color.teal, width=1)
        keepLn(posLnE)
        keepLn(posLnS)
        keepLn(posLnT)
        if scaleOk
            float lvl = posSide < 0 ? posEntryLvl - scaleR * posRisk : posEntryLvl + scaleR * posRisk
            posLnP := line.new(bar_index, lvl, bar_index + 1, lvl, color=color.new(color.teal, 50), style=line.style_dashed, width=1)
            keepLn(posLnP)
        keepLbl(label.new(bar_index, posFill, "filled " + px(posFill), style=posSide < 0 ? label.style_label_up : label.style_label_down, color=color.new(colSide(posSide), 0), textcolor=color.white, size=size.tiny))
    if posActive and not na(posLnE)
        line.set_x2(posLnE, bar_index)
        line.set_xy2(posLnS, bar_index, posStop)
        line.set_x2(posLnT, bar_index)
        if not na(posLnP)
            line.set_x2(posLnP, bar_index)
    if partialNow
        keepLbl(label.new(bar_index, posPartialPx, "partial " + px(posPartialPx) + (beNow ? "\nstop -> BE " + px(posStop) : ""), style=label.style_label_left, color=color.new(color.teal, 20), textcolor=color.white, size=size.tiny))
    if exitCode != 0
        keepLbl(label.new(bar_index, exitPx, "exit " + exitTxt(exitCode) + " " + px(exitPx) + "\nR " + str.tostring(tradeR, "#.##"),
             style=label.style_label_left, color=color.new(tradeR > 0 ? color.green : color.red, 20), textcolor=color.white, size=size.tiny))
  • [ ] Step 2: Append the HUD
// ---------------------------------------------------------------- HUD
var table hud = table.new(position.top_right, 2, 8, border_width=1)
if showHud and (barstate.islast or barstate.islastconfirmedhistory)
    string warn = chartOk ? "" : "  (chart is not 5m/15m)"
    table.cell(hud, 0, 0, "ZTD Entry", text_color=color.white, bgcolor=color.new(color.blue, 20), text_size=size.small)
    table.cell(hud, 1, 0, biasTf + "m " + biasSource + warn, text_color=color.white, bgcolor=color.new(color.blue, 20), text_size=size.small)
    table.cell(hud, 0, 1, "bias", text_color=color.white, bgcolor=color.new(color.gray, 60), text_size=size.small)
    table.cell(hud, 1, 1, biasSide == 0 ? "none" : sideTxt(biasSide) + " " + kindTxt(biasKind) + "  " + str.tostring(bar_index - biasStartBar) + " bars",
         text_color=color.white, bgcolor=biasSide == 0 ? color.new(color.gray, 60) : color.new(colSide(biasSide), 40), text_size=size.small)
    table.cell(hud, 0, 2, "stop ref / zero-touch", text_color=color.white, bgcolor=color.new(color.gray, 60), text_size=size.small)
    table.cell(hud, 1, 2, biasSide == 0 ? "-" : px(biasExt) + (na(biasTarget) ? "" : " / " + px(biasTarget)), text_color=color.white, bgcolor=color.new(color.gray, 60), text_size=size.small)
    table.cell(hud, 0, 3, "pending", text_color=color.white, bgcolor=color.new(color.gray, 60), text_size=size.small)
    table.cell(hud, 1, 3, pendActive ? (pendSide < 0 ? "sell stop " : "buy stop ") + px(pendEntry) + "  stop " + px(pendStop) + "  tgt " + px(pendTarget) : "none",
         text_color=color.white, bgcolor=color.new(color.gray, 60), text_size=size.small)
    table.cell(hud, 0, 4, "position", text_color=color.white, bgcolor=color.new(color.gray, 60), text_size=size.small)
    float runR = posActive ? f_tradeR(posSide, posFill, close, posRisk, posQty, posPartialPx) : na
    table.cell(hud, 1, 4, posActive ? sideTxt(posSide) + " " + str.tostring(posQty, "#.##") + " @ " + px(posFill) + "  stop " + px(posStop) + "  R " + str.tostring(runR, "#.##") : "flat",
         text_color=color.white, bgcolor=posActive ? color.new(colSide(posSide), 40) : color.new(color.gray, 60), text_size=size.small)
    table.cell(hud, 0, 5, "rules", text_color=color.white, bgcolor=color.new(color.gray, 60), text_size=size.small)
    table.cell(hud, 1, 5, stopMode + "  " + (fixedTgt ? "R:R " + str.tostring(tgtRR, "#.#") : "zero-touch") + (scaleOk ? "  partial " + str.tostring(scaleFrac, "#.##") + " @ " + str.tostring(scaleR, "#.#") + "R" + (beAfter ? " BE" : "") : ""),
         text_color=color.white, bgcolor=color.new(color.gray, 60), text_size=size.small)
  • [ ] Step 3: Append alerts
// ---------------------------------------------------------------- alerts (alert() with prices, once per bar close) + one classic alertcondition
string tag = "ZTD ENTRY " + syminfo.ticker + " " + timeframe.period + "m: "
bool anyEvent = false
if barstate.isconfirmed
    if alBias and biasEndNow
        alert(tag + "bias end " + endTxt(biasEndCode) + " (" + sideTxt(endedSide) + ")", alert.freq_once_per_bar_close)
        anyEvent := true
    if alBias and biasStartNow
        alert(tag + "bias start " + sideTxt(biasSide) + " " + kindTxt(biasKind) + " stop-ref " + px(biasExt) + (na(biasTarget) ? "" : " zero-touch " + px(biasTarget)), alert.freq_once_per_bar_close)
        anyEvent := true
    if alOrder and orderCancelCode != 0 and not fillNow
        alert(tag + "order cancelled " + cancelTxt(orderCancelCode), alert.freq_once_per_bar_close)
        anyEvent := true
    if alOrder and orderPlacedNow
        alert(tag + "order placed " + (pendSide < 0 ? "SELL STOP " : "BUY STOP ") + px(pendEntry) + " stop " + px(pendStop) + " target " + px(pendTarget) + " cancel-at " + px(pendCancel), alert.freq_once_per_bar_close)
        anyEvent := true
    if alFill and fillNow
        alert(tag + "filled " + sideTxt(posSide) + " " + px(posFill) + " stop " + px(posStop) + " target " + px(posTarget), alert.freq_once_per_bar_close)
        anyEvent := true
    if alPartial and partialNow
        alert(tag + "partial " + str.tostring(scaleFrac, "#.##") + " at " + px(posPartialPx), alert.freq_once_per_bar_close)
        anyEvent := true
    if alBe and beNow
        alert(tag + "stop to breakeven " + px(posStop), alert.freq_once_per_bar_close)
        anyEvent := true
    if alExit and exitCode != 0
        alert(tag + "exit " + exitTxt(exitCode) + " " + px(exitPx) + " R " + str.tostring(tradeR, "#.##"), alert.freq_once_per_bar_close)
        anyEvent := true
alertcondition(biasStartNow or biasEndNow or orderPlacedNow or orderCancelCode != 0 or fillNow or partialNow or beNow or exitCode != 0, "ZTD Entry: any event", "ZTD Entry event on {{ticker}} {{interval}}")

Note alert() must be called with a dynamic message inside a condition, which is what the block does; alertcondition needs a constant message.

  • [ ] Step 4: Compile, save, look

Compile clean (the label/line helpers must be declared before use; Pine forbids modifying globals inside functions, which is why every state write sits at top level), save, update the chart instance. Take capture_screenshot on a stretch with a bias, an order and a fill, and read the HUD with data_get_pine_tables. Confirm no drawing budget error over the loaded 5m history (status().type === 2). Record the screenshot path and the HUD text in the report.

  • [ ] Step 5: Commit
cd "D:\vwap tpo pine" && git add entry5m/pine/ztd_entry.pine
git commit -m "feat(entry-pine): bias/order/position drawings, HUD, alert() messages" -m "Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"

Task 5: Chart-level parity against pyakao and replay spot checks

Files:

  • Modify: pyakao/src/pyakao/entry5m/runner.py (orders log), pyakao/tests/test_entry5m_runner.py
  • Create: entry5m/validation/compare_entry_tv.py

Interfaces:

  • Consumes: the indicator's data window (Tasks 1-3 plot titles), run_entry5m, Entry5mConfig, Bars, the export tooling.

  • Produces: Entry5mResult.orders: list[PendingOrder] (every placed order, in placement order); the parity script with RESULT: PASS|FAIL.

  • [ ] Step 1: Orders log in the runner (failing test first) — append to pyakao/tests/test_entry5m_runner.py:

def test_runner_logs_every_placed_order():
    res = run_entry5m(bars_from(SCENARIO), scenario_cfg(), engine=Scripted(FIRE))
    assert len(res.orders) == res.counters["orders_placed"] == 1
    od = res.orders[0]
    assert (od.side, od.placed_bar, od.pb_bar, od.origin_bar) == (-1, 59, 56, 52)
    assert od.entry == pytest.approx(94.99) and od.stop == pytest.approx(98.01) and od.target == 90.0

Run it (expect AttributeError: 'Entry5mResult' object has no attribute 'orders'), then in runner.py add orders: list = field(default_factory=list) to Entry5mResult and res.orders.append(order) right after broker.place(order) in the placement branch. Run the whole suite (expect 301).

  • [ ] Step 2: Export the 5m chart with the indicator (defaults: Divergence, HTF extreme, Fixed R:R 3, no partial)

python harmonic/validation/export_tf.py --tf 5 --out entry5m/validation/data_entry --match "ZTD Entry" on EURUSD. Then a second export after setting the instance to Take a partial = on via setInputValues (find the in_N ids with data_get_indicator), written with --out entry5m/validation/data_entry_partial.

  • [ ] Step 3: Write the parity script entry5m/validation/compare_entry_tv.py
"""Chart-level parity: the ZTD Entry indicator's data window on the exported 5m/15m bars against
pyakao's run_entry5m on the SAME bars (slippage 0, matching inputs).

Compared, by chart bar time: bias starts (side, kind, ext, target), bias ends (code), orders
(placed bar, entry, stop, target, cancel), fills (bar, price), partials (bar, price), exits
(bar, code, price, R). Pass = zero mismatches on bias and orders; fill/exit mismatches are listed
with the bars involved so each can be traced to an HTF-alignment difference (spec section 8).

Usage: python compare_entry_tv.py --data entry5m/validation/data_entry --symbol EURUSD --tf 5 [--stop-mode h1_extreme] [--target-mode fixed_r] [--target-r 3] [--bias-source divergence] [--scale-out-r 0]
"""
from __future__ import annotations
import argparse, json, math, sys
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "pyakao" / "validation"))
sys.path.insert(0, str(ROOT / "pyakao" / "src"))
from compare_ztd_tv import find_study, column, _i  # noqa: E402
from pyakao.data import Bars  # noqa: E402
from pyakao.entry5m import Entry5mConfig, run_entry5m  # noqa: E402

KIND = {"RDIV": 1, "HDIV": 2, "ARM": 3}
END = {"target": 1, "contradiction": 2, "timeout": 3, "replaced": 4, "range_end": 5}
EXIT = {"stop": 1, "target": 2, "bias_end": 3, "breakeven": 4}


def isna(x):
    return x is None or (isinstance(x, float) and math.isnan(x))


def close(a, b, tol=1e-6):
    return (isna(a) and isna(b)) or (not isna(a) and not isna(b) and abs(a - b) <= tol)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--data", default=str(ROOT / "entry5m" / "validation" / "data_entry"))
    ap.add_argument("--symbol", default="EURUSD")
    ap.add_argument("--tf", default="5")
    ap.add_argument("--stop-mode", default="h1_extreme")
    ap.add_argument("--target-mode", default="fixed_r")
    ap.add_argument("--target-r", type=float, default=3.0)
    ap.add_argument("--bias-source", default="divergence")
    ap.add_argument("--scale-out-r", type=float, default=0.0)
    ap.add_argument("--first", type=int, default=0, help="first chart bar to compare (after the HTF warm-up)")
    a = ap.parse_args()
    data = Path(a.data)
    studies = json.loads((data / f"{a.symbol}_{a.tf}_studies.json").read_text())
    raw = json.loads((data / f"{a.symbol}_{a.tf}.json").read_text())
    ent = find_study(studies, "ZTD Entry")
    T = [int(r[0]) for r in raw]
    bars = Bars(times=[datetime.fromtimestamp(t, tz=timezone.utc) for t in T], opens=[r[1] for r in raw],
                highs=[r[2] for r in raw], lows=[r[3] for r in raw], closes=[r[4] for r in raw], volumes=[r[5] for r in raw])
    cfg = Entry5mConfig(stop_mode=a.stop_mode, target_mode=a.target_mode, target_r=a.target_r, bias_source=a.bias_source,
                        scale_out_r=a.scale_out_r, slippage_ticks=0)
    res = run_entry5m(bars, cfg, symbol=a.symbol)      # slippage_ticks=0 is kept (it differs from the default)
    col = {k: column(ent, k, exact=True) for k in ("biasStartNow", "biasSide", "biasKind", "biasExt", "biasTarget", "biasEndCode",
                                                   "orderPlaced", "pendEntry", "pendStop", "pendTarget", "pendCancel",
                                                   "fillNow", "posFill", "partialNow", "partialPx", "exitCode", "exitPx", "tradeR")}
    bad = []
    first = max(a.first, 0)
    # bias starts
    py_starts = {b.start_5m: b for b in res.biases if b.start_5m >= first}
    tv_starts = {i for i in range(first, len(T)) if _i(col["biasStartNow"].get(T[i], 0)) == 1}
    for i in sorted(set(py_starts) | tv_starts):
        b = py_starts.get(i)
        if b is None or i not in tv_starts:
            bad.append(f"bias start only in {'pyakao' if b else 'TV'} at bar {i} {datetime.fromtimestamp(T[i], tz=timezone.utc)}")
            continue
        side, kind = _i(col["biasSide"].get(T[i], 0)), _i(col["biasKind"].get(T[i], 0))
        if side != b.side or kind != KIND[b.kind] or not close(col["biasExt"].get(T[i]), b.ext) or not close(col["biasTarget"].get(T[i]), b.target):
            bad.append(f"bias start fields differ at bar {i}: TV side {side} kind {kind} ext {col['biasExt'].get(T[i])} tgt {col['biasTarget'].get(T[i])} vs pyakao {b.side} {b.kind} {b.ext} {b.target}")
    # bias ends
    py_ends = {b.end_5m: END[b.end_reason] for b in res.biases if b.end_5m >= first}
    for i in range(first, len(T)):
        c = _i(col["biasEndCode"].get(T[i], 0))
        if c != py_ends.get(i, 0):
            bad.append(f"bias end differs at bar {i}: TV code {c} vs pyakao {py_ends.get(i, 0)}")
    # orders
    py_orders = {o.placed_bar: o for o in res.orders if o.placed_bar >= first}
    for i in range(first, len(T)):
        placed = _i(col["orderPlaced"].get(T[i], 0)) == 1
        o = py_orders.get(i)
        if placed != (o is not None):
            bad.append(f"order placement differs at bar {i}: TV {placed} vs pyakao {o is not None}")
        elif o is not None and not all(close(col[k].get(T[i]), v) for k, v in (("pendEntry", o.entry), ("pendStop", o.stop), ("pendTarget", o.target), ("pendCancel", o.pb_extreme))):
            bad.append(f"order levels differ at bar {i}: TV {[col[k].get(T[i]) for k in ('pendEntry', 'pendStop', 'pendTarget', 'pendCancel')]} vs pyakao {(o.entry, o.stop, o.target, o.pb_extreme)}")
    n_bias_order = len(bad)
    # fills / partials / exits (listed, not required)
    py_fills = {t.entry_bar: t for t in res.trades if t.entry_bar >= first}
    for i in range(first, len(T)):
        f = _i(col["fillNow"].get(T[i], 0)) == 1
        t = py_fills.get(i)
        if f != (t is not None) or (t is not None and not close(col["posFill"].get(T[i]), t.entry)):
            bad.append(f"fill differs at bar {i}: TV {f} {col['posFill'].get(T[i])} vs pyakao {t is not None} {getattr(t, 'entry', None)}")
    py_exits = {t.exit_bar: t for t in res.trades if t.exit_bar >= first}
    for i in range(first, len(T)):
        c = _i(col["exitCode"].get(T[i], 0))
        t = py_exits.get(i)
        want = EXIT[t.reason] if t else 0
        if c != want or (t is not None and (not close(col["exitPx"].get(T[i]), t.exit) or not close(col["tradeR"].get(T[i]), t.r, 1e-3))):
            bad.append(f"exit differs at bar {i}: TV code {c} px {col['exitPx'].get(T[i])} R {col['tradeR'].get(T[i])} vs pyakao {t.reason if t else None} {getattr(t, 'exit', None)} {getattr(t, 'r', None)}")
    for line in bad[:60]:
        print("MISMATCH", line)
    print(f"bars {len(T)} from {first}; pyakao biases {len(res.biases)} orders {len(res.orders)} trades {len(res.trades)}; "
          f"mismatches: bias/order {n_bias_order}, fills/exits {len(bad) - n_bias_order}")
    print("RESULT:", "PASS" if n_bias_order == 0 else "FAIL")
    return 0 if n_bias_order == 0 else 1


if __name__ == "__main__":
    sys.exit(main())
  • [ ] Step 4: Run the parity on both exports

python entry5m/validation/compare_entry_tv.py --data entry5m/validation/data_entry --symbol EURUSD --tf 5 --first <bar> (the pyakao mintick for EURUSD is 0.00001, the same as syminfo.mintick on the FXCM feed; if the chart's feed differs, pass the chart's tick by editing Entry5mConfig(mintick=...) in the script and say so) where <bar> is the first chart bar after the H1 leg has 2100 closed bars (about 2100 x 12 = 25,200 5m bars — if the export holds fewer, use the first bar at which the indicator's htfSigBear/htfSigBull history exists at all and say so; the pyakao run needs ~2000 H1 bars of warm-up too, so both sides are blind on the same stretch). Then the partial export with --scale-out-r 1.0. Expected: RESULT: PASS on bias/orders. Every fill/exit mismatch line is traced in the report to its cause (a 5m bar whose HTF bucket TV assigns differently from build_htf, or a gap-open fill) — if a mismatch has no such cause, it is a bug in the Pine port or the plan: fix the Pine (the Python is the reference), re-export, re-run.

  • [ ] Step 5: Replay spot checks

With replay_start at three dates from the parity export (one divergence bias start, one arm bias start after switching the instance to Extreme arm, one partial + breakeven with the partial on), step bar by bar and confirm the indicator's data window at the event bars equals the export (same-bar, non-repainting), reading replay_status as the cursor truth and accepting a value only when two reads agree. replay_stop afterwards; restore the instance inputs to the defaults.

  • [ ] Step 6: Commit
cd "D:\vwap tpo pine" && git add pyakao/src/pyakao/entry5m/runner.py pyakao/tests/test_entry5m_runner.py entry5m/validation/compare_entry_tv.py
git commit -m "validation(entry-pine): orders log in the runner, chart-level parity script, replay checks" -m "Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"

Task 6: Docs and memory

Files:

  • Modify: USER_GUIDE_indicators.md (new section 13 "ZTD Entry"), README.md (script table row), entry5m/README.md (Pine section), spec Status: line

  • Memory: C:\Users\Danielshobhan\.claude\projects\D--vwap-tpo-pine\memory\indicator-suite.md (one paragraph), project-state.md (status line)

  • [ ] Step 1: Guide section 13 — what it is (one paragraph), how the H1 bias reaches the 5m chart and why it never repaints, the inputs table from spec section 3 with the input ids as reported by data_get_indicator, how to read the drawings and HUD, the alert messages (one line each with the exact prefix), the data-window titles, the verification results (H1 gate mismatches, chart parity counts, replay checks) with dates and the pine-facade version, and the known limits from spec section 9.

  • [ ] Step 2: README rows — root README.md script table: entry5m/pine/ztd_entry.pine "ZTD Entry" row with the one-line purpose and the version; entry5m/README.md: a "Pine indicator" section pointing to the spec, the plan, the transform command (python entry5m/tools/make_entry_core.py), the parity commands, and the verification numbers. Spec Status: becomes built <date>, ZTD Entry v<N>; verified: H1 gate PASS, chart parity <counts>.

  • [ ] Step 3: Memoryindicator-suite.md: a paragraph "ZTD Entry (2026-09-0x)" naming the script, the security-wrapped core idiom, the input ids, the verification status, and the entity id on Daniel's chart; project-state.md: replace the "next" sentence with "ZTD Entry indicator built and verified; the entry rules remain research-dead (six pre-registered fails), the indicator is the forward-observation tool".

  • [ ] Step 4: Commit

cd "D:\vwap tpo pine" && git add USER_GUIDE_indicators.md README.md entry5m/README.md entry5m/docs/2026-09-06-ztd-entry-indicator-design.md
git commit -m "docs(entry-pine): guide section 13, README rows, spec status" -m "Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"

Self-review

Spec coverage. Section 2 architecture: Task 1 (transform, f_ztd, f_ztdPrev, security with lookahead_on, htfNew, constants incl. the tMinTier rule). Section 3 inputs: Task 1 skeleton (every row of the table, chart warning in Task 4's HUD). Section 4 bias tracker: Task 2 (divergence and arm branches, target-touch end, replace, deadline renewal). Section 5 pullbacks/orders: Task 3 (pivots, origin search with earliest-tie, age, pricing per stop/target mode, already-broken, RR floor, replace, cancel at the cancel level, expiry, no order while in position). Section 6 simulated position: Task 3 (f_manage: stop, partial, breakeven from the next bar, target; fill at entry or gap open; no slippage; size-weighted R). Section 7 drawings/HUD/alerts/data window: Tasks 2-4 (all listed titles present: HTF surface 11, bias 8, orders/position 21; alert() messages per event; one alertcondition). Section 8 verification: Task 1 step 7 (H1 gate, midLoB/midHiU against the port), Task 5 (chart parity with the orders log, partial export, replay checks, pipeline per the quirks memory). Section 9 limits: documented in Task 6.

Placeholder scan. No TBD/TODO. Every Pine block is complete; the two conditional instructions (Task 1 step 6 compile fallbacks, Task 5 step 4 warm-up bar) say exactly what to do.

Type consistency. Plot titles used by compare_core_tv.py (htfSigBear, htfSigBull, htfOvl, htfExt, htfCurHi, htfCurLo, htfMidLoB, htfMidHiU) match Task 1's plots; titles used by compare_entry_tv.py (biasStartNow, biasSide, biasKind, biasExt, biasTarget, biasEndCode, orderPlaced, pendEntry, pendStop, pendTarget, pendCancel, fillNow, posFill, partialNow, partialPx, exitCode, exitPx, tradeR) match Tasks 2-3; codes (biasEndCode 1-5, exitCode 1-4, orderCancelCode 1-4, biasKind 1-3) match the Python reason strings through the END/EXIT/KIND maps; f_manage's 7-tuple is destructured identically at both call sites; Entry5mResult.orders holds PendingOrder whose pb_extreme is the cancel level, as pendCancel.