opencode-pyneruntime 6.6.4__py3-none-any.whl

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.
Files changed (261) hide show
  1. opencode_pyneruntime-6.6.4.dist-info/METADATA +281 -0
  2. opencode_pyneruntime-6.6.4.dist-info/RECORD +261 -0
  3. opencode_pyneruntime-6.6.4.dist-info/WHEEL +5 -0
  4. opencode_pyneruntime-6.6.4.dist-info/entry_points.txt +6 -0
  5. opencode_pyneruntime-6.6.4.dist-info/licenses/LICENSE +201 -0
  6. opencode_pyneruntime-6.6.4.dist-info/licenses/NOTICE +21 -0
  7. opencode_pyneruntime-6.6.4.dist-info/top_level.txt +1 -0
  8. pynecore/__init__.py +6 -0
  9. pynecore/cli/__init__.py +2 -0
  10. pynecore/cli/app.py +238 -0
  11. pynecore/cli/commands/__init__.py +343 -0
  12. pynecore/cli/commands/benchmark.py +186 -0
  13. pynecore/cli/commands/compile.py +198 -0
  14. pynecore/cli/commands/data.py +857 -0
  15. pynecore/cli/commands/debug.py +63 -0
  16. pynecore/cli/commands/optimize.py +956 -0
  17. pynecore/cli/commands/plugin.py +242 -0
  18. pynecore/cli/commands/run.py +2006 -0
  19. pynecore/cli/pluggable.py +132 -0
  20. pynecore/cli/utils/__init__.py +0 -0
  21. pynecore/cli/utils/api_error_handler.py +168 -0
  22. pynecore/cli/utils/broker_picker.py +330 -0
  23. pynecore/cli/utils/error_hook.py +28 -0
  24. pynecore/cli/utils/keyreader.py +178 -0
  25. pynecore/cli/utils/provider_picker.py +19 -0
  26. pynecore/cli/utils/symbol_browser.py +1149 -0
  27. pynecore/core/__init__.py +0 -0
  28. pynecore/core/aggregator.py +257 -0
  29. pynecore/core/bar_magnifier.py +168 -0
  30. pynecore/core/broker/__init__.py +64 -0
  31. pynecore/core/broker/defaults.py +113 -0
  32. pynecore/core/broker/disappearance.py +927 -0
  33. pynecore/core/broker/emulator.py +345 -0
  34. pynecore/core/broker/exceptions.py +346 -0
  35. pynecore/core/broker/idempotency.py +401 -0
  36. pynecore/core/broker/intent_builder.py +334 -0
  37. pynecore/core/broker/journal.py +1785 -0
  38. pynecore/core/broker/models.py +1600 -0
  39. pynecore/core/broker/native_failsafe_manager.py +1436 -0
  40. pynecore/core/broker/one_way_emulator.py +1128 -0
  41. pynecore/core/broker/position.py +787 -0
  42. pynecore/core/broker/run_identity.py +126 -0
  43. pynecore/core/broker/software_entry_stop_engine.py +351 -0
  44. pynecore/core/broker/software_partial_bracket_engine.py +1379 -0
  45. pynecore/core/broker/spot_inventory.py +1327 -0
  46. pynecore/core/broker/storage.py +2655 -0
  47. pynecore/core/broker/store_helpers.py +2161 -0
  48. pynecore/core/broker/sync_engine.py +16070 -0
  49. pynecore/core/broker/validation.py +382 -0
  50. pynecore/core/class_property.py +7 -0
  51. pynecore/core/config.py +392 -0
  52. pynecore/core/csv_file.py +547 -0
  53. pynecore/core/currency.py +262 -0
  54. pynecore/core/data_converter.py +1002 -0
  55. pynecore/core/datetime.py +296 -0
  56. pynecore/core/download_info.py +71 -0
  57. pynecore/core/download_runner.py +274 -0
  58. pynecore/core/htf_aggregator.py +181 -0
  59. pynecore/core/import_hook.py +358 -0
  60. pynecore/core/instance_state.py +494 -0
  61. pynecore/core/live_ltf_collector.py +442 -0
  62. pynecore/core/live_ltf_window.py +189 -0
  63. pynecore/core/live_runner.py +1347 -0
  64. pynecore/core/module_property.py +26 -0
  65. pynecore/core/ohlcv_file.py +1888 -0
  66. pynecore/core/overload.py +371 -0
  67. pynecore/core/pine_cast.py +113 -0
  68. pynecore/core/pine_export.py +95 -0
  69. pynecore/core/pine_method.py +244 -0
  70. pynecore/core/pine_range.py +86 -0
  71. pynecore/core/pine_udt.py +69 -0
  72. pynecore/core/plugin/__init__.py +394 -0
  73. pynecore/core/plugin/broker.py +781 -0
  74. pynecore/core/plugin/cli.py +96 -0
  75. pynecore/core/plugin/live_provider.py +208 -0
  76. pynecore/core/plugin/provider.py +331 -0
  77. pynecore/core/provider_string.py +148 -0
  78. pynecore/core/random.py +40 -0
  79. pynecore/core/resampler.py +686 -0
  80. pynecore/core/safe_convert.py +64 -0
  81. pynecore/core/script.py +1011 -0
  82. pynecore/core/script_runner.py +3202 -0
  83. pynecore/core/security.py +1749 -0
  84. pynecore/core/security_process.py +1253 -0
  85. pynecore/core/security_shm.py +456 -0
  86. pynecore/core/series.py +417 -0
  87. pynecore/core/strategy_stats.py +669 -0
  88. pynecore/core/symbol_map.py +134 -0
  89. pynecore/core/syminfo.py +505 -0
  90. pynecore/core/viz.py +591 -0
  91. pynecore/lib/__init__.py +1771 -0
  92. pynecore/lib/_fixnan.py +32 -0
  93. pynecore/lib/_math_stateful.py +202 -0
  94. pynecore/lib/_timeframe_change.py +101 -0
  95. pynecore/lib/adjustment.py +6 -0
  96. pynecore/lib/alert.py +39 -0
  97. pynecore/lib/alert.pyi +14 -0
  98. pynecore/lib/array.py +1051 -0
  99. pynecore/lib/barmerge.py +60 -0
  100. pynecore/lib/barstate.py +30 -0
  101. pynecore/lib/box.py +415 -0
  102. pynecore/lib/chart.py +128 -0
  103. pynecore/lib/color.py +152 -0
  104. pynecore/lib/color.pyi +50 -0
  105. pynecore/lib/currency.py +62 -0
  106. pynecore/lib/dayofweek.py +36 -0
  107. pynecore/lib/dayofweek.pyi +18 -0
  108. pynecore/lib/display.py +8 -0
  109. pynecore/lib/dividends.py +9 -0
  110. pynecore/lib/earnings.py +11 -0
  111. pynecore/lib/extend.py +6 -0
  112. pynecore/lib/font.py +5 -0
  113. pynecore/lib/footprint.py +79 -0
  114. pynecore/lib/format.py +11 -0
  115. pynecore/lib/hline.py +67 -0
  116. pynecore/lib/hline.pyi +24 -0
  117. pynecore/lib/label.py +409 -0
  118. pynecore/lib/line.py +433 -0
  119. pynecore/lib/linefill.py +93 -0
  120. pynecore/lib/location.py +11 -0
  121. pynecore/lib/log.py +362 -0
  122. pynecore/lib/map.py +150 -0
  123. pynecore/lib/math.py +385 -0
  124. pynecore/lib/matrix.py +708 -0
  125. pynecore/lib/order.py +8 -0
  126. pynecore/lib/pivotpointtype.py +8 -0
  127. pynecore/lib/plot.py +95 -0
  128. pynecore/lib/plot.pyi +33 -0
  129. pynecore/lib/polyline.py +91 -0
  130. pynecore/lib/position.py +15 -0
  131. pynecore/lib/request.py +281 -0
  132. pynecore/lib/runtime.py +5 -0
  133. pynecore/lib/scale.py +9 -0
  134. pynecore/lib/session.py +267 -0
  135. pynecore/lib/session.pyi +12 -0
  136. pynecore/lib/shape.py +18 -0
  137. pynecore/lib/size.py +12 -0
  138. pynecore/lib/splits.py +4 -0
  139. pynecore/lib/strategy/__init__.py +4778 -0
  140. pynecore/lib/strategy/closedtrades.py +347 -0
  141. pynecore/lib/strategy/closedtrades.pyi +53 -0
  142. pynecore/lib/strategy/commission.py +9 -0
  143. pynecore/lib/strategy/direction.py +9 -0
  144. pynecore/lib/strategy/oca.py +13 -0
  145. pynecore/lib/strategy/opentrades.py +281 -0
  146. pynecore/lib/strategy/opentrades.pyi +49 -0
  147. pynecore/lib/strategy/risk.py +109 -0
  148. pynecore/lib/string.py +649 -0
  149. pynecore/lib/syminfo.py +84 -0
  150. pynecore/lib/ta.py +2230 -0
  151. pynecore/lib/table.py +290 -0
  152. pynecore/lib/text.py +17 -0
  153. pynecore/lib/ticker.py +207 -0
  154. pynecore/lib/timeframe.py +293 -0
  155. pynecore/lib/volume_row.py +67 -0
  156. pynecore/lib/xloc.py +4 -0
  157. pynecore/lib/yloc.py +5 -0
  158. pynecore/providers/__init__.py +0 -0
  159. pynecore/providers/ccxt.py +664 -0
  160. pynecore/providers/replay.py +187 -0
  161. pynecore/pynesys/__init__.py +0 -0
  162. pynecore/pynesys/api.py +498 -0
  163. pynecore/pynesys/compiler.py +112 -0
  164. pynecore/standalone.py +99 -0
  165. pynecore/testing/__init__.py +1 -0
  166. pynecore/testing/broker_lab/__init__.py +41 -0
  167. pynecore/testing/broker_lab/__main__.py +5 -0
  168. pynecore/testing/broker_lab/cli.py +87 -0
  169. pynecore/testing/broker_lab/generate.py +47 -0
  170. pynecore/testing/broker_lab/model.py +84 -0
  171. pynecore/testing/broker_lab/reference.py +645 -0
  172. pynecore/testing/broker_lab/runner.py +372 -0
  173. pynecore/testing/broker_lab/scheduler.py +50 -0
  174. pynecore/testing/broker_lab/subprocess.py +73 -0
  175. pynecore/transformers/__init__.py +0 -0
  176. pynecore/transformers/builtin_shadow.py +136 -0
  177. pynecore/transformers/closure_arguments_transformer.py +428 -0
  178. pynecore/transformers/display_rewrite.py +140 -0
  179. pynecore/transformers/dynamic_default.py +147 -0
  180. pynecore/transformers/function_isolation.py +757 -0
  181. pynecore/transformers/import_lifter.py +61 -0
  182. pynecore/transformers/import_normalizer.py +328 -0
  183. pynecore/transformers/inline_series_hoist.py +178 -0
  184. pynecore/transformers/input_transformer.py +175 -0
  185. pynecore/transformers/lib_series.py +201 -0
  186. pynecore/transformers/locations.py +70 -0
  187. pynecore/transformers/module_properties.json +3387 -0
  188. pynecore/transformers/module_property.py +221 -0
  189. pynecore/transformers/ne_guard.py +70 -0
  190. pynecore/transformers/persistent.py +320 -0
  191. pynecore/transformers/persistent_series.py +76 -0
  192. pynecore/transformers/safe_convert_transformer.py +97 -0
  193. pynecore/transformers/safe_division_transformer.py +95 -0
  194. pynecore/transformers/script_requirements.py +308 -0
  195. pynecore/transformers/security.py +752 -0
  196. pynecore/transformers/security_instantiation.py +274 -0
  197. pynecore/transformers/series.py +275 -0
  198. pynecore/transformers/slot_layout.py +381 -0
  199. pynecore/transformers/type_checking_stripper.py +25 -0
  200. pynecore/transformers/unused_series_detector.py +267 -0
  201. pynecore/types/__init__.py +21 -0
  202. pynecore/types/alert.py +5 -0
  203. pynecore/types/barmerge.py +5 -0
  204. pynecore/types/base.py +39 -0
  205. pynecore/types/box.py +37 -0
  206. pynecore/types/chart.py +17 -0
  207. pynecore/types/color.py +107 -0
  208. pynecore/types/currency.py +5 -0
  209. pynecore/types/datetime.py +6 -0
  210. pynecore/types/display.py +5 -0
  211. pynecore/types/dividends.py +5 -0
  212. pynecore/types/earnings.py +5 -0
  213. pynecore/types/extend.py +5 -0
  214. pynecore/types/font.py +5 -0
  215. pynecore/types/footprint.py +41 -0
  216. pynecore/types/format.py +5 -0
  217. pynecore/types/hline.py +24 -0
  218. pynecore/types/ib_persistent.py +8 -0
  219. pynecore/types/ib_persistent.pyi +10 -0
  220. pynecore/types/label.py +35 -0
  221. pynecore/types/line.py +32 -0
  222. pynecore/types/linefill.py +13 -0
  223. pynecore/types/location.py +5 -0
  224. pynecore/types/matrix.py +999 -0
  225. pynecore/types/na.py +237 -0
  226. pynecore/types/na.pyi +83 -0
  227. pynecore/types/ohlcv.py +12 -0
  228. pynecore/types/order.py +5 -0
  229. pynecore/types/persistent.py +8 -0
  230. pynecore/types/persistent.pyi +13 -0
  231. pynecore/types/pine_types.py +11 -0
  232. pynecore/types/pine_types.pyi +15 -0
  233. pynecore/types/pivotpointtype.py +5 -0
  234. pynecore/types/plot.py +12 -0
  235. pynecore/types/plot_meta.py +60 -0
  236. pynecore/types/polyline.py +40 -0
  237. pynecore/types/position.py +5 -0
  238. pynecore/types/scale.py +5 -0
  239. pynecore/types/script_type.py +15 -0
  240. pynecore/types/series.py +23 -0
  241. pynecore/types/series.pyi +19 -0
  242. pynecore/types/session.py +35 -0
  243. pynecore/types/shape.py +5 -0
  244. pynecore/types/size.py +5 -0
  245. pynecore/types/source.py +33 -0
  246. pynecore/types/splits.py +5 -0
  247. pynecore/types/strategy.py +45 -0
  248. pynecore/types/table.py +87 -0
  249. pynecore/types/text.py +13 -0
  250. pynecore/types/type_checker.py +7 -0
  251. pynecore/types/type_checker.pyi +48 -0
  252. pynecore/types/volume_row.py +36 -0
  253. pynecore/types/weekdays.py +11 -0
  254. pynecore/types/xloc.py +5 -0
  255. pynecore/types/yloc.py +5 -0
  256. pynecore/utils/__init__.py +0 -0
  257. pynecore/utils/file_utils.py +50 -0
  258. pynecore/utils/rich/__init__.py +0 -0
  259. pynecore/utils/rich/date_column.py +25 -0
  260. pynecore/utils/sequence_view.py +92 -0
  261. pynecore/utils/stdlib_checker.py +17 -0
