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,134 @@
1
+ """
2
+ Global workdir-level symbol map for ``request.security()`` resolution.
3
+
4
+ Scripts use TradingView-canonical symbols (``NASDAQ:AAPL``); the user's own
5
+ data files carry provider-native symbols (``capitalcom:AAPL``, or a
6
+ multi-broker ``ccxt:BYBIT:BTC/USDT:USDT``). The global map
7
+ (``<workdir>/config/symbol_map.toml``) translates one into the other so both
8
+ backtest (file resolution) and live (``PluginSymbol`` construction) can find
9
+ the right data without an explicit ``--security`` mapping.
10
+
11
+ File schema::
12
+
13
+ [symbol_map]
14
+ "BINANCE:BTCUSDT" = "ccxt:BYBIT:BTC/USDT:USDT"
15
+ "NASDAQ:AAPL" = "capitalcom:AAPL"
16
+ "NASDAQ:AAPL:60" = "capitalcom:AAPL" # optional per-timeframe override
17
+
18
+ The KEY is the TradingView-style ``PREFIX:TICKER`` written in the script, with
19
+ an optional ``:TF`` suffix for a per-timeframe override. The VALUE is a
20
+ provider-qualified NATIVE symbol string in the same format as the ``[download]``
21
+ provider string (``provider:rest``, where ``rest`` may itself contain colons).
22
+ It is NOT a filename — the backtest ``.ohlcv`` path is derived deterministically
23
+ via :meth:`ProviderPlugin.get_ohlcv_path`.
24
+
25
+ A missing or malformed file yields an empty map (never crashes a run); parse
26
+ errors are logged as warnings.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import logging
32
+ import tomllib
33
+ from dataclasses import dataclass, field
34
+ from pathlib import Path
35
+
36
+ __all__ = ['MappedSymbol', 'SymbolMap', 'SYMBOL_MAP_FILENAME']
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+ #: Name of the global symbol map file inside ``<workdir>/config``.
41
+ SYMBOL_MAP_FILENAME = 'symbol_map.toml'
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class MappedSymbol:
46
+ """A resolved global-map entry: a provider-qualified native symbol.
47
+
48
+ :ivar provider: Plugin entry-point name (e.g. ``"ccxt"``, ``"capitalcom"``).
49
+ :ivar native_symbol: The provider-native symbol (the ``rest`` of the
50
+ ``provider:rest`` value; may itself contain further colons, e.g.
51
+ ``"BYBIT:BTC/USDT:USDT"`` for a multi-broker provider).
52
+ """
53
+ provider: str
54
+ native_symbol: str
55
+
56
+ @classmethod
57
+ def parse(cls, value: str) -> 'MappedSymbol | None':
58
+ """Parse a ``"provider:rest"`` value into a :class:`MappedSymbol`.
59
+
60
+ :param value: The provider-qualified native symbol string.
61
+ :return: The parsed :class:`MappedSymbol`, or ``None`` when the value
62
+ is malformed (no colon, empty provider, or empty native symbol).
63
+ """
64
+ provider, sep, rest = value.partition(':')
65
+ if not sep or not provider or not rest:
66
+ return None
67
+ return cls(provider=provider, native_symbol=rest)
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class SymbolMap:
72
+ """The parsed ``[symbol_map]`` table of ``config/symbol_map.toml``."""
73
+
74
+ entries: dict[str, MappedSymbol] = field(default_factory=dict)
75
+
76
+ @classmethod
77
+ def load(cls, config_dir: 'Path | str | None') -> 'SymbolMap':
78
+ """Load the global symbol map from ``config_dir/symbol_map.toml``.
79
+
80
+ A missing file, an unreadable file, or a malformed ``[symbol_map]``
81
+ table degrades to an empty map (a run is never crashed by the map).
82
+ Parse errors and individual malformed entries are logged as warnings.
83
+
84
+ :param config_dir: The workdir ``config`` directory, or ``None``.
85
+ :return: A :class:`SymbolMap` (empty when nothing could be loaded).
86
+ """
87
+ if config_dir is None:
88
+ return cls()
89
+ path = Path(config_dir) / SYMBOL_MAP_FILENAME
90
+ if not path.is_file():
91
+ return cls()
92
+ try:
93
+ with open(path, 'rb') as f:
94
+ data = tomllib.load(f)
95
+ except (OSError, tomllib.TOMLDecodeError) as e:
96
+ logger.warning("Could not read symbol map %s: %s", path, e)
97
+ return cls()
98
+ table = data.get('symbol_map')
99
+ if not isinstance(table, dict):
100
+ if table is not None:
101
+ logger.warning("Ignoring [symbol_map] in %s: expected a table", path)
102
+ return cls()
103
+ entries: dict[str, MappedSymbol] = {}
104
+ for key, value in table.items():
105
+ if not isinstance(value, str):
106
+ logger.warning("Ignoring non-string symbol_map entry %r in %s", key, path)
107
+ continue
108
+ mapped = MappedSymbol.parse(value)
109
+ if mapped is None:
110
+ logger.warning(
111
+ "Ignoring malformed symbol_map entry %r = %r in %s "
112
+ "(expected 'provider:native_symbol')", key, value, path)
113
+ continue
114
+ entries[key] = mapped
115
+ return cls(entries=entries)
116
+
117
+ def resolve(self, symbol: str, timeframe: 'str | None' = None) -> 'MappedSymbol | None':
118
+ """Resolve a TradingView-style symbol to a provider-native mapping.
119
+
120
+ Key precedence mirrors ``_resolve_security_data``: a ``"SYMBOL:TF"``
121
+ per-timeframe override wins over a bare ``"SYMBOL"`` entry.
122
+
123
+ :param symbol: The TradingView-style ``PREFIX:TICKER`` from the script.
124
+ :param timeframe: The security timeframe (used for the ``:TF`` override).
125
+ :return: The :class:`MappedSymbol`, or ``None`` when unmapped.
126
+ """
127
+ if timeframe:
128
+ tf_hit = self.entries.get(f"{symbol}:{timeframe}")
129
+ if tf_hit is not None:
130
+ return tf_hit
131
+ return self.entries.get(symbol)
132
+
133
+ def __bool__(self) -> bool:
134
+ return bool(self.entries)
@@ -0,0 +1,505 @@
1
+ from typing import Literal, NamedTuple, Self
2
+ from pathlib import Path
3
+ from dataclasses import dataclass, field
4
+ from datetime import time, date, datetime
5
+
6
+ SymInfoInterval = NamedTuple("SymInfoInterval", [('day', int), ('start', time), ('end', time)])
7
+ SymInfoSession = NamedTuple("SymInfoSession", [('day', int), ('time', time)])
8
+ SymInfoScheduleVariant = NamedTuple("SymInfoScheduleVariant", [
9
+ ('effective_from', date),
10
+ ('opening_hours', list[SymInfoInterval]),
11
+ ('session_starts', list[SymInfoSession]),
12
+ ('session_ends', list[SymInfoSession]),
13
+ ])
14
+ """One era of a symbol's trading schedule, in effect from ``effective_from``
15
+ (an exchange-local trading-day date) until the next variant's date. See
16
+ :attr:`SymInfo.session_schedules`."""
17
+
18
+ # Commented TOML appended by ``save_toml`` when a symbol has no schedule history,
19
+ # so anyone opening the file sees how to add one. Valid TOML if uncommented.
20
+ _SESSION_SCHEDULE_EXAMPLE_COMMENT = """\
21
+ # Effective-dated session history (optional).
22
+ #
23
+ # Markets occasionally change their trading hours. A single static schedule then
24
+ # mis-confirms the part of a backtest that falls on the other side of the change.
25
+ # Add [[session_schedules]] blocks below -- oldest first, each self-contained --
26
+ # to describe that history. `effective_from` MUST be the first line of its block
27
+ # (an exchange-local trading-day date). A bar whose trading day is on or after a
28
+ # block's date uses that block; a date before the earliest block uses the
29
+ # earliest block. The flat opening_hours / session_starts / session_ends above
30
+ # are regenerated from the NEWEST variant on save, so edit the variants here, not
31
+ # the flat blocks.
32
+ #
33
+ # Example -- a market whose night session END moved 23:30 -> 23:00 on 2026-01-12:
34
+ #
35
+ # [[session_schedules]]
36
+ # effective_from = 2025-06-01
37
+ # [[session_schedules.opening_hours]]
38
+ # day = 0
39
+ # start = "10:00:00"
40
+ # end = "18:00:00"
41
+ # [[session_schedules.opening_hours]]
42
+ # day = 0
43
+ # start = "21:00:00"
44
+ # end = "23:30:00"
45
+ # [[session_schedules.session_starts]]
46
+ # day = 0
47
+ # time = "10:00:00"
48
+ # [[session_schedules.session_ends]]
49
+ # day = 0
50
+ # time = "23:30:00"
51
+ #
52
+ # [[session_schedules]]
53
+ # effective_from = 2026-01-12
54
+ # [[session_schedules.opening_hours]]
55
+ # day = 0
56
+ # start = "10:00:00"
57
+ # end = "18:00:00"
58
+ # [[session_schedules.opening_hours]]
59
+ # day = 0
60
+ # start = "21:00:00"
61
+ # end = "23:00:00"
62
+ # [[session_schedules.session_starts]]
63
+ # day = 0
64
+ # time = "10:00:00"
65
+ # [[session_schedules.session_ends]]
66
+ # day = 0
67
+ # time = "23:00:00\""""
68
+
69
+
70
+ def default_mincontract(sym_type: str, basecurrency: str | None = None) -> float:
71
+ """
72
+ Heuristic minimum order quantity step for symbols whose data source does
73
+ not expose one.
74
+
75
+ Crypto exchanges quote fractional lot steps (BTC pairs commonly 1e-5,
76
+ other coins 1e-4); everything else trades in whole contracts. This is the
77
+ last resort of the ``mincontract`` resolution chain (exchange value ->
78
+ volume-data analysis -> heuristic), and it also fills the gap when
79
+ loading symbol info saved before ``mincontract`` existed.
80
+
81
+ :param sym_type: The symbol type (``SymInfo.type``).
82
+ :param basecurrency: The symbol's base currency, if known.
83
+ :return: The estimated minimum quantity step.
84
+ """
85
+ if sym_type == 'crypto':
86
+ return 1e-05 if basecurrency == 'BTC' else 1e-04
87
+ return 1.0
88
+
89
+
90
+ @dataclass(kw_only=True, slots=True)
91
+ class SymInfo:
92
+ """
93
+ Symbol information dataclass
94
+
95
+ It is stored in TOML format in the working directory. It is initially from the provider, but
96
+ users can edit according to their needs to make it compatible with the TradingView platform.
97
+ It is almost impossible to make providers fully compatible, this is why users may need to
98
+ edit the symbol information in very specific cases.
99
+ """
100
+ prefix: str
101
+ description: str
102
+ ticker: str
103
+ currency: str
104
+ basecurrency: str | None = None
105
+ period: str
106
+ type: Literal[
107
+ "stock", "fund", "dr", "right", "bond", "warrant", "structured", "index", "forex",
108
+ "futures", "spread", "economic", "fundamental", "crypto", "spot", "swap", "option",
109
+ "commodity", "other"
110
+ ]
111
+ volumetype: Literal["base", "quote", "tick", "n/a"] = 'base'
112
+ mintick: float
113
+ pricescale: int
114
+ minmove: int = 1
115
+ pointvalue: float
116
+ mincontract: float
117
+ """Minimum order quantity step (Pine ``syminfo.mincontract``); order sizes
118
+ are truncated to this grid. Always positive: filled from the exchange when
119
+ available, otherwise estimated from volume data or the
120
+ :func:`default_mincontract` heuristic."""
121
+ opening_hours: list[SymInfoInterval]
122
+ session_starts: list[SymInfoSession]
123
+ session_ends: list[SymInfoSession]
124
+ session_schedules: list[SymInfoScheduleVariant] = field(default_factory=list)
125
+ """Optional effective-dated session history (oldest first). When non-empty the
126
+ flat ``opening_hours`` / ``session_starts`` / ``session_ends`` mirror the
127
+ newest variant; consumers that need a schedule for a specific date resolve it
128
+ via :meth:`schedule_for` / :meth:`schedule_index_for`. Empty for the common
129
+ case of a symbol whose hours never changed."""
130
+ timezone: str = 'UTC'
131
+
132
+ avg_spread: float | None = None
133
+ taker_fee: float | None = None
134
+ maker_fee: float | None = None
135
+
136
+ # Reference data (None when the data source does not expose it)
137
+ country: str | None = None
138
+ sector: str | None = None
139
+ industry: str | None = None
140
+ isin: str | None = None
141
+
142
+ # Futures contract information
143
+ expiration_date: int | None = None # UNIX timestamp
144
+ current_contract: str | None = None
145
+
146
+ # Fundamentals (stocks only, None elsewhere)
147
+ employees: int | None = None
148
+ shareholders: int | None = None
149
+ shares_outstanding_total: float | None = None
150
+ shares_outstanding_float: float | None = None
151
+
152
+ # Analyst recommendation counts
153
+ recommendations_buy: int | None = None
154
+ recommendations_buy_strong: int | None = None
155
+ recommendations_date: int | None = None # UNIX timestamp
156
+ recommendations_hold: int | None = None
157
+ recommendations_sell: int | None = None
158
+ recommendations_sell_strong: int | None = None
159
+ recommendations_total: int | None = None
160
+
161
+ # Analyst price target information (added 2025-07-08)
162
+ target_price_average: float | None = None
163
+ target_price_high: float | None = None
164
+ target_price_low: float | None = None
165
+ target_price_median: float | None = None
166
+ target_price_date: int | None = None # UNIX timestamp
167
+ target_price_estimates: int | None = None
168
+
169
+ @classmethod
170
+ def load_toml(cls, path: Path) -> Self:
171
+ """
172
+ Load SymInfo object from TOML file.
173
+
174
+ :param path: Path to the TOML file
175
+ :return: SymInfo instance
176
+ :raises ValueError: If required fields are missing or invalid
177
+ """
178
+ import tomllib
179
+
180
+ with open(path, 'rb') as f:
181
+ data = tomllib.load(f)
182
+
183
+ if 'symbol' not in data:
184
+ raise ValueError("Missing [symbol] section in TOML")
185
+
186
+ symbol = data['symbol']
187
+
188
+ # Parse time strings in arrays
189
+ # noinspection PyShadowingNames
190
+ def parse_time(time_str: str) -> time:
191
+ """Parse time string in HH:MM:SS fmt"""
192
+ h, m, s = map(int, time_str.split(':'))
193
+ return time(h, m, s)
194
+
195
+ # Convert opening hours
196
+ opening_hours = []
197
+ for oh in data.get('opening_hours', []):
198
+ opening_hours.append(SymInfoInterval(
199
+ day=oh['day'],
200
+ start=parse_time(oh['start']),
201
+ end=parse_time(oh['end'])
202
+ ))
203
+
204
+ # Convert session times
205
+ session_starts = []
206
+ for s in data.get('session_starts', []):
207
+ session_starts.append(SymInfoSession(
208
+ day=s['day'],
209
+ time=parse_time(s['time'])
210
+ ))
211
+
212
+ session_ends = []
213
+ for s in data.get('session_ends', []):
214
+ session_ends.append(SymInfoSession(
215
+ day=s['day'],
216
+ time=parse_time(s['time'])
217
+ ))
218
+
219
+ # Effective-dated session history (optional). Each variant is a
220
+ # self-contained schedule taking effect on its ``effective_from``
221
+ # exchange-local trading day. The list is sorted ascending and, when
222
+ # present, overwrites the flat fields above with the NEWEST variant so
223
+ # setup-time classification and the live "now" both see today's calendar.
224
+ # noinspection PyShadowingNames
225
+ def parse_effective_from(value: object) -> date:
226
+ """Normalize a TOML local-date / local-datetime / ISO string to a date."""
227
+ if isinstance(value, datetime): # datetime first: it subclasses date
228
+ return value.date()
229
+ if isinstance(value, date):
230
+ return value
231
+ if isinstance(value, str):
232
+ return date.fromisoformat(value.replace(' ', 'T').split('T')[0])
233
+ raise ValueError("Invalid session_schedules effective_from type "
234
+ f"{type(value).__name__}: expected a date or YYYY-MM-DD string")
235
+
236
+ session_schedules: list[SymInfoScheduleVariant] = []
237
+ seen_effective: set[date] = set()
238
+ for sched in data.get('session_schedules', []):
239
+ if 'effective_from' not in sched:
240
+ raise ValueError("session_schedules entry is missing 'effective_from'")
241
+ eff = parse_effective_from(sched['effective_from'])
242
+ if eff in seen_effective:
243
+ raise ValueError(f"Duplicate session_schedules effective_from: {eff}")
244
+ seen_effective.add(eff)
245
+ session_schedules.append(SymInfoScheduleVariant(
246
+ effective_from=eff,
247
+ opening_hours=[SymInfoInterval(day=oh['day'], start=parse_time(oh['start']),
248
+ end=parse_time(oh['end']))
249
+ for oh in sched.get('opening_hours', [])],
250
+ session_starts=[SymInfoSession(day=s['day'], time=parse_time(s['time']))
251
+ for s in sched.get('session_starts', [])],
252
+ session_ends=[SymInfoSession(day=s['day'], time=parse_time(s['time']))
253
+ for s in sched.get('session_ends', [])],
254
+ ))
255
+ session_schedules.sort(key=lambda v: v.effective_from)
256
+ if session_schedules:
257
+ # History is the source of truth: the flat blocks mirror the newest.
258
+ newest = session_schedules[-1]
259
+ opening_hours = newest.opening_hours
260
+ session_starts = newest.session_starts
261
+ session_ends = newest.session_ends
262
+
263
+ # Create instance with all fields
264
+ return cls(
265
+ prefix=symbol['prefix'],
266
+ description=symbol['description'],
267
+ ticker=symbol['ticker'],
268
+ currency=symbol['currency'],
269
+ basecurrency=symbol['basecurrency'] if 'basecurrency' in symbol else None,
270
+ period=symbol['period'],
271
+ type=symbol['type'],
272
+ mintick=symbol['mintick'],
273
+ pricescale=symbol['pricescale'],
274
+ minmove=symbol.get('minmove', 1),
275
+ pointvalue=symbol['pointvalue'],
276
+ # Files saved before mincontract existed fall back to the heuristic
277
+ mincontract=(float(symbol.get('mincontract', 0.0))
278
+ or default_mincontract(symbol['type'], symbol.get('basecurrency'))),
279
+ opening_hours=opening_hours,
280
+ session_starts=session_starts,
281
+ session_ends=session_ends,
282
+ session_schedules=session_schedules,
283
+ timezone=symbol.get('timezone', 'UTC'),
284
+ volumetype=symbol.get('volumetype', 'base'),
285
+ avg_spread=symbol.get('avg_spread'),
286
+ taker_fee=symbol.get('taker_fee'),
287
+ maker_fee=symbol.get('maker_fee'),
288
+ country=symbol.get('country'),
289
+ sector=symbol.get('sector'),
290
+ industry=symbol.get('industry'),
291
+ isin=symbol.get('isin'),
292
+ expiration_date=symbol.get('expiration_date'),
293
+ current_contract=symbol.get('current_contract'),
294
+ employees=symbol.get('employees'),
295
+ shareholders=symbol.get('shareholders'),
296
+ shares_outstanding_total=symbol.get('shares_outstanding_total'),
297
+ shares_outstanding_float=symbol.get('shares_outstanding_float'),
298
+ recommendations_buy=symbol.get('recommendations_buy'),
299
+ recommendations_buy_strong=symbol.get('recommendations_buy_strong'),
300
+ recommendations_date=symbol.get('recommendations_date'),
301
+ recommendations_hold=symbol.get('recommendations_hold'),
302
+ recommendations_sell=symbol.get('recommendations_sell'),
303
+ recommendations_sell_strong=symbol.get('recommendations_sell_strong'),
304
+ recommendations_total=symbol.get('recommendations_total'),
305
+ target_price_average=symbol.get('target_price_average'),
306
+ target_price_high=symbol.get('target_price_high'),
307
+ target_price_low=symbol.get('target_price_low'),
308
+ target_price_median=symbol.get('target_price_median'),
309
+ target_price_date=symbol.get('target_price_date'),
310
+ target_price_estimates=symbol.get('target_price_estimates')
311
+ )
312
+
313
+ def save_toml(self, path: Path):
314
+ """
315
+ Save SymInfo object to TOML-like fmt without dependencies.
316
+ Organizes data under [symbol] section.
317
+ None values are commented out with '#key ='
318
+
319
+ An existing [download] section (written by `pyne data download`, see
320
+ core.download_info) is preserved verbatim across the rewrite.
321
+
322
+ :param path: Path to save the file
323
+ """
324
+ from .download_info import extract_download_section
325
+
326
+ preserved_download = None
327
+ if path.exists():
328
+ try:
329
+ preserved_download = extract_download_section(path.read_text(encoding='utf-8'))
330
+ except OSError:
331
+ pass
332
+
333
+ def time_to_str(t):
334
+ """Convert time object to string"""
335
+ return t.strftime("%H:%M:%S")
336
+
337
+ # noinspection PyShadowingNames
338
+ def format_field(key, value):
339
+ """Format field to TOML string"""
340
+ if value is None:
341
+ return f"#{key} ="
342
+ if isinstance(value, str):
343
+ return f"{key} = \"{value}\""
344
+ if isinstance(value, bool):
345
+ return f"{key} = {str(value).lower()}"
346
+ if isinstance(value, float):
347
+ return f"{key} = {value:.8f}"
348
+ return f"{key} = {value}"
349
+
350
+ lines = ["[symbol]"] # Root table/section
351
+
352
+ # Basic fields
353
+ for key in ['prefix', 'description', 'ticker', 'currency', 'basecurrency',
354
+ 'period', 'type', 'mintick', 'pricescale', 'minmove', 'pointvalue',
355
+ 'mincontract', 'timezone', 'volumetype', 'avg_spread', 'taker_fee', 'maker_fee',
356
+ 'country', 'sector', 'industry', 'isin',
357
+ 'expiration_date', 'current_contract',
358
+ 'employees', 'shareholders',
359
+ 'shares_outstanding_total', 'shares_outstanding_float',
360
+ 'recommendations_buy', 'recommendations_buy_strong', 'recommendations_date',
361
+ 'recommendations_hold', 'recommendations_sell', 'recommendations_sell_strong',
362
+ 'recommendations_total',
363
+ 'target_price_average', 'target_price_high', 'target_price_low',
364
+ 'target_price_median', 'target_price_date', 'target_price_estimates']:
365
+ lines.append(format_field(key, getattr(self, key)))
366
+
367
+ # Arrays of tables. With an effective-dated history the flat blocks mirror
368
+ # the NEWEST variant (the load-time invariant); regenerate them from it on
369
+ # save so a programmatic edit that left ``self.*`` stale cannot leak the old
370
+ # schedule into the flat block -- this is what the comment below promises.
371
+ if self.session_schedules:
372
+ newest = self.session_schedules[-1]
373
+ flat_opening_hours = newest.opening_hours
374
+ flat_session_starts = newest.session_starts
375
+ flat_session_ends = newest.session_ends
376
+ else:
377
+ flat_opening_hours = self.opening_hours
378
+ flat_session_starts = self.session_starts
379
+ flat_session_ends = self.session_ends
380
+
381
+ lines.append("\n# Opening hours")
382
+ for oh in flat_opening_hours:
383
+ lines.append("[[opening_hours]]")
384
+ lines.append(f"day = {oh.day}")
385
+ lines.append(f'start = "{time_to_str(oh.start)}"')
386
+ lines.append(f'end = "{time_to_str(oh.end)}"')
387
+ lines.append("")
388
+
389
+ lines.append("# Session starts")
390
+ for s in flat_session_starts:
391
+ lines.append("[[session_starts]]")
392
+ lines.append(f"day = {s.day}")
393
+ lines.append(f'time = "{time_to_str(s.time)}"')
394
+ lines.append("")
395
+
396
+ lines.append("# Session ends")
397
+ for s in flat_session_ends:
398
+ lines.append("[[session_ends]]")
399
+ lines.append(f"day = {s.day}")
400
+ lines.append(f'time = "{time_to_str(s.time)}"')
401
+ lines.append("")
402
+
403
+ # Effective-dated session history, or a commented example when absent.
404
+ if self.session_schedules:
405
+ lines.append("# Effective-dated session history (oldest first). The flat")
406
+ lines.append("# opening_hours / session_starts / session_ends above are regenerated")
407
+ lines.append("# from the newest variant on save -- edit the variants below.")
408
+ for variant in self.session_schedules:
409
+ lines.append("[[session_schedules]]")
410
+ lines.append(f"effective_from = {variant.effective_from.isoformat()}")
411
+ for oh in variant.opening_hours:
412
+ lines.append("[[session_schedules.opening_hours]]")
413
+ lines.append(f"day = {oh.day}")
414
+ lines.append(f'start = "{time_to_str(oh.start)}"')
415
+ lines.append(f'end = "{time_to_str(oh.end)}"')
416
+ for s in variant.session_starts:
417
+ lines.append("[[session_schedules.session_starts]]")
418
+ lines.append(f"day = {s.day}")
419
+ lines.append(f'time = "{time_to_str(s.time)}"')
420
+ for s in variant.session_ends:
421
+ lines.append("[[session_schedules.session_ends]]")
422
+ lines.append(f"day = {s.day}")
423
+ lines.append(f'time = "{time_to_str(s.time)}"')
424
+ lines.append("")
425
+ else:
426
+ lines.append(_SESSION_SCHEDULE_EXAMPLE_COMMENT)
427
+ lines.append("")
428
+
429
+ if preserved_download:
430
+ lines.append(preserved_download)
431
+ lines.append("")
432
+
433
+ # Write to file
434
+ with open(path, 'w', encoding='utf-8') as f:
435
+ f.write('\n'.join(lines))
436
+
437
+ @property
438
+ def has_schedule_history(self) -> bool:
439
+ """``True`` when an effective-dated session history is present."""
440
+ return bool(self.session_schedules)
441
+
442
+ def schedule_index_for(self, d: date) -> int:
443
+ """
444
+ Index into :attr:`session_schedules` of the variant effective on ``d``.
445
+
446
+ The chain is sorted ascending by ``effective_from`` at load time, so this
447
+ is the last variant with ``effective_from <= d``. A date before the first
448
+ variant clamps to index ``0`` (the oldest variant), never to the flat
449
+ fields -- those mirror the NEWEST schedule and would reintroduce the stale
450
+ divergence. Requires a non-empty :attr:`session_schedules`.
451
+
452
+ :param d: Exchange-local trading-day date.
453
+ :return: Index into :attr:`session_schedules`.
454
+ """
455
+ idx = 0
456
+ for i, variant in enumerate(self.session_schedules):
457
+ if variant.effective_from <= d:
458
+ idx = i
459
+ else:
460
+ break
461
+ return idx
462
+
463
+ def schedule_for(self, d: date) -> tuple[
464
+ list[SymInfoInterval], list[SymInfoSession], list[SymInfoSession]]:
465
+ """
466
+ Resolve the session schedule effective on date ``d``.
467
+
468
+ Without history this returns the flat :attr:`opening_hours` /
469
+ :attr:`session_starts` / :attr:`session_ends`, so a migrated caller is
470
+ bit-identical to the pre-history behaviour. With history it returns the
471
+ variant selected by :meth:`schedule_index_for`.
472
+
473
+ :param d: Exchange-local trading-day date.
474
+ :return: ``(opening_hours, session_starts, session_ends)`` effective on ``d``.
475
+ """
476
+ if not self.session_schedules:
477
+ return self.opening_hours, self.session_starts, self.session_ends
478
+ v = self.session_schedules[self.schedule_index_for(d)]
479
+ return v.opening_hours, v.session_starts, v.session_ends
480
+
481
+
482
+ def mintick_decimals(mintick: float) -> int:
483
+ """
484
+ Number of decimal places implied by a symbol's ``mintick``.
485
+
486
+ Derived from ``str(mintick)`` (Python's shortest round-trip repr), so
487
+ ``0.05`` yields ``2`` and ``0.025`` yields ``3`` without exposing float
488
+ dust (``f"{0.05:.20f}"`` would be ``"0.05000000000000000278"``). This
489
+ mirrors the ``format.mintick`` logic in :mod:`pynecore.lib.string` and is
490
+ correct for fractional tick grids, where ``pricescale`` may be
491
+ ``round(1 / mintick)`` (e.g. ``20`` for ``0.05``) rather than a power of
492
+ ten.
493
+
494
+ :param mintick: The symbol's minimum tick size.
495
+ :return: Decimal place count, ``0`` for non-positive or integer ticks.
496
+ """
497
+ if not mintick or mintick <= 0:
498
+ return 0
499
+ tick_str = str(mintick)
500
+ if 'e' in tick_str or 'E' in tick_str:
501
+ # Scientific notation (very small ticks): expand without float dust.
502
+ tick_str = f"{mintick:.20f}".rstrip('0')
503
+ if '.' not in tick_str:
504
+ return 0
505
+ return len(tick_str.rstrip('0').split('.')[1])