@hoox-sh/pynets 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -86,6 +86,19 @@ export const compileArray = {
86
86
  get(id: unknown, index?: unknown): unknown {
87
87
  return asArray(id)?.get(n(index, 0)) ?? null;
88
88
  },
89
+ /**
90
+ * Python `visit_Subscript` list path: `arr[0]` is the LAST slot
91
+ * (`value[-(offset + 1)]`, newest-first like series offsets). `na` /
92
+ * negative / OOB offsets soft-fail to `na`.
93
+ */
94
+ sub(id: unknown, offset?: unknown): unknown {
95
+ const a = asArray(id);
96
+ const off = cell(offset);
97
+ if (a == null || off == null || off < 0) return null;
98
+ const size = a.size();
99
+ const i = size - 1 - Math.trunc(off);
100
+ return i < 0 || i >= size ? null : a.get(i);
101
+ },
89
102
  set(id: unknown, index?: unknown, value?: unknown): unknown {
90
103
  asArray(id)?.set(n(index, 0), store(value));
91
104
  return id ?? null;
@@ -35,7 +35,7 @@ import type {
35
35
  UnaryOp,
36
36
  While,
37
37
  } from "../../ast/nodes.ts";
38
- import { emitCall, TA_BARE_ATTRS } from "./emit_call.ts";
38
+ import { emitCall, isArrayReturningCall, TA_BARE_ATTRS } from "./emit_call.ts";
39
39
  import { emitFunctionDef } from "./emit_udf.ts";
40
40
  import type { EmitCtx, VisitFn } from "./types.ts";
41
41
 
@@ -134,6 +134,7 @@ interface State {
134
134
  export function newEmitCtx(): EmitCtx {
135
135
  return {
136
136
  arrays: new Set(),
137
+ arrayNames: new Set(),
137
138
  plots: [],
138
139
  varNames: new Set(),
139
140
  functions: [],
@@ -980,6 +981,11 @@ function emitAttribute(state: State, node: Attribute): string {
980
981
  function emitSubscript(state: State, node: Subscript): string {
981
982
  const slice = node.slice != null ? emitExpr(state, node.slice) : "0";
982
983
  const arr = histArray(state, node.value);
984
+ // Array-valued names index the current PineArray (Python list subscript:
985
+ // newest-first, `value[-(offset + 1)]`), not series history.
986
+ if (node.value.kind === "Name" && state.ctx.arrayNames.has((node.value as Name).id)) {
987
+ return `__h.array.sub(${arr}[__bar_idx], ${slice})`;
988
+ }
983
989
  return `__h.hist(${arr}, __bar_idx, ${slice})`;
984
990
  }
985
991
 
@@ -1082,13 +1088,25 @@ function emitImport(state: State, node: Import): string {
1082
1088
  function emitAssign(state: State, node: Assign): string {
1083
1089
  const rhs = node.value != null ? emitExpr(state, node.value) : "null";
1084
1090
  const isVar = isVarMode(node.mode);
1091
+ trackArrayName(state, node.target, node.value);
1085
1092
  return emitStore(state, node.target, rhs, isVar);
1086
1093
  }
1087
1094
 
1088
1095
  function emitReAssign(state: State, node: ReAssign): string {
1096
+ trackArrayName(state, node.target, node.value);
1089
1097
  return emitStore(state, node.target, emitExpr(state, node.value), false);
1090
1098
  }
1091
1099
 
1100
+ /** Names bound to PineArray results get list-subscript lowering (`g[0]`). */
1101
+ function trackArrayName(state: State, target: expr, value: expr | null | undefined): void {
1102
+ if (target.kind !== "Name") return;
1103
+ if (value != null && value.kind === "Call" && isArrayReturningCall((value as Call).func)) {
1104
+ state.ctx.arrayNames.add(target.id);
1105
+ } else {
1106
+ state.ctx.arrayNames.delete(target.id);
1107
+ }
1108
+ }
1109
+
1092
1110
  function emitAugAssign(state: State, node: AugAssign): string {
1093
1111
  // Attribute targets store the raw rhs — the old field is discarded
1094
1112
  // (quirk preserved from Python visit_AugAssign and interpret evalAugAssign).
@@ -560,6 +560,21 @@ function unwrapSpecialize(node: expr): expr {
560
560
  return node;
561
561
  }
562
562
 
563
+ /**
564
+ * Calls whose result binds a PineArray value, so a later `name[i]` subscript
565
+ * lowers to `__h.array.sub` (Python `visit_Subscript` list path) instead of
566
+ * series history. Python `mode="compile"` does not support list subscripts
567
+ * (returns na); PyneTS keeps compile aligned with interpret instead.
568
+ */
569
+ export function isArrayReturningCall(func: expr): boolean {
570
+ const { name } = resolveCallee(func);
571
+ if (name === "ta_gamma_levels" || name === "gamma_levels") return true;
572
+ if (name === "array_from" || name === "array_copy" || name === "array_concat" || name === "array_slice") {
573
+ return true;
574
+ }
575
+ return name.startsWith("array_new");
576
+ }
577
+
563
578
  function collectArgs(node: Call, visit: VisitFn): CallArgs {
564
579
  const pos: string[] = [];
565
580
  const kw: Record<string, string> = {};
@@ -51,6 +51,8 @@ export class CompileIneligibleError extends CompileError {
51
51
  /** Visitor state while lowering a Script to JS. */
52
52
  export interface EmitCtx {
53
53
  arrays: Set<string>;
54
+ /** Script-scope names bound to PineArray values (list subscript `g[0]`). */
55
+ arrayNames: Set<string>;
54
56
  plots: CompilePlotMeta[];
55
57
  varNames: Set<string>;
56
58
  functions: string[];
@@ -220,6 +220,12 @@ export interface RuntimeOptions {
220
220
  timeout_seconds?: number | null;
221
221
  /** Visit the last bar as a forming realtime bar (`barstate.isrealtime`). */
222
222
  realtimeLastBar?: boolean;
223
+ /** Visits per realtime bar (Python `realtime_ticks`; >1 implies a last-bar window). */
224
+ realtimeTicks?: number;
225
+ /** Size of the trailing realtime window (Python `realtime_bars`). */
226
+ realtimeBars?: number;
227
+ /** First bar index of the realtime window (Python `realtime_from_bar`). */
228
+ realtimeFromBar?: number | null;
223
229
  }
224
230
 
225
231
  export interface RuntimeFill {
@@ -364,8 +370,18 @@ interface Env {
364
370
  maxBarsBackDecls: Array<{ var: Value; num: Value }>;
365
371
  /** Python `_alert_fire_bars` keys for `alert` / `alertcondition` freq gating. */
366
372
  alertFireBars: Map<string, number>;
367
- /** Visit the last bar as a forming realtime bar (`barstate.isrealtime`). */
368
- realtimeLastBar: boolean;
373
+ /** First bar of the realtime window; null = purely historical run. */
374
+ realtimeFirst: number | null;
375
+ /** Visits per realtime bar (Python `realtime_ticks`). */
376
+ realtimeTicks: number;
377
+ /** True when the current bar is inside the realtime window. */
378
+ barRealtime: boolean;
379
+ /** Current tick index within the bar (0 = first visit). */
380
+ tickIndex: number;
381
+ /** Total visits for the current bar. */
382
+ tickCount: number;
383
+ /** `varip`-declared names — persist across realtime ticks (reference varip). */
384
+ varipNames: Set<string>;
369
385
  }
370
386
 
371
387
  export class Runtime {
@@ -417,6 +433,12 @@ export class Runtime {
417
433
  const extraOpts = extra && !isPlainInputs(extra) ? extra : undefined;
418
434
  const mode = extraOpts?.mode ?? this.mode;
419
435
  const inputs = mergeInputs(this.inputs, extra);
436
+ const rt = resolveRealtimeWindow(ohlcv.length, {
437
+ realtimeLastBar: extraOpts?.realtimeLastBar,
438
+ realtimeTicks: extraOpts?.realtimeTicks,
439
+ realtimeBars: extraOpts?.realtimeBars,
440
+ realtimeFromBar: extraOpts?.realtimeFromBar,
441
+ });
420
442
  const host = {
421
443
  symbol: this.symbol,
422
444
  inputs,
@@ -424,7 +446,8 @@ export class Runtime {
424
446
  timeframe: extraOpts?.timeframe ?? this.timeframe,
425
447
  libraries: extraOpts?.libraries != null ? resolveLibraries(extraOpts.libraries) : this.libraries,
426
448
  timeout_seconds: extraOpts?.timeout_seconds ?? this.timeout_seconds,
427
- realtime_last_bar: extraOpts?.realtimeLastBar ?? false,
449
+ realtime_first: rt.first,
450
+ realtime_ticks: rt.ticks,
428
451
  };
429
452
  if (mode === "compile") {
430
453
  return runCompiled(source, ohlcv, host);
@@ -549,6 +572,126 @@ export function interpret(source: string, ohlcv: OHLCVBar[]): { plots: Array<num
549
572
  return { plots: out.plots };
550
573
  }
551
574
 
575
+ /** Python `_snapshot_realtime_scope` analog — one intermediate realtime tick. */
576
+ interface TickSnapshot {
577
+ ctx: Record<string, Value>;
578
+ containers: Array<[string, unknown]>;
579
+ varInited: Set<string>;
580
+ plotsLens: Map<string, number>;
581
+ plotMetaLen: number;
582
+ ta: Array<[string, Map<string, unknown>]>;
583
+ book: Record<string, unknown>;
584
+ udfSites: Array<[string, Record<string, Value>, Set<string>, Map<string, number>]>;
585
+ }
586
+
587
+ function isSnapshotContainer(v: unknown): boolean {
588
+ return (
589
+ v !== null &&
590
+ typeof v === "object" &&
591
+ typeof (v as { snapshotContents?: unknown }).snapshotContents === "function"
592
+ );
593
+ }
594
+
595
+ function isRestoreContainer(v: unknown): v is { restoreContents(snap: unknown): void } {
596
+ return (
597
+ v !== null &&
598
+ typeof v === "object" &&
599
+ typeof (v as { restoreContents?: unknown }).restoreContents === "function"
600
+ );
601
+ }
602
+
603
+ /** Undo state for one intermediate realtime tick (Python `_run_batch` tick loop). */
604
+ function snapshotTickState(env: Env): TickSnapshot {
605
+ const containers: Array<[string, unknown]> = [];
606
+ for (const [id, v] of Object.entries(env.ctx)) {
607
+ if (isSnapshotContainer(v)) {
608
+ containers.push([id, (v as { snapshotContents(): unknown }).snapshotContents()]);
609
+ }
610
+ }
611
+ const plotsLens = new Map<string, number>();
612
+ for (const [key, arr] of Object.entries(env.plots)) plotsLens.set(key, arr.length);
613
+ const udfSites: Array<[string, Record<string, Value>, Set<string>, Map<string, number>]> = [];
614
+ for (const [key, f] of env.udfSites) {
615
+ const seriesLens = new Map<string, number>();
616
+ for (const [name, s] of f.series) seriesLens.set(name, s.length);
617
+ udfSites.push([key, { ...f.ctx }, new Set(f.varInited), seriesLens]);
618
+ }
619
+ return {
620
+ ctx: { ...env.ctx },
621
+ containers,
622
+ varInited: new Set(env.varInited),
623
+ plotsLens,
624
+ plotMetaLen: env.plot_meta.length,
625
+ ta: env.ta.snapshot(),
626
+ book: env.book.snapshot(),
627
+ udfSites,
628
+ };
629
+ }
630
+
631
+ /**
632
+ * Roll back one intermediate realtime tick: var scope (varip persists, Python
633
+ * `_restore_realtime_scope`), container contents in place, UDF-site var state,
634
+ * plot cells appended this tick (Python `_discard_realtime_plot_tick`), TA
635
+ * windows (Python `_restore_ta_state`), and broker state. History pushes are
636
+ * intentionally left alone — the start-of-bar commit already ran.
637
+ */
638
+ function restoreTickState(env: Env, snap: TickSnapshot): void {
639
+ const varips = env.varipNames;
640
+ for (const key of Object.keys(env.ctx)) {
641
+ if (!(key in snap.ctx) && !varips.has(key)) delete env.ctx[key];
642
+ }
643
+ for (const [key, value] of Object.entries(snap.ctx)) {
644
+ if (varips.has(key)) continue;
645
+ env.ctx[key] = value;
646
+ }
647
+ for (const [id, contents] of snap.containers) {
648
+ if (varips.has(id)) continue;
649
+ const cur = env.ctx[id];
650
+ if (isRestoreContainer(cur)) {
651
+ (cur as unknown as { restoreContents(snap: unknown): void }).restoreContents(contents);
652
+ }
653
+ }
654
+ const restoredInited = new Set(snap.varInited);
655
+ for (const id of varips) {
656
+ if (env.varInited.has(id)) restoredInited.add(id);
657
+ }
658
+ env.varInited = restoredInited;
659
+ for (const key of Object.keys(env.plots)) {
660
+ const len = snap.plotsLens.get(key);
661
+ if (len === undefined) delete env.plots[key];
662
+ else if (env.plots[key]!.length > len) env.plots[key]!.length = len;
663
+ }
664
+ env.plot_meta.length = snap.plotMetaLen;
665
+ env.ta.restore(snap.ta);
666
+ env.book.restore(snap.book);
667
+ const siteKeys = new Set(snap.udfSites.map(([key]) => key));
668
+ for (const key of [...env.udfSites.keys()]) {
669
+ if (!siteKeys.has(key)) env.udfSites.delete(key);
670
+ }
671
+ for (const [key, ctx, varInited, seriesLens] of snap.udfSites) {
672
+ const f = env.udfSites.get(key);
673
+ if (f == null) continue;
674
+ for (const k of Object.keys(f.ctx)) {
675
+ if (!(k in ctx) && !varips.has(k)) delete f.ctx[k];
676
+ }
677
+ for (const [k, v] of Object.entries(ctx)) {
678
+ if (varips.has(k)) continue;
679
+ f.ctx[k] = v;
680
+ }
681
+ // Roll back per-tick series pushes so the next tick's carry reads the
682
+ // pre-tick current (Python leaves history alone; frames re-carry per call).
683
+ for (const [name, len] of seriesLens) {
684
+ const s = f.series.get(name);
685
+ if (s != null && s.length > len) s.truncate(len);
686
+ }
687
+ const restored = new Set(varInited);
688
+ for (const id of varips) {
689
+ if (f.varInited.has(id)) restored.add(id);
690
+ }
691
+ f.varInited = restored;
692
+ }
693
+ }
694
+
552
695
  export function interpretTree(
553
696
  tree: AST,
554
697
  ohlcv: OHLCVBar[],
@@ -559,7 +702,8 @@ export function interpretTree(
559
702
  timeframe?: string | null;
560
703
  libraries?: LibraryRegistry;
561
704
  timeout_seconds?: number | null;
562
- realtime_last_bar?: boolean;
705
+ realtime_first?: number | null;
706
+ realtime_ticks?: number;
563
707
  },
564
708
  ): RuntimeResult {
565
709
  const scriptNode = tree.kind === "Script" ? (tree as Script) : null;
@@ -598,7 +742,12 @@ export function interpretTree(
598
742
  onceFired: new WeakSet(),
599
743
  maxBarsBackDecls: [],
600
744
  alertFireBars: new Map(),
601
- realtimeLastBar: host?.realtime_last_bar ?? false,
745
+ realtimeFirst: host?.realtime_first ?? null,
746
+ realtimeTicks: Math.max(1, host?.realtime_ticks ?? 1),
747
+ barRealtime: false,
748
+ tickIndex: 0,
749
+ tickCount: 1,
750
+ varipNames: new Set(),
602
751
  };
603
752
 
604
753
  const n = ohlcv.length;
@@ -608,8 +757,9 @@ export function interpretTree(
608
757
  const timeoutSec = host?.timeout_seconds;
609
758
  const deadline =
610
759
  timeoutSec != null && Number.isFinite(timeoutSec) ? performance.now() + timeoutSec * 1000 : null;
760
+ let aborted = false;
611
761
  try {
612
- for (let i = 0; i < n; i++) {
762
+ for (let i = 0; i < n && !aborted; i++) {
613
763
  if (deadline != null && (i & 31) === 0 && performance.now() > deadline) {
614
764
  timedOut = true;
615
765
  barsDone = i;
@@ -629,31 +779,44 @@ export function interpretTree(
629
779
  num(env.ctx.close) ?? 0,
630
780
  );
631
781
 
632
- try {
633
- for (const stmt of body) {
634
- const decl = captureDecl(stmt);
635
- if (decl) {
636
- if (script_name == null) script_name = decl.name;
637
- if (decl.kind === "strategy") {
638
- script_type = "strategy";
639
- applyStrategyDecl(stmt, env);
640
- } else if (decl.kind === "library") {
641
- script_type = "library";
642
- applyDrawingDecl(stmt, env);
643
- } else {
644
- applyDrawingDecl(stmt, env);
782
+ // Python `_run_batch`: bars in the realtime window are visited once per
783
+ // tick; intermediate ticks roll back var scope, TA windows, the broker,
784
+ // and plot cells (varip persists).
785
+ const barRt = env.realtimeFirst != null && i >= env.realtimeFirst;
786
+ const ticks = barRt ? env.realtimeTicks : 1;
787
+ env.barRealtime = barRt;
788
+ env.tickCount = ticks;
789
+ for (let tick = 0; tick < ticks; tick++) {
790
+ env.tickIndex = tick;
791
+ const snap = barRt && tick < ticks - 1 ? snapshotTickState(env) : null;
792
+ try {
793
+ for (const stmt of body) {
794
+ const decl = captureDecl(stmt);
795
+ if (decl) {
796
+ if (script_name == null) script_name = decl.name;
797
+ if (decl.kind === "strategy") {
798
+ script_type = "strategy";
799
+ applyStrategyDecl(stmt, env);
800
+ } else if (decl.kind === "library") {
801
+ script_type = "library";
802
+ applyDrawingDecl(stmt, env);
803
+ } else {
804
+ applyDrawingDecl(stmt, env);
805
+ }
806
+ continue;
645
807
  }
646
- continue;
808
+ execStmt(stmt, env);
647
809
  }
648
- execStmt(stmt, env);
649
- }
650
- if (i === 0 && script_type === "library" && script_name) {
651
- finalizeLibrary(env, script_name);
810
+ if (i === 0 && script_type === "library" && script_name) {
811
+ finalizeLibrary(env, script_name);
812
+ }
813
+ } catch (err) {
814
+ if (err === LOOP_BREAK || err === LOOP_CONTINUE) break;
815
+ runError = { error: formatRunError(err), error_kind: "runtime" };
816
+ aborted = true;
817
+ break;
652
818
  }
653
- } catch (err) {
654
- if (err === LOOP_BREAK || err === LOOP_CONTINUE) continue;
655
- runError = { error: formatRunError(err), error_kind: "runtime" };
656
- break;
819
+ if (snap) restoreTickState(env, snap);
657
820
  }
658
821
  }
659
822
  } catch (err) {
@@ -931,7 +1094,8 @@ function uniquifyPlotKey(env: Env, node: object, title: string): string {
931
1094
  }
932
1095
 
933
1096
  function isVarMode(stmt: Assign): boolean {
934
- return stmt.mode?.kind === "Var";
1097
+ // `varip` matches `var` on historical bars: init on first execution, persist.
1098
+ return stmt.mode?.kind === "Var" || stmt.mode?.kind === "VarIp";
935
1099
  }
936
1100
 
937
1101
  function isTuple(v: Value): v is TupleVal {
@@ -1055,6 +1219,16 @@ function evalAssign(stmt: Assign, env: Env): Value {
1055
1219
  }
1056
1220
  if (stmt.target.kind !== "Name") return NA;
1057
1221
  const id = stmt.target.id;
1222
+ const isVarip = stmt.mode?.kind === "VarIp";
1223
+ if (isVarip) env.varipNames.add(id);
1224
+ if (isVarip && env.barRealtime) {
1225
+ // Reference varip (Python `ast.VarIp` + `barstate.isrealtime`): re-run the
1226
+ // initializer on every realtime tick so the value can track intrabar state.
1227
+ const value = evalExpr(stmt.value, env);
1228
+ bindName(env, id, value);
1229
+ inited.add(id);
1230
+ return value;
1231
+ }
1058
1232
  // var: init on first execution (bar_index==0, or first time the name is missing).
1059
1233
  if (isVarMode(stmt) && (inited.has(id) || (env.barIndex !== 0 && ctxGet(env, id) !== undefined))) {
1060
1234
  inited.add(id);
@@ -1309,6 +1483,14 @@ function evalExpr(node: expr | undefined, env: Env): Value {
1309
1483
  if (node.value.kind === "Name") {
1310
1484
  const s = seriesGet(env, node.value.id);
1311
1485
  if (s) return s.get(offset);
1486
+ // Python `visit_Subscript` list path: array-valued names index
1487
+ // newest-first (`value[-(offset + 1)]`); OOB soft-fails to na.
1488
+ const v = ctxGet(env, node.value.id);
1489
+ if (v instanceof PineArray) {
1490
+ const size = v.size();
1491
+ const i = size - 1 - offset;
1492
+ return i < 0 || i >= size ? NA : (v.get(i) as Value);
1493
+ }
1312
1494
  }
1313
1495
  return NA;
1314
1496
  }
@@ -1687,14 +1869,14 @@ function evalAttribute(node: Attribute, env: Env): Value {
1687
1869
  }
1688
1870
  if (node.value.id === "barstate") {
1689
1871
  const last = env.barIndex === env.barCount - 1;
1690
- const realtime = env.realtimeLastBar && last;
1872
+ const rt = env.barRealtime;
1691
1873
  if (node.attr === "isfirst") return env.barIndex === 0 ? 1 : 0;
1692
1874
  if (node.attr === "islast") return last ? 1 : 0;
1693
- if (node.attr === "ishistory") return realtime ? 0 : 1;
1694
- if (node.attr === "isconfirmed") return 1;
1695
- if (node.attr === "isnew") return 1;
1696
- if (node.attr === "isrealtime") return realtime ? 1 : 0;
1697
- if (node.attr === "islastconfirmedhistory") return realtime ? 0 : last ? 1 : 0;
1875
+ if (node.attr === "ishistory") return rt ? 0 : 1;
1876
+ if (node.attr === "isconfirmed") return barIsConfirmed(env) ? 1 : 0;
1877
+ if (node.attr === "isnew") return rt && env.tickIndex > 0 ? 0 : 1;
1878
+ if (node.attr === "isrealtime") return rt ? 1 : 0;
1879
+ if (node.attr === "islastconfirmedhistory") return rt ? 0 : last ? 1 : 0;
1698
1880
  }
1699
1881
  if (node.value.id === "session") {
1700
1882
  if (node.attr === "regular") return "regular";
@@ -2499,6 +2681,39 @@ function formatRunError(err: unknown): string {
2499
2681
  return "runtime error";
2500
2682
  }
2501
2683
 
2684
+ /**
2685
+ * Python `_resolve_realtime_first`: realtime window start (inclusive) + tick
2686
+ * count. Precedence: `realtimeFromBar` > `realtimeBars` > last-bar flags
2687
+ * (`realtimeLastBar` or `realtimeTicks > 1`). `first = null` = purely
2688
+ * historical run.
2689
+ */
2690
+ export function resolveRealtimeWindow(nBars: number, opts: {
2691
+ realtimeLastBar?: boolean;
2692
+ realtimeTicks?: number;
2693
+ realtimeBars?: number;
2694
+ realtimeFromBar?: number | null;
2695
+ }): { first: number | null; ticks: number } {
2696
+ const intOr = (v: number | undefined | null, fallback: number): number => {
2697
+ if (v == null || !Number.isFinite(v)) return fallback;
2698
+ return Math.trunc(v);
2699
+ };
2700
+ let ticks = intOr(opts.realtimeTicks, 1);
2701
+ if (ticks < 1) ticks = 1;
2702
+ let rtBars = intOr(opts.realtimeBars, 0);
2703
+ if (rtBars < 0) rtBars = 0;
2704
+ if (opts.realtimeFromBar != null) {
2705
+ let first = intOr(opts.realtimeFromBar, 0);
2706
+ if (first < 0) first = 0;
2707
+ if (first >= nBars) return { first: null, ticks };
2708
+ return { first, ticks };
2709
+ }
2710
+ if (rtBars > 0) return { first: nBars > rtBars ? nBars - rtBars : 0, ticks };
2711
+ if (opts.realtimeLastBar || ticks > 1) {
2712
+ return { first: nBars > 0 ? nBars - 1 : null, ticks };
2713
+ }
2714
+ return { first: null, ticks };
2715
+ }
2716
+
2502
2717
  function isPlainInputs(extra: RuntimeOptions | InputOverrides): extra is InputOverrides {
2503
2718
  return (
2504
2719
  !("inputs" in extra) &&
@@ -2507,7 +2722,10 @@ function isPlainInputs(extra: RuntimeOptions | InputOverrides): extra is InputOv
2507
2722
  !("libraries" in extra) &&
2508
2723
  !("mode" in extra) &&
2509
2724
  !("timeout_seconds" in extra) &&
2510
- !("realtimeLastBar" in extra)
2725
+ !("realtimeLastBar" in extra) &&
2726
+ !("realtimeTicks" in extra) &&
2727
+ !("realtimeBars" in extra) &&
2728
+ !("realtimeFromBar" in extra)
2511
2729
  );
2512
2730
  }
2513
2731
 
@@ -2660,7 +2878,8 @@ function runAuto(
2660
2878
  timeframe?: string | null;
2661
2879
  libraries?: LibraryRegistry;
2662
2880
  timeout_seconds?: number | null;
2663
- realtime_last_bar?: boolean;
2881
+ realtime_first?: number | null;
2882
+ realtime_ticks?: number;
2664
2883
  },
2665
2884
  ): RuntimeResult {
2666
2885
  const elig = compileEligible(source);
@@ -2738,9 +2957,9 @@ function normalizeAlertFreq(freq: unknown): string {
2738
2957
  return ALERT_FREQ_ALIASES[s] ?? (s ? s : FREQ_ONCE_PER_BAR);
2739
2958
  }
2740
2959
 
2741
- /** Python `_bar_is_confirmed`: single-tick runs are confirmed (ticks>1 not modeled). */
2742
- function barIsConfirmed(_env: Env): boolean {
2743
- return true;
2960
+ /** Python `_bar_is_confirmed`: false only on intermediate realtime ticks. */
2961
+ function barIsConfirmed(env: Env): boolean {
2962
+ return !env.barRealtime || env.tickIndex === env.tickCount - 1;
2744
2963
  }
2745
2964
 
2746
2965
  /** Python `_should_fire_alert`. */
@@ -5,6 +5,9 @@
5
5
  * Tiny Pine `map.*` value. Keys are string or finite number; `null` is `na`.
6
6
  * Backed by `Map` (never a plain object) so user keys cannot pollute prototypes.
7
7
  */
8
+
9
+ import { cloneValue } from "./clone.ts";
10
+
8
11
  export type Cell = number | null;
9
12
  export type MapKey = string | number;
10
13
 
@@ -76,6 +79,28 @@ export class PineMap {
76
79
  return out;
77
80
  }
78
81
 
82
+ /** Deep clone (realtime tick snapshots). */
83
+ clone(): PineMap {
84
+ const out = new PineMap();
85
+ out.restoreContents(this.snapshotContents());
86
+ return out;
87
+ }
88
+
89
+ /** Deep-cloned entries for realtime tick rollback. */
90
+ snapshotContents(): { data: Array<[string, Cell]>; order: string[] } {
91
+ const data: Array<[string, Cell]> = [];
92
+ for (const [k, v] of this.data) data.push([k, cloneValue(v) as Cell]);
93
+ return { data, order: [...this.order] };
94
+ }
95
+
96
+ /** Replace entries with clones of `snap` — same instance for aliases. */
97
+ restoreContents(snap: { data: Array<[string, Cell]>; order: string[] }): void {
98
+ this.data.clear();
99
+ this.order.length = 0;
100
+ for (const [k, v] of snap.data) this.data.set(k, cloneValue(v) as Cell);
101
+ for (const k of snap.order) this.order.push(k);
102
+ }
103
+
79
104
  putAll(other: PineMap): void {
80
105
  if (!(other instanceof PineMap)) return;
81
106
  const keys = other.keys();
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { UdtInstance } from "./udt.ts";
11
+ import { cloneValue } from "./clone.ts";
11
12
 
12
13
  export type Cell = number | null;
13
14
 
@@ -564,6 +565,30 @@ export class PineMatrix {
564
565
  }
565
566
  }
566
567
 
568
+ /** Deep clone (realtime tick snapshots). */
569
+ clone(): PineMatrix {
570
+ const out = new PineMatrix(this.nRows, this.nCols);
571
+ out.restoreContents(this.snapshotContents());
572
+ return out;
573
+ }
574
+
575
+ /** Deep-cloned grid + dims for realtime tick rollback. */
576
+ snapshotContents(): { cells: unknown[][]; nRows: number; nCols: number } {
577
+ return {
578
+ cells: this.cells.map((row) => row.map(cloneValue)),
579
+ nRows: this.nRows,
580
+ nCols: this.nCols,
581
+ };
582
+ }
583
+
584
+ /** Replace grid + dims with clones of `snap` — same instance for aliases. */
585
+ restoreContents(snap: { cells: unknown[][]; nRows: number; nCols: number }): void {
586
+ this.cells.length = 0;
587
+ for (const row of snap.cells) this.cells.push(row.map(cloneValue));
588
+ this.nRows = snap.nRows;
589
+ this.nCols = snap.nCols;
590
+ }
591
+
567
592
  transpose(): PineMatrix {
568
593
  const t = new PineMatrix(this.nCols, this.nRows);
569
594
  for (let i = 0; i < this.nRows; i++) {
@@ -21,6 +21,12 @@ export class PineSeries {
21
21
  else this.data[this.data.length - 1] = value;
22
22
  }
23
23
 
24
+ /** Drop cells beyond `len` (realtime tick rollback). No-op when shorter. */
25
+ truncate(len: number): void {
26
+ if (len < 0) return;
27
+ if (this.data.length > len) this.data.length = len;
28
+ }
29
+
24
30
  get current(): Cell {
25
31
  return this.data.length === 0 ? NA : this.data[this.data.length - 1]!;
26
32
  }
@@ -11,6 +11,8 @@
11
11
  * fillEntry from flat / reverse leftover opens a trade; flatten moves it to closed.
12
12
  */
13
13
 
14
+ import { cloneValue } from "./clone.ts";
15
+
14
16
  export type StrategyDirection = "long" | "short";
15
17
  /** `strategy(..., avg_price_model=)` — stock FIFO reweight / sticky futures AEP. */
16
18
  export type AvgPriceModel = "stock" | "futures" | "inverse";
@@ -441,6 +443,36 @@ export class StrategyBook {
441
443
  if (settings) this.applyDeclSettings(settings);
442
444
  }
443
445
 
446
+ /**
447
+ * Snapshot all broker state (Python `strategy_state.snapshot()` analog)
448
+ * for realtime tick rollback: events, position, fills, pending orders,
449
+ * trades, and every counter.
450
+ */
451
+ snapshot(): Record<string, unknown> {
452
+ const out: Record<string, unknown> = {};
453
+ for (const [key, value] of Object.entries(this as unknown as Record<string, unknown>)) {
454
+ if (typeof value === "function") continue;
455
+ out[key] = cloneValue(value);
456
+ }
457
+ return out;
458
+ }
459
+
460
+ /** Restore from `snapshot()` — arrays refill in place so aliases keep identity. */
461
+ restore(snap: Record<string, unknown>): void {
462
+ const self = this as unknown as Record<string, unknown>;
463
+ for (const [key, value] of Object.entries(snap)) {
464
+ const live = self[key];
465
+ if (Array.isArray(live)) {
466
+ live.length = 0;
467
+ for (const item of value as unknown[]) live.push(cloneValue(item));
468
+ } else if (live !== null && typeof live === "object" && value !== null && typeof value === "object") {
469
+ Object.assign(live, value);
470
+ } else {
471
+ self[key] = cloneValue(value);
472
+ }
473
+ }
474
+ }
475
+
444
476
  /** Margin locked in open trades: sum(|entry * size|) / leverage (Python `capital_held`). */
445
477
  capitalHeld(): number {
446
478
  let notional = 0;