@@ -0,0 +1,1749 @@
1
+ """
2
+ Runtime protocol for request.security() — chart-side and security-side functions.
3
+
4
+ The SecurityTransformer (Phase 1) rewrites request.security() calls into four protocol
5
+ functions: __sec_signal__, __sec_write__, __sec_read__, __sec_wait__. This module provides
6
+ the runtime implementations that coordinate via shared memory and multiprocessing Events.
7
+
8
+ Architecture:
9
+ - Chart process: signals security processes, waits for results, reads from shared memory
10
+ - Security process: receives signals, runs bars, writes results to shared memory
11
+ - Cross-context reads: security processes read other contexts' latest values immediately
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import threading
17
+ from dataclasses import dataclass, field
18
+ from datetime import date, datetime, time, timedelta
19
+ from enum import Enum, auto
20
+ from multiprocessing import Event, Lock, connection
21
+ from pathlib import Path
22
+ from typing import TYPE_CHECKING
23
+ from zoneinfo import ZoneInfo
24
+
25
+ from .datetime import parse_timezone
26
+ from .security_shm import (
27
+ SyncBlock, ResultBlock, ResultReader, INITIAL_RESULT_SIZE,
28
+ FLAG_IS_DEVELOPING, FLAG_CLOSED_OVERRIDE,
29
+ FLAG_LTF_WINDOW, FLAG_LTF_CHART_DEVELOPING, FLAG_LTF_LIVE_PHASE,
30
+ write_result,
31
+ )
32
+
33
+ if TYPE_CHECKING:
34
+ from multiprocessing.process import BaseProcess
35
+ from multiprocessing.synchronize import Event as EventType, Lock as LockType
36
+ from typing import Callable
37
+ from .resampler import Resampler
38
+ from .htf_aggregator import HTFAggregator
39
+ from .syminfo import SymInfo, SymInfoSession, SymInfoInterval
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+
44
+ class Lookahead(Enum):
45
+ """Lookahead mode for a security context.
46
+
47
+ OFF
48
+ TV-faithful default. The security context advances to the most
49
+ recent HTF bar that has CLOSED at or before the chart bar's CLOSE
50
+ instant — the HTF period's last chart bar already carries the
51
+ period's final value. In historical mode this matches TradingView's
52
+ ``barmerge.lookahead_off`` exactly. In live mode the chart-side
53
+ ``HTFAggregator`` ships each freshly closed HTF bar to the
54
+ subprocess via the SyncBlock (the static ``.ohlcv`` file cannot
55
+ grow at runtime) as soon as the confirmed chart bar completing the
56
+ period is folded — no developing-bar exposure.
57
+
58
+ LAST_CLOSED
59
+ PyneSys-native, repaint-free alternative. Always returns the most
60
+ recently closed security bar. In historical mode it is functionally
61
+ equivalent to ``OFF``; in live mode it uses the same closed-bar
62
+ transport as ``OFF`` (no developing exposure) and remains
63
+ repaint-free. Recommended for non-charting backtests when the TV
64
+ ``close[1]`` idiom is not desired.
65
+
66
+ ON
67
+ TV ``lookahead_on`` semantics — the security context steps into
68
+ the bar that *contains* the chart bar's time. In live mode the
69
+ bar runs with ``barstate.isconfirmed=False`` and OHLCV aggregated
70
+ from the chart timeframe by ``HTFAggregator``; on HTF period
71
+ boundaries the closed bar is delivered first (snapshot saved),
72
+ then the fresh developing bar. In historical/backtest mode the
73
+ containing period's bar is already complete in the child's data
74
+ file, so the child reads its final OHLCV: a bare ``close`` on the
75
+ developing period reproduces TV's classical historical future-leak,
76
+ and the inner-``[1]`` daily-pivot idiom
77
+ ``request.security(sym, "D", close[1], lookahead_on)`` reads the
78
+ just-closed prior period (yesterday) — matching TradingView. Use
79
+ ``LAST_CLOSED`` for the repaint-free alternative. The outer-``[1]``
80
+ form ``request.security(..., close, lookahead_on)[1]`` is also
81
+ TV-compatible because ``close[1]`` on the chart series is the
82
+ previously delivered value.
83
+
84
+ **Cross-symbol HTF** — when the security symbol differs from the
85
+ chart symbol there is no chart-side aggregator (the chart OHLCV
86
+ is the wrong instrument). The chart-side read returns ``na`` while
87
+ an HTF period is open (``na_on_developing``); at the period
88
+ boundary the chart receives the just-closed cross-symbol HTF
89
+ close, and the TV ``request.security(..., lookahead_on)[1]``
90
+ idiom continues to deliver that value on the next chart bar.
91
+ Behaviour is identical in historical and live mode.
92
+ """
93
+ OFF = auto()
94
+ LAST_CLOSED = auto()
95
+ ON = auto()
96
+
97
+
98
+ def _lookahead_mode(value) -> Lookahead:
99
+ """Map a ``barmerge.lookahead_*`` singleton (or None) to a :class:`Lookahead`."""
100
+ from pynecore.lib import barmerge
101
+ if value is barmerge.lookahead_on:
102
+ return Lookahead.ON
103
+ if value is barmerge.lookahead_last_closed:
104
+ return Lookahead.LAST_CLOSED
105
+ return Lookahead.OFF
106
+
107
+
108
+ # Liveness poll interval for security-process waits without a death watcher
109
+ # (legacy fallback). Short enough to detect a crashed child quickly.
110
+ _LIVENESS_POLL_SECONDS = 0.5
111
+
112
+
113
+ def watch_security_child(
114
+ sec_id: str,
115
+ proc: 'BaseProcess',
116
+ failed_children: set[str],
117
+ events: 'tuple[EventType, ...]',
118
+ ) -> None:
119
+ """
120
+ Start a daemon thread that watches a security child for abnormal death.
121
+
122
+ The per-bar chart waits must be UNTIMED ``event.wait()`` calls: macOS has
123
+ no ``sem_timedwait``, so a timed multiprocessing wait falls back to
124
+ CPython's ``sem_timedwait_save`` emulation — a ``sem_trywait`` +
125
+ ``select()`` polling loop with millisecond-growing sleeps that adds up to
126
+ ~20ms of wake latency PER WAIT regardless of the timeout value. On a per-
127
+ bar signalled context that quantization dominated the whole run (measured
128
+ ~2ms/bar, >80% of wall time). Death detection therefore moves out of the
129
+ wait: this watcher blocks on the process sentinel (no polling), and on a
130
+ non-zero exit registers the sec_id in ``failed_children`` BEFORE setting
131
+ the events, so a blocked ``_wait_with_liveness`` wakes immediately and
132
+ raises instead of deadlocking. A clean exit (code 0) registers nothing —
133
+ a child never exits cleanly while the chart still waits on it.
134
+
135
+ :param sec_id: Security context id the process serves
136
+ :param proc: The started child process
137
+ :param failed_children: Shared registry of abnormally died sec_ids
138
+ :param events: Events a chart wait may block on for this context
139
+ """
140
+ def _watch() -> None:
141
+ connection.wait([proc.sentinel])
142
+ if proc.exitcode not in (0, None):
143
+ failed_children.add(sec_id)
144
+ for ev in events:
145
+ ev.set()
146
+
147
+ threading.Thread(target=_watch, daemon=True,
148
+ name=f"sec-watch-{sec_id}").start()
149
+
150
+
151
+ def _wait_with_liveness(
152
+ event: 'EventType',
153
+ sec_id: str,
154
+ sec_processes: 'dict[str, BaseProcess] | None',
155
+ failed_children: 'set[str] | None' = None,
156
+ ) -> None:
157
+ """
158
+ Wait for ``event`` without deadlocking on a dead security process.
159
+
160
+ With a ``failed_children`` registry (see :func:`watch_security_child`)
161
+ the wait is a plain unbounded ``event.wait()`` — the cheap, non-polling
162
+ path (macOS emulates TIMED multiprocessing waits with a select() polling
163
+ loop whose wake latency is disastrous per bar) — and a wake caused by the
164
+ death watcher raises ``RuntimeError``. Without a registry, fall back to
165
+ polling ``proc.is_alive()`` on a timed wait.
166
+
167
+ Same-context and ignored sec_ids have no associated Process — they use
168
+ the plain unbounded ``event.wait()`` because their signalling is driven
169
+ by the chart itself, not a separate process.
170
+ """
171
+ if sec_processes is None or sec_id not in sec_processes:
172
+ event.wait()
173
+ return
174
+ if failed_children is not None:
175
+ event.wait()
176
+ if sec_id in failed_children:
177
+ proc = sec_processes[sec_id]
178
+ raise RuntimeError(
179
+ f"Security process for '{sec_id}' died unexpectedly "
180
+ f"(exit code: {proc.exitcode})"
181
+ )
182
+ return
183
+ proc = sec_processes[sec_id]
184
+ while not event.wait(timeout=_LIVENESS_POLL_SECONDS):
185
+ if not proc.is_alive():
186
+ raise RuntimeError(
187
+ f"Security process for '{sec_id}' died unexpectedly "
188
+ f"(exit code: {proc.exitcode})"
189
+ )
190
+
191
+
192
+ @dataclass
193
+ class SecurityState:
194
+ """Per-security-context runtime state."""
195
+ sec_id: str
196
+ timeframe: str
197
+ gaps_on: bool
198
+ same_timeframe: bool
199
+ resampler: Resampler | None # None only if same_timeframe AND same_symbol
200
+ tz: ZoneInfo
201
+
202
+ # Multiprocessing events (shared between chart and security processes)
203
+ data_ready: EventType = field(default_factory=Event)
204
+ advance_event: EventType = field(default_factory=Event)
205
+ done_event: EventType = field(default_factory=Event)
206
+ stop_event: EventType = field(default_factory=Event)
207
+
208
+ # Cross-process mutex protecting this slot's ResultBlock + sync metadata.
209
+ # Held by writers (write_result/write_na) and by cross-context readers in
210
+ # security children. Chart-side reads also acquire it for uniformity, but
211
+ # never contend (data_ready already gates them).
212
+ result_lock: LockType = field(default_factory=Lock)
213
+
214
+ # LTF mode (lower timeframe → array return)
215
+ is_ltf: bool = False
216
+
217
+ # Plain ``request.security()`` with a timeframe FINER than the chart's
218
+ # (scalar return, unlike ``is_ltf``). TradingView merge rule (verified on
219
+ # captured references): ``lookahead_off`` returns the expression's value on
220
+ # the LAST intrabar of each chart bar, ``lookahead_on`` on the FIRST. The
221
+ # chart side therefore targets the chart bar's own period end (OFF) or open
222
+ # (ON) and the child's last per-intrabar write wins. ``plain_ltf_span_ms``
223
+ # caches the security period in ms for the live developing-bar clamp.
224
+ plain_ltf: bool = False
225
+ plain_ltf_span_ms: int = 0
226
+
227
+ # Synthetic chart type requested via ``ticker.heikinashi()`` etc. ``None`` is
228
+ # an ordinary feed. When set (currently only ``"heikinashi"``), it is passed
229
+ # to the security child, which applies the chart-type transform per bar
230
+ # (backtest and live alike) and flips the matching ``chart.*`` builtin. LTF
231
+ # (sub-bar) chart types are rejected at spawn.
232
+ chart_type: str | None = None
233
+
234
+ # LTF prefix-skip (chart-side, backtest/file-backed only). The LTF child's
235
+ # ``.ohlcv`` feed first bar open, in ms. The child includes intrabars with
236
+ # ``bar_open <= target_time`` and the historical target is the chart bar's
237
+ # last ms (``chart_off``), so a chart bar contains an intrabar only when its
238
+ # period end reaches the feed (``target_time >= ltf_first_ms``). A chart bar
239
+ # whose whole period ends before the feed's first open therefore yields an
240
+ # empty array unconditionally (TradingView returns ``na`` before the LTF
241
+ # series begins). ``__sec_signal__`` then skips the per-bar signal+wait
242
+ # handshake for that idle prefix and ``__sec_read__`` returns the empty-array
243
+ # default without touching shared memory. ``None`` disables the optimization
244
+ # (live ``PluginSymbol`` streams have no static first bar), restoring the
245
+ # original per-chart-bar signal. Populated by ``load_ltf_first_ms`` at child
246
+ # spawn.
247
+ ltf_first_ms: int | None = None
248
+
249
+ # Lookahead mode (Pine `lookahead=barmerge.lookahead_*`). Drives whether
250
+ # the security process should emit a ghost-bar write step on chart bars
251
+ # that fall inside an unclosed HTF period.
252
+ lookahead: Lookahead = Lookahead.OFF
253
+
254
+ # Per-sec_id HTF aggregator (chart-side). Populated by
255
+ # ``setup_security_states`` for every same-symbol HTF context — drives
256
+ # the live-mode closed-bar transport (all lookahead modes) and, for
257
+ # ``Lookahead.ON``, the developing-bar transport. None for same-TF, LTF,
258
+ # and cross-symbol HTF (chart-derived OHLCV would be the wrong instrument).
259
+ htf_aggregator: HTFAggregator | None = None
260
+
261
+ # Cross-symbol HTF + ``Lookahead.ON``: the containing developing bar
262
+ # cannot be aggregated (chart OHLCV is the wrong instrument), so the
263
+ # chart-side read returns ``na`` on every chart bar inside an open HTF
264
+ # period. The subprocess still advances on HTF period closes, so
265
+ # ``close[1]`` on the first chart bar of a fresh HTF period returns the
266
+ # just-closed cross-symbol HTF close — the TV ``lookahead_on + close[1]``
267
+ # idiom continues to work. Applies in both historical and live mode;
268
+ # backtest never silently emits a value live could not produce.
269
+ na_on_developing: bool = False
270
+
271
+ # True once the ScriptRunner enters live mode (``barstate.ishistory=False``).
272
+ # Chart-side ``__sec_signal__`` consults this to gate the developing-bar
273
+ # transport — historical bars never emit developing OHLCV.
274
+ is_live: bool = False
275
+
276
+ # True when this is an LTF (``request.security_lower_tf``) context backed by a
277
+ # live streaming source (a :class:`PluginSymbol`, no static ``.ohlcv`` file).
278
+ # Such a context has no ``ltf_first_ms`` (the loader is skipped) and its
279
+ # subprocess pulls intrabars from its own streamer, so ``__sec_signal__``
280
+ # routes every round — warmup replay included — through the LTF-window path
281
+ # (``FLAG_LTF_WINDOW``) rather than the file-backed read-ahead path. Set at
282
+ # setup; distinct from ``is_live`` (a lifecycle phase that flips only after
283
+ # warmup) and from ``ltf_first_ms is None`` (which also matches an empty
284
+ # static feed that has no streamer).
285
+ ltf_live_stream: bool = False
286
+
287
+ # Intraday session anchoring (chart-side). Populated by
288
+ # ``setup_security_states`` only when this security's session opens off the
289
+ # requested HTF grid (e.g. equities 09:30 at 1H). ``None`` selects the pure
290
+ # UTC clock-floor fast path in ``Resampler.get_bar_time`` — zero overhead for
291
+ # 24/7, on-hour, and session-aligned instruments. ``session_tz`` is the
292
+ # security's own exchange timezone (correct even for cross-symbol HTF).
293
+ session_starts: 'list[SymInfoSession] | None' = None
294
+ session_tz: ZoneInfo | None = None
295
+
296
+ # Daily/weekly/monthly HTF confirmation (chart-side). The child's data file
297
+ # realizes the actual trading calendar, so confirmation rides the child's
298
+ # real bar opens instead of an arithmetic grid that assumes a bar on every
299
+ # calendar period — essential for sparse daily series (ECONOMICS macro data,
300
+ # dividends) where the grid would confirm phantom periods and the subprocess
301
+ # would ``write_na`` into empty windows, wiping the ``gaps_off`` forward-fill.
302
+ # Two strategies, by ``bar_opens_multiperiod`` (see ``_get_confirmed_time``):
303
+ # * multi-period (nD/nW/nM): WALK the opens — the grid cannot reproduce
304
+ # TradingView's scheduled multi-period boundaries (holiday calendar).
305
+ # * single-period (1D/1W/1M): the grid gives the correct calendar close
306
+ # instant; CLAMP it to the latest real open so a sparse child still
307
+ # forward-fills its last value between bars instead of confirming late.
308
+ # Populated by ``load_htf_bar_opens`` at child spawn (backtest only).
309
+ # ``chart_off`` is the chart bar span minus one ms for
310
+ # intraday/seconds charts (0 for D/W/M charts); ``_get_confirmed_time``
311
+ # derives the chart bar's close instant from it for HTF confirmation.
312
+ # ``sec_grid_args`` are the security's own (tz, session_starts,
313
+ # opening_hours, mode) for the past-end-of-data fallback grid.
314
+ bar_opens: list[int] | None = None
315
+ # Session-bounded intraday HTF only: the scheduled session-end instant (ms)
316
+ # of each ``bar_opens`` entry, derived from ``opening_hours``.
317
+ # ``_get_confirmed_time`` then confirms such a bar on its session end
318
+ # (calendar-known) instead of the arithmetic next-period boundary, which a
319
+ # non-trading gap before the next session would push a full period late.
320
+ # ``None`` for D/W/M, sessionless and dense feeds (they keep the grid clamp).
321
+ bar_closes: list[int] | None = None
322
+ bar_opens_multiperiod: bool = False
323
+ bar_ptr: int = -1
324
+ chart_off: int = 0
325
+ sec_grid_args: tuple | None = None
326
+
327
+ # LTF window on a daily/weekly/monthly CHART (chart-side, file-backed). A
328
+ # single-period civil D/W/M chart bar has no fixed arithmetic span
329
+ # (``chart_off == 0``), so the Phase 1 ``chart_time + chart_off`` target
330
+ # degrades to the bar open and the child would re-collect the *previous*
331
+ # period. When the chart is a single-period civil D/W/M timeframe,
332
+ # ``chart_resampler`` (with ``chart_dwm_modifier`` 'D'/'W'/'M') is set for
333
+ # every single-period D/W/M chart so ``__sec_signal__`` can target the chart
334
+ # bar's civil period end (``_next_civil_period_open`` minus one ms) — the
335
+ # bar's OWN period ``[T, next_civil_open)``. Both stay ``None``/``''`` for
336
+ # intraday charts (the ``chart_off`` fast path) and for multi-period D/W/M
337
+ # charts (excluded at setup). Session-anchored D/W/M charts DO get these set,
338
+ # but the per-bar civil-anchored guard in ``__sec_signal__``
339
+ # (``get_bar_time(chart_time) == chart_time``) falls back to ``chart_off``
340
+ # for a bar that does not open on the civil boundary — a correct window there
341
+ # needs the chart's real bar opens, not a civil-calendar guess, and no
342
+ # TradingView ground truth exists for it (documented limitation).
343
+ chart_resampler: Resampler | None = None
344
+ chart_dwm_modifier: str = ''
345
+
346
+ # Tracking (chart-side only)
347
+ last_confirmed: int = 0
348
+ needs_wait: bool = False
349
+ new_period: bool = False
350
+
351
+ # Set by ``__sec_signal__`` when an LTF chart bar precedes the feed (see
352
+ # ``ltf_first_ms``): no handshake ran this bar, so ``__sec_read__`` returns
353
+ # the empty-array default directly instead of waiting on shared memory.
354
+ ltf_skip: bool = False
355
+
356
+
357
+ def _get_confirmed_time(state: SecurityState, chart_time: int) -> int:
358
+ """
359
+ Determine which security period the subprocess should advance to.
360
+
361
+ Same timeframe: a chart bar and its same-time security bar close at the same
362
+ instant, so the target is the chart bar's own open — clamped to the latest
363
+ real child bar open when ``bar_opens`` is loaded (gappy cross-symbol feed,
364
+ e.g. a session-bounded bond yield on a 24/7 crypto chart). Between real bars
365
+ nothing new is confirmed, so ``gaps_off`` forward-fills the last real value
366
+ and ``gaps_on`` emits ``na`` — TradingView never evaluates the expression on
367
+ the writer's synthetic gap-fill bars.
368
+
369
+ Daily/weekly/monthly HTF (when ``bar_opens`` is loaded — see
370
+ ``SecurityState.bar_opens``): confirmation rides the child's real bar opens
371
+ so a sparse child (scattered macro days) forward-fills its last value
372
+ instead of confirming phantom calendar periods. Multi-period walks the opens
373
+ directly; single-period clamps the arithmetic grid's calendar-close target
374
+ to the latest real open. Falls back to the bare arithmetic grid when
375
+ ``bar_opens`` is ``None`` (intraday HTF, live streams, unit tests).
376
+
377
+ HTF:
378
+ * ``OFF`` / ``LAST_CLOSED``: target is the most recent HTF period that has
379
+ CLOSED by the current chart bar's close instant. TradingView's historical
380
+ ``lookahead_off`` merge rule: an HTF bar's final value is carried already
381
+ by the chart bar whose close coincides with the HTF bar's close (the
382
+ period's last chart bar), not by the next period's first bar.
383
+ * ``ON`` (same-symbol HTF, an aggregator exists): target is the CONTAINING
384
+ period's opening time — the subprocess steps into the developing HTF bar.
385
+ Live mode supplies developing OHLCV via the SyncBlock; historical/backtest
386
+ mode reads the containing period's already-complete bar straight from the
387
+ child's data file (TV's lookahead_on future-leak for a bare ``close``, the
388
+ just-closed prior period for an inner ``close[1]``). Cross-symbol HTF has
389
+ no aggregator and keeps OFF (last-closed) semantics.
390
+
391
+ :param state: Security context state
392
+ :param chart_time: Current chart bar time in milliseconds
393
+ :return: Target time in milliseconds
394
+ """
395
+ if state.same_timeframe:
396
+ if state.bar_opens is None:
397
+ return chart_time
398
+ # Gappy same-TF cross-symbol feed: clamp to the latest real child bar
399
+ # open. ``chart_time`` and ``bar_opens`` are both monotonic across chart
400
+ # bars, so the persistent ``bar_ptr`` only ever advances. ``ptr == -1``
401
+ # means the chart still precedes the first real security bar — return
402
+ # ``last_confirmed`` (0) so nothing is confirmed yet and the read stays
403
+ # ``na``, matching TradingView before the security series begins.
404
+ opens = state.bar_opens
405
+ n = len(opens)
406
+ ptr = state.bar_ptr
407
+ while ptr + 1 < n and opens[ptr + 1] <= chart_time:
408
+ ptr += 1
409
+ state.bar_ptr = ptr
410
+ if ptr >= 0:
411
+ return opens[ptr]
412
+ return state.last_confirmed
413
+
414
+ resampler = state.resampler
415
+ assert resampler is not None
416
+
417
+ # The chart bar's close instant. ``chart_off`` is span-1 for intraday and
418
+ # seconds charts; D/W/M chart bars have no fixed arithmetic span
419
+ # (``chart_off == 0``), so confirmation degrades to the bar's open instant.
420
+ close_time = chart_time + state.chart_off + 1
421
+
422
+ if state.bar_opens is not None and state.bar_opens_multiperiod:
423
+ # Multi-period walk: advance the pointer to the child bar the chart bar's
424
+ # close instant falls in; entering bar ``ptr`` closes every bar before it
425
+ opens = state.bar_opens
426
+ ptr = state.bar_ptr
427
+ n = len(opens)
428
+ advanced = False
429
+ while ptr + 1 < n and opens[ptr + 1] <= close_time:
430
+ ptr += 1
431
+ advanced = True
432
+ state.bar_ptr = ptr
433
+ if advanced and ptr >= 1:
434
+ return opens[ptr - 1]
435
+ if n and ptr == n - 1 and state.sec_grid_args is not None:
436
+ # The chart marched past the child's last bar: no child bar
437
+ # realizes the next period, so the arithmetic grid decides when
438
+ # the last bar is closed
439
+ sec_tz, ss, oh, mode = state.sec_grid_args
440
+ if resampler.get_bar_time(close_time, sec_tz, ss, oh, mode) > opens[-1]:
441
+ return opens[-1]
442
+ return state.last_confirmed
443
+
444
+ if (state.lookahead is Lookahead.ON
445
+ and state.htf_aggregator is not None):
446
+ # ``lookahead_on`` on a same-symbol HTF (an aggregator exists): step into
447
+ # the containing period rather than the last-closed one — TV faithful.
448
+ # In backtest/historical mode the containing period's bar is already
449
+ # complete in the child's data file, so the child reads its final OHLCV:
450
+ # a bare ``close`` reproduces TV's classical lookahead_on future-leak,
451
+ # while an ``<expr>[1]`` inside the security (the daily-pivot idiom
452
+ # ``security(sym, "D", close[1], lookahead_on)``) reads the just-closed
453
+ # prior period — the whole point of the idiom. ``lookahead_last_closed``
454
+ # remains the repaint-free alternative. Cross-symbol HTF has no
455
+ # aggregator (chart OHLCV is the wrong instrument), so it keeps
456
+ # closed-bar semantics and the chart-side read returns ``na`` while a
457
+ # period is open (``na_on_developing``). ``chart_time`` (not
458
+ # ``close_time``) selects the containing period so the last chart bar of
459
+ # a period still maps to that period, not the next.
460
+ if state.session_starts is not None:
461
+ # Off-grid intraday session → anchor HTF bars to the session open,
462
+ # using the security's own exchange timezone.
463
+ return resampler.get_bar_time(
464
+ chart_time, state.session_tz, state.session_starts)
465
+ return resampler.get_bar_time(chart_time, state.tz)
466
+
467
+ # OFF / LAST_CLOSED (and the historical ON fallback): the period preceding
468
+ # the one ``close_time`` falls in. When the chart bar's close lands exactly
469
+ # on a period boundary, ``get_bar_time`` floors it to that boundary and the
470
+ # period ending there is returned — the just-closed HTF bar is confirmed on
471
+ # its own last chart bar.
472
+ if state.session_starts is not None:
473
+ # Off-grid intraday session → anchor HTF bars to the session open,
474
+ # using the security's own exchange timezone.
475
+ period = resampler.get_bar_time(
476
+ close_time, state.session_tz, state.session_starts)
477
+ grid_target = resampler.get_bar_time(
478
+ period - 1, state.session_tz, state.session_starts)
479
+ else:
480
+ period = resampler.get_bar_time(close_time, state.tz)
481
+ grid_target = resampler.get_bar_time(period - 1, state.tz)
482
+
483
+ if state.bar_opens is None:
484
+ return grid_target
485
+
486
+ opens = state.bar_opens
487
+ n = len(opens)
488
+ ptr = state.bar_ptr
489
+
490
+ if state.bar_closes is not None:
491
+ # Session-bounded intraday HTF: each real bar closes at its session's
492
+ # scheduled end (calendar-known via ``opening_hours``), NOT at the
493
+ # arithmetic next-period boundary — a non-trading gap before the next
494
+ # session (e.g. the dead time between a futures day and night session)
495
+ # would otherwise delay confirmation by a full period. TradingView
496
+ # ``lookahead_off`` confirms the bar on its own last chart bar, whose
497
+ # close coincides with the session end. Confirm the latest real bar whose
498
+ # session end the chart bar's close (``close_time``) has reached.
499
+ # ``close_time`` and ``bar_closes`` are both monotonic across chart bars,
500
+ # so the persistent ``bar_ptr`` only ever advances.
501
+ closes = state.bar_closes
502
+ while ptr + 1 < n and closes[ptr + 1] <= close_time:
503
+ ptr += 1
504
+ state.bar_ptr = ptr
505
+ if ptr >= 0 and closes[ptr] <= close_time:
506
+ return opens[ptr]
507
+ return state.last_confirmed
508
+
509
+ # Single-period D/W/M with a loaded child: the grid above gives the correct
510
+ # calendar close instant, but the child may carry a bar only on scattered
511
+ # days (sparse macro series). Clamp the grid target to the latest real child
512
+ # open so the subprocess never advances into an empty window (which would
513
+ # ``write_na`` and wipe the forward-fill). Dense data has a bar on every
514
+ # period, so the clamp is a no-op and behaviour is identical to the bare
515
+ # grid; sparse data holds its last real value, matching TV ``gaps_off``.
516
+ # ``grid_target`` is monotonically non-decreasing across chart bars (it is
517
+ # ``get_bar_time(period - 1)`` of a monotonically increasing ``close_time``),
518
+ # so the persistent ``bar_ptr`` only ever advances. ``last_confirmed`` is a
519
+ # timestamp (0 = before any bar), returned while the chart precedes the first
520
+ # real open so nothing is confirmed yet.
521
+ while ptr + 1 < n and opens[ptr + 1] <= grid_target:
522
+ ptr += 1
523
+ state.bar_ptr = ptr
524
+ if ptr >= 0 and opens[ptr] <= grid_target:
525
+ return opens[ptr]
526
+ return state.last_confirmed
527
+
528
+
529
+ def _next_civil_period_open(modifier: str, current_ms: int, tz: ZoneInfo) -> int:
530
+ """
531
+ Open time (ms) of the civil period immediately following the one that
532
+ contains ``current_ms``, for a single-period daily/weekly/monthly chart.
533
+
534
+ The next local calendar boundary (next day's midnight, next Monday, or the
535
+ first of next month) is constructed *directly* in ``tz`` and then converted
536
+ back to epoch ms — never by adding a fixed 24h / 7d / nominal-month delta —
537
+ so the result is correct across DST transitions and variable month lengths.
538
+ Used to window LTF intrabars into a D/W/M chart bar's own period
539
+ ``[T, next_open)`` (the caller targets ``next_open - 1``).
540
+
541
+ :param modifier: Chart timeframe modifier, one of ``'D'``, ``'W'``, ``'M'``.
542
+ :param current_ms: The chart bar's open time in milliseconds.
543
+ :param tz: The chart's timezone (defines where the civil boundary falls).
544
+ :return: The next civil period's open time in milliseconds.
545
+ """
546
+ cur = datetime.fromtimestamp(current_ms / 1000, tz)
547
+ if modifier == 'D':
548
+ nd = cur.date() + timedelta(days=1)
549
+ nxt = datetime(nd.year, nd.month, nd.day, tzinfo=tz)
550
+ elif modifier == 'W':
551
+ # Anchor to the bar's Monday, then step a full week — robust even if the
552
+ # bar open is not exactly the Monday boundary.
553
+ monday = cur.date() - timedelta(days=cur.weekday())
554
+ nd = monday + timedelta(days=7)
555
+ nxt = datetime(nd.year, nd.month, nd.day, tzinfo=tz)
556
+ else: # 'M'
557
+ year, month = (cur.year + 1, 1) if cur.month == 12 else (cur.year, cur.month + 1)
558
+ nxt = datetime(year, month, 1, tzinfo=tz)
559
+ return int(nxt.timestamp()) * 1000
560
+
561
+
562
+ def create_chart_protocol(
563
+ states: dict[str, SecurityState],
564
+ sync_block: SyncBlock,
565
+ deferred_resolve_fn: 'Callable[[str, str, str | None], None] | None' = None,
566
+ lazy_spawn_fn: 'Callable[[str], None] | None' = None,
567
+ same_context_ids: 'set[str] | frozenset[str]' = frozenset(),
568
+ no_process_ids: 'set[str] | frozenset[str]' = frozenset(),
569
+ result_blocks: dict[str, ResultBlock] | None = None,
570
+ currency_conversions: dict[str, tuple[str, str]] | None = None,
571
+ sec_processes: 'dict[str, BaseProcess] | None' = None,
572
+ auto_rate_sec_ids: frozenset[str] = frozenset(),
573
+ failed_children: 'set[str] | None' = None,
574
+ ) -> tuple:
575
+ """
576
+ Create protocol functions for the **chart** process.
577
+
578
+ :param states: Per-security-context runtime states
579
+ :param sync_block: Shared memory sync block
580
+ :param deferred_resolve_fn: Optional callback for resolving deferred security contexts.
581
+ Called with (sec_id, symbol, timeframe) on first __sec_signal__.
582
+ :param lazy_spawn_fn: Optional callback for lazy-spawning static security processes.
583
+ Called with sec_id on first __sec_signal__ for static contexts.
584
+ :param same_context_ids: Security IDs that share the chart's symbol+timeframe.
585
+ These are handled directly by the chart (no separate process).
586
+ :param no_process_ids: Security IDs that have no process (same-context + ignored).
587
+ Signal/wait are skipped for these.
588
+ :param result_blocks: Result blocks for writing same-context values to shared memory.
589
+ :param currency_conversions: Maps sec_id → (from_currency, to_currency) for auto-conversion.
590
+ :param sec_processes: Live ``sec_id → Process`` map. Captured by reference, so
591
+ entries added by lazy/deferred spawn become visible to the
592
+ read/wait protocol functions. When provided, blocked waits
593
+ poll ``proc.is_alive()`` and raise instead of deadlocking
594
+ if a child dies.
595
+ :param auto_rate_sec_ids: Hidden ``__auto_rate_*`` sec_ids driving currency
596
+ rate sources. No Pine call signals them, so the
597
+ chart loop must call ``signal_rate_sources()``
598
+ once per bar to advance their subprocess and
599
+ refresh the ResultBlock the
600
+ :class:`CurrencyRateProvider` reads from.
601
+ :param failed_children: Shared registry filled by
602
+ :func:`watch_security_child` when a child dies
603
+ abnormally. Enables the cheap UNTIMED waits; when
604
+ None the waits fall back to liveness polling.
605
+ :return: (sec_signal, sec_write, sec_read, sec_wait, cleanup,
606
+ signal_rate_sources)
607
+ """
608
+ readers: dict[str, ResultReader] = {
609
+ sid: ResultReader(sid) for sid in states
610
+ }
611
+
612
+ resolved: set[str] = set()
613
+
614
+ def __sec_signal__(sec_id: str, symbol: str | None = None,
615
+ timeframe: str | None = None, lookahead=None,
616
+ _scope_id=None):
617
+ from pynecore import lib
618
+ state = states[sec_id]
619
+
620
+ # Resolve deferred symbol/timeframe on first call. The two callbacks are
621
+ # NOT alternatives: in a script with both deferred and static contexts the
622
+ # deferred resolver no-ops for a static sec_id (and the runtime symbol
623
+ # argument is always present), so an elif here would leave every static
624
+ # context's subprocess unspawned and its first real read deadlocked.
625
+ # ``lazy_spawn_fn`` itself skips sids that already have a process.
626
+ if sec_id not in resolved:
627
+ resolved.add(sec_id)
628
+ if lookahead is not None:
629
+ # Input-derived (Pine "simple") lookahead: the transformer stored
630
+ # None in __security_contexts__ and passes the actual value here.
631
+ # Resolve the mode BEFORE the deferred symbol/timeframe callback,
632
+ # which recomputes ``na_on_developing`` from ``state.lookahead``.
633
+ state.lookahead = _lookahead_mode(lookahead)
634
+ # Static symbol/timeframe with deferred lookahead: mirror the
635
+ # setup-time cross-symbol decision (no aggregator ⇒ cross-symbol
636
+ # HTF). A deferred symbol/timeframe context is recomputed by the
637
+ # resolver below instead.
638
+ state.na_on_developing = (
639
+ not state.is_ltf and not state.plain_ltf
640
+ and not state.same_timeframe
641
+ and state.htf_aggregator is None
642
+ and state.lookahead is Lookahead.ON
643
+ )
644
+ if deferred_resolve_fn is not None and symbol is not None:
645
+ deferred_resolve_fn(sec_id, symbol, timeframe)
646
+ if lazy_spawn_fn is not None:
647
+ lazy_spawn_fn(sec_id)
648
+
649
+ # No-process contexts (same-context, ignored): skip advance/wait
650
+ if sec_id in no_process_ids:
651
+ if sec_id in same_context_ids:
652
+ state.new_period = True
653
+ state.data_ready.clear()
654
+ return
655
+
656
+ # noinspection PyProtectedMember
657
+ chart_time = lib._time
658
+
659
+ if state.plain_ltf:
660
+ # Plain (scalar) request.security with a timeframe FINER than the
661
+ # chart's. TradingView merge rule: ``lookahead_off`` returns the
662
+ # expression value on the LAST intrabar of the chart bar's own
663
+ # period, ``lookahead_on`` on the FIRST. The child's historical
664
+ # loop runs every feed bar with ``bar_open <= target`` and the
665
+ # last per-intrabar write wins, so the target IS the merge rule:
666
+ # the bar's period end for OFF, the bar's open for ON.
667
+ if state.lookahead is Lookahead.ON:
668
+ target_time = chart_time
669
+ elif (state.chart_dwm_modifier and state.chart_resampler is not None
670
+ and state.chart_resampler.get_bar_time(chart_time, state.tz)
671
+ == chart_time):
672
+ # Single-period civil D/W/M chart bar: no fixed arithmetic
673
+ # span (``chart_off`` is 0) — target the civil period end.
674
+ target_time = _next_civil_period_open(
675
+ state.chart_dwm_modifier, chart_time, state.tz) - 1
676
+ else:
677
+ target_time = chart_time + state.chart_off
678
+ # A developing live chart bar's period end lies in the future —
679
+ # clamp to the last surely-closed intrabar so the child never
680
+ # blocks waiting for intrabars that have not closed yet.
681
+ if state.is_live and not lib.barstate.isconfirmed and state.plain_ltf_span_ms:
682
+ now_ms = int(datetime.now().timestamp() * 1000)
683
+ elapsed_close = (now_ms // state.plain_ltf_span_ms
684
+ ) * state.plain_ltf_span_ms - 1
685
+ if elapsed_close < target_time:
686
+ target_time = elapsed_close
687
+ # Prefix skip: a chart bar whose whole period ends before the LTF
688
+ # feed's first bar cannot contain an intrabar — TradingView
689
+ # returns ``na`` before the LTF series begins.
690
+ if state.ltf_first_ms is not None and target_time < state.ltf_first_ms:
691
+ state.ltf_skip = True
692
+ state.new_period = True
693
+ state.needs_wait = False
694
+ return
695
+ state.ltf_skip = False
696
+ state.new_period = True
697
+ state.data_ready.clear()
698
+ sync_block.set_target_time(sec_id, target_time)
699
+ state.advance_event.set()
700
+ state.needs_wait = True
701
+ return
702
+
703
+ if state.is_ltf:
704
+ # Live streaming LTF (PluginSymbol source): the chart bar may be
705
+ # developing, so read-ahead is impossible. The subprocess pulls
706
+ # intrabars from its own LTF streamer and builds the chart period's
707
+ # window (closed intrabars + the developing intrabar as the live last
708
+ # element). The parent ships only the period bounds and whether the
709
+ # chart bar is still developing; warmup replay (confirmed chart bars)
710
+ # flows through the same path and yields full closed periods.
711
+ if state.ltf_live_stream:
712
+ period_start = chart_time
713
+ if (state.chart_dwm_modifier and state.chart_resampler is not None
714
+ and state.chart_resampler.get_bar_time(chart_time, state.tz)
715
+ == chart_time):
716
+ period_end_exclusive = _next_civil_period_open(
717
+ state.chart_dwm_modifier, chart_time, state.tz)
718
+ else:
719
+ period_end_exclusive = chart_time + state.chart_off + 1
720
+ ltf_flags = sync_block.get_flags(sec_id) & ~(
721
+ FLAG_IS_DEVELOPING | FLAG_CLOSED_OVERRIDE
722
+ )
723
+ ltf_flags |= FLAG_LTF_WINDOW
724
+ if lib.barstate.isconfirmed:
725
+ ltf_flags &= ~FLAG_LTF_CHART_DEVELOPING
726
+ else:
727
+ ltf_flags |= FLAG_LTF_CHART_DEVELOPING
728
+ if state.is_live:
729
+ ltf_flags |= FLAG_LTF_LIVE_PHASE
730
+ else:
731
+ ltf_flags &= ~FLAG_LTF_LIVE_PHASE
732
+ sync_block.set_flags(sec_id, ltf_flags)
733
+ sync_block.set_target_time(sec_id, period_start)
734
+ sync_block.set_ltf_period_end(sec_id, period_end_exclusive)
735
+ state.ltf_skip = False
736
+ state.new_period = True
737
+ state.data_ready.clear()
738
+ state.advance_event.set()
739
+ state.needs_wait = True
740
+ return
741
+
742
+ # Historical/file-backed LTF: the child includes intrabars with
743
+ # ``bar_open <= target_time``. Target the chart bar's last ms so the
744
+ # child returns the bar's OWN period — matching TradingView. Adjacent
745
+ # bars tile with no gap or overlap (the prior bar targeted that ms
746
+ # minus one). An empty static feed (no streamer) has no intrabars to
747
+ # window, so keep the legacy chart-open target there.
748
+ if state.ltf_first_ms is None:
749
+ ltf_target_time = chart_time
750
+ elif (state.chart_dwm_modifier and state.chart_resampler is not None
751
+ and state.chart_resampler.get_bar_time(chart_time, state.tz)
752
+ == chart_time):
753
+ # Single-period civil D/W/M chart: ``chart_off`` is 0, so the
754
+ # period end is the next civil open minus one ms (not a fixed
755
+ # span). The civil-anchored guard above means we only do this
756
+ # when the chart bar actually opens on the civil boundary; an
757
+ # off-grid (session-anchored) D/W/M bar falls through to the
758
+ # ``chart_off`` path, keeping the documented limitation rather
759
+ # than mis-windowing on a civil-calendar guess.
760
+ ltf_target_time = _next_civil_period_open(
761
+ state.chart_dwm_modifier, chart_time, state.tz) - 1
762
+ else:
763
+ # Intraday/seconds chart: ``chart_off`` == span-1 gives the
764
+ # bar's own period ``[T, T+tf)``.
765
+ ltf_target_time = chart_time + state.chart_off
766
+ # Prefix skip: a chart bar whose whole period ends before the LTF
767
+ # feed's first bar (``target_time < ltf_first_ms``) cannot contain an
768
+ # intrabar, so the read is an empty array. Skip the cross-process
769
+ # signal+wait entirely — ``__sec_read__`` returns the empty-array
770
+ # default. Disabled (``ltf_first_ms is None``) for live streams,
771
+ # which keep signalling every bar.
772
+ if state.ltf_first_ms is not None and ltf_target_time < state.ltf_first_ms:
773
+ state.ltf_skip = True
774
+ state.new_period = True
775
+ state.needs_wait = False
776
+ return
777
+ state.ltf_skip = False
778
+ # LTF: every chart bar needs intrabar data — always signal. The
779
+ # period start bounds the flushed array to the bar's OWN intrabars:
780
+ # the child still replays any earlier feed bars for expression
781
+ # state, but their values are prefix, not array content.
782
+ state.new_period = True
783
+ state.data_ready.clear()
784
+ sync_block.set_ltf_period_start(sec_id, chart_time)
785
+ sync_block.set_target_time(sec_id, ltf_target_time)
786
+ state.advance_event.set()
787
+ state.needs_wait = True
788
+ return
789
+
790
+ # Live HTF transport — the chart aggregates its own OHLCV into the
791
+ # containing HTF bar via ``HTFAggregator`` and ships it to the
792
+ # subprocess on the SyncBlock. The static ``.ohlcv`` file cannot
793
+ # grow at runtime, so this transport is the *only* way for any
794
+ # lookahead mode to advance an HTF security context live.
795
+ #
796
+ # Phase 1 (closed-bar override): every HTF period close pushes the
797
+ # newly closed OHLCV to the subprocess synchronously. All lookahead
798
+ # modes use this phase — it is the live equivalent of reading the
799
+ # next ``.ohlcv`` bar in historical mode.
800
+ #
801
+ # Phase 2 (developing bar): only ``Lookahead.ON`` exposes the
802
+ # in-progress HTF bar with ``barstate.isconfirmed=False``; OFF and
803
+ # LAST_CLOSED stay repaint-free and skip this phase.
804
+ #
805
+ # Seed the aggregator on EVERY chart bar (warmup included). If we
806
+ # only fed it once ``is_live`` flipped, a live transition that
807
+ # happens mid-HTF-period would lose all warmup bars belonging to
808
+ # the in-progress period, and the first developing/closed override
809
+ # emitted live would carry partial OHLCV (open/high/low/volume
810
+ # missing the prior chart bars).
811
+ if state.htf_aggregator is not None:
812
+ chart_open = float(lib.open)
813
+ chart_high = float(lib.high)
814
+ chart_low = float(lib.low)
815
+ chart_close = float(lib.close)
816
+ raw_vol = lib.volume
817
+ chart_volume = 0.0 if raw_vol is None else float(raw_vol)
818
+
819
+ _, dev_bar, closed_bar = state.htf_aggregator.update(
820
+ chart_time, chart_open, chart_high, chart_low,
821
+ chart_close, chart_volume,
822
+ chart_confirmed=bool(lib.barstate.isconfirmed),
823
+ )
824
+
825
+ if state.is_live:
826
+ # Phase 1: synchronously deliver any just-closed HTF bar
827
+ if closed_bar is not None:
828
+ sync_block.set_developing_bar(
829
+ sec_id,
830
+ closed_bar.open, closed_bar.high, closed_bar.low,
831
+ closed_bar.close, closed_bar.volume,
832
+ closed_bar.period_start,
833
+ )
834
+ base_flags = (
835
+ sync_block.get_flags(sec_id) & ~FLAG_IS_DEVELOPING
836
+ ) | FLAG_CLOSED_OVERRIDE
837
+ sync_block.set_flags(sec_id, base_flags)
838
+ sync_block.set_target_time(sec_id, closed_bar.period_start)
839
+ state.last_confirmed = closed_bar.period_start
840
+ state.data_ready.clear()
841
+ state.advance_event.set()
842
+ # Block until the subprocess finishes processing the closed
843
+ # bar (writes result, saves var_snapshot). For ON, this also
844
+ # ensures the developing-bar phase below cannot race ahead
845
+ # of the closed phase.
846
+ _wait_with_liveness(state.done_event, sec_id, sec_processes, failed_children)
847
+ state.done_event.clear()
848
+
849
+ # Phase 2: developing bar — only for ``Lookahead.ON``.
850
+ # ``dev_bar`` is None when the confirmed chart bar just
851
+ # completed the period (Phase 1 delivered it); no fresh
852
+ # developing bar exists until the next chart bar.
853
+ if state.lookahead is Lookahead.ON and dev_bar is not None:
854
+ sync_block.set_developing_bar(
855
+ sec_id,
856
+ dev_bar.open, dev_bar.high, dev_bar.low,
857
+ dev_bar.close, dev_bar.volume, dev_bar.period_start,
858
+ )
859
+ base_flags = (
860
+ sync_block.get_flags(sec_id) & ~FLAG_CLOSED_OVERRIDE
861
+ ) | FLAG_IS_DEVELOPING
862
+ sync_block.set_flags(sec_id, base_flags)
863
+ sync_block.set_target_time(sec_id, dev_bar.period_start)
864
+ state.new_period = True
865
+ state.data_ready.clear()
866
+ state.advance_event.set()
867
+ state.needs_wait = True
868
+ return
869
+
870
+ # OFF / LAST_CLOSED in live mode: closed-bar transport only.
871
+ # ``new_period`` reflects whether a fresh HTF close just landed
872
+ # (drives ``gaps_on`` na/value selection in ``__sec_read__``).
873
+ # We already waited synchronously inside Phase 1, so no further
874
+ # wait is needed in ``__sec_wait__``.
875
+ state.new_period = closed_bar is not None
876
+ state.needs_wait = False
877
+ # Clear any stale developing flag from a prior ``Lookahead.ON``
878
+ # session (defensive — same SyncBlock slot).
879
+ if closed_bar is None:
880
+ stale_flags = sync_block.get_flags(sec_id) & ~(
881
+ FLAG_IS_DEVELOPING | FLAG_CLOSED_OVERRIDE
882
+ )
883
+ sync_block.set_flags(sec_id, stale_flags)
884
+ return
885
+ # Warmup with an HTF aggregator falls through to the closed-only
886
+ # flow below; the aggregator state has already advanced so the
887
+ # live transition starts with the correct in-progress HTF bar.
888
+
889
+ # Closed-only flow (historical / lookahead_off / lookahead_last_closed)
890
+ target_time = _get_confirmed_time(state, chart_time)
891
+
892
+ if target_time > state.last_confirmed:
893
+ state.last_confirmed = target_time
894
+ state.new_period = True
895
+ state.data_ready.clear()
896
+ # Make sure no stale developing/override flag leaks across modes.
897
+ stale_flags = sync_block.get_flags(sec_id) & ~(
898
+ FLAG_IS_DEVELOPING | FLAG_CLOSED_OVERRIDE
899
+ )
900
+ sync_block.set_flags(sec_id, stale_flags)
901
+ sync_block.set_target_time(sec_id, target_time)
902
+ state.advance_event.set()
903
+ state.needs_wait = True
904
+ else:
905
+ state.new_period = False
906
+
907
+ def __sec_write__(sec_id: str, value, _scope_id=None):
908
+ if sec_id in same_context_ids and result_blocks is not None:
909
+ with states[sec_id].result_lock:
910
+ write_result(result_blocks[sec_id], sync_block, value)
911
+ states[sec_id].data_ready.set()
912
+
913
+ def __sec_read__(sec_id: str, default=None, _scope_id=None):
914
+ # ``ignore_invalid_symbol=True`` may downgrade a live security to
915
+ # ``no-process`` after syminfo prefetch fails — no subprocess is
916
+ # ever spawned, so ``data_ready`` would never be set and a plain
917
+ # ``_wait_with_liveness`` would deadlock here. Short-circuit to
918
+ # ``default`` (Pine ``na``) so the script keeps running.
919
+ if sec_id in no_process_ids and sec_id not in same_context_ids:
920
+ return default
921
+ state = states[sec_id]
922
+ if state.ltf_skip:
923
+ # LTF chart bar before the feed began: ``__sec_signal__`` skipped the
924
+ # handshake, so the result is the empty-array default (identical to
925
+ # the empty-buffer flush an unskipped bar would have produced). The
926
+ # flag is current-bar-fresh: SecurityTransformer emits every
927
+ # context's ``__sec_signal__`` at ``main()``'s start, ahead of any
928
+ # ``__sec_read__``, so each read observes this bar's flag — the same
929
+ # signal-before-read invariant ``new_period``/``needs_wait`` rely on.
930
+ return default
931
+ _wait_with_liveness(state.data_ready, sec_id, sec_processes, failed_children)
932
+
933
+ if not state.is_ltf and not state.new_period:
934
+ # gaps_on emits ``na`` between HTF closes (Pine semantics).
935
+ # na_on_developing emits ``na`` while inside an open cross-symbol
936
+ # HTF period when lookahead_on is requested (developing bar cannot
937
+ # be aggregated). Both share the same shape: ``na`` whenever the
938
+ # chart bar is not opening a fresh HTF period.
939
+ if state.gaps_on or state.na_on_developing:
940
+ return default
941
+
942
+ with state.result_lock:
943
+ result = readers[sec_id].read(sync_block, default)
944
+
945
+ if currency_conversions and sec_id in currency_conversions and result is not default:
946
+ from ..lib import request
947
+ from math import isnan
948
+ from_cur, to_cur = currency_conversions[sec_id]
949
+ rate = request.currency_rate(from_cur, to_cur)
950
+ if not isnan(rate):
951
+ if isinstance(result, (int, float)):
952
+ result = result * rate
953
+ elif isinstance(result, tuple):
954
+ result = tuple(
955
+ v * rate if isinstance(v, (int, float)) else v for v in result
956
+ )
957
+
958
+ return result
959
+
960
+ def __sec_wait__(sec_id: str, _scope_id=None):
961
+ state = states[sec_id]
962
+ if state.needs_wait:
963
+ _wait_with_liveness(state.done_event, sec_id, sec_processes, failed_children)
964
+ state.done_event.clear()
965
+ state.needs_wait = False
966
+
967
+ def cleanup():
968
+ for r in readers.values():
969
+ r.close()
970
+
971
+ def signal_rate_sources():
972
+ """Advance every auto-spawned rate-source subprocess by one bar.
973
+
974
+ No Pine call drives ``__auto_rate_*`` sec_ids (they are synthetic
975
+ contexts created by ``_autospawn_rate_sources``), so the chart loop
976
+ is the only place that can tick them forward. Each rate-source
977
+ subprocess runs the lightweight close-only loop in
978
+ ``security_process._run_rate_source_loop``: advance → drain
979
+ newly-closed bars → write the latest close to its ResultBlock → set
980
+ data_ready. We wait synchronously for data_ready so the rate value
981
+ :meth:`CurrencyRateProvider._lookup_sec` reads later in the same
982
+ chart bar reflects the bars closed up to ``chart_time``.
983
+ """
984
+ if not auto_rate_sec_ids:
985
+ return
986
+ from pynecore import lib
987
+ # noinspection PyProtectedMember
988
+ chart_time = lib._time
989
+ for sec_id in auto_rate_sec_ids:
990
+ if sec_id in no_process_ids or sec_id not in states:
991
+ continue
992
+ state = states[sec_id]
993
+ sync_block.set_target_time(sec_id, chart_time)
994
+ state.data_ready.clear()
995
+ state.advance_event.set()
996
+ _wait_with_liveness(state.data_ready, sec_id, sec_processes, failed_children)
997
+
998
+ return (
999
+ __sec_signal__, __sec_write__, __sec_read__, __sec_wait__,
1000
+ cleanup, signal_rate_sources,
1001
+ )
1002
+
1003
+
1004
+ def __ltf_unzip__(rows, n):
1005
+ """Transpose a row-major LTF tuple buffer into Pine's column-major arrays.
1006
+
1007
+ ``request.security_lower_tf(sym, tf, (e0, ..., e{n-1}))`` returns a tuple of
1008
+ ``n`` arrays, where array ``i`` holds the per-intrabar values of ``e_i``. The
1009
+ LTF subprocess accumulates one ``(e0, ..., e{n-1})`` tuple per intrabar, so the
1010
+ raw result is row-major (a list of ``n``-tuples). This transposes it into the
1011
+ ``n`` column arrays the tuple-unpack expects, returning ``n`` empty arrays when
1012
+ the chart bar has no intrabars (e.g. the lower-timeframe feed does not reach
1013
+ that period).
1014
+
1015
+ Inserted by ``SecurityTransformer`` only for tuple-valued
1016
+ ``request.security_lower_tf()`` calls; scalar calls read the array directly.
1017
+
1018
+ :param rows: Per-intrabar value tuples (possibly empty when there are no
1019
+ intrabars).
1020
+ :param n: Tuple arity (number of expression elements).
1021
+ :return: Tuple of ``n`` lists, column-major.
1022
+ """
1023
+ if not rows:
1024
+ return tuple([] for _ in range(n))
1025
+ return tuple(list(col) for col in zip(*rows))
1026
+
1027
+
1028
+ def create_security_protocol(
1029
+ sec_id: str,
1030
+ sync_block: SyncBlock,
1031
+ result_block: ResultBlock,
1032
+ all_sec_ids: list[str],
1033
+ result_locks: 'dict[str, LockType]',
1034
+ is_ltf: bool = False,
1035
+ ) -> tuple:
1036
+ """
1037
+ Create protocol functions for a **security** process.
1038
+
1039
+ In security context, __sec_signal__ and __sec_wait__ are no-ops (guarded by
1040
+ AST ``if __active_security__ is None`` checks). __sec_write__ writes to shared
1041
+ memory. __sec_read__ reads immediately without waiting (no deadlock).
1042
+
1043
+ When ``is_ltf=True``, __sec_write__ appends to an internal buffer instead of
1044
+ writing to shared memory. The caller must invoke ``flush()`` at the end of
1045
+ each round to write the accumulated array.
1046
+
1047
+ :param sec_id: This security context's ID (the only slot it writes to).
1048
+ :param sync_block: Shared memory sync block
1049
+ :param result_block: Shared memory result block for writing
1050
+ :param all_sec_ids: All security context IDs (for cross-context reads)
1051
+ :param result_locks: Per-slot ``multiprocessing.Lock`` keyed by sec_id.
1052
+ Writers acquire ``result_locks[sec_id]``; cross-context
1053
+ readers acquire ``result_locks[<peer sid>]``.
1054
+ :param is_ltf: If True, enable LTF accumulation mode.
1055
+ :return: (sec_signal, sec_write, sec_read, sec_wait, cleanup, flush,
1056
+ ltf_take_value, ltf_publish, buffer_len). ``flush``/
1057
+ ``ltf_take_value``/``ltf_publish``/``buffer_len`` are None when
1058
+ ``is_ltf=False``; ``flush``/``buffer_len`` serve the file-backed
1059
+ array path, ``ltf_take_value``/``ltf_publish`` the live LTF-window
1060
+ path.
1061
+ """
1062
+ readers: dict[str, ResultReader] = {
1063
+ sid: ResultReader(sid) for sid in all_sec_ids
1064
+ }
1065
+ own_lock = result_locks[sec_id]
1066
+
1067
+ def __sec_signal__(_sid: str, _symbol=None, _timeframe=None, _lookahead=None,
1068
+ _scope_id=None):
1069
+ pass
1070
+
1071
+ if is_ltf:
1072
+ _buffer: list = []
1073
+
1074
+ def __sec_write__(_sid: str, value, _scope_id=None):
1075
+ _buffer.append(value)
1076
+
1077
+ def flush(skip: int = 0):
1078
+ """Publish the round's intrabar array. ``skip`` drops the first N
1079
+ buffered values — feed bars the round replayed for expression
1080
+ state but which open BEFORE the chart bar's own period (a cold
1081
+ start mid-feed, or intrabars in a chart session gap). TradingView
1082
+ arrays carry only the bar's own period."""
1083
+ with own_lock:
1084
+ write_result(result_block, sync_block, _buffer[skip:])
1085
+ _buffer.clear()
1086
+
1087
+ def buffer_len() -> int:
1088
+ return len(_buffer)
1089
+
1090
+ def ltf_take_value():
1091
+ """Return the value written by the latest intrabar run, clearing the
1092
+ buffer. Used by the live LTF-window path to capture one intrabar's
1093
+ expression value per ``__run_script_main`` instead of the whole
1094
+ accumulated array (which the path manages via its own window)."""
1095
+ if not _buffer:
1096
+ return None
1097
+ value = _buffer[-1]
1098
+ _buffer.clear()
1099
+ return value
1100
+
1101
+ def ltf_publish(values):
1102
+ """Write a live LTF-window array under the result lock."""
1103
+ with own_lock:
1104
+ write_result(result_block, sync_block, list(values))
1105
+ else:
1106
+ def __sec_write__(_sid: str, value, _scope_id=None):
1107
+ with own_lock:
1108
+ write_result(result_block, sync_block, value)
1109
+
1110
+ flush = None
1111
+ ltf_take_value = None
1112
+ ltf_publish = None
1113
+ buffer_len = None
1114
+
1115
+ def __sec_read__(sid: str, default=None, _scope_id=None):
1116
+ # ``read_cached``: the child re-runs the script per (intra)bar, so a
1117
+ # peer context's unchanged result must not be re-unpickled every run —
1118
+ # a peer's deep-first-round LTF array would otherwise make the replay
1119
+ # quadratic (hours instead of seconds). See ResultReader.read_cached.
1120
+ with result_locks[sid]:
1121
+ return readers[sid].read_cached(sync_block, default)
1122
+
1123
+ def __sec_wait__(_sid: str, _scope_id=None):
1124
+ pass
1125
+
1126
+ def cleanup():
1127
+ for r in readers.values():
1128
+ r.close()
1129
+
1130
+ return (__sec_signal__, __sec_write__, __sec_read__, __sec_wait__, cleanup,
1131
+ flush, ltf_take_value, ltf_publish, buffer_len)
1132
+
1133
+
1134
+ # Representative dates for the off-grid session probe — one on each side of the
1135
+ # DST boundary so a session that lands on the tf grid only half the year is still
1136
+ # detected. Both are Mondays, so the weekday offset arithmetic below is exact.
1137
+ _WINTER_PROBE = date(2024, 1, 15)
1138
+ _SUMMER_PROBE = date(2024, 7, 15)
1139
+
1140
+
1141
+ def _needs_session_anchor(
1142
+ session_starts: 'list[SymInfoSession]',
1143
+ tzinfo: ZoneInfo | None,
1144
+ timeframe: str,
1145
+ ) -> bool:
1146
+ """
1147
+ Whether intraday ``timeframe`` bars need session anchoring for this market.
1148
+
1149
+ Session anchoring changes nothing when every declared session open already
1150
+ lands on the ``timeframe`` UTC-epoch grid (24/7, on-hour, session-aligned
1151
+ markets), so those keep the zero-overhead clock-floor fast path. The open is
1152
+ probed on both a winter and a summer date to cover both DST offsets. The test
1153
+ is deliberately conservative: it anchors whenever any open is off-grid.
1154
+
1155
+ :param session_starts: Per-trading-day primary opens.
1156
+ :param tzinfo: The market's exchange timezone.
1157
+ :param timeframe: The requested HTF string (e.g. ``"60"``).
1158
+ :return: True if the security loop must pass ``session_starts`` to anchor.
1159
+ """
1160
+ if not session_starts:
1161
+ return False
1162
+ # Local import: ``core`` ↔ ``lib`` would otherwise form an import cycle
1163
+ # (mirrors the existing ``from pynecore.lib import ...`` uses in this file).
1164
+ from ..lib import timeframe as tf_module
1165
+ # noinspection PyProtectedMember
1166
+ modifier, _ = tf_module._process_tf(timeframe)
1167
+ if modifier not in ('S', ''):
1168
+ return False # D/W/M alignment is timezone-driven, not session-anchored
1169
+ tf_seconds = tf_module.in_seconds(timeframe)
1170
+ for probe in (_WINTER_PROBE, _SUMMER_PROBE):
1171
+ for s in session_starts:
1172
+ d = probe + timedelta(days=(s.day - probe.weekday()) % 7)
1173
+ open_sec = int(datetime(
1174
+ d.year, d.month, d.day,
1175
+ s.time.hour, s.time.minute, s.time.second,
1176
+ tzinfo=tzinfo,
1177
+ ).timestamp())
1178
+ if open_sec % tf_seconds != 0:
1179
+ return True
1180
+ return False
1181
+
1182
+
1183
+ def resolve_session_anchor(
1184
+ si: 'SymInfo | None',
1185
+ timeframe: str,
1186
+ fallback_tz: ZoneInfo,
1187
+ ) -> 'tuple[list[SymInfoSession] | None, ZoneInfo | None]':
1188
+ """
1189
+ Decide intraday HTF session anchoring for one security context.
1190
+
1191
+ Returns ``(session_starts, session_tz)`` to store on the ``SecurityState`` so
1192
+ ``_get_confirmed_time`` anchors HTF bars to the session open, or ``(None,
1193
+ None)`` when the market opens on the ``timeframe`` grid (the clock-floor fast
1194
+ path). ``session_tz`` is the security's own exchange timezone — correct even
1195
+ for a cross-symbol HTF in a different session.
1196
+
1197
+ :param si: The security's own ``SymInfo`` (``None`` → no anchoring).
1198
+ :param timeframe: The resolved HTF string (e.g. ``"60"``).
1199
+ :param fallback_tz: Timezone used if the syminfo timezone is missing/invalid.
1200
+ """
1201
+ if si is None or not getattr(si, 'session_starts', None):
1202
+ return None, None
1203
+ if si.timezone:
1204
+ try:
1205
+ # parse_timezone resolves both IANA names and UTC/GMT±HHMM offset
1206
+ # forms (e.g. "UTC-5"), which bare ZoneInfo() rejects.
1207
+ si_tz = parse_timezone(si.timezone)
1208
+ except (ValueError, KeyError):
1209
+ # Unknown / malformed timezone (ZoneInfoNotFoundError is a KeyError;
1210
+ # TimezoneNotFoundError is a ValueError).
1211
+ si_tz = fallback_tz
1212
+ else:
1213
+ si_tz = fallback_tz
1214
+ if _needs_session_anchor(si.session_starts, si_tz, timeframe):
1215
+ return si.session_starts, si_tz
1216
+ return None, None
1217
+
1218
+
1219
+ def _session_bar_closes(
1220
+ opens: list[int],
1221
+ tz: ZoneInfo | None,
1222
+ opening_hours: list[SymInfoInterval],
1223
+ period_ms: int,
1224
+ ) -> list[int] | None:
1225
+ """
1226
+ Close instant (epoch ms) of each intraday HTF bar: the earlier of its period
1227
+ end and its session's scheduled end.
1228
+
1229
+ An HTF bar covers ``[open, open + period)`` but never extends past its trading
1230
+ session, so it closes at ``min(open + period, session_end)``. When the period
1231
+ is at least as long as the session (one bar per session, e.g. a 720-minute bar
1232
+ on a 3-session palm-oil contract) the session end wins, and ``_get_confirmed_
1233
+ time`` confirms the bar there instead of at the arithmetic next-period boundary
1234
+ that the non-trading gap before the next session would push a full period late.
1235
+ When several bars fit inside a session (e.g. a 60-minute HTF) the period end
1236
+ wins and behaviour matches the plain grid. Each open's session end comes from
1237
+ the ``opening_hours`` interval that contains it (overnight intervals — ``end <=
1238
+ start`` — close on the following calendar day). A bar opening *after* midnight
1239
+ is matched to the PREVIOUS calendar day's overnight interval, whose session it
1240
+ belongs to (e.g. a ``21:00->02:00`` night session's ``01:00`` bar closes at the
1241
+ ``02:00`` session end, not a full period later).
1242
+
1243
+ :param opens: HTF bar opens in epoch ms, ascending.
1244
+ :param tz: The security's exchange timezone.
1245
+ :param opening_hours: The security's ``SymInfo.opening_hours`` intervals.
1246
+ :param period_ms: The HTF period length in milliseconds.
1247
+ :return: A parallel list of close instants (epoch ms), or ``None`` if any open
1248
+ has no containing interval — the schedule does not fully describe the
1249
+ feed, so the caller keeps the arithmetic grid clamp rather than risk a
1250
+ wrong session end.
1251
+ """
1252
+ from .resampler import crosses_midnight
1253
+ closes: list[int] = []
1254
+ for open_ms in opens:
1255
+ open_dt = datetime.fromtimestamp(open_ms / 1000, tz=tz)
1256
+ weekday = open_dt.weekday()
1257
+ prev_weekday = (weekday - 1) % 7
1258
+ open_time = open_dt.time()
1259
+ end_ms: int | None = None
1260
+ for interval in opening_hours:
1261
+ overnight = crosses_midnight(interval.start, interval.end)
1262
+ if (interval.day == weekday and interval.start <= open_time
1263
+ and (overnight or open_time < interval.end)):
1264
+ # Same-day session, or the pre-midnight leg of an overnight one
1265
+ # (which closes on the following calendar day).
1266
+ end_date = open_dt.date() + timedelta(days=1 if overnight else 0)
1267
+ elif overnight and interval.day == prev_weekday and open_time < interval.end:
1268
+ # After-midnight leg of the PREVIOUS day's overnight session: the
1269
+ # bar opens today but its session started yesterday and closes
1270
+ # today (e.g. a 21:00->02:00 night session's 01:00 bar).
1271
+ end_date = open_dt.date()
1272
+ else:
1273
+ continue
1274
+ candidate = int(
1275
+ datetime.combine(end_date, interval.end, tzinfo=tz).timestamp() * 1000)
1276
+ if end_ms is None or candidate < end_ms:
1277
+ end_ms = candidate
1278
+ if end_ms is None:
1279
+ return None
1280
+ # Whichever comes first: the bar's own period end, or the session end (a
1281
+ # non-trading gap before the next session must not delay confirmation).
1282
+ closes.append(min(open_ms + period_ms, end_ms))
1283
+ return closes
1284
+
1285
+
1286
+ def _dated_session_bar_closes(
1287
+ opens: list[int],
1288
+ tz: ZoneInfo | None,
1289
+ si: SymInfo,
1290
+ period_ms: int,
1291
+ overnight: dict[int, time],
1292
+ ) -> list[int] | None:
1293
+ """
1294
+ Close instants for an HTF feed whose exchange changed its session hours within
1295
+ the data range (effective-dated schedule history).
1296
+
1297
+ Like :func:`_session_bar_closes`, but each bar open is matched to the session
1298
+ schedule *variant* effective on its exchange-local trading day, so a backtest
1299
+ spanning a session-hours change confirms each side with its own schedule. The
1300
+ trading-day key (not the raw calendar date of the open) is what
1301
+ ``request.security`` already uses to attribute overnight bars: a night bar
1302
+ opening 21:00 the evening before belongs to the next trading day and must take
1303
+ that day's variant -- keying on the raw open date would mis-assign the boundary
1304
+ bar by one day, exactly where a schedule change lives.
1305
+
1306
+ Consecutive opens resolving to the same variant index are grouped into one
1307
+ segment and handed to the UNCHANGED :func:`_session_bar_closes` with that
1308
+ variant's ``opening_hours``, so every segment runs the same, already-tested
1309
+ close-instant arithmetic. Grouping is by variant *index* (not object identity),
1310
+ so an ``A -> B -> A`` history yields three segments and the result is stable
1311
+ even if the resolver ever returns copies. Any segment the schedule cannot fully
1312
+ describe returns ``None``, propagated so the caller keeps the arithmetic grid
1313
+ clamp.
1314
+
1315
+ :param opens: HTF bar opens in epoch ms, ascending.
1316
+ :param tz: The security's exchange timezone.
1317
+ :param si: The security's :class:`SymInfo` (carries ``session_schedules``).
1318
+ :param period_ms: The HTF period length in milliseconds.
1319
+ :param overnight: Per-weekday rolling opens from ``overnight_opens``, used to
1320
+ roll each open to its trading day.
1321
+ :return: A parallel list of close instants, or ``None`` if any variant fails to
1322
+ describe its bars.
1323
+ """
1324
+ from .resampler import trading_day
1325
+ # Resolve every open's variant index in one pass (trading-day keyed), then walk
1326
+ # maximal same-index runs. Setup-time only -- never on the per-bar hot path.
1327
+ idx = [si.schedule_index_for(trading_day(o // 1000, tz, overnight)) for o in opens]
1328
+ closes: list[int] = []
1329
+ i, n = 0, len(opens)
1330
+ while i < n:
1331
+ k = idx[i]
1332
+ j = i
1333
+ while j < n and idx[j] == k:
1334
+ j += 1
1335
+ oh = si.session_schedules[k].opening_hours
1336
+ seg = _session_bar_closes(opens[i:j], tz, oh, period_ms)
1337
+ if seg is None:
1338
+ return None
1339
+ closes.extend(seg)
1340
+ i = j
1341
+ return closes
1342
+
1343
+
1344
+ def load_htf_bar_opens(state: SecurityState, data_path: str) -> None:
1345
+ """
1346
+ Load the child's real bar opens for HTF confirmation against the actual feed.
1347
+
1348
+ The arithmetic grid in ``_get_confirmed_time`` assumes a child bar exists at
1349
+ every grid period — true only for a DENSE feed. Two cases break it, and both
1350
+ confirm by riding the child's real bar opens instead:
1351
+
1352
+ * D/W/M — including the single-period ``D``/``W``/``M`` case: macro aggregates
1353
+ (ECONOMICS series, dividends) carry a bar only on scattered days, so the
1354
+ grid would emit a confirmation boundary for every calendar period and a
1355
+ chart bar landing on a day with no real child bar would advance the
1356
+ subprocess into an empty window — writing ``na`` and destroying the
1357
+ ``gaps_off`` (TV default) forward-fill.
1358
+ * Gappy intraday HTF: ``OHLCVWriter`` forward-fills a session-gapped futures
1359
+ feed (e.g. a 720-minute HTF on a 3-session palm-oil contract) to a
1360
+ continuous grid, but the security child reads only the real bars
1361
+ (gap-compacted, see ``security_process``). The grid would then confirm
1362
+ phantom periods on the fills' timestamps. Dense intraday feeds keep the
1363
+ cheaper arithmetic grid (this stays a no-op for them); LTF contexts run
1364
+ their own intrabar machinery, not HTF confirmation.
1365
+
1366
+ A gappy SAME-TF cross-symbol feed (a session-bounded symbol requested at the
1367
+ chart's own TF on a 24/7 chart) rides the same intraday path: the child is
1368
+ gap-compacted, and ``_get_confirmed_time`` clamps the chart bar time to
1369
+ these opens so gap bars confirm nothing new.
1370
+
1371
+ Riding the real opens (clamp for single-period / intraday, walk for
1372
+ multi-period D/W/M) makes ``new_period`` fire only on real bars: between them
1373
+ ``gaps_off`` holds the last value and ``gaps_on`` emits ``na``, both matching
1374
+ TradingView. The security's own grid parameters are loaded from its TOML for
1375
+ the past-end-of-data fallback.
1376
+
1377
+ :param state: Security context state (``state.timeframe`` already resolved)
1378
+ :param data_path: Path to the child's OHLCV data file
1379
+ """
1380
+ # Local import: ``core`` ↔ ``lib`` would otherwise form an import cycle.
1381
+ from ..lib import timeframe as tf_module
1382
+ from .ohlcv_file import OHLCVReader
1383
+ from .resampler import grid_mode, overnight_opens, trading_day
1384
+ from .syminfo import SymInfo
1385
+ # noinspection PyProtectedMember
1386
+ modifier, multiplier = tf_module._process_tf(state.timeframe)
1387
+ is_dwm = modifier in ('D', 'W', 'M')
1388
+
1389
+ # LTF contexts (array windows and the scalar plain-LTF merge alike) never
1390
+ # use HTF confirmation — their target is the chart bar's own period.
1391
+ if state.is_ltf or state.plain_ltf:
1392
+ return
1393
+
1394
+ if not is_dwm:
1395
+ # Intraday HTF: only a GAPPY feed needs the real-opens clamp (see above);
1396
+ # a dense feed keeps the arithmetic grid.
1397
+ period_sec = tf_module.in_seconds(state.timeframe)
1398
+ with OHLCVReader(data_path) as reader:
1399
+ start_ts = reader.start_timestamp
1400
+ if start_ts is None:
1401
+ return
1402
+ opens = [candle.timestamp * 1000 for candle in reader.read_from(start_ts)]
1403
+ # A feed is dense only when its real bars tile the timeframe grid (file
1404
+ # interval == period). The row count alone is not enough: a session-
1405
+ # spaced feed whose bars sit wider than the period (e.g. a gap-free,
1406
+ # 24h-spaced 720-minute night future) has no gap fills, so
1407
+ # ``len(opens) == reader.size`` holds, yet its bars do NOT tile the
1408
+ # period grid and must still ride the session-close path below. A
1409
+ # single-record file has no interval to compare, so it keeps the dense
1410
+ # fast path as before.
1411
+ if len(opens) == reader.size and reader.interval in (None, period_sec):
1412
+ return # dense feed: the arithmetic grid is already correct
1413
+ # Gappy fixed-span intraday HTF: keep the arithmetic (fixed-span) grid for
1414
+ # the close instant, but CLAMP it to the latest real open so an empty
1415
+ # (gap) period holds the last real bar instead of advancing into a phantom
1416
+ # period and writing na — see ``_get_confirmed_time``.
1417
+ state.bar_opens_multiperiod = False
1418
+ else:
1419
+ # Multi-period (nD/nW/nM) walks the opens directly (the arithmetic grid
1420
+ # cannot reproduce TradingView's scheduled multi-period calendar). Single
1421
+ # period (1D/1W/1M) instead uses the grid for the calendar close instant
1422
+ # and only *clamps* to these opens — see ``_get_confirmed_time``.
1423
+ state.bar_opens_multiperiod = multiplier > 1
1424
+ with OHLCVReader(data_path) as reader:
1425
+ if reader.size == 1:
1426
+ # A single-record feed has no derivable interval, so
1427
+ # ``start_timestamp`` stays ``None`` and ``read_from`` bails —
1428
+ # ``opens`` would be empty and ``_get_confirmed_time`` would then
1429
+ # never confirm the lone bar (the child reads ``na`` forever).
1430
+ # This is reachable when a finer base feed spans exactly one
1431
+ # requested D/W/M period and resamples to a single aggregate.
1432
+ # Read the one bar directly so its open still anchors the clamp.
1433
+ opens = [reader.read(0).timestamp * 1000]
1434
+ else:
1435
+ start_ts = reader.start_timestamp
1436
+ opens = ([] if start_ts is None else
1437
+ [candle.timestamp * 1000 for candle in reader.read_from(start_ts)])
1438
+
1439
+ state.bar_opens = opens
1440
+ state.bar_ptr = -1
1441
+
1442
+ sec_tz: ZoneInfo | None = state.tz
1443
+ sec_starts = sec_hours = mode = None
1444
+ toml_path = Path(data_path).with_suffix('.toml')
1445
+ if toml_path.exists():
1446
+ si = SymInfo.load_toml(toml_path)
1447
+ try:
1448
+ sec_tz = parse_timezone(si.timezone) if si.timezone else state.tz
1449
+ except (ValueError, KeyError):
1450
+ sec_tz = state.tz
1451
+ sec_starts = si.session_starts or None
1452
+ sec_hours = si.opening_hours or None
1453
+ mode = grid_mode(si.type, si.opening_hours)
1454
+
1455
+ # Session-bounded intraday HTF (e.g. a futures contract's day/night
1456
+ # sessions): confirm each bar on its scheduled session end instead of the
1457
+ # arithmetic next-period boundary (see ``_get_confirmed_time``). Needs the
1458
+ # session schedule; ``None`` (no schedule, or a bar outside it) keeps the
1459
+ # grid clamp.
1460
+ if not is_dwm and sec_hours and not state.same_timeframe:
1461
+ period_ms = tf_module.in_seconds(state.timeframe) * 1000
1462
+ if si.has_schedule_history:
1463
+ # The trading-day roll keys off the flat (newest) session opens;
1464
+ # this Core path assumes the session OPEN / trading-day attribution
1465
+ # is stable across variants (close-only era changes, e.g. a futures
1466
+ # contract that shortened its night session). A symbol that shifts
1467
+ # its session START across eras needs the deferred session-anchoring
1468
+ # work -- the assumption is stated here in code, not only the docs.
1469
+ overnight = overnight_opens(sec_hours, sec_starts)
1470
+ # Surface that unsupported shape instead of silently mis-confirming:
1471
+ # an earlier variant whose overnight session OPENS at a different
1472
+ # time than the newest one (a session-START shift, not a close-only
1473
+ # change) is rolled to the wrong trading day by the newest-keyed
1474
+ # ``overnight`` above and can pick the wrong variant. Only a weekday
1475
+ # that is overnight in BOTH variants but at a different time counts;
1476
+ # a structurally different (e.g. day-only) era is handled by the
1477
+ # ``None`` fallback below, not a START shift.
1478
+ for variant in si.session_schedules[:-1]:
1479
+ vo = overnight_opens(variant.opening_hours, variant.session_starts)
1480
+ if any(overnight.get(d) is not None and t != overnight[d]
1481
+ for d, t in vo.items()):
1482
+ logger.warning(
1483
+ "%s:%s session schedule history changes the overnight "
1484
+ "session OPEN at variant effective %s; the dated HTF "
1485
+ "path attributes every bar by the newest variant's open, "
1486
+ "so bars near that change may confirm against the wrong "
1487
+ "variant. Session-START shifts are not yet supported "
1488
+ "(close-only era changes are).",
1489
+ si.prefix, si.ticker, variant.effective_from)
1490
+ break
1491
+ if opens:
1492
+ first_td = trading_day(opens[0] // 1000, sec_tz, overnight)
1493
+ earliest = si.session_schedules[0].effective_from
1494
+ if first_td < earliest:
1495
+ logger.warning(
1496
+ "%s:%s session schedule history starts %s but the HTF "
1497
+ "feed opens on trading day %s; the oldest variant was "
1498
+ "applied to the earlier bars. Add an earlier "
1499
+ "[[session_schedules]] variant for an exact backtest "
1500
+ "across that range.",
1501
+ si.prefix, si.ticker, earliest, first_td)
1502
+ state.bar_closes = _dated_session_bar_closes(
1503
+ opens, sec_tz, si, period_ms, overnight)
1504
+ else:
1505
+ state.bar_closes = _session_bar_closes(opens, sec_tz, sec_hours, period_ms)
1506
+ state.sec_grid_args = (sec_tz, sec_starts, sec_hours, mode)
1507
+
1508
+
1509
+ def load_ltf_first_ms(state: SecurityState, data_path: str) -> None:
1510
+ """
1511
+ Record the LTF child feed's first bar open for the chart-side prefix skip.
1512
+
1513
+ ``request.security_lower_tf()`` makes the chart block on a cross-process
1514
+ handshake for *every* chart bar, because any chart bar may contain
1515
+ intrabars. Chart bars whose whole period ends before the feed's very first
1516
+ bar never can: the child includes intrabars with ``bar_open <= target_time``
1517
+ and the historical target is the chart bar's last ms, so a chart bar whose
1518
+ period ends strictly below the feed's first open yields an empty intrabar
1519
+ array unconditionally — matching TradingView, which returns ``na`` before the
1520
+ lower-timeframe series begins. Recording that first open lets
1521
+ ``__sec_signal__`` skip the signal+wait over the idle prefix and
1522
+ ``__sec_read__`` return the empty-array default without touching shared
1523
+ memory. Backtest/file-backed only: a live ``PluginSymbol`` stream has no
1524
+ static first bar, so ``ltf_first_ms`` stays ``None`` and every chart bar
1525
+ signals as before. No-op for non-LTF contexts (``plain_ltf`` — the scalar
1526
+ lower-timeframe merge — uses the same prefix skip, so it loads too).
1527
+
1528
+ :param state: LTF security context state.
1529
+ :param data_path: Path to the child's OHLCV data file.
1530
+ """
1531
+ if not state.is_ltf and not state.plain_ltf:
1532
+ return
1533
+ from .ohlcv_file import OHLCVReader
1534
+ with OHLCVReader(data_path) as reader:
1535
+ start_ts = reader.start_timestamp
1536
+ state.ltf_first_ms = None if start_ts is None else int(start_ts * 1000)
1537
+
1538
+
1539
+ def setup_security_states(
1540
+ contexts: dict[str, dict],
1541
+ chart_timeframe: str,
1542
+ tz: 'ZoneInfo',
1543
+ chart_symbol: str | None = None,
1544
+ chart_syminfo: 'SymInfo | None' = None,
1545
+ sec_syminfos: 'dict[str, SymInfo] | None' = None,
1546
+ ) -> tuple[dict[str, SecurityState], SyncBlock, dict[str, ResultBlock]]:
1547
+ """
1548
+ Initialize security states, shared memory, and events from ``__security_contexts__``.
1549
+
1550
+ :param contexts: The ``__security_contexts__`` dict from the script module.
1551
+ Keys are sec_ids, values are dicts with 'symbol', 'timeframe', 'gaps'.
1552
+ :param chart_timeframe: The chart's timeframe string (e.g., "5", "1D").
1553
+ :param tz: The chart's timezone.
1554
+ :param chart_symbol: The chart's ticker (e.g. ``"AAPL"``). Drives same-symbol
1555
+ gating for the live HTF transport — a cross-symbol HTF
1556
+ context gets no ``HTFAggregator`` because the chart-side
1557
+ OHLCV would be the wrong instrument. ``None`` (unit-test
1558
+ / legacy callers without symbol context) is treated as
1559
+ "every HTF is same-symbol".
1560
+ :param chart_syminfo: The chart symbol's ``SymInfo``, used as the session
1561
+ source for same-symbol HTF anchoring. ``None`` disables
1562
+ anchoring unless a per-security syminfo is supplied.
1563
+ :param sec_syminfos: ``sec_id → SymInfo`` for cross-symbol contexts, used so
1564
+ each security anchors to its own session/timezone. ``None``
1565
+ falls back to ``chart_syminfo``.
1566
+ :return: (states, sync_block, result_blocks)
1567
+ """
1568
+ from pynecore.lib import barmerge
1569
+ from pynecore.lib import timeframe as tf_module
1570
+ from .resampler import Resampler
1571
+ from .htf_aggregator import HTFAggregator
1572
+
1573
+ # Chart bar open -> last instant offset: multi-period boundaries resolve a
1574
+ # chart bar by the instant it ends at, so the bar containing a session
1575
+ # open counts as the new period's first bar. D/W/M chart bars are
1576
+ # session-aligned by construction and need no offset.
1577
+ # noinspection PyProtectedMember
1578
+ chart_mod, chart_mult = tf_module._process_tf(chart_timeframe)
1579
+ chart_off = (tf_module.in_seconds(chart_timeframe) * 1000 - 1
1580
+ if chart_mod in ('', 'S') else 0)
1581
+
1582
+ # Single-period civil daily/weekly/monthly chart: the LTF window cannot use
1583
+ # the (zero) ``chart_off`` span; ``__sec_signal__`` instead targets the
1584
+ # chart bar's civil period end via this resampler. Multi-period and
1585
+ # intraday charts keep the ``chart_off`` path. Only attached to LTF states.
1586
+ chart_ltf_resampler = None
1587
+ chart_ltf_modifier = ''
1588
+ if chart_mod in ('D', 'W', 'M') and chart_mult == 1:
1589
+ chart_ltf_resampler = Resampler.get_resampler(chart_timeframe)
1590
+ chart_ltf_modifier = chart_mod
1591
+
1592
+ sec_ids = list(contexts.keys())
1593
+ sync_block = SyncBlock(sec_ids)
1594
+ states: dict[str, SecurityState] = {}
1595
+ result_blocks: dict[str, ResultBlock] = {}
1596
+
1597
+ for sec_id, ctx in contexts.items():
1598
+ tf_val = ctx.get('timeframe', chart_timeframe)
1599
+ if tf_val is None or tf_val == '':
1600
+ # Runtime-dependent (deferred) timeframe gets the chart TF as a
1601
+ # placeholder until the runtime ``__sec_signal__`` resolves it;
1602
+ # an empty string IS the chart's timeframe (Pine semantics)
1603
+ tf_val = chart_timeframe
1604
+ timeframe = str(tf_val)
1605
+ is_ltf = bool(ctx.get('is_ltf', False))
1606
+
1607
+ htf_aggregator: HTFAggregator | None = None
1608
+ na_on_developing = False
1609
+ anchor_starts: 'list[SymInfoSession] | None' = None
1610
+ anchor_tz: ZoneInfo | None = None
1611
+ plain_ltf = False
1612
+ if is_ltf:
1613
+ is_gaps_on = False
1614
+ same_tf = False
1615
+ resampler = None # chart-side resampler not needed for LTF
1616
+ lookahead_mode = Lookahead.OFF # LTF has no lookahead concept
1617
+ else:
1618
+ gaps_val = ctx.get('gaps', barmerge.gaps_off)
1619
+ is_gaps_on = gaps_val is barmerge.gaps_on
1620
+ same_tf = (timeframe == chart_timeframe)
1621
+ # Plain security with a FINER timeframe than the chart: scalar
1622
+ # LTF merge (last/first intrabar of the chart bar), no resampler,
1623
+ # no HTF aggregator — the chart targets its own bar period.
1624
+ if not same_tf:
1625
+ sec_seconds = tf_module.in_seconds(timeframe)
1626
+ chart_seconds = tf_module.in_seconds(chart_timeframe)
1627
+ plain_ltf = 0 < sec_seconds < chart_seconds
1628
+ resampler = (None if same_tf or plain_ltf
1629
+ else Resampler.get_resampler(timeframe))
1630
+
1631
+ # A None value is a runtime-deferred (input-derived) lookahead; OFF
1632
+ # serves as the placeholder until the first ``__sec_signal__``
1633
+ # delivers the actual value.
1634
+ lookahead_mode = _lookahead_mode(ctx.get('lookahead'))
1635
+
1636
+ # Live HTF transport via the chart's ``HTFAggregator`` (closed-bar
1637
+ # override for all lookahead modes, plus developing-bar for
1638
+ # ``Lookahead.ON``) requires same-symbol chart→HTF aggregation.
1639
+ # The chart bar OHLCV must belong to the same instrument as the
1640
+ # security, so cross-symbol HTF keeps no aggregator: in backtest
1641
+ # it reads from the security's own ``.ohlcv`` file; in live mode
1642
+ # the security subprocess drives its own provider (warmup
1643
+ # download + WS stream) so the cross-symbol context advances on
1644
+ # real feed bars instead of staying inert.
1645
+ if not same_tf and resampler is not None:
1646
+ sym = ctx.get('symbol')
1647
+ if sym is not None:
1648
+ # Strip any chart-type marker (``ticker.heikinashi()``) so a
1649
+ # static same-symbol chart-type HTF resolves as same-symbol
1650
+ # (and gets an aggregator), not misrouted as cross-symbol.
1651
+ from ..lib.ticker import _split_chart_type
1652
+ sym, _ = _split_chart_type(str(sym))
1653
+ is_same_symbol = (
1654
+ chart_symbol is None
1655
+ or sym is None
1656
+ or str(sym) == chart_symbol
1657
+ )
1658
+
1659
+ # Intraday session anchoring: align HTF bars to the session open
1660
+ # (TradingView behaviour) when the open is off the requested tf
1661
+ # grid. Use the security's OWN syminfo — correct even for a
1662
+ # cross-symbol HTF in a different exchange session.
1663
+ si = (sec_syminfos.get(sec_id)
1664
+ if sec_syminfos is not None else None) or chart_syminfo
1665
+ anchor_starts, anchor_tz = resolve_session_anchor(si, timeframe, tz)
1666
+
1667
+ if is_same_symbol:
1668
+ htf_aggregator = HTFAggregator(
1669
+ timeframe, tz, session_starts=anchor_starts,
1670
+ chart_span_ms=chart_off + 1 if chart_off else 0)
1671
+ elif lookahead_mode is Lookahead.ON:
1672
+ # Cross-symbol HTF + lookahead_on: developing bar cannot
1673
+ # be aggregated from chart OHLCV (wrong instrument). The
1674
+ # subprocess still advances on closed cross-symbol HTF
1675
+ # bars, but the chart-side read returns ``na`` on every
1676
+ # chart bar inside an open HTF period — backtest never
1677
+ # silently exposes a value live could not produce, and
1678
+ # the ``close[1]`` idiom keeps working at the period
1679
+ # boundary.
1680
+ na_on_developing = True
1681
+
1682
+ state = SecurityState(
1683
+ sec_id=sec_id,
1684
+ timeframe=timeframe,
1685
+ gaps_on=is_gaps_on,
1686
+ same_timeframe=same_tf,
1687
+ resampler=resampler,
1688
+ tz=tz,
1689
+ is_ltf=is_ltf,
1690
+ plain_ltf=plain_ltf,
1691
+ plain_ltf_span_ms=(tf_module.in_seconds(timeframe) * 1000
1692
+ if plain_ltf else 0),
1693
+ lookahead=lookahead_mode,
1694
+ htf_aggregator=htf_aggregator,
1695
+ na_on_developing=na_on_developing,
1696
+ session_starts=anchor_starts,
1697
+ session_tz=anchor_tz,
1698
+ chart_off=chart_off,
1699
+ chart_resampler=chart_ltf_resampler if (is_ltf or plain_ltf) else None,
1700
+ chart_dwm_modifier=chart_ltf_modifier if (is_ltf or plain_ltf) else '',
1701
+ )
1702
+ # data_ready starts SET so reads before first signal return na (via result_size=0)
1703
+ state.data_ready.set()
1704
+
1705
+ states[sec_id] = state
1706
+
1707
+ result_block = ResultBlock(sec_id, create=True, version=0, size=INITIAL_RESULT_SIZE)
1708
+ result_blocks[sec_id] = result_block
1709
+
1710
+ return states, sync_block, result_blocks
1711
+
1712
+
1713
+ def inject_protocol(module, signal_fn, write_fn, read_fn, wait_fn,
1714
+ active_security=None,
1715
+ same_context: 'set[str] | frozenset[str]' = frozenset()):
1716
+ """
1717
+ Inject protocol functions and __active_security__ into a script module's globals.
1718
+
1719
+ :param module: The script module
1720
+ :param signal_fn: __sec_signal__ implementation
1721
+ :param write_fn: __sec_write__ implementation
1722
+ :param read_fn: __sec_read__ implementation
1723
+ :param wait_fn: __sec_wait__ implementation
1724
+ :param active_security: None for chart context, sec_id for security context
1725
+ :param same_context: Frozenset of sec_ids sharing the chart's symbol+timeframe
1726
+ """
1727
+ module.__sec_signal__ = signal_fn
1728
+ module.__sec_write__ = write_fn
1729
+ module.__sec_read__ = read_fn
1730
+ module.__sec_wait__ = wait_fn
1731
+ module.__active_security__ = active_security
1732
+ module.__same_context__ = same_context
1733
+
1734
+
1735
+ def cleanup_shared_memory(
1736
+ sync_block: SyncBlock,
1737
+ result_blocks: dict[str, ResultBlock],
1738
+ ):
1739
+ """
1740
+ Clean up all shared memory resources.
1741
+
1742
+ :param sync_block: The sync block to close and unlink
1743
+ :param result_blocks: Result blocks to close and unlink
1744
+ """
1745
+ for rb in result_blocks.values():
1746
+ rb.close()
1747
+ rb.unlink()
1748
+ sync_block.close()
1749
+ sync_block.unlink()