@hoox-sh/pynets 0.8.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.
@@ -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
  /** Numeric/na cell. Arrays may also store UDT instances, strings, and bools. */
13
14
  export type Cell = number | null;
@@ -245,6 +246,24 @@ export class PineArray {
245
246
  return out;
246
247
  }
247
248
 
249
+ /** Deep clone (realtime tick snapshots). */
250
+ clone(): PineArray {
251
+ const out = new PineArray();
252
+ out.restoreContents(this.snapshotContents());
253
+ return out;
254
+ }
255
+
256
+ /** Deep-cloned slots for realtime tick rollback. */
257
+ snapshotContents(): unknown[] {
258
+ return this.cells.map(cloneValue);
259
+ }
260
+
261
+ /** Replace slots with clones of `snap` — same instance, aliases keep identity. */
262
+ restoreContents(snap: unknown[]): void {
263
+ this.cells.length = 0;
264
+ for (const c of snap) this.cells.push(cloneValue(c));
265
+ }
266
+
248
267
  reverse(): void {
249
268
  this.cells.reverse();
250
269
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Copyright (C) 2024-2026 jango_blockchained
3
+ * SPDX-License-Identifier: AGPL-3.0-or-later
4
+ *
5
+ * Deep-clone runtime values for realtime tick snapshots (Python
6
+ * `_snapshot_realtime_scope` / `_snapshot_ta_state` analog). Containers
7
+ * duck-type `clone()` so this module stays import-free.
8
+ */
9
+
10
+ /** Deep clone: primitives, arrays, Map/Set, containers (via `clone()`), plain objects. */
11
+ export function cloneValue(v: unknown): unknown {
12
+ if (v == null) return v;
13
+ if (typeof v !== "object") return v;
14
+ if (Array.isArray(v)) return v.map(cloneValue);
15
+ if (v instanceof Map) {
16
+ const out = new Map<unknown, unknown>();
17
+ for (const [k, x] of v) out.set(k, cloneValue(x));
18
+ return out;
19
+ }
20
+ if (v instanceof Set) return new Set(v);
21
+ const clone = (v as { clone?: () => unknown }).clone;
22
+ if (typeof clone === "function") return clone.call(v);
23
+ const out: Record<string, unknown> = {};
24
+ for (const [k, x] of Object.entries(v)) out[k] = cloneValue(x);
25
+ return out;
26
+ }
@@ -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[];
@@ -218,6 +218,14 @@ export interface RuntimeOptions {
218
218
  mode?: RuntimeMode;
219
219
  /** Wall-clock budget in seconds. Interpret only; checked every 32 bars. */
220
220
  timeout_seconds?: number | null;
221
+ /** Visit the last bar as a forming realtime bar (`barstate.isrealtime`). */
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;
221
229
  }
222
230
 
223
231
  export interface RuntimeFill {
@@ -362,6 +370,18 @@ interface Env {
362
370
  maxBarsBackDecls: Array<{ var: Value; num: Value }>;
363
371
  /** Python `_alert_fire_bars` keys for `alert` / `alertcondition` freq gating. */
364
372
  alertFireBars: Map<string, number>;
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>;
365
385
  }
366
386
 
367
387
  export class Runtime {
@@ -413,6 +433,12 @@ export class Runtime {
413
433
  const extraOpts = extra && !isPlainInputs(extra) ? extra : undefined;
414
434
  const mode = extraOpts?.mode ?? this.mode;
415
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
+ });
416
442
  const host = {
417
443
  symbol: this.symbol,
418
444
  inputs,
@@ -420,6 +446,8 @@ export class Runtime {
420
446
  timeframe: extraOpts?.timeframe ?? this.timeframe,
421
447
  libraries: extraOpts?.libraries != null ? resolveLibraries(extraOpts.libraries) : this.libraries,
422
448
  timeout_seconds: extraOpts?.timeout_seconds ?? this.timeout_seconds,
449
+ realtime_first: rt.first,
450
+ realtime_ticks: rt.ticks,
423
451
  };
424
452
  if (mode === "compile") {
425
453
  return runCompiled(source, ohlcv, host);
@@ -494,7 +522,7 @@ export class RuntimeStream {
494
522
  if (this.closed) return undefined;
495
523
  this.bars.push(bar);
496
524
  try {
497
- const out = this.runtime.run(this.source, this.bars);
525
+ const out = this.runtime.run(this.source, this.bars, { realtimeLastBar: true });
498
526
  for (const h of this.barHandlers) h(out);
499
527
  return out;
500
528
  } catch (err) {
@@ -504,9 +532,35 @@ export class RuntimeStream {
504
532
  }
505
533
  }
506
534
 
535
+ /** Number of committed bars in the stream. */
536
+ get barCount(): number {
537
+ return this.bars.length;
538
+ }
539
+
540
+ /**
541
+ * Re-tick the forming last bar with revised OHLCV. The bar count does not
542
+ * advance; the script is re-evaluated from bar 0, so `once`, TA state, and
543
+ * broker fills naturally roll back to the revised history.
544
+ */
545
+ updateLastBar(bar: OHLCVBar): RuntimeResult {
546
+ if (this.closed) throw new Error("stream is closed");
547
+ if (this.bars.length === 0) throw new Error("updateLastBar requires at least one bar");
548
+ this.bars[this.bars.length - 1] = bar;
549
+ const out = this.runtime.run(this.source, this.bars, { realtimeLastBar: true });
550
+ for (const h of this.barHandlers) h(out);
551
+ return out;
552
+ }
553
+
554
+ /** Append several bars, returning the final result. */
555
+ appendBars(bars: OHLCVBar[]): RuntimeResult | undefined {
556
+ let out: RuntimeResult | undefined;
557
+ for (const bar of bars) out = this.push(bar);
558
+ return out;
559
+ }
560
+
507
561
  close(): RuntimeResult {
508
562
  this.closed = true;
509
- const out = this.runtime.run(this.source, this.bars);
563
+ const out = this.runtime.run(this.source, this.bars, { realtimeLastBar: false });
510
564
  for (const h of this.endHandlers) h(out);
511
565
  return out;
512
566
  }
@@ -518,6 +572,126 @@ export function interpret(source: string, ohlcv: OHLCVBar[]): { plots: Array<num
518
572
  return { plots: out.plots };
519
573
  }
520
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
+
521
695
  export function interpretTree(
522
696
  tree: AST,
523
697
  ohlcv: OHLCVBar[],
@@ -528,6 +702,8 @@ export function interpretTree(
528
702
  timeframe?: string | null;
529
703
  libraries?: LibraryRegistry;
530
704
  timeout_seconds?: number | null;
705
+ realtime_first?: number | null;
706
+ realtime_ticks?: number;
531
707
  },
532
708
  ): RuntimeResult {
533
709
  const scriptNode = tree.kind === "Script" ? (tree as Script) : null;
@@ -566,6 +742,12 @@ export function interpretTree(
566
742
  onceFired: new WeakSet(),
567
743
  maxBarsBackDecls: [],
568
744
  alertFireBars: new Map(),
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(),
569
751
  };
570
752
 
571
753
  const n = ohlcv.length;
@@ -575,8 +757,9 @@ export function interpretTree(
575
757
  const timeoutSec = host?.timeout_seconds;
576
758
  const deadline =
577
759
  timeoutSec != null && Number.isFinite(timeoutSec) ? performance.now() + timeoutSec * 1000 : null;
760
+ let aborted = false;
578
761
  try {
579
- for (let i = 0; i < n; i++) {
762
+ for (let i = 0; i < n && !aborted; i++) {
580
763
  if (deadline != null && (i & 31) === 0 && performance.now() > deadline) {
581
764
  timedOut = true;
582
765
  barsDone = i;
@@ -596,31 +779,44 @@ export function interpretTree(
596
779
  num(env.ctx.close) ?? 0,
597
780
  );
598
781
 
599
- try {
600
- for (const stmt of body) {
601
- const decl = captureDecl(stmt);
602
- if (decl) {
603
- if (script_name == null) script_name = decl.name;
604
- if (decl.kind === "strategy") {
605
- script_type = "strategy";
606
- applyStrategyDecl(stmt, env);
607
- } else if (decl.kind === "library") {
608
- script_type = "library";
609
- applyDrawingDecl(stmt, env);
610
- } else {
611
- 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;
612
807
  }
613
- continue;
808
+ execStmt(stmt, env);
614
809
  }
615
- execStmt(stmt, env);
616
- }
617
- if (i === 0 && script_type === "library" && script_name) {
618
- 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;
619
818
  }
620
- } catch (err) {
621
- if (err === LOOP_BREAK || err === LOOP_CONTINUE) continue;
622
- runError = { error: formatRunError(err), error_kind: "runtime" };
623
- break;
819
+ if (snap) restoreTickState(env, snap);
624
820
  }
625
821
  }
626
822
  } catch (err) {
@@ -898,7 +1094,8 @@ function uniquifyPlotKey(env: Env, node: object, title: string): string {
898
1094
  }
899
1095
 
900
1096
  function isVarMode(stmt: Assign): boolean {
901
- 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";
902
1099
  }
903
1100
 
904
1101
  function isTuple(v: Value): v is TupleVal {
@@ -1022,6 +1219,16 @@ function evalAssign(stmt: Assign, env: Env): Value {
1022
1219
  }
1023
1220
  if (stmt.target.kind !== "Name") return NA;
1024
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
+ }
1025
1232
  // var: init on first execution (bar_index==0, or first time the name is missing).
1026
1233
  if (isVarMode(stmt) && (inited.has(id) || (env.barIndex !== 0 && ctxGet(env, id) !== undefined))) {
1027
1234
  inited.add(id);
@@ -1276,6 +1483,14 @@ function evalExpr(node: expr | undefined, env: Env): Value {
1276
1483
  if (node.value.kind === "Name") {
1277
1484
  const s = seriesGet(env, node.value.id);
1278
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
+ }
1279
1494
  }
1280
1495
  return NA;
1281
1496
  }
@@ -1319,7 +1534,9 @@ function evalExpr(node: expr | undefined, env: Env): Value {
1319
1534
  const t = onceNode.test == null ? 1 : unwrap(evalExpr(onceNode.test, env));
1320
1535
  if (!isTruthy(t)) return NA;
1321
1536
  for (const s of onceNode.body) execStmt(s, env);
1322
- env.onceFired.add(onceNode);
1537
+ // Python commits the fired flag only on a confirmed bar; unconfirmed
1538
+ // realtime ticks may re-run the body (rollback semantics).
1539
+ if (barIsConfirmed(env)) env.onceFired.add(onceNode);
1323
1540
  return NA;
1324
1541
  }
1325
1542
  case "BoolOp":
@@ -1652,13 +1869,14 @@ function evalAttribute(node: Attribute, env: Env): Value {
1652
1869
  }
1653
1870
  if (node.value.id === "barstate") {
1654
1871
  const last = env.barIndex === env.barCount - 1;
1872
+ const rt = env.barRealtime;
1655
1873
  if (node.attr === "isfirst") return env.barIndex === 0 ? 1 : 0;
1656
1874
  if (node.attr === "islast") return last ? 1 : 0;
1657
- if (node.attr === "ishistory") return last ? 0 : 1;
1658
- if (node.attr === "isconfirmed") return 1;
1659
- if (node.attr === "isnew") return 1;
1660
- if (node.attr === "isrealtime") return 0;
1661
- if (node.attr === "islastconfirmedhistory") return 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;
1662
1880
  }
1663
1881
  if (node.value.id === "session") {
1664
1882
  if (node.attr === "regular") return "regular";
@@ -2463,6 +2681,39 @@ function formatRunError(err: unknown): string {
2463
2681
  return "runtime error";
2464
2682
  }
2465
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
+
2466
2717
  function isPlainInputs(extra: RuntimeOptions | InputOverrides): extra is InputOverrides {
2467
2718
  return (
2468
2719
  !("inputs" in extra) &&
@@ -2470,7 +2721,11 @@ function isPlainInputs(extra: RuntimeOptions | InputOverrides): extra is InputOv
2470
2721
  !("timeframe" in extra) &&
2471
2722
  !("libraries" in extra) &&
2472
2723
  !("mode" in extra) &&
2473
- !("timeout_seconds" in extra)
2724
+ !("timeout_seconds" in extra) &&
2725
+ !("realtimeLastBar" in extra) &&
2726
+ !("realtimeTicks" in extra) &&
2727
+ !("realtimeBars" in extra) &&
2728
+ !("realtimeFromBar" in extra)
2474
2729
  );
2475
2730
  }
2476
2731
 
@@ -2623,6 +2878,8 @@ function runAuto(
2623
2878
  timeframe?: string | null;
2624
2879
  libraries?: LibraryRegistry;
2625
2880
  timeout_seconds?: number | null;
2881
+ realtime_first?: number | null;
2882
+ realtime_ticks?: number;
2626
2883
  },
2627
2884
  ): RuntimeResult {
2628
2885
  const elig = compileEligible(source);
@@ -2700,9 +2957,9 @@ function normalizeAlertFreq(freq: unknown): string {
2700
2957
  return ALERT_FREQ_ALIASES[s] ?? (s ? s : FREQ_ONCE_PER_BAR);
2701
2958
  }
2702
2959
 
2703
- /** Python `_bar_is_confirmed`. This host sets `barstate.isconfirmed` to 1 on every bar. */
2704
- function barIsConfirmed(_env: Env): boolean {
2705
- 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;
2706
2963
  }
2707
2964
 
2708
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();