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,686 @@
1
+ from datetime import datetime, timedelta, timezone as dt_timezone, date, time as dt_time
2
+ from typing import ClassVar, TYPE_CHECKING
3
+ from functools import lru_cache
4
+ from zoneinfo import ZoneInfo
5
+
6
+ from ..lib import timeframe as tf_module
7
+
8
+ if TYPE_CHECKING:
9
+ from .syminfo import SymInfoSession, SymInfoInterval
10
+
11
+
12
+ def _session_anchor_sec(
13
+ t_sec: int,
14
+ tz: ZoneInfo | dt_timezone | None,
15
+ session_starts: 'list[SymInfoSession]',
16
+ ) -> int:
17
+ """
18
+ Epoch seconds of the session open anchoring ``t_sec``'s trading session.
19
+
20
+ Returns the opening instant of the trading session that *contains* ``t_sec`` —
21
+ for overnight sessions this is the previous evening's open. Used to align
22
+ intraday HTF bars to the session open the way TradingView does, instead of
23
+ flooring to the UTC clock.
24
+
25
+ Sessions are assumed shorter than 24h, so the containing open lies on the bar's
26
+ local date or the one before it. Falls back to ``0`` — which makes the caller
27
+ reproduce the plain UTC clock-floor — when no declared session covers ``t_sec``.
28
+
29
+ :param t_sec: Bar timestamp in epoch seconds.
30
+ :param tz: Exchange timezone for locating session opens. ``None`` uses local time.
31
+ :param session_starts: Per-trading-day primary opens (``SymInfoSession``).
32
+ :return: Anchor (session-open) time in epoch seconds, or 0 if none applies.
33
+ """
34
+ if tz is not None:
35
+ local_date = datetime.fromtimestamp(t_sec, tz=tz).date()
36
+ else:
37
+ local_date = datetime.fromtimestamp(t_sec).date()
38
+
39
+ best: int | None = None
40
+ for delta in (0, 1):
41
+ d = local_date - timedelta(days=delta)
42
+ weekday = d.weekday()
43
+ for s in session_starts:
44
+ if s.day != weekday:
45
+ continue
46
+ open_sec = int(datetime(
47
+ d.year, d.month, d.day,
48
+ s.time.hour, s.time.minute, s.time.second,
49
+ tzinfo=tz,
50
+ ).timestamp())
51
+ if open_sec <= t_sec and (best is None or open_sec > best):
52
+ best = open_sec
53
+ return best if best is not None else 0
54
+
55
+
56
+ #
57
+ # Multi-period (nD/nW/nM) scheduled grid — TradingView counts *scheduled trading
58
+ # days* on a per-exchange calendar, restarts the counter at each year's first
59
+ # scheduled day, and stamps every period with its first scheduled day's session
60
+ # open (synthetic when that day has no data). Which days are scheduled depends
61
+ # on the market:
62
+ #
63
+ # 'calendar' — 24/7 markets (crypto): every calendar day. Pure arithmetic,
64
+ # exact for any data window.
65
+ # 'weekday' — FX/CFD feeds: every Mon-Fri weekday. TradingView has no FX
66
+ # holiday calendar, so Dec 25 / Jan 1 consume a grid slot even
67
+ # with no data. Pure arithmetic, exact for any data window.
68
+ # 'observed' — exchange-listed symbols (futures, stocks): TradingView's real
69
+ # holiday calendar, which we don't have — but the daily data is
70
+ # its realization, so counting actual trading days reproduces it
71
+ # (verified 100% on CME 2022+). Needs the data stream; the
72
+ # arithmetic here serves only as the dataless fallback.
73
+ #
74
+ # Verified against TradingView: OANDA/CAPITALCOM EURUSD 5D 2002-2026 (weekday,
75
+ # 100%), BINANCE:BTCUSDT 5D 2017-2026 (calendar, 100%), CME_MINI:RTY1! 5D/2D/6D
76
+ # 2022-2026 (observed, 100%) and RTY 3W/3M (week/month grids).
77
+ #
78
+ # On intraday charts a chart bar belongs to the period its *last* instant falls
79
+ # into: the bar containing a session open is the new trading day's first bar
80
+ # even when its own timestamp precedes the open (CAPITALCOM EURUSD opens
81
+ # Mon-Thu at 17:05 ET — on a 240-minute grid the 17:00 bar starts the new day).
82
+ # The functions below map plain instants to the grid; callers resolve chart
83
+ # bars by passing ``bar open + chart span - 1``. D/W/M chart bars are
84
+ # session-aligned by construction and need no offset.
85
+ #
86
+
87
+
88
+ def grid_mode(sym_type: str | None,
89
+ opening_hours: 'list[SymInfoInterval] | None') -> str:
90
+ """
91
+ Classify the symbol's scheduled-trading-day calendar.
92
+
93
+ :param sym_type: ``SymInfo.type`` (e.g. "forex", "futures", "crypto")
94
+ :param opening_hours: ``SymInfo.opening_hours`` intervals
95
+ :return: ``'calendar'``, ``'weekday'`` or ``'observed'``
96
+ """
97
+ if opening_hours:
98
+ days = {day for day, _start, _end in opening_hours}
99
+ if len(days) == 7:
100
+ return 'calendar'
101
+ if sym_type in ('crypto', 'spot', 'swap'):
102
+ return 'calendar'
103
+ if sym_type == 'forex':
104
+ return 'weekday'
105
+ return 'observed'
106
+
107
+
108
+ # Three midnight-crossing predicates on a session interval's (start, end)
109
+ # times-of-day. They are deliberately distinct rules — kept here as the single
110
+ # definition each so the same test is never re-spelled slightly differently
111
+ # across modules (the historical source of trading-day grouping bugs):
112
+ #
113
+ # crosses_midnight end <= start the interval ends on the next
114
+ # calendar day (close cap, security)
115
+ # rolls_trading_day crosses & not midnight the open belongs to the next
116
+ # trading day (overnight roll)
117
+ #
118
+ # The strict ``end < start`` session-overlap test (``lib.timeframe`` /
119
+ # ``lib.session``) is a third, genuinely different rule and stays inline there.
120
+
121
+
122
+ def crosses_midnight(start: dt_time, end: dt_time) -> bool:
123
+ """
124
+ Whether a session interval ends on the next calendar day.
125
+
126
+ :param start: Interval open time-of-day
127
+ :param end: Interval close time-of-day
128
+ :return: ``True`` when the close is at or before the open
129
+ """
130
+ return end <= start
131
+
132
+
133
+ def rolls_trading_day(start: dt_time, end: dt_time) -> bool:
134
+ """
135
+ Whether an overnight session open rolls the trading day forward.
136
+
137
+ An interval rolls the trading day when it crosses midnight but does not open
138
+ exactly at midnight (a ``00:00`` open belongs to its own calendar day).
139
+
140
+ :param start: Interval open time-of-day
141
+ :param end: Interval close time-of-day
142
+ :return: ``True`` for evening opens of the following trading day
143
+ """
144
+ return crosses_midnight(start, end) and not (
145
+ start.hour == 0 and start.minute == 0 and start.second == 0)
146
+
147
+
148
+ def overnight_opens(
149
+ opening_hours: 'list[SymInfoInterval] | None',
150
+ session_starts: 'list[SymInfoSession] | None' = None) -> dict[int, dt_time]:
151
+ """
152
+ Earliest trading-day-rolling session open per open-weekday.
153
+
154
+ Only sessions that roll the trading day count (:func:`rolls_trading_day`) —
155
+ same rule as ``lib.time_tradingday``. Without ``opening_hours`` the overnight
156
+ status is inferred from ``session_starts`` alone: an open at or after 12:00
157
+ is an evening open of the next trading day (true for every overnight market
158
+ we know — CME 17:00, FX 17:00 — while day sessions open in the morning).
159
+
160
+ :param opening_hours: ``SymInfo.opening_hours`` (preferred source)
161
+ :param session_starts: ``SymInfo.session_starts`` fallback
162
+ :return: weekday -> earliest rolling open time
163
+ """
164
+ res: dict[int, dt_time] = {}
165
+ if opening_hours:
166
+ for day, start, end in opening_hours:
167
+ if rolls_trading_day(start, end):
168
+ cur = res.get(day)
169
+ if cur is None or start < cur:
170
+ res[day] = start
171
+ elif session_starts:
172
+ for day, start in session_starts:
173
+ if start.hour >= 12:
174
+ cur = res.get(day)
175
+ if cur is None or start < cur:
176
+ res[day] = start
177
+ return res
178
+
179
+
180
+ def overnight_starts_by_weekday(
181
+ opening_hours: 'list[SymInfoInterval] | None') -> dict[int, list[dt_time]]:
182
+ """
183
+ All trading-day-rolling session opens per open-weekday (list shape).
184
+
185
+ Same selection as :func:`overnight_opens` (:func:`rolls_trading_day`) but
186
+ keeps every rolling open, not just the earliest — the per-bar overnight-roll
187
+ code in ``lib.time_tradingday`` / ``lib.time_close`` walks the full list.
188
+
189
+ :param opening_hours: ``SymInfo.opening_hours``
190
+ :return: weekday -> list of rolling open times
191
+ """
192
+ res: dict[int, list[dt_time]] = {}
193
+ if opening_hours:
194
+ for day, start, end in opening_hours:
195
+ if rolls_trading_day(start, end):
196
+ res.setdefault(day, []).append(start)
197
+ return res
198
+
199
+
200
+ def close_table_by_weekday(
201
+ opening_hours: 'list[SymInfoInterval]',
202
+ overnight_by_wd: dict[int, list[dt_time]]) -> dict[int, tuple[dt_time, int]]:
203
+ """
204
+ Per-trading-day-weekday closing instant of the session that ends that day.
205
+
206
+ Each interval's end is assigned to the trading day it closes — rolled to the
207
+ next day when the end lies inside an overnight session — and the latest end
208
+ per trading day wins (a lunch-break morning end loses to the afternoon
209
+ close). The result maps a trading day's weekday to its close as a
210
+ ``(time-of-day, calendar-day offset from the trading-day date)`` pair.
211
+
212
+ :param opening_hours: ``SymInfo.opening_hours`` (``SymInfoInterval`` list)
213
+ :param overnight_by_wd: Rolling opens from :func:`overnight_starts_by_weekday`
214
+ :return: trading-day weekday -> (close time-of-day, +days offset)
215
+ """
216
+ midnight = dt_time(0, 0, 0)
217
+ best: dict[int, tuple[int, dt_time, int]] = {} # td_wd -> (sort key, tod, +days)
218
+ for day, start, end in opening_hours:
219
+ crosses = crosses_midnight(start, end) # the interval ends next calendar day
220
+ if end == midnight:
221
+ # The instant just before the end is still on the opening day
222
+ eps_day = day
223
+ rolled = bool(overnight_by_wd.get(eps_day))
224
+ else:
225
+ eps_day = (day + 1) % 7 if crosses else day
226
+ rolled = any(end > o for o in overnight_by_wd.get(eps_day, ()))
227
+ td_wd = (eps_day + 1) % 7 if rolled else eps_day
228
+ end_cal_day = (day + 1) % 7 if crosses else day
229
+ offset = (end_cal_day - td_wd) % 7
230
+ key = offset * 86_400 + end.hour * 3600 + end.minute * 60 + end.second
231
+ prev = best.get(td_wd)
232
+ if prev is None or key > prev[0]:
233
+ best[td_wd] = (key, end, offset)
234
+ return {wd: (tod, offset) for wd, (_key, tod, offset) in best.items()}
235
+
236
+
237
+ def trading_day(ts_sec: float, tz: ZoneInfo | dt_timezone | None,
238
+ overnight: dict[int, dt_time]) -> date:
239
+ """
240
+ Calendar date of the trading day a timestamp belongs to.
241
+
242
+ A bar at or after its weekday's overnight session open belongs to the next
243
+ calendar day (CME Sunday 17:00 open -> Monday's trading day).
244
+
245
+ :param ts_sec: Timestamp in epoch seconds
246
+ :param tz: Exchange timezone (``None`` uses the system's local timezone)
247
+ :param overnight: Per-weekday rolling opens from :func:`overnight_opens`
248
+ :return: The trading day's calendar date
249
+ """
250
+ dt_loc = datetime.fromtimestamp(ts_sec, tz)
251
+ d = dt_loc.date()
252
+ if overnight:
253
+ t0 = overnight.get(dt_loc.weekday())
254
+ if t0 is not None and dt_loc.time() >= t0:
255
+ d += timedelta(days=1)
256
+ return d
257
+
258
+
259
+ def weekday_ordinal(d: date) -> int:
260
+ """
261
+ Index of ``d`` among its year's Mon-Fri weekdays (Jan 1 weekday = 0).
262
+
263
+ :param d: A weekday date
264
+ :return: 0-based scheduled-day ordinal in the 'weekday' grid
265
+ """
266
+ days = (d - date(d.year, 1, 1)).days
267
+ weeks, rem = divmod(days, 7)
268
+ n = weeks * 5
269
+ w0 = date(d.year, 1, 1).weekday()
270
+ for i in range(rem):
271
+ if (w0 + i) % 7 < 5:
272
+ n += 1
273
+ return n
274
+
275
+
276
+ def weekday_from_ordinal(year: int, idx: int) -> date:
277
+ """
278
+ Inverse of :func:`weekday_ordinal`: the year's ``idx``-th Mon-Fri weekday.
279
+
280
+ :param year: Calendar year
281
+ :param idx: 0-based weekday ordinal
282
+ :return: The weekday's date
283
+ """
284
+ jan1 = date(year, 1, 1)
285
+ w0 = jan1.weekday()
286
+ base = jan1 + timedelta(days=0 if w0 < 5 else 7 - w0)
287
+ weeks, rem = divmod(idx, 5)
288
+ base += timedelta(weeks=weeks)
289
+ bw = base.weekday()
290
+ return base + timedelta(days=rem if bw + rem <= 4 else rem + 2)
291
+
292
+
293
+ def trading_day_open_sec(d: date, tz: ZoneInfo | dt_timezone | None,
294
+ session_starts: 'list[SymInfoSession] | None',
295
+ overnight: dict[int, dt_time]) -> int:
296
+ """
297
+ Epoch seconds of the session open that begins trading day ``d``.
298
+
299
+ For overnight markets this is the previous day's evening open (CME Monday
300
+ trading day -> Sunday 17:00). The instant exists on the schedule even when
301
+ the exchange was closed that day — TradingView stamps multi-period bars
302
+ with these synthetic opens (e.g. FX 5D bars opening on a dataless Jan 1).
303
+ Falls back to ``d``'s local midnight when no template entry matches.
304
+
305
+ :param d: Trading day date
306
+ :param tz: Exchange timezone (``None`` uses the system's local timezone)
307
+ :param session_starts: ``SymInfo.session_starts`` template
308
+ :param overnight: Per-weekday rolling opens from :func:`overnight_opens`
309
+ :return: Session open in epoch seconds
310
+ """
311
+ best: int | None = None
312
+ if session_starts:
313
+ for delta in (1, 0):
314
+ od = d - timedelta(days=delta)
315
+ w = od.weekday()
316
+ for day, t in session_starts:
317
+ if day != w:
318
+ continue
319
+ rolls = w in overnight and t >= overnight[w]
320
+ if (delta == 1) != rolls:
321
+ continue
322
+ sec = int(datetime(od.year, od.month, od.day,
323
+ t.hour, t.minute, t.second, tzinfo=tz).timestamp())
324
+ if best is None or sec < best:
325
+ best = sec
326
+ if best is None:
327
+ return int(datetime(d.year, d.month, d.day, tzinfo=tz).timestamp())
328
+ return best
329
+
330
+
331
+ def scheduled_day_ordinal(d: date, mode: str) -> int:
332
+ """
333
+ 0-based ordinal of trading day ``d`` on its year's scheduled-day grid.
334
+
335
+ :param d: Trading day date
336
+ :param mode: ``'calendar'`` or ``'weekday'`` (``'observed'`` has no
337
+ dataless ordinal — callers count the data stream instead and
338
+ use the weekday grid only as pre-window approximation)
339
+ :return: Scheduled-day ordinal within the year
340
+ """
341
+ if mode == 'calendar':
342
+ return (d - date(d.year, 1, 1)).days
343
+ return weekday_ordinal(d)
344
+
345
+
346
+ def scheduled_day_from_ordinal(year: int, idx: int, mode: str) -> date:
347
+ """
348
+ Inverse of :func:`scheduled_day_ordinal`.
349
+
350
+ :param year: Calendar year
351
+ :param idx: 0-based scheduled-day ordinal
352
+ :param mode: ``'calendar'`` or ``'weekday'``
353
+ :return: The scheduled day's date
354
+ """
355
+ if mode == 'calendar':
356
+ return date(year, 1, 1) + timedelta(days=idx)
357
+ return weekday_from_ordinal(year, idx)
358
+
359
+
360
+ def first_monday(year: int) -> date:
361
+ """
362
+ The year's first Monday — anchor of the weekly grid.
363
+
364
+ A week belongs to its Monday's calendar year, so the week containing
365
+ Jan 1 belongs to the previous year unless Jan 1 is a Monday.
366
+
367
+ :param year: Calendar year
368
+ :return: Date of the first Monday
369
+ """
370
+ jan1 = date(year, 1, 1)
371
+ w0 = jan1.weekday()
372
+ return jan1 + timedelta(days=0 if w0 == 0 else 7 - w0)
373
+
374
+
375
+ def observed_week_key(d: date) -> tuple[int, int]:
376
+ """
377
+ Weekly grid coordinates of trading day ``d``.
378
+
379
+ A week belongs to its Monday's calendar year and weeks count from that
380
+ year's first Monday. The single definition of the nD/nW/nM weekly grouping
381
+ used by the aggregator, the bar magnifier and the ``_dg_*`` tracker.
382
+
383
+ :param d: Trading day date
384
+ :return: ``(week's year, 0-based week ordinal within that year)``
385
+ """
386
+ monday = d - timedelta(days=d.weekday())
387
+ return monday.year, (monday - first_monday(monday.year)).days // 7
388
+
389
+
390
+ class ObservedDayCounter:
391
+ """
392
+ Year-reset observed-trading-day counter for multi-period grouping.
393
+
394
+ Feed consecutive trading days; :meth:`ordinal` returns each day's in-year
395
+ scheduled ordinal and :meth:`key` turns it into the nD/nW/nM grouping key.
396
+ 'Observed' symbols realize TradingView's holiday calendar through their
397
+ actual daily data, so counting the days present in the stream reproduces the
398
+ grid. The first day seeds the counter from the weekday grid — the days
399
+ between Jan 1 and the stream start are not observable (exact when the stream
400
+ begins at the year's first session).
401
+
402
+ Holiday half-day fold (intraday source only): TradingView's daily feed does
403
+ not emit a separate bar for the holiday half-day adjacent to an early close
404
+ (e.g. the day after a 13:00 Thanksgiving close) — it folds into the
405
+ early-close day. Detected from data alone, no calendar: a trading day is an
406
+ early close when its last real bar ends before its scheduled session close
407
+ (:meth:`_is_early`); the immediately following day then shares its ordinal
408
+ instead of advancing. A folded day is itself not a trigger (chain-stop). Fold
409
+ needs the per-bar end instants — pass ``bar_end`` to :meth:`ordinal`, or feed
410
+ the previous day's last bar end via :meth:`note_bar_end` before rolling.
411
+
412
+ A bare ``ObservedDayCounter()`` (no template, ``fold=False``) is the plain
413
+ year-reset counter — ``ordinal(td)`` advances one slot per present day.
414
+ """
415
+
416
+ __slots__ = ('_cur', '_ordinal', '_cur_end', '_folded', '_fold', '_tz',
417
+ '_close_table')
418
+
419
+ def __init__(self,
420
+ tz: 'ZoneInfo | dt_timezone | None' = None,
421
+ opening_hours: 'list[SymInfoInterval] | None' = None,
422
+ fold: bool = False) -> None:
423
+ """
424
+ :param tz: Exchange timezone (only needed when ``fold`` is on)
425
+ :param opening_hours: ``SymInfo.opening_hours`` for the fold's scheduled
426
+ close table (no template -> fold disabled)
427
+ :param fold: Enable holiday half-day folding (intraday source only)
428
+ """
429
+ self._cur: date | None = None
430
+ self._ordinal = 0
431
+ self._cur_end: int | None = None # running max bar-end of the current day
432
+ self._folded = False # current day folded into the previous (chain-stop)
433
+ self._fold = fold and bool(opening_hours)
434
+ self._tz = tz
435
+ if self._fold:
436
+ assert opening_hours is not None
437
+ self._close_table = close_table_by_weekday(
438
+ opening_hours, overnight_starts_by_weekday(opening_hours))
439
+ else:
440
+ self._close_table: dict[int, tuple[dt_time, int]] = {}
441
+
442
+ def note_bar_end(self, bar_end: int | None) -> None:
443
+ """
444
+ Record a bar's end instant (epoch seconds) for the current trading day.
445
+
446
+ Keeps the running maximum so the fold can tell whether the day closed
447
+ early. Used when the counter does not see every bar (the ``_dg_*``
448
+ tracker fires once per trading day): feed the previous day's last bar end
449
+ before calling :meth:`ordinal` for the new day.
450
+
451
+ :param bar_end: Bar end instant in epoch seconds, or ``None`` (no-op)
452
+ """
453
+ if bar_end is not None and (self._cur_end is None or bar_end > self._cur_end):
454
+ self._cur_end = bar_end
455
+
456
+ def _is_early(self, day: date, end: int | None) -> bool:
457
+ """
458
+ Whether ``day``'s last real bar ``end`` precedes its scheduled close.
459
+
460
+ :param day: Trading day
461
+ :param end: The day's last bar end in epoch seconds
462
+ :return: ``True`` for an early-close (holiday half) day
463
+ """
464
+ if end is None:
465
+ return False
466
+ entry = self._close_table.get(day.weekday())
467
+ if entry is None:
468
+ return False
469
+ end_tod, offset = entry
470
+ close_date = day + timedelta(days=offset)
471
+ close_sec = int(datetime(
472
+ close_date.year, close_date.month, close_date.day,
473
+ end_tod.hour, end_tod.minute, end_tod.second,
474
+ tzinfo=self._tz).timestamp())
475
+ return end < close_sec
476
+
477
+ def ordinal(self, td: date, bar_end: int | None = None) -> int:
478
+ """
479
+ In-year scheduled ordinal of trading day ``td``.
480
+
481
+ :param td: Trading day of the current bar (must not decrease)
482
+ :param bar_end: This bar's end instant in epoch seconds (fold only)
483
+ :return: 0-based ordinal within ``td``'s year
484
+ """
485
+ cur = self._cur
486
+ if td != cur:
487
+ if cur is None:
488
+ self._ordinal = weekday_ordinal(td)
489
+ self._folded = False
490
+ elif td.year != cur.year:
491
+ self._ordinal = 0
492
+ self._folded = False
493
+ elif (self._fold and not self._folded
494
+ and (td - cur).days == 1
495
+ and self._is_early(cur, self._cur_end)):
496
+ # td is the holiday half folding into the early-close day cur:
497
+ # share its ordinal, and do not let the next day fold too.
498
+ self._folded = True
499
+ else:
500
+ self._ordinal += 1
501
+ self._folded = False
502
+ self._cur = td
503
+ self._cur_end = bar_end
504
+ elif bar_end is not None and (self._cur_end is None or bar_end > self._cur_end):
505
+ self._cur_end = bar_end
506
+ return self._ordinal
507
+
508
+ def key(self, modifier: str, multiplier: int) -> tuple[int, int]:
509
+ """
510
+ nD/nW/nM grouping key of the current trading day.
511
+
512
+ :param modifier: 'D', 'W' or 'M'
513
+ :param multiplier: Period multiplier (> 1)
514
+ :return: Period identity key
515
+ """
516
+ cur = self._cur
517
+ assert cur is not None
518
+ if modifier == 'D':
519
+ return cur.year, self._ordinal // multiplier
520
+ if modifier == 'W':
521
+ wy, week = observed_week_key(cur)
522
+ return wy, week // multiplier
523
+ return cur.year, (cur.month - 1) // multiplier
524
+
525
+
526
+ class Resampler:
527
+ """
528
+ Resampler class for handling different timeframes and calculating bar times.
529
+
530
+ This class provides functionality to resample data to different timeframes
531
+ and calculate the opening time of bars for various timeframe specifications.
532
+ """
533
+
534
+ _resamplers: ClassVar[dict[str, 'Resampler']] = {}
535
+
536
+ def __init__(self, timeframe: str):
537
+ """
538
+ Initialize resampler for a specific timeframe.
539
+
540
+ :param timeframe: Timeframe string (e.g., "1D", "4H", "60", "15")
541
+ """
542
+ self.timeframe = timeframe
543
+ self._validate_timeframe()
544
+
545
+ def _validate_timeframe(self) -> None:
546
+ """Validate that the timeframe is supported."""
547
+ try:
548
+ tf_module.in_seconds(self.timeframe)
549
+ except (ValueError, AssertionError) as e:
550
+ raise ValueError(f"Invalid timeframe: {self.timeframe}") from e
551
+
552
+ @classmethod
553
+ @lru_cache(maxsize=128)
554
+ def get_resampler(cls, timeframe: str) -> 'Resampler':
555
+ """
556
+ Get a resampler instance for the specified timeframe.
557
+
558
+ :param timeframe: Timeframe string
559
+ :return: Resampler instance
560
+ :raises ValueError: If timeframe is invalid
561
+ """
562
+ if timeframe not in cls._resamplers:
563
+ cls._resamplers[timeframe] = cls(timeframe)
564
+ return cls._resamplers[timeframe]
565
+
566
+ def get_bar_time(self, current_time_ms: int,
567
+ tz: ZoneInfo | dt_timezone | None = None,
568
+ session_starts: 'list[SymInfoSession] | None' = None,
569
+ opening_hours: 'list[SymInfoInterval] | None' = None,
570
+ mode: str | None = None) -> int:
571
+ """
572
+ Calculate the bar opening time for the current timeframe.
573
+
574
+ For daily, weekly, and monthly timeframes, the timezone determines where
575
+ midnight falls — i.e., which calendar day a timestamp belongs to.
576
+
577
+ For intraday timeframes the bar grid is, by default, a pure UTC-epoch
578
+ clock-floor (session-unaware fast path). When ``session_starts`` is given
579
+ the grid is instead anchored to the session open — matching TradingView,
580
+ which aligns intraday HTF bars to the session open rather than the UTC
581
+ clock (e.g. a 09:30 open at 1H yields 09:30, 10:30, 11:30…). For sessions
582
+ that open on a ``tf`` boundary the two are identical; callers therefore
583
+ pass ``session_starts`` only when the open is actually off-grid, so the
584
+ common case keeps the zero-overhead fast path.
585
+
586
+ Multi-period timeframes (nD/nW/nM with n > 1) live on the year-reset
587
+ scheduled grid (see the module docs): periods count scheduled trading
588
+ days/weeks/months from the year's first scheduled one, and each period
589
+ is stamped with its first scheduled day's session open. For 'observed'
590
+ symbols (exchange-listed) this arithmetic is only the dataless fallback
591
+ on the weekday grid — data-driven callers count actual trading days.
592
+
593
+ :param current_time_ms: Current time in milliseconds (UNIX timestamp)
594
+ :param tz: Timezone for day/week/month boundary calculation, and for
595
+ locating session opens when ``session_starts`` is given.
596
+ If None, uses the system's local timezone.
597
+ :param session_starts: Per-trading-day primary opens for intraday session
598
+ anchoring and for multi-period session-open stamps.
599
+ ``None`` (default) selects the pure clock-floor.
600
+ :param opening_hours: ``SymInfo.opening_hours`` — preferred source for
601
+ the trading-day roll of multi-period grids (inferred from
602
+ ``session_starts`` when omitted).
603
+ :param mode: Scheduled-grid mode from :func:`grid_mode`; ``None`` infers
604
+ 'calendar' when the session template covers all 7 days,
605
+ 'weekday' otherwise. Only multi-period timeframes use it.
606
+ :return: Bar opening time in milliseconds
607
+ """
608
+ # Convert to seconds for calculations
609
+ current_time_sec = current_time_ms // 1000
610
+
611
+ # Get timeframe in seconds
612
+ tf_seconds = tf_module.in_seconds(self.timeframe)
613
+
614
+ # Calculate bar opening time based on timeframe type
615
+ # noinspection PyProtectedMember
616
+ modifier, multiplier = tf_module._process_tf(self.timeframe)
617
+
618
+ if modifier in ('S', ''): # Seconds / minutes (intraday)
619
+ if session_starts is None:
620
+ # Pure UTC-epoch clock-floor — session-unaware fast path
621
+ bar_start_sec = (current_time_sec // tf_seconds) * tf_seconds
622
+ else:
623
+ # Session-anchored grid. Anchor and step in absolute epoch
624
+ # seconds so the boundaries stay correct across DST transitions.
625
+ anchor_sec = _session_anchor_sec(current_time_sec, tz, session_starts)
626
+ bar_start_sec = anchor_sec + (
627
+ (current_time_sec - anchor_sec) // tf_seconds) * tf_seconds
628
+
629
+ elif modifier in ('D', 'W', 'M') and multiplier > 1:
630
+ # Multi-period: year-reset scheduled grid stamped at session opens
631
+ on = overnight_opens(opening_hours, session_starts)
632
+ if mode in ('calendar', 'weekday'):
633
+ gmode = mode
634
+ else:
635
+ day_set = {day for day, _t in session_starts} if session_starts else set()
636
+ gmode = 'calendar' if len(day_set) == 7 else 'weekday'
637
+ td = trading_day(current_time_sec, tz, on)
638
+
639
+ if modifier == 'D':
640
+ idx = scheduled_day_ordinal(td, gmode)
641
+ slot = scheduled_day_from_ordinal(
642
+ td.year, (idx // multiplier) * multiplier, gmode)
643
+
644
+ elif modifier == 'W':
645
+ # A week belongs to its Monday's year; weeks count from the
646
+ # year's first Monday.
647
+ monday = td - timedelta(days=td.weekday())
648
+ fm = first_monday(monday.year)
649
+ weeks = (monday - fm).days // 7
650
+ slot = fm + timedelta(weeks=(weeks // multiplier) * multiplier)
651
+
652
+ else: # 'M' — calendar months grouped within the year
653
+ m0 = ((td.month - 1) // multiplier) * multiplier + 1
654
+ slot = date(td.year, m0, 1)
655
+ if gmode == 'weekday' and slot.weekday() >= 5:
656
+ # First scheduled day of the period's first month
657
+ slot += timedelta(days=7 - slot.weekday())
658
+
659
+ bar_start_sec = trading_day_open_sec(slot, tz, session_starts, on)
660
+
661
+ elif modifier in ('D', 'W', 'M'):
662
+ # Daily/Weekly/Monthly — timezone matters for calendar alignment
663
+ if tz is not None:
664
+ current_dt = datetime.fromtimestamp(current_time_sec, tz=tz)
665
+ else:
666
+ current_dt = datetime.fromtimestamp(current_time_sec)
667
+
668
+ if modifier == 'D': # Daily
669
+ bar_start_dt = current_dt.replace(hour=0, minute=0, second=0, microsecond=0)
670
+
671
+ elif modifier == 'W': # Weekly
672
+ bar_start_dt = current_dt.replace(hour=0, minute=0, second=0, microsecond=0)
673
+ days_to_monday = bar_start_dt.weekday() # 0 = Monday
674
+ bar_start_dt -= timedelta(days=days_to_monday)
675
+
676
+ else: # Monthly
677
+ bar_start_dt = current_dt.replace(
678
+ day=1, hour=0, minute=0, second=0, microsecond=0)
679
+
680
+ bar_start_sec = int(bar_start_dt.timestamp())
681
+
682
+ else:
683
+ raise ValueError(f"Unsupported timeframe modifier: {modifier}")
684
+
685
+ # Convert back to milliseconds
686
+ return bar_start_sec * 1000