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.
- opencode_pyneruntime-6.6.4.dist-info/METADATA +281 -0
- opencode_pyneruntime-6.6.4.dist-info/RECORD +261 -0
- opencode_pyneruntime-6.6.4.dist-info/WHEEL +5 -0
- opencode_pyneruntime-6.6.4.dist-info/entry_points.txt +6 -0
- opencode_pyneruntime-6.6.4.dist-info/licenses/LICENSE +201 -0
- opencode_pyneruntime-6.6.4.dist-info/licenses/NOTICE +21 -0
- opencode_pyneruntime-6.6.4.dist-info/top_level.txt +1 -0
- pynecore/__init__.py +6 -0
- pynecore/cli/__init__.py +2 -0
- pynecore/cli/app.py +238 -0
- pynecore/cli/commands/__init__.py +343 -0
- pynecore/cli/commands/benchmark.py +186 -0
- pynecore/cli/commands/compile.py +198 -0
- pynecore/cli/commands/data.py +857 -0
- pynecore/cli/commands/debug.py +63 -0
- pynecore/cli/commands/optimize.py +956 -0
- pynecore/cli/commands/plugin.py +242 -0
- pynecore/cli/commands/run.py +2006 -0
- pynecore/cli/pluggable.py +132 -0
- pynecore/cli/utils/__init__.py +0 -0
- pynecore/cli/utils/api_error_handler.py +168 -0
- pynecore/cli/utils/broker_picker.py +330 -0
- pynecore/cli/utils/error_hook.py +28 -0
- pynecore/cli/utils/keyreader.py +178 -0
- pynecore/cli/utils/provider_picker.py +19 -0
- pynecore/cli/utils/symbol_browser.py +1149 -0
- pynecore/core/__init__.py +0 -0
- pynecore/core/aggregator.py +257 -0
- pynecore/core/bar_magnifier.py +168 -0
- pynecore/core/broker/__init__.py +64 -0
- pynecore/core/broker/defaults.py +113 -0
- pynecore/core/broker/disappearance.py +927 -0
- pynecore/core/broker/emulator.py +345 -0
- pynecore/core/broker/exceptions.py +346 -0
- pynecore/core/broker/idempotency.py +401 -0
- pynecore/core/broker/intent_builder.py +334 -0
- pynecore/core/broker/journal.py +1785 -0
- pynecore/core/broker/models.py +1600 -0
- pynecore/core/broker/native_failsafe_manager.py +1436 -0
- pynecore/core/broker/one_way_emulator.py +1128 -0
- pynecore/core/broker/position.py +787 -0
- pynecore/core/broker/run_identity.py +126 -0
- pynecore/core/broker/software_entry_stop_engine.py +351 -0
- pynecore/core/broker/software_partial_bracket_engine.py +1379 -0
- pynecore/core/broker/spot_inventory.py +1327 -0
- pynecore/core/broker/storage.py +2655 -0
- pynecore/core/broker/store_helpers.py +2161 -0
- pynecore/core/broker/sync_engine.py +16070 -0
- pynecore/core/broker/validation.py +382 -0
- pynecore/core/class_property.py +7 -0
- pynecore/core/config.py +392 -0
- pynecore/core/csv_file.py +547 -0
- pynecore/core/currency.py +262 -0
- pynecore/core/data_converter.py +1002 -0
- pynecore/core/datetime.py +296 -0
- pynecore/core/download_info.py +71 -0
- pynecore/core/download_runner.py +274 -0
- pynecore/core/htf_aggregator.py +181 -0
- pynecore/core/import_hook.py +358 -0
- pynecore/core/instance_state.py +494 -0
- pynecore/core/live_ltf_collector.py +442 -0
- pynecore/core/live_ltf_window.py +189 -0
- pynecore/core/live_runner.py +1347 -0
- pynecore/core/module_property.py +26 -0
- pynecore/core/ohlcv_file.py +1888 -0
- pynecore/core/overload.py +371 -0
- pynecore/core/pine_cast.py +113 -0
- pynecore/core/pine_export.py +95 -0
- pynecore/core/pine_method.py +244 -0
- pynecore/core/pine_range.py +86 -0
- pynecore/core/pine_udt.py +69 -0
- pynecore/core/plugin/__init__.py +394 -0
- pynecore/core/plugin/broker.py +781 -0
- pynecore/core/plugin/cli.py +96 -0
- pynecore/core/plugin/live_provider.py +208 -0
- pynecore/core/plugin/provider.py +331 -0
- pynecore/core/provider_string.py +148 -0
- pynecore/core/random.py +40 -0
- pynecore/core/resampler.py +686 -0
- pynecore/core/safe_convert.py +64 -0
- pynecore/core/script.py +1011 -0
- pynecore/core/script_runner.py +3202 -0
- pynecore/core/security.py +1749 -0
- pynecore/core/security_process.py +1253 -0
- pynecore/core/security_shm.py +456 -0
- pynecore/core/series.py +417 -0
- pynecore/core/strategy_stats.py +669 -0
- pynecore/core/symbol_map.py +134 -0
- pynecore/core/syminfo.py +505 -0
- pynecore/core/viz.py +591 -0
- pynecore/lib/__init__.py +1771 -0
- pynecore/lib/_fixnan.py +32 -0
- pynecore/lib/_math_stateful.py +202 -0
- pynecore/lib/_timeframe_change.py +101 -0
- pynecore/lib/adjustment.py +6 -0
- pynecore/lib/alert.py +39 -0
- pynecore/lib/alert.pyi +14 -0
- pynecore/lib/array.py +1051 -0
- pynecore/lib/barmerge.py +60 -0
- pynecore/lib/barstate.py +30 -0
- pynecore/lib/box.py +415 -0
- pynecore/lib/chart.py +128 -0
- pynecore/lib/color.py +152 -0
- pynecore/lib/color.pyi +50 -0
- pynecore/lib/currency.py +62 -0
- pynecore/lib/dayofweek.py +36 -0
- pynecore/lib/dayofweek.pyi +18 -0
- pynecore/lib/display.py +8 -0
- pynecore/lib/dividends.py +9 -0
- pynecore/lib/earnings.py +11 -0
- pynecore/lib/extend.py +6 -0
- pynecore/lib/font.py +5 -0
- pynecore/lib/footprint.py +79 -0
- pynecore/lib/format.py +11 -0
- pynecore/lib/hline.py +67 -0
- pynecore/lib/hline.pyi +24 -0
- pynecore/lib/label.py +409 -0
- pynecore/lib/line.py +433 -0
- pynecore/lib/linefill.py +93 -0
- pynecore/lib/location.py +11 -0
- pynecore/lib/log.py +362 -0
- pynecore/lib/map.py +150 -0
- pynecore/lib/math.py +385 -0
- pynecore/lib/matrix.py +708 -0
- pynecore/lib/order.py +8 -0
- pynecore/lib/pivotpointtype.py +8 -0
- pynecore/lib/plot.py +95 -0
- pynecore/lib/plot.pyi +33 -0
- pynecore/lib/polyline.py +91 -0
- pynecore/lib/position.py +15 -0
- pynecore/lib/request.py +281 -0
- pynecore/lib/runtime.py +5 -0
- pynecore/lib/scale.py +9 -0
- pynecore/lib/session.py +267 -0
- pynecore/lib/session.pyi +12 -0
- pynecore/lib/shape.py +18 -0
- pynecore/lib/size.py +12 -0
- pynecore/lib/splits.py +4 -0
- pynecore/lib/strategy/__init__.py +4778 -0
- pynecore/lib/strategy/closedtrades.py +347 -0
- pynecore/lib/strategy/closedtrades.pyi +53 -0
- pynecore/lib/strategy/commission.py +9 -0
- pynecore/lib/strategy/direction.py +9 -0
- pynecore/lib/strategy/oca.py +13 -0
- pynecore/lib/strategy/opentrades.py +281 -0
- pynecore/lib/strategy/opentrades.pyi +49 -0
- pynecore/lib/strategy/risk.py +109 -0
- pynecore/lib/string.py +649 -0
- pynecore/lib/syminfo.py +84 -0
- pynecore/lib/ta.py +2230 -0
- pynecore/lib/table.py +290 -0
- pynecore/lib/text.py +17 -0
- pynecore/lib/ticker.py +207 -0
- pynecore/lib/timeframe.py +293 -0
- pynecore/lib/volume_row.py +67 -0
- pynecore/lib/xloc.py +4 -0
- pynecore/lib/yloc.py +5 -0
- pynecore/providers/__init__.py +0 -0
- pynecore/providers/ccxt.py +664 -0
- pynecore/providers/replay.py +187 -0
- pynecore/pynesys/__init__.py +0 -0
- pynecore/pynesys/api.py +498 -0
- pynecore/pynesys/compiler.py +112 -0
- pynecore/standalone.py +99 -0
- pynecore/testing/__init__.py +1 -0
- pynecore/testing/broker_lab/__init__.py +41 -0
- pynecore/testing/broker_lab/__main__.py +5 -0
- pynecore/testing/broker_lab/cli.py +87 -0
- pynecore/testing/broker_lab/generate.py +47 -0
- pynecore/testing/broker_lab/model.py +84 -0
- pynecore/testing/broker_lab/reference.py +645 -0
- pynecore/testing/broker_lab/runner.py +372 -0
- pynecore/testing/broker_lab/scheduler.py +50 -0
- pynecore/testing/broker_lab/subprocess.py +73 -0
- pynecore/transformers/__init__.py +0 -0
- pynecore/transformers/builtin_shadow.py +136 -0
- pynecore/transformers/closure_arguments_transformer.py +428 -0
- pynecore/transformers/display_rewrite.py +140 -0
- pynecore/transformers/dynamic_default.py +147 -0
- pynecore/transformers/function_isolation.py +757 -0
- pynecore/transformers/import_lifter.py +61 -0
- pynecore/transformers/import_normalizer.py +328 -0
- pynecore/transformers/inline_series_hoist.py +178 -0
- pynecore/transformers/input_transformer.py +175 -0
- pynecore/transformers/lib_series.py +201 -0
- pynecore/transformers/locations.py +70 -0
- pynecore/transformers/module_properties.json +3387 -0
- pynecore/transformers/module_property.py +221 -0
- pynecore/transformers/ne_guard.py +70 -0
- pynecore/transformers/persistent.py +320 -0
- pynecore/transformers/persistent_series.py +76 -0
- pynecore/transformers/safe_convert_transformer.py +97 -0
- pynecore/transformers/safe_division_transformer.py +95 -0
- pynecore/transformers/script_requirements.py +308 -0
- pynecore/transformers/security.py +752 -0
- pynecore/transformers/security_instantiation.py +274 -0
- pynecore/transformers/series.py +275 -0
- pynecore/transformers/slot_layout.py +381 -0
- pynecore/transformers/type_checking_stripper.py +25 -0
- pynecore/transformers/unused_series_detector.py +267 -0
- pynecore/types/__init__.py +21 -0
- pynecore/types/alert.py +5 -0
- pynecore/types/barmerge.py +5 -0
- pynecore/types/base.py +39 -0
- pynecore/types/box.py +37 -0
- pynecore/types/chart.py +17 -0
- pynecore/types/color.py +107 -0
- pynecore/types/currency.py +5 -0
- pynecore/types/datetime.py +6 -0
- pynecore/types/display.py +5 -0
- pynecore/types/dividends.py +5 -0
- pynecore/types/earnings.py +5 -0
- pynecore/types/extend.py +5 -0
- pynecore/types/font.py +5 -0
- pynecore/types/footprint.py +41 -0
- pynecore/types/format.py +5 -0
- pynecore/types/hline.py +24 -0
- pynecore/types/ib_persistent.py +8 -0
- pynecore/types/ib_persistent.pyi +10 -0
- pynecore/types/label.py +35 -0
- pynecore/types/line.py +32 -0
- pynecore/types/linefill.py +13 -0
- pynecore/types/location.py +5 -0
- pynecore/types/matrix.py +999 -0
- pynecore/types/na.py +237 -0
- pynecore/types/na.pyi +83 -0
- pynecore/types/ohlcv.py +12 -0
- pynecore/types/order.py +5 -0
- pynecore/types/persistent.py +8 -0
- pynecore/types/persistent.pyi +13 -0
- pynecore/types/pine_types.py +11 -0
- pynecore/types/pine_types.pyi +15 -0
- pynecore/types/pivotpointtype.py +5 -0
- pynecore/types/plot.py +12 -0
- pynecore/types/plot_meta.py +60 -0
- pynecore/types/polyline.py +40 -0
- pynecore/types/position.py +5 -0
- pynecore/types/scale.py +5 -0
- pynecore/types/script_type.py +15 -0
- pynecore/types/series.py +23 -0
- pynecore/types/series.pyi +19 -0
- pynecore/types/session.py +35 -0
- pynecore/types/shape.py +5 -0
- pynecore/types/size.py +5 -0
- pynecore/types/source.py +33 -0
- pynecore/types/splits.py +5 -0
- pynecore/types/strategy.py +45 -0
- pynecore/types/table.py +87 -0
- pynecore/types/text.py +13 -0
- pynecore/types/type_checker.py +7 -0
- pynecore/types/type_checker.pyi +48 -0
- pynecore/types/volume_row.py +36 -0
- pynecore/types/weekdays.py +11 -0
- pynecore/types/xloc.py +5 -0
- pynecore/types/yloc.py +5 -0
- pynecore/utils/__init__.py +0 -0
- pynecore/utils/file_utils.py +50 -0
- pynecore/utils/rich/__init__.py +0 -0
- pynecore/utils/rich/date_column.py +25 -0
- pynecore/utils/sequence_view.py +92 -0
- 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
